Skip to content

Support mcp 2.x alongside 1.x for in-process SDK MCP servers - #1218

Merged
qing-ant merged 3 commits into
anthropics:mainfrom
maxisbey:mcp-dual-major
Aug 18, 2026
Merged

qing-ant merged 3 commits into
anthropics:mainfrom
maxisbey:mcp-dual-major

Conversation

@maxisbey

@maxisbey maxisbey commented Aug 16, 2026 •

Copy link
Copy Markdown
Contributor

What breaks on mcp 2.x

Nothing fails at import, but create_sdk_mcp_server and the bridge behind it were written against mcp 1.x internals. The factory registers tools with the @server.list_tools() / @server.call_tool() decorators, and Query._handle_sdk_mcp_request hand-routes JSON-RPC by looking handlers up in server.request_handlers[RequestClass], calling them with request objects, and reading result.root.* with camelCase attribute names. mcp 2 removed the decorators (tools are Server(..., on_list_tools=, on_call_tool=) callbacks taking (ctx, params)), keeps its handler table private and keyed by method string, drops the RootModel wrappers, and renames the Python attributes to snake_case. So on 2.x server construction raises, and where dispatch does get through, isError, image mimeType and maxResultSizeChars are silently lost. Patching each field read would leave two dispatch paths that behave differently per major (2.x would still lose argument validation and exception-to-error-result mapping, which the 1.x decorator provided implicitly), and it would keep the lossy re-encoding of tool results that already drops anything the factory does not know about.

What this does instead

McpSdkServerConfig["instance"] stays a real, runnable mcp.server.Server. The hand-rolled method switch (hardcoded initialize payload, tools/list and tools/call re-encoding, the notifications/initialized ack) is deleted and replaced with mcp's own in-memory transport, which is the same on both majors: one bridge per SDK server runs one session for the lifetime of the query: it opens create_client_server_memory_streams(), runs server.run(...) (started by the first message, closed with the query), feeds each mcp_message from the CLI onto the client stream, and has a single reader that resolves per-request waiters by JSON-RPC id. Every method the server implements is dispatched by mcp itself, and every message flows into that one session, including a repeated handshake: the CLI re-initializes every SDK server at the start of a turn while any of them is failed or pending and whenever the set changes, and mcp accepts a repeated initialize on a live connection, so a call in flight at that moment is undisturbed and a hand-built server's lifespan runs once. A session is over when server.run(...) returns (so a lifespan finishes its teardown); if that happens underneath the CLI because the server failed, later messages get a JSON-RPC error naming the server and the cause, and only a new initialize from the CLI (its own retry for a server it considers failed) starts it again. This mirrors the TypeScript Agent SDK, which creates one transport and calls connect() once per SDK server for the lifetime of the query, forwards every mcp_message including any later initialize into that one connection, and treats a server that fails as failed rather than restarting it.

The only code that knows which major is installed is a small private compat module: parsing a raw dict into the transport message type, constructing the tool server (decorators on 1.x, constructor callbacks on 2.x), and whether a served server can take a client's cancellation (any server on 2.x; on 1.x only the ones the SDK built). Tool execution semantics are owned by the SDK once, so factory tools behave identically on both: unknown tool, schema-invalid arguments (Input validation error: ..., handler not called), a malformed handler payload, or an exception in the handler all come back as isError results.

