Repository navigation
agent: MCP Events follow-ups — panic exit, delivery by tag, permanent refusals, runtime restore - #136
Merged
Merged
Conversation
jaredLunde
force-pushed
the
jared/mcp-events-followups
branch
3 times, most recently
from
October 7, 2026 06:27
605b33f to
ba49ac9
Compare
… 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
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
jaredLunde
force-pushed
the
jared/mcp-events-followups
branch
from
October 7, 2026 07:03
ba49ac9 to
b50dd9b
Compare
This was referenced Oct 7, 2026
agent: MCP tasks follow-ups — run --continue resumes, unreachable stays pending, sealed journal
#137
Merged
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.
What
Follow-ups to the merged MCP Events work (#133), rebased on
mainafter #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 onmainor 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
servehung in runtime drop, on the parked stdin reader, beforeExitSweepran.maincatches the unwind, abandons the runtime's threads, sweeps the stdio servers and exits101.mcp_stdio_transport::a_panic_inside_serve_exits_promptly_and_still_sweepserrorframe, it ends, and the daemon keeps serving the others.mcp_stdio_transport::a_panic_in_a_daemon_session_ends_that_session_onlymcp_events_routing::the_events_session_is_restarted_after_a_panicSteeringMessage::tag, reported inAgentEvent::Steeredafter 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 durabledonerecords; agent-core unita_steered_message_reports_its_tag_when_it_reaches_the_modelrefused(as a frame, and inmcp_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_sessionmcp_events_receiver::a_runtime_subscription_is_restored_after_a_restart_without_its_clientmcp_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), plusmcp_wireunitsa_json_body_over_the_cap_answers_the_request_with_an_errorandany_request_is_held_to_the_capevents/*mcp_events_nested::*(6 tests, incl. N3)mainby design; all fail withEventsPeer::track_requestdisabledItem 5 covers these paths:
an_over_cap_unary_json_answer_on_the_events_wire_is_refused.an_over_cap_unary_sse_answer_on_the_events_wire_is_refused.events/streamevent: 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 onlyskills/*(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.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'sscan_head) for its routing, cursor and id, and the rest is skipped unread. A small$oversizedstand-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 anoversizedgap, which reaches the client as agapframe and the model as a notice that an event was dropped unread, and carries on. Proved with theblobfixture: 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)
…_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 unitsruntime_subscriptions_expire_without_a_client,client_activity_is_recorded_only_while_it_mattersdonerecords.receipts.receivedfails it)Steeredwas emitted.Steeredis now emitted before the terminate return.a_steer_folded_into_a_terminating_batch_is_still_reportedevents/stream). Over rmcp they are attributed bytrack_call.mcp_events_nested::over_direct_http_a_nested_request_during_an_events_{poll,stream}_is_refused_not_ignored-32012was classified as permanent.mcp_events_poll::a_forbidden_subscribe_is_retried_and_comes_up_once_credentials_work,…forbidden_over_and_over_is_eventually_refusedblobtool was unused.Re-audit fixes (at f86d197)
Each "fails with the fix reverted" was checked by reverting the fix in place and running the named test.
Nonewas trusted forever.set_runtimerecords 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)a_runtime_subscription_is_restored_after_a_restart_without_its_clientfails.Accept, so a streamable-HTTP server answers406and keeps waiting.Acceptas every events POST, and the fixture now406s an answer without one.mcp_events_nested::over_direct_http_*(both)events/streamspawned a task per request, without limit.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)mcp_events::tests::the_boot_cap_keeps_the_most_recently_used_sessionsforget_sub: spec, cursor and callback.mcp_events_receiver::a_runtime_subscription_to_a_removed_server_is_forgotten_not_resurrected(asserts no"webhook"orwhsec_remains)mcp_events_receiver::a_session_with_runtime_subscriptions_is_restarted_after_a_panicordinary_limitserialized the whole message to read its id, and the move of every POST off rmcp's client was not documented or tested for parity.HttpClient::post_bounded; rmcp's ownpost_messageis never called. That still holds after #138 and #140:OAuthHttp→ViewCappedHttp→HttpClient. #140'sHttpClient { oauth }flag, which routed only an OAuth server's POSTs throughpost_bounded, now selects nothing and is removed, along withOAUTH_MAX_MESSAGE_BYTES; the one per-message cap applies.reqwestclient: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(401AuthRequired, 403InsufficientScope, 404-on-sessionSessionExpired, 202, JSON with session id, SSE) , which also pins the one deliberate divergence: a 401 with no challenge and a JSON-RPC error body isAuthRequiredhere, while rmcp's own client returns an ordinary error response (#140's rule, now for every server)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.Conn::send, withoutMcp-Methodbecause an answer has no method.mcp_events::wire::a_refused_server_request_is_answered_with_the_current_token_and_refreshed_on_401N2 interop note. The Python SDK servers (mcp 2.3.0) used by
check.pyare stateless. They answer400("Body must be a single JSON-RPC request or notification object") to any POSTed JSON-RPC response, with or withoutAccept, so they never raise server→client requests and the406cannot be reproduced against them. The fixture enforcesAcceptthe way the SDK's stateful transport does, so a regression failsover_direct_http_*.Design notes
Delivery by tag.
SteeringMessagegainstag, andAgentEvent::Steeredgainstags, which is not serialized.Steeredis emitted after the checkpoint on every path, including a terminating batch.servetags each MCP Events steer with its batch.Receipts::receivedrecords the batch as delivered when the tag is reported, and commits that at once.donerecord; that record failing to be written.Permanent refusals:
-32601,-32602,-32011;-32014other thanschema_changed;-32012(forbidden) is auth-retry instead. Transient failures keep the 60 s-capped backoff and still keep the session alive.Runtime restore.
mcp_events_subscribestores its spec (PersistedSub::runtime), and the session re-subscribes it on start.Panics.
__test_paniccommand, gated onBEYOND_AI_AGENT_TEST_PANICS.One POST path. Every streamable-HTTP POST goes through
mcp_wire::HttpClient::post_bounded, so rmcp's ownpost_messageis unused.post_boundedapplies 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'sOAuthHttpwraps it as the outermost layer. Since agent: MCP OAuth hardening — any 401 by status, RFC 6749 refusals definitive, pinned reload rule #140, any 401 isAuthRequiredfor every server, somcp_oauthalways 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 describepost_boundedas every server's path.Checks
tests/mcp_*.rssuite passes (39 binaries, incl. agent: refresh a rejected MCP OAuth token mid-session and retry once #138'smcp_oauth_refresh), as doserve_reaper,serve_http,serve_drain,run_signal_handling,run_cli_flagsand agent: kernel-locked manifest writes off the runtime, LISTEN_PID required, one frame deadline #139'sserve_harness_deadlines(its frame-deadline lint). Themcp_eventssuites read stdout through agent: file_lock module, unreachable deadline-free readers, LISTEN_PID operator note #141's guardedFrames::new(&mut child, …), and agent: file_lock module, unreachable deadline-free readers, LISTEN_PID operator note #141's widened lint passes.agentandagent-corepass: 2760 tests across all 48 binaries in this sweep (incl. agent: pin the OAuth server routing end to end; rmcp parity for non-JSON-RPC successes #142'smcp_oauth_routing), 0 failed, after the rebase onto agent: file_lock module, unreachable deadline-free readers, LISTEN_PID operator note #141.cargo clippy(agent with code-mode, and the whole workspace) is clean with-D warnings.cargo fmt --checkanddprint checkare clean.check.pypasses 12/12 over HTTP and 12/12 over stdio.🤖 Generated with Claude Code
https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk