From 139c2b5e157abab1097b49a356393055730e9847 Mon Sep 17 00:00:00 2001 From: "D. Michael Piscitelli" Date: Sat, 7 Mar 2026 06:10:41 +0100 Subject: [PATCH 1/2] 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 --- src/mcp/server/lowlevel/server.py | 15 +++++--- .../test_lowlevel_exception_handling.py | 38 +++++++++++++++++++ 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 1c84c8610..a79b328e2 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -414,11 +414,16 @@ async def _handle_message( ) case Exception(): logger.error(f"Received exception from stream: {message}") - await session.send_log_message( - level="error", - data="Internal Server Error", - logger="mcp.server.exception_handler", - ) + try: + await session.send_log_message( + level="error", + data="Internal Server Error", + logger="mcp.server.exception_handler", + ) + except (anyio.ClosedResourceError, anyio.BrokenResourceError): + logger.debug( + "Could not send error log to client: write stream already closed" + ) if raise_exceptions: raise message case _: diff --git a/tests/server/test_lowlevel_exception_handling.py b/tests/server/test_lowlevel_exception_handling.py index 848b35b29..f98062e6b 100644 --- a/tests/server/test_lowlevel_exception_handling.py +++ b/tests/server/test_lowlevel_exception_handling.py @@ -1,5 +1,6 @@ from unittest.mock import AsyncMock, Mock +import anyio import pytest from mcp import types @@ -72,3 +73,40 @@ async def test_normal_message_handling_not_affected(): # Verify _handle_request was called server._handle_request.assert_called_once() + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "error_class", + [anyio.ClosedResourceError, anyio.BrokenResourceError], +) +async def test_exception_handling_tolerates_closed_write_stream(error_class: type[Exception]): + """Test that _handle_message does not crash when send_log_message fails + because the client already disconnected (write stream closed). + + Regression test for https://github.com/modelcontextprotocol/python-sdk/issues/2064 + """ + server = Server("test-server") + session = Mock(spec=ServerSession) + session.send_log_message = AsyncMock(side_effect=error_class()) + + test_exception = RuntimeError("client disconnected mid-request") + + # Should not raise — the ClosedResourceError/BrokenResourceError from + # send_log_message must be caught and logged, not propagated. + await server._handle_message(test_exception, session, {}, raise_exceptions=False) + + session.send_log_message.assert_called_once() + + +@pytest.mark.anyio +async def test_exception_handling_closed_stream_still_reraises_when_requested(): + """Test that raise_exceptions=True still works even when the write stream is closed.""" + server = Server("test-server") + session = Mock(spec=ServerSession) + session.send_log_message = AsyncMock(side_effect=anyio.ClosedResourceError()) + + test_exception = RuntimeError("original error") + + with pytest.raises(RuntimeError, match="original error"): + await server._handle_message(test_exception, session, {}, raise_exceptions=True) From ff3663358c0c5f8ced6d576b7a650c6c4e8fbee5 Mon Sep 17 00:00:00 2001 From: "D. Michael Piscitelli" Date: Sat, 7 Mar 2026 06:47:57 +0100 Subject: [PATCH 2/2] style: apply ruff format to logger.debug call Single-line the debug message as ruff format prefers. Github-Issue: #2064 --- src/mcp/server/lowlevel/server.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index a79b328e2..8bf70fa69 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -421,9 +421,7 @@ async def _handle_message( logger="mcp.server.exception_handler", ) except (anyio.ClosedResourceError, anyio.BrokenResourceError): - logger.debug( - "Could not send error log to client: write stream already closed" - ) + logger.debug("Could not send error log to client: write stream already closed") if raise_exceptions: raise message case _: