11from __future__ import annotations
22
3+ import asyncio
34import logging
45from collections .abc import AsyncGenerator
56from contextlib import asynccontextmanager
1011from mcp .server .auth .routes import build_resource_metadata_url
1112from mcp .server .lowlevel .server import LifespanResultT
1213from mcp .server .sse import SseServerTransport
13- from starlette .requests import Request
1414from starlette .responses import Response
1515from starlette .routing import BaseRoute , Mount , Route
1616
@@ -28,6 +28,13 @@ def _is_benign_disconnect_exception(exc: BaseException) -> bool:
2828 """Return True when an exception only represents a dropped SSE client."""
2929 if isinstance (exc , (anyio .ClosedResourceError , anyio .BrokenResourceError )):
3030 return True
31+ if isinstance (exc , asyncio .CancelledError ):
32+ # Uvicorn cancels outstanding request tasks during Ctrl+C shutdown.
33+ return True
34+ if isinstance (exc , AssertionError ) and str (exc ) == "Request already responded to" :
35+ # MCP low-level request responder can assert during SSE teardown races
36+ # when cancellation/close wins over the normal response path.
37+ return True
3138
3239 if isinstance (exc , BaseExceptionGroup ):
3340 return len (exc .exceptions ) > 0 and all (_is_benign_disconnect_exception (child ) for child in exc .exceptions )
@@ -50,7 +57,7 @@ async def _run_sse_session(
5057 streams [1 ],
5158 server ._mcp_server .create_initialization_options (),
5259 )
53- except Exception as exc :
60+ except BaseException as exc :
5461 if _is_benign_disconnect_exception (exc ):
5562 logger .debug ("Suppressing benign SSE disconnect during response flush" )
5663 return False
@@ -59,6 +66,25 @@ async def _run_sse_session(
5966 return True
6067
6168
69+ async def _handle_sse (
70+ server : FastMCP [LifespanResultT ],
71+ sse : SseServerTransport ,
72+ scope ,
73+ receive ,
74+ send ,
75+ ) -> bool :
76+ """
77+ Serve SSE directly as ASGI and avoid sending any follow-up HTTP response.
78+
79+ `connect_sse(...)(scope, receive, send)` owns the HTTP response lifecycle.
80+ Returning an additional Response after it exits can race with teardown and
81+ trigger duplicate MCP request completion assertions.
82+
83+ Returns False when the session ended due to a benign client disconnect.
84+ """
85+ return await _run_sse_session (server , sse , scope , receive , send )
86+
87+
6288def create_resilient_sse_app (
6389 server : FastMCP [LifespanResultT ],
6490 message_path : str | None = None ,
@@ -78,9 +104,27 @@ def create_resilient_sse_app(
78104
79105 sse = SseServerTransport (message_path )
80106
81- async def handle_sse (scope , receive , send ) -> Response :
82- await _run_sse_session (server , sse , scope , receive , send )
83- return Response (status_code = 204 )
107+ async def handle_sse (scope , receive , send ) -> bool :
108+ return await _handle_sse (server , sse , scope , receive , send )
109+
110+ class SseEndpoint :
111+ """ASGI app wrapping handle_sse that tracks whether a response was started."""
112+
113+ async def __call__ (self , scope , receive , send ) -> None :
114+ response_started = False
115+
116+ async def tracked_send (message ) -> None :
117+ nonlocal response_started
118+ if message .get ("type" ) == "http.response.start" :
119+ response_started = True
120+ await send (message )
121+
122+ completed = await handle_sse (scope , receive , tracked_send )
123+ if completed and not response_started :
124+ response = Response (status_code = 204 )
125+ await response (scope , receive , send )
126+
127+ sse_endpoint = SseEndpoint ()
84128
85129 if auth :
86130 auth_middleware = auth .get_middleware ()
@@ -95,7 +139,7 @@ async def handle_sse(scope, receive, send) -> Response:
95139 Route (
96140 sse_path ,
97141 endpoint = RequireAuthMiddleware (
98- handle_sse ,
142+ sse_endpoint ,
99143 auth .required_scopes ,
100144 resource_metadata_url ,
101145 ),
@@ -113,10 +157,6 @@ async def handle_sse(scope, receive, send) -> Response:
113157 )
114158 )
115159 else :
116-
117- async def sse_endpoint (request : Request ) -> Response :
118- return await handle_sse (request .scope , request .receive , request ._send )
119-
120160 server_routes .append (Route (sse_path , endpoint = sse_endpoint , methods = ["GET" ]))
121161 server_routes .append (Mount (message_path , app = sse .handle_post_message ))
122162
0 commit comments