From a79e30e8a67eee9b8e77b9aaba90c3dd5d7f7b3f Mon Sep 17 00:00:00 2001 From: umeGe <111581242+umeGe@users.noreply.github.com> Date: Sun, 8 Nov 2026 09:57:11 +0800 Subject: [PATCH] fix(server): don't log a client session close as an ERROR in the standalone SSE writer terminate() closes every request stream, including the standalone GET stream, while standalone_sse_writer is still iterating it. The resulting anyio.ClosedResourceError fell into the writer's generic `except Exception: logger.exception(...)`, so every normal client-initiated session close (DELETE) left an ERROR traceback in the server log. Catch ClosedResourceError separately and log it at DEBUG: a stream closed under the writer during termination is a normal end. All other exceptions keep the existing ERROR report. Regression test drives a GET request straight into the transport, calls terminate() and asserts no ERROR record from mcp.server.streamable_http (reproduces the traceback and fails on unpatched source). Focused transport subset: 13 passed; ruff check/format clean. --- src/mcp/server/streamable_http.py | 4 ++ tests/shared/test_streamable_http.py | 57 ++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index 2e8236d973..60d28483cd 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -771,6 +771,10 @@ async def standalone_sse_writer(): # Send the message via SSE event_data = self._create_event_data(event_message) await sse_stream_writer.send(event_data) + except anyio.ClosedResourceError: + # terminate() closes the GET stream while this writer is still iterating it. + # A client-initiated session close is a normal end, not an error (issue #3833). + logger.debug("Standalone SSE stream closed during session termination") except Exception: logger.exception("Error in standalone SSE writer") finally: diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index 35376bc1c9..4dc684f999 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -5,6 +5,7 @@ """ import json +import logging import multiprocessing import socket import time @@ -36,6 +37,7 @@ ) from mcp.server import Server from mcp.server.streamable_http import ( + GET_STREAM_KEY, MCP_PROTOCOL_VERSION_HEADER, MCP_SESSION_ID_HEADER, SESSION_ID_PATTERN, @@ -793,6 +795,61 @@ async def test_transport_whose_idle_period_ran_out_answers_as_terminated() -> No assert await _post_to_transport(transport, ping) == 404 +@pytest.mark.anyio +async def test_termination_does_not_log_an_error_in_the_standalone_sse_writer(caplog: pytest.LogCaptureFixture) -> None: + """A client-initiated session close closes the GET stream under the SSE writer; that is a normal + end of the stream and must not be logged at ERROR (issue #3833).""" + transport = StreamableHTTPServerTransport(mcp_session_id="valid-id") + caplog.set_level(logging.DEBUG, logger="mcp.server.streamable_http") + + scope: Scope = { + "type": "http", + "method": "GET", + "path": "/mcp", + "query_string": b"", + "headers": [ + (b"accept", b"text/event-stream"), + (MCP_SESSION_ID_HEADER.encode(), b"valid-id"), + ], + } + sent: list[Message] = [] + + async def send(message: Message) -> None: + sent.append(message) + + disconnected = anyio.Event() + + async def receive() -> Message: + await disconnected.wait() + return {"type": "http.disconnect"} + + handler_done = anyio.Event() + + async def run_handler() -> None: + try: + await transport.handle_request(scope, receive, send) + finally: + handler_done.set() + + async with transport.connect(): + async with anyio.create_task_group() as tg: + tg.start_soon(run_handler) + with anyio.fail_after(5): + while GET_STREAM_KEY not in transport._request_streams: + await anyio.sleep(0.01) + await transport.terminate() + disconnected.set() + await handler_done.wait() + tg.cancel_scope.cancel() + + errors = [ + record + for record in caplog.records + if record.name == "mcp.server.streamable_http" and record.levelno >= logging.ERROR + ] + assert errors == [] + + def test_session_termination(basic_server: None, basic_server_url: str): """Test session termination via DELETE and subsequent request handling.""" response = requests.post(