Skip to content

Commit 0816035

Browse files
committed
fix: clean up streamable HTTP SSE disconnects
1 parent 603342f commit 0816035

2 files changed

Lines changed: 56 additions & 3 deletions

File tree

src/mcp/server/streamable_http.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -625,9 +625,18 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
625625

626626
# Start the SSE response (this will send headers immediately)
627627
try:
628+
629+
async def run_response_with_cleanup() -> None:
630+
try:
631+
await response(scope, receive, send)
632+
finally:
633+
self._sse_stream_writers.pop(request_id, None)
634+
await sse_stream_writer.aclose()
635+
await self._clean_up_memory_streams(request_id)
636+
628637
# First send the response to establish the SSE connection
629638
async with anyio.create_task_group() as tg:
630-
tg.start_soon(response, scope, receive, send)
639+
tg.start_soon(run_response_with_cleanup)
631640
# Then send the message to be processed by the server
632641
session_message = self._create_session_message(message, request, request_id, protocol_version)
633642
await writer.send(session_message)

tests/server/test_streamable_http_manager.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22

33
import json
44
import logging
5-
from typing import Any
5+
from typing import Any, cast
66
from unittest.mock import AsyncMock, patch
77

88
import anyio
99
import httpx
1010
import pytest
11-
from mcp_types import INVALID_REQUEST, ListToolsResult, PaginatedRequestParams
11+
from mcp_types import INVALID_REQUEST, JSONRPCRequest, ListToolsResult, PaginatedRequestParams
1212
from starlette.types import Message, Scope
1313

1414
from mcp import Client
@@ -18,6 +18,7 @@
1818
from mcp.server.auth.provider import AccessToken
1919
from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, StreamableHTTPServerTransport
2020
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
21+
from mcp.shared.message import SessionMessage
2122

2223

2324
@pytest.mark.anyio
@@ -101,6 +102,49 @@ async def running_manager():
101102
yield manager, app
102103

103104

105+
@pytest.mark.anyio
106+
async def test_streamable_http_post_sse_cleans_up_streams_when_response_returns(monkeypatch: pytest.MonkeyPatch):
107+
transport = StreamableHTTPServerTransport(mcp_session_id=None)
108+
sent_messages: list[Message] = []
109+
body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}).encode()
110+
111+
class DisconnectingEventSourceResponse:
112+
def __init__(self, *args: Any, **kwargs: Any) -> None:
113+
pass
114+
115+
async def __call__(self, scope: Scope, receive: Any, send: Any) -> None:
116+
await send({"type": "http.response.start", "status": 200, "headers": []})
117+
118+
async def send(message: Message) -> None:
119+
sent_messages.append(message)
120+
121+
async def receive() -> Message:
122+
return {"type": "http.request", "body": body, "more_body": False}
123+
124+
scope: Scope = {
125+
"type": "http",
126+
"method": "POST",
127+
"path": "/mcp",
128+
"headers": [
129+
(b"accept", b"application/json, text/event-stream"),
130+
(b"content-type", b"application/json"),
131+
],
132+
}
133+
134+
monkeypatch.setattr("mcp.server.streamable_http.EventSourceResponse", DisconnectingEventSourceResponse)
135+
136+
async with transport.connect() as (read_stream, _write_stream):
137+
async with anyio.create_task_group() as tg:
138+
tg.start_soon(transport.handle_request, scope, receive, send)
139+
session_message = cast(SessionMessage, await read_stream.receive())
140+
assert isinstance(session_message.message, JSONRPCRequest)
141+
assert session_message.message.method == "tools/list"
142+
143+
assert transport._request_streams == {}
144+
assert transport._sse_stream_writers == {}
145+
assert any(message["type"] == "http.response.start" for message in sent_messages)
146+
147+
104148
@pytest.mark.anyio
105149
async def test_stateful_session_cleanup_on_graceful_exit(running_manager: tuple[StreamableHTTPSessionManager, Server]):
106150
manager, _app = running_manager

0 commit comments

Comments
 (0)