Skip to content

Commit 9323614

Browse files
committed
Restore SSE keepalive ping after the deferred-start rewrite
The 5820a22 raw-ASGI rewrite dropped the comment-line keepalive that EventSourceResponse used to provide, so a long-silent handler behind a proxy idle-read timeout would have its stream closed (which on this path cancels the handler). The receive loop now emits ': ping' on a 15s move_on_after between events, matching the legacy paths' interval.
1 parent 6ac24b7 commit 9323614

2 files changed

Lines changed: 37 additions & 3 deletions

File tree

src/mcp/server/_streamable_http_modern.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,9 @@ async def _to_jsonrpc_response(
147147
return JSONRPCResponse(jsonrpc="2.0", id=request_id, result=result)
148148

149149

150+
_SSE_PING_INTERVAL: float = 15.0
151+
"""Seconds between SSE comment-line keepalives once `text/event-stream` has committed."""
152+
150153
_SSE_HEADERS: Final[list[tuple[bytes, bytes]]] = [
151154
(b"content-type", b"text/event-stream"),
152155
(b"cache-control", b"no-cache, no-transform"),
@@ -304,9 +307,15 @@ async def watch_disconnect(cancel_scope: anyio.CancelScope) -> None:
304307
await _write(result[0], scope, receive, send)
305308
else:
306309
await send({"type": "http.response.start", "status": _OK_STATUS, "headers": _SSE_HEADERS})
307-
await send({"type": "http.response.body", "body": first, "more_body": True})
308-
async for event in recv_ch:
309-
await send({"type": "http.response.body", "body": event, "more_body": True})
310+
event: bytes | None = first
311+
while True:
312+
await send({"type": "http.response.body", "body": event or b": ping\r\n\r\n", "more_body": True})
313+
event = None
314+
with anyio.move_on_after(_SSE_PING_INTERVAL):
315+
try:
316+
event = await recv_ch.receive()
317+
except anyio.EndOfStream:
318+
break
310319
await send({"type": "http.response.body", "body": _sse_event(result[0]), "more_body": False})
311320

312321
tg.cancel_scope.cancel()

tests/server/test_streamable_http_modern.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,31 @@ async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams |
371371
assert events[2]["result"]["tools"] == []
372372

373373

374+
async def test_sse_mode_emits_keepalive_comment_between_events(monkeypatch: pytest.MonkeyPatch) -> None:
375+
"""SSE mode: while the stream is idle between events the server emits an SSE comment line so a
376+
proxy idle-read timeout does not close the stream (which would cancel the handler).
377+
SDK-defined: spec encourages keepalive comments for long-lived streams."""
378+
monkeypatch.setattr("mcp.server._streamable_http_modern._SSE_PING_INTERVAL", 0.01)
379+
380+
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
381+
await ctx.session.report_progress(1.0)
382+
await anyio.sleep(0.05)
383+
return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public")
384+
385+
async with _asgi_client(Server("test", on_list_tools=list_tools), json_response=False) as http:
386+
with anyio.fail_after(5):
387+
response = await http.post(
388+
"/mcp", json=_list_tools_body_with_token("tok"), headers={MCP_METHOD_HEADER: "tools/list"}
389+
)
390+
391+
assert response.headers["content-type"].split(";", 1)[0] == "text/event-stream"
392+
assert b": ping\r\n\r\n" in response.content
393+
events = _sse_payloads(response.text)
394+
assert len(events) == 2
395+
assert events[0]["method"] == "notifications/progress"
396+
assert events[1]["result"]["tools"] == []
397+
398+
374399
async def test_sse_mode_streams_log_notification() -> None:
375400
"""SSE mode: a request-scoped `notifications/message` emitted by the handler precedes the
376401
terminal response on the same stream. SDK-defined: notifications sent on the request's outbound

0 commit comments

Comments
 (0)