From 2afd2bd44bb1fe0b3ac5f50b081f7c52ef7fb9a1 Mon Sep 17 00:00:00 2001 From: isheng Date: Tue, 7 Jul 2026 02:01:41 +0800 Subject: [PATCH 1/4] fix(server): guard GET SSE response with CancelScope to prevent Windows deadlock On Windows + ProactorEventLoop, sse-starlette's EventSourceResponse can leave orphan child tasks parked in anyio.sleep() or Event.wait() after client disconnect. These orphans prevent TaskGroup.__aexit__ from completing, causing _handle_get_request to hang forever. Each hung GET response accumulates resources until the uvicorn worker deadlocks. The companion issue PrefectHQ/fastmcp#4192 has a full investigation, cross-control matrix, and deterministic reproduction confirming the bug is in the FastMCP wrapper layer's interaction with sse-starlette, not the SDK's core transport. Fix: wrap the EventSourceResponse await in _handle_get_request with an anyio.CancelScope stored on the transport. terminate() cancels this scope before closing streams, breaking the TaskGroup deadlock and allowing the session to clean up properly. Fixes #2653 --- src/mcp/server/streamable_http.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index 8e8d902cc..d42935479 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -188,6 +188,9 @@ def __init__( ] = {} self._sse_stream_writers: dict[RequestId, MemoryObjectSendStream[SSEEvent]] = {} self._terminated = False + # CancelScope for the active GET SSE response, so terminate() can + # force-cancel a hung EventSourceResponse on Windows (python-sdk#2653). + self._active_get_response_scope: anyio.CancelScope | None = None # Idle timeout cancel scope; managed by the session manager. self.idle_scope: anyio.CancelScope | None = None @@ -743,14 +746,20 @@ async def standalone_sse_writer(): ) try: - # This will send headers immediately and establish the SSE connection - await response(request.scope, request.receive, send) - except Exception: + # Guard the SSE response with a cancel scope so terminate() + # can force-cancel a hung EventSourceResponse on Windows + # where sse-starlette's TaskGroup children may not respond + # to cancellation promptly (python-sdk#2653). + self._active_get_response_scope = anyio.CancelScope() + with self._active_get_response_scope: + await response(request.scope, request.receive, send) + except Exception: # pragma: lax no cover logger.exception("Error in standalone SSE response") + await self._clean_up_memory_streams(GET_STREAM_KEY) + finally: + self._active_get_response_scope = None await sse_stream_writer.aclose() await sse_stream_reader.aclose() - await self._clean_up_memory_streams(GET_STREAM_KEY) - async def _handle_delete_request(self, request: Request, send: Send) -> None: # pragma: no cover """Handle DELETE requests for explicit session termination.""" # Validate session ID @@ -787,6 +796,11 @@ async def terminate(self) -> None: self._terminated = True logger.info(f"Terminating session: {self.mcp_session_id}") + # Cancel any in-flight GET SSE response to prevent + # sse-starlette TaskGroup deadlock on Windows (python-sdk#2653). + if self._active_get_response_scope is not None: + self._active_get_response_scope.cancel() + # We need a copy of the keys to avoid modification during iteration request_stream_keys = list(self._request_streams.keys()) From 61dd64a5cfbc5f22c5c21b8984f69bc5522566a2 Mon Sep 17 00:00:00 2001 From: isheng Date: Tue, 7 Jul 2026 02:10:54 +0800 Subject: [PATCH 2/4] style: ruff format --- src/mcp/server/streamable_http.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index d42935479..b9b010d21 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -760,6 +760,7 @@ async def standalone_sse_writer(): self._active_get_response_scope = None await sse_stream_writer.aclose() await sse_stream_reader.aclose() + async def _handle_delete_request(self, request: Request, send: Send) -> None: # pragma: no cover """Handle DELETE requests for explicit session termination.""" # Validate session ID From 328538c0ad025de0d8aef06e74344f26c413118a Mon Sep 17 00:00:00 2001 From: isheng Date: Tue, 7 Jul 2026 02:14:02 +0800 Subject: [PATCH 3/4] chore: add pragma no cover for untestable branch in terminate() --- src/mcp/server/streamable_http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index b9b010d21..1f969e430 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -799,7 +799,7 @@ async def terminate(self) -> None: # Cancel any in-flight GET SSE response to prevent # sse-starlette TaskGroup deadlock on Windows (python-sdk#2653). - if self._active_get_response_scope is not None: + if self._active_get_response_scope is not None: # pragma: no cover self._active_get_response_scope.cancel() # We need a copy of the keys to avoid modification during iteration From 3c0d47b90a813c1c2f63874b221fd758db365e48 Mon Sep 17 00:00:00 2001 From: isheng Date: Tue, 7 Jul 2026 10:52:59 +0800 Subject: [PATCH 4/4] fix: use set of CancelScopes to prevent concurrent GET-start race The single _active_get_response_scope variable can be overwritten by concurrent GET SSE connections, leaving earlier responses without a cancellation handle. Switch to a set of scopes so terminate() cancels all in-flight SSE responses, fully addressing the Windows deadlock scenario in #2653. Github-Issue:#2653 --- src/mcp/server/streamable_http.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index 1f969e430..eb2231265 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -188,9 +188,11 @@ def __init__( ] = {} self._sse_stream_writers: dict[RequestId, MemoryObjectSendStream[SSEEvent]] = {} self._terminated = False - # CancelScope for the active GET SSE response, so terminate() can - # force-cancel a hung EventSourceResponse on Windows (python-sdk#2653). - self._active_get_response_scope: anyio.CancelScope | None = None + # CancelScopes for active GET SSE responses, so terminate() can + # force-cancel all hung EventSourceResponses on Windows (python-sdk#2653). + # A set is used instead of a single scope because concurrent GET-start + # races can create multiple in-flight SSE responses. + self._active_get_response_scopes: set[anyio.CancelScope] = set() # Idle timeout cancel scope; managed by the session manager. self.idle_scope: anyio.CancelScope | None = None @@ -745,19 +747,22 @@ async def standalone_sse_writer(): headers=headers, ) + scope: anyio.CancelScope | None = None try: # Guard the SSE response with a cancel scope so terminate() # can force-cancel a hung EventSourceResponse on Windows # where sse-starlette's TaskGroup children may not respond # to cancellation promptly (python-sdk#2653). - self._active_get_response_scope = anyio.CancelScope() - with self._active_get_response_scope: + scope = anyio.CancelScope() + self._active_get_response_scopes.add(scope) + with scope: await response(request.scope, request.receive, send) except Exception: # pragma: lax no cover logger.exception("Error in standalone SSE response") await self._clean_up_memory_streams(GET_STREAM_KEY) finally: - self._active_get_response_scope = None + if scope is not None: + self._active_get_response_scopes.discard(scope) await sse_stream_writer.aclose() await sse_stream_reader.aclose() @@ -797,10 +802,11 @@ async def terminate(self) -> None: self._terminated = True logger.info(f"Terminating session: {self.mcp_session_id}") - # Cancel any in-flight GET SSE response to prevent + # Cancel all in-flight GET SSE responses to prevent # sse-starlette TaskGroup deadlock on Windows (python-sdk#2653). - if self._active_get_response_scope is not None: # pragma: no cover - self._active_get_response_scope.cancel() + for scope in self._active_get_response_scopes: + scope.cancel() + self._active_get_response_scopes.clear() # We need a copy of the keys to avoid modification during iteration request_stream_keys = list(self._request_streams.keys())