Skip to content

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

Open
ionmincu wants to merge 2 commits 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 2 commits 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.dev1010535708",

  # 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 5 times, most recently from 8c59ff1 to e1b06b2 Compare August 28, 2026 15:20
@ionmincu ionmincu self-assigned this Aug 31, 2026
# previous connection's resolution must not decide how this failure is
# recovered from.
self._resolved = self._legacy
await negotiate_auto(session)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please do not import mcp.client._probe. It is private SDK surface; use a public API or own this small policy here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

We used it to add affinity in meta json, but i dropped it, we only send it now at header level.

ionmincu added a commit that referenced this pull request Sep 2, 2026
…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>
ionmincu added a commit that referenced this pull request Sep 3, 2026
…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>
@ionmincu
ionmincu force-pushed the feat/mcp-2026-protocol-support branch from be0dd14 to c665b95 Compare September 3, 2026 12:18
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>
@ionmincu
ionmincu force-pushed the feat/mcp-2026-protocol-support branch from c665b95 to 61eeee8 Compare September 3, 2026 13:39
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>
@sonarqubecloud

sonarqubecloud Bot commented Sep 3, 2026

Copy link
Copy Markdown

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.

3 participants