Skip to content

Commit 139c2b5

Browse files
committed
fix: catch ClosedResourceError in _handle_message error recovery path
When a client disconnects while a stateless streamable-HTTP server is reading the request body, the exception handler in _handle_message tries to send_log_message() back to the client. Since the write stream is already closed, this raises ClosedResourceError, which crashes the stateless session with an unhandled ExceptionGroup. Wrap the send_log_message() call in a try/except for ClosedResourceError and BrokenResourceError, matching the pattern already used throughout streamable_http.py (lines 589, 915, 1011, 1020). Failing to notify a disconnected client is expected and harmless. Github-Issue: #2064 Reported-by: dannygoldstein
1 parent 7ba41dc commit 139c2b5

File tree

2 files changed

+48
-5
lines changed

2 files changed

+48
-5
lines changed

src/mcp/server/lowlevel/server.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -414,11 +414,16 @@ 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-
)
417+
try:
418+
await session.send_log_message(
419+
level="error",
420+
data="Internal Server Error",
421+
logger="mcp.server.exception_handler",
422+
)
423+
except (anyio.ClosedResourceError, anyio.BrokenResourceError):
424+
logger.debug(
425+
"Could not send error log to client: write stream already closed"
426+
)
422427
if raise_exceptions:
423428
raise message
424429
case _:

tests/server/test_lowlevel_exception_handling.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from unittest.mock import AsyncMock, Mock
22

3+
import anyio
34
import pytest
45

56
from mcp import types
@@ -72,3 +73,40 @@ async def test_normal_message_handling_not_affected():
7273

7374
# Verify _handle_request was called
7475
server._handle_request.assert_called_once()
76+
77+
78+
@pytest.mark.anyio
79+
@pytest.mark.parametrize(
80+
"error_class",
81+
[anyio.ClosedResourceError, anyio.BrokenResourceError],
82+
)
83+
async def test_exception_handling_tolerates_closed_write_stream(error_class: type[Exception]):
84+
"""Test that _handle_message does not crash when send_log_message fails
85+
because the client already disconnected (write stream closed).
86+
87+
Regression test for https://github.com/modelcontextprotocol/python-sdk/issues/2064
88+
"""
89+
server = Server("test-server")
90+
session = Mock(spec=ServerSession)
91+
session.send_log_message = AsyncMock(side_effect=error_class())
92+
93+
test_exception = RuntimeError("client disconnected mid-request")
94+
95+
# Should not raise — the ClosedResourceError/BrokenResourceError from
96+
# send_log_message must be caught and logged, not propagated.
97+
await server._handle_message(test_exception, session, {}, raise_exceptions=False)
98+
99+
session.send_log_message.assert_called_once()
100+
101+
102+
@pytest.mark.anyio
103+
async def test_exception_handling_closed_stream_still_reraises_when_requested():
104+
"""Test that raise_exceptions=True still works even when the write stream is closed."""
105+
server = Server("test-server")
106+
session = Mock(spec=ServerSession)
107+
session.send_log_message = AsyncMock(side_effect=anyio.ClosedResourceError())
108+
109+
test_exception = RuntimeError("original error")
110+
111+
with pytest.raises(RuntimeError, match="original error"):
112+
await server._handle_message(test_exception, session, {}, raise_exceptions=True)

0 commit comments

Comments
 (0)