Skip to content

Commit ba61116

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

2 files changed

Lines changed: 63 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: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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):
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+
async def test_session_group_streamable_http_connect_error_is_catchable(
26+
monkeypatch: pytest.MonkeyPatch,
27+
) -> None:
28+
async def raise_connect_error(request: httpx.Request) -> httpx.Response:
29+
raise httpx.ConnectError("server unavailable", request=request)
30+
31+
def mock_http_client(
32+
headers: dict[str, str] | None = None,
33+
timeout: httpx.Timeout | None = None,
34+
auth: httpx.Auth | None = None,
35+
) -> httpx.AsyncClient:
36+
return httpx.AsyncClient(
37+
auth=auth,
38+
headers=headers,
39+
timeout=timeout,
40+
transport=httpx.MockTransport(raise_connect_error),
41+
)
42+
43+
monkeypatch.setattr("mcp.client.session_group.create_mcp_http_client", mock_http_client)
44+
45+
async with ClientSessionGroup() as group:
46+
with anyio.fail_after(5), pytest.raises(MCPError) as exc_info:
47+
await group.connect_to_server(StreamableHttpParameters(url="http://example.invalid/mcp"))
48+
49+
assert "Transport error: server unavailable" in exc_info.value.error.message
50+
assert not _contains_cancel_scope_error(exc_info.value)

0 commit comments

Comments
 (0)