Skip to content

Commit b389b31

Browse files
committed
fix: surface ClientSession transport faults
1 parent f1b6589 commit b389b31

5 files changed

Lines changed: 80 additions & 0 deletions

File tree

‎src/mcp/client/client.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,8 @@ async def handler(message: IncomingMessage) -> None:
166166
await user_handler(message)
167167
else:
168168
# Mirrors ClientSession's default handler (session._default_message_handler).
169+
if isinstance(message, Exception):
170+
raise message
169171
await anyio.lowlevel.checkpoint()
170172

171173
return handler

‎src/mcp/client/session.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,8 @@ async def __call__(self, message: IncomingMessage) -> None: ... # pragma: no br
243243

244244

245245
async def _default_message_handler(message: IncomingMessage) -> None:
246+
if isinstance(message, Exception):
247+
raise message
246248
await anyio.lowlevel.checkpoint()
247249

248250

‎src/mcp/shared/jsonrpc_dispatcher.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,9 @@ async def _dispatch(
536536
are awaited; any other `await` would head-of-line block the read loop.
537537
"""
538538
if isinstance(item, Exception):
539+
# Fail in-flight waiters with CONNECTION_CLOSED so they do not hang,
540+
# but keep the receive loop open (unlike EOF / `_fan_out_closed`).
541+
self._fan_out_transport_error(item)
539542
if self.on_stream_exception is None:
540543
logger.debug("transport yielded exception: %r", item)
541544
return
@@ -698,6 +701,20 @@ def _fan_out_closed(self) -> None:
698701
pass
699702
self._pending.clear()
700703

704+
def _fan_out_transport_error(self, exc: Exception) -> None:
705+
"""Wake every pending `send_raw_request` waiter after a transport Exception item.
706+
707+
Unlike `_fan_out_closed`, this does not mark the dispatcher closed: a single
708+
Exception item is not EOF, and the receive loop must keep serving.
709+
"""
710+
error = ErrorData(code=CONNECTION_CLOSED, message=f"Transport error: {exc!r}")
711+
for pending in self._pending.values():
712+
try:
713+
pending.send.send_nowait(error)
714+
except (anyio.WouldBlock, anyio.BrokenResourceError, anyio.ClosedResourceError):
715+
pass
716+
self._pending.clear()
717+
701718
async def _handle_request(
702719
self,
703720
req: JSONRPCRequest,

‎tests/client/test_session.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1001,6 +1001,23 @@ async def handler(msg: object) -> None:
10011001
assert "Failed to validate notification: notifications/progress" in caplog.text
10021002

10031003

1004+
@pytest.mark.anyio
1005+
async def test_default_message_handler_raises_on_transport_exception(
1006+
caplog: pytest.LogCaptureFixture,
1007+
):
1008+
"""With no custom `message_handler`, a transport `Exception` is re-raised by the
1009+
default handler and logged via `_deliver_stream_exception` (SDK-defined).
1010+
Raw streams because only a transport can put an `Exception` item on the read stream."""
1011+
async with raw_client_session() as (_session, to_client, from_client):
1012+
await to_client.send(ValueError("bad bytes"))
1013+
# Prove the receive loop kept serving after the default handler re-raised.
1014+
await to_client.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=9, method="ping")))
1015+
out = await from_client.receive()
1016+
assert isinstance(out.message, JSONRPCResponse)
1017+
assert out.message.id == 9
1018+
assert "message_handler raised on transport exception" in caplog.text
1019+
1020+
10041021
@pytest.mark.anyio
10051022
async def test_raising_message_handler_on_transport_exception_costs_the_delivery_not_the_connection(
10061023
caplog: pytest.LogCaptureFixture,

‎tests/shared/test_jsonrpc_dispatcher.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1594,6 +1594,48 @@ async def test_transport_exception_in_read_stream_is_logged_and_dropped():
15941594
s.close()
15951595

15961596

1597+
@pytest.mark.anyio
1598+
async def test_send_raw_request_raises_connection_closed_on_transport_exception():
1599+
"""A blocked send_raw_request is woken with CONNECTION_CLOSED when a transport
1600+
Exception item arrives; the receive loop stays healthy for a later request."""
1601+
c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
1602+
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
1603+
client: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(s2c_recv, c2s_send)
1604+
on_request, on_notify = echo_handlers(Recorder())
1605+
hiccup = ValueError("transport hiccup")
1606+
try:
1607+
async with anyio.create_task_group() as tg:
1608+
await tg.start(client.run, on_request, on_notify)
1609+
1610+
async def caller() -> None:
1611+
with pytest.raises(MCPError) as exc:
1612+
await client.send_raw_request("ping", None)
1613+
assert exc.value.error.code == CONNECTION_CLOSED
1614+
assert "Transport error:" in exc.value.error.message
1615+
assert "transport hiccup" in exc.value.error.message
1616+
1617+
tg.start_soon(caller)
1618+
# Let the outbound request register in `_pending` before the fault.
1619+
req = await c2s_recv.receive()
1620+
assert isinstance(req, SessionMessage)
1621+
assert isinstance(req.message, JSONRPCRequest)
1622+
await s2c_send.send(hiccup)
1623+
await anyio.sleep(0)
1624+
# Loop must still serve after the Exception item (not closed like EOF).
1625+
await s2c_send.send(
1626+
SessionMessage(message=JSONRPCRequest(jsonrpc="2.0", id=99, method="t", params=None))
1627+
)
1628+
with anyio.fail_after(5):
1629+
resp = await c2s_recv.receive()
1630+
assert isinstance(resp, SessionMessage)
1631+
assert isinstance(resp.message, JSONRPCResponse)
1632+
assert resp.message.id == 99
1633+
tg.cancel_scope.cancel()
1634+
finally:
1635+
for s in (c2s_send, c2s_recv, s2c_send, s2c_recv):
1636+
s.close()
1637+
1638+
15971639
@pytest.mark.anyio
15981640
async def test_on_stream_exception_observes_transport_exceptions():
15991641
"""With an observer set, Exception items reach it instead of being dropped; the loop stays healthy."""

0 commit comments

Comments
 (0)