Skip to content

Commit 1ca7e07

Browse files
authored
build: update MCP Python SDK to v2
Updates the ADCP MCP integration for MCP Python SDK v2.\n\nValidation: full local pytest suite passed; pre-commit hooks passed; GitHub CI and Argus checks passed.
1 parent a2610a5 commit 1ca7e07

14 files changed

Lines changed: 453 additions & 188 deletions

examples/minimal_sales_agent.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
import uuid
3636
from typing import Any
3737

38-
from mcp.server.fastmcp import FastMCP
38+
from mcp.server import MCPServer
3939

4040
from adcp.types import (
4141
CpmPricingOption,
@@ -190,7 +190,7 @@
190190

191191
# -- MCP server ----------------------------------------------------------------
192192

193-
mcp = FastMCP(
193+
mcp = MCPServer(
194194
"Riverdale Gazette",
195195
instructions=(
196196
"You are the advertising sales agent for the Riverdale Gazette, "

pyproject.toml

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,10 @@ dependencies = [
6767
# rather than relying on a2a-sdk's transitive pin (which is wider).
6868
"protobuf>=6,<8",
6969
"sse-starlette>=2.0", # required by a2a-sdk v0.3 compat adapter
70-
# 1.27.0 added ``session_idle_timeout`` to ``StreamableHTTPSessionManager``
71-
# which we pass when adopters opt into stateful streamable-http. Upper
72-
# bound at <2.0 because ``adcp.server.serve.create_mcp_server`` reads
73-
# FastMCP private attrs (``_mcp_server``, ``_event_store``,
74-
# ``_retry_interval``) when pre-creating the session manager — that
75-
# contract is not preserved across majors, and upstream signaled v2
76-
# development on ``main`` (v1.x maintenance branch only ports critical
77-
# fixes). Bump deliberately when v2 lands.
78-
"mcp>=1.27.0,<2.0",
70+
# MCP Python SDK v2 is the stable line for the 2026-07-28 MCP spec.
71+
# The ADCP integration uses the v2 ``MCPServer`` API and the
72+
# streamable-HTTP session manager's explicit configuration arguments.
73+
"mcp>=2.0.0,<3.0",
7974
"email-validator>=2.0.0",
8075
"cryptography>=41.0.0",
8176
# RFC 8785 JSON Canonicalization Scheme — used by the server-side

src/adcp/protocols/mcp.py

Lines changed: 136 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
"""MCP protocol adapter using official Python MCP SDK."""
44

55
import asyncio
6+
import contextlib
67
import logging
78
import time
89
from collections.abc import Awaitable, Callable
9-
from contextlib import AsyncExitStack
10+
from contextlib import AsyncExitStack, asynccontextmanager
1011
from typing import TYPE_CHECKING, Any
1112
from urllib.parse import urlparse
1213

@@ -27,14 +28,24 @@
2728
from mcp import ClientSession
2829

2930
try:
31+
import anyio
32+
import httpx2 as _mcp_httpx
3033
from mcp import ClientSession as _ClientSession
3134
from mcp.client.sse import sse_client
32-
from mcp.client.streamable_http import MCP_SESSION_ID, streamablehttp_client
35+
from mcp.client.streamable_http import (
36+
MCP_SESSION_ID,
37+
StreamableHTTPTransport,
38+
)
39+
from mcp.shared._compat import resync_tracer
40+
from mcp.shared._context_streams import create_context_streams
3341
from mcp.shared._httpx_utils import MCP_DEFAULT_SSE_READ_TIMEOUT, MCP_DEFAULT_TIMEOUT
42+
from mcp.shared.message import SessionMessage
3443

3544
MCP_AVAILABLE = True
45+
_MCP_HTTP_STATUS_ERROR_TYPES: tuple[type[BaseException], ...] = (_mcp_httpx.HTTPStatusError,)
3646
except ImportError:
3747
MCP_AVAILABLE = False
48+
_MCP_HTTP_STATUS_ERROR_TYPES = ()
3849

3950
try:
4051
import httpx as _httpx
@@ -47,6 +58,11 @@
4758
_HTTP_STATUS_ERROR_TYPES = ()
4859
_httpx = None # type: ignore[assignment]
4960

61+
_ALL_HTTP_STATUS_ERROR_TYPES: tuple[type[BaseException], ...] = (
62+
*_HTTP_STATUS_ERROR_TYPES,
63+
*_MCP_HTTP_STATUS_ERROR_TYPES,
64+
)
65+
5066
import json
5167

5268
from adcp import _idempotency
@@ -71,55 +87,62 @@
7187
_MAX_TEXT_SIZE_BYTES = 1_048_576 # 1MB cap on text items before JSON.parse
7288

7389

74-
def _make_hardened_mcp_http_factory() -> Callable[..., httpx.AsyncClient]:
90+
def _make_hardened_mcp_http_factory() -> Callable[..., Any]:
7591
"""Build an MCP HTTP client factory that ignores proxy environment variables."""
7692

7793
def factory(
7894
headers: dict[str, str] | None = None,
79-
timeout: httpx.Timeout | None = None,
80-
auth: httpx.Auth | None = None,
95+
timeout: Any = None,
96+
auth: Any = None,
8197
**extra: Any,
82-
) -> httpx.AsyncClient:
98+
) -> Any:
8399
has_sensitive_request_state = bool(headers) or auth is not None
84100
kwargs: dict[str, Any] = {
85101
**extra,
86102
"follow_redirects": not has_sensitive_request_state,
87103
"trust_env": False,
88104
}
89-
if timeout is None:
90-
kwargs["timeout"] = _httpx.Timeout(
91-
MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT
92-
)
93-
else:
94-
kwargs["timeout"] = timeout
105+
kwargs["timeout"] = _coerce_mcp_timeout(timeout)
95106
if headers is not None:
96107
kwargs["headers"] = headers
97108
if auth is not None:
98109
kwargs["auth"] = auth
99-
return _httpx.AsyncClient(**kwargs)
110+
return _mcp_httpx.AsyncClient(**kwargs)
100111

101112
return factory
102113

103114

115+
def _coerce_mcp_timeout(timeout: Any) -> Any:
116+
if timeout is None:
117+
return _mcp_httpx.Timeout(MCP_DEFAULT_TIMEOUT, read=MCP_DEFAULT_SSE_READ_TIMEOUT)
118+
if isinstance(timeout, _mcp_httpx.Timeout):
119+
return timeout
120+
connect = getattr(timeout, "connect", None)
121+
read = getattr(timeout, "read", None)
122+
write = getattr(timeout, "write", None)
123+
pool = getattr(timeout, "pool", None)
124+
if all(value is not None for value in (connect, read, write, pool)):
125+
return _mcp_httpx.Timeout(connect=connect, read=read, write=write, pool=pool)
126+
return timeout
127+
128+
104129
def _make_signing_http_factory(
105-
hook: Callable[[httpx.Request], Awaitable[None]],
106-
) -> Callable[..., httpx.AsyncClient]:
107-
"""Build an ``httpx_client_factory`` that installs a signing request hook.
108-
109-
``streamablehttp_client`` accepts a factory with signature
110-
``(headers, timeout, auth) -> httpx.AsyncClient``. Our factory forwards
111-
those kwargs, registers the signing hook as an httpx request event
112-
hook, and disables redirect following: an RFC 9421 signature binds the
113-
original ``@authority``, so a 302 to a different host would send the
114-
signed request onward with a stale authority and fail verification.
130+
hook: Callable[[Any], Awaitable[None]],
131+
) -> Callable[..., Any]:
132+
"""Build an MCP HTTP client factory that installs a signing request hook.
133+
134+
MCP SDK v2 uses ``httpx2`` internally, but the signing hook only relies
135+
on the request's method, URL, headers, and body attributes shared with
136+
``httpx``. Redirects stay disabled because an RFC 9421 signature binds
137+
the original ``@authority``.
115138
"""
116139

117140
def factory(
118141
headers: dict[str, str] | None = None,
119-
timeout: httpx.Timeout | None = None,
120-
auth: httpx.Auth | None = None,
142+
timeout: Any = None,
143+
auth: Any = None,
121144
**extra: Any,
122-
) -> httpx.AsyncClient:
145+
) -> Any:
123146
# Forward any future MCP-SDK kwargs (e.g. verify=, cert=) verbatim
124147
# so adding a new factory parameter upstream doesn't break signing.
125148
kwargs: dict[str, Any] = {
@@ -128,17 +151,76 @@ def factory(
128151
"event_hooks": {"request": [hook]},
129152
"trust_env": False,
130153
}
131-
if timeout is not None:
132-
kwargs["timeout"] = timeout
154+
kwargs["timeout"] = _coerce_mcp_timeout(timeout)
133155
if headers is not None:
134156
kwargs["headers"] = headers
135157
if auth is not None:
136158
kwargs["auth"] = auth
137-
return _httpx.AsyncClient(**kwargs)
159+
return _mcp_httpx.AsyncClient(**kwargs)
138160

139161
return factory
140162

141163

164+
@asynccontextmanager
165+
async def streamablehttp_client(
166+
url: str,
167+
*,
168+
headers: dict[str, str] | None = None,
169+
timeout: Any = None,
170+
httpx_client_factory: Callable[..., Any] | None = None,
171+
auth: Any = None,
172+
terminate_on_close: bool = True,
173+
) -> Any:
174+
"""Compatibility wrapper matching the MCP SDK v1 streamable client shape.
175+
176+
MCP SDK v2's convenience client accepts a pre-built ``httpx2`` client and
177+
yields only ``(read, write)``. ADCP exposes the current MCP session id, so
178+
this mirrors the v2 transport setup while returning ``get_session_id`` as
179+
the third tuple item used by older ADCP code.
180+
"""
181+
182+
if httpx_client_factory is None:
183+
httpx_client_factory = _make_hardened_mcp_http_factory()
184+
185+
transport = StreamableHTTPTransport(url)
186+
client = httpx_client_factory(headers=headers, timeout=timeout, auth=auth)
187+
188+
async with contextlib.AsyncExitStack() as stack:
189+
await stack.enter_async_context(client)
190+
191+
read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0)
192+
write_stream, write_stream_reader = create_context_streams[SessionMessage](0)
193+
194+
async with (
195+
read_stream_writer,
196+
read_stream,
197+
write_stream,
198+
write_stream_reader,
199+
anyio.create_task_group() as tg,
200+
):
201+
202+
def start_get_stream() -> None:
203+
tg.start_soon(transport.handle_get_stream, client, read_stream_writer)
204+
205+
tg.start_soon(
206+
transport.post_writer,
207+
client,
208+
write_stream_reader,
209+
read_stream_writer,
210+
write_stream,
211+
start_get_stream,
212+
tg,
213+
)
214+
215+
try:
216+
yield read_stream, write_stream, lambda: transport.session_id
217+
finally:
218+
if transport.session_id and terminate_on_close:
219+
await transport.terminate_session(client)
220+
tg.cancel_scope.cancel()
221+
await resync_tracer()
222+
223+
142224
def _text_of(item: Any) -> str | None:
143225
"""Return the text payload of an MCP content item, or None if not a text item."""
144226
if isinstance(item, dict):
@@ -152,6 +234,14 @@ def _text_of(item: Any) -> str | None:
152234
return text if isinstance(text, str) and text else None
153235

154236

237+
def _result_is_error(result: Any) -> bool:
238+
return bool(getattr(result, "isError", getattr(result, "is_error", False)))
239+
240+
241+
def _result_structured_content(result: Any) -> Any:
242+
return getattr(result, "structuredContent", getattr(result, "structured_content", None))
243+
244+
155245
def extract_adcp_success(result: Any) -> dict[str, Any] | None:
156246
"""Extract AdCP success response data from an MCP tool result.
157247
@@ -167,10 +257,10 @@ def extract_adcp_success(result: Any) -> dict[str, Any] | None:
167257
if it is a non-array object that is NOT ``adcp_error``-only.
168258
4. No structured data found — return ``None``.
169259
"""
170-
if getattr(result, "isError", False):
260+
if _result_is_error(result):
171261
return None
172262

173-
sc = getattr(result, "structuredContent", None)
263+
sc = _result_structured_content(result)
174264
if isinstance(sc, dict) and not (len(sc) == 1 and "adcp_error" in sc):
175265
return sc
176266

@@ -194,10 +284,10 @@ def extract_adcp_error(result: Any) -> dict[str, Any] | None:
194284
docs/building/implementation/transport-errors.mdx. Only applies when
195285
``isError`` is truthy. Returns a validated error object or ``None``.
196286
"""
197-
if not getattr(result, "isError", False):
287+
if not _result_is_error(result):
198288
return None
199289

200-
sc = getattr(result, "structuredContent", None)
290+
sc = _result_structured_content(result)
201291
if isinstance(sc, dict):
202292
validated = _validate_adcp_error(sc.get("adcp_error"))
203293
if validated is not None:
@@ -365,7 +455,7 @@ def _log_cleanup_error(self, exc: BaseException, context: str) -> None:
365455
) or (
366456
# HTTP errors during cleanup (if httpx is available)
367457
HTTPX_AVAILABLE
368-
and isinstance(exc, _HTTP_STATUS_ERROR_TYPES)
458+
and isinstance(exc, _ALL_HTTP_STATUS_ERROR_TYPES)
369459
)
370460

371461
if is_known_cleanup_error:
@@ -602,7 +692,7 @@ async def _call_mcp_tool(self, tool_name: str, params: dict[str, Any]) -> TaskRe
602692
_signing_operation.reset(signing_token)
603693

604694
# Check if this is an error response
605-
is_error = hasattr(result, "isError") and result.isError
695+
is_error = _result_is_error(result)
606696

607697
# Extract human-readable message from content
608698
message_text = None
@@ -925,15 +1015,23 @@ async def close_mcp_session(self, session_id: str | None = None) -> None:
9251015
headers = self._http_headers()
9261016
headers[MCP_SESSION_ID] = session_id
9271017
timeout = _httpx.Timeout(self.agent_config.timeout)
928-
httpx_client_factory = self._streamable_http_client_factory()
1018+
event_hooks: dict[str, list[Any]] = {}
1019+
if self.signing_request_hook is not None:
1020+
event_hooks["request"] = [self.signing_request_hook]
9291021
urls_to_try = (
9301022
[self._connected_url] if self._connected_url is not None else self._urls_to_try()
9311023
)
9321024

9331025
last_error: BaseException | None = None
9341026
for url in urls_to_try:
9351027
try:
936-
async with httpx_client_factory(headers=headers, timeout=timeout) as client:
1028+
async with _httpx.AsyncClient(
1029+
headers=headers,
1030+
timeout=timeout,
1031+
follow_redirects=False,
1032+
trust_env=False,
1033+
event_hooks=event_hooks,
1034+
) as client:
9371035
response = await client.delete(url)
9381036
if response.is_redirect:
9391037
location = response.headers.get("location")
@@ -949,7 +1047,7 @@ async def close_mcp_session(self, session_id: str | None = None) -> None:
9491047
if current_session_id == session_id:
9501048
await self._cleanup_failed_connection("after explicit MCP session close")
9511049
return
952-
except _HTTP_STATUS_ERROR_TYPES as exc:
1050+
except _ALL_HTTP_STATUS_ERROR_TYPES as exc:
9531051
last_error = exc
9541052
# Keep fallback behavior symmetrical with session initialization:
9551053
# a 404/405 on one candidate usually means "try the slash variant".

0 commit comments

Comments
 (0)