diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index 8e8d902cc..eb2231265 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -188,6 +188,11 @@ def __init__( ] = {} self._sse_stream_writers: dict[RequestId, MemoryObjectSendStream[SSEEvent]] = {} self._terminated = False + # 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 @@ -742,14 +747,24 @@ async def standalone_sse_writer(): headers=headers, ) + scope: anyio.CancelScope | None = None 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). + 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: + if scope is not None: + self._active_get_response_scopes.discard(scope) 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.""" @@ -787,6 +802,12 @@ async def terminate(self) -> None: self._terminated = True logger.info(f"Terminating session: {self.mcp_session_id}") + # Cancel all in-flight GET SSE responses to prevent + # sse-starlette TaskGroup deadlock on Windows (python-sdk#2653). + 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())