Skip to content

feat(mcp)!: support mcp 2 and drop mcp 1 - #128

Merged
gopaljigaur merged 5 commits into
mainfrom
feat/mcp-2
Oct 15, 2026
Merged

gopaljigaur merged 5 commits into
mainfrom
feat/mcp-2

Conversation

@gopaljigaur

@gopaljigaur gopaljigaur commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

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(...)) against MCPError(code, message);
  • camelCase against snake_case attributes on every protocol type cyclops reads (isError, structuredContent, mimeType, inputSchema, nextCursor, the four annotation hints);
  • ServerNotification.root unwrapping against plain unions;
  • list_tools(cursor=) against list_tools(params=PaginatedRequestParams(...));
  • timedelta against float timeouts;
  • two different lowlevel Server registration APIs (decorators against on_* constructor handlers);
  • httpx against httpx2, which leaks into the public API: MCPClient.attach(auth=...) must be an httpx.Auth on one and an httpx2.Auth on the other, so user code changes with the SDK major anyway;
  • the session expiry signal (McpError with the positive code 32600 on any 404, against the server's own JSON-RPC error body);
  • and FastMCP against MCPServer in 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:

Library mcp 2 support How
OpenAI Agents SDK 0.24 both, mcp>=1.19,<3 a 191 line agents/mcp/_compat.py (MCP_V2 flag, an accessor per renamed field, httpx or httpx2 picked at import) plus MCP_V2 branches in server.py
Pydantic AI 2.56 both, through fastmcp-slim>=3.3,<5 delegates to fastmcp 3 (mcp 1) or fastmcp 4 (mcp 2), with a _mcp_compat.py that reads fields by either spelling and union types for the two HTTP clients
LangChain 2 only langchain-mcp-adapters 0.3.2 stays capped at mcp<2 (langchain-mcp-adapters#590); its 2.x issue (#578) was closed in favour of the new langchain.mcp, whose langchain[mcp] extra (langchain 1.4.6) requires fastmcp>=4.0.11,<5, so mcp 2 only
fastmcp 4 2 only, mcp>=2,<3 dropped mcp 1 in its new major

The 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 <3 bound follows the reasoning of #123 and the mcp migration guide.

The new Client class

I checked whether mcp.Client makes cyclops/mcp/connection.py simpler. It does not, so the connection still drives a ClientSession:

  • cyclops still builds the transports itself (headers, auth, the connect timeout with no read timeout, SSE), so Client would take them as a Transport and replace only ClientSession(...), async with session and initialize().
  • Client defaults to mode="auto", which negotiates the 2026-07-28 revision with mcp 2 servers. On that revision notifications/tools/list_changed only arrives on a subscriptions/listen stream, so keeping tool list refreshes would mean running a listen task per server: more code, not less.
  • With mode="legacy" it is the same session plus a response cache that list_tools would read through, so a refresh after list_changed would 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): declare anyio and httpx (still mcp 1 at that point), which cyclops imported without declaring. ToolResult now serializes through pydantic.TypeAdapter(Any).dump_python(mode="json", fallback=str), so pydantic_core is no longer imported directly.

Commit 2, feat(mcp)!, the port, item by item from #124:

  1. MCPError replaces McpError, matched on e.code; the closed connection error is built as MCPError(CONNECTION_CLOSED, "Connection closed").
  2. httpx2: the HTTP transport builds an httpx2.Timeout for create_mcp_http_client, and auth is typed httpx2.Auth. httpx is no longer a dependency; httpx2 is.
  3. snake_case: is_error, structured_content, mime_type, input_schema, next_cursor, read_only_hint and the other hints.
  4. Pagination: list_tools(params=PaginatedRequestParams(cursor=...)).
  5. Float timeouts: call_tool(read_timeout_seconds=self.call_timeout).
  6. MCPServer registers on_list_tools and on_call_tool (returning ListToolsResult and CallToolResult), and still catches a tool's exception and builds CallToolResult(is_error=True) itself, so the model keeps seeing the error text. It now passes its version to the SDK, which reports an empty version otherwise.
  7. streamable_http_client yields a 2-tuple.
  8. Dispatcher changes:
    • Notification callbacks now run in their own task, cancelled when the session closes, so the tool list refresh is awaited in the handler and the extra task group around the session is gone.
    • A timed out or cancelled call now sends notifications/cancelled; a new test pins that a timed out call stops the server's tool.
    • Timeouts raise -32001 with the message Request 'tools/call' timed out; nothing in cyclops matched on the old 408, and the timeout tests now look for timed out.
  9. The test fixture uses MCPServer from mcp.server.mcpserver, takes the port in run(), reads headers from ctx.headers, and raises ToolError (mcp 2 hides the text of other exceptions behind Error executing tool fail). The cyclops server test uses mcp.Client(server.server) in place of the removed create_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:

  • A call that could not be sent. mcp 1 raised anyio.ClosedResourceError for a send on a closed stream, which cyclops retried. mcp 2 raises MCPError(CONNECTION_CLOSED) both for that and for a call dropped in flight, so the two can no longer be told apart. Instead, the session's JSONRPCDispatcher subclass 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 now disconnected at once, and the next call reconnects first, which keeps test_reconnects_after_server_is_killed passing. Removing the hook makes that test fail.
  • A restarted streamable HTTP server. mcp 1 turned any 404 into 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 carried Mcp-Session-Id, through an httpx2 response hook. The hook flags only the call whose own request got the 404: call_tool sets a ContextVar for 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 makes test_restarted_http_server_gets_a_new_session fail, and test_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 grown ReferenceType objects. mcp 2 imports jsonschema on the first tool call (to validate tool results), and its rpds dependency registers eight ABCs, which empties the ABC caches the harness's first type_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 out becoming timed out.

Migration for users

  • Install mcp>=2. Code of your own that uses the MCP SDK needs the mcp migration guide.
  • auth= takes an httpx2.Auth: subclass httpx2.Auth for a custom scheme. The SDK's OAuthClientProvider works as before, but its callback_handler now returns an AuthorizationCodeResult.
  • MCPClient.call_tool() returns the mcp 2 CallToolResult: read is_error, structured_content and mime_type.
  • A timed out call's error text is now Request 'tools/call' timed out.
  • A timed out or cancelled call sends notifications/cancelled, so the server stops the tool instead of finishing it.
  • mcp 2 starts OpenTelemetry spans for MCP requests whenever a global tracer provider is configured, and injects traceparent into requests.
  • cyclops still connects with the initialize handshake. Servers that announce tool list changes only on a 2026-07-28 subscriptions/listen stream 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, and docs(mcp) says an SSE server that exits is seen at once too.

Docs

docs/guide/mcp.md and the website MCP guide: timeouts send notifications/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, and httpx2.Auth. Both API references show auth: Optional[httpx2.Auth].

Tests

  • Full suite on Python 3.10, 3.12 and 3.14: 591 passed, 1 xfailed.
  • uv run pytest -m soak -k mcp on 3.10, 3.12 and 3.14: 3 passed each; the full soak suite on 3.14 passes.
  • Latest deps (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.
  • Lowest direct deps (--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-files and uv run mypy cyclops pass.

Closes #124

@gopaljigaur gopaljigaur left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Two findings, each with an inline thread:

  1. 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.
  2. 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-agents 0.24.0 has mcp>=1.19.0,<3 and a 191 line agents/mcp/_compat.py.
    • pydantic-ai-slim 2.56.0 has fastmcp-slim[client]>=3.3.0,<5, where fastmcp-slim 3.3.0 needs mcp<2 and 4.x needs mcp>=2. Its _mcp_compat.py is 56 lines.
    • langchain-mcp-adapters 0.3.2 has mcp>=1.24.0,<2.0.0. Its #578 was closed in favour of langchain.mcp, and langchain[mcp] 1.4.6 needs fastmcp>=4.0.11,<5.
    • fastmcp 4.1.0 needs mcp>=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= and JSONRPCDispatcher are public; the migration guide calls passing the SDK's own dispatcher the supported use, and the run() lifecycle provisional. mcp 2 has no public end of stream hook, so the subclass is reasonable under the <3 pin. 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 disconnected at 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 --upgrade on 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.
  • 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 jsonschema is 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.
  • 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.

Comment thread cyclops/mcp/connection.py Resolved
Comment thread docs/guide/mcp.md Resolved
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 gopaljigaur left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

No findings.

@gopaljigaur
gopaljigaur merged commit 3dd1186 into main Oct 15, 2026
14 checks passed
@gopaljigaur
gopaljigaur deleted the feat/mcp-2 branch October 15, 2026 07:00
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.

feat(mcp): support mcp 2.x

1 participant