Repository navigation
Support mcp 2.x alongside 1.x for in-process SDK MCP servers - #1218
Conversation
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1218 +/- ##
=======================================
Coverage ? 91.53%
=======================================
Files ? 25
Lines ? 4522
Branches ? 0
=======================================
Hits ? 4139
Misses ? 383
Partials ? 0
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
fc25f47 to
f948088
Compare
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.
f948088 to
d72c877
Compare
ashwin-ant
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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") |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| T = TypeVar("T") | ||
|
|
||
|
|
||
| class ToolAnnotations(_McpToolAnnotations, extra="allow"): |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
| 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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Pinned in 65770fb (the three sites you named).
qing-ant
left a comment
There was a problem hiding this comment.
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.
| 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() |
There was a problem hiding this comment.
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).
| 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.
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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.
| 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.)
There was a problem hiding this comment.
Applied in 65770fb (docstring adjusted to match).
| 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. |
There was a problem hiding this comment.
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, butann.readOnlyHintattribute access is anAttributeErroron 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 mypycall-argerror on both majors (as onmain).SdkMcpTool.annotations/tool(annotations=)are now typed as the mcp base class, sox: claude_agent_sdk.ToolAnnotations | None = my_tool.annotationsnewly fails mypy, andisinstance(mcp.types.ToolAnnotations(...), claude_agent_sdk.ToolAnnotations)flips toFalse.
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.
There was a problem hiding this comment.
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.
…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).
|
Thanks both, this was a thorough pass. Two commits on top apply it. 65770fb: 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 Real CLI on 1.23.0 and 2.0.0: |
What breaks on mcp 2.x
Nothing fails at import, but
create_sdk_mcp_serverand the bridge behind it were written against mcp 1.x internals. The factory registers tools with the@server.list_tools()/@server.call_tool()decorators, andQuery._handle_sdk_mcp_requesthand-routes JSON-RPC by looking handlers up inserver.request_handlers[RequestClass], calling them with request objects, and readingresult.root.*with camelCase attribute names. mcp 2 removed the decorators (tools areServer(..., on_list_tools=, on_call_tool=)callbacks taking(ctx, params)), keeps its handler table private and keyed by method string, drops theRootModelwrappers, and renames the Python attributes to snake_case. So on 2.x server construction raises, and where dispatch does get through,isError, imagemimeTypeandmaxResultSizeCharsare 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, runnablemcp.server.Server. The hand-rolled method switch (hardcodedinitializepayload,tools/listandtools/callre-encoding, thenotifications/initializedack) 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 openscreate_client_server_memory_streams(), runsserver.run(...)(started by the first message, closed with the query), feeds eachmcp_messagefrom 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 repeatedinitializeon 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 whenserver.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 newinitializefrom 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 callsconnect()once per SDK server for the lifetime of the query, forwards everymcp_messageincluding any laterinitializeinto 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 asisErrorresults.User-visible changes
mcp>=1.23.0,<3.0.0(was capped below 2.0.0);jsonschema>=4.20.0is now declared (it was already installed by mcp on both majors). A fresh install resolves mcp 2.x. If you hand-build anmcp.server.Serverwith the 1.x decorator API and pass it asinstance, pinmcp<2until you port it.initializeis answered by the server rather than a hardcoded payload: capabilities are the server's real ones ({"experimental": {}, "tools": {"listChanged": false}}for factory servers) andprotocolVersionis negotiated instead of fixed at2024-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, apingis answered, and other requests (roots, sampling, elicitation) are answered with-32601so the server's call fails at once instead of waiting. The TypeScript SDK does forward such traffic to the CLI as client-sentmcp_messagecontrol requests; adding that here is a small follow-up on top of this bridge.claude_agent_sdk.ToolAnnotationsis now a subclass ofmcp.types.ToolAnnotationsthat declaresmaxResultSizeCharsand accepts every hint in camelCase or snake_case on every mcp version (ToolAnnotations(readOnlyHint=True)andToolAnnotations(read_only_hint=True)are the same thing and both type-check), which is what keeps the documentedToolAnnotations(maxResultSizeChars=N)working on 2.x (the base model ignores extras there) and stopsread_only_hint=Truefrom being silently kept as an extra on 1.x. No new keyword, no warning.isinstanceagainst the mcp class still holds; it is a distinct class, sotype(x) is mcp.types.ToolAnnotationsand==against a plain mcp instance are False, and attribute access follows the installed mcp (.readOnlyHinton 1.x,.read_only_hinton 2.x). As a declared fieldmaxResultSizeCharsno longer appears inside the wireannotationson 1.x, only in_meta(which is what the CLI reads). ImportToolAnnotationsfromclaude_agent_sdkif you rely onmaxResultSizeChars: the plainmcp.typesclass drops it on 2.x.notifications/cancelled, e.g. on interrupt) now cancels the running@toolfunction, and the request is settled on both majors. Previously the notification was rejected and the tool ran to completion. The exception is a hand-builtServeron 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.isError: false; a server with no tools answerstools/listwith[]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-builtServeron 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.pyis rewritten to drive the JSON-RPC surface the CLI uses (initialize first, thentools/list/tools/call), with no reach into mcp internals, and covers: real serverInfo/capabilities, notification handling, the camelCase wire format with_metacarryingmaxResultSizeCharsfrom the annotations (no warning raised), goldentools/calloutputs, concurrent calls on one server, re-initialize, cancellation, close with calls in flight (including one blocked in a thread), a hand-built lowlevelServerserved 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 newinitialize, a server-to-client request refused immediately and a ping answered, a lifespan running once with its teardown completing, cancellation on 1.x (a@toolthat 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.ymlgains atest-mcp-v1-floorjob that pinsmcp==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.pypasses 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,maxResultSizeCharsthrough annotations), a hand-built lowlevelServerwhose audio andstructuredContentresults 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-e2eandtest-examplesskip 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 /
MCPServerobjects asinstance(unsupported today as well); audio / structured-content additions to the@tooldict 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