33"""MCP protocol adapter using official Python MCP SDK."""
44
55import asyncio
6+ import contextlib
67import logging
78import time
89from collections .abc import Awaitable , Callable
9- from contextlib import AsyncExitStack
10+ from contextlib import AsyncExitStack , asynccontextmanager
1011from typing import TYPE_CHECKING , Any
1112from urllib .parse import urlparse
1213
2728 from mcp import ClientSession
2829
2930try :
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 ,)
3646except ImportError :
3747 MCP_AVAILABLE = False
48+ _MCP_HTTP_STATUS_ERROR_TYPES = ()
3849
3950try :
4051 import httpx as _httpx
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+
5066import json
5167
5268from adcp import _idempotency
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+
104129def _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+
142224def _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+
155245def 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