From 8209093871af25bc3ceb50bfbcec317632218afd Mon Sep 17 00:00:00 2001 From: Sarthak Bhardwaj <100398847+SarthakB11@users.noreply.github.com> Date: Sun, 10 May 2026 20:28:13 +0530 Subject: [PATCH] fix(http): terminate active streamable-HTTP transports before lifespan shutdown (#4118) --- src/fastmcp/server/http.py | 21 ++++++++++- .../http/test_streamable_http_shutdown.py | 36 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 tests/server/http/test_streamable_http_shutdown.py diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py index 46d0c956d..6459ee845 100644 --- a/src/fastmcp/server/http.py +++ b/src/fastmcp/server/http.py @@ -370,7 +370,26 @@ def create_streamable_http_app( server._lifespan_manager(), streamable_http_app.session_manager.run(), ): - yield + try: + yield + finally: + # Gracefully terminate active streamable-HTTP transports before + # the session manager's task group is cancelled. Without this, + # active SSE/streaming responses are aborted mid-flight and + # Uvicorn logs "ASGI callable returned without completing + # response." See PrefectHQ/fastmcp#3025. + sm = streamable_http_app.session_manager + # `_server_instances` is a private attribute of the upstream + # `StreamableHTTPSessionManager` (mcp SDK); termination is + # idempotent and tolerates new instances being added concurrently. + for transport in list(sm._server_instances.values()): + try: + await transport.terminate() + except Exception: + logger.debug( + "Error terminating streamable-HTTP transport on shutdown", + exc_info=True, + ) # Create and return the app with lifespan app = create_base_app( diff --git a/tests/server/http/test_streamable_http_shutdown.py b/tests/server/http/test_streamable_http_shutdown.py new file mode 100644 index 000000000..ae323766c --- /dev/null +++ b/tests/server/http/test_streamable_http_shutdown.py @@ -0,0 +1,36 @@ +"""Regression tests for graceful streamable-HTTP shutdown (issue #3025).""" + +from unittest.mock import AsyncMock + +from fastmcp.server import FastMCP + + +async def test_lifespan_terminates_active_transports_before_task_group_cancel(): + """Active streamable-HTTP transports must be terminated during lifespan + shutdown so streaming responses end cleanly instead of being aborted by + the session manager's task-group cancel. + + See PrefectHQ/fastmcp#3025: without graceful termination, Uvicorn logs + "ASGI callable returned without completing response." on CTRL+C while a + client holds an SSE GET stream open. + """ + server = FastMCP(name="ShutdownTest") + app = server.http_app(path="/mcp") + + fake_transport = AsyncMock() + fake_transport.terminate = AsyncMock() + + async with app.router.lifespan_context(app): + # The session manager is constructed inside the lifespan; once it has + # started, register a fake active transport as if a client were + # holding an SSE stream open. + sm = None + for route in app.router.routes: + endpoint = getattr(route, "endpoint", None) + sm = getattr(endpoint, "session_manager", None) + if sm is not None: + break + assert sm is not None, "streamable-http session manager not initialised" + sm._server_instances["fake-session"] = fake_transport + + fake_transport.terminate.assert_awaited_once()