feat(mcp): support the 2026-07-28 protocol behind an opt-in mode - #1053
feat(mcp): support the 2026-07-28 protocol behind an opt-in mode#1053ionmincu wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds opt-in support for the MCP 2026-07-28 “modern” era (via server/discover) to McpClient while preserving legacy wire behavior by default, by introducing an era-specific protocol strategy layer and extending the test matrix to cover modern/auto negotiation plus UiPath’s modern-era affinity routing.
Changes:
- Introduces
ProtocolStrategyimplementations (legacy,modern,auto) and wires them intoMcpClient(protocol_mode=..., affinity_meta_key=...). - Enhances the Streamable HTTP adapter to support era-dependent session identity behavior (including client-minted affinity IDs for modern mode).
- Expands unit + integration tests to validate modern discovery, auto probing/fallback, resumed legacy sessions via a second handshake, and gateway affinity pinning.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/agent/tools/test_mcp/test_protocol_version_support.py | Updates SDK “tripwire” tests to match modern-era reachability and auto negotiation assumptions. |
| tests/agent/tools/test_mcp/test_protocol_strategy.py | Adds coverage for modern/auto strategies, affinity behavior, and per-era recovery rules. |
| tests/agent/tools/test_mcp/test_mcp_client.py | Refines legacy endpoint simulation for faithful session routing; updates resume behavior tests. |
| tests/agent/tools/test_mcp/claude.md | Updates test suite documentation to reflect the new strategy tests. |
| testcases/simple-http-mcp/src/simple-http-mcp/servers.py | Extends testcase server to implement server/discover and modern-required response fields. |
| testcases/simple-http-mcp/src/simple-http-mcp/graph.py | Runs a multi-leg matrix through build_protocol_strategy, adds an affinity routing leg. |
| testcases/simple-http-mcp/src/assert.py | Updates assertions for the expanded leg matrix and modern-era expectations. |
| src/uipath_langchain/agent/tools/mcp/streamable_http.py | Introduces SessionIdentityWire/SessionIdentity and modern-era termination guard; supports optional _meta mirroring. |
| src/uipath_langchain/agent/tools/mcp/protocol_strategy.py | Adds per-era negotiation/recovery logic and strategy factory (legacy/modern/auto). |
| src/uipath_langchain/agent/tools/mcp/mcp_client.py | Integrates strategies into McpClient; removes legacy protocol-version probe adoption logic. |
| src/uipath_langchain/agent/tools/mcp/claude.md | Updates internal module documentation to explain strategies, identity wiring, and modern affinity. |
| docs/superpowers/specs/2026-08-27-mcp-2026-protocol-support-design.md | Adds a design/spec document describing rationale, risks, and implementation details. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
8c59ff1 to
e1b06b2
Compare
| # previous connection's resolution must not decide how this failure is | ||
| # recovered from. | ||
| self._resolved = self._legacy | ||
| await negotiate_auto(session) |
There was a problem hiding this comment.
We cannot leave auto like this: negotiate_auto() sends server/discover before the affinity ID is minted below. I reproduced discovery on instance-1 and the first tool call on instance-2, so this defeats the serverless pinning goal.
There was a problem hiding this comment.
Fixed, now we set affinity id before the probe, so server/discover will land on same instance as tool/calls
| if self._session_info: | ||
| await self._session_info.set_session_id(None) | ||
| await self._initialize_session() | ||
| await self._strategy.reset(self._session_info) |
There was a problem hiding this comment.
Do not reset on every recovery. CONNECTION_CLOSED only means the transport dropped; clearing the persisted ID here makes the retry start a new server session. Keep it unless the server actually rejected or terminated it.
There was a problem hiding this comment.
We do not call strategy reset on connection_Closed error.
| from uuid import uuid4 | ||
|
|
||
| from mcp import ClientSession | ||
| from mcp.client._probe import negotiate_auto |
There was a problem hiding this comment.
Please do not import mcp.client._probe. It is private SDK surface; use a public API or own this small policy here.
There was a problem hiding this comment.
Removed probe and implemented our own.
| HTTPX detail cannot break importing the package. Mirroring into ``_meta`` is | ||
| opt-in, so only that feature depends on it. | ||
| """ | ||
| from httpx2._content import ByteStream |
There was a problem hiding this comment.
Please do not import httpx2._content.ByteStream either. This makes the opt-in affinity-meta path depend on HTTPX internals; use a public request/body seam.
There was a problem hiding this comment.
We used it to add affinity in meta json, but i dropped it, we only send it now at header level.
…private imports Addresses the review on #1053. - AutoStrategy mints the affinity ID before server/discover so the probe reaches the same instance the tool calls will; a freshly minted ID is withdrawn before a legacy fallback handshake, a restored one is kept for the handshake to resume. - McpClient skips strategy.reset on CONNECTION_CLOSED: a dropped transport is not the server's verdict on the session, so the reconnect resumes the persisted session instead of starting a cold one. - Own the auto negotiation policy (probe_modern_era) on the public ClientSession.send_discover / adopt seam instead of importing mcp.client._probe.negotiate_auto. - Remove params._meta affinity mirroring (affinity_meta_key) and with it the httpx2._content.ByteStream dependency; the ID travels on mcp-session-id only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…private imports Addresses the review on #1053. - AutoStrategy mints the affinity ID before server/discover so the probe reaches the same instance the tool calls will; a freshly minted ID is withdrawn before a legacy fallback handshake, a restored one is kept for the handshake to resume. - McpClient skips strategy.reset on CONNECTION_CLOSED: a dropped transport is not the server's verdict on the session, so the reconnect resumes the persisted session instead of starting a cold one. - Own the auto negotiation policy (probe_modern_era) on the public ClientSession.send_discover / adopt seam instead of importing mcp.client._probe.negotiate_auto. - Remove params._meta affinity mirroring (affinity_meta_key) and with it the httpx2._content.ByteStream dependency; the ID travels on mcp-session-id only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
be0dd14 to
c665b95
Compare
Upgrade the MCP Python SDK from 1.26.0 to 2.0.0, replace the copied SDK 1.x Streamable HTTP transport with a thin adapter over the upstream one, and add opt-in support for the 2026-07-28 protocol revision. Cut `streamable_http.py` from roughly 800 lines to a session-aware adapter, so upstream fixes and new protocol behavior arrive without hand-merging a fork. MCP 2 removed the transport's `get_session_id` callback, so two `httpx2` event hooks carry UiPath's externally persisted `SessionInfo` instead: one puts the stored ID on each request, one persists an ID the server returns. Make session recovery correct under concurrency and failure. `initialize()` is idempotent per `ClientSession` in MCP 2, so recovery replaces the transport and session rather than re-initializing, guarded so a late failure from a superseded session cannot tear down its replacement. A failed replacement no longer poisons the client: the next operation reopens. Recovery clears the persisted session ID only on the server's verdict; a plain `CONNECTION_CLOSED` means the transport dropped, so the ID is kept and the reconnect resumes the same session instead of starting a cold one. Reach 2026-07-28 through `ProtocolStrategy`, which abstracts the session lifecycle rather than the transport. Negotiation is one call in either era; what differs is how a connection is negotiated, whether a restored one can be reused, which errors a reconnect can fix, and how the connection is identified on the wire. No high-level client is needed: `ClientSession.discover()` already reaches the modern era, and the `auto` probe is owned here on the public `ClientSession.send_discover` / `adopt` seam rather than importing the SDK's private `mcp.client._probe` helper. Select the era with a keyword-only `protocol_mode`, defaulted to `"legacy"` so every existing caller keeps identical wire behavior. `"auto"` would otherwise move any discovery-capable UiPath MCP server to stateless 2026-07-28 and stop issuing session IDs, breaking the AgentHub playground persistence that `SessionInfoDebugState` exists for. Replace the resumed-session version probe with a second handshake. The probe tried each handshake version with a ping and adopted the first that answered, but servers do not validate that header against what the session negotiated, so the oldest version always won: a session negotiated at 2025-11-25 was adopted as 2025-03-26, silently disabling the server's 2025-11-25 SSE resumability. The version cannot be recovered from the wire -- responses carry only the session ID -- so re-running `initialize` inside the restored session is what learns it. That is safe because the server routes by the session header and mints a new session only when the header is absent. Resume drops from up to four round trips to one. Carry a UiPath-minted affinity ID in the modern era. AgentHub routes serverless MCP instances by `mcp-session-id`, which 2026-07-28 removes, so the modern strategy mints its own value and keeps sending it on that header as an opaque routing key -- ignored by a modern server, and requiring no gateway change. The ID travels on the header only; no request body is rewritten. In both `modern` and `auto` the client mints it before negotiating, so it is present on the very first request, `server/discover` included, and the probe reaches the same instance the tool calls will. When `auto` falls back to the legacy handshake, a freshly minted ID is withdrawn first so a server that routes by the header is not asked to resume a session it never issued; a restored ID is kept for the handshake to resume. `SessionInfo` needed no new API: it stores whichever ID this server uses, so a subclass persists an affinity ID unmodified. Guard that ID against a proxy echoing `mcp-session-id` back, and against disposal sending `DELETE` for a session the server never issued -- which reached the gateway as a teardown for the live instance the ID exists to pin. Scope retries to what each era can recover: a legacy session can be lost and re-established, while every modern request is self-contained, so only a dropped connection is retryable there. Retain compatibility with the previously accepted `httpx.Timeout` API, and replace the incompatible `langchain-mcp-adapters` dependency -- it imports `RequestContext`, which MCP 2 removed -- with a tested first-party session-to-LangChain tool converter. Test the public `McpClient` against a real `MCPServer` over real HTTP rather than a mocked transport: negotiation in every mode, resume asserting the originally negotiated version, affinity pinning across clients, per-era retry and disposal, and all four handshake versions. Add `testcases/simple-http-mcp`, an LLM-free integration testcase hosting MCP over Streamable HTTP on real sockets, covering the same matrix plus the API surface `uipath-agents-python` depends on. Mocked transports are kept only for conditions a cooperative server cannot produce: concurrency races, pathological servers, and pure-function matrices. Move the `uipath new` scaffold pin to the 0.17 minor. The guard from #1052 fails on every minor bump so the scaffold gets reviewed; the templates and hints touch no MCP surface, so only the pin constant changes. BREAKING CHANGE: `McpClient` is a public low-level API that deliberately keeps returning the MCP SDK's raw result models, and SDK 2.0 renamed their Python attributes to snake case. Callers that read them directly must update: CallToolResult.isError -> is_error CallToolResult.structuredContent -> structured_content Tool.inputSchema -> input_schema Tool.outputSchema -> output_schema ListToolsResult.nextCursor -> next_cursor The wire format is unchanged: these became snake_case fields carrying the old names as serialization aliases, so `model_dump(by_alias=True)` still emits camelCase. Note that plain `model_dump()` now emits snake_case keys, which is the one form of this break that fails silently rather than raising. `McpClient.SESSION_ERROR_CODES` is removed; use the `McpClient.is_session_error` static method instead. Removing the vendored transport also drops the unused public names it carried, including `streamablehttp_client`, `StreamableHTTPTransport`, `RequestContext`, `StreamableHTTPError` and `ResumptionError`. Co-Authored-By: Ion Mincu <ion.mincu@uipath.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
c665b95 to
61eeee8
Compare
The SDK 2 upgrade made a resumed legacy session re-run `initialize` inside the restored session, because that was the only way left to learn the version the session had been negotiated at. The pre-upgrade client sent no handshake at all on resume, so this changed the wire for every caller of the default `legacy` mode whose session store outlives a connection. It is not only extra traffic. Whether a server accepts a second `initialize` on a live session is implementation-defined: the Python SDK answers it, while the reference TypeScript implementation refuses outright with `-32600 "Invalid Request: Server already initialized"`. Driven against that server, every resume lost the persisted session and started a cold one -- and with it the `mcp-session-id` affinity a gateway routes serverless MCP instances by, which is the whole point of persisting the session. Store the negotiated version alongside the ID instead, and adopt it. The version is the only thing a fresh `ClientSession` lacks, and `ClientSession.adopt()` installs negotiated state locally with no wire traffic, so a resumed run sends only ordinary requests carrying `mcp-session-id`, exactly as it did before the upgrade. `SessionInfo` grows async accessors for the version, mirroring the ID's, so a store that persists one can persist both; `reset` clears them together, so a replacement session can never inherit a version it did not negotiate. The handshake path stays for a store with no version recorded and for a stored version this client cannot speak on a legacy wire. Decide `reset` on the server's verdict rather than on the error code alone. `CONNECTION_CLOSED` is JSON-RPC's implementation-defined `-32000`, which the TypeScript SDK's transport also answers with when it refuses a session it does not know, so comparing the code kept a session the server had just declared dead and spent the retry resuming it. `is_session_rejected` reads the message when the code is ambiguous: a dropped transport still keeps the ID, and a `-32000` naming a lost session now clears it -- as the pre-upgrade client always did. Add a `refuse_reinitialize` mode to the pinned real-HTTP endpoint, so the TypeScript server's refusal is a regression test rather than a manual finding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|



Upgrades the MCP Python SDK to 2.0 and adds opt-in support for the
2026-07-28protocol revision. Supersedes #1025 — this branch now contains that work, so #1025 should be closed rather than merged.Why
McpClientspoke only the legacyinitializehandshake, reaching2024-11-05through2025-11-25. The2026-07-28revision replaces that handshake with a statelessserver/discoverprobe and drops session IDs entirely. This makes that era reachable while leaving every existing caller's wire behaviour byte-for-byte unchanged.No high-level client is needed:
ClientSession.discover()already negotiates2026-07-28through UiPath's transport adapter, and the SDK ships the era-negotiation policymcp.client._probe.negotiate_autothatmcp.Client(mode="auto")itself uses.How agents use it
Nothing changes for existing callers.
create_mcp_tools_and_clientsstill defaults to the legacy handshake, andSessionInfoDebugStatekeeps persisting server-minted session IDs exactly as before:To opt into the new era, construct the client directly and pass a mode:
protocol_mode"legacy"(default)initializeonly — today's behaviour, unchanged"auto"server/discover, fall back to the handshake"modern"server/discoveronly, no fallbackAgentHub needs no change.
2026-07-28removesmcp-session-id, which AgentHub routes serverless MCP instances by — so in modern mode the client mints its own ID and keeps sending it on that same header as an opaque routing key. A modern server ignores it. Because the client mints it before negotiating, it is on the very first request (server/discoverincluded), which a server-assigned session ID never could be.uipath-agents-pythonneeds no change either.SessionInfostores whichever ID the server uses, soSessionInfoDebugStatepersists a client-minted affinity ID unmodified, and a later run returns to the same warm instance. Verified against a dev build of this branch in UiPath/uipath-agents-python#702: all 36 checks pass.Known gap:
create_mcp_tools_and_clientshas noprotocol_modeparameter yet, so the new era is only reachable by constructingMcpClientdirectly. Deliberate for now — the default must stay legacy — but worth a follow-up if AgentHub wants it plumbed through.What changed
ProtocolStrategyabstracts the session lifecycle, not the transport. Negotiation is one call in either era; what differs is how a connection is negotiated, whether a restored one can be reused, which errors a reconnect can fix, and how the connection is identified on the wire.The default stays
"legacy"deliberately."auto"would silently move any discovery-capable UiPath MCP server to stateless2026-07-28and stop issuing session IDs, breaking the AgentHub playground persistenceSessionInfoDebugStateexists for.Fixes a silent protocol downgrade on resume. The old code probed each handshake version with a ping and adopted the first that answered — but servers don't validate that header against what the session negotiated, so the oldest always won. A session negotiated at
2025-11-25was adopted as2025-03-26, disabling the server's2025-11-25SSE resumability for the rest of the run. The version can't be recovered from the wire (responses carry only the session ID), so resume now re-runsinitializeinside the restored session, which is safe because the server routes by the session header and mints a new session only when that header is absent. Resume drops from up to four round trips to one.Two bugs found and fixed while building this: a proxy echoing
mcp-session-idback could overwrite the client-minted routing key mid-connection; and disposal sentDELETEfor a session the server never issued, reaching the gateway as a teardown for the live instance the ID exists to pin — on every run after the first.Retries are scoped per era. A legacy session can be lost and re-established; every modern request is self-contained, so only a dropped connection is retryable there.
Also:
streamable_http.pydrops from ~800 lines to a thin adapter over the upstream transport;langchain-mcp-adaptersis replaced by a first-party session-to-LangChain converter (it importsRequestContext, removed in MCP 2);httpx.Timeoutremains accepted.Testing
McpClientis tested against a realMCPServerover real HTTP, not a mocked transport — negotiation in every mode, resume asserting the originally negotiated version, affinity pinning across clients, per-era retry and disposal, and all four handshake versions (2024-11-05and2025-03-26were previously untested anywhere). Mocked transports remain only for what a cooperative server can't produce: concurrency races, pathological servers, and pure-function matrices.testcases/simple-http-mcpadds an LLM-free integration testcase hosting MCP over Streamable HTTP on real sockets, covering the same matrix plus the exact API surfaceuipath-agents-pythondepends on.Breaking changes
Version bumped to 0.17.0. SDK 2.0 renamed the raw result-model attributes
McpClientdeliberately returns:CallToolResult.isErroris_errorCallToolResult.structuredContentstructured_contentTool.inputSchemainput_schemaTool.outputSchemaoutput_schemaListToolsResult.nextCursornext_cursorThe wire format is unchanged — these became snake_case fields carrying the old names as serialization aliases, so
model_dump(by_alias=True)still emits camelCase. Plainmodel_dump()now emits snake_case keys, the one form of this break that fails silently rather than raising.McpClient.SESSION_ERROR_CODESis removed — use theMcpClient.is_session_errorstatic method. Removing the vendored transport also drops the unused public names it carried:streamablehttp_client,StreamableHTTPTransport,RequestContext,StreamableHTTPError,ResumptionError.One thing to flag: modern mode sends
mcp-session-id, a header2026-07-28doesn't define for requests, purely as a routing key. The SDK server ignores it; a stricter server or proxy could object. Worth confirming with whoever owns UiPath's MCP server implementation.🤖 Generated with Claude Code
Development Package
uipath pack --nolockto get the latest dev build from this PR (requires version range).