User-visible changes

  • Dependency becomes mcp>=1.23.0,<3.0.0 (was capped below 2.0.0); jsonschema>=4.20.0 is now declared (it was already installed by mcp on both majors). A fresh install resolves mcp 2.x. If you hand-build an mcp.server.Server with the 1.x decorator API and pass it as instance, pin mcp<2 until you port it.
  • initialize is answered by the server rather than a hardcoded payload: capabilities are the server's real ones ({"experimental": {}, "tools": {"listChanged": false}} for factory servers) and protocolVersion is negotiated instead of fixed at 2024-11-05. This is what makes hand-built servers work at full fidelity: resources, prompts, ping and any result content (audio, structuredContent) now reach the CLI verbatim, where previously only tools worked and results were re-encoded lossily. Traffic a server sends to the client is not forwarded yet: notifications (logging, progress, list_changed) are dropped, a ping is answered, and other requests (roots, sampling, elicitation) are answered with -32601 so the server's call fails at once instead of waiting. The TypeScript SDK does forward such traffic to the CLI as client-sent mcp_message control requests; adding that here is a small follow-up on top of this bridge.
  • claude_agent_sdk.ToolAnnotations is now a subclass of mcp.types.ToolAnnotations that declares maxResultSizeChars and accepts every hint in camelCase or snake_case on every mcp version (ToolAnnotations(readOnlyHint=True) and ToolAnnotations(read_only_hint=True) are the same thing and both type-check), which is what keeps the documented ToolAnnotations(maxResultSizeChars=N) working on 2.x (the base model ignores extras there) and stops read_only_hint=True from being silently kept as an extra on 1.x. No new keyword, no warning. isinstance against the mcp class still holds; it is a distinct class, so type(x) is mcp.types.ToolAnnotations and == against a plain mcp instance are False, and attribute access follows the installed mcp (.readOnlyHint on 1.x, .read_only_hint on 2.x). As a declared field maxResultSizeChars no longer appears inside the wire annotations on 1.x, only in _meta (which is what the CLI reads). Import ToolAnnotations from claude_agent_sdk if you rely on maxResultSizeChars: the plain mcp.types class drops it on 2.x.
  • A tool call the CLI abandons (notifications/cancelled, e.g. on interrupt) now cancels the running @tool function, and the request is settled on both majors. Previously the notification was rejected and the tool ran to completion. The exception is a hand-built Server on mcp 1.x: 1.x stops the whole server when a handler answers an already-cancelled call, and an arbitrary handler cannot be made to end otherwise, so there the cancellation is not passed on and the tool runs to completion exactly as it does today.
  • Success results always carry isError: false; a server with no tools answers tools/list with [] instead of -32601; unknown methods get mcp's own error codes; a request that reuses an id still in flight is refused with a JSON-RPC error instead of being forwarded.
  • disconnect() cancels in-flight tool calls: the bridge cancels them through mcp itself before closing the connection (so this is prompt on mcp 1.26 and older too, which do not cancel running handlers when a connection closes), then waits for the server; a tool blocked outside the event loop, or a hand-built server still starting its lifespan, is given up on after a 5 s grace per server. The exception is a hand-built Server on mcp 1.x with a tool still running (see cancellation above): that waits up to the grace.

Tests and CI

