Skip to content

feat(mcp): support the 2026-07-28 protocol behind an opt-in mode - #1053

Open
ionmincu wants to merge 1 commit into
mainfrom
feat/mcp-2026-protocol-support
Open

feat(mcp): support the 2026-07-28 protocol behind an opt-in mode#1053
ionmincu wants to merge 1 commit into
mainfrom
feat/mcp-2026-protocol-support

Conversation

@ionmincu

@ionmincu ionmincu commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Upgrades the MCP Python SDK to 2.0 and adds opt-in support for the 2026-07-28 protocol revision. Supersedes #1025 — this branch now contains that work, so #1025 should be closed rather than merged.

Why

McpClient spoke only the legacy initialize handshake, reaching 2024-11-05 through 2025-11-25. The 2026-07-28 revision replaces that handshake with a stateless server/discover probe 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 negotiates 2026-07-28 through UiPath's transport adapter, and the SDK ships the era-negotiation policy mcp.client._probe.negotiate_auto that mcp.Client(mode="auto") itself uses.

How agents use it

Nothing changes for existing callers. create_mcp_tools_and_clients still defaults to the legacy handshake, and SessionInfoDebugState keeps persisting server-minted session IDs exactly as before:

tools, clients = await create_mcp_tools_and_clients(
    resources, session_info_factory=SessionInfoDebugStateFactory(agent_id)
)

To opt into the new era, construct the client directly and pass a mode:

client = McpClient(
    config=resource,
    session_info_factory=SessionInfoDebugStateFactory(agent_id),
    protocol_mode="auto",   # probe 2026-07-28, fall back to the handshake
)
protocol_mode Behaviour
"legacy" (default) initialize only — today's behaviour, unchanged
"auto" probe server/discover, fall back to the handshake
"modern" server/discover only, no fallback

AgentHub needs no change. 2026-07-28 removes mcp-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/discover included), which a server-assigned session ID never could be.

uipath-agents-python needs no change either. SessionInfo stores whichever ID the server uses, so SessionInfoDebugState persists 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_clients has no protocol_mode parameter yet, so the new era is only reachable by constructing McpClient directly. Deliberate for now — the default must stay legacy — but worth a follow-up if AgentHub wants it plumbed through.

What changed

ProtocolStrategy abstracts 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 stateless 2026-07-28 and stop issuing session IDs, breaking the AgentHub playground persistence SessionInfoDebugState exists 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-25 was adopted as 2025-03-26, disabling the server's 2025-11-25 SSE 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-runs initialize inside 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-id back could overwrite the client-minted routing key mid-connection; and disposal sent DELETE for 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.py drops from ~800 lines to a thin adapter over the upstream transport; langchain-mcp-adapters is replaced by a first-party session-to-LangChain converter (it imports RequestContext, removed in MCP 2); httpx.Timeout remains accepted.

Testing

McpClient is tested against a real MCPServer over 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-05 and 2025-03-26 were 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-mcp adds an LLM-free integration testcase hosting MCP over Streamable HTTP on real sockets, covering the same matrix plus the exact API surface uipath-agents-python depends on.

Breaking changes

Version bumped to 0.17.0. SDK 2.0 renamed the raw result-model attributes McpClient deliberately returns:

Before After
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. Plain model_dump() now emits snake_case keys, 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. 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 header 2026-07-28 doesn'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

  • Use uipath pack --nolock to get the latest dev build from this PR (requires version range).
  • Add this package as a dependency in your pyproject.toml:
[project]
dependencies = [
  # Exact version:
  "uipath-langchain==0.17.0.dev1010535657",

  # Any version from PR
  "uipath-langchain>=0.17.0.dev1010530000,<0.17.0.dev1010540000"
]

[[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
publish-url = "https://test.pypi.org/legacy/"
explicit = true

[tool.uv.sources]
uipath-langchain = { index = "testpypi" }

[tool.uv]
override-dependencies = [
    "uipath-langchain>=0.17.0.dev1010530000,<0.17.0.dev1010540000",
]

Copilot AI lite review requested due to automatic review settings August 28, 2026 08:41

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 ProtocolStrategy implementations (legacy, modern, auto) and wires them into McpClient(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.

Comment thread tests/agent/tools/test_mcp/claude.md Outdated
Comment thread src/uipath_langchain/agent/tools/mcp/protocol_strategy.py
Comment thread src/uipath_langchain/agent/tools/mcp/protocol_strategy.py
Comment thread src/uipath_langchain/agent/tools/mcp/streamable_http.py Outdated
@ionmincu
ionmincu changed the base branch from chore/upgrade-mcp-sdk-latest to main August 28, 2026 10:44
@ionmincu ionmincu closed this Aug 28, 2026
@ionmincu ionmincu reopened this Aug 28, 2026
@ionmincu
ionmincu force-pushed the feat/mcp-2026-protocol-support branch 4 times, most recently from 5ea8312 to 8c59ff1 Compare August 28, 2026 15:14
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
ionmincu force-pushed the feat/mcp-2026-protocol-support branch from 8c59ff1 to e1b06b2 Compare August 28, 2026 15:20
@sonarqubecloud

Copy link
Copy Markdown

@ionmincu ionmincu self-assigned this Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants