From 69a8b7dcf4eaa78b90bee631c6403e6b47b78093 Mon Sep 17 00:00:00 2001 From: BabyChrist666 Date: Mon, 16 Feb 2026 23:25:24 -0500 Subject: [PATCH] fix: handle ClosedResourceError in _handle_message error path (#2064) When a client disconnects mid-request, 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 ExceptionGroup. Wrap the send_log_message call in a try/except that catches ClosedResourceError and BrokenResourceError, since failing to notify a disconnected client is expected and harmless. Co-Authored-By: Claude Opus 4.6 --- src/mcp/server/lowlevel/server.py | 15 ++++-- .../test_lowlevel_exception_handling.py | 49 +++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 04404a3fc..e4d8c23f6 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -420,11 +420,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): + # Client already disconnected; logging back to + # the client is impossible and harmless to skip. + logger.debug("Could not send error log: client disconnected") 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..d12ad9d91 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,51 @@ 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_with_disconnected_client(error_class: type[Exception]): + """Test that send_log_message failure due to client disconnect is handled gracefully. + + When a client disconnects and the write stream is closed, send_log_message + raises ClosedResourceError or BrokenResourceError. The server should catch + these and not crash the session (fixes #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 should be caught and suppressed. + await server._handle_message(test_exception, session, {}, raise_exceptions=False) + + # send_log_message was still attempted + session.send_log_message.assert_called_once() + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "error_class", + [anyio.ClosedResourceError, anyio.BrokenResourceError], +) +async def test_exception_handling_with_disconnected_client_raise_exceptions(error_class: type[Exception]): + """Test that the original exception is still raised when raise_exceptions=True, + even if send_log_message fails due to client disconnect. + """ + server = Server("test-server") + session = Mock(spec=ServerSession) + session.send_log_message = AsyncMock(side_effect=error_class()) + + test_exception = RuntimeError("Client disconnected mid-request") + + # The original exception should still be raised + with pytest.raises(RuntimeError, match="Client disconnected mid-request"): + await server._handle_message(test_exception, session, {}, raise_exceptions=True) + + session.send_log_message.assert_called_once()