From 4c9adea743c864583d116ba0ccc47ef8b4db88f6 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 2cb4c0748..92e139c45 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -767,7 +767,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 b43a3361c..27cd214a1 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -7,6 +7,7 @@ from __future__ import annotations as _annotations import json +import logging import time from collections.abc import AsyncIterator from contextlib import asynccontextmanager @@ -588,6 +589,43 @@ def test_streamable_http_transport_init_validation() -> None: 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: '" + + @pytest.mark.anyio async def test_session_termination(basic_app: Starlette) -> None: """DELETE terminates the session, after which requests for it return 404."""