Skip to content

Commit 80cd21e

Browse files
committed
Fix ClientSession error handling: add transport_exception_handler callback
- Add new callback to ClientSession and Client for handling transport-level exceptions (timeouts, connection errors) - Update to use the new handler with fallback to for backwards compatibility - Make default log transport exceptions at ERROR level - Add tests for the new callback and fallback behavior Fixes #1401
1 parent 57394b0 commit 80cd21e

3 files changed

Lines changed: 84 additions & 3 deletions

File tree

src/mcp/client/client.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
LoggingFnT,
5858
MessageHandlerFnT,
5959
SamplingFnT,
60+
TransportExceptionHandlerFnT,
6061
)
6162
from mcp.client.stdio import StdioServerParameters, stdio_client
6263
from mcp.client.streamable_http import streamable_http_client
@@ -329,6 +330,14 @@ async def main():
329330
message_handler: MessageHandlerFnT | None = None
330331
"""Callback for handling raw messages."""
331332

333+
transport_exception_handler: TransportExceptionHandlerFnT | None = None
334+
"""Callback for handling transport-level exceptions (timeouts, connection errors, etc.).
335+
336+
When provided, this handler receives transport exceptions directly, allowing the caller
337+
to propagate, log, or handle them as needed. If not provided, exceptions are delivered
338+
to `message_handler` for backwards compatibility.
339+
"""
340+
332341
client_info: Implementation | None = None
333342
"""Client implementation info to send to server."""
334343

@@ -442,6 +451,7 @@ async def _build_session(self, exit_stack: AsyncExitStack) -> ClientSession:
442451
logging_callback=self.logging_callback,
443452
log_level=self.log_level,
444453
message_handler=message_handler,
454+
transport_exception_handler=self.transport_exception_handler,
445455
client_info=self.client_info,
446456
elicitation_callback=self.elicitation_callback,
447457
extensions=self._folded_extensions.ad,

src/mcp/client/session.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,13 @@ class MessageHandlerFnT(Protocol):
249249
async def __call__(self, message: IncomingMessage) -> None: ... # pragma: no branch
250250

251251

252+
class TransportExceptionHandlerFnT(Protocol):
253+
async def __call__(self, exc: Exception) -> None: ... # pragma: no branch
254+
255+
252256
async def _default_message_handler(message: IncomingMessage) -> None:
257+
if isinstance(message, Exception):
258+
logger.exception("Transport exception received: %s", message)
253259
await anyio.lowlevel.checkpoint()
254260

255261

@@ -418,6 +424,7 @@ def __init__(
418424
list_roots_callback: ListRootsFnT | None = None,
419425
logging_callback: LoggingFnT | None = None,
420426
message_handler: MessageHandlerFnT | None = None,
427+
transport_exception_handler: TransportExceptionHandlerFnT | None = None,
421428
client_info: types.Implementation | None = None,
422429
*,
423430
log_level: types.LoggingLevel | None = None,
@@ -444,6 +451,7 @@ def __init__(
444451
self._logging_callback = logging_callback or _default_logging_callback
445452
self._log_level: types.LoggingLevel | None = log_level
446453
self._message_handler = message_handler or _default_message_handler
454+
self._transport_exception_handler = transport_exception_handler
447455
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
448456
# Compiled output-schema validators, derived from `_tool_output_schemas` and owned by
449457
# `_absorb_tool_listing`, which evicts a tool's entry whenever its schema changes.
@@ -1501,7 +1509,10 @@ async def _on_stream_exception(self, exc: Exception) -> None:
15011509
self._task_group.start_soon(self._deliver_stream_exception, exc)
15021510

15031511
async def _deliver_stream_exception(self, exc: Exception) -> None:
1512+
# If a dedicated transport exception handler is provided, use it.
1513+
# Otherwise fall back to message_handler for backwards compatibility.
1514+
handler = self._transport_exception_handler or self._message_handler
15041515
try:
1505-
await self._message_handler(exc)
1516+
await handler(exc)
15061517
except Exception:
1507-
logger.exception("message_handler raised on transport exception")
1518+
logger.exception("transport exception handler raised")

tests/client/test_session.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1026,7 +1026,7 @@ async def handler(msg: object) -> None:
10261026
assert seen == [exc]
10271027
assert isinstance(out.message, JSONRPCResponse)
10281028
assert out.message.id == 9
1029-
assert "message_handler raised on transport exception" in caplog.text
1029+
assert "transport exception handler raised" in caplog.text
10301030

10311031

10321032
@pytest.mark.anyio
@@ -1052,6 +1052,66 @@ async def handler(msg: object) -> None:
10521052
await ponged.wait()
10531053

10541054

1055+
@pytest.mark.anyio
1056+
async def test_transport_exception_handler_receives_exceptions_separately_from_message_handler(
1057+
caplog: pytest.LogCaptureFixture,
1058+
):
1059+
"""A dedicated `transport_exception_handler` receives transport exceptions, while
1060+
`message_handler` only receives server notifications (SDK-defined)."""
1061+
seen_messages: list[object] = []
1062+
seen_exceptions: list[Exception] = []
1063+
msg_delivered = anyio.Event()
1064+
exc_delivered = anyio.Event()
1065+
1066+
async def message_handler(msg: object) -> None:
1067+
seen_messages.append(msg)
1068+
msg_delivered.set()
1069+
1070+
async def transport_exception_handler(exc: Exception) -> None:
1071+
seen_exceptions.append(exc)
1072+
exc_delivered.set()
1073+
1074+
async with raw_client_session(
1075+
message_handler=message_handler,
1076+
transport_exception_handler=transport_exception_handler,
1077+
) as (_session, to_client, _from_client):
1078+
# Send a transport exception
1079+
exc = ValueError("transport timeout")
1080+
await to_client.send(exc)
1081+
await exc_delivered.wait()
1082+
1083+
# Transport exception should only go to transport_exception_handler
1084+
assert seen_exceptions == [exc]
1085+
assert seen_messages == []
1086+
1087+
# Send a server notification
1088+
await to_client.send(
1089+
SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/tools/list_changed"))
1090+
)
1091+
await msg_delivered.wait()
1092+
1093+
# Server notification should only go to message_handler
1094+
assert len(seen_messages) == 1
1095+
assert isinstance(seen_messages[0], type(types.ToolListChangedNotification()))
1096+
1097+
1098+
@pytest.mark.anyio
1099+
async def test_transport_exception_handler_fallback_to_message_handler(caplog: pytest.LogCaptureFixture):
1100+
"""When no `transport_exception_handler` is provided, transport exceptions fall back
1101+
to `message_handler` for backwards compatibility (SDK-defined). The default
1102+
`message_handler` logs the exception."""
1103+
async with raw_client_session() as (_session, to_client, _from_client):
1104+
exc = ValueError("bad bytes")
1105+
await to_client.send(exc)
1106+
# Give the handler a moment to run
1107+
await anyio.sleep(0.01)
1108+
1109+
assert "Transport exception received" in caplog.text
1110+
1111+
# The default message_handler logs the exception
1112+
assert "Transport exception received" in caplog.text
1113+
1114+
10551115
@pytest.mark.anyio
10561116
async def test_receive_loop_consumes_server_cancelled_without_reaching_message_handler():
10571117
"""A server-sent notifications/cancelled is swallowed, matching the pre-swap contract.

0 commit comments

Comments
 (0)