Skip to content

feat(mcp): poll_messages watch primitive + source echo-guard#11

Merged
hamr0 merged 2 commits into
masterfrom
feat/poll-messages-watch-primitive
Jun 15, 2026
Merged

feat(mcp): poll_messages watch primitive + source echo-guard#11
hamr0 merged 2 commits into
masterfrom
feat/poll-messages-watch-primitive

Conversation

@hamr0

@hamr0 hamr0 commented Jun 15, 2026

Copy link
Copy Markdown
Owner

First consumer-driven feature — multis, beeperbox's first customer, was hand-rolling a seed→poll→dedup loop against the raw /v1/ API and hitting the classic off-by-ones. This exposes the missing capability natively. Ability, not policy: beeperbox provides the watch mechanism; poll cadence and "handled" state stay with the integrator. It does not make beeperbox a streaming system (PRD §3) — it's a better-shaped poll.

What's in it

poll_messages (the watch primitive) — 11 tools now. Passive, cursor-based "what's new since I last looked?" feed across all recent chats (or one via chat_id). Read-only: never marks read, archives, or mutates.

  • First call (no cursor) seeds "from now" and returns an empty backlog + a starting cursor.
  • The cursor is opaque and fully stateless server-side → persist it and resume across process/container restarts with no missed or duplicated messages. Same-millisecond messages dedup by id (the {ts, ids@ts} encoding) — the specific bug hand-rolled pollers get wrong.
  • Fetches the newest 100 per chat (headroom over the delivery page) and advances the cursor over only what was delivered, so an over-limit burst pages out across polls instead of being skipped.

source echo-guard — new source ("api"|"external") + echoed client_tag on every Message. On one account both the owner's own typed messages and the agent's API replies are is_self: true, so is_self can't guard echo. send_message/note_to_self record sends to a ledger persisted in the config volume (BEEPERBOX_SENT_LEDGER); read-back tags the match. Branch on source, not is_self. The content fallback is is_self-gated, so an external sender can never be mis-tagged and dropped.

Raw /v1/ contract made explicit (context guide + GUIDE) so direct-API consumers match the MCP layer: the ?limit=N ~25-item floor, the client-side cursor recipe, and the canonical heuristics (note-to-self total===1 && isSelf, network via accountID, group type==='group', pendingMessageID not stable).

Versioning

New tool + additive schema fields (source/client_tag on Message; client_tag on the two send tools) = MINOR. No field renamed/removed; existing clients need no edits.

Tests

node --test mcp/server.test.js (21 tests) over the pure cursor/dedup/echo-guard/delivery logic — same-ms dedup, a seed→poll→restart-resume walk, an over-page burst that must not lose/dupe, and the source matcher's id/content/window/cross-chat cases. Each guard was mutation-verified to fail without its fix. Wired into mcp-test.yml as a fast unit job; poll_messages added to the guard-check + smoke-test contracts.

Honest limitations (documented, not hidden)

  • The source match is unverified against a live Beeper account (no live account in CI — the standing gate limitation), compounded by pendingMessageID possibly being swapped for a real bridge id on ack. Matcher is deliberately conservative; validate against a real account before a high-stakes auto-responder leans on it.
  • A single chat receiving >100 messages between two polls can lose the oldest of that burst — Beeper's messages endpoint returns only the newest N with no backward paging.

Security

Ran /security on the diff. Closed two Lows by bounding caller-supplied input: cursor ids capped at 4096 on decode (CPU-amplification guard); client_tag truncated to 256 before the ledger (bloat guard). Both have tests that fail without the cap. Single-tenant trust model means IDOR/tenant-isolation are N/A.

🤖 Generated with Claude Code

hamr0 and others added 2 commits June 15, 2026 21:46
First consumer-driven feature (multis, beeperbox's first customer). multis
was hand-rolling a seed→poll→dedup loop against the raw /v1/ API and hitting
the usual off-by-ones; this exposes the missing ability natively — ability,
not policy.

- New MCP tool `poll_messages` (11 tools now): passive, cursor-based
  "what's new since I last looked?" feed across all recent chats (or one via
  chat_id). Read-only — never marks read / archives / mutates. First call
  seeds "from now"; subsequent calls return only messages newer than the
  opaque, restart-resumable cursor, with same-millisecond id dedup. Fetches
  the newest 100 per chat (headroom > delivery page) and advances the cursor
  over only what was delivered, so an over-limit burst pages out across polls
  instead of being skipped.
- Echo-guard: new `source` ("api"|"external") + echoed `client_tag` on every
  Message. On one account both the owner's own typed messages and the agent's
  API replies are is_self=true, so is_self can't guard echo. send_message /
  note_to_self record sends to a ledger persisted in the config volume
  (BEEPERBOX_SENT_LEDGER); read-back tags the match. Branch on source, not
  is_self. Content fallback is is_self-gated so an external sender can never
  be mis-tagged and dropped.
- Schema additions are additive (source/client_tag on Message; client_tag on
  the two send tools) — new tool + new fields = MINOR.
- Input bounds (from a /security pass): cursor ids capped at 4096 on decode;
  client_tag truncated to 256 before the ledger.
- Tests: node --test unit suite over the pure cursor/dedup/echo-guard/
  delivery logic (incl. a same-ms dedup case, restart-resume walk, and an
  over-page burst that must not lose/dupe). Wired into mcp-test.yml; the tool
  added to the guard-check + smoke-test contracts.
- Docs: poll_messages + the source/client_tag fields across context guide,
  GUIDE, PRD, CHANGELOG; plus the raw /v1/ contract made explicit (the ~25
  limit floor, the client-side cursor recipe, the canonical heuristics) so
  direct-API consumers match the MCP layer.

Honest limitation (documented): the source match is unverified against a
live Beeper account (no live account in CI), and a single chat receiving
>100 messages between two polls can lose the oldest of that burst (Beeper's
messages endpoint has no backward paging).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lets operators relocate the sent-message ledger via .env, consistent with
the other env-overridable settings. Empty (the default) falls back to the
path inside the config volume, so existing deployments are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@hamr0 hamr0 merged commit 643ba85 into master Jun 15, 2026
2 checks passed
@hamr0 hamr0 deleted the feat/poll-messages-watch-primitive branch June 15, 2026 19:50
@hamr0 hamr0 mentioned this pull request Jun 15, 2026
hamr0 added a commit that referenced this pull request Jun 15, 2026
)

Cuts 0.6.0 (MINOR) so :latest carries Ask A/B before multis Phase 2 ships
(Phase 2 hard-depends on the exact-id echo-guard — no text fallback left).
Bundles everything under [Unreleased]: poll_messages watch primitive + source
echo-guard (#11), docker-restart stale-lock fix (#12), reliable echo via
final-id resolution + supervised backend (#13).

- CHANGELOG: [Unreleased] -> [0.6.0] — 2026-06-15 with a release summary.
- mcp/server.js: serverInfo.version 0.5.0 -> 0.6.0 (was stale).
- docs/PRD.md: status stamp -> v0.6.0; release-history table corrected (the
  stale "(unreleased)" row is 0.5.1) + 0.6.0 row; example tag bumped.
- beeperbox.context.md / README.md: version stamps + pin example -> 0.6.0.

Tag v0.6.0 after merge triggers the gated publish (:0.6.0/:0.6/:0/:latest,
old :latest -> :previous). Live id-resolution round-trip validated by multis;
CI gate still runs the server standalone (no account).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant