Skip to content

Commit b3cb4d8

Browse files
committed
Surface streamable HTTP connection errors
1 parent 2472563 commit b3cb4d8

3 files changed

Lines changed: 55 additions & 6 deletions

File tree

src/mcp/client/session_group.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ def __init__(
147147
self._session_exit_stacks = {}
148148
self._component_name_hook = component_name_hook
149149

150-
async def __aenter__(self) -> Self: # pragma: no cover
150+
async def __aenter__(self) -> Self:
151151
# Enter the exit stack only if we created it ourselves
152152
if self._owns_exit_stack:
153153
await self._exit_stack.__aenter__()
@@ -158,7 +158,7 @@ async def __aexit__(
158158
_exc_type: type[BaseException] | None,
159159
_exc_val: BaseException | None,
160160
_exc_tb: TracebackType | None,
161-
) -> bool | None: # pragma: no cover
161+
) -> bool | None:
162162
"""Closes session exit stacks and main exit stack upon completion."""
163163

164164
# Only close the main exit stack if we created it
@@ -323,7 +323,7 @@ async def _establish_session(
323323
await self._exit_stack.enter_async_context(session_stack)
324324

325325
return result.server_info, session
326-
except Exception: # pragma: no cover
326+
except Exception:
327327
# If anything during this setup fails, ensure the session-specific
328328
# stack is closed.
329329
await session_stack.aclose()

src/mcp/client/streamable_http.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -467,17 +467,26 @@ async def _handle_message(session_message: SessionMessage) -> None:
467467
read_stream_writer=read_stream_writer,
468468
)
469469

470-
async def handle_request_async():
470+
async def send_message() -> None:
471471
if is_resumption:
472472
await self._handle_resumption_request(ctx)
473473
else:
474474
await self._handle_post_request(ctx)
475475

476+
async def handle_request_async(request: JSONRPCRequest) -> None:
477+
try:
478+
await send_message()
479+
except httpx.TransportError as exc:
480+
logger.debug("Error handling request", exc_info=True)
481+
error_data = ErrorData(code=INTERNAL_ERROR, message=f"Transport error: {exc}")
482+
error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=request.id, error=error_data))
483+
await ctx.read_stream_writer.send(error_msg)
484+
476485
# If this is a request, start a new task to handle it
477486
if isinstance(message, JSONRPCRequest):
478-
tg.start_soon(handle_request_async)
487+
tg.start_soon(handle_request_async, message)
479488
else:
480-
await handle_request_async()
489+
await send_message()
481490

482491
async for session_message in write_stream_reader:
483492
sender_ctx = write_stream_reader.last_context

tests/client/test_session_group.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,27 @@ def test_client_session_group_component_properties():
5151
assert mcp_session_group.tools == {"my_tool": mock_tool}
5252

5353

54+
@pytest.mark.anyio
55+
async def test_client_session_group_context_manager_closes_session_stacks_with_external_stack():
56+
class SessionStack(contextlib.AsyncExitStack):
57+
def __init__(self) -> None:
58+
super().__init__()
59+
self.closed = False
60+
61+
async def aclose(self) -> None:
62+
self.closed = True
63+
await super().aclose()
64+
65+
session_stack = SessionStack()
66+
group = ClientSessionGroup(exit_stack=contextlib.AsyncExitStack())
67+
group._session_exit_stacks[mock.Mock(spec=mcp.ClientSession)] = session_stack
68+
69+
async with group as entered:
70+
assert entered is group
71+
72+
assert session_stack.closed
73+
74+
5475
@pytest.mark.anyio
5576
async def test_client_session_group_call_tool():
5677
# --- Mock Dependencies ---
@@ -278,6 +299,25 @@ async def test_client_session_group_disconnect_non_existent_server():
278299
await group.disconnect_from_server(session)
279300

280301

302+
@pytest.mark.anyio
303+
async def test_client_session_group_streamable_http_connection_error_surfaces() -> None:
304+
async def fail_request(request: httpx.Request) -> httpx.Response:
305+
raise httpx.ConnectError("offline", request=request)
306+
307+
http_client = httpx.AsyncClient(transport=httpx.MockTransport(fail_request))
308+
309+
with mock.patch("mcp.client.session_group.create_mcp_http_client", return_value=http_client):
310+
async with ClientSessionGroup() as group:
311+
with pytest.raises(MCPError) as excinfo:
312+
await group.connect_to_server(
313+
StreamableHttpParameters(url="http://example.test/mcp"),
314+
ClientSessionParameters(read_timeout_seconds=2),
315+
)
316+
317+
assert excinfo.value.error.code == types.INTERNAL_ERROR
318+
assert excinfo.value.error.message == "Transport error: offline"
319+
320+
281321
# TODO(Marcelo): This is horrible. We should drop this test.
282322
@pytest.mark.anyio
283323
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)