From 65356a1783dc84190ba3b832bae2cb231802241f Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Sat, 23 May 2026 07:40:29 -0700 Subject: [PATCH] fix(streamable-http): downgrade stateless 'Terminating session: None' log In stateless mode every request created a transport with mcp_session_id=None and terminated it on completion, producing 'INFO: Terminating session: None' on every request. The repeated noise made real session terminations hard to find and confused users into thinking their connection was dropping. Branch on mcp_session_id in terminate(): keep the existing INFO log for stateful session terminations, and switch the stateless path to a DEBUG log with a clearer message ("Stateless request completed, cleaning up transport"). Adds two caplog tests covering both branches. Closes #2329. --- src/mcp/server/streamable_http.py | 5 +++- tests/shared/test_streamable_http.py | 38 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index f14201857c..c46851c2b1 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -769,7 +769,10 @@ async def terminate(self) -> None: """ self._terminated = True - logger.info(f"Terminating session: {self.mcp_session_id}") + if self.mcp_session_id is not None: + logger.info(f"Terminating session: {self.mcp_session_id}") + else: + logger.debug("Stateless request completed, cleaning up transport") # We need a copy of the keys to avoid modification during iteration request_stream_keys = list(self._request_streams.keys()) diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index 3d5770fb61..93fbc801b9 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -6,6 +6,7 @@ from __future__ import annotations as _annotations import json +import logging import multiprocessing import socket import time @@ -767,6 +768,43 @@ def test_streamable_http_transport_init_validation(): StreamableHTTPServerTransport(mcp_session_id="test\n") +@pytest.mark.anyio +async def test_terminate_stateless_log_is_debug(caplog: pytest.LogCaptureFixture): + """Stateless terminate() should not emit INFO 'Terminating session: None'. + + Regression test for issue #2329: in stateless mode the transport has no + session id, so the prior INFO log produced 'Terminating session: None' on + every request. The stateless path now logs at DEBUG with a clearer message, + while the stateful path keeps the INFO-level log. + """ + transport = StreamableHTTPServerTransport(mcp_session_id=None) + + with caplog.at_level(logging.DEBUG, logger="mcp.server.streamable_http"): + await transport.terminate() + + info_records = [r for r in caplog.records if r.levelno == logging.INFO] + assert not any("Terminating session" in r.getMessage() for r in info_records), ( + "Stateless terminate() must not emit INFO 'Terminating session: ...'" + ) + assert any( + r.levelno == logging.DEBUG and "Stateless request completed" in r.getMessage() for r in caplog.records + ), "Stateless terminate() should log a DEBUG completion message" + + +@pytest.mark.anyio +async def test_terminate_stateful_log_is_info(caplog: pytest.LogCaptureFixture): + """Stateful terminate() should still log session id at INFO (#2329).""" + session_id = "abc123" + transport = StreamableHTTPServerTransport(mcp_session_id=session_id) + + with caplog.at_level(logging.INFO, logger="mcp.server.streamable_http"): + await transport.terminate() + + assert any( + r.levelno == logging.INFO and f"Terminating session: {session_id}" in r.getMessage() for r in caplog.records + ), "Stateful terminate() must still emit INFO 'Terminating session: '" + + def test_session_termination(basic_server: None, basic_server_url: str): """Test session termination via DELETE and subsequent request handling.""" response = requests.post(