Repository navigation
agent: MCP Events client (draft extension) — webhook, push and poll into serve sessions - #133
Merged
Merged
Conversation
…nto serve sessions
Implements the client side of the MCP Triggers & Events WG design sketch
(draft, no SEP) pinned at experimental-ext-triggers-events@6682596d65eec778fe0b8b1f43b4e89d2fe2c546.
- tools/mcp_events.rs: discovery (events/list, list_changed), poll, push
(events/stream) and webhook (events/subscribe with a per-subscription
whsec_ secret, verification challenge, Standard Webhooks HMAC + 5-minute
timestamp check, TTL refresh at 3/4 of refreshBefore rotating the secret
each time, previous secret still accepted, unsubscribe on teardown).
- Subscriptions from settings.json mcp_servers[].events, or at runtime via
mcp_events_list / mcp_events_subscribe / mcp_events_unsubscribe.
- Every occurrence is deduplicated by eventId and broadcast as an mcp_event
frame. Unless the action is notify, a per-session coalescer injects at most
one prompt (streaming_behavior follow_up/steer) per window into the
session's own command channel; follow-ups are held while a run is busy.
- Webhook callbacks land on serve's existing listener at
/_beyond/mcp-events/<token>, enabled by --mcp-events-callback-url (refused
without a listener). run does not subscribe; service mode refuses the
commands explicitly.
- Fix for an rmcp 3.x decoding bug, found by running against the independent
mcp-webhook-events server: a custom result carrying _meta (as every
2026-07-28 server sends) decoded as an empty CallToolResult. Stdio servers
are now spawned through a stdout pump that rescues exactly those
responses; HTTP events requests go direct over stateless HTTP.
Proof: tests/mcp_events_{poll,push,webhook}.rs drive the real serve against
a new hand-rolled fixture (src/bin/mcp_fixture_events_server.rs, stdio +
streamable HTTP). The interop check in eval/mcp_events_interop passes 12/12
over both transports (not in CI).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk
…d cursors, v1a Closes every gap the first MCP Events commit listed, each proven end to end against the real serve binary and the fixture. - Busy sessions: an injected event prompt (now marked "mcp_events": true) that lands during a host bash, a branch summary or a retry backoff is deferred and replayed the moment the loop is idle, never refused as busy; a mid-run follow-up injection runs after the run. tests/mcp_events_busy.rs proves the model receives events emitted mid-bash and mid-compact. - Commands: mcp_events_* run as spawned tasks from every loop, idle or busy, so discovery/verification never block the session and work mid-run. - Keep-alive: a session with a live subscription is exempt from the daemon's idle reaper (new keep_alive flag on the slot); --mcp-events-reapable opts out. Proven: an event arriving while detached runs the model and shows in the re-attach catchup; with the flag the reaper unsubscribes. - Persistence: cursors and the dedup window persist beside the transcript in <session>.mcp-events.json (private temp file, fsync, rename, dir fsync; coalesced writes on the blocking pool; flushed at teardown). Restarts resume from the cursor: events emitted while serve was down are delivered, once, for webhook (daemon) and poll (stdio), with an inclusive-replay fixture proving the saved dedup window. Poll now persists the cursor after the page's events. Optional max_age_ms (the draft's maxAgeMs) is sent. - Server identity: v1a Ed25519 signatures. Keys come from the MCP server origin's /.well-known/mcp-webhook-jwks.json (the draft's standalone-JWKS location); when published, every delivery must carry a valid v1a. - Receiver document: the listener serves /.well-known/mcp-webhook-receiver.json, so servers can verify without a challenge (proven with a fixture that verifies that way). - Legacy session-bound HTTP servers (pre-2026-07-28) go through rmcp's own connection, which carries the Mcp-Session-Id; webhook deliveryStatus is kept in mcp_events_list. - ARCHITECTURE.md: the new behaviour, the --mcp-events-reapable row, and the TLS-in-front deployment requirement for webhook callbacks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk
Each finding has an end-to-end or unit test that fails on e4dc6c7 and passes now. HIGH - F1 ownership: configured subscriptions are owned by ONE session — the stdio serve's, or the daemon's --mcp-events-session (default `mcp-events`), booted at start. One event = one subscription = one model run; only owning sessions are kept alive. (tests/mcp_events_routing.rs) - F2 durability: accepted events are stored as pending in the session's state file before the webhook 2xx / cursor advance; injected batches leave it only after their run's transcript is persisted; restarts re-inject. Exactly once across graceful restarts, at least once across a hard crash. (tests/mcp_events_durability.rs; mutation m18 caught) MEDIUM - F3 the stdio transport (all stdio servers) moves to tools/mcp_stdio.rs: skips non-UTF-8 lines, 3 s grace after stdin closes before kill + group sweep, byte-level pre-check so ordinary tool results are not parsed; rescue behind one boundary. (tests/mcp_stdio_transport.rs) - F4 v1a fails closed: keys ever seen per origin persist and only grow; a first subscribe whose JWKS fetch fails is refused. (tests/mcp_events_v1a.rs) - F5 webhook route: token / method / length / 1 MiB cap checked before reading the body, body grown not preallocated, no route in service mode. - F6 timestamp check uses abs_diff (no overflow); future-dated test (m3). - F7 push router: overflow marks the stream, forwards nothing after the drop, and resumes from the last delivered cursor; no silent loss. - F8 RFC 3339 parser accepts minute precision etc.; unparseable refreshBefore refreshes in 30 s with a warning. - F9 hasMore with no events waits the poll floor. - F10 -32011 kind:event / -32014 schema_changed re-discover and resubscribe; gaps are explained to the model. - F11 state file: single writer task (newest snapshot wins), ends and writes on last drop, async read, follows new_session/switch_session, trashed/restored with its session. - F12 per-key subscribe locks (no global lock across discovery/ready wait); bounded SSE/JWKS/response bodies and a linear SSE scan. - Mutation-survivor tests: m4 (exact rotation grace), m11 (coalescer busy hold in isolation), m21 (terminated-only session becomes reapable). CI: agent-lib, agent-rest and agent-code-mode shards run clean locally under the ci nextest profile (no SLOW, no LEAK). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk
…e stdio exit sweep Every fix has a test that fails on 295dea6 and passes now. - N1 a steer injection counts as delivered only if its text reached the transcript; an abort (which clears the steer lane) returns it to pending. N7 a failed run or a transcript that did not persist returns its batches too; after 3 attempts an event is dropped and reported. - N2 (regression vs main) McpConnection's Drop sweeps again: mcp_stdio::retire waits out the server's grace, then kills its group on a tracked thread; serve settles the pumps and waits for the sweeps before process::exit (EOF and SIGTERM). - N3 configured subscriptions are kept up: retried with capped backoff forever after a failed subscribe or a termination, counting as live for the keep-alive meanwhile. - N4 the JWKS fetch has one deadline over headers and body; subscription tasks that overstay their cancellation are aborted (taking their webhook route with them). - N5 the pending queue is bounded in bytes as well as count, and lives in an append-only log (fdatasync'd per commit, compacted when mostly dead) beside a small snapshot. - N6 a commit that fails or times out is reported: webhook answers 503, poll holds its cursor, the coalescer does not inject; failed writes are retried by the next one. - N8 nothing is written while there is nothing to persist: no sidecar without events. - N9 the coalescer's send watches shutdown; shutdown's wait on it is bounded (then aborted). - N10 hasMore with only duplicates waits the poll floor (counted after dedup). - N11 relocation moves the state before the next command, merging the target's own state and removing the old files. - N12 the re-discovery budget recovers after a healthy period; a resubscribe after a termination tells the model about the possible gap. - N13 X-MCP-Subscription-Id is required once known; the webhook body read has a deadline (408); only 8 signature entries are examined; the SSE drain is linear; the callback token and secret persist so deliveries retried across a crash still land. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk
- R1 (regression vs main) every exit of `run` and `serve` goes through exit_process / finish_process_cleanup: retire every stdio server still running, then wait (bounded) for the sweeps. `agent run` no longer orphans a server's grandchild, finishing or cancelled. - R2 a torn pending-log tail is truncated (and synced) on load before anything is appended, so the next acknowledged event is not glued onto the fragment. - R3 `settle` is gone: retire closes stdin synchronously, so nothing needs the runtime to run on the way out. retire_all is proven necessary by a run cancelled mid-turn. - R4 the grace is measured by a non-blocking wait on the owned child (portable), not /proc. - R5 keep_subscribed re-checks that the key is still configured before every attempt. - R6 persisted callback tokens are held from startup: an early retry gets 503, not 410. - R7 a failed compaction puts back the records it displaced. - R8 the post-reap group sweep never kills by pid; and the group kill passes `--` (procps-ng read a bare `-<pgid>` as an option and signalled nothing). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk
…exit status was inverted Without `--`, procps-ng kill signals a live group but exits 1 (and 0 for a gone group), so kill_process_group's by-pid fallback ran after every successful group kill. Comment and ARCHITECTURE.md corrected; no behaviour change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk
…, batch ids, token holds - A panic unwinding out of main sweeps (mcp_stdio::ExitSweep guard runs the bounded sweep_before_exit in its Drop). - Configuration errors found after MCP connect (--lifecycle-url, --approve, memory backend, --output-schema) exit through exit_process. - Steered batches are found in the transcript by the batch id their text starts with, not by exact text; the compaction duplicate case is documented. - Only configured subscriptions' persisted tokens are held after a restart; a runtime one's old callback answers 410. - Exit latency is documented as a cost by design. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk
This was referenced Oct 7, 2026
Merged
jaredLunde
added a commit
that referenced
this pull request
Oct 7, 2026
Client side of `io.modelcontextprotocol/skills`: a declaring server's `skills/list` entries become `<server>:<name>` skills in a separate `<available_skills origin="mcp">` block, loaded (size, digest and frontmatter verified, never prefetched) through `mcp__<server>__skill__read`, invocable as `/skill:<server>:<name>`, listed by `get_commands`, re-listed on `ttlMs` expiry or list-changed, and cached in the manifest. Activation of a model-chosen skill and code execution while one is active need the user's approval (`run` denies unless `--approve-mcp-skills`); state is per session and restored only from host-authored transcript records. Rebased onto MCP Events (#133): `tools/mcp_stdio.rs` is the one stdio transport, and its `rescue`/`unwrap_rescued` is the one workaround for rmcp's untagged-result bug (rust-sdk#1197), for events and skills results alike. `tools/mcp_wire.rs` keeps only the streamable-HTTP client, which applies the same `rescue` to `skills/*` JSON bodies and SSE events. Rebased onto MCP Tasks (#130) too: every skills request (skills/list, skills/get, resources/read) goes through `mcp::request_tracked` (`track_call` under the calling session's host), so a nested request is attributed or refused, never sent to a shared connection's own host. A steered `/skill:` expands as its session (its command loop can answer); a re-list or expansion between runs runs under a client-less host, so a nested request is declined at once instead of stalling the prompt. Round-3 fixes: a steer waiting behind a `/skill:` when its run is cancelled is acked as not queued (the cancellation already dropped the run's lanes), and an expansion that finished but was never queued activates nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk
jaredLunde
added a commit
that referenced
this pull request
Oct 7, 2026
Client side of `io.modelcontextprotocol/skills`: a declaring server's `skills/list` entries become `<server>:<name>` skills in a separate `<available_skills origin="mcp">` block, loaded (size, digest and frontmatter verified, never prefetched) through `mcp__<server>__skill__read`, invocable as `/skill:<server>:<name>`, listed by `get_commands`, re-listed on `ttlMs` expiry or list-changed, and cached in the manifest. Activation of a model-chosen skill and code execution while one is active need the user's approval (`run` denies unless `--approve-mcp-skills`); state is per session and restored only from host-authored transcript records. Rebased onto MCP Events (#133): `tools/mcp_stdio.rs` is the one stdio transport, and its `rescue`/`unwrap_rescued` is the one workaround for rmcp's untagged-result bug (rust-sdk#1197), for events and skills results alike. `tools/mcp_wire.rs` keeps only the streamable-HTTP client, which applies the same `rescue` to `skills/*` JSON bodies and SSE events. Rebased onto MCP Tasks (#130) too: every skills request (skills/list, skills/get, resources/read) goes through `mcp::request_tracked` (`track_call` under the calling session's host), so a nested request is attributed or refused, never sent to a shared connection's own host. A steered `/skill:` expands as its session (its command loop can answer); a re-list or expansion between runs runs under a client-less host, so a nested request is declined at once instead of stalling the prompt. Rebased onto MCP Apps (#132): the skills resource hiding and the Apps `ui://` filter both run in `tools_from_client`; one resource-list-changed handler clears the Apps views and invalidates the skills listing; and the skills invalidation flag rides the dial into `McpHandler::for_dial`. Round-3 fixes: a steer waiting behind a `/skill:` when its run is cancelled is acked as not queued (the cancellation already dropped the run's lanes), and an expansion that finished but was never queued activates nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk
jaredLunde
added a commit
that referenced
this pull request
Oct 7, 2026
Client side of `io.modelcontextprotocol/skills`: a declaring server's `skills/list` entries become `<server>:<name>` skills in a separate `<available_skills origin="mcp">` block, loaded (size, digest and frontmatter verified, never prefetched) through `mcp__<server>__skill__read`, invocable as `/skill:<server>:<name>`, listed by `get_commands`, re-listed on `ttlMs` expiry or list-changed, and cached in the manifest. Activation of a model-chosen skill and code execution while one is active need the user's approval (`run` denies unless `--approve-mcp-skills`); state is per session and restored only from host-authored transcript records. Rebased onto MCP Events (#133): `tools/mcp_stdio.rs` is the one stdio transport, and its `rescue`/`unwrap_rescued` is the one workaround for rmcp's untagged-result bug (rust-sdk#1197), for events and skills results alike. `tools/mcp_wire.rs` keeps only the streamable-HTTP client, which applies the same `rescue` to `skills/*` JSON bodies and SSE events. Rebased onto MCP Tasks (#130) too: every skills request (skills/list, skills/get, resources/read) goes through `mcp::request_tracked` (`track_call` under the calling session's host), so a nested request is attributed or refused, never sent to a shared connection's own host. A steered `/skill:` expands as its session (its command loop can answer); a re-list or expansion between runs runs under a client-less host, so a nested request is declined at once instead of stalling the prompt. Rebased onto MCP Apps (#132): the skills resource hiding and the Apps `ui://` filter both run in `tools_from_client`; one resource-list-changed handler clears the Apps views and invalidates the skills listing; and the skills invalidation flag rides the dial into `McpHandler::for_dial`. Round-3 fixes: a steer waiting behind a `/skill:` when its run is cancelled is acked as not queued (the cancellation already dropped the run's lanes), and an expansion that finished but was never queued activates nothing. Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 7, 2026
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
Client-side support for the MCP Events extension in
crates/agent: aservesession can subscribe to events on its configured MCP servers and react when they fire — over webhook (the mode ChatGPT ships), push (events/stream) and poll (events/poll) — with every event surfaced as anmcp_eventframe and, unless the subscription saysnotify, handed to the model as a rate-bounded follow-up.Draft status
There is no SEP. This implements the Triggers & Events WG design sketch at
6682596d65eec778fe0b8b1f43b4e89d2fe2c546, cross-checked against OpenAI's MCP Events guide and the TS SDK proposal (modelcontextprotocol/typescript-sdk#2945). The commit is pinned intools/mcp_events/mod.rs(SPEC_COMMIT, also returned bymcp_events_list) and in ARCHITECTURE.md. A server with noeventsentry is never sent anevents/*request, and a session that never subscribes writes no events state. What does change for every stdio MCP server, events or not, is its transport (see below).Configuration
serve --listen 127.0.0.1:8080 --mcp-events-callback-url https://agent.example.comturns webhook delivery on. Callbacks land on the existing listener at/_beyond/mcp-events/<token>. Runtime, per session (accepted idle or mid-run):mcp_events_list,mcp_events_subscribe,mcp_events_unsubscribe. On a daemon, configured subscriptions are owned by one session,--mcp-events-session(defaultmcp-events), which is started at boot.--mcp-events-reapableopts that session back into idle reaping.Deployment: servers require
httpscallback URLs, andserve --listenspeaks plain HTTP by design (it binds loopback or an internal interface and trusts the front door, like the rest of the daemon). So--mcp-events-callback-urlis the publichttps://base of whatever terminates TLS and routes/_beyond/mcp-events/*(and/.well-known/mcp-webhook-receiver.json) to the listener. Verification doesn't depend on end-to-end TLS, because the HMAC covers the raw body and timestamp. This is documented in ARCHITECTURE.md.Design decisions
Configured subscriptions are owned once and kept up. On stdio
serve, the session owns them. On a daemon they belong to one session,--mcp-events-session(defaultmcp-events), which starts at boot. One server event means one subscription and one model run.mcp_events_subscribe.Durable delivery. An accepted event is appended to the session's pending log and
fdatasync'd before anything is acknowledged.2xxwaits for that write. A full queue, a failed write or a timeout answers503instead, and the server's retry lands once the write works.What counts as delivered. A batch leaves the pending queue only when the model has received it in a transcript that persisted.
kind: "dropped"), so a poison event cannot re-run the model forever.The guarantee, exactly. It applies to
follow_upandsteersubscriptions; anotifyevent only becomes a frame, and nothing is queued. An event counts as acknowledged once its webhook got a2xxor its poll cursor moved past it. A push cursor is saved only after the event's record.Every acknowledged event is on disk (fsync'd) before that point, and reaches the model at least once, across graceful restarts and crashes alike. The single exception is reported: an event is dropped with
kind: "dropped"after 3 injections that never reached the model.It reaches the model exactly once except in four cases, each of which re-injects the batch:
donerecord;donerecord fails to be written in that same window.A graceful exit (SIGTERM, stdin EOF, the reaper) writes that record first, so it never duplicates. Two things the guarantee does not cover: events a server emits while no subscription exists, and events it emits during a graceful restart for a type with no cursor replay. Neither was ever acknowledged.
State files: two files beside the transcript.
<session>.mcp-events.jsonis a small snapshot of cursors, dedup windows, the webhook callback and server keys.<session>.mcp-events.logis an append-only pending log. A commit costs the size of the change, not of the queue, and the log is compacted when it is mostly dead records.new_session/switch_sessionthe state moves before the next command is read, merged with whatever the target already had.Everything enters through the session's own command channel. Injections are ordinary
promptcommands tagged with anmcp_eventsbatch number. They go through a weak sender, so EOF still ends the session. When the loop is busy they are deferred, never refused.Events never start unbounded runs.
eventId.follow_upevents are held while a run is in flight.Webhook receiver:
503, never410. A runtime subscription is not resubscribed after a restart, so its old callback answers410.Content-Lengthand a 1 MiB cap. The body read itself has a deadline (408).webhook-signatureentries are examined.X-MCP-Subscription-Idis required once the subscription id is known.v1afails closed. Before each subscribe and refresh, keys come from<origin>/.well-known/mcp-webhook-jwks.json.404/410means "not published".Push router: each stream's queue is bounded. On overflow it drains what it has and reconnects from the last delivered cursor, so nothing is lost silently. The SSE reader's scan and drain are both linear.
Re-discovery: a
-32011/-32014termination of a runtime subscription re-discovers and resubscribes, up to 5 times. That budget is restored after 10 healthy minutes. Any resubscribe after a termination tells the model that events may have been missed.The stdio transport changes for every stdio MCP server
rmcp 3.x decodes every response into an untagged union in which
CallToolResultmatches any object with_meta. That turned2026-07-28custom results into empty tool results. So all stdio MCP servers, not only those with events, now go throughtools/mcp_stdio.rs:Spawning: the server runs in its own process group with stderr inherited.
Reading stdout: a non-UTF-8 line is skipped.
Rescue: a byte-level pre-check means only responses that could be misdecoded are parsed and rescued (
rescue/unwrap_rescued, behind one boundary).Shutdown:
mcp_stdio::retireruns in three places: when the transport drops, when the connection drops (McpConnection'sDrop, the idle reap), and for every server still running on the way out of the process (retire_all). Each time it:The waiting and killing run on a tracked OS thread. Every way out of
runandserveafter the servers are up waits for those sweeps, bounded by the grace plus 2 s:process::exitgoes throughexit_process: stdin EOF, SIGTERM, a cancelled or refusedrun, and every configuration error found after connecting;mainreturning, or a panic unwinding out of it, passes theExitSweepguard'sDrop.Only argument-parsing failures skip the sweep, and they happen before anything is spawned.
This costs exit time, by design. A process that ran a stdio server exits in about 166 ms instead of 44 ms when the server exits on EOF, because each server gets the chance to exit cleanly and is then swept. When a server ignores EOF and has to wait out its grace, exit takes up to about 3.15 s.
This restores main's exit-time sweep, which earlier pushes of this branch had regressed, for both
serveandrun. A double-forked grandchild no longer outlives the agent. A hard-killed agent still sweeps nothing; that needs a supervisor.A pre-existing bug, fixed in passing:
exec::kill_process_group's group kill now passes--. Without it, procps-ngkillstill killed every member of a live group, but its exit status was inverted: 1 on success, 0 for a group already gone. The by-pid fallback therefore ran after every successful group kill, which is exactly the pid-reuse hazard. The group signal itself always worked. An earlier version of this description said the group kill signalled nothing; that was wrong.Round 4 findings — all fixed
mcp_stdio::a_panic_unwinding_past_the_exit_guard_still_sweeps: panics after a stdio server is up and finds no orphaned grandchildmainhas no panic hook to drive.mcp_stdio_transport::a_run_refused_for_bad_configuration_after_connecting_still_sweeps(--output-schema)exit(2)restored. All five such exits (--lifecycle-url,--approve, memory backend,--output-schema×2) now useexit_process.mcp_events::a_steered_batch_is_found_by_its_id_not_its_content;mcp_events_receiver::after_a_restart_a_runtime_subscriptions_old_callback_is_gone_not_heldRound 3 findings — all fixed
"Fails before" means the test was run against
df9ce79, or the fix was reverted in place, and the test failed.agent runorphaned stdio grandchildren on exitmcp_stdio_transport::run_exiting_sweeps_its_stdio_servers_groups_after_their_gracedf9ce79state::a_torn_log_tail_does_not_swallow_the_next_eventsettle()was untestedretirecloses stdin synchronously, so nothing needs the runtime on the way out. Its replacement on that path,retire_all, is proven bymcp_stdio_transport::a_run_stopped_mid_turn_still_sweeps_its_stdio_servers. The exit wait itself is proven too: removing it fails all four exit tests.df9ce79; and withoutretire_all)/proc-based liveness meant no grace on macOSmcp_stdio::retire_gives_the_grace_then_kills_a_server_that_ignores_stdin./procworked. The defect is macOS-only and CI has no macOS runner.mcp_events_routing::unsubscribing_a_configured_subscription_while_its_server_is_down_stops_the_retriesdf9ce79and with the check reverted410mcp_events_receiver::a_retry_that_beats_the_resubscribe_after_a_restart_is_told_to_retry_not_to_stopdf9ce79state::a_failed_compaction_loses_no_recordsmcp_stdio::the_sweep_after_a_reap_never_kills_by_pid: a non-leader standing in for a pid-reuser survives the sweep, while the fallback path kills itR8 note. The R8 test's stand-in is not a group leader, so its pid names no group. Without
--,killexits 0 for such a group, the fallback is skipped, and the old code passes there; on a live group the fallback did run. With--the status is correct, and the test shows the fallback path would kill the stand-in while the group-only sweep leaves it alone.Re-audit findings (round 2) — all fixed
"Fails before" means the test was run against the previous head,
295dea6, with this push's fixture and test helpers, and failed there.mcp_events_durability::a_steered_event_an_abort_drops_is_injected_againserveexitmcp_stdio_transport::serve_exiting_on_eof_sweeps_its_stdio_servers_groups_after_their_grace,::serve_exiting_on_sigterm_…mcp_events_routing::configured_subscriptions_that_fail_at_boot_are_retried_and_kept_alive,::a_configured_subscription_the_server_ends_is_resubscribedmcp_events_v1a::a_jwks_body_that_stalls_is_timed_out_and_leaves_nothing_behindmcp_events_receiver::the_pending_queue_is_bounded_in_bytes_and_answers_503_past_it; unitstate::commit_cost_tracks_the_change_not_the_queue,::the_pending_queue_is_bounded_in_bytes,::the_log_is_compacted_once_mostly_deadmcp_events_receiver::a_delivery_that_cannot_be_stored_gets_503_and_lands_on_retry; unitstate::a_failed_write_is_reported_and_retrieddelivered()despite a persist error or failed runmcp_events_durability::an_event_whose_run_fails_is_injected_again_up_to_a_boundmcp_events_poll::a_session_without_events_writes_no_events_state; unitstate::nothing_is_written_while_there_is_nothing_to_persistmcp_events::tests::the_coalescer_ends_on_shutdown_even_while_its_send_is_blockedhasMorepage spunmcp_events_poll::has_more_with_only_duplicates_does_not_spinmcp_events_durability::the_events_state_follows_the_session_to_a_new_transcript(now asserts a move); unitstate::relocating_merges_into_the_target_and_removes_the_old_filesmcp_events_push::rediscovery_tells_the_model_about_the_gap_and_its_budget_recoverswebhook::the_receiver_enforces_token_freshness_signature_and_echoes_challenges(missing id → 401),webhook::signature_entries_past_the_cap_are_ignored,wire::many_events_in_one_chunk_drain_in_linear_time(4.4 s vs 0.26 s);mcp_events_receiver::a_delivery_body_that_never_finishes_is_timed_out,::the_callback_survives_a_crash_so_retried_deliveries_still_landAbout the "fails before" column:
state.rsAPI, which did not exist before, so they can't be run against295dea6. The end-to-end test in the same row is the before/after proof.mcp_events_routing::a_session_whose_only_subscription_terminated_becomes_reapablewas rewritten to use a runtime subscription, because configured ones are now kept up (N3). It passes on both heads and stays as the m21 guard.Mutations from round 1 are still caught after this push:
a_future_dated_or_absurd_timestamp_is_refused_without_harmrotation_grace_accepts_exactly_the_previous_secret_and_refuses_older_onesthe_coalescer_holds_follow_ups_while_busy_and_releases_them_afterpoll_does_not_advance_its_cursor_past_events_it_could_not_accepta_session_whose_only_subscription_terminated_becomes_reapableThese were applied by script. The CI
mutantsjob does not cover this crate: its "changed lines" shards pass in about 30 s without mutatingcrates/agent.Round 1 (F1–F12, at
295dea6) remains fixed. Each of those findings is proven by its test listed in that commit's message.Proof
Everything runs the real
serve(andrun) binary againstsrc/bin/mcp_fixture_events_server.rs(stdio and streamable HTTP, with a control API) and a mock model. There are 60 end-to-end tests:mcp_events_pollmcp_events_webhookmcp_events_pushmcp_events_routingmcp_events_durabilitymcp_events_receiver(new)mcp_events_lifecyclemcp_events_v1amcp_stdio_transportmcp_events_busyThere are also 34 unit tests across
tools::mcp_events::{mod,state,webhook,wire}andtools::mcp_stdio.Checks run on this head:
tests/mcp_*.rssuite is green, 19 binaries includingmcp_reap. The lib tests are green (1471).serve_reaper,serve_http,serve_drain,run_signal_handlingandrun_cli_flagsare green.cargo clippy -p beyond-ai-agent --all-targets --features code-mode -D warnings, a workspace clippy,cargo fmt --checkanddprint checkare all clean.79ff913, every job includingagent-lib,agent-restandagent-code-mode(4–5 min). It was also green on295dea6.df9ce79those three shards were cancelled at exactly 30 min.jared/mcp-tasks-proof, which does not contain this code, was cancelled in the same window. This is environmental and being investigated separately.Independent check (not in CI)
crates/agent/eval/mcp_events_interop/check.pyruns the real agent againstmcp-webhook-events0.2.0 on the official Python MCP SDK (mcp2.3.0). Deliveries are signed by the officialstandardwebhookslibrary, and the package does sendX-MCP-Subscription-Id.Remaining caveats
v1akey source: the standalone JWKS is implemented. SEP-2127 server cards don't exist yet.donerecord re-injects that batch once. That is at least once, not exactly once.🤖 Generated with Claude Code
https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk