feat(mcp): poll_messages watch primitive + source echo-guard#11
Merged
Conversation
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>
Merged
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 viachat_id). Read-only: never marks read, archives, or mutates.{ts, ids@ts}encoding) — the specific bug hand-rolled pollers get wrong.limitburst pages out across polls instead of being skipped.sourceecho-guard — newsource("api"|"external") + echoedclient_tagon everyMessage. On one account both the owner's own typed messages and the agent's API replies areis_self: true, sois_selfcan't guard echo.send_message/note_to_selfrecord sends to a ledger persisted in the config volume (BEEPERBOX_SENT_LEDGER); read-back tags the match. Branch onsource, notis_self. The content fallback isis_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-selftotal===1 && isSelf, network viaaccountID, grouptype==='group',pendingMessageIDnot stable).Versioning
New tool + additive schema fields (
source/client_tagonMessage;client_tagon 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 thesourcematcher's id/content/window/cross-chat cases. Each guard was mutation-verified to fail without its fix. Wired intomcp-test.ymlas a fastunitjob;poll_messagesadded to the guard-check + smoke-test contracts.Honest limitations (documented, not hidden)
sourcematch is unverified against a live Beeper account (no live account in CI — the standing gate limitation), compounded bypendingMessageIDpossibly 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.Security
Ran
/securityon the diff. Closed two Lows by bounding caller-supplied input: cursoridscapped at 4096 on decode (CPU-amplification guard);client_tagtruncated 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