tests/test_sdk_mcp_integration.py is rewritten to drive the JSON-RPC surface the CLI uses (initialize first, then tools/list / tools/call), with no reach into mcp internals, and covers: real serverInfo/capabilities, notification handling, the camelCase wire format with _meta carrying maxResultSizeChars from the annotations (no warning raised), golden tools/call outputs, concurrent calls on one server, re-initialize, cancellation, close with calls in flight (including one blocked in a thread), a hand-built lowlevel Server served verbatim, a repeated handshake served by the same session with an in-flight call surviving it, a failed server answered with a named error and started again only by a new initialize, a server-to-client request refused immediately and a ping answered, a lifespan running once with its teardown completing, cancellation on 1.x (a @tool that swallows it does not take the server down; a hand-built server's threaded tool runs to completion), shutdown with a call in flight (prompt and quiet on the 1.23 floor; bounded by the grace with a tool stuck in a thread or a lifespan still starting; a request id stays reserved until the server answers it), and both annotation spellings. The suite passes locally on mcp 1.23.0, 1.29.0 and 2.0.0 (1442 passed, asyncio and trio), with ruff and strict mypy clean on both majors. test.yml gains a test-mcp-v1-floor job that pins mcp==1.23.0, asserts it, type-checks and runs the suite; the existing matrix covers the newest mcp. Against the real CLI, e2e-tests/test_sdk_mcp_tools.py passes on all three versions (4/4 each), and a longer manual drive on 1.23.0, 1.29.0 and 2.0.0 covered a factory server (text, image, a raising tool, maxResultSizeChars through annotations), a hand-built lowlevel Server whose audio and structuredContent results reach the model, an interrupt mid-call (tool coroutine cancelled, turn ends at once), disconnect, a failed sibling SDK server making the CLI re-handshake a healthy one every turn (served by the same session), and an interrupt during a hand-built 1.x server's threaded tool (server keeps serving). test-e2e and test-examples skip on fork PRs, so a maintainer run of those would be welcome.

Not in this PR

Forwarding server-initiated requests/notifications to the CLI; accepting FastMCP / MCPServer objects as instance (unsupported today as well); audio / structured-content additions to the @tool dict format (#650 and #738 become small pass-throughs on top of this); any change to control-protocol framing; CHANGELOG.md (left for the release).

Closes #1150

AI Disclaimer

@codecov-commenter

codecov-commenter commented Aug 16, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 97.48428% with 8 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (main@d416278). Learn more about missing BASE report.

Files with missing lines Patch % Lines
src/claude_agent_sdk/_internal/sdk_mcp_bridge.py 96.08% 7 Missing ⚠️
src/claude_agent_sdk/_internal/query.py 90.00% 1 Missing ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@           Coverage Diff           @@
##             main    #1218   +/-   ##
=======================================
  Coverage        ?   91.53%           
=======================================
  Files           ?       25           
  Lines           ?     4522           
  Branches        ?        0           
=======================================
  Hits            ?     4139           
  Misses          ?      383           
  Partials        ?        0           
Flag Coverage Δ
mcp-v1-floor 91.13% <92.45%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@maxisbey
maxisbey marked this pull request as ready for review August 16, 2026 17:46
@maxisbey
maxisbey marked this pull request as draft August 16, 2026 17:47
@maxisbey
maxisbey marked this pull request as ready for review August 17, 2026 19:43
create_sdk_mcp_server and the bridge behind it were written against mcp
1.x internals: tools registered with the @server.list_tools() /
@server.call_tool() decorators, and JSON-RPC hand-routed by looking
handlers up in server.request_handlers, calling them with request objects
and reading result.root.* with camelCase attribute names. mcp 2 removed
the decorators, keeps its handler table private, drops the RootModel
wrappers and renames the attributes, so on 2.x server construction
raised and isError, image mimeType and maxResultSizeChars were silently
lost.

Instead of shimming each field, serve in-process servers through mcp's
own in-memory transport, which is the same on both majors. One bridge
per SDK server runs one session for the lifetime of the query:
create_client_server_memory_streams() plus Server.run(), each mcp_message
from the CLI fed onto the client stream, and a single reader resolving
per-request waiters by JSON-RPC id. Every method the server implements
(initialize with its real capabilities, tools, resources, prompts, ping,
cancellation) is dispatched by mcp itself, a repeated handshake from the
CLI flows into the same session, and a hand-built lowlevel Server passed
as McpSdkServerConfig instance is served verbatim. A server whose run()
dies is answered with an error naming it and the cause until the CLI
initializes it again. Requests a server sends to the client are answered
(ping) or refused (-32601) rather than left hanging; notifications are
dropped for now.

The only version-specific code is a small compat module: parsing a raw
dict into the transport message, building the tool server (decorators on
1.x, constructor callbacks on 2.x), and whether a server can take a
client's cancellation (on 1.x only servers built here, since 1.x stops
the whole server when a handler answers an already-cancelled call). Tool
semantics are owned by the SDK once, so unknown tools, schema-invalid
arguments, unusable schemas and handler exceptions are isError results
on every version. claude_agent_sdk.ToolAnnotations becomes a subclass
that keeps unknown fields so ToolAnnotations(maxResultSizeChars=N) keeps
working on 2.x.

Dependency: mcp>=1.23.0,<3.0.0, plus jsonschema which mcp already
installs. CI gains a job that pins the 1.23 floor, type-checks and runs
the suite against it, and both jobs upload coverage. The SDK MCP tests
now drive the JSON-RPC surface the CLI uses instead of mcp internals.
ashwin-ant
ashwin-ant previously approved these changes Aug 17, 2026

@ashwin-ant ashwin-ant left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is careful work. What I checked before approving:

Full suite on mcp 1.23.0, 1.29.0 and 2.0.0: 1432 passed / 5 skipped, asyncio and trio, stable across repeat runs. tests/test_sdk_mcp_integration.py on every intermediate release 1.24 to 1.28: 132/132 each. mypy strict and ruff clean on both majors. Every mcp API the compat module touches checked against the installed sources for each version (call_tool(validate_input=) is in 1.23.0, jsonrpc_message_adapter is absent on all 1.x in range, on_request_unanswered exists on 2.0.0). Premise confirmed: main on mcp 2.0.0 fails in create_sdk_mcp_server, and a fresh unpinned install already resolves 2.0.0 today. Drove a factory server against the real CLI on all three versions (text result, raising tool to is_error, maxResultSizeChars via _meta, interrupt cancelling a running @tool), output identical to main on 1.x. Factory-server behavior is unchanged: same accept/reject decisions and error strings for dict, TypedDict and raw-schema inputs, since validation and exception mapping already ran through the 1.x decorator.

I also tried to break the bridge (concurrent calls, cancel, re-initialize mid-call, crash then restart only on initialize, close with calls in flight, close before any message) on both backends and could not. The _AsyncioTaskHandle.wait() change is an improvement, not a regression.

Inline comments are all non-blocking. Two doc notes: "only a new initialize from the CLI (its own retry for a server it considers failed) starts it again" holds when the handshake fails; a server whose run() dies after a good handshake stays down for the session unless something else triggers a re-init (same as the TS SDK). And since a fresh install will now resolve mcp 2.x, I will cut this as a minor bump with a changelog line telling hand-built Server users to pin mcp<2 until they port.

Approving. Follow-ups can go in a separate PR.

# been told to stop. Servers stop within milliseconds; only a tool that does
# not react to cancellation (one blocked in a thread, say) can hold one open,
# and that is not worth hanging a reconnect or shutdown on.
SHUTDOWN_GRACE_SECONDS = 5.0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking. With a tool sitting in anyio.to_thread.run_sync, aclose() takes the full 5s on 1.23, 1.29 and 2.0. On mcp <=1.26 a plain await anyio.sleep(30) tool does the same, since mcp only started cancelling in-flight handlers on transport close in 1.27. Query.close() awaits bridges serially inside its shield, so N stuck servers is up to N x 5s on disconnect(). Documented in the PR body, so fine as-is, but a shorter default or a note on the disconnect() docstring would help.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a sentence to disconnect() and Query.close() in 65770fb. With the pre-close cancellation from qing's thread only a tool blocked outside the event loop (or a lifespan still starting) reaches the grace now, so I kept 5 s.

await self._to_server.aclose()
with anyio.move_on_after(SHUTDOWN_GRACE_SECONDS) as scope:
await self._task.wait()
if scope.cancelled_caught:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On mcp 2.x this unblocks the closer, but a concurrent handle() caller on the same session stays pending until the stuck thread releases (mcp 2's runner closes the write stream only after the dispatcher returns; 1.x closes it when the read stream ends, so 1.x callers fail right away). Masked in Query.close() because control-request tasks are cancelled first. Calling _fail_pending() here would make the give-up semantics the same on both majors.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 65770fb as part of the aclose() change: waiters are failed once the grace is up.


def __init__(self, table: dict[Any, _PendingResponse], request_id: Any) -> None:
if request_id in table:
raise Exception(f"Request id {request_id!r} is already in flight")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is reachable in practice. Each time the CLI re-initializes SDK servers it starts a fresh MCP client, so ids restart at 0. If the old client still had a call in flight on our side (hand-built 1.x server that does not get the cancellation, thread-blocked tool, or a call the CLI backgrounded), the new request with the same id is refused. Reproduced at bridge level on all three versions. Narrow, and better than the TS SDK (which would silently overwrite), so not blocking. Scoping the pending table per initialize generation would close it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed it's reachable. Scoping the table per initialize generation would not be enough on its own, though: responses only carry the id, and mcp itself overwrites its responder for a duplicate id (both majors then emit two responses with that id), so two live requests with the same id cannot be told apart once forwarded. What I have in mind instead is for the bridge to assign its own ids: remap the CLI's id to a session-unique one on the way in (and in notifications/cancelled), and back on the way out. That makes the collision impossible by construction and removes the refuse-duplicates path altogether. I'd do it as a follow-up rather than grow this PR.

AI Disclaimer

T = TypeVar("T")


class ToolAnnotations(_McpToolAnnotations, extra="allow"):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this subclass exists to keep ToolAnnotations(maxResultSizeChars=N) working and the docstrings now advertise it, consider declaring maxResultSizeChars: int | None = None. Strict mypy rejects the documented call on every mcp version today (same as main). _build_meta uses getattr, so it would still reach _meta. For the record: type(x) is mcp.types.ToolAnnotations and == against a plain mcp instance flip to False. Nothing in the SDK relies on that and the export path is unchanged.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 65770fb, and ad03391 makes both spellings of every hint work on both majors (see qing's thread). One side effect worth noting: as a declared field, maxResultSizeChars no longer leaks into the wire annotations object on 1.x; it is only in _meta, which is all the CLI reads.

Comment thread tests/test_sdk_mcp_integration.py Outdated
return {"content": [{"type": "text", "text": "ok"}]}
greeting = await client.call_tool("srv", "greet_user", {"name": "Alice"})
assert greeting["content"] == [{"type": "text", "text": "Hello, Alice!"}]
assert not greeting.get("isError", False)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR body lists "success results always carry isError: false", but the success-path assertions here (and at 714, 1179) use .get("isError", False), which passes whether the key is present or not. I stripped isError at the dump boundary and the suite still passed 132/132. One assert greeting["isError"] is False would pin it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pinned in 65770fb (the three sites you named).

@qing-ant qing-ant left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — the design is the right one (it's what the old TODO in _handle_sdk_mcp_request was asking for, and it matches how the TS SDK does it: one transport + one connect() per SDK server per query). Two of us worked through it together on the maintainer side: read the bridge/compat code closely, then drove it against the real CLI on all three mcp versions and diffed the wire behaviour against main. Summary of what we did successfully test, so it's on record since test-e2e / test-examples skip on fork PRs:

mcp 1.23.0 (floor) mcp 1.29.0 mcp 2.0.0
ruff / ruff format / mypy strict clean clean clean
pytest tests/ — py3.13 (asyncio + trio) 1432 passed 1432 passed 1432 passed
pytest tests/ — py3.10 1431 passed – 1431 passed
full e2e-tests/ suite vs CLI 2.1.233 (the currently bundled version) 36 passed 36 passed 36 passed
examples/mcp_calculator.py, examples/streaming_mode_trio.py ok – ok
extra real-CLI scenarios (below) all pass all pass all pass (and pass on main+1.29 as the baseline)

The extra real-CLI scenarios, each run on all three versions: a factory server with text / image / raising tool / is_error / typed args / a 120k-char result kept inline via ToolAnnotations(maxResultSizeChars=…) (plus a control run showing it spills to a preview without the annotation); a hand-built lowlevel Server (built with whichever major is installed) with prompts + structuredContent; three turns on one ClaudeSDKClient with toggle_mcp_server in between; a sibling SDK server whose lifespan raises (the CLI re-handshakes it every turn while the healthy server keeps working from the same session); interrupt() mid-call; disconnect() with an async tool in flight and with a tool blocked in a worker thread; PreToolUse/PostToolUse hooks + can_use_tool interleaved with the MCP call; a subagent calling the SDK tool; four genuinely concurrent tools/calls (peak concurrency 4); and the one-shot query() path. No leaked tasks at exit on any version. At unit level we also ran a 600-way concurrent call/ping/list stress with periodic re-initialize, 40 concurrent tools each sending log notifications + a ping + a roots/list to the client, and the races we could think of (two concurrent first messages, handle() after/racing aclose(), a message in the crash window, re-initialize with a call in flight, int vs str ids) on asyncio and trio × 3 versions — all fine.

Confirmed improvements over main, on the wire: interrupt() now actually cancels the @tool coroutine and settles the call (on main the notifications/cancelled is rejected with -32601 and the tool keeps running after the turn ends); a hand-built server's structuredContent reaches the model verbatim (re-encoded to text and dropped on main); real capabilities + negotiated protocolVersion. And main + mcp==2.0.0 does hard-fail exactly as described ('Server' object has no attribute 'list_tools', 40/68 of the old integration tests fail), so the widened range genuinely needs this layer.

Three suggestions inline — two small shutdown-path changes we verified locally (lint/mypy/full unit suite green on 1.23.0/1.29.0/2.0.0 with them applied, plus the real-CLI scenarios above), and one API-shape question on ToolAnnotations. None of them block; everything else (naming the failure cause in the "session closed"/"stopped" errors, closing bridges concurrently in _close_impl, hasattr-based MCP_MAJOR) is nit-level and can be follow-ups. For the release notes we'll want to call out the intended behaviour deltas: real initialize result, cancellation now delivered, isError: false on success, hand-built servers' lifespans now run once per query, and mcp's own error codes for unknown methods.

Comment on lines +293 to +306
await self._ready.wait()
if self._to_server is not None:
await self._to_server.aclose()
with anyio.move_on_after(SHUTDOWN_GRACE_SECONDS) as scope:
await self._task.wait()
if scope.cancelled_caught:
logger.warning(
"SDK MCP server %r did not stop within %ss of being closed "
"(a tool is probably blocked outside the event loop); "
"no longer waiting for it",
self._name,
SHUTDOWN_GRACE_SECONDS,
)
self._task.cancel()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion 1 (shutdown with a call in flight, bites on the mcp ≤ 1.26 floor). aclose() closes the client→server stream and waits for Server.run() to return. 1.27+/2.x cancel in-flight handlers at that point, but 1.23–1.26 wait for them, so with the real CLI on 1.23.0 disconnect() with a perfectly cancellable sleeping @tool in flight took 7.2 s (vs 1.3 s on main, ~1 s on 1.29/2.0) and logged the "probably blocked outside the event loop" warning; and if the tool finishes inside the grace it answers into the stream mcp already closed, Server.run dies with ClosedResourceError, and _run logs a ~60-line ERROR traceback (the tool also runs to completion instead of being cancelled). Cancelling what's still pending through mcp's own path before closing the stream fixes both and keeps the lifespan-teardown wait intact.

Suggestion 2 (2.x, latent). Past the grace, a handler stuck in to_thread.run_sync keeps mcp 2's dispatcher — and so the server's write stream — open, so the reader never ends, _output_ended/finally never run, and a handle() still awaiting that call hangs until the thread returns. It's masked in Query.close() today only because child tasks are cancelled first; failing the waiters when the grace expires makes aclose() self-contained.

With both applied (plus the _PendingResponse.wait() change below, which this depends on), real-CLI disconnect() with a sleeping tool in flight is 1.06 s / 0.90 s / 1.12 s on 1.23.0 / 1.29.0 / 2.0.0 with no warning, the tool gets CancelledError, and the thread-blocked case is unchanged (~6 s with the — now accurate — warning).

Suggested change
await self._ready.wait()
if self._to_server is not None:
await self._to_server.aclose()
with anyio.move_on_after(SHUTDOWN_GRACE_SECONDS) as scope:
await self._task.wait()
if scope.cancelled_caught:
logger.warning(
"SDK MCP server %r did not stop within %ss of being closed "
"(a tool is probably blocked outside the event loop); "
"no longer waiting for it",
self._name,
SHUTDOWN_GRACE_SECONDS,
)
self._task.cancel()
await self._ready.wait()
if self._to_server is not None:
# Cancel whatever is still in flight through mcp's own path first.
# mcp releases that do not cancel running handlers when the
# connection closes (< 1.27) would otherwise either hold the
# session open for the whole grace period or, if the tool finishes
# inside it, answer into a stream that is already closed.
if self._pending and can_cancel_requests(self._server):
for request_id in list(self._pending):
with suppress(Exception):
await self.submit(
{
"jsonrpc": "2.0",
"method": "notifications/cancelled",
"params": {
"requestId": request_id,
"reason": "connection closing",
},
}
)
await self._to_server.aclose()
with anyio.move_on_after(SHUTDOWN_GRACE_SECONDS) as scope:
await self._task.wait()
if scope.cancelled_caught:
logger.warning(
"SDK MCP server %r did not stop within %ss of being closed "
"(a tool is probably blocked outside the event loop); "
"no longer waiting for it",
self._name,
SHUTDOWN_GRACE_SECONDS,
)
self._task.cancel()
# The task may take a while to actually unwind (a handler blocked
# in a thread keeps it alive), so do not leave callers waiting on
# responses that can no longer arrive.
self._fail_pending("session closed")

A unit test that closes with an ordinary anyio.sleep tool in flight on the floor version and asserts (a) no WARNING/ERROR records and (b) aclose() well under the grace would pin this in the test-mcp-v1-floor job; the current tests only cover "tool sleeps past a patched 0.5 s grace" and don't look at logs, which is why CI is green.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 65770fb together with the wait() change, plus the floor test (no WARNING/ERROR, aclose() well under the grace, tool cancelled); it fails on 1.23.0 without the pre-close cancel. One adjustment in ad03391: the cancel sends share the grace deadline with the wait, since a server that is not reading yet (lifespan startup) cannot take them, and the pending handshake itself is skipped.

Comment on lines +86 to +95
async def wait(self) -> dict[str, Any]:
try:
await self._event.wait()
finally:
if self._table.get(self._request_id) is self:
del self._table[self._request_id]
assert self._outcome is not None
if isinstance(self._outcome, Exception):
raise self._outcome
return self._outcome

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Companion to the aclose() suggestion: freeing the slot when the waiter gives up means that by the time Query._close_impl reaches bridge.aclose() (it cancels the child tasks that own these waiters first) _pending is already empty, so there is nothing left to cancel and 1.23 falls back to the grace wait — we hit exactly that when first trying suggestion 1 against the real CLI. It also means the docstring invariant above doesn't quite hold: after a waiter is cancelled the server still owns the request, and a new request reusing the id would receive the old one's response (reproducible at unit level on 1.29/2.0, though nothing in the CLI triggers it today). Keeping the id reserved until a response or session failure resolves it fixes both; resolve() already removes the entry, and _fail_pending covers anything the server never answers.

Suggested change
async def wait(self) -> dict[str, Any]:
try:
await self._event.wait()
finally:
if self._table.get(self._request_id) is self:
del self._table[self._request_id]
assert self._outcome is not None
if isinstance(self._outcome, Exception):
raise self._outcome
return self._outcome
async def wait(self) -> dict[str, Any]:
# The slot stays in the table even if this waiter is cancelled: the
# server still owns the request, so its id must stay reserved (a new
# request reusing it would otherwise receive this one's response) and
# the session must still be able to cancel it when it closes.
await self._event.wait()
assert self._outcome is not None
if isinstance(self._outcome, Exception):
raise self._outcome
return self._outcome

(The class docstring's "or whoever is waiting gives up" would go too.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 65770fb (docstring adjusted to match).

Comment thread src/claude_agent_sdk/__init__.py Outdated
Comment on lines +167 to +179
class ToolAnnotations(_McpToolAnnotations, extra="allow"):
"""Hints about a tool's behavior (``readOnlyHint``, ``destructiveHint``, ...).

This is ``mcp.types.ToolAnnotations`` with unknown fields preserved, so
``ToolAnnotations(maxResultSizeChars=N)`` (the size up to which Claude Code
keeps a large tool result inline instead of saving it to a file) keeps
working on every supported mcp version; mcp 2.x's own class drops fields
it does not know. Either class is accepted wherever the SDK takes
annotations.

The camelCase keyword arguments work at runtime on every mcp version. mcp
2.x names the Python attributes in snake_case (``read_only_hint``), which
is also the spelling type checkers expect there.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion 3 (API shape — would like to settle this before it ships, here or in an immediate follow-up). Since a fresh install now resolves mcp 2.x, a few things about the annotation surface change under users even though nothing regresses at runtime for existing camelCase code:

  • With 2.x installed, mypy rejects the camelCase keywords our README/docstrings (including the example just below) use: ToolAnnotations(readOnlyHint=True) → Unexpected keyword argument "readOnlyHint"; did you mean "read_only_hint"?. It works at runtime via the alias, but ann.readOnlyHint attribute access is an AttributeError on 2.x.
  • The snake_case spelling that type-checks on 2.x is silently swallowed on 1.x by this extra="allow" subclass: ToolAnnotations(read_only_hint=True) on 1.29 dumps as {'read_only_hint': True} and the hint is lost. So there's no single spelling that both type-checks on 2.x and works on 1.x.
  • maxResultSizeChars= — the documented reason this subclass exists — is still a mypy call-arg error on both majors (as on main).
  • SdkMcpTool.annotations / tool(annotations=) are now typed as the mcp base class, so x: claude_agent_sdk.ToolAnnotations | None = my_tool.annotations newly fails mypy, and isinstance(mcp.types.ToolAnnotations(...), claude_agent_sdk.ToolAnnotations) flips to False.

Given the SDK owns this class now, I'd rather it present one stable surface across majors: declare maxResultSizeChars: int | None = None as a real field, and accept both spellings on both majors (normalise snake→camel on 1.x; 2.x's aliases already take camelCase) behind a TYPE_CHECKING-only __init__ signature so either spelling type-checks — or, minimally, reject unknown snake_case names on 1.x instead of storing them as extras, and add a README note about which spelling to use. Happy to take this as a follow-up PR if you'd prefer to keep this one scoped, but wanted your view as the mcp maintainer on which way the SDK should lean.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in this PR (ad03391), along the lines you sketched: claude_agent_sdk.ToolAnnotations declares maxResultSizeChars, and takes every hint as camelCase or snake_case on every mcp version by folding the snake_case names into the wire names before validation (field names on 1.x, aliases on 2.x; the wire name wins if both are given), with a TYPE_CHECKING-only __init__ listing both spellings so either type-checks on either major (checked with mypy --strict against 1.29 and 2.0). So read_only_hint=True is no longer silently kept as an extra on 1.x, and the README's camelCase examples stay valid everywhere.

Two things I left as they are, deliberately: attribute access still follows the installed mcp (.readOnlyHint on 1.x, .read_only_hint on 2.x), and tool(annotations=) / SdkMcpTool.annotations stay typed as the mcp base class, since narrowing them to the SDK class would newly reject a plain mcp.types.ToolAnnotations for type-checker users (and the union with the base is just the base). Happy to revisit either if you'd prefer otherwise.

AI Disclaimer

…served

Closing the input alone leaves mcp releases older than 1.27 waiting on
running handlers, so shutting down with a call in flight took the whole
grace period, and a handler finishing inside it answered into a closed
stream. Cancelling what is still pending through mcp's own path first
makes close prompt and quiet on every version, and waiters are failed
once the grace period is up so nothing keeps waiting on a handler stuck
in a thread. A request's id now stays reserved until the server answers
it even if the caller stopped waiting, since the server still owns it.

Also declare maxResultSizeChars on ToolAnnotations so the documented
call type-checks, and pin isError: false in the success-path tests.
Sending the pre-close cancellations could itself wait forever on a
server that is not reading its input (one still starting its lifespan
with the handshake pending), which made shutdown unbounded again. The
courtesy cancels and the wait for the task now share the one grace
period, and the pending initialize is never cancelled.

claude_agent_sdk.ToolAnnotations takes every hint in camelCase or
snake_case on every mcp version and both spellings type-check, so there
is one class that behaves the same on 1.x and 2.x; mcp's own differs
(1.x only knows camelCase, 2.x prefers snake_case, and snake_case names
were silently kept as extras on 1.x).
@maxisbey

Copy link
Copy Markdown
Contributor Author

Thanks both, this was a thorough pass. Two commits on top apply it.

65770fb: aclose() cancels in-flight calls through mcp before closing the input and fails waiters once the grace is up (qing's shutdown suggestions, with the wait() change they depend on), plus the floor-version test (it fails on 1.23.0 without the pre-close cancel); maxResultSizeChars is declared on ToolAnnotations; the success-path tests pin isError is False; and the docstrings no longer oversell recovery (only a failed handshake gets the CLI's per-turn initialize retry; a server that dies after a good handshake stays down for the query) and mention the per-server grace on disconnect().

ad03391: those pre-close cancels now come out of the same grace budget as the wait (sending them could itself block on a server that is not reading its input yet, e.g. one still starting its lifespan with the handshake pending), the pending initialize is never cancelled, and ToolAnnotations accepts every hint in either spelling on every mcp version (details in the thread).

Real CLI on 1.23.0 and 2.0.0: disconnect() with a sleeping @tool in flight is back to the ~2.5 s the subprocess teardown takes, the tool is cancelled and nothing is logged; with a hand-built server stuck in lifespan startup it is bounded by the grace. Follow-ups I'll open separately: bridge-assigned request ids and forwarding server-to-client traffic.

AI Disclaimer

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.

Support MCP Python SDK v2 for in-process SDK MCP servers

4 participants