Skip to content
155 changes: 155 additions & 0 deletions docs/mcp-sdk-2-upgrade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# Migrating to MCP Python SDK 2.0

## Outcome

`uipath-langchain` 0.17.0 pins `mcp==2.0.0`, the latest stable MCP Python
SDK in the local upstream checkout. The lockfile resolves its new `mcp-types`
and `httpx2` dependencies. `langchain-mcp-adapters` does not support MCP 2, so
the direct dependency was removed and the in-repository consumers now use
UiPath's session-to-LangChain tool converter.

The UiPath client continues to use the SDK's low-level `ClientSession` and
Streamable HTTP transport. Its externally persisted session-ID extension is
now a small adapter around the upstream transport rather than a private copy of
the complete transport.

## What UiPath changed in the old SDK 1.26 transport copy

The private `streamable_http.py` was introduced in commit `9c038fa2` and was
based on `mcp.client.streamable_http` from MCP Python SDK 1.26. Compared with
that upstream implementation, UiPath added:

- An asynchronous `SessionInfo` abstraction whose `get_session_id()` and
`set_session_id()` methods can be overridden to load and save AgentHub debug
state.
- Asynchronous request-header preparation so every request can load the latest
externally stored session ID.
- Persistence of the `mcp-session-id` returned by an initialization response.
- A `session_info` argument on the local context manager, replacing the old
transport's session-ID callback shape.
- Raw response-body logging for HTTP error responses.

The resulting file duplicated roughly 800 lines of SDK transport code. That
made fixes and new protocol behavior in upstream Streamable HTTP unavailable
without manually merging the copy.

## How Streamable HTTP evolved in SDK 2.0

SDK 2.0's upstream transport now owns substantially more behavior than the
1.26 copy, including:

- Legacy initialization and 2026 modern-protocol routing.
- `Mcp-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` headers.
- Correct JSON-RPC errors from non-2xx response bodies and request-scoped
fallback errors when a body is absent.
- SSE resumption with `Last-Event-ID` and bounded reconnection.
- 2026 HTTP cancellation by aborting the in-flight request POST.
- GET channel and DELETE session lifecycle handling.
- Per-request error delivery rather than transport-wide exception groups.

The UiPath adapter now delegates all of this to
`mcp.client.streamable_http.streamable_http_client`. Two `httpx2` event hooks
provide the UiPath-specific behavior:

1. Before a request, asynchronously load `SessionInfo` and set or remove
`mcp-session-id`.
2. After a response, persist a returned `mcp-session-id` through `SessionInfo`.

This retains compatibility with `SessionInfoDebugState` in
`uipath-agents-python` without forking the transport again.

The old raw error-body logging was deliberately not recreated. SDK 2.0 now
parses a JSON-RPC error carried by a non-2xx response and surfaces its message
through `MCPError`; logging an arbitrary raw server body would add payload and
credential-leak risk without improving the structured error path.

## SDK 2.0 breaking changes relevant here

| SDK 1.x API | SDK 2.0 API / behavior | Upgrade action |
| --- | --- | --- |
| `McpError` | `MCPError(code, message, data)` | Updated imports, catches, construction, and tests. |
| Python model fields such as `inputSchema` and `outputSchema` | `input_schema` and `output_schema` | Updated all attribute reads. Wire JSON remains camelCase. |
| JSON-RPC root-model wrappers and `.root` | Plain discriminated message unions | The old copied transport was removed, eliminating these accesses locally. |
| `httpx` plus `httpx-sse` | `httpx2`, including SSE support | MCP connection and timeout types now use `httpx2`. |
| `httpx.Timeout` or seconds as `float` | `httpx2.Timeout` | `McpClient` continues to accept the old `httpx.Timeout` type and converts all four phase values for the MCP 2 transport. |
| Transport `get_session_id` callback | No callback | Replaced with request/response event hooks. |
| `StreamableHTTPTransport.protocol_version` | Removed | Version handling is left to `ClientSession` and the transport. |
| Transport failures may surface through an `ExceptionGroup` | A request receives an `MCPError` | Retry logic catches `MCPError` directly. |
| Recalling `ClientSession.initialize()` could be used as local recovery logic | Initialization is idempotent per `ClientSession` | Recovery now replaces the transport and `ClientSession`, then performs a fresh handshake. |
| Experimental Tasks APIs | Removed | No UiPath code used them. |

### `McpClient` result-model migration

`McpClient` is a public low-level API and continues to return the MCP SDK's raw
Pydantic result models. SDK 2.0 renamed their Python attributes to snake case.
Callers upgrading to `uipath-langchain` 0.17.0 must update direct attribute
access as follows:

| SDK 1.x Python attribute | SDK 2.0 Python attribute |
| --- | --- |
| `ListToolsResult.nextCursor` | `ListToolsResult.next_cursor` |
| `Tool.inputSchema` | `Tool.input_schema` |
| `Tool.outputSchema` | `Tool.output_schema` |
| `CallToolResult.structuredContent` | `CallToolResult.structured_content` |
| `CallToolResult.isError` | `CallToolResult.is_error` |

The JSON wire format and `model_dump(by_alias=True)` output remain camelCase.
UiPath's higher-level LangChain tools perform this migration internally; only
consumers that use exported `McpClient` results directly need code changes.

## Protocol-version and backward-compatibility behavior

MCP SDK 2.0 declares these legacy handshake versions:

- `2024-11-05`
- `2025-03-26`
- `2025-06-18`
- `2025-11-25`

It also declares `2026-07-28` as a modern protocol version. The high-level SDK
`Client(mode="auto")` probes modern discovery and falls back to a legacy
initialization handshake.

UiPath currently uses low-level `ClientSession.initialize()`. That method sends
the latest legacy version (`2025-11-25`) and accepts any version in the legacy
handshake set returned by the server. Therefore:

| Server behavior | Current UiPath client |
| --- | --- |
| Negotiates `2025-03-26` | Supported and tested. |
| Negotiates `2025-06-18` | Supported and tested. |
| Negotiates `2025-11-25` | Supported and tested. |
| Supports 2026 but also accepts legacy initialize | Connects in legacy mode. |
| Supports only modern `2026-07-28` discovery | Not supported by the current low-level UiPath connection path. |

Supporting a 2026-only server would require adopting the high-level auto mode
or reproducing its discover/adopt flow. That is a separate behavior change from
this dependency upgrade.

## Session recovery details

For sessions initialized in the current process, SDK 2.0 maps a bare HTTP 404
to `MCPError(INVALID_REQUEST, "Session terminated")`. UiPath recognizes that
error and `CONNECTION_CLOSED`, closes the old connection stack, clears the
external session ID, and opens a fresh transport and `ClientSession` over the
same authenticated HTTP client.

An externally restored session ID is not stored inside the new transport; it is
injected by the request hook. Consequently, the transport initially maps a bare
404 to `METHOD_NOT_FOUND`. UiPath disambiguates that exact bare-404 error when
an external session ID was attached, clears the stale ID, initializes a new
session, and retries. JSON-RPC `METHOD_NOT_FOUND` errors with a response body
are not retried.

## Validation added

The MCP tests use the real SDK 2.0 `ClientSession` and Streamable HTTP transport
over `httpx2.MockTransport`. They cover:

- Negotiation with `2025-03-26`, `2025-06-18`, and `2025-11-25` servers.
- Session-header capture and reuse.
- Replacing the transport/session after HTTP 404 while reusing the HTTP client.
- Reuse of an externally persisted session without another initialization.
- Recovery from an expired externally persisted session.
- Retry exhaustion and non-session error classification.
- Tool listing cache/refresh, disposal/reuse, and full tool-call mapping.
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "uipath-langchain"
version = "0.16.5"
version = "0.17.0"
description = "Python SDK that enables developers to build and deploy LangGraph agents to the UiPath Cloud Platform"
readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.11"
Expand All @@ -18,11 +18,11 @@ dependencies = [
"pydantic-settings>=2.6.0",
"python-dotenv>=1.0.1",
"httpx>=0.27.0",
"httpx2>=2.5.0, <2.10.0",
"openinference-instrumentation-langchain>=0.1.69, <0.2.0",
"jsonschema-pydantic-converter>=0.4.0",
"jsonpath-ng>=1.7.0",
"mcp==1.26.0",
"langchain-mcp-adapters==0.2.1",
"mcp==2.0.0",

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.

Restore compatibility with langchain-mcp-adapters before pinning MCP 2.0. The repository’s own simple-local-mcp integration resolves langchain-mcp-adapters==0.3.1 with this pin, then fails at import because the adapter imports RequestContext removed by MCP 2. I reproduced the same failure with uv run --with langchain-mcp-adapters==0.3.1 ..., and the alpha/cloud/staging integration jobs all fail on it. The adapter metadata only says mcp>=1.24.0, so dependency resolution cannot protect downstream users. Land a compatible adapter/migration for this supported path, or prevent the incompatible combination from resolving.

"pillow>=12.1.1",
"rdflib>=7.0.0, <8.0.0",
"a2a-sdk>=1.1.2,<2.0.0",
Expand Down
5 changes: 2 additions & 3 deletions samples/oauth-external-apps-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ The workflow follows a ReAct pattern:

- Python 3.11+
- `uipath-langchain`
- `langchain-mcp-adapters`
- MCP Python SDK 2.0
- `langgraph`
- `httpx`
- `httpx2`
- `python-dotenv`
- UiPath OAuth credentials and MCP server URL in environment
- UiPath external application configured with `OR.Jobs` scope (or appropriate scope for your MCP server)
Expand Down Expand Up @@ -81,4 +81,3 @@ For debugging issues:
uipath run agent --debug '{"task": "What is 2 + 2?"}'
```


76 changes: 47 additions & 29 deletions samples/oauth-external-apps-agent/main.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import os
import dotenv
import httpx
from contextlib import asynccontextmanager
from typing import Optional, Literal
from typing import Literal, Optional

from pydantic import BaseModel
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command
import dotenv
import httpx2
from langchain.agents import create_agent
from langchain.messages import SystemMessage, HumanMessage

from uipath_langchain.chat.models import UiPathChat
from langchain_mcp_adapters.tools import load_mcp_tools
from langchain.messages import HumanMessage, SystemMessage
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command
from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
from mcp.client.streamable_http import streamable_http_client
from pydantic import BaseModel
from uipath.platform import UiPath

from uipath_langchain.agent.tools.mcp import load_mcp_tools
from uipath_langchain.chat.models import UiPathChat

dotenv.load_dotenv()

UIPATH_CLIENT_ID = "EXTERNAL_APP_CLIENT_ID_HERE"
Expand All @@ -24,17 +24,21 @@
UIPATH_URL = "base_url"
UIPATH_MCP_SERVER_URL = os.getenv("UIPATH_MCP_SERVER_URL")


class GraphInput(BaseModel):
task: str


class GraphOutput(BaseModel):
result: str


class State(BaseModel):
task: str
access_token: Optional[str] = os.getenv("UIPATH_ACCESS_TOKEN")
result: Optional[str] = None


async def fetch_new_access_token(state: State) -> Command:
try:
UiPath(
Expand All @@ -46,44 +50,58 @@ async def fetch_new_access_token(state: State) -> Command:
return Command(update={"access_token": os.getenv("UIPATH_ACCESS_TOKEN")})

except Exception as e:
raise Exception(f"Failed to initialize UiPath SDK: {str(e)}")
raise Exception(f"Failed to initialize UiPath SDK: {str(e)}") from e


@asynccontextmanager
async def agent_mcp(access_token: str):
async with streamablehttp_client(
url=UIPATH_MCP_SERVER_URL,
async with httpx2.AsyncClient(
headers={"Authorization": f"Bearer {access_token}"},
timeout=60,
) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await load_mcp_tools(session)
model = UiPathChat(model="anthropic.claude-3-5-sonnet-20240620-v1:0")
agent = create_agent(model, tools=tools)
yield agent
timeout=httpx2.Timeout(60),
follow_redirects=True,
) as http_client:
async with streamable_http_client(
url=UIPATH_MCP_SERVER_URL,
http_client=http_client,
) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await load_mcp_tools(session)
model = UiPathChat(model="anthropic.claude-3-5-sonnet-20240620-v1:0")
agent = create_agent(model, tools=tools)
yield agent


async def connect_to_mcp(state: State) -> Command:
try:
async with agent_mcp(state.access_token) as agent:
agent_response = await agent.ainvoke({
"messages": [
SystemMessage(content="You are a helpful assistant."),
HumanMessage(content=state.task),
],
})
agent_response = await agent.ainvoke(
{
"messages": [
SystemMessage(content="You are a helpful assistant."),
HumanMessage(content=state.task),
],
}
)
return Command(update={"result": agent_response["messages"][-1].content})
except ExceptionGroup as e:
for error in e.exceptions:
if isinstance(error, httpx.HTTPStatusError) and error.response.status_code == 401:
if (
isinstance(error, httpx2.HTTPStatusError)
and error.response.status_code == 401
):
return Command(update={"access_token": None})
raise


def route_start(state: State) -> Literal["fetch_new_access_token", "connect_to_mcp"]:
return "fetch_new_access_token" if state.access_token is None else "connect_to_mcp"


def route_after_connect(state: State):
return "fetch_new_access_token" if state.access_token is None else END


builder = StateGraph(State, input=GraphInput, output=GraphOutput)
builder.add_node("fetch_new_access_token", fetch_new_access_token)
builder.add_node("connect_to_mcp", connect_to_mcp)
Expand Down
3 changes: 2 additions & 1 deletion samples/oauth-external-apps-agent/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ dependencies = [
"langgraph>=1.0.4",
"python-dotenv>=1.0.0",
"anthropic>=0.57.1",
"langchain-mcp-adapters>=0.1.14",
"httpx2>=2.5.0,<2.10.0",
"mcp==2.0.0",
"mypy>=1.17.1",
"uipath",
"uipath-langchain",
Expand Down
4 changes: 2 additions & 2 deletions samples/simple-local-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ The workflow follows a ReAct pattern:

- Python 3.11+
- `langchain-anthropic`
- `langchain-mcp-adapters`
- MCP Python SDK 2.0
- `langgraph`
- Anthropic API key set as an environment variable

Expand Down Expand Up @@ -91,5 +91,5 @@ For debugging issues:
To add a new tool:

1. Create a new MCP-compatible server (similar to math_server.py)
2. Add it to the MultiServerMCPClient configuration dictionary
2. Add its script to the server list in `make_graph`
3. The agent will automatically discover and use the new tool's capabilities
4 changes: 1 addition & 3 deletions samples/simple-local-mcp/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ description = "Math and Weather Local MCP Server Agent"
authors = [{ name = "John Doe", email = "john.doe@myemail.com" }]
dependencies = [
"langchain-anthropic>=1.2.0",
"langchain-mcp-adapters>=0.1.14",
"mcp>=1.15.0",
"mcp==2.0.0",
"uipath",
"uipath-langchain",
]
Expand All @@ -16,4 +15,3 @@ requires-python = ">=3.11"
dev = [
"uipath-dev",
]

Loading
Loading