fix(server): guard GET SSE response with CancelScope to prevent Windows deadlock (#2653)

Closed
IS
23d ago
+25-4

Summary

Fixes #2653 — mcp.server.fastmcp.FastMCP HTTP transport deadlocks after ~5 sequential client sessions on Windows.

Root Cause

sse-starlette's EventSourceResponse.__call__ creates an internal anyio.TaskGroup whose child tasks (_ping, _listen_for_disconnect, _listen_for_exit_signal, standalone_sse_writer) may not respond to cancellation promptly on Windows ProactorEventLoop. When the client disconnects, orphan children remain parked in anyio.sleep() or Event.wait(), preventing TaskGroup.__aexit__ from completing.

The _handle_get_request handler directly awaits the EventSourceResponse callable, so the hung TaskGroup blocks the handler forever. Each session that opens a GET SSE stream and disconnects leaks one such hung task group. After enough sessions (~5–18 depending on Python version), the next initialize POST never dispatches and the server deadlocks.

The raw mcp.server.lowlevel.Server + StreamableHTTPSessionManager path is unaffected because it writes text/event-stream directly without sse-starlette. The Node.js reference server is also unaffected. The defect is confined to the FastMCP wrapper layer's use of sse-starlette.EventSourceResponse.

Fix

Three changes in src/mcp/server/streamable_http.py:

  1. __init__: Add _active_get_response_scope: anyio.CancelScope | None attribute to track the in-flight GET SSE response.

  2. _handle_get_request: Wrap the await response(...) call with the cancel scope instead of awaiting it directly. This gives terminate() a handle to force-cancel a hung response.

  3. terminate(): Cancel _active_get_response_scope (if set) before closing streams. This breaks the sse-starlette TaskGroup deadlock and allows the session to clean up, even on Windows ProactorEventLoop.

The fix is minimal (19 insertions, 5 deletions), backward-compatible, and targets only the GET SSE path where EventSourceResponse is awaited directly. The POST handler already runs EventSourceResponse inside an anyio.create_task_group() which provides equivalent cancellation safety.

Related

  • PrefectHQ/fastmcp#4192 — full investigation, cross-control matrix, deterministic reproduction
  • sse-starlette — issue is in the library's TaskGroup cleanup on Windows, but the SDK can defend against it here