From 9e3d580880f3c968d826cfcd2a574c6a36b65c8d Mon Sep 17 00:00:00 2001 From: Devansh Khandelwal Date: Tue, 30 Jun 2026 08:06:19 +0530 Subject: [PATCH] fix(client): catch httpx transport errors in streamable HTTP post_writer When an unreachable streamable HTTP server causes httpx.HTTPError inside a request task, complete the pending JSON-RPC waiter with an error response instead of letting the exception crash the outer transport task group. Fixes #915 --- src/mcp/client/streamable_http.py | 25 ++++++++++++++--- .../test_915_streamable_http_unreachable.py | 27 +++++++++++++++++++ 2 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 tests/issues/test_915_streamable_http_unreachable.py diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index f28eb7c7a..db9573b55 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -479,10 +479,27 @@ async def _handle_message(session_message: SessionMessage) -> None: ) async def handle_request_async(): - if is_resumption: - await self._handle_resumption_request(ctx) - else: - await self._handle_post_request(ctx) + try: + if is_resumption: + await self._handle_resumption_request(ctx) + else: + await self._handle_post_request(ctx) + except httpx.HTTPError as exc: + # Letting this escape into `tg` would crash the outer task group + # from a different task than the one yielding the streams, + # producing an uncatchable cancel-scope RuntimeError instead of + # a connect error at the caller. + logger.exception("Transport error handling request") + if isinstance(message, JSONRPCRequest): + error_data = ErrorData( + code=INTERNAL_ERROR, + message=f"Transport error: {exc}", + ) + error_msg = SessionMessage( + JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data) + ) + with contextlib.suppress(anyio.BrokenResourceError, anyio.ClosedResourceError): + await read_stream_writer.send(error_msg) # If this is a request, start a new task to handle it if isinstance(message, JSONRPCRequest): diff --git a/tests/issues/test_915_streamable_http_unreachable.py b/tests/issues/test_915_streamable_http_unreachable.py new file mode 100644 index 000000000..a72a1082f --- /dev/null +++ b/tests/issues/test_915_streamable_http_unreachable.py @@ -0,0 +1,27 @@ +"""Regression test for issue #915. + +When a streamable HTTP MCP server is unreachable, httpx transport errors must +complete the pending JSON-RPC waiter instead of escaping into the outer task +group (which surfaces as an uncatchable cancel-scope RuntimeError). +""" + +from __future__ import annotations + +import anyio +import pytest +from mcp_types import INTERNAL_ERROR + +from mcp.client.session_group import ClientSessionGroup, StreamableHttpParameters +from mcp.shared.exceptions import MCPError + + +@pytest.mark.anyio +async def test_unreachable_streamable_http_server_raises_catchable_error() -> None: + async with ClientSessionGroup() as group: + server_params = StreamableHttpParameters(url="http://127.0.0.1:1/mcp/") + with anyio.fail_after(10): + with pytest.raises(MCPError) as exc_info: + await group.connect_to_server(server_params) + + assert exc_info.value.code == INTERNAL_ERROR + assert "Transport error" in exc_info.value.message