Repository navigation
agent: MCP skills follow-ups, and the serve suite back in CI - #135
Merged
Merged
Conversation
MCP skills (SEP-2640) gaps from #131: - A subagent's skill-load question names that subagent (`origin`), the same provenance its code-execution questions carry: its registry binds the loading tools in its own name (`filter_by_enabled_as`). - A listing re-fetched after connect is written back to the on-disk manifest cache at once (`mcp_manifest::store_skills`), or forgotten if it is now `cacheScope: private`. - Remembered approvals persist with the session (`mcp_skill_approval` custom entries, journaled at run end) and are restored before each prompt. Keys embed the content fingerprint, so an unchanged skill is not asked about again after a restart and a changed one is. The serve test suite: - `serve_get_tree_since_works_from_the_busy_loop_mid_prompt` hung on main too, deterministically: the session-title request serve makes after a first run took a reply from the in-order mock model's script, shifting every later turn. The same cause failed or hung 48 serve tests, unseen because CI has excluded `binary(~serve)` since #39. The scripted mock servers now answer title requests themselves, off the script and out of the record; title tests match `SESSION_TITLE_MARKER` on a routed server. - Two real bugs it had been hiding: session listings tied on `updated_at` (one-second resolution) had no stable order, so `offset` paging could repeat a session (tie broken by id); and since #131 an `approve` in a session without `--approve` was acknowledged even when it matched no question (refused again, pointing at `--approve`). - CI runs the serve tests again, as an `agent-serve` shard. Lib tests that assert "no enclosing repo / context file" now use a temp root with no project above it (`test_support::isolated_tempdir`), so they pass with TMPDIR inside a checkout or under a CLAUDE.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk
…ee test ports Audit of 237d9a5: - F1: persisted MCP-skill approvals were forgeable. A model with write/edit tools can append an `mcp_skill_approval` entry to the user's session file (it knows the fingerprint from the loaded tag), and a client could `append_custom` one; an `execute` approval would then run unasked after a restart. Approvals are no longer persisted or restored: session-lifetime, in memory. A client's `append_custom` of a kind the agent writes itself (`mcp_task`, `mcp_task_result`, `mcp_skill_approval`: `session_store::HOST_CUSTOM_KINDS`) is refused. - F4: a typed `/skill:` is consent for that invocation only; it no longer approves the model's later loads of the skill. - F3: same-second session listings tie-break by id descending, so the newest (generated ids lead with their creation time) comes first. - F5: manifest-cache writes are read-modify-writes of one shared file; they now run under a lockfile with a per-writer temporary name. - F6: `serve_state_reporting` reads stdout with a deadline instead of hanging on a stall, and the scripted servers count title calls, so a change in title frequency shows (one per session, pinned). Test ports: `free_port()` released the port it picked and hoped the child bound it first; under parallel suites another process could take it. `serve` children now bind `--listen 127.0.0.1:0` and the test reads the port from serve's announcement (`spawn_listening`); a daemon whose own arguments name its port (an MCP Events callback URL) is handed a listener the test bound, by socket activation (`HeldPort`), kept across a restart; a "nothing listens here" port is held bound, never listening (`DeadPort`). `free_port` remains only for the gateway and nats-server. 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
…ired, one frame deadline (#139) Re-audit of #135: - The manifest cache's write lock was a create-new lockfile broken once older than 10 s: two waiters could both judge it stale, remove and recreate it, and both hold it. It is now the repo's session lock (`session_store::acquire_session_lock`) on `<manifest>.lock`, a kernel file lock that dies with its holder, so there is nothing to judge stale. The lock wait blocks, so `store`/`forget`/`store_skills` are async and run it with `spawn_blocking`; they were called from connection tasks on tokio workers. - `serve` adopted socket-activated descriptors with `LISTEN_PID` unset (`listenfd` only checks it when present), and the comment claimed the guard was enforced. Production now requires `LISTEN_PID` to name this process and refuses otherwise at startup. The test harness's `HeldPort` starts `serve` the way an activator does (a shell exports its own pid and execs in place), so the binary has no test-only path. - One frame deadline (`FRAME_DEADLINE`, 60 s) for every `serve` reader: `serve_frames` for stdout in all serve_* suites, `ws_next_frame` for WebSockets, and `skills_env::Serve`, which is now built on it. A stalled run fails promptly instead of at the runner's kill; `serve_harness_deadlines` holds every serve_* suite to it. Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
jaredLunde
added a commit
that referenced
this pull request
Oct 7, 2026
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
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
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
… refusals, runtime restore (#136) * agent: MCP Events follow-ups — panic exit, delivery by tag, permanent 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 * agent: MCP Events follow-ups — audit fixes (F1–F6), session panic containment, 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 * agent: one MCP message cap on every path — the events HTTP wire and every 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 * agent: an over-cap MCP Events push event is skipped and reported, not 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 * agent tests: follow-up tests on #135's race-free daemon ports Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk * agent: MCP Events re-audit fixes (N1–N7) — runtime TTL from creation, 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 * agent: a refused server request is answered with the current OAuth token (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 * agent: reconcile the one POST path with #140 — drop HttpClient's oauth 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 * agent tests: widen the steered-compaction test's window so full-suite 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 * agent: reconcile the one POST path with #142 — every server, two pinned 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 * agent tests: read the MCP Events suites' stdout through #141's guarded 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 --------- Co-authored-by: Claude Opus 5.5 <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.
What
This PR fixes the five follow-ups left after the MCP PRs (#130–#133) merged. Fixing item 4 turned up a bigger problem: 48
servetests were failing or hanging on main, and nobody saw it because CI hasn't run them since #39. That is fixed here too, and the serve tests run in CI again.A second commit (d72c375) fixes what the audit of the first (237d9a5) found. The biggest change: approvals are no longer persisted (F1, below), because a persisted approval could be forged. It also removes the
free_port()race from the agent test suite, now that the serve tests run in parallel in CI.Every fix has a test that fails without it. I checked this by reverting each fix in scripted mutation runs: 8/8 killed for the first commit, 8/8 killed for the audit fixes. For item 5, the proof is the lib suite run with TMPDIR inside the worktree: 7 failures before the fix, 0 after.
The five items
origin: "main"filter_by_enabled_as,Consent::Model(origin)). The question carries{agent, spawn_id}, the same as a code-execution question.mcp_skills_approval::a_subagents_skill_load_is_asked_about_in_its_own_nameServerSkills::refresh_if_duewrites it back right away (mcp_manifest::store_skills, which amends only the skills fields). If the listing is nowcacheScope: private, the server is forgotten instead.mcp_skills_listing::a_refreshed_listing_is_written_back_to_the_manifest_cache(a restart served from the cache offers the newly published skill),a_listing_that_turns_private_on_refresh_is_forgotten_by_the_cachemcp_skills_resume::an_approval_lasts_the_session_and_cannot_be_planted_on_disk_or_by_a_clientserve_get_tree_since_works_from_the_busy_loop_mid_prompthungserve_session_title::a_title_call_does_not_consume_the_conversation_script,the_scripted_servers_recognise_the_real_title_request; the original test now passesserve::tests::git_branch_reports_none_outside_a_git_repository, failed the same way)test_support::isolated_tempdir(), a temp root with no.git,CLAUDE.md,AGENTS.mdor.agentsabove it. Production code is unchanged.Item 4 in detail: the serve suite
servemakes a session-title request to the model. The in-order mock model treated that request as the next scripted conversation turn and handed it a reply. Every later turn then got the reply meant for the turn before it. A test waiting for atool_starthung, and others failed on the wrong reply or on a "transport error" once the script ran out.binary(~serve)test on main: 42 failed and 6 timed out, out of 348.binary(~serve)since feat(gateway): model routing with connect- and status-level failover #39, after those harnesses froze the runner.crates/test-support). The in-order servers answer title requests themselves, recognised bySESSION_TITLE_MARKER. Title requests no longer consume the script and are not recorded. A test that is actually about titles routes them explicitly, whichserve_session_titlenow does. A test pins the marker to the realSESSION_TITLE_SYSTEM, so rewording the prompt fails loudly instead of quietly breaking the harness again.updated_at, which has one-second resolution, had no stable order, sooffsetpaging could serve one session twice and skip another. Fixed with an id tie-break (session_store::by_recency, used by every listing sort), descending since d72c375 so that same-second sessions list newest first (F3). Proved byserve_list_paging::a_listing_is_one_page_and_reports_the_totalandsession_store::tests::same_second_sessions_list_newest_first_in_one_stable_order.--approve, anapprovethat matched no question was acknowledged. It is refused again, with a pointer to--approve. A matching MCP-skill answer still succeeds. Proved byserve_approval::a_malformed_approve_is_rejected_and_a_session_without_a_gate_says_so.agent-serveshard runs the serve tests again. The comment explaining why they had been excluded is updated.Audit fixes (d72c375)
write/editcan append anmcp_skill_approvalentry to it, and it knows the fingerprint because that is in the loaded skill's tag. A client could alsoappend_customone. Either way, after a restart anexecuteapproval would run code without asking.append_customof a kind the agent writes itself (session_store::HOST_CUSTOM_KINDS:mcp_task,mcp_task_result,mcp_skill_approval) is refused. ARCHITECTURE.md says why approvals are never persisted.mcp_skills_resume::an_approval_lasts_the_session_and_cannot_be_planted_on_disk_or_by_a_client(the audit's forgery probe, adopted). It checks four things: an approval holds for the rest of the session; a restart asks again; an approval written into the session file is ignored; a client-appended one is refused and grants nothing. Alsoserve_session_tree::serve_append_custom_refuses_the_kinds_the_agent_writes_itselfswitch_branchrestore_from_transcriptrebuilds only the acting window, never approvals.session_store::tests::same_second_sessions_list_newest_first_in_one_stable_order(kills both an ascending tie-break and no tie-break)/skill:became a remembered approval, so it pre-approved every later model-initiated load of that skillmcp_skills_approval::a_typed_skill_invocation_does_not_approve_the_models_later_loadsstore_skillsdid an unlocked read-modify-write of the manifest, through a fixedjson.tmpnamestore,forget,store_skills) runs under the repo's lockfile pattern (mcp_manifest::FileLock) and renames a temp file of its own into place.mcp_manifest::tests::concurrent_stores_and_skills_refreshes_lose_no_update(24 concurrent connects, then 24 concurrent refreshes; no update lost, no lock or temp file left behind)serve_state_reportinghung when title recognition brokecommon::frames_with_deadline), so a stall panics and says why. The scripted servers now count title calls (title_calls), and a test pins the count at one per session.serve_session_title::a_title_call_does_not_consume_the_conversation_scriptasserts exactly 1 title call over 3 turns and fails when a title is requested every turn.Test ports: the
free_port()racefree_port()picked a port, released it, and hoped the child would bind it before anything else did. Under parallel suites, another process sometimes took it in between. This caused the one flake in the first proof run:serve_service_grantgot HTTP 200 from another test's server where it expected a WebSocket upgrade. Eachservechild now gets its port in one of three ways, none of which leaves a gap:spawn_listening(most tests): passes--listen 127.0.0.1:0and reads the bound port back from the lineservealready prints (serve: websocket listening on …). The child's stderr is forwarded into the test's captured output, so a crash shows up in a failing test instead of being discarded.HeldPort(the MCP Events daemons): their callback URL names the port beforeservestarts. The test binds the listener and hands it toserveby socket activation (LISTEN_FDS, whichservealready supports for systemd), passed as stdin because the workspace forbidsunsafe. A daemon restarted on the same port gets the same socket.down()keeps the port reserved between the two daemons while refusing connections, as a stopped daemon would.DeadPort(a port where nothing listens): a socket that is bound but never listening. Connections are refused, and no other process can take the port.free_port()remains only for the gateway andnats-server, which can neither report a bound port nor adopt one. Themcp_apps_resilienceretry loop that worked around the race is gone.Load proof: three concurrent nextest runs, 8 threads each, of every affected binary (26 binaries, 155 tests), two rounds, while a separate process churned ephemeral ports (1,595,136 binds). Result: 930/930 passed.
Proof (local, d72c375)
beyond-ai-agenttest exceptexec_endpoint_live(lib, allmcp_*, allserve_*): 2484/2484 passed.-D warnings, agent + test-support + fleet-sim, all targets, code-mode),cargo fmt --checkanddprint check: clean.CI: green on the first attempt: all 21 jobs passed (run 37567072020), including agent-serve, agent-rest, agent-lib, agent-code-mode and the changed-lines mutants.
Proof (local, 237d9a5)
beyond-ai-agenttest exceptexec_endpoint_live(lib, allmcp_*, allserve_*) under nextest with 8 threads: 2479/2480 passed. The one failure,serve_service_grant::a_daemon_without_service_mode_ignores_the_grant_header, passed 3/3 on rerun. It is a race in thefree_port()helper: another parallel test can grab the port between the helper picking it andservebinding it, and here that test answered HTTP 200 where a WebSocket upgrade was expected. That helper race predates this PR; Fixed in d72c375 (see "Test ports" above).-D warnings, agent + test-support + fleet-sim, all targets, code-mode),cargo fmt --checkanddprint check: clean.CI: green on the first attempt: all 21 jobs passed (run 37563481230). That includes the new
agent-serveshard (7m26s), the first CI run of the serve tests since #39, alongside agent-lib, agent-rest and agent-code-mode.Notes
runnever asks: it denies unless--approve-mcp-skillsis set.🤖 Generated with Claude Code
https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk