Skip to content

agent: MCP Events follow-ups — panic exit, delivery by tag, permanent refusals, runtime restore - #136

Merged
jaredLunde merged 11 commits into
mainfrom
jared/mcp-events-followups
Oct 7, 2026
Merged

jaredLunde merged 11 commits into
mainfrom
jared/mcp-events-followups

Conversation

@jaredLunde

@jaredLunde jaredLunde commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

What

Follow-ups to the merged MCP Events work (#133), rebased on main after #134, #135, #138, #139, #140, #141, #142 and #143. Every known gap is fixed, including item 5's former known limit (now item 7) and every re-audit finding (N1–N7). Each fix has a test that fails before it, either on main or with the fix reverted in place. The exceptions are item 6, which only adds tests (see its row), and N7's parity tests, which pin behaviour rather than fix it.

Original items

# gap fix proving test fails before
1 A panic inside serve hung in runtime drop, on the parked stdin reader, before ExitSweep ran. main catches the unwind, abandons the runtime's threads, sweeps the stdio servers and exits 101. mcp_stdio_transport::a_panic_inside_serve_exits_promptly_and_still_sweeps yes: hung 15 s, orphan left
1b A panic in one daemon session Contained: that session's clients get an error frame, it ends, and the daemon keeps serving the others. mcp_stdio_transport::a_panic_in_a_daemon_session_ends_that_session_only yes (no error frame)
1c …and if that session was the MCP Events session, configured subscriptions stopped until restart The events session restarts automatically, with backoff (1 s doubling to 60 s, reset after 10 min up). mcp_events_routing::the_events_session_is_restarted_after_a_panic yes
2 A mid-run compaction could summarize a steered batch away, and the transcript-text match then re-injected it. Delivery is recorded by tag at the moment the model receives the batch (SteeringMessage::tag, reported in AgentEvent::Steered after the checkpoint), not re-derived from the transcript. mcp_events_durability::a_steered_batch_summarized_away_by_a_mid_run_compaction_is_not_injected_again, which also asserts an empty pending log and durable done records; agent-core unit a_steered_message_reports_its_tag_when_it_reaches_the_model yes
3 A permanently refused configured subscription retried every 60 s forever and kept its session alive. Permanent refusals are classified. They are reported as refused (as a frame, and in mcp_events_list.unestablished), retried hourly (…_REFUSED_RETRY_MS), and not counted as live. mcp_events_routing::a_permanently_refused_configured_subscription_is_reported_and_does_not_keep_the_session yes
4 Runtime subscriptions did not survive a restart. Persisted, and restored at session start. A daemon starts the sessions that hold them, and their webhook tokens are held. mcp_events_receiver::a_runtime_subscription_is_restored_after_a_restart_without_its_client yes
5 Message-size cap Extends #134's single cap (mcp_stdio::max_message_bytes, BEYOND_AI_AGENT_MCP_MAX_MESSAGE_BYTES, still one env var) to every path it did not cover, listed below. mcp_message_cap::* (6 tests), plus mcp_wire units a_json_body_over_the_cap_answers_the_request_with_an_error and any_request_is_held_to_the_cap yes: every path below except stdio (already #134's)
6 Nested request during events/* Tests only: over stdio (poll and stream), in a daemon with a bystander session, and over direct HTTP (see F4) mcp_events_nested::* (6 tests, incl. N3) the rmcp-path tests pass on main by design; all fail with EventsPeer::track_request disabled

Item 5 covers these paths:

  • Events wire, unary JSON body: an_over_cap_unary_json_answer_on_the_events_wire_is_refused.
  • Events wire, unary SSE answer: an_over_cap_unary_sse_answer_on_the_events_wire_is_refused.
  • Events wire, events/stream event: never read whole: skipped and reported (item 7). The wire's own 4 MiB / 1 MiB caps are gone.
  • mcp_wire::HttpClient: every POST, not only skills/* (the skills-only 32 MiB constant is gone). A request's over-cap answer becomes its JSON-RPC error. Test: an_over_limit_message_from_an_http_server_fails_its_call_and_the_connection_survives.
  • The GET stream: capped through rmcp's own SSE-event limit.

Item 7: an over-cap push event is skipped, not reconnected into. This was item 5's known limit. The event's bounded head is read structurally (mcp_stdio::oversized_stand_in, the same top-level member walk as #134's scan_head) for its routing, cursor and id, and the rest is skipped unread. A small $oversized stand-in takes its place, on both transports (the stdio pump, and the events SSE reader). The subscription keeps that cursor (or else the next heartbeat's), records an oversized gap, which reaches the client as a gap frame and the model as a notice that an event was dropped unread, and carries on. Proved with the blob fixture: a giant push event followed by a normal one; the normal event is delivered and the gap reported, over HTTP and over stdio (mcp_message_cap::an_over_cap_push_event_over_{http,stdio}_is_skipped_and_reported_and_the_next_is_delivered).

Audit fixes (at 229c29e)

# finding fix proving test fails with the fix reverted
F1 A runtime subscription to a removed server pinned its session forever. An unknown or removed server is a permanent refusal. A restore refused for good forgets the spec. Runtime subscriptions expire after their session's last client command (…_RUNTIME_TTL_MS, 7 d). Boot restoration is bounded (…_MAX_RESTORED_SESSIONS, 32). mcp_events_receiver::a_runtime_subscription_to_a_removed_server_is_forgotten_not_resurrected (renamed server, 1 s idle timeout, two restarts); state units runtime_subscriptions_expire_without_a_client, client_activity_is_recorded_only_while_it_matters yes
F2 The item-2 test did not kill its mutants. The redundant received-list is removed, so delivery rests on the tag alone. The test now asserts an empty pending log and durable done records. as item 2 yes (removing receipts.received fails it)
F3 A terminating batch returned before Steered was emitted. Steered is now emitted before the terminate return. agent-core a_steer_folded_into_a_terminating_batch_is_still_reported yes
F4 Over direct HTTP, server→client requests were silently ignored. They are answered with an error at once (unary SSE and events/stream). Over rmcp they are attributed by track_call. mcp_events_nested::over_direct_http_a_nested_request_during_an_events_{poll,stream}_is_refused_not_ignored yes, both
F5 -32012 was classified as permanent. Retried with credentials re-resolved, on a short backoff (≤5 s), and refused only after 5 in a row. mcp_events_poll::a_forbidden_subscribe_is_retried_and_comes_up_once_credentials_work, …forbidden_over_and_over_is_eventually_refused yes
F6 The fixture's blob tool was unused. It is now used by the message-cap tests. — —

Re-audit fixes (at f86d197)

Each "fails with the fix reverted" was checked by reverting the fix in place and running the named test.

# finding fix proving test fails with the fix reverted
N1 A runtime subscription made by a single client command was resurrected on every boot, because it had no client-activity record and None was trusted forever. set_runtime records client activity when it creates one, and a state with runtime subscriptions but no record is not restored. mcp_events_receiver::a_runtime_subscription_made_by_one_command_expires_with_no_client (subscribe, disconnect, crash, restart with TTL 1 ms: no resubscribe, session not started) yes. The creation record alone is also load-bearing: without it, a_runtime_subscription_is_restored_after_a_restart_without_its_client fails.
N2 The refusal POST had no Accept, so a streamable-HTTP server answers 406 and keeps waiting. It sends the same Accept as every events POST, and the fixture now 406s an answer without one. mcp_events_nested::over_direct_http_* (both) yes
N3 Refusals on an events/stream spawned a task per request, without limit. They are answered inline, one at a time, each under a 5 s bound. mcp_events_nested::a_flood_of_server_requests_on_an_events_stream_is_answered_one_at_a_time (20 requests; the fixture reports at most 1 answer in flight) yes
N4 The boot cap kept the first sessions alphabetically. It ranks by most recent client activity. mcp_events::tests::the_boot_cap_keeps_the_most_recently_used_sessions yes
N5 A forgotten runtime subscription left its webhook token and secret in the snapshot. TTL expiry, permanent refusal on restore, and a server ending it all forget_sub: spec, cursor and callback. mcp_events_receiver::a_runtime_subscription_to_a_removed_server_is_forgotten_not_resurrected (asserts no "webhook" or whsec_ remains) yes
N6 A panicked session holding runtime subscriptions was not restarted. It restarts like the events session, with per-session backoff (1 s doubling to 60 s, reset after 10 min up). mcp_events_receiver::a_session_with_runtime_subscriptions_is_restarted_after_a_panic yes
N7 ordinary_limit serialized the whole message to read its id, and the move of every POST off rmcp's client was not documented or tested for parity. It reads the id off the request. ARCHITECTURE.md states that every streamable-HTTP POST goes through HttpClient::post_bounded; rmcp's own post_message is never called. That still holds after #138 and #140: OAuthHttp → ViewCappedHttp → HttpClient. #140's HttpClient { oauth } flag, which routed only an OAuth server's POSTs through post_bounded, now selects nothing and is removed, along with OAUTH_MAX_MESSAGE_BYTES; the one per-message cap applies. differential units against rmcp's reqwest client: mcp_wire::an_ordinary_request_is_sent_as_rmcps_own_client_would (bearer, Mcp-Session-Id, Accept, protocol version, custom headers, body) and …_answered_as_rmcps_own_client_would (401 AuthRequired, 403 InsufficientScope, 404-on-session SessionExpired, 202, JSON with session id, SSE) , which also pins the one deliberate divergence: a 401 with no challenge and a JSON-RPC error body is AuthRequired here, while rmcp's own client returns an ordinary error response (#140's rule, now for every server) parity pins: dropping the session header or the bearer fails them
N2b Found while rebasing after #138, which made every direct events/* POST carry the server's current OAuth token and refresh on 401. The refusal still sent the token from dial time, so after a refresh it spent a 401 and the server waited. The refusal goes through the same Conn::send, without Mcp-Method because an answer has no method. mcp_events::wire::a_refused_server_request_is_answered_with_the_current_token_and_refreshed_on_401 yes

N2 interop note. The Python SDK servers (mcp 2.3.0) used by check.py are stateless. They answer 400 ("Body must be a single JSON-RPC request or notification object") to any POSTed JSON-RPC response, with or without Accept, so they never raise server→client requests and the 406 cannot be reproduced against them. The fixture enforces Accept the way the SDK's stateful transport does, so a regression fails over_direct_http_*.

Design notes

  • Delivery by tag.

    • SteeringMessage gains tag, and AgentEvent::Steered gains tags, which is not serialized.
    • Steered is emitted after the checkpoint on every path, including a terminating batch.
    • serve tags each MCP Events steer with its batch. Receipts::received records the batch as delivered when the tag is reported, and commits that at once.
    • The exactly-once exceptions are now three: a failed or aborted run before the model received the batch; a crash between the checkpoint and the done record; that record failing to be written.
  • Permanent refusals:

    • an unknown server;
    • an event the server doesn't offer;
    • -32601, -32602, -32011;
    • -32014 other than schema_changed;
    • no compatible delivery mode.

    -32012 (forbidden) is auth-retry instead. Transient failures keep the 60 s-capped backoff and still keep the session alive.

  • Runtime restore.

    • A successful mcp_events_subscribe stores its spec (PersistedSub::runtime), and the session re-subscribes it on start.
    • The TTL runs from the session's last client command; creating a runtime subscription counts as one. A state with no record at all is not restored.
    • At boot, a daemon starts at most 32 sessions holding specs still within their TTL, choosing the most recently used.
    • A spec is forgotten on explicit unsubscribe, on the server ending the subscription (unless re-discovery succeeds), on a permanent refusal at restore, and after the TTL with no client. Forgetting removes the cursor and the webhook token and secret too.
  • Panics.

    • A main-future panic exits promptly, with the sweep.
    • A daemon session panic is contained. That is sound because sessions share only state behind plain replace-the-value locks, and MCP server processes belong to the process.
    • The events session, and any session holding runtime subscriptions, is restarted on per-session backoff. Any other panicked session restarts when a client comes back.
    • The test hook is a debug-build-only __test_panic command, gated on BEYOND_AI_AGENT_TEST_PANICS.
  • One POST path. Every streamable-HTTP POST goes through mcp_wire::HttpClient::post_bounded, so rmcp's own post_message is unused. post_bounded applies the message cap, mcp_stdio::rescue (rust-sdk#1197), and rmcp's status handling. The differential tests above pin it to rmcp's client on request headers and response shapes. agent: refresh a rejected MCP OAuth token mid-session and retry once #138's OAuthHttp wraps it as the outermost layer. Since agent: MCP OAuth hardening — any 401 by status, RFC 6749 refusals definitive, pinned reload rule #140, any 401 is AuthRequired for every server, so mcp_oauth always sees the status; this is one of two pinned divergences from rmcp; the other, from agent: pin the OAuth server routing end to end; rmcp parity for non-JSON-RPC successes #142, is that a non-JSON-RPC success answering a request is an error (a_json_success_that_is_not_json_rpc_is_accepted_for_a_notification_but_fails_a_request). agent: pin the OAuth server routing end to end; rmcp parity for non-JSON-RPC successes #142's routing tests and docs now describe post_bounded as every server's path.

Checks

🤖 Generated with Claude Code

https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk

@jaredLunde
jaredLunde force-pushed the jared/mcp-events-followups branch 3 times, most recently from 605b33f to ba49ac9 Compare October 7, 2026 06:27
jaredLunde and others added 11 commits October 6, 2026 23:55
… refusals, runtime restore

- A panic out of `run`/`serve` no longer hangs in runtime drop on the parked stdin reader: `main`
  catches the unwind, abandons the runtime's threads, sweeps stdio servers and exits 101.
- Steered MCP Events batches are delivered by tag: `SteeringMessage::tag` comes back in
  `AgentEvent::Steered` (emitted after the checkpoint on both paths), and the batch is recorded
  as delivered right then — a later compaction can no longer make it look undelivered.
- Permanent refusals (event not offered, extension unsupported, invalid/denied, no mode) are
  classified, reported as `refused` (frame + `mcp_events_list.unestablished`), retried hourly,
  and no longer keep the events session alive.
- Runtime subscriptions are persisted and restored after a restart; a daemon boots the sessions
  that hold them, and their webhook tokens are held like configured ones.
- Tests for nested elicitation during `events/poll` and `events/stream` (stdio and daemon).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk
…tainment, events-session restart

- Unknown/removed servers are permanent refusals; a restore refused for good forgets the runtime
  subscription; runtime subscriptions expire after their session's last client command
  (RUNTIME_TTL_MS, 7 d); boot restoration is bounded (MAX_RESTORED_SESSIONS, 32).
- Steered-batch delivery relies only on the Steered tag (the redundant received list is gone); the
  compaction test now asserts the durable done records and an empty pending queue.
- agent-core reports Steered before ending a terminating batch.
- Direct-HTTP events requests refuse server->client requests with an error instead of ignoring
  them (unary SSE and events/stream).
- -32012 is retried with fresh credentials on a short backoff, refused only after 5 in a row.
- The unused blob fixture tool is gone.
- A panicking daemon session is contained (error frame, session ends, daemon carries on); the MCP
  Events session is restarted after a panic, with backoff.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk
…very HttpClient POST

Extends #134's single per-message cap (mcp_stdio::max_message_bytes,
BEYOND_AI_AGENT_MCP_MAX_MESSAGE_BYTES) to the paths it did not cover: the MCP Events direct-HTTP
wire (unary JSON, unary SSE, events/stream — its own 4 MiB / 1 MiB caps are gone) and every
mcp_wire::HttpClient POST (not only skills/*; a request's over-cap answer becomes its JSON-RPC
error), plus the GET stream through rmcp's SSE-event limit. The skills-only 32 MiB constant is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk
… reconnected into forever

The over-cap event's bounded head is read structurally (mcp_stdio::oversized_stand_in — the same
member walk as scan_head) for its routing, cursor and id; a small $oversized stand-in replaces it on
both transports (the stdio pump, and the events SSE reader, which skips the rest of the event
unread). The subscription keeps the cursor (else the next heartbeat's), records an 'oversized' gap
(frame + model notice) and carries on — no reconnect into the same giant event.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk
… refusal Accept, inline refusals, boot ranking, purge, restart, one POST path

- N1: creating a runtime subscription records client activity (set_runtime), and a state with
  runtime subscriptions but no record is no longer trusted forever: one made by a single command
  expires one TTL after creation. (a_runtime_subscription_made_by_one_command_expires_with_no_client)
- N2: a direct-HTTP server->client request's refusal POST carries the same Accept as every events
  POST; the fixture now 406s an answer without it, as the Python SDK would. (over_direct_http_*)
- N3: refusals on an events/stream are answered inline, one at a time — a flooding server cannot
  make the reader spawn without limit.
  (a_flood_of_server_requests_on_an_events_stream_is_answered_one_at_a_time)
- N4: the boot cap keeps the sessions a client used most recently, not the first alphabetically.
  (the_boot_cap_keeps_the_most_recently_used_sessions)
- N5: a forgotten runtime subscription (TTL expiry, permanent refusal on restore, server ended)
  is forgotten whole: its webhook token and secret leave the snapshot.
  (a_runtime_subscription_to_a_removed_server_is_forgotten_not_resurrected)
- N6: a panicked session holding runtime subscriptions is restarted with per-session backoff,
  like the events session. (a_session_with_runtime_subscriptions_is_restarted_after_a_panic)
- N7: ordinary_limit reads the request id directly instead of serializing the whole message;
  every streamable-HTTP POST goes through post_bounded (rmcp's post_message is unused) — pinned
  against rmcp's own client on request headers and response shapes
  (an_ordinary_request_is_sent_as_rmcps_own_client_would / ..._answered_as_...).
- ARCHITECTURE.md: the above, and the over-cap push-event skip from the previous commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk
…ken (after #138)

#138 made every direct events/* POST carry the server's current shared token and refresh-and-resend
once on a 401; the refusal POST for a server->client request still sent the headers built at dial,
so after a refresh it spent a 401 and the server kept waiting. It now goes through the same
Conn::send (Accept included; no Mcp-Method, since an answer has no method).
(a_refused_server_request_is_answered_with_the_current_token_and_refreshed_on_401)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk
…h flag, pin the 401 divergence

#140 routed an OAuth server's POSTs through post_bounded (HttpClient { oauth }) so a 401 stays
visible; this branch already routes every server's POSTs there, so the flag selected nothing: it is
removed, with OAUTH_MAX_MESSAGE_BYTES (the one per-message cap, mcp_stdio::max_message_bytes,
applies). The rmcp-parity test now pins the one deliberate divergence: a 401 with no challenge and
a JSON-RPC error body is AuthRequired here, an error response from rmcp's own client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk
… load cannot close it

Its second event must be polled, coalesced and steered within one stalled turn; 2.5 s was missed
once under full-suite load on the host. 6 s per turn, and a 90 s bound on the run's response.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk
…ed divergences from rmcp

#142's routing docs and tests described post_bounded as the OAuth servers' path, chosen by the
removed oauth flag; it answers every server's POSTs. ARCHITECTURE.md now names both deliberate
differences from rmcp's client (any 401 is AuthRequired; a non-JSON-RPC success answering a request
is an error), each with its pinning test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk
…d Frames

#141's ChildGuard takes the stdout pipe at spawn and its lint refuses any other route to it; the
follow-up tests built Frames from child.stdout.take(). They now pass the guard, like every suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk
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