Skip to content

Commit 62eb08e

Browse files
authored
fix: don't send log notification on transport error (#2257)
1 parent 31a38b5 commit 62eb08e

File tree

3 files changed

+58
-31
lines changed

3 files changed

+58
-31
lines changed

src/mcp/server/lowlevel/server.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -414,11 +414,6 @@ async def _handle_message(
414414
)
415415
case Exception():
416416
logger.error(f"Received exception from stream: {message}")
417-
await session.send_log_message(
418-
level="error",
419-
data="Internal Server Error",
420-
logger="mcp.server.exception_handler",
421-
)
422417
if raise_exceptions:
423418
raise message
424419
case _:

src/mcp/shared/session.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ async def _receive_loop(self) -> None:
334334
async with self._read_stream, self._write_stream:
335335
try:
336336
async for message in self._read_stream:
337-
if isinstance(message, Exception): # pragma: no cover
337+
if isinstance(message, Exception):
338338
await self._handle_incoming(message)
339339
elif isinstance(message.message, JSONRPCRequest):
340340
try:
Lines changed: 57 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,42 @@
11
from unittest.mock import AsyncMock, Mock
22

3+
import anyio
34
import pytest
45

56
from mcp import types
67
from mcp.server.lowlevel.server import Server
78
from mcp.server.session import ServerSession
9+
from mcp.shared.message import SessionMessage
810
from mcp.shared.session import RequestResponder
911

1012

1113
@pytest.mark.anyio
1214
async def test_exception_handling_with_raise_exceptions_true():
13-
"""Test that exceptions are re-raised when raise_exceptions=True"""
15+
"""Transport exceptions are re-raised when raise_exceptions=True."""
1416
server = Server("test-server")
1517
session = Mock(spec=ServerSession)
16-
session.send_log_message = AsyncMock()
1718

1819
test_exception = RuntimeError("Test error")
1920

2021
with pytest.raises(RuntimeError, match="Test error"):
2122
await server._handle_message(test_exception, session, {}, raise_exceptions=True)
2223

23-
session.send_log_message.assert_called_once()
24-
2524

2625
@pytest.mark.anyio
27-
@pytest.mark.parametrize(
28-
"exception_class,message",
29-
[
30-
(ValueError, "Test validation error"),
31-
(RuntimeError, "Test runtime error"),
32-
(KeyError, "Test key error"),
33-
(Exception, "Basic error"),
34-
],
35-
)
36-
async def test_exception_handling_with_raise_exceptions_false(exception_class: type[Exception], message: str):
37-
"""Test that exceptions are logged when raise_exceptions=False"""
26+
async def test_exception_handling_with_raise_exceptions_false():
27+
"""Transport exceptions are logged locally but not sent to the client.
28+
29+
The transport that reported the error is likely broken; writing back
30+
through it races with stream closure (#1967, #2064). The TypeScript,
31+
Go, and C# SDKs all log locally only.
32+
"""
3833
server = Server("test-server")
3934
session = Mock(spec=ServerSession)
4035
session.send_log_message = AsyncMock()
4136

42-
test_exception = exception_class(message)
43-
44-
await server._handle_message(test_exception, session, {}, raise_exceptions=False)
45-
46-
# Should send log message
47-
session.send_log_message.assert_called_once()
48-
call_args = session.send_log_message.call_args
37+
await server._handle_message(RuntimeError("Test error"), session, {}, raise_exceptions=False)
4938

50-
assert call_args.kwargs["level"] == "error"
51-
assert call_args.kwargs["data"] == "Internal Server Error"
52-
assert call_args.kwargs["logger"] == "mcp.server.exception_handler"
39+
session.send_log_message.assert_not_called()
5340

5441

5542
@pytest.mark.anyio
@@ -72,3 +59,48 @@ async def test_normal_message_handling_not_affected():
7259

7360
# Verify _handle_request was called
7461
server._handle_request.assert_called_once()
62+
63+
64+
@pytest.mark.anyio
65+
async def test_server_run_exits_cleanly_when_transport_yields_exception_then_closes():
66+
"""Regression test for #1967 / #2064.
67+
68+
Exercises the real Server.run() path with real memory streams, reproducing
69+
what happens in stateless streamable HTTP when a POST handler throws:
70+
71+
1. Transport yields an Exception into the read stream
72+
(streamable_http.py does this in its broad POST-handler except).
73+
2. Transport closes the read stream (terminate() in stateless mode).
74+
3. _receive_loop exits its `async with read_stream, write_stream:` block,
75+
closing the write stream.
76+
4. Meanwhile _handle_message(exc) was spawned via tg.start_soon and runs
77+
after the write stream is closed.
78+
79+
Before the fix, _handle_message tried to send_log_message through the
80+
closed write stream, raising ClosedResourceError inside the TaskGroup
81+
and crashing server.run(). After the fix, it only logs locally.
82+
"""
83+
server = Server("test-server")
84+
85+
read_send, read_recv = anyio.create_memory_object_stream[SessionMessage | Exception](1)
86+
# Zero-buffer on the write stream forces send() to block until received.
87+
# With no receiver, a send() sits blocked until _receive_loop exits its
88+
# `async with self._read_stream, self._write_stream:` block and closes the
89+
# stream, at which point the blocked send raises ClosedResourceError.
90+
# This deterministically reproduces the race without sleeps.
91+
write_send, write_recv = anyio.create_memory_object_stream[SessionMessage](0)
92+
93+
# What the streamable HTTP transport does: push the exception, then close.
94+
read_send.send_nowait(RuntimeError("simulated transport error"))
95+
read_send.close()
96+
97+
with anyio.fail_after(5):
98+
# stateless=True so server.run doesn't wait for initialize handshake.
99+
# Before this fix, this raised ExceptionGroup(ClosedResourceError).
100+
await server.run(read_recv, write_send, server.create_initialization_options(), stateless=True)
101+
102+
# write_send was closed inside _receive_loop's `async with`; receive_nowait
103+
# raises EndOfStream iff the buffer is empty (i.e., server wrote nothing).
104+
with pytest.raises(anyio.EndOfStream):
105+
write_recv.receive_nowait()
106+
write_recv.close()

0 commit comments

Comments
 (0)