Repository navigation
feat(mcp)!: support mcp 2 and drop mcp 1 - #128
Merged
Merged
Conversation
gopaljigaur
commented
Oct 15, 2026
gopaljigaur
left a comment
Owner
Author
There was a problem hiding this comment.
Two findings, each with an inline thread:
connection.py: the 404 retry depends on the session having had any 404, not on the failing call's own POST. A timed out call is retried and the tool runs twice when GET answers 404, or when another request's 404 marks the session. I reproduced this; repro and two possible fixes are in the thread.docs/guide/mcp.md(and the website copy): a remote SSE server that exits while idle is now seen at once and reconnects in the background, but the docs say only stdio is watched.
What I verified and found sound:
- Survey. Checked against PyPI metadata:
openai-agents0.24.0 hasmcp>=1.19.0,<3and a 191 lineagents/mcp/_compat.py.pydantic-ai-slim2.56.0 hasfastmcp-slim[client]>=3.3.0,<5, where fastmcp-slim 3.3.0 needsmcp<2and 4.x needsmcp>=2. Its_mcp_compat.pyis 56 lines.langchain-mcp-adapters0.3.2 hasmcp>=1.24.0,<2.0.0. Its #578 was closed in favour oflangchain.mcp, andlangchain[mcp]1.4.6 needsfastmcp>=4.0.11,<5.fastmcp4.1.0 needsmcp>=2,<3.
- Spec. The 404 signal is the spec's: 2025-11-25, Basic > Transports > Streamable HTTP > Session Management, items 3 and 4.
- Dispatcher.
dispatcher=andJSONRPCDispatcherare public; the migration guide calls passing the SDK's own dispatcher the supported use, and therun()lifecycle provisional. mcp 2 has no public end of stream hook, so the subclass is reasonable under the<3pin. Removing either hook fails the test the body names. - Real servers. Kill and restart against the Python SDK (stdio, streamable HTTP, SSE) and the TypeScript SDK 1.30.0 (stdio, streamable HTTP with sessions):
- an idle restart retries once and runs the tool once;
- a call killed mid way returns an error and is not retried;
- an idle stdio exit is
disconnectedat once and the next call reconnects.
- Cancellation. A cancelled call stops the server's tool.
- Tests.
- Full suite: 549 passed, 1 xfailed on 3.10, 3.11, 3.12 and 3.14.0 (the 3.14.0 run covers the same tick race).
uv sync --upgradeon 3.12 also passes. - Lowest direct deps on 3.10: 2 litellm tests fail at litellm 1.101.0, and they fail on main too. So it is not this PR, but the body's "suite passes" is not quite right.
- mypy and pre-commit pass.
- Full suite: 549 passed, 1 xfailed on 3.10, 3.11, 3.12 and 3.14.0 (the 3.14.0 run covers the same tick race).
- Soak.
- The type count move is a harness fix. With the old order the growth is a fixed ~51 to 56 objects at both 1000 and 3000 runs, and it is gone once
jsonschemais imported before the first count. - The MCP scenarios pass with the leakinject plugin off, and every injected leak (dict, str, obj, msg, bytes, task, thread, fd) fails the agent scenario.
- The type count move is a harness fix. With the old order the growth is a fixed ~51 to 56 objects at both 1000 and 3000 runs, and it is gone once
- Decision. Dropping 1.x is the right call. Nit on the body and commit text: the mcp 1.x README says the line gets "critical bug fixes and security patches", not security fixes only.
Both arrived only through mcp and litellm. mcp 2 no longer brings httpx, so a direct import should not rely on it. Serialize tool results through pydantic's TypeAdapter instead of importing pydantic_core.
Port the client, server and test fixtures to the mcp 2 SDK and drop mcp 1, which now gets only critical bug fixes and security patches. - MCPError replaces McpError; protocol fields are read in snake_case; tool lists page through PaginatedRequestParams; call timeouts are float seconds. - HTTP transports build httpx2 clients, and auth takes an httpx2.Auth. - MCPServer registers on_list_tools and on_call_tool, and still turns a raised tool error into an error result so the model sees its text. - Tool list refreshes run in the notification's own task, which the session now provides. - The session's dispatcher reports when the read stream ends, so a stdio server that exits is marked lost at once and the next call reconnects. The SDK no longer tells a call that was not sent from one that was, so this replaces the retry on a closed stream. - A 404 to a request carrying the session id marks the session expired, per the spec, and the refused call is retried once on a new session. The SDK now reports it with the server's own error code and message. - A timed out or cancelled call sends notifications/cancelled, so the server stops the tool; the tests pin it. BREAKING CHANGE: cyclops requires mcp>=2,<3 and httpx2. MCPClient.attach takes an httpx2.Auth for auth, and MCPClient.call_tool returns the mcp 2 CallToolResult, whose fields are snake_case.
mcp 2 imports jsonschema on the first tool call, and its rpds dependency registers ABCs, which empties the ABC caches the first count filled. The count before the runs then refilled them after its own snapshot, so the MCP agent scenario saw about 55 weakrefs grow that no run had made.
A 404 to any request carrying the session id marked the whole session expired, so after a GET or another call's POST got one, any error on the session was retried, including the timeout of a tool that had already run. The call now sets a context variable that its own request carries, and the response hook flags that attempt alone.
gopaljigaur
force-pushed
the
feat/mcp-2
branch
from
October 15, 2026 06:48
c44d640 to
f0fb07c
Compare
This was referenced Oct 15, 2026
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.
Decision: require mcp 2 and drop mcp 1
mcp 1 is in maintenance mode: its README says the line gets critical bug fixes and security patches, and new protocol work lands in 2.x only. Supporting both majors would need a branch at almost every point where cyclops touches the SDK:
McpError(ErrorData(...))againstMCPError(code, message);isError,structuredContent,mimeType,inputSchema,nextCursor, the four annotation hints);ServerNotification.rootunwrapping against plain unions;list_tools(cursor=)againstlist_tools(params=PaginatedRequestParams(...));timedeltaagainst float timeouts;Serverregistration APIs (decorators againston_*constructor handlers);httpxagainsthttpx2, which leaks into the public API:MCPClient.attach(auth=...)must be anhttpx.Authon one and anhttpx2.Authon the other, so user code changes with the SDK major anyway;McpErrorwith the positive code 32600 on any 404, against the server's own JSON-RPC error body);FastMCPagainstMCPServerin the test fixture, with the test matrix doubled.That is not a small layer, and every branch would be a fallback kept only for a line that gets no new features.
What the other client libraries did:
mcp>=1.19,<3agents/mcp/_compat.py(MCP_V2flag, an accessor per renamed field, httpx or httpx2 picked at import) plusMCP_V2branches inserver.pyfastmcp-slim>=3.3,<5_mcp_compat.pythat reads fields by either spelling and union types for the two HTTP clientslangchain-mcp-adapters0.3.2 stays capped atmcp<2(langchain-mcp-adapters#590); its 2.x issue (#578) was closed in favour of the newlangchain.mcp, whoselangchain[mcp]extra (langchain 1.4.6) requiresfastmcp>=4.0.11,<5, so mcp 2 onlymcp>=2,<3The two that support both carry a dedicated compatibility module for it; LangChain and fastmcp require mcp 2. cyclops is pre-1.0, has no downstream pinning it to mcp 1, and the owner prefers no fallbacks, so this follows fastmcp: require
mcp>=2.0.0,<3. The<3bound follows the reasoning of #123 and the mcp migration guide.The new
ClientclassI checked whether
mcp.Clientmakescyclops/mcp/connection.pysimpler. It does not, so the connection still drives aClientSession:auth, the connect timeout with no read timeout, SSE), soClientwould take them as aTransportand replace onlyClientSession(...),async with sessionandinitialize().Clientdefaults tomode="auto", which negotiates the 2026-07-28 revision with mcp 2 servers. On that revisionnotifications/tools/list_changedonly arrives on asubscriptions/listenstream, so keeping tool list refreshes would mean running a listen task per server: more code, not less.mode="legacy"it is the same session plus a response cache thatlist_toolswould read through, so a refresh afterlist_changedwould depend on the cache's eviction.The public API of cyclops is unchanged apart from the types the SDK hands through (below).
What changed
Commit 1,
build(deps): declareanyioandhttpx(still mcp 1 at that point), which cyclops imported without declaring.ToolResultnow serializes throughpydantic.TypeAdapter(Any).dump_python(mode="json", fallback=str), sopydantic_coreis no longer imported directly.Commit 2,
feat(mcp)!, the port, item by item from #124:MCPErrorreplacesMcpError, matched one.code; the closed connection error is built asMCPError(CONNECTION_CLOSED, "Connection closed").httpx2: the HTTP transport builds anhttpx2.Timeoutforcreate_mcp_http_client, andauthis typedhttpx2.Auth.httpxis no longer a dependency;httpx2is.is_error,structured_content,mime_type,input_schema,next_cursor,read_only_hintand the other hints.list_tools(params=PaginatedRequestParams(cursor=...)).call_tool(read_timeout_seconds=self.call_timeout).MCPServerregisterson_list_toolsandon_call_tool(returningListToolsResultandCallToolResult), and still catches a tool's exception and buildsCallToolResult(is_error=True)itself, so the model keeps seeing the error text. It now passes itsversionto the SDK, which reports an empty version otherwise.streamable_http_clientyields a 2-tuple.notifications/cancelled; a new test pins that a timed out call stops the server's tool.-32001with the messageRequest 'tools/call' timed out; nothing in cyclops matched on the old 408, and the timeout tests now look fortimed out.MCPServerfrommcp.server.mcpserver, takes the port inrun(), reads headers fromctx.headers, and raisesToolError(mcp 2 hides the text of other exceptions behindError executing tool fail). The cyclops server test usesmcp.Client(server.server)in place of the removedcreate_connected_server_and_client_session.Two behaviours of mcp 1 that cyclops relied on are gone in mcp 2, and needed replacing to keep #79:
anyio.ClosedResourceErrorfor a send on a closed stream, which cyclops retried. mcp 2 raisesMCPError(CONNECTION_CLOSED)both for that and for a call dropped in flight, so the two can no longer be told apart. Instead, the session'sJSONRPCDispatchersubclass reports when the transport's read stream ends, and the connection marks the session lost right away. A stdio server that exits while idle is nowdisconnectedat once, and the next call reconnects first, which keepstest_reconnects_after_server_is_killedpassing. Removing the hook makes that test fail.McpError(32600, "Session terminated"). mcp 2 surfaces the server's own JSON-RPC error body: the Python SDK answers-32600 "Session not found"and the TypeScript SDK-32001 "Session not found", which is also the new timeout code. So the connection now watches for the spec's own signal, a 404 to a request that carriedMcp-Session-Id, through anhttpx2response hook. The hook flags only the call whose own request got the 404:call_toolsets aContextVarfor the attempt, and the SDK sends the POST in the calling task's context, so a 404 to the background GET, a DELETE or another call's POST never makes a call retryable. A call flagged that way is retried once on a new session. Removing the hook makestest_restarted_http_server_gets_a_new_sessionfail, andtest_timed_out_call_is_not_retried_after_another_404(a server whose GET answers 404, a timed out tool, which must start once) fails with the earlier session wide flag.Commit 3,
test(soak): the MCP agent soak scenario failed on every Python with about 55 grownReferenceTypeobjects. mcp 2 importsjsonschemaon the first tool call (to validate tool results), and itsrpdsdependency registers eight ABCs, which empties the ABC caches the harness's firsttype_counts()had filled before the warmup. The count before the runs then refilled them after its own snapshot. The first count now runs after the warmup. Not a leak: with the count moved, all three MCP scenarios and the full soak suite pass.Fixed behaviour kept
The MCP tests pass with the same meaning; the only test edits are the renames above and
Timed outbecomingtimed out._callpattern and the same-tick result race: unchanged code, tests pass on 3.10, 3.12 and 3.14.defer_loading, OAuth (authpasses anyhttpx2.Auth; the SDK'sOAuthClientProvidernow subclasses it), and tool list change notifications (test_tools_follow_list_changed,test_clashing_refresh_keeps_the_old_tools).Migration for users
mcp>=2. Code of your own that uses the MCP SDK needs the mcp migration guide.auth=takes anhttpx2.Auth: subclasshttpx2.Authfor a custom scheme. The SDK'sOAuthClientProviderworks as before, but itscallback_handlernow returns anAuthorizationCodeResult.MCPClient.call_tool()returns the mcp 2CallToolResult: readis_error,structured_contentandmime_type.Request 'tools/call' timed out.notifications/cancelled, so the server stops the tool instead of finishing it.traceparentinto requests.initializehandshake. Servers that announce tool list changes only on a 2026-07-28subscriptions/listenstream do not reach it; supporting that revision is a separate change.Commits 4 and 5 answer the review:
fix(mcp)ties the 404 retry to the call's own request as above, anddocs(mcp)says an SSE server that exits is seen at once too.Docs
docs/guide/mcp.mdand the website MCP guide: timeouts sendnotifications/cancelled, a stdio or SSE server's exit is seen at once (a streamable HTTP session is not watched while idle), the 404 retry, the handshake note under tool list changes, andhttpx2.Auth. Both API references showauth: Optional[httpx2.Auth].Tests
uv run pytest -m soak -k mcpon 3.10, 3.12 and 3.14: 3 passed each; the full soak suite on 3.14 passes.uv sync --upgrade --python 3.12: mcp 2.3.0, httpx2 2.13.1, anyio 4.15.1, pydantic 2.14.1, litellm 1.105.1): suite passes.--resolution lowest-direct: mcp 2.0.0, httpx2 2.5.0, anyio 4.9.0, pydantic 2.12.4, litellm 1.101.0): the suite passed in my runs on 3.10 and 3.12. Review saw 2 litellm tests fail at litellm 1.101.0 on 3.10, on main as well, so that floor is a litellm question outside this PR; I could not reproduce it locally. mcp 2.0.0 itself passes everything.uv run pre-commit run --all-filesanduv run mypy cyclopspass.Closes #124