Skip to content

Commit a79e30e

Browse files
committed
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.
1 parent 52e64dd commit a79e30e

2 files changed

Lines changed: 61 additions & 0 deletions

File tree

‎src/mcp/server/streamable_http.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -771,6 +771,10 @@ async def standalone_sse_writer():
771771
# Send the message via SSE
772772
event_data = self._create_event_data(event_message)
773773
await sse_stream_writer.send(event_data)
774+
except anyio.ClosedResourceError:
775+
# terminate() closes the GET stream while this writer is still iterating it.
776+
# A client-initiated session close is a normal end, not an error (issue #3833).
777+
logger.debug("Standalone SSE stream closed during session termination")
774778
except Exception:
775779
logger.exception("Error in standalone SSE writer")
776780
finally:

‎tests/shared/test_streamable_http.py‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
import json
8+
import logging
89
import multiprocessing
910
import socket
1011
import time
@@ -36,6 +37,7 @@
3637
)
3738
from mcp.server import Server
3839
from mcp.server.streamable_http import (
40+
GET_STREAM_KEY,
3941
MCP_PROTOCOL_VERSION_HEADER,
4042
MCP_SESSION_ID_HEADER,
4143
SESSION_ID_PATTERN,
@@ -793,6 +795,61 @@ async def test_transport_whose_idle_period_ran_out_answers_as_terminated() -> No
793795
assert await _post_to_transport(transport, ping) == 404
794796

795797

798+
@pytest.mark.anyio
799+
async def test_termination_does_not_log_an_error_in_the_standalone_sse_writer(caplog: pytest.LogCaptureFixture) -> None:
800+
"""A client-initiated session close closes the GET stream under the SSE writer; that is a normal
801+
end of the stream and must not be logged at ERROR (issue #3833)."""
802+
transport = StreamableHTTPServerTransport(mcp_session_id="valid-id")
803+
caplog.set_level(logging.DEBUG, logger="mcp.server.streamable_http")
804+
805+
scope: Scope = {
806+
"type": "http",
807+
"method": "GET",
808+
"path": "/mcp",
809+
"query_string": b"",
810+
"headers": [
811+
(b"accept", b"text/event-stream"),
812+
(MCP_SESSION_ID_HEADER.encode(), b"valid-id"),
813+
],
814+
}
815+
sent: list[Message] = []
816+
817+
async def send(message: Message) -> None:
818+
sent.append(message)
819+
820+
disconnected = anyio.Event()
821+
822+
async def receive() -> Message:
823+
await disconnected.wait()
824+
return {"type": "http.disconnect"}
825+
826+
handler_done = anyio.Event()
827+
828+
async def run_handler() -> None:
829+
try:
830+
await transport.handle_request(scope, receive, send)
831+
finally:
832+
handler_done.set()
833+
834+
async with transport.connect():
835+
async with anyio.create_task_group() as tg:
836+
tg.start_soon(run_handler)
837+
with anyio.fail_after(5):
838+
while GET_STREAM_KEY not in transport._request_streams:
839+
await anyio.sleep(0.01)
840+
await transport.terminate()
841+
disconnected.set()
842+
await handler_done.wait()
843+
tg.cancel_scope.cancel()
844+
845+
errors = [
846+
record
847+
for record in caplog.records
848+
if record.name == "mcp.server.streamable_http" and record.levelno >= logging.ERROR
849+
]
850+
assert errors == []
851+
852+
796853
def test_session_termination(basic_server: None, basic_server_url: str):
797854
"""Test session termination via DELETE and subsequent request handling."""
798855
response = requests.post(

0 commit comments

Comments
 (0)