Skip to content

Commit a0f81a9

Browse files
committed
Bound the SSE deferral window at the keepalive interval
A handler that runs silent past _SSE_PING_INTERVAL now commits text/event-stream and starts pinging instead of leaving zero bytes on the wire — so a proxy idle-read timeout can't close the connection (which on this path cancels the handler) before the result arrives. Kernel-dispatch errors still get the table-mapped JSON status because they resolve well within the 15s window.
1 parent 9323614 commit a0f81a9

2 files changed

Lines changed: 47 additions & 12 deletions

File tree

src/mcp/server/_streamable_http_modern.py

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@
88
`Mcp-Session-Id`, one JSON-RPC request in, one JSON-RPC response out. JSON
99
mode handles the request directly in the ASGI task. SSE mode runs the handler
1010
as a sibling task and defers committing to `text/event-stream` until the
11-
handler actually emits a notification: a handler that completes (or raises)
12-
without emitting still gets a JSON response with the table-mapped HTTP
13-
status, so the spec's `404`/`400` MUSTs hold for kernel-dispatch errors.
11+
handler emits a notification or `_SSE_PING_INTERVAL` elapses, whichever
12+
comes first: a handler that completes (or raises) within that window without
13+
emitting still gets a JSON response with the table-mapped HTTP status, so
14+
the spec's `404`/`400` MUSTs hold for kernel-dispatch errors; a handler that
15+
runs silent past the window commits SSE so the keepalive ping can keep the
16+
connection open behind a proxy idle-read timeout.
1417
"""
1518

1619
from __future__ import annotations
@@ -298,24 +301,33 @@ async def watch_disconnect(cancel_scope: anyio.CancelScope) -> None:
298301
tg.start_soon(run_handler)
299302
tg.start_soon(watch_disconnect, tg.cancel_scope)
300303

301-
try:
302-
first = await recv_ch.receive()
303-
except anyio.EndOfStream:
304-
first = None
305-
306-
if first is None:
304+
event: bytes | None = None
305+
done = False
306+
with anyio.move_on_after(_SSE_PING_INTERVAL):
307+
try:
308+
event = await recv_ch.receive()
309+
except anyio.EndOfStream:
310+
done = True
311+
312+
if done:
313+
# Handler completed within the deferral window without emitting:
314+
# `application/json` with the table-mapped status. Kernel-dispatch
315+
# errors (METHOD_NOT_FOUND, missing-capability, INVALID_PARAMS)
316+
# resolve here in practice.
307317
await _write(result[0], scope, receive, send)
308318
else:
319+
# First notification arrived, or the deferral window elapsed: commit
320+
# `text/event-stream` and start pinging so a proxy idle-read timeout
321+
# cannot close the stream (which on this path cancels the handler).
309322
await send({"type": "http.response.start", "status": _OK_STATUS, "headers": _SSE_HEADERS})
310-
event: bytes | None = first
311-
while True:
323+
while not done:
312324
await send({"type": "http.response.body", "body": event or b": ping\r\n\r\n", "more_body": True})
313325
event = None
314326
with anyio.move_on_after(_SSE_PING_INTERVAL):
315327
try:
316328
event = await recv_ch.receive()
317329
except anyio.EndOfStream:
318-
break
330+
done = True
319331
await send({"type": "http.response.body", "body": _sse_event(result[0]), "more_body": False})
320332

321333
tg.cancel_scope.cancel()

tests/server/test_streamable_http_modern.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,29 @@ async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams |
396396
assert events[1]["result"]["tools"] == []
397397

398398

399+
async def test_sse_mode_silent_handler_commits_sse_after_ping_interval(monkeypatch: pytest.MonkeyPatch) -> None:
400+
"""SSE mode: a handler that runs silent past the deferral window commits `text/event-stream`
401+
and starts pinging — even though it never emits a notification — so a proxy idle-read timeout
402+
does not close the connection and cancel it. SDK-defined: the deferral window is bounded by
403+
`_SSE_PING_INTERVAL`."""
404+
monkeypatch.setattr("mcp.server._streamable_http_modern._SSE_PING_INTERVAL", 0.01)
405+
406+
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
407+
await anyio.sleep(0.05)
408+
return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public")
409+
410+
async with _asgi_client(Server("test", on_list_tools=list_tools), json_response=False) as http:
411+
with anyio.fail_after(5):
412+
response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "tools/list"})
413+
414+
assert response.status_code == 200
415+
assert response.headers["content-type"].split(";", 1)[0] == "text/event-stream"
416+
assert b": ping\r\n\r\n" in response.content
417+
events = _sse_payloads(response.text)
418+
assert len(events) == 1
419+
assert events[0]["result"]["tools"] == []
420+
421+
399422
async def test_sse_mode_streams_log_notification() -> None:
400423
"""SSE mode: a request-scoped `notifications/message` emitted by the handler precedes the
401424
terminal response on the same stream. SDK-defined: notifications sent on the request's outbound

0 commit comments

Comments
 (0)