Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/mcp-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ on:
- 'entrypoint.sh'
- 'docker-compose.yml'
- 'scripts/mcp-guard-check.sh'
- 'scripts/asset-serve-check.js'
- '.github/workflows/mcp-test.yml'

permissions:
Expand All @@ -36,6 +37,10 @@ jobs:
node-version: '20'
- name: Unit tests
run: node --test mcp/server.test.js
- name: download_asset integration check (stub Beeper API, no account)
# Spins the real MCP server against a stub /v1/assets/serve and exercises
# the tough turns: binary integrity, both byte-cap paths, error branches.
run: node scripts/asset-serve-check.js

mcp-guard-matrix:
runs-on: ubuntu-latest
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@ beeperbox follows [Semantic Versioning 2.0.0](https://semver.org/) with one conc

Published tags on GHCR: `:X.Y.Z` (exact, immutable), `:X.Y` (rolling within a minor), `:X` (rolling within a major — always `:0` today), `:latest` (newest release tag, rebuilt weekly to pick up upstream Beeper AppImage drift), `:edge` (every push to `master`, may break).

## [Unreleased] `[MINOR]`

### Added

- **Attachment reach for media messages.** Every normalized `Message` now carries an `attachments[]` array (`[]` when none); each entry is `{type, file_name, mime_type, src_url, size, is_voice_note}`, a pure passthrough of the raw Beeper message's `attachments[]`. Previously a media message arrived as `type: "MEDIA"`, `text: "[MEDIA]"` with no way to reach the file — an agent could see that a file existed but never read it. `src_url` (an `mxc://` / `localmxc://` / `file://` URL) is the download reference.
- **New MCP tool `download_asset`** (12 tools now). Fetches an attachment's actual bytes and returns them base64-encoded — the MCP-only path to a file's content, so a remote deployment publishing only `:23375` (not the raw Beeper API on `:23373`) can still read attachments. Reference the attachment by `src_url`, or by `chat_id` + `message_id` (+ optional `index`) in which case beeperbox resolves the `src_url` and also returns the file's `file_name` / `mime_type` / `size`. Internally proxies `GET /v1/assets/serve?url=…`. The payload rides base64 inside the JSON-RPC result, so it is capped at `BEEPERBOX_MAX_ASSET_BYTES` (default 8 MiB, tunable) — an oversized asset returns a clear error (Content-Length pre-check + buffered post-check) rather than a truncated body; raise the cap or hit `serve` directly for larger files.

### Security

- **`download_asset` confines `src_url` to attachments, not the filesystem (defense-in-depth).** Real attachment `src_url`s (verified against a live account) are `mxc://` / `localmxc://` for remote media and `file:///root/.config/BeeperTexts/media/…` once Beeper caches the file locally. Beeper's own `serve` endpoint already restricts this — it returns `403` for a `file://` outside its media dir and `400` for a non-`mxc`/`localmxc`/`file` scheme (confirmed live) — but `download_asset` is the network-reachable MCP surface and does not lean on that undocumented upstream behavior. It independently allows `mxc://` / `localmxc://`, allows `file://` **only when it resolves inside the media cache** (`BEEPERBOX_ASSET_FILE_ROOT`, default `/root/.config/BeeperTexts/media/`; the path is percent-decoded + normalized, and any URL authority/host or residual double-encoded `%2e`/`%2f` is refused, so `../`/UNC tricks can't escape the prefix check), and refuses everything else **before** the fetch — covering both the caller-supplied `src_url` and a `src_url` resolved off a (potentially hostile-sender-crafted) message attachment. The payoff is not "this was a confirmed exploit" — Beeper's guard blocks the raw case — but a second, in-repo, clear-error boundary that survives an upstream guard regressing. A per-call timeout (`BEEPERBOX_ASSET_TIMEOUT_MS`, default 30 s) bounds a hung or slow-drip source.

### Notes

- **Validated against a live Beeper account**, not just the stub: a real cached PDF downloads via both reference paths with exact byte length, the raw attachment shape matches the `normalizeAttachments` mapping, and the `file://` cache confinement blocks `/etc/passwd`, the sent-ledger, and `../` traversal while still serving real cached attachments. CI itself has no account, so `scripts/asset-serve-check.js` reproduces these against a stub `serve`; the pure `normalizeAttachments` / `assertServableSrcUrl` logic is unit-tested.

## [0.6.0] — 2026-06-15 `[MINOR]`

First release driven end-to-end by a real consumer ([multis](https://github.com/hamr0/multis), beeperbox's first customer): a watch primitive and a reliable echo-guard so an agent can react to incoming messages without reinventing the seed/poll/dedup loop or echo-looping on its own sends, plus two container-lifecycle robustness fixes. MINOR per the versioning policy — new MCP tool + additive schema fields + new runtime behavior, all backward compatible. **Live-validation note:** the `source` echo-guard and the `pendingMessageID` → final-bridge-id resolution were validated against a real Beeper account by multis (CI has none); the standard release gate (guard + VNC probes) still runs the server standalone.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ Done.
|---|---|---|
| `6080` | noVNC web UI — first-run login only | `127.0.0.1` |
| `23373` | Raw Beeper Desktop HTTP API | `127.0.0.1` |
| `23375` | Opinionated 11-tool MCP server | `127.0.0.1` |
| `23375` | Opinionated 12-tool MCP server | `127.0.0.1` |

All three are env-overridable (`BEEPERBOX_NOVNC_PORT`, `BEEPERBOX_HOST_PORT`, `BEEPERBOX_MCP_PORT`) so you can run multiple instances on one VPS. For remote access use SSH tunnel, Tailscale, or a TLS reverse proxy — never drop the `127.0.0.1` prefix.

Expand Down
13 changes: 12 additions & 1 deletion beeperbox.context.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
beeperbox is a headless [Beeper Desktop](https://www.beeper.com/) in a Docker container that exposes **two things to agents**:

1. **Raw Beeper Desktop HTTP API** on `127.0.0.1:23373` — the unmodified `/v1/*` endpoints for callers who want full control.
2. **Opinionated Model Context Protocol server** on `127.0.0.1:23375` (HTTP) or stdio — 11 semantic tools, normalized `Chat` / `Message` schemas, note-to-self isolation, clean network slugs. Consume from Claude Code, Cursor, Cline, Continue, bareagent, or any other MCP-speaking runtime.
2. **Opinionated Model Context Protocol server** on `127.0.0.1:23375` (HTTP) or stdio — 12 semantic tools, normalized `Chat` / `Message` schemas, note-to-self isolation, clean network slugs. Consume from Claude Code, Cursor, Cline, Continue, bareagent, or any other MCP-speaking runtime.

Agents that consume beeperbox get read/write access to **every bridge the user's Beeper account has connected**: WhatsApp, iMessage, Signal, Discord, Slack, Telegram, Facebook Messenger, Instagram, LinkedIn, Google Messages, Matrix, and any future Beeper bridge. One config, every messenger.

Expand Down Expand Up @@ -230,6 +230,15 @@ Archive or unarchive a chat. Archived chats are removed from `list_inbox` but hi
**Returns:** `{chat_id, archived}`.
**Note:** Beeper does not expose a `mark_as_read` endpoint. `archive_chat` is the closest primitive for the "I am done with this conversation" pattern. Pass `archived: false` to unarchive.

### `download_asset`

Download a message attachment's bytes and return them base64-encoded — the MCP-only way to reach a media/PDF/document file, without touching the raw Beeper API on `:23373`. Reference the attachment by its `src_url` (off a `Message.attachments[]` entry), or by `chat_id` + `message_id` (+ `index`) and beeperbox resolves the `src_url` and returns the file's `file_name` / `mime_type` / `size`.

**Arguments:** `{src_url?: string}` **or** `{chat_id: string, message_id: string, index?: number = 0}`.
**Returns:** `{src_url, content_type, bytes, encoding: "base64", data_base64, file_name?, mime_type?, size?}`.
**Note:** The bytes ride base64 inside the JSON-RPC result, so the asset is capped at `BEEPERBOX_MAX_ASSET_BYTES` (default 8 MiB) — an oversized file returns a clear error, not a truncated body. Internally proxies `GET /v1/assets/serve?url=…`, so a remote deployment publishing only `:23375` can still read attachments. Raise the cap or hit `serve` directly for larger files.
**Security:** `src_url` is confined to real attachments — `mxc://` / `localmxc://`, or a `file://` URL inside Beeper's media cache (`BEEPERBOX_ASSET_FILE_ROOT`, default `/root/.config/BeeperTexts/media/`); any other path, scheme, URL host, or encoded-`../` traversal is refused before the fetch. This is defense-in-depth — Beeper's own `serve` independently `403`s/`400`s those — so a caller can't turn `download_asset` into an arbitrary-file reader even if the upstream guard regresses.

## Schemas

Two normalized shapes. Learn them once and every tool returns the same thing.
Expand Down Expand Up @@ -275,6 +284,7 @@ Two normalized shapes. Learn them once and every tool returns the same thing.
},
"text": "are we still meeting tomorrow?",
"type": "TEXT",
"attachments": [],
"timestamp": "2026-04-13T09:30:00Z",
"reply_to": null,
"source": "external",
Expand All @@ -290,6 +300,7 @@ Two normalized shapes. Learn them once and every tool returns the same thing.
| `sender.is_self` | True if the **Beeper account** sent this message — **from any client**, including the human owner typing on their phone. Distinguishes "from my account" from "from someone else". **Not an echo-guard** (see `source`). |
| `text` | Message body. For non-text messages (media, voice, etc.) this is `"[MEDIA]"` or `"[<type>]"`. |
| `type` | `"TEXT"`, `"MEDIA"`, etc. |
| `attachments` | Array of the message's files, `[]` when none. Each: `{type, file_name, mime_type, src_url, size, is_voice_note}`. `src_url` (an `mxc://` / `localmxc://` URL, or a `file://` URL inside Beeper's media cache once the file is downloaded locally) is the download reference — pass it to `download_asset` to pull the bytes. For a `MEDIA` message this is the only way to reach the file (`text` is just `"[MEDIA]"`). |
| `reply_to` | Parent message ID if this is a reply, else `null`. |
| `source` | **`"api"`** if *this* beeperbox sent the message via `send_message` / `note_to_self`; **`"external"`** otherwise. This — not `is_self` — is the echo-guard: on one account both the owner's own typed messages and the agent's API replies are `is_self: true`, so only `source` can tell "I sent this programmatically" from "the human owner sent this". Skip `source === "api"` in a poll loop; process `"external"` (the owner's own Note-to-self commands included). |
| `client_tag` | The `client_tag` passed to `send_message` / `note_to_self` for this message, echoed back, else `null`. An idempotency key the agent can correlate to a specific send. |
Expand Down
10 changes: 8 additions & 2 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ If you see an error, jump to [Troubleshooting](#troubleshooting).

### What you can do next

- **Point an AI agent runtime at it**: Claude Code, Cursor, Cline, bareagent — any MCP client that supports stdio or HTTP transport can consume beeperbox as a tool source. Configure it once and the LLM sees all 11 tools (`list_inbox`, `list_unread`, `poll_messages`, `read_chat`, `get_chat`, `search_messages`, `list_accounts`, `send_message`, `note_to_self`, `react_to_message`, `archive_chat`). See the [MCP tools reference](#mcp-tools-reference) section below.
- **Point an AI agent runtime at it**: Claude Code, Cursor, Cline, bareagent — any MCP client that supports stdio or HTTP transport can consume beeperbox as a tool source. Configure it once and the LLM sees all 12 tools (`list_inbox`, `list_unread`, `poll_messages`, `read_chat`, `get_chat`, `search_messages`, `list_accounts`, `send_message`, `note_to_self`, `react_to_message`, `archive_chat`, `download_asset`). See the [MCP tools reference](#mcp-tools-reference) section below.
- **Build something custom**: hit the raw Beeper API on `http://localhost:23373/v1/*` from any language with an HTTP client and your `BEEPER_TOKEN`. See [Use it — real examples](#use-it--real-examples) for curl / Node / Python snippets.
- **Deploy to a VPS**: same steps work on any Linux box with Docker. SSH-tunnel noVNC for the one-time login. See [Deploy to a VPS](#deploy-to-a-vps).

Expand Down Expand Up @@ -496,7 +496,7 @@ If you call `/v1/*` directly instead of going through the MCP tools, you inherit

beeperbox exposes 11 semantic tools over Model Context Protocol on two interchangeable transports. Any AI agent runtime that speaks MCP (Claude Code, Cursor, Cline, Continue, bareagent, etc.) can consume them.

### The 11 tools
### The 12 tools

| Tool | Required | Returns | Use case |
|---|---|---|---|
Expand All @@ -511,11 +511,16 @@ beeperbox exposes 11 semantic tools over Model Context Protocol on two interchan
| `note_to_self` | `text` | same | Agent self-notes, debug output, scheduled reminders — auto-resolves to the Beeper-native Note to self chat (won't leak into per-platform saved-messages chats) |
| `react_to_message` | `chat_id`, `message_id`, `emoji` | `{...status: reacted}` | Lightweight ack, no full reply needed |
| `archive_chat` | `chat_id` | `{chat_id, archived}` | Clean handled chats out of inbox (closest primitive to mark-as-read that Beeper exposes) |
| `download_asset` | `src_url` *or* `chat_id`+`message_id` | `{src_url, content_type, bytes, encoding, data_base64, ...}` | Fetch an attachment's actual bytes (base64) — reach a media/PDF/doc file an LLM can index. Capped at `BEEPERBOX_MAX_ASSET_BYTES` (default 8 MiB) |

#### `poll_messages` — the watch loop

A passive new-messages-since-cursor feed; the right tool for an agent that wants to *react* to incoming messages instead of repeatedly diffing `list_inbox` by hand. **Read-only** — it never marks read, archives, or mutates. First call (omit `cursor`) **seeds** from now and returns `{cursor, messages: [], seeded: true}`; pass the cursor back each subsequent call to get only newer messages plus a fresh cursor. The cursor is opaque and **restart-resumable** — persist it and resume across restarts with no missed or duplicated messages (same-millisecond messages are deduplicated by id). `has_more: true` means more is immediately fetchable — keep polling until it's `false`, then sleep. (Residual: beeperbox reads up to the newest 100 messages per chat per poll, so a chat receiving more than 100 messages between two polls can lose the oldest of that burst — Beeper's API has no backward paging; poll frequently enough, or backfill with `read_chat`.) Includes the account's own messages on purpose; branch on each message's **`source`** field (`"api"` = sent by this beeperbox, skip it; `"external"` = everything else including the owner's own Note-to-self commands) rather than `sender.is_self`, which can't tell the agent's API sends from the human's own typing. The poll interval is yours — beeperbox provides the *ability* to watch, not a *policy* about cadence.

#### `download_asset` — reach an attachment's bytes (MCP-only)

A media message (`type: "MEDIA"`) carries no usable `text` — its file lives behind the `attachments[]` array each normalized `Message` now exposes (`{type, file_name, mime_type, src_url, size, is_voice_note}`). `download_asset` is how an MCP-only agent pulls that file's actual bytes without touching the raw Beeper Desktop API on `:23373`: reference the attachment **either** by its `src_url` (the value straight off the attachment — an `mxc://` / `localmxc://` URL, or a `file://` URL inside Beeper's media cache once the file is downloaded locally; arbitrary `file://` paths and other schemes are refused, so the tool can only reach real attachments, never the rest of the container's filesystem) **or** by `chat_id` + `message_id` (+ optional `index` to pick one of several), in which case beeperbox resolves the `src_url` and also returns the file's `file_name` / `mime_type` / `size`. The bytes come back **base64** in `data_base64` (with `content_type`, `bytes`, `encoding`). Because the payload is base64 inside the JSON-RPC result, it's capped at `BEEPERBOX_MAX_ASSET_BYTES` (default 8 MiB) — an oversized asset returns a clear error rather than a truncated file; raise the cap, or fetch `GET /v1/assets/serve?url=…` directly, for larger files. Internally `download_asset` proxies that same `serve` endpoint, so a remote/MCP-only deployment that only publishes `:23375` can still read attachments.

### Schemas the LLM learns once and reuses everywhere

```
Expand All @@ -537,6 +542,7 @@ Message:
sender { id, name, is_self } # is_self = sent by my account from ANY client
text message body (or "[MEDIA]" / "[non-text]" for non-text types)
type "TEXT" | "MEDIA" | ...
attachments array of { type, file_name, mime_type, src_url, size, is_voice_note }; [] when none
timestamp ISO 8601
reply_to parent message id if this is a reply, else null
source "api" if THIS beeperbox sent it (send_message/note_to_self), else "external"
Expand Down
Loading
Loading