feat(mcp): support the 2026-07-28 protocol behind an opt-in mode - #1053
Open
ionmincu wants to merge 1 commit into
Open
feat(mcp): support the 2026-07-28 protocol behind an opt-in mode#1053ionmincu wants to merge 1 commit into
ionmincu wants to merge 1 commit 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.
ionmincu
force-pushed
the
feat/mcp-2026-protocol-support
branch
4 times, most recently
from
August 28, 2026 15:14
5ea8312 to
8c59ff1
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.
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 SDK ships the era-negotiation policy that
`mcp.Client(mode="auto")` itself uses.
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.
Because the client mints it before negotiating, it is present on the very first
request, `server/discover` included. `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.
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>
ionmincu
force-pushed
the
feat/mcp-2026-protocol-support
branch
from
August 28, 2026 15:20
8c59ff1 to
e1b06b2
Compare
|
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.



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).