Skip to content

MCP lifecycle hardening, dispatch-only tool sources, compose spec caching (0.10.1) - #9

Merged
danielkov merged 8 commits into
mainfrom
fix/mcp-api-hardening
Jul 13, 2026
Merged

MCP lifecycle hardening, dispatch-only tool sources, compose spec caching (0.10.1)#9
danielkov merged 8 commits into
mainfrom
fix/mcp-api-hardening

Conversation

@danielkov

@danielkov danielkov commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Hardens the MCP server lifecycle APIs around error paths and adds the primitives a lazy, on-demand connection strategy needs — driven by findings from an agent building exactly that on top of agentkit.

MCP lifecycle (agentkit-mcp)

  • disconnect_server is now state-consistent on close failure: tools are always unregistered and ServerDisconnected always emitted; the close error is returned as advisory. Previously a failed close left stale, still-callable adapters in the catalog with no retry path (reattempt returned UnknownServer).
  • unregister_server (new): fully removes a server — config, options, credentials — disconnecting first if live. Configs no longer accrete forever, and detached servers can't be resurrected by bulk connects.
  • connect_servers_settled(ids) (new): settle-connect a chosen subset in parallel. Idempotent per server (already-connected handles are returned untouched, no re-handshake), unknown ids settle as UnknownServer failures, duplicates are attempted once. connect_all_settled now delegates to it.
  • Replaced connections are gracefully closed on overwrite instead of silently dropped — plugs leaked Streamable HTTP server-side sessions on reconnect.
  • McpServerHandle::tool_names() (new): list namespaced tool names without building adapters or cloning schemas.

Tool error vocabulary

McpToolAdapter collapsed every failure into ToolError::ExecutionFailed(String). Now:

  • Io / Transport / TimeoutToolError::Unavailable — "the connection is unhealthy", so reconnect logic can match on the variant instead of string-sniffing.
  • JSON-RPC InvalidParams / InvalidRequest / ParseErrorToolError::InvalidInput — "fix your arguments", same feedback path as schema violations. Applies only when no McpErrorResponder synthesizes a result; responder behavior is untouched.
  • Server-side and custom codes stay ExecutionFailed.

Tool sources (agentkit-tools-core, agentkit-tool-compose)

  • Unadvertised<S> / ToolSource::unadvertised() (new): dispatch-only sources — specs() empty, get() passes through. Fills the gap between Filtered (hides dispatch too) and fully advertised sources.
  • Compose spec caching: the compose description (which renders every child's output schema) was rebuilt on every specs() call — once per model step. It's now memoized and invalidated by drain_catalog_events, the same signal the loop uses to refresh the model-visible catalog, so the cache can never be staler than the model's view. Children wrapped with unadvertised() stay callable from scripts without being enumerated in the description.

Tests & snapshots

  • New coverage: subset/idempotent settled connects, unregistration, disconnect against a dead server (via new HttpServerHandle::shutdown()), unadvertised dispatch end-to-end through a Lua script, spec-cache invalidation, and the error-mapping table.
  • Snapshot recordings now canonicalise JSON key order: agent-client-protocol enables serde_json's preserve_order workspace-wide, so serialization order depended on the test binary's build graph (deterministic permission_deny failure, flaky after_tool_result). Stale recordings refreshed.

Version bumped to 0.10.1.

Also fixes the pre-existing agentkit-tool-fs replace_move_and_delete_work failure (stale assertion: in-tool permission denials surface as Failed, not FailedBeforeInvocation).

Dispatch-only catalogs: specs() empty, get() passes through,
inner catalog events drained but not forwarded. Fills the gap
between Filtered (hides dispatch too) and fully advertised
sources — needed for compose children the model should invoke
through scripts without seeing individually.
- disconnect_server: unregister tools and emit ServerDisconnected
  even when close() fails; error is advisory. Previously a close
  failure left stale, still-callable adapters in the catalog with
  no retry path (UnknownServer on reattempt).
- unregister_server: new API to fully remove a server (config,
  options, auth) so detached servers stop accreting and cannot be
  resurrected by bulk connects.
- connect_servers_settled: settle a chosen subset in parallel,
  idempotent per server — building block for lazy on-demand
  connection strategies. connect_all_settled now delegates to it
  and no longer re-handshakes live servers.
- close replaced connections on overwrite instead of dropping
  them silently (leaked Streamable HTTP sessions).
- map IO/transport/timeout call failures to ToolError::Unavailable
  so callers can trigger reconnects without string-matching.
- McpServerHandle::tool_names(): list namespaced names without
  building adapters.
specs() re-rendered every child's output schema into the compose
description on each call — once per model step — even for frozen
catalogs. Cache the rendered spec and invalidate on
drain_catalog_events, the same signal the loop uses to refresh
the model-visible catalog, so the cache is never staler than the
model's view. Document Unadvertised as the supported way to keep
children dispatchable without enumerating them.
agent-client-protocol enables serde_json preserve_order for the
whole workspace, so map serialization order depends on the build
graph — snapshots recorded pre-ACP mismatched deterministically
(permission_deny) or flaked by binary (after_tool_result). Sort
object keys during normalisation and refresh recordings, which
also picks up the failure_kind metadata and explicit None fields
stale snapshots predated.
InvalidParams/InvalidRequest/ParseError mean the caller sent a bad
request — map them to ToolError::InvalidInput so the model is told
to fix its arguments instead of treating the tool as broken. Only
applies after the error responder declines (PassThrough) or when
none is installed; server-side and custom codes stay
ExecutionFailed.
Adds HttpServerHandle::shutdown so tests can kill the server midway
and pin that disconnect_server leaves clean state — connection
untracked, tools unregistered, ServerDisconnected emitted, config
retryable — regardless of the close outcome.
The read-before-write policy is enforced inside the tool body (it
needs resource state), so its denial surfaces as Failed —
FailedBeforeInvocation is reserved for checker denials that stop
the tool from ever starting. The assertion predated that split.
@danielkov
danielkov merged commit 9fda8b3 into main Jul 13, 2026
3 checks passed
@danielkov
danielkov deleted the fix/mcp-api-hardening branch July 13, 2026 14:26
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