From fe0a371f4fa5273324dbdc186ae20e1b75161f19 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Thu, 13 Aug 2026 15:54:01 +0800 Subject: [PATCH 1/4] fix(server): deregister terminated streamable HTTP sessions --- src/mcp/server/streamable_http_manager.py | 3 ++ tests/server/test_streamable_http_manager.py | 30 ++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/src/mcp/server/streamable_http_manager.py b/src/mcp/server/streamable_http_manager.py index 31f587ee66..3f2ae8ef3e 100644 --- a/src/mcp/server/streamable_http_manager.py +++ b/src/mcp/server/streamable_http_manager.py @@ -281,6 +281,9 @@ async def _handle_stateful_request(self, scope: Scope, receive: Receive, send: S if transport.idle_scope is not None and self.session_idle_timeout is not None: transport.idle_scope.deadline = anyio.current_time() + self.session_idle_timeout # pragma: no cover await transport.handle_request(scope, receive, send) + if transport.is_terminated: + self._server_instances.pop(request_mcp_session_id, None) + self._session_owners.pop(request_mcp_session_id, None) return if request_mcp_session_id is None: diff --git a/tests/server/test_streamable_http_manager.py b/tests/server/test_streamable_http_manager.py index 70440d9d03..7cb90e6b2b 100644 --- a/tests/server/test_streamable_http_manager.py +++ b/tests/server/test_streamable_http_manager.py @@ -304,6 +304,36 @@ async def mock_receive(): assert not manager._server_instances, "No sessions should be tracked after the only session exits gracefully" +@pytest.mark.anyio +async def test_terminated_existing_session_is_removed_from_registry( + running_manager: tuple[StreamableHTTPSessionManager, Server], +): + manager, _app = running_manager + session_id = "terminated-session" + transport = AsyncMock() + transport.is_terminated = True + transport.idle_scope = None + manager._server_instances[session_id] = transport + + scope = { + "type": "http", + "method": "DELETE", + "path": "/mcp", + "headers": [(MCP_SESSION_ID_HEADER.encode(), session_id.encode())], + } + + async def mock_receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + async def mock_send(_message: Message): + pass + + await manager.handle_request(scope, mock_receive, mock_send) + + assert session_id not in manager._server_instances + assert session_id not in manager._session_owners + + @pytest.mark.anyio async def test_stateful_session_cleanup_on_exception(running_manager: tuple[StreamableHTTPSessionManager, Server]): manager, _app = running_manager From 59dd0838d8847237ad4d88da9c745a381252df8e Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Sat, 15 Aug 2026 13:17:55 +0800 Subject: [PATCH 2/4] test: seed session owner before cleanup --- tests/server/test_streamable_http_manager.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/server/test_streamable_http_manager.py b/tests/server/test_streamable_http_manager.py index 7cb90e6b2b..50de8b6964 100644 --- a/tests/server/test_streamable_http_manager.py +++ b/tests/server/test_streamable_http_manager.py @@ -3,7 +3,7 @@ import json import logging from collections.abc import Iterator -from typing import Any +from typing import Any, cast from unittest.mock import AsyncMock, patch import anyio @@ -289,6 +289,10 @@ async def mock_receive(): assert session_id is not None, "Session ID not found in response headers" + # Seed the owner registry so this assertion verifies that the cleanup path + # removes the session owner as well as the server instance. + manager._session_owners[session_id] = cast(Any, None) + mock_serve.assert_called_once() # At this point, mock_serve has completed, and the finally block in From d81476f7fc4317c0b6228c157bca9391db18a753 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Sat, 15 Aug 2026 13:45:52 +0800 Subject: [PATCH 3/4] test(server): remove invalid session owner seed --- tests/server/test_streamable_http_manager.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/server/test_streamable_http_manager.py b/tests/server/test_streamable_http_manager.py index 50de8b6964..7cb90e6b2b 100644 --- a/tests/server/test_streamable_http_manager.py +++ b/tests/server/test_streamable_http_manager.py @@ -3,7 +3,7 @@ import json import logging from collections.abc import Iterator -from typing import Any, cast +from typing import Any from unittest.mock import AsyncMock, patch import anyio @@ -289,10 +289,6 @@ async def mock_receive(): assert session_id is not None, "Session ID not found in response headers" - # Seed the owner registry so this assertion verifies that the cleanup path - # removes the session owner as well as the server instance. - manager._session_owners[session_id] = cast(Any, None) - mock_serve.assert_called_once() # At this point, mock_serve has completed, and the finally block in From af25027c7384757f4724ab41c656c4a8f2aac4ed Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Sat, 15 Aug 2026 20:58:16 +0800 Subject: [PATCH 4/4] test(server): verify terminated session owner cleanup --- tests/server/test_streamable_http_manager.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/server/test_streamable_http_manager.py b/tests/server/test_streamable_http_manager.py index 7cb90e6b2b..14e55352d8 100644 --- a/tests/server/test_streamable_http_manager.py +++ b/tests/server/test_streamable_http_manager.py @@ -3,7 +3,7 @@ import json import logging from collections.abc import Iterator -from typing import Any +from typing import Any, cast from unittest.mock import AsyncMock, patch import anyio @@ -314,6 +314,7 @@ async def test_terminated_existing_session_is_removed_from_registry( transport.is_terminated = True transport.idle_scope = None manager._server_instances[session_id] = transport + manager._session_owners[session_id] = cast(Any, None) scope = { "type": "http",