beeperbox 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +49 -0
  2. package/package.json +41 -0
  3. package/server.js +1306 -0
package/server.js ADDED
@@ -0,0 +1,1306 @@
1
+ #!/usr/bin/env node
2
+ // beeperbox MCP server — POC phase 1
3
+ // Single-file, vanilla Node, zero deps. Requires Node 18+ for global fetch.
4
+ //
5
+ // Speaks Model Context Protocol over HTTP transport (POST JSON-RPC 2.0).
6
+ // Stdio transport will be added once HTTP is solid.
7
+
8
+ const http = require('http');
9
+ const crypto = require('crypto');
10
+ const path = require('path');
11
+ const os = require('os');
12
+
13
+ // Single source of truth for the version: the sibling package.json — so the
14
+ // lite-mode npm package, the container (which COPYs this dir to /opt/mcp), and
15
+ // serverInfo can never report different versions. Falls back only if the
16
+ // manifest is somehow absent (e.g. server.js copied alone).
17
+ const VERSION = (() => {
18
+ try { return require('./package.json').version; } catch { return '0.0.0-dev'; }
19
+ })();
20
+
21
+ const PORT = parseInt(process.env.MCP_PORT || '23375', 10);
22
+ const BEEPER_API = process.env.BEEPER_API || 'http://127.0.0.1:23373';
23
+ const BEEPER_TOKEN = process.env.BEEPER_TOKEN || '';
24
+
25
+ // ─── http transport hardening ─────────────────────────────────────
26
+ // The HTTP transport is the network-exposed surface (stdio is local-only).
27
+ // Three guards, all configurable so they don't break the documented
28
+ // loopback publish or a reverse-proxy deployment:
29
+ // MCP_AUTH_TOKEN — if set, every HTTP request must send
30
+ // `Authorization: Bearer <token>`; unset = open
31
+ // (back-compat; relies on the loopback publish).
32
+ // MCP_ALLOWED_HOSTS — Host/Origin allowlist (comma-separated). Blocks
33
+ // DNS-rebinding and cross-origin browser access.
34
+ // Defaults to loopback; set it for reverse proxies.
35
+ // MCP_MAX_BODY — max request body bytes (default 1 MiB) so a large
36
+ // POST can't grow the in-memory buffer unbounded.
37
+ // The listener stays bound to 0.0.0.0 ON PURPOSE: a Docker published port
38
+ // is unreachable if the in-container process binds 127.0.0.1, so loopback
39
+ // binding here would break `127.0.0.1:23375:23375`. Auth + Host/Origin
40
+ // checks are the defense, not the bind address.
41
+ const MCP_AUTH_TOKEN = process.env.MCP_AUTH_TOKEN || '';
42
+ const MCP_ALLOWED_HOSTS = new Set(
43
+ (process.env.MCP_ALLOWED_HOSTS || 'localhost,127.0.0.1,::1,[::1]')
44
+ .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
45
+ );
46
+ const MCP_MAX_BODY = parseInt(process.env.MCP_MAX_BODY || String(1024 * 1024), 10);
47
+
48
+ // Echo-guard id resolution. A send returns a `pendingMessageID`, but Beeper
49
+ // swaps it for the real bridge id once the message is acked — so the id we'd
50
+ // record at send time never matches the id the same message reappears under in
51
+ // poll_messages / read_chat. We resolve it: GET the message back until its id
52
+ // differs from the pending one, then store the FINAL id so the echo-guard
53
+ // matches on exact id instead of falling back to fragile text matching. The
54
+ // ack is asynchronous, so this is bounded + best-effort (retry a few times,
55
+ // then give up and keep the text fallback as the safety net). Tunable so a
56
+ // latency-sensitive deployment can shrink or disable it (RETRIES=0).
57
+ // Clamp to a non-negative integer, falling back to the default on a malformed
58
+ // value. Without this a typo (e.g. RETRIES="four" → NaN) would silently pass
59
+ // the `<= 0` / loop guards and DISABLE resolution — degrading the echo-guard to
60
+ // text-only with no error. `0` stays a valid, intentional disable.
61
+ function envIntNonNeg(name, def) {
62
+ const n = parseInt(process.env[name] || '', 10);
63
+ return Number.isFinite(n) && n >= 0 ? n : def;
64
+ }
65
+ const RESOLVE_RETRIES = envIntNonNeg('BEEPERBOX_RESOLVE_RETRIES', 4);
66
+ const RESOLVE_DELAY_MS = envIntNonNeg('BEEPERBOX_RESOLVE_DELAY_MS', 250);
67
+ // Per-attempt fetch timeout so a hung Beeper API can't stall a send unbounded:
68
+ // worst-case added send latency is RETRIES × (TIMEOUT + DELAY), not infinite.
69
+ const RESOLVE_TIMEOUT_MS = envIntNonNeg('BEEPERBOX_RESOLVE_TIMEOUT_MS', 3000);
70
+
71
+ // download_asset ships the bytes base64-encoded inside the JSON-RPC result, so
72
+ // a large attachment would bloat the response (base64 inflates ~33%) and the
73
+ // in-memory buffer. Cap it; tunable for a deployment that needs bigger files.
74
+ // FAQ/doc attachments — the driving use case — are well under this.
75
+ const MAX_ASSET_BYTES = envIntNonNeg('BEEPERBOX_MAX_ASSET_BYTES', 8 * 1024 * 1024);
76
+ // Per-call timeout on the asset-serve fetch so a hung or slow-drip source can't
77
+ // stall the request forever and the buffer can't grow unbounded behind it.
78
+ const ASSET_TIMEOUT_MS = envIntNonNeg('BEEPERBOX_ASSET_TIMEOUT_MS', 30000);
79
+ // Startup preflight: one bounded probe of the Beeper API on boot so a bad
80
+ // BEEPER_API / token is obvious in the logs immediately, not at first tool call.
81
+ // Container has a Docker HEALTHCHECK + supervises beepertexts; lite mode has
82
+ // neither, so this is its boot sanity check. Opt out with BEEPERBOX_PREFLIGHT=0.
83
+ const PREFLIGHT_TIMEOUT_MS = envIntNonNeg('BEEPERBOX_PREFLIGHT_TIMEOUT_MS', 5000);
84
+
85
+ // Real attachment src_urls come in two shapes: remote Matrix content
86
+ // (mxc:// / localmxc://) and, once Beeper caches the file locally,
87
+ // file:///root/.config/BeeperTexts/media/... — verified against a live account.
88
+ // Beeper's own /v1/assets/serve already guards this (it 403s a file:// outside
89
+ // its media dir and 400s a non-mxc/localmxc/file scheme — observed live). We do
90
+ // NOT lean on that undocumented upstream behavior: download_asset is the
91
+ // network-reachable MCP surface, so it independently allows mxc:// / localmxc://,
92
+ // allows file:// ONLY when it resolves inside the media cache, and refuses
93
+ // everything else BEFORE the fetch — defense in depth, plus a clear error
94
+ // instead of a raw Beeper 4xx. The file:// path is decoded + normalized and any
95
+ // authority/host or double-encoded dot/slash refused, so a caller can't smuggle
96
+ // ../ or a UNC host past the prefix check. Applied to BOTH the caller-supplied
97
+ // src_url and a src_url resolved off a message (a hostile sender could craft a
98
+ // file:// attachment pointing elsewhere).
99
+ const ASSET_FILE_ROOT = (() => {
100
+ const r = process.env.BEEPERBOX_ASSET_FILE_ROOT || '/root/.config/BeeperTexts/media/';
101
+ return r.endsWith('/') ? r : r + '/';
102
+ })();
103
+
104
+ function fileUrlInsideMediaCache(srcURL) {
105
+ let u;
106
+ try { u = new URL(srcURL); } catch { return false; }
107
+ // A file:// authority/host (file://host/path) is a network/UNC semantic the
108
+ // pathname check would miss — and we forward the ORIGINAL url, host and all.
109
+ // Legit local attachments have no host (file:///… ; even file://localhost
110
+ // normalizes the host away), so refuse any non-empty host.
111
+ if (u.host) return false;
112
+ let p;
113
+ try { p = decodeURIComponent(u.pathname); } catch { return false; }
114
+ // One decode matches Beeper serve's own single-decode (observed: it 404s a
115
+ // double-encoded path as a literal, never re-decodes). Any %2e/%2f still
116
+ // present after that decode is a DOUBLE-encoded dot/slash — an attempt to
117
+ // smuggle ../ past normalize() — so refuse rather than forward it. (A plain
118
+ // literal %25 in a real filename decodes to a bare % here, not %2e/%2f, so
119
+ // this doesn't over-reject normal names.)
120
+ if (/%2[ef]/i.test(p)) return false;
121
+ return path.posix.normalize(p).startsWith(ASSET_FILE_ROOT);
122
+ }
123
+
124
+ function assertServableSrcUrl(srcURL) {
125
+ const s = String(srcURL);
126
+ if (/^(?:mxc|localmxc):\/\//i.test(s)) return;
127
+ if (/^file:\/\//i.test(s)) {
128
+ if (fileUrlInsideMediaCache(s)) return;
129
+ throw rpcError(-32602, `file:// src_url must resolve inside the Beeper media cache (${ASSET_FILE_ROOT}); other paths are refused`);
130
+ }
131
+ throw rpcError(-32602, 'src_url must be an mxc://, localmxc://, or Beeper-cache file:// URL — other schemes are refused');
132
+ }
133
+
134
+ // Strip the port from a Host header value ("127.0.0.1:23375" -> "127.0.0.1",
135
+ // "[::1]:23375" -> "[::1]").
136
+ function hostFromHeader(value) {
137
+ if (!value) return '';
138
+ const h = value.trim().toLowerCase();
139
+ if (h.startsWith('[')) return h.slice(0, h.indexOf(']') + 1) || h;
140
+ const c = h.indexOf(':');
141
+ return c >= 0 ? h.slice(0, c) : h;
142
+ }
143
+
144
+ // Returns null when the request may proceed, else { status, message }.
145
+ function httpGuard(req) {
146
+ // DNS-rebinding guard: the browser sends the attacker's domain as Host.
147
+ const host = hostFromHeader(req.headers.host);
148
+ if (host && !MCP_ALLOWED_HOSTS.has(host)) {
149
+ return { status: 403, message: `host not allowed: ${host}` };
150
+ }
151
+ // Cross-origin browser guard: native clients (curl, MCP runtimes) send no
152
+ // Origin, so this only ever rejects browser-initiated cross-site requests.
153
+ const origin = req.headers.origin;
154
+ if (origin) {
155
+ let oh;
156
+ try { oh = new URL(origin).hostname.toLowerCase(); } catch { oh = null; }
157
+ if (!oh || !(MCP_ALLOWED_HOSTS.has(oh) || MCP_ALLOWED_HOSTS.has(`[${oh}]`))) {
158
+ return { status: 403, message: `origin not allowed: ${origin}` };
159
+ }
160
+ }
161
+ // Bearer-token guard: only enforced when a token is configured.
162
+ if (MCP_AUTH_TOKEN && req.headers.authorization !== `Bearer ${MCP_AUTH_TOKEN}`) {
163
+ return { status: 401, message: 'unauthorized' };
164
+ }
165
+ return null;
166
+ }
167
+
168
+ // ─── beeper api helper ────────────────────────────────────────────
169
+
170
+ async function beeperFetch(path, opts = {}) {
171
+ if (!BEEPER_TOKEN) throw rpcError(-32000, 'BEEPER_TOKEN env var not set — create a token in Beeper Settings > Developers and set the BEEPER_TOKEN environment variable');
172
+ const init = {
173
+ method: opts.method || 'GET',
174
+ headers: { Authorization: `Bearer ${BEEPER_TOKEN}` },
175
+ };
176
+ if (opts.body !== undefined) {
177
+ init.headers['Content-Type'] = 'application/json';
178
+ init.body = JSON.stringify(opts.body);
179
+ }
180
+ // Opt-in per-call timeout (opts.timeoutMs). Default callers are unbounded as
181
+ // before; the resolve path passes one so a hung API can't stall a send.
182
+ let timer = null;
183
+ if (opts.timeoutMs) {
184
+ const ac = new AbortController();
185
+ init.signal = ac.signal;
186
+ timer = setTimeout(() => ac.abort(), opts.timeoutMs);
187
+ }
188
+ try {
189
+ const r = await fetch(`${BEEPER_API}${path}`, init);
190
+ if (!r.ok) throw rpcError(-32001, `beeper api ${r.status}: ${(await r.text()).slice(0, 200)}`);
191
+ // Binary mode (opts.raw): return the bytes + content-type instead of
192
+ // parsing JSON — used by download_asset to serve attachment bytes. Enforce
193
+ // the byte cap twice: the Content-Length header (reject before buffering a
194
+ // well-behaved large response) and the actual buffered length (in case the
195
+ // header is absent or lies). Decoding these bytes as text would corrupt
196
+ // them, so this path never touches r.text()/JSON.parse.
197
+ if (opts.raw) {
198
+ const max = opts.maxBytes || 0;
199
+ const declared = parseInt(r.headers.get('content-length') || '', 10);
200
+ if (max && Number.isFinite(declared) && declared > max) {
201
+ throw rpcError(-32005, `asset is ${declared} bytes, over the ${max}-byte cap — raise BEEPERBOX_MAX_ASSET_BYTES or fetch /v1/assets/serve directly`);
202
+ }
203
+ const buf = Buffer.from(await r.arrayBuffer());
204
+ if (max && buf.length > max) {
205
+ throw rpcError(-32005, `asset is ${buf.length} bytes, over the ${max}-byte cap — raise BEEPERBOX_MAX_ASSET_BYTES or fetch /v1/assets/serve directly`);
206
+ }
207
+ return { bytes: buf, content_type: r.headers.get('content-type') || '' };
208
+ }
209
+ // Some POST/DELETE endpoints return empty body — return null instead of throwing on r.json()
210
+ const text = await r.text();
211
+ if (!text) return null;
212
+ try { return JSON.parse(text); } catch { return text; }
213
+ } finally {
214
+ if (timer) clearTimeout(timer);
215
+ }
216
+ }
217
+
218
+ // ─── network normalization ────────────────────────────────────────
219
+ // Beeper's /v1/accounts endpoint already returns a human-readable
220
+ // network name per account ("Discord", "WhatsApp", "Beeper (Matrix)").
221
+ // We cache the accountID -> {network, network_label} map at first use
222
+ // and look up each chat by its accountID. Chat objects do NOT encode
223
+ // the network in the room ID — that's an accountID lookup.
224
+
225
+ const NETWORK_SLUGS = {
226
+ 'WhatsApp': 'whatsapp',
227
+ 'iMessage': 'imessage',
228
+ 'Telegram': 'telegram',
229
+ 'Signal': 'signal',
230
+ 'Discord': 'discord',
231
+ 'Slack': 'slack',
232
+ 'Instagram': 'instagram',
233
+ 'Facebook Messenger': 'facebook',
234
+ 'LinkedIn': 'linkedin',
235
+ 'Google Messages': 'gmessages',
236
+ 'X (Twitter)': 'twitter',
237
+ 'Beeper (Matrix)': 'matrix',
238
+ 'Matrix': 'matrix',
239
+ };
240
+
241
+ function networkSlug(label) {
242
+ return NETWORK_SLUGS[label] || String(label || 'unknown').toLowerCase().replace(/[^a-z0-9]+/g, '');
243
+ }
244
+
245
+ let accountCache = null;
246
+ let noteToSelfChatID = null;
247
+
248
+ async function getNoteToSelfChatID() {
249
+ if (noteToSelfChatID) return noteToSelfChatID;
250
+ // The "single participant who is self" heuristic also matches each platform's
251
+ // saved-messages chat (Telegram "Saved Messages", WhatsApp "Send to yourself",
252
+ // etc.). To avoid leaking agent self-notes onto a third-party network, require
253
+ // the chat live on the Beeper-native matrix account. Fall back to any single-
254
+ // self chat only if no matrix one exists (preserves prior behavior for users
255
+ // without a Beeper-native account, with a clearer error if nothing matches).
256
+ const [accounts, raw] = await Promise.all([
257
+ getAccountMap(),
258
+ beeperFetch('/v1/chats?limit=100'),
259
+ ]);
260
+ const list = raw.items || raw.chats || (Array.isArray(raw) ? raw : []);
261
+ let fallback = null;
262
+ for (const c of list) {
263
+ const participants = c.participants?.items || [];
264
+ if (c.participants?.total !== 1 || participants[0]?.isSelf !== true) continue;
265
+ if (accounts[c.accountID]?.network === 'matrix') {
266
+ noteToSelfChatID = c.id;
267
+ return noteToSelfChatID;
268
+ }
269
+ if (!fallback) fallback = c.id;
270
+ }
271
+ if (fallback) {
272
+ // Defeats the matrix-preferred routing but is better than failing for users
273
+ // without a Beeper-native account. Log loudly so the behavior isn't silent
274
+ // — if a user expects matrix-native routing and sees this, something is
275
+ // misconfigured (matrix bridge offline, account just removed, etc.).
276
+ process.stderr.write(`[beeperbox-mcp] note_to_self: no Beeper-native matrix chat found; falling back to non-matrix single-self chat ${fallback} — agent self-notes will route to this platform's saved-messages chat\n`);
277
+ noteToSelfChatID = fallback;
278
+ return noteToSelfChatID;
279
+ }
280
+ throw rpcError(-32002, 'note-to-self chat not found in top 100 chats — open Beeper Desktop and verify a "Note to self" chat exists');
281
+ }
282
+
283
+ async function getAccountMap() {
284
+ if (accountCache) return accountCache;
285
+ const accounts = await beeperFetch('/v1/accounts');
286
+ accountCache = {};
287
+ for (const a of (Array.isArray(accounts) ? accounts : (accounts.items || []))) {
288
+ accountCache[a.accountID] = {
289
+ network: networkSlug(a.network),
290
+ network_label: a.network,
291
+ };
292
+ }
293
+ return accountCache;
294
+ }
295
+
296
+ // ─── chat normalizer ──────────────────────────────────────────────
297
+ // Map Beeper's raw chat object into the schema MCP clients consume.
298
+ // One shape, returned everywhere — `list_inbox`, `list_unread`, `get_chat`.
299
+ //
300
+ // Real Beeper fields (verified against /v1/chats response):
301
+ // id → room ID (matrix-style)
302
+ // accountID → maps to /v1/accounts[].network
303
+ // title → chat title
304
+ // type → "group" | "single"
305
+ // participants → { items: [{isSelf}], total: N }
306
+ // lastActivity → ISO timestamp
307
+ // unreadCount → integer
308
+
309
+ // ─── message normalizer ───────────────────────────────────────────
310
+ // Map Beeper's raw message object into the second canonical shape
311
+ // MCP clients consume. Carries chat_id and network on every message
312
+ // so agents never need a second lookup for grounding.
313
+ //
314
+ // Real Beeper message fields (from /v1/chats/<id>/messages):
315
+ // id → message ID
316
+ // chatID → parent chat id
317
+ // senderID → sender Matrix-style ID
318
+ // senderName → human name
319
+ // isSender → true iff this user sent it
320
+ // timestamp → ISO 8601
321
+ // text → message body (when type === 'TEXT')
322
+ // type → 'TEXT' | 'MEDIA' | etc.
323
+ // replyTo → optional, parent message id
324
+
325
+ // Map Beeper's raw message `attachments[]` into a normalized shape MCP clients
326
+ // can act on. Pure passthrough of the fields that already ride the raw message
327
+ // — no extra fetch. `src_url` is the download reference (an mxc:// / localmxc://
328
+ // Matrix content URL, or a file:// URL into Beeper's media cache once the file
329
+ // is downloaded locally) that download_asset takes. `size` carries the byte
330
+ // length (raw `fileSize`) when present. Returns [] when
331
+ // there are no attachments, so the field is always an array.
332
+ function normalizeAttachments(raw) {
333
+ const list = Array.isArray(raw?.attachments) ? raw.attachments : [];
334
+ return list.map((a) => ({
335
+ type: a.type || null,
336
+ file_name: a.fileName || null,
337
+ mime_type: a.mimeType || null,
338
+ src_url: a.srcURL || null,
339
+ size: typeof a.fileSize === 'number' ? a.fileSize : null,
340
+ is_voice_note: !!a.isVoiceNote,
341
+ }));
342
+ }
343
+
344
+ function normalizeMessage(raw, chat) {
345
+ return {
346
+ id: String(raw.id),
347
+ chat_id: raw.chatID || chat?.id || null,
348
+ network: chat?.network || 'unknown',
349
+ network_label: chat?.network_label || 'Unknown',
350
+ sender: {
351
+ id: raw.senderID || null,
352
+ name: raw.senderName || null,
353
+ is_self: !!raw.isSender,
354
+ },
355
+ text: raw.text || (raw.type === 'TEXT' ? '' : `[${raw.type || 'non-text'}]`),
356
+ type: raw.type || 'TEXT',
357
+ // Media reach: a MEDIA message carries no usable `text`, so without this an
358
+ // agent could see "[MEDIA]" but never reach the file. Always an array.
359
+ attachments: normalizeAttachments(raw),
360
+ timestamp: raw.timestamp || null,
361
+ reply_to: raw.replyTo || raw.reply_to || null,
362
+ // Echo-guard origin. Defaults assume "not sent through this beeperbox";
363
+ // applyEchoTags() upgrades to source:'api' (+ client_tag) for messages
364
+ // the send_message / note_to_self tools recorded. See the sent-ledger
365
+ // section below for why is_self alone can't tell these apart.
366
+ source: 'external',
367
+ client_tag: null,
368
+ };
369
+ }
370
+
371
+ function normalizeChat(raw, accounts) {
372
+ const acct = accounts[raw.accountID] || { network: 'unknown', network_label: 'Unknown' };
373
+ const participants = raw.participants?.items || [];
374
+ // Note-to-self = chat with exactly one participant who is yourself.
375
+ // Catches Beeper's native Note-to-self AND each platform's saved-messages
376
+ // chat (Telegram "Saved Messages", WhatsApp "Send to yourself", etc.).
377
+ const isNoteToSelf = raw.participants?.total === 1 && participants[0]?.isSelf === true;
378
+ return {
379
+ id: raw.id,
380
+ title: raw.title || '(untitled)',
381
+ network: acct.network,
382
+ network_label: acct.network_label,
383
+ is_group: raw.type === 'group' && !isNoteToSelf,
384
+ is_note_to_self: isNoteToSelf,
385
+ last_message_at: raw.lastActivity || null,
386
+ unread_count: raw.unreadCount || 0,
387
+ };
388
+ }
389
+
390
+ // ─── poll cursor (pure, stateless, restart-resumable) ─────────────
391
+ // The cursor is an opaque base64 of {ts, ids}: ts is the high-water
392
+ // message timestamp delivered so far, ids are the message ids seen at
393
+ // EXACTLY that ts. A message is "after" the cursor iff its ts is
394
+ // strictly greater, OR equal-ts but its id was not already in `ids`.
395
+ // The {ids} half is what makes same-millisecond messages dedup
396
+ // correctly instead of one silently swallowing the other — the exact
397
+ // seed/poll/dedup bug-class this primitive exists to retire. The caller
398
+ // persists the opaque string and passes it back; because no state lives
399
+ // server-side, resuming after a process/container restart is automatic.
400
+
401
+ function encodeCursor(state) {
402
+ return Buffer
403
+ .from(JSON.stringify({ ts: state.ts || '', ids: state.ids || [] }), 'utf8')
404
+ .toString('base64');
405
+ }
406
+
407
+ // A legitimate cursor only ever carries the ids sharing one high-water
408
+ // millisecond — at most a single poll page (≤100). Anything beyond this is a
409
+ // crafted/corrupt cursor; reject it rather than pay O(n) per message scanning
410
+ // `cur.ids.includes(...)` on attacker-chosen length.
411
+ const CURSOR_IDS_MAX = 4096;
412
+
413
+ function decodeCursor(cursor) {
414
+ if (!cursor) return null; // absent → seed mode (start from now)
415
+ let s;
416
+ try {
417
+ s = JSON.parse(Buffer.from(String(cursor), 'base64').toString('utf8'));
418
+ } catch {
419
+ s = undefined;
420
+ }
421
+ if (!s || typeof s.ts !== 'string' || !Array.isArray(s.ids)) {
422
+ throw rpcError(-32602, 'poll_messages: malformed cursor — pass back the exact cursor string from the previous response, or omit it to re-seed from now');
423
+ }
424
+ if (s.ids.length > CURSOR_IDS_MAX) {
425
+ throw rpcError(-32602, `poll_messages: cursor too large (${s.ids.length} ids) — it was not produced by this server; omit the cursor to re-seed`);
426
+ }
427
+ return { ts: s.ts, ids: s.ids.map(String) };
428
+ }
429
+
430
+ // ISO-8601 timestamps (Beeper emits `...Z`) sort lexicographically, so a
431
+ // plain string compare is also a chronological compare.
432
+ function isAfterCursor(msg, cur) {
433
+ if (!cur) return true; // defensive; seed path returns early elsewhere
434
+ if (!msg.timestamp) return false; // unorderable → never enters the feed (can't advance past it)
435
+ if (msg.timestamp > cur.ts) return true;
436
+ if (msg.timestamp === cur.ts) return !cur.ids.includes(String(msg.id));
437
+ return false;
438
+ }
439
+
440
+ // Advance the cursor over the set we actually delivered (already filtered
441
+ // as after `prev`). New high-water ts resets the id set to that ts; more
442
+ // messages at the existing high-water ts accumulate into it.
443
+ function advanceCursor(prev, delivered) {
444
+ let ts = prev ? prev.ts : '';
445
+ let ids = prev ? prev.ids.slice() : [];
446
+ for (const m of delivered) {
447
+ if (!m.timestamp) continue;
448
+ if (m.timestamp > ts) { ts = m.timestamp; ids = [String(m.id)]; }
449
+ else if (m.timestamp === ts) { ids.push(String(m.id)); }
450
+ }
451
+ return { ts, ids: [...new Set(ids)] };
452
+ }
453
+
454
+ // Pure: from the union of fresh (after-cursor) messages across all scanned
455
+ // chats, hand back the OLDEST `page` and advance the cursor over EXACTLY
456
+ // those. Delivering oldest-first (not newest) is what makes an over-`page`
457
+ // burst recoverable: the cursor moves forward only past what the caller
458
+ // actually received, so the newer remainder arrives on the next poll instead
459
+ // of being skipped. `hasMore` is true iff there is an immediately-fetchable
460
+ // remainder — i.e. re-polling now will return more.
461
+ function selectDelivery(fresh, cur, page) {
462
+ const sorted = fresh.slice().sort((a, b) =>
463
+ a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1
464
+ : a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
465
+ const hasMore = sorted.length > page;
466
+ const delivered = hasMore ? sorted.slice(0, page) : sorted;
467
+ return { delivered, next: advanceCursor(cur, delivered), hasMore };
468
+ }
469
+
470
+ // ─── sent-message ledger (echo-guard) ─────────────────────────────
471
+ // Distinguishes "this message was sent THROUGH beeperbox's own send API"
472
+ // from "this message is from my Beeper account" (sender.is_self). On ONE
473
+ // account both the human owner's own typed messages AND the agent's API
474
+ // replies are is_self === true, so is_self cannot guard against an agent
475
+ // re-processing its own sends. We record every message send_message /
476
+ // note_to_self emits and tag it source:'api' (with any caller client_tag)
477
+ // on read-back; everything else stays source:'external'. The ledger is
478
+ // persisted to the config volume so the guard survives a restart — the
479
+ // brittle text-prefix guard this replaces was restart-durable too.
480
+ //
481
+ // PRIMARY match is exact id. The send only knows the pendingMessageID, which
482
+ // Beeper swaps for a real bridge id on ack — so we resolve the final id right
483
+ // after sending (resolveSentId) and record BOTH against the entry. A read-back
484
+ // then matches by exact id whether or not the swap happened. The content
485
+ // fallback survives only as a last-ditch safety net, and ONLY for entries we
486
+ // could NOT resolve a final id for: it matches our own (is_self) messages, in
487
+ // the same chat, inside a short window. Once an entry IS resolved, exact id is
488
+ // authoritative for it and the text fallback is deliberately disabled — that is
489
+ // what stops a human re-typing identical text from being mis-tagged as ours.
490
+ //
491
+ // CAVEAT (unverifiable in CI — no live Beeper account; validate against a real
492
+ // account before relying on it): the resolution and content fallback both
493
+ // depend on Beeper's live id/ack behavior.
494
+
495
+ const LEDGER_MAX = 500;
496
+ const LEDGER_TEXT_WINDOW_MS = 15 * 60 * 1000;
497
+ // Bound a caller-supplied client_tag so it can't bloat the persisted ledger
498
+ // (the tag is an idempotency key — 256 chars is far more than any real one).
499
+ const CLIENT_TAG_MAX = 256;
500
+
501
+ // Where the echo-guard ledger persists. BEEPERBOX_SENT_LEDGER overrides
502
+ // explicitly; otherwise a per-user XDG path under the config home. This is one
503
+ // code path for BOTH deployments — no container-detection — because os.homedir()
504
+ // is /root in the container (so the file lands on the persisted /root/.config
505
+ // volume) and the real user's home in lite mode (so a non-root host process can
506
+ // actually write it). The old hardcoded /root/.config/... default silently
507
+ // failed to persist on any normal host, degrading the echo-guard across
508
+ // restarts; this is the fix.
509
+ function ledgerPath() {
510
+ if (process.env.BEEPERBOX_SENT_LEDGER) return process.env.BEEPERBOX_SENT_LEDGER;
511
+ const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
512
+ return path.join(configHome, 'beeperbox', 'sent-ledger.json');
513
+ }
514
+
515
+ let ledger = null; // lazily loaded array of { chat_id, sent_ids[], resolved, text_hash, client_tag, ts }
516
+
517
+ function textHash(text) {
518
+ return crypto.createHash('sha256')
519
+ .update(String(text == null ? '' : text).trim())
520
+ .digest('hex')
521
+ .slice(0, 16);
522
+ }
523
+
524
+ function loadLedger() {
525
+ if (ledger) return ledger;
526
+ ledger = [];
527
+ try {
528
+ const fs = require('fs');
529
+ const arr = JSON.parse(fs.readFileSync(ledgerPath(), 'utf8'));
530
+ if (Array.isArray(arr)) ledger = arr.slice(-LEDGER_MAX);
531
+ } catch { /* best-effort: no ledger yet, unreadable, or bad json → start empty */ }
532
+ return ledger;
533
+ }
534
+
535
+ let ledgerPersistWarned = false;
536
+ function persistLedger() {
537
+ try {
538
+ const fs = require('fs');
539
+ const p = ledgerPath();
540
+ // The per-user XDG dir (~/.config/beeperbox) won't exist on a fresh host —
541
+ // create it (recursive, ignores already-exists) so the very first send can
542
+ // persist instead of degrading the guard to in-memory.
543
+ fs.mkdirSync(path.dirname(p), { recursive: true });
544
+ fs.writeFileSync(p, JSON.stringify(ledger.slice(-LEDGER_MAX)), { mode: 0o600 });
545
+ } catch (e) {
546
+ // Swallow — a degraded echo-guard must never take down a send. Warn once.
547
+ if (!ledgerPersistWarned) {
548
+ ledgerPersistWarned = true;
549
+ process.stderr.write(`[beeperbox-mcp] sent-ledger persist failed (${e.code || e.message}) — echo-guard degrades to in-memory for this run\n`);
550
+ }
551
+ }
552
+ }
553
+
554
+ function recordSent({ chat_id, sent_id, text, client_tag }) {
555
+ loadLedger();
556
+ const entry = {
557
+ chat_id,
558
+ // Carries every id this send is known by — the pendingMessageID now, the
559
+ // resolved bridge id once addResolvedId() lands it. `resolved` flips true
560
+ // when we have the authoritative final id (retires the text fallback).
561
+ sent_ids: sent_id ? [String(sent_id)] : [],
562
+ resolved: false,
563
+ text_hash: textHash(text),
564
+ client_tag: client_tag ? String(client_tag).slice(0, CLIENT_TAG_MAX) : null,
565
+ ts: Date.now(),
566
+ };
567
+ ledger.push(entry);
568
+ if (ledger.length > LEDGER_MAX) ledger = ledger.slice(-LEDGER_MAX);
569
+ persistLedger();
570
+ return entry; // so the caller can attach the resolved id (addResolvedId)
571
+ }
572
+
573
+ // Attach the resolved final bridge id to a ledger entry and mark it exact-id
574
+ // authoritative, then persist so the resolution survives a restart. Idempotent.
575
+ function addResolvedId(entry, finalID) {
576
+ if (!entry || !finalID) return;
577
+ const s = String(finalID);
578
+ if (!entry.sent_ids) entry.sent_ids = [];
579
+ if (!entry.sent_ids.includes(s)) entry.sent_ids.push(s);
580
+ entry.resolved = true;
581
+ persistLedger();
582
+ }
583
+
584
+ // Best-effort resolve a pendingMessageID to the final bridge id Beeper assigns
585
+ // on ack. The ack is asynchronous, so GET the message back a few times until
586
+ // its id differs from the pending one. Returns the final id, or null if it
587
+ // never swapped within the retry budget (caller keeps the pending id + text
588
+ // fallback). Never throws — a degraded echo-guard must never fail a send.
589
+ async function resolveSentId(chatID, pendingMessageID) {
590
+ const pend = pendingMessageID ? String(pendingMessageID) : '';
591
+ if (!pend || !chatID || RESOLVE_RETRIES <= 0) return null;
592
+ for (let attempt = 0; attempt < RESOLVE_RETRIES; attempt++) {
593
+ try {
594
+ const m = await beeperFetch(
595
+ `/v1/chats/${encodeURIComponent(chatID)}/messages/${encodeURIComponent(pend)}`,
596
+ { timeoutMs: RESOLVE_TIMEOUT_MS },
597
+ );
598
+ // GET is by id → a single message object. Only trust that shape: a
599
+ // list/other response would let us read an arbitrary message's id and
600
+ // store the WRONG final id, which would mis-tag an unrelated read-back.
601
+ const id = m && m.id != null ? String(m.id) : null;
602
+ if (id && id !== pend) return id; // swapped to the real bridge id
603
+ } catch { /* not acked yet, timed out, or transient — retry */ }
604
+ if (attempt < RESOLVE_RETRIES - 1 && RESOLVE_DELAY_MS > 0) {
605
+ await new Promise((r) => setTimeout(r, RESOLVE_DELAY_MS));
606
+ }
607
+ }
608
+ return null;
609
+ }
610
+
611
+ // Pure: decide api-vs-external origin + echoed client_tag for one message
612
+ // given the ledger entries and a `now`. Exact id match first (high
613
+ // confidence); content fallback only for our own recent same-chat sends.
614
+ function matchSentMessage(msg, entries, now) {
615
+ const mid = String(msg.id);
616
+ // 1. Exact id match — high confidence. Checks every id the send is known by:
617
+ // the pendingMessageID and (once resolved) the final bridge id, so a
618
+ // read-back matches whether or not Beeper swapped the id on ack. Tolerates
619
+ // the legacy single-`sent_id` entry shape for ledgers written pre-upgrade.
620
+ for (const e of entries) {
621
+ if (e.chat_id !== msg.chat_id) continue;
622
+ const ids = e.sent_ids || (e.sent_id ? [e.sent_id] : []);
623
+ for (const id of ids) {
624
+ if (id && String(id) === mid) return { source: 'api', client_tag: e.client_tag || null };
625
+ }
626
+ }
627
+ // 2. Last-ditch text fallback — ONLY for our own (is_self) messages, same
628
+ // chat, recent window, AND only against entries whose final id we could
629
+ // NOT resolve (resolved !== true). Once an entry is resolved, step 1 is
630
+ // authoritative for it; skipping its text match is what keeps a human
631
+ // re-typing identical text from being mis-tagged as ours and dropped.
632
+ if (msg.is_self) {
633
+ const h = textHash(msg.text);
634
+ for (const e of entries) {
635
+ if (e.chat_id !== msg.chat_id) continue;
636
+ if (e.resolved === true) continue;
637
+ if (e.text_hash === h && (now - e.ts) <= LEDGER_TEXT_WINDOW_MS) {
638
+ return { source: 'api', client_tag: e.client_tag || null };
639
+ }
640
+ }
641
+ }
642
+ return { source: 'external', client_tag: null };
643
+ }
644
+
645
+ // Stamp source/client_tag onto already-normalized messages.
646
+ function applyEchoTags(messages, now) {
647
+ const entries = loadLedger();
648
+ return messages.map((m) => {
649
+ const meta = matchSentMessage(
650
+ { id: m.id, chat_id: m.chat_id, text: m.text, is_self: m.sender.is_self },
651
+ entries,
652
+ now,
653
+ );
654
+ return { ...m, source: meta.source, client_tag: meta.client_tag };
655
+ });
656
+ }
657
+
658
+ // ─── tool registry ────────────────────────────────────────────────
659
+
660
+ const TOOLS = [
661
+ {
662
+ name: 'list_accounts',
663
+ description: 'List all messaging accounts (networks) connected to this Beeper account. Each account corresponds to one platform — WhatsApp, Telegram, Discord, etc. Use this to see which platforms are reachable before calling other tools, or to discover what kinds of chats exist. Returns network slug (machine-readable, e.g. "whatsapp"), network label (human, e.g. "WhatsApp"), the underlying account ID, and the user\'s display name on that platform.',
664
+ inputSchema: {
665
+ type: 'object',
666
+ properties: {},
667
+ additionalProperties: false,
668
+ },
669
+ },
670
+ {
671
+ name: 'get_chat',
672
+ description: 'Fetch metadata for one specific chat by ID. Returns the same Chat schema as list_inbox so the caller does not need to learn a second shape. Use this when you have a chat ID from a previous call (e.g. from list_inbox or search_messages) and need its current state — most often to check unread_count, last_message_at, or title before replying.',
673
+ inputSchema: {
674
+ type: 'object',
675
+ properties: {
676
+ chat_id: { type: 'string', description: 'The chat ID to fetch (the `id` field from any Chat object returned by list_inbox or get_chat).' },
677
+ },
678
+ required: ['chat_id'],
679
+ additionalProperties: false,
680
+ },
681
+ },
682
+ {
683
+ name: 'read_chat',
684
+ description: 'Fetch the most recent messages from one chat. Returns messages in chronological order (oldest to newest within the page) with normalized sender info, network, and chat_id propagated to every message. Use this to read context before replying, or to pull the last few messages of a conversation for the LLM to reason about.',
685
+ inputSchema: {
686
+ type: 'object',
687
+ properties: {
688
+ chat_id: { type: 'string', description: 'The chat ID to read from (the `id` field from any Chat object).' },
689
+ limit: { type: 'integer', description: 'Max messages to return (default 20)', default: 20, minimum: 1, maximum: 100 },
690
+ },
691
+ required: ['chat_id'],
692
+ additionalProperties: false,
693
+ },
694
+ },
695
+ {
696
+ name: 'archive_chat',
697
+ description: 'Archive or unarchive a chat. Archived chats are moved out of the active inbox (list_inbox no longer returns them) but messages and history are preserved. Use this to clean up handled chats after replying or processing them. Beeper does not expose a mark-as-read endpoint, so archiving is the closest primitive for the "I am done with this conversation" pattern. Pass archived=false to unarchive.',
698
+ inputSchema: {
699
+ type: 'object',
700
+ properties: {
701
+ chat_id: { type: 'string', description: 'The chat ID to archive (the `id` field from any Chat object).' },
702
+ archived: { type: 'boolean', description: 'true to archive (default), false to unarchive', default: true },
703
+ },
704
+ required: ['chat_id'],
705
+ additionalProperties: false,
706
+ },
707
+ },
708
+ {
709
+ name: 'list_inbox',
710
+ description: 'List the most recently active chats from the user\'s connected messaging accounts. Excludes the bot\'s own note-to-self chat (use note_to_self for that). Returns chat metadata including network (whatsapp/telegram/imessage/etc.), title, unread count, and last activity timestamp.',
711
+ inputSchema: {
712
+ type: 'object',
713
+ properties: {
714
+ limit: { type: 'integer', description: 'Max chats to return (default 20)', default: 20, minimum: 1, maximum: 100 },
715
+ },
716
+ additionalProperties: false,
717
+ },
718
+ },
719
+ {
720
+ name: 'search_messages',
721
+ description: 'Full-text search across all messages in all chats. Returns matching messages with chat_id and network propagated so the LLM can immediately tell which conversation each hit belongs to without a second lookup. Use this for follow-up questions ("what did Sara say about the contract?"), historical lookups, or finding old context the agent does not have in its current window. Older history may not be indexed if Beeper has not synced it — see the "top 20 active chats" caveat in the GUIDE.',
722
+ inputSchema: {
723
+ type: 'object',
724
+ properties: {
725
+ query: { type: 'string', description: 'The text to search for. Plain text — no special operators.', minLength: 1 },
726
+ limit: { type: 'integer', description: 'Max messages to return (default 20)', default: 20, minimum: 1, maximum: 100 },
727
+ },
728
+ required: ['query'],
729
+ additionalProperties: false,
730
+ },
731
+ },
732
+ {
733
+ name: 'note_to_self',
734
+ description: 'Send a message to the bot\'s own Note to self chat — the dedicated command/control channel for the agent itself. Use this for agent self-notes ("processed 5 customer messages"), debugging output, scheduled reminders to self, or anything you want recorded but NOT seen by anyone else. Auto-resolves the correct chat ID, so no chat_id parameter needed. The note-to-self chat is excluded from list_inbox / list_unread / search_messages, so messages here will not pollute customer inbox views.',
735
+ inputSchema: {
736
+ type: 'object',
737
+ properties: {
738
+ text: { type: 'string', description: 'The note text. Markdown supported.', minLength: 1 },
739
+ client_tag: { type: 'string', description: 'Optional idempotency/echo tag. Recorded against this send and echoed back on the message as `client_tag` when it reappears in poll_messages / read_chat (where it is also marked source:"api"), so an agent can recognize and skip its own programmatic sends.' },
740
+ },
741
+ required: ['text'],
742
+ additionalProperties: false,
743
+ },
744
+ },
745
+ {
746
+ name: 'send_message',
747
+ description: 'Send a text message to a chat. The headline write operation. Use this to reply to a customer, send a notification, or initiate a conversation. The chat must already exist (use a chat_id from list_inbox / list_unread / search_messages / get_chat). Markdown is supported in the text. To reply specifically to one message rather than just adding to the conversation, pass reply_to_message_id. Returns the new message ID for downstream operations like react_to_message.',
748
+ inputSchema: {
749
+ type: 'object',
750
+ properties: {
751
+ chat_id: { type: 'string', description: 'The chat ID to send to (the `id` field from any Chat object).' },
752
+ text: { type: 'string', description: 'The message body. Markdown supported.', minLength: 1 },
753
+ reply_to_message_id: { type: 'string', description: 'Optional. Pass a message_id to send this as a reply to that specific message instead of as a new conversation entry.' },
754
+ client_tag: { type: 'string', description: 'Optional idempotency/echo tag. Recorded against this send and echoed back on the message as `client_tag` when it reappears in poll_messages / read_chat (where it is also marked source:"api"), so an agent can recognize and skip its own programmatic sends.' },
755
+ },
756
+ required: ['chat_id', 'text'],
757
+ additionalProperties: false,
758
+ },
759
+ },
760
+ {
761
+ name: 'react_to_message',
762
+ description: 'Add an emoji reaction to a specific message. The lightest possible "I saw it" or "ack" signal — use this when you want to acknowledge a message without sending a full reply. Pass the unicode emoji directly (e.g. "👍", "❤️", "✅"). Reactions are visible to the message sender on every supported network (WhatsApp, iMessage, Telegram, Discord, Slack, Signal, etc.).',
763
+ inputSchema: {
764
+ type: 'object',
765
+ properties: {
766
+ chat_id: { type: 'string', description: 'The chat ID containing the message (the `chat_id` field from any Message object).' },
767
+ message_id: { type: 'string', description: 'The message ID to react to (the `id` field from any Message object).' },
768
+ emoji: { type: 'string', description: 'The unicode emoji to react with (e.g. "👍", "❤️", "✅").' },
769
+ },
770
+ required: ['chat_id', 'message_id', 'emoji'],
771
+ additionalProperties: false,
772
+ },
773
+ },
774
+ {
775
+ name: 'list_unread',
776
+ description: 'List chats that have one or more unread messages. Same Chat schema as list_inbox, filtered to only chats where unread_count > 0. Use this as the primary "what needs my attention right now?" tool — agents typically call this first to triage, then read_chat on each result to fetch the actual unread messages.',
777
+ inputSchema: {
778
+ type: 'object',
779
+ properties: {
780
+ limit: { type: 'integer', description: 'Max chats to return (default 20)', default: 20, minimum: 1, maximum: 100 },
781
+ },
782
+ additionalProperties: false,
783
+ },
784
+ },
785
+ {
786
+ name: 'poll_messages',
787
+ description: 'Passive "what is new since I last looked?" feed — the watch primitive for a poll loop. Returns every message that arrived after an opaque cursor, across all recent chats (or one chat if chat_id is given), each as the normalized Message schema. It is READ-ONLY: it never marks anything read, never archives, never mutates — call it as often as you like with zero side effects. First call (omit cursor) SEEDS: it returns {cursor, messages: [], seeded: true} — an empty backlog and a starting cursor meaning "from now". Persist that cursor; pass it back next call to receive only messages newer than it, plus a fresh cursor. The cursor is restart-safe: save it to disk and resume across process/container restarts with no missed or duplicated messages. Includes the account\'s own messages (sender.is_self may be true) on purpose — the owner messaging themselves in Note to self is a real inbound signal. To avoid an agent answering its own sends, branch on the `source` field, NOT is_self: source is "api" for messages this beeperbox sent via send_message / note_to_self (skip these), "external" for everything else (the human owner\'s own messages included). If has_more is true, more messages are waiting beyond this page — poll again immediately with the returned cursor before sleeping.',
788
+ inputSchema: {
789
+ type: 'object',
790
+ properties: {
791
+ cursor: { type: 'string', description: 'Opaque cursor from a previous poll_messages response. Omit on the first call to seed from now. Pass back verbatim — never construct or parse it.' },
792
+ chat_id: { type: 'string', description: 'Optional. Restrict the feed to one chat (the `id` from any Chat object). Omit to watch all recent chats.' },
793
+ limit: { type: 'integer', description: 'Max messages to return this call (default 50). If more are pending, has_more is true and they come on the next poll.', default: 50, minimum: 1, maximum: 100 },
794
+ },
795
+ additionalProperties: false,
796
+ },
797
+ },
798
+ {
799
+ name: 'download_asset',
800
+ description: 'Download the bytes of a message attachment (image, PDF, document, voice note, etc.) and return them base64-encoded. This is the MCP-only way to reach an attachment\'s actual content — use it after read_chat / poll_messages surfaces a message whose `attachments[]` carries a `src_url`. Reference the attachment EITHER by passing its `src_url` directly (the value from a normalized attachment — an mxc:// / localmxc:// URL, or a file:// URL inside Beeper\'s local media cache; arbitrary file:// paths and other schemes are refused), OR by `chat_id` + `message_id` (+ optional `index` to pick one of several attachments), in which case beeperbox resolves the src_url for you and also returns the attachment\'s file_name / mime_type / size. Returns { content_type, bytes, encoding: "base64", data_base64, ... }. Large files are capped (default 8 MiB) to keep the JSON-RPC result bounded — raise BEEPERBOX_MAX_ASSET_BYTES if you need bigger.',
801
+ inputSchema: {
802
+ type: 'object',
803
+ properties: {
804
+ src_url: { type: 'string', description: 'The attachment\'s `src_url` from a normalized Message attachment — an mxc:// / localmxc:// URL or a file:// URL inside Beeper\'s media cache. Arbitrary file:// paths and other schemes are refused. Provide this, OR chat_id + message_id.' },
805
+ chat_id: { type: 'string', description: 'The chat containing the message. Required if src_url is omitted (used with message_id to resolve the attachment).' },
806
+ message_id: { type: 'string', description: 'The message whose attachment to download. Required if src_url is omitted.' },
807
+ index: { type: 'integer', description: 'Which attachment to download when resolving by message_id and the message has more than one (default 0).', default: 0, minimum: 0 },
808
+ },
809
+ additionalProperties: false,
810
+ },
811
+ },
812
+ ];
813
+
814
+ async function callTool(name, args) {
815
+ switch (name) {
816
+ case 'list_accounts': {
817
+ const accounts = await beeperFetch('/v1/accounts');
818
+ const list = Array.isArray(accounts) ? accounts : (accounts.items || []);
819
+ return list.map((a) => ({
820
+ account_id: a.accountID,
821
+ network: networkSlug(a.network),
822
+ network_label: a.network,
823
+ user: {
824
+ id: a.user?.id || null,
825
+ display_name: a.user?.fullName || a.user?.displayText || a.user?.username || null,
826
+ },
827
+ }));
828
+ }
829
+
830
+ case 'get_chat': {
831
+ if (!args.chat_id) throw rpcError(-32602, 'get_chat requires chat_id');
832
+ const [accounts, raw] = await Promise.all([
833
+ getAccountMap(),
834
+ beeperFetch(`/v1/chats/${encodeURIComponent(args.chat_id)}`),
835
+ ]);
836
+ return normalizeChat(raw, accounts);
837
+ }
838
+
839
+ case 'read_chat': {
840
+ if (!args.chat_id) throw rpcError(-32602, 'read_chat requires chat_id');
841
+ const limit = Math.min(Math.max(args.limit || 20, 1), 100);
842
+ // Same Beeper-side minimum-page-size workaround as list_inbox: the API
843
+ // returns ~25 items regardless of ?limit=, so over-fetch then slice.
844
+ const [accounts, chatRaw, msgRaw] = await Promise.all([
845
+ getAccountMap(),
846
+ beeperFetch(`/v1/chats/${encodeURIComponent(args.chat_id)}`),
847
+ beeperFetch(`/v1/chats/${encodeURIComponent(args.chat_id)}/messages?limit=${Math.max(limit, 25)}`),
848
+ ]);
849
+ const chat = normalizeChat(chatRaw, accounts);
850
+ const list = msgRaw.items || msgRaw.messages || (Array.isArray(msgRaw) ? msgRaw : []);
851
+ // Beeper returns newest first; reverse so oldest comes first within the page
852
+ // (more natural for an LLM building a conversation thread).
853
+ const msgs = list.slice(0, limit).map((m) => normalizeMessage(m, chat)).reverse();
854
+ return applyEchoTags(msgs, Date.now());
855
+ }
856
+
857
+ case 'archive_chat': {
858
+ if (!args.chat_id) throw rpcError(-32602, 'archive_chat requires chat_id');
859
+ const archived = args.archived !== false; // default true
860
+ await beeperFetch(`/v1/chats/${encodeURIComponent(args.chat_id)}/archive`, {
861
+ method: 'POST',
862
+ body: { archived },
863
+ });
864
+ return { chat_id: args.chat_id, archived };
865
+ }
866
+
867
+ case 'list_inbox': {
868
+ const limit = Math.min(Math.max(args.limit || 20, 1), 100);
869
+ // Beeper returns ~25 items minimum regardless of ?limit=, so we fetch
870
+ // at least that many, then slice client-side after note-to-self filter.
871
+ const [accounts, raw] = await Promise.all([
872
+ getAccountMap(),
873
+ beeperFetch(`/v1/chats?limit=${Math.max(limit, 25)}`),
874
+ ]);
875
+ const list = raw.items || raw.chats || (Array.isArray(raw) ? raw : []);
876
+ return list
877
+ .map((c) => normalizeChat(c, accounts))
878
+ .filter((c) => !c.is_note_to_self)
879
+ .slice(0, limit);
880
+ }
881
+
882
+ case 'search_messages': {
883
+ if (!args.query) throw rpcError(-32602, 'search_messages requires query');
884
+ const limit = Math.min(Math.max(args.limit || 20, 1), 100);
885
+ const [accounts, raw] = await Promise.all([
886
+ getAccountMap(),
887
+ beeperFetch(`/v1/messages/search?query=${encodeURIComponent(args.query)}&limit=${limit}`),
888
+ ]);
889
+ const items = raw?.items || [];
890
+ const chatsMap = raw?.chats || {};
891
+ // Pre-normalize the chat map so each hit can carry network info cheaply.
892
+ const normalizedChats = {};
893
+ for (const [id, c] of Object.entries(chatsMap)) {
894
+ normalizedChats[id] = normalizeChat(c, accounts);
895
+ }
896
+ const msgs = items.slice(0, limit).map((m) => {
897
+ const chat = normalizedChats[m.chatID] || { network: 'unknown', network_label: 'Unknown' };
898
+ return normalizeMessage(m, chat);
899
+ });
900
+ return applyEchoTags(msgs, Date.now());
901
+ }
902
+
903
+ case 'note_to_self': {
904
+ if (!args.text) throw rpcError(-32602, 'note_to_self requires text');
905
+ const chatID = await getNoteToSelfChatID();
906
+ const sent = await beeperFetch(
907
+ `/v1/chats/${encodeURIComponent(chatID)}/messages`,
908
+ { method: 'POST', body: { text: args.text } },
909
+ );
910
+ const pendingID = String(sent?.pendingMessageID || '');
911
+ // Echo-guard: record so poll_messages / read_chat can tag this
912
+ // self-note source:'api' and the agent won't re-process its own note.
913
+ const entry = recordSent({ chat_id: chatID, sent_id: pendingID, text: args.text, client_tag: args.client_tag });
914
+ // Resolve the pending id to the final bridge id so the guard matches the
915
+ // read-back by exact id, not fragile text. Best-effort — null on timeout.
916
+ const finalID = await resolveSentId(chatID, pendingID);
917
+ if (finalID) addResolvedId(entry, finalID);
918
+ return {
919
+ chat_id: chatID,
920
+ message_id: finalID || pendingID,
921
+ pending_message_id: pendingID,
922
+ resolved: !!finalID,
923
+ client_tag: args.client_tag || null,
924
+ status: 'sent',
925
+ };
926
+ }
927
+
928
+ case 'send_message': {
929
+ if (!args.chat_id) throw rpcError(-32602, 'send_message requires chat_id');
930
+ if (!args.text) throw rpcError(-32602, 'send_message requires text');
931
+ const body = { text: args.text };
932
+ if (args.reply_to_message_id) body.replyToMessageID = args.reply_to_message_id;
933
+ const sent = await beeperFetch(
934
+ `/v1/chats/${encodeURIComponent(args.chat_id)}/messages`,
935
+ { method: 'POST', body },
936
+ );
937
+ const chatID = sent?.chatID || args.chat_id;
938
+ const pendingID = String(sent?.pendingMessageID || '');
939
+ // Echo-guard: record so a poll_messages loop can skip the bot's own
940
+ // reply (source:'api') instead of treating it as a new inbound message.
941
+ const entry = recordSent({ chat_id: chatID, sent_id: pendingID, text: args.text, client_tag: args.client_tag });
942
+ // Resolve the pending id to the final bridge id so the guard matches the
943
+ // read-back by exact id, not fragile text. Best-effort — null on timeout.
944
+ const finalID = await resolveSentId(chatID, pendingID);
945
+ if (finalID) addResolvedId(entry, finalID);
946
+ return {
947
+ chat_id: chatID,
948
+ message_id: finalID || pendingID,
949
+ pending_message_id: pendingID,
950
+ resolved: !!finalID,
951
+ client_tag: args.client_tag || null,
952
+ status: 'sent',
953
+ };
954
+ }
955
+
956
+ case 'react_to_message': {
957
+ if (!args.chat_id) throw rpcError(-32602, 'react_to_message requires chat_id');
958
+ if (!args.message_id) throw rpcError(-32602, 'react_to_message requires message_id');
959
+ if (!args.emoji) throw rpcError(-32602, 'react_to_message requires emoji');
960
+ await beeperFetch(
961
+ `/v1/chats/${encodeURIComponent(args.chat_id)}/messages/${encodeURIComponent(args.message_id)}/reactions`,
962
+ { method: 'POST', body: { reactionKey: args.emoji } },
963
+ );
964
+ return { chat_id: args.chat_id, message_id: args.message_id, emoji: args.emoji, status: 'reacted' };
965
+ }
966
+
967
+ case 'list_unread': {
968
+ const limit = Math.min(Math.max(args.limit || 20, 1), 100);
969
+ // Pull a wider page than the user's limit so the unread filter has
970
+ // headroom — most chats will be already-read so we need to over-fetch.
971
+ const [accounts, raw] = await Promise.all([
972
+ getAccountMap(),
973
+ beeperFetch(`/v1/chats?limit=100`),
974
+ ]);
975
+ const list = raw.items || raw.chats || (Array.isArray(raw) ? raw : []);
976
+ return list
977
+ .map((c) => normalizeChat(c, accounts))
978
+ .filter((c) => !c.is_note_to_self && c.unread_count > 0)
979
+ .slice(0, limit);
980
+ }
981
+
982
+ case 'poll_messages': {
983
+ const cur = decodeCursor(args.cursor); // null ⇒ seed mode
984
+ const page = Math.min(Math.max(args.limit || 50, 1), 100);
985
+ const accounts = await getAccountMap();
986
+
987
+ // Resolve which chats to scan: one if chat_id given, else the inbox.
988
+ let chats;
989
+ if (args.chat_id) {
990
+ const raw = await beeperFetch(`/v1/chats/${encodeURIComponent(args.chat_id)}`);
991
+ chats = [normalizeChat(raw, accounts)];
992
+ } else {
993
+ const raw = await beeperFetch('/v1/chats?limit=100');
994
+ const list = raw.items || raw.chats || (Array.isArray(raw) ? raw : []);
995
+ chats = list.map((c) => normalizeChat(c, accounts));
996
+ }
997
+
998
+ // Seed (no cursor): return the current high-water mark and NO backlog,
999
+ // so the caller "starts watching from now". This is the half of the
1000
+ // bug-class that integrators get wrong — there's now one right answer.
1001
+ if (!cur) {
1002
+ let maxTs = '';
1003
+ for (const c of chats) {
1004
+ if (c.last_message_at && c.last_message_at > maxTs) maxTs = c.last_message_at;
1005
+ }
1006
+ return { cursor: encodeCursor({ ts: maxTs, ids: [] }), messages: [], has_more: false, seeded: true };
1007
+ }
1008
+
1009
+ // Only chats whose last activity is at/after the cursor can hold new
1010
+ // messages — skip the rest so the per-chat fan-out stays bounded.
1011
+ const candidates = args.chat_id
1012
+ ? chats
1013
+ : chats.filter((c) => !c.last_message_at || c.last_message_at >= cur.ts);
1014
+
1015
+ // Fetch the Beeper max (100) per chat, NOT `page`: the fetch window must
1016
+ // exceed the delivery page so that a chat with more than `page` new
1017
+ // messages is delivered across successive polls via the cursor, rather
1018
+ // than the cursor jumping to the newest and stranding the older ones.
1019
+ // (Residual: a single chat receiving >100 messages between two polls can
1020
+ // still lose the oldest of that burst — Beeper's messages endpoint only
1021
+ // returns the newest N with no backward paging. Documented in the GUIDE.)
1022
+ const fetchLimit = 100;
1023
+ const fresh = [];
1024
+ for (const c of candidates) {
1025
+ const msgRaw = await beeperFetch(`/v1/chats/${encodeURIComponent(c.id)}/messages?limit=${fetchLimit}`);
1026
+ const mlist = msgRaw.items || msgRaw.messages || (Array.isArray(msgRaw) ? msgRaw : []);
1027
+ for (const m of mlist.map((x) => normalizeMessage(x, c))) {
1028
+ if (isAfterCursor(m, cur)) fresh.push(m);
1029
+ }
1030
+ }
1031
+
1032
+ const { delivered, next, hasMore } = selectDelivery(fresh, cur, page);
1033
+ const tagged = applyEchoTags(delivered, Date.now());
1034
+ return { cursor: encodeCursor(next), messages: tagged, has_more: hasMore };
1035
+ }
1036
+
1037
+ case 'download_asset': {
1038
+ // Two ways to reference the attachment: a src_url directly (cheapest —
1039
+ // multis already has it from the normalized message), or chat_id +
1040
+ // message_id (+ index), where we fetch the message and read the src_url
1041
+ // off attachments[index] — which also lets us return its file metadata.
1042
+ let srcURL = args.src_url ? String(args.src_url) : '';
1043
+ let meta = {};
1044
+ if (!srcURL) {
1045
+ if (!args.chat_id || !args.message_id) {
1046
+ throw rpcError(-32602, 'download_asset requires src_url, or both chat_id and message_id');
1047
+ }
1048
+ const raw = await beeperFetch(
1049
+ `/v1/chats/${encodeURIComponent(args.chat_id)}/messages/${encodeURIComponent(args.message_id)}`,
1050
+ );
1051
+ const atts = normalizeAttachments(raw);
1052
+ const idx = Math.max(parseInt(args.index, 10) || 0, 0);
1053
+ const att = atts[idx];
1054
+ if (!att) throw rpcError(-32602, `message has no attachment at index ${idx} (found ${atts.length})`);
1055
+ if (!att.src_url) throw rpcError(-32004, `attachment ${idx} has no src_url to download`);
1056
+ srcURL = att.src_url;
1057
+ meta = { file_name: att.file_name, mime_type: att.mime_type, size: att.size };
1058
+ }
1059
+ // Confine the src_url to a real attachment (mxc/localmxc, or file:// inside
1060
+ // the media cache) BEFORE the fetch — defense-in-depth over Beeper serve's
1061
+ // own path guard, covering both ref paths. See assertServableSrcUrl.
1062
+ assertServableSrcUrl(srcURL);
1063
+ // /v1/assets/serve streams the bytes for an mxc:// / localmxc:// URL.
1064
+ // beeperFetch raw-mode returns the buffer + content-type, enforces the
1065
+ // byte cap (header pre-check + buffered post-check), and the timeout
1066
+ // bounds a hung/slow-drip source.
1067
+ const { bytes, content_type } = await beeperFetch(
1068
+ `/v1/assets/serve?url=${encodeURIComponent(srcURL)}`,
1069
+ { raw: true, maxBytes: MAX_ASSET_BYTES, timeoutMs: ASSET_TIMEOUT_MS },
1070
+ );
1071
+ return {
1072
+ src_url: srcURL,
1073
+ ...meta,
1074
+ content_type: content_type || meta.mime_type || null,
1075
+ bytes: bytes.length,
1076
+ encoding: 'base64',
1077
+ data_base64: bytes.toString('base64'),
1078
+ };
1079
+ }
1080
+ default:
1081
+ throw rpcError(-32601, `unknown tool: ${name}`);
1082
+ }
1083
+ }
1084
+
1085
+ // ─── jsonrpc dispatch ─────────────────────────────────────────────
1086
+
1087
+ function rpcError(code, message) {
1088
+ const e = new Error(message);
1089
+ e.rpcCode = code;
1090
+ return e;
1091
+ }
1092
+
1093
+ async function handleRequest(req) {
1094
+ // Notifications carry no id; the spec says no response is sent.
1095
+ const isNotification = req.id === undefined;
1096
+
1097
+ if (req.jsonrpc !== '2.0') {
1098
+ if (isNotification) return null;
1099
+ return { jsonrpc: '2.0', id: req.id ?? null, error: { code: -32600, message: 'jsonrpc must be "2.0"' } };
1100
+ }
1101
+
1102
+ try {
1103
+ let result;
1104
+ switch (req.method) {
1105
+ case 'initialize':
1106
+ result = {
1107
+ protocolVersion: '2025-03-26',
1108
+ serverInfo: { name: 'beeperbox', version: VERSION },
1109
+ capabilities: { tools: {} },
1110
+ };
1111
+ break;
1112
+
1113
+ case 'notifications/initialized':
1114
+ // Client tells us it finished initializing. No response.
1115
+ return null;
1116
+
1117
+ case 'tools/list':
1118
+ result = { tools: TOOLS };
1119
+ break;
1120
+
1121
+ case 'tools/call': {
1122
+ const params = req.params || {};
1123
+ const name = params.name;
1124
+ const args = params.arguments || {};
1125
+ if (!name) throw rpcError(-32602, 'tools/call requires params.name');
1126
+ const data = await callTool(name, args);
1127
+ // MCP wraps tool results in a content array of typed parts.
1128
+ result = { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
1129
+ break;
1130
+ }
1131
+
1132
+ default:
1133
+ throw rpcError(-32601, `unknown method: ${req.method}`);
1134
+ }
1135
+
1136
+ if (isNotification) return null;
1137
+ return { jsonrpc: '2.0', id: req.id, result };
1138
+ } catch (err) {
1139
+ if (isNotification) return null;
1140
+ return {
1141
+ jsonrpc: '2.0',
1142
+ id: req.id ?? null,
1143
+ error: { code: err.rpcCode || -32603, message: err.message },
1144
+ };
1145
+ }
1146
+ }
1147
+
1148
+ // ─── startup preflight ────────────────────────────────────────────
1149
+ // Fire-and-forget boot probe. Calls /v1/accounts once (token-gated, so it
1150
+ // proves reachability AND that the token is accepted in a single hit) and logs
1151
+ // one clear verdict. Best-effort and non-fatal: a down API or unset token must
1152
+ // NOT stop the server from booting (the container's first run has no token /
1153
+ // no login yet, by design). Always logs to stderr — never the protocol channel
1154
+ // — so it's safe under both HTTP and stdio transports.
1155
+ async function preflight() {
1156
+ if (process.env.BEEPERBOX_PREFLIGHT === '0') return;
1157
+ const say = (m) => process.stderr.write(`[beeperbox-mcp] ${m}\n`);
1158
+ if (!BEEPER_TOKEN) {
1159
+ say('preflight: BEEPER_TOKEN not set — skipping reachability check (tool calls will fail until it is set)');
1160
+ return;
1161
+ }
1162
+ try {
1163
+ const accounts = await beeperFetch('/v1/accounts', { timeoutMs: PREFLIGHT_TIMEOUT_MS });
1164
+ const list = Array.isArray(accounts) ? accounts : (accounts?.items || []);
1165
+ say(`preflight OK: ${BEEPER_API} reachable, token accepted, ${list.length} account(s)`);
1166
+ } catch (e) {
1167
+ say(`preflight FAIL: ${BEEPER_API} unreachable or token rejected — ${e.message}`);
1168
+ }
1169
+ }
1170
+
1171
+ // ─── http transport ───────────────────────────────────────────────
1172
+
1173
+ function startHttpTransport() {
1174
+ const server = http.createServer((req, res) => {
1175
+ if (req.method !== 'POST') {
1176
+ res.writeHead(405, { Allow: 'POST' }).end();
1177
+ return;
1178
+ }
1179
+
1180
+ const denied = httpGuard(req);
1181
+ if (denied) {
1182
+ res.writeHead(denied.status, { 'Content-Type': 'application/json' });
1183
+ res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32600, message: denied.message } }));
1184
+ return;
1185
+ }
1186
+
1187
+ let body = '';
1188
+ let aborted = false;
1189
+ req.on('data', (chunk) => {
1190
+ if (aborted) return;
1191
+ body += chunk;
1192
+ if (body.length > MCP_MAX_BODY) {
1193
+ aborted = true;
1194
+ res.writeHead(413, { 'Content-Type': 'application/json' });
1195
+ res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32600, message: `request body exceeds ${MCP_MAX_BODY} bytes` } }));
1196
+ req.destroy();
1197
+ }
1198
+ });
1199
+ req.on('end', async () => {
1200
+ if (aborted) return;
1201
+ let parsed;
1202
+ try {
1203
+ parsed = JSON.parse(body);
1204
+ } catch {
1205
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1206
+ res.end(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } }));
1207
+ return;
1208
+ }
1209
+
1210
+ const response = await handleRequest(parsed);
1211
+ if (response === null) {
1212
+ res.writeHead(204).end();
1213
+ } else {
1214
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1215
+ res.end(JSON.stringify(response));
1216
+ }
1217
+ });
1218
+ });
1219
+
1220
+ server.listen(PORT, '0.0.0.0', () => {
1221
+ console.log(`[beeperbox-mcp] listening on http://0.0.0.0:${PORT}`);
1222
+ console.log(`[beeperbox-mcp] beeper api: ${BEEPER_API}`);
1223
+ console.log(`[beeperbox-mcp] beeper token: ${BEEPER_TOKEN ? 'set' : 'NOT SET (set BEEPER_TOKEN env var)'}`);
1224
+ console.log(`[beeperbox-mcp] http auth: ${MCP_AUTH_TOKEN ? 'required (MCP_AUTH_TOKEN set)' : 'OPEN — set MCP_AUTH_TOKEN to require a bearer token'}`);
1225
+ console.log(`[beeperbox-mcp] allowed hosts: ${[...MCP_ALLOWED_HOSTS].join(', ')}`);
1226
+ preflight();
1227
+ });
1228
+ }
1229
+
1230
+ // ─── stdio transport ──────────────────────────────────────────────
1231
+ // Newline-delimited JSON-RPC over stdin/stdout. Used when the MCP
1232
+ // client (Claude Code, Cursor, Cline, bareagent) spawns the server
1233
+ // as a subprocess — typically via `docker exec -i beeperbox node
1234
+ // /opt/mcp/server.js --stdio`. Stdout is reserved for the protocol;
1235
+ // all logging goes to stderr.
1236
+
1237
+ function startStdioTransport() {
1238
+ let buf = '';
1239
+ process.stdin.setEncoding('utf8');
1240
+ process.stdin.on('data', async (chunk) => {
1241
+ buf += chunk;
1242
+ let nl;
1243
+ while ((nl = buf.indexOf('\n')) >= 0) {
1244
+ const line = buf.slice(0, nl).trim();
1245
+ buf = buf.slice(nl + 1);
1246
+ if (!line) continue;
1247
+ let req;
1248
+ try {
1249
+ req = JSON.parse(line);
1250
+ } catch {
1251
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } }) + '\n');
1252
+ continue;
1253
+ }
1254
+ const response = await handleRequest(req);
1255
+ if (response !== null) {
1256
+ process.stdout.write(JSON.stringify(response) + '\n');
1257
+ }
1258
+ }
1259
+ });
1260
+ // Do NOT process.exit() on stdin end — Node's event loop will exit
1261
+ // naturally once all pending async handlers settle. Exiting eagerly
1262
+ // drops in-flight tool responses (async fetch() to the Beeper API).
1263
+ // Stderr — stdout is the protocol channel and must not carry chatter.
1264
+ process.stderr.write('[beeperbox-mcp] stdio transport ready\n');
1265
+ process.stderr.write(`[beeperbox-mcp] beeper api: ${BEEPER_API}\n`);
1266
+ process.stderr.write(`[beeperbox-mcp] beeper token: ${BEEPER_TOKEN ? 'set' : 'NOT SET'}\n`);
1267
+ preflight();
1268
+ }
1269
+
1270
+ // ─── pick transport ───────────────────────────────────────────────
1271
+
1272
+ // Only boot a transport when run as the entrypoint (`node server.js` /
1273
+ // `--stdio`). When require()'d — by the unit tests — skip the listeners and
1274
+ // just expose the pure helpers below.
1275
+ if (require.main === module) {
1276
+ if (process.argv.includes('--stdio')) {
1277
+ startStdioTransport();
1278
+ } else {
1279
+ startHttpTransport();
1280
+ }
1281
+ }
1282
+
1283
+ // Test surface — only the pure logic the unit suite exercises (the
1284
+ // seed/poll/dedup bug-class core + the echo-guard matcher). The HTTP/stdio
1285
+ // handlers and normalizers are covered black-box by the guard/smoke scripts.
1286
+ module.exports = {
1287
+ // Parity surface — lite mode and the container run THIS file, so asserting the
1288
+ // version + tool names here is what guarantees the two builds can't drift.
1289
+ VERSION,
1290
+ TOOL_NAMES: TOOLS.map((t) => t.name),
1291
+ ledgerPath,
1292
+ encodeCursor,
1293
+ decodeCursor,
1294
+ isAfterCursor,
1295
+ advanceCursor,
1296
+ selectDelivery,
1297
+ textHash,
1298
+ normalizeAttachments,
1299
+ assertServableSrcUrl,
1300
+ matchSentMessage,
1301
+ recordSent,
1302
+ addResolvedId,
1303
+ loadLedger,
1304
+ // test hook: drop the in-memory ledger so a test can re-load from a fresh path
1305
+ _resetLedger: () => { ledger = null; ledgerPersistWarned = false; },
1306
+ };