Skip to content

Commit 3bcaf66

Browse files
committed
fix(client): surface streamable http transport errors
1 parent cf110e3 commit 3bcaf66

2 files changed

Lines changed: 81 additions & 5 deletions

File tree

src/mcp/client/streamable_http.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -468,11 +468,19 @@ async def _handle_message(session_message: SessionMessage) -> None:
468468
read_stream_writer=read_stream_writer,
469469
)
470470

471-
async def handle_request_async():
472-
if is_resumption:
473-
await self._handle_resumption_request(ctx)
474-
else:
475-
await self._handle_post_request(ctx)
471+
async def handle_request_async() -> None:
472+
try:
473+
if is_resumption:
474+
await self._handle_resumption_request(ctx)
475+
else:
476+
await self._handle_post_request(ctx)
477+
except httpx.HTTPError as exc:
478+
logger.exception("Transport error handling request")
479+
if isinstance(message, JSONRPCRequest):
480+
error_data = ErrorData(code=INTERNAL_ERROR, message=f"Transport error: {exc}")
481+
error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data))
482+
with contextlib.suppress(anyio.BrokenResourceError, anyio.ClosedResourceError):
483+
await read_stream_writer.send(error_msg)
476484

477485
# If this is a request, start a new task to handle it
478486
if isinstance(message, JSONRPCRequest):
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from typing import cast
2+
3+
import anyio
4+
import httpx
5+
import pytest
6+
7+
from mcp.client.session_group import ClientSessionGroup, StreamableHttpParameters
8+
from mcp.shared.exceptions import MCPError
9+
10+
pytestmark = pytest.mark.anyio
11+
12+
13+
def _contains_cancel_scope_error(exc: BaseException) -> bool:
14+
if isinstance(exc, RuntimeError) and "Attempted to exit cancel scope" in str(exc):
15+
return True
16+
17+
raw_grouped_exceptions = getattr(exc, "exceptions", ())
18+
if isinstance(raw_grouped_exceptions, tuple) and raw_grouped_exceptions:
19+
grouped_exceptions = cast(tuple[BaseException, ...], raw_grouped_exceptions)
20+
return any(_contains_cancel_scope_error(inner) for inner in grouped_exceptions)
21+
22+
return any(_contains_cancel_scope_error(inner) for inner in (exc.__cause__, exc.__context__) if inner is not None)
23+
24+
25+
def test_contains_cancel_scope_error_follows_exception_tree() -> None:
26+
cancel_scope_error = RuntimeError("Attempted to exit cancel scope in a different task than it was entered in")
27+
wrapped = RuntimeError("wrapped")
28+
wrapped.__cause__ = cancel_scope_error
29+
30+
assert _contains_cancel_scope_error(wrapped)
31+
32+
33+
def test_contains_cancel_scope_error_follows_grouped_exceptions() -> None:
34+
cancel_scope_error = RuntimeError("Attempted to exit cancel scope in a different task than it was entered in")
35+
36+
class DummyGroup(Exception):
37+
def __init__(self) -> None:
38+
self.exceptions = (cancel_scope_error,)
39+
40+
assert _contains_cancel_scope_error(DummyGroup())
41+
42+
43+
async def test_session_group_streamable_http_connect_error_is_catchable(
44+
monkeypatch: pytest.MonkeyPatch,
45+
) -> None:
46+
async def raise_connect_error(request: httpx.Request) -> httpx.Response:
47+
raise httpx.ConnectError("server unavailable", request=request)
48+
49+
def mock_http_client(
50+
headers: dict[str, str] | None = None,
51+
timeout: httpx.Timeout | None = None,
52+
auth: httpx.Auth | None = None,
53+
) -> httpx.AsyncClient:
54+
return httpx.AsyncClient(
55+
auth=auth,
56+
headers=headers,
57+
timeout=timeout,
58+
transport=httpx.MockTransport(raise_connect_error),
59+
)
60+
61+
monkeypatch.setattr("mcp.client.session_group.create_mcp_http_client", mock_http_client)
62+
63+
async with ClientSessionGroup() as group:
64+
with anyio.fail_after(5), pytest.raises(MCPError) as exc_info:
65+
await group.connect_to_server(StreamableHttpParameters(url="http://example.invalid/mcp"))
66+
67+
assert "Transport error: server unavailable" in exc_info.value.error.message
68+
assert not _contains_cancel_scope_error(exc_info.value)

0 commit comments

Comments
 (0)