Skip to content

Commit fd1d142

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

3 files changed

Lines changed: 53 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: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,25 @@ 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:
57+
def __init__(self) -> None:
58+
self.closed = False
59+
60+
async def aclose(self) -> None:
61+
self.closed = True
62+
63+
session_stack = SessionStack()
64+
group = ClientSessionGroup(exit_stack=contextlib.AsyncExitStack())
65+
group._session_exit_stacks[mock.Mock(spec=mcp.ClientSession)] = session_stack
66+
67+
async with group as entered:
68+
assert entered is group
69+
70+
assert session_stack.closed
71+
72+
5473
@pytest.mark.anyio
5574
async def test_client_session_group_call_tool():
5675
# --- Mock Dependencies ---
@@ -278,6 +297,25 @@ async def test_client_session_group_disconnect_non_existent_server():
278297
await group.disconnect_from_server(session)
279298

280299

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

0 commit comments

Comments
 (0)