Repository navigation
feat: add ClaudeSDKClient.set_mcp_servers() - #1360
omgupta-duplo wants to merge 3 commits into
Conversation
Expose the CLI's `mcp_set_servers` control request so a running session can swap its MCP server configuration without restarting. The motivating case is an OAuth-authenticated HTTP MCP server whose bearer expires mid-session: `.mcp.json` and the subprocess env are both fixed at spawn, so a rotated token cannot otherwise reach a connected server. `mcp_set_servers` is authoritative -- it replaces the whole dynamically-managed server set, and the in-process SDK servers registered from `ClaudeAgentOptions.mcp_servers` are part of that set. A caller who pushes only their external server therefore loses every SDK tool for the rest of the session. `Query.set_mcp_servers` pre-seeds the payload with the already registered SDK servers (instance-stripped, the same shape sent at init) so that cannot happen, and callers do not have to know to re-list them. An entry carrying an `instance` is registered with an `SdkMcpBridge` so its tools route back in-process, and the `instance` is stripped before the config reaches the CLI, exactly as at startup. Caller entries win on a name collision. Mirrors the shape of `reconnect_mcp_server` / `toggle_mcp_server`.
|
On exact head
A later authoritative A regression can be two calls: replace SDK Could the local SDK registry / bridge state be updated to match caller replacements only after a successful |
…epts Two state bugs in set_mcp_servers, both found in review. The payload is pre-seeded from self.sdk_mcp_servers so an authoritative replace cannot drop the in-process SDK servers. But when a caller entry took the name of a registered SDK server, only the payload reflected it -- the server stayed in sdk_mcp_servers and _sdk_mcp_bridges. The next call pre-seeded from that stale entry and silently resurrected the replaced server, overwriting whatever had taken its name. A caller entry that is not an SDK config now deregisters the server it replaces. An SDK entry sent without an instance is a re-declaration, not a replacement, so it keeps its registration. The registry and bridges were also written before the control request was awaited, so a rejected or timed-out request left this process routing tools for a server the CLI never received. Both the removals and the registrations are now staged and applied only after _send_control_request returns. Covers the two-call regression and the failure path with tests; the mock transport can now answer chosen subtypes with an error.
|
Good catch -- both confirmed and fixed in e438e0d. The resurrection bug. You're right that only the wire payload reflected the replacement. A caller entry that is not an SDK config now also deregisters whatever it replaced: else:
# A non-SDK entry takes over this name for the session; any
# in-process server registered under it is gone with it.
removed.add(name)
servers_for_cli[name] = configso the next pre-seed can't re-emit it. One case I deliberately left alone: an SDK entry sent without an Staging until the response. Agreed, and done for the same reason you give. Tests. Added the two-call regression you described -- replace SDK Full file is green (76 passed), plus ruff and mypy. |
A bridge taken out of _sdk_mcp_bridges is no longer reached by the aclose() loop in _close_impl, and its session's task is spawned detached rather than tracked in _child_tasks, so nothing else reaps it either: aclose() is the only stop signal there is. Two paths dropped a bridge without it -- the removal that reconciles a replaced name, and the re-registration that overwrites a name with a new instance. Once a bridge has served a message it owns a live session: a task running Server.run over a stream pair. Abandoning one leaves that task running for the rest of the process, never unwinds the server's lifespan, and never fails the callers waiting on its pending responses. Collect the outgoing bridges while applying the new set and close them after the swap, so traffic reaches the new bridge while the old one winds down -- the order SdkMcpBridge.handle already uses when it replaces a finished session. A failure to wind down is logged rather than raised: the CLI has applied the new set by then, and that call should not fail.
|
One more in 658bb0d, from re-reading the lifecycle around the fix above -- not something you raised, and half of it predates your review.
Once a bridge has served a message it owns a live session (a task running Outgoing bridges are now collected while the new set is applied and closed after the swap, so traffic reaches the new bridge while the old one winds down -- the order Two tests cover it (replaced by an external server, and re-registered with a new instance); both fail on e438e0d and pass here. Full suite green: 1603 passed, 6 skipped, plus ruff and mypy. Two things I noticed but deliberately left alone, happy to follow up if you'd rather they were part of this:
|
Summary
Adds
ClaudeSDKClient.set_mcp_servers(servers), exposing the CLI'smcp_set_serverscontrol request so a running session can change its MCP server configuration without restarting.The motivating case is an OAuth-authenticated HTTP MCP server whose bearer token expires mid-session.
.mcp.jsonand the subprocess environment are both fixed when the CLI is spawned, so a freshly minted token has no way to reach an already-connected server — today the only options are to end the turn or restart the client.reconnect_mcp_serverre-reads the config file, but that requires writing the literal bearer to disk, which is not an option when the session directory is on shared storage.mcp_set_serverscarries the new headers in-memory, over the control channel.The part worth reviewing
mcp_set_serversis authoritative: it replaces the whole dynamically-managed server set. The in-process SDK servers registered fromClaudeAgentOptions.mcp_serversare part of that set, so a caller who pushes only their external server silently loses every SDK tool for the rest of the session — the CLI's own response reports them underremoved.Rather than make that a documented footgun,
Query.set_mcp_serverspre-seeds the payload with the already-registered SDK servers, serialized instance-stripped as{"type": "sdk", "name": ...}— the same shapesubprocess_clisends at startup. So:instanceis registered with anSdkMcpBridge, so a server added mid-session routes its tool calls back in-process; theinstanceis stripped before the config reaches the CLI, exactly as at startup.Happy to change the preserve-by-default behavior to an explicit opt-in flag if you'd rather the method stay a thin pass-through — the trade-off is that the authoritative-replace semantics then have to be learned from a failure.
Test plan
Four tests in
tests/test_streaming_client.py, mirroring the existingreconnect_mcp_server/toggle_mcp_servercontrol-request tests:serverspayloadsdk_mcp_serversand itsinstancedoes not reach the wireconnect()raisesCLIConnectionErrorThe underlying control request was also verified against a live CLI (2.1.251): a push naming only the external server returns
{"added": ["carbonarc"], "removed": ["agent_tools"]}and the SDK tool stops existing, while re-including the SDK entry returnsremoved: []and the tool keeps working.