Repository navigation
mcp.server.fastmcp.FastMCP HTTP transport deadlocks after ~5 sequential client sessions on Windows (companion to PrefectHQ/fastmcp#4192) #2653
Description
Activity
- addedtriageQueued for automated analysis — bot will process and remove this labelQueued for automated analysis — bot will process and remove this label
on May 31, 2026 can't reproduce on linux + cpython 3.14.5 against
origin/v1.x(wheremcp.server.fastmcp.FastMCPstill exists; onmainit was renamed tomcp.server.mcpserver.MCPServerin #1951 / 65c614e but the transport plumbing is unchanged). a 30-sequential-session driver completes 30/30 in ~80ms each, no slowdown — matching the reporter's own caveat that linux wasn't validated and that epoll loop semantics might mask it. without a windows host or a runnable reproducer attached here, this can't be confirmed against the sdk.one factual correction worth flagging before any fix lands: the writeup says "raw
mcp.server.lowlevel.Serverwrites text/event-stream directly via its own ASGI write loop without ever instantiating EventSourceResponse." that's not how the code is structured. bothFastMCP.streamable_http_app()and the raw lowlevel path delegate toStreamableHTTPSessionManager→StreamableHTTPServerTransportinsrc/mcp/server/streamable_http.py, which instantiatessse_starlette.EventSourceResponseat three sites (POST-with-SSE, standalone GET, resumption replay). there is no separate sse_starlette integration in the wrapper layer. whatever explains the "raw passes 30/30, wrapper hangs at 5" delta on windows, it isn't a different EventSourceResponse instantiation site.what would unblock investigation: a single-file reproducer attached to this issue that hangs on windows + 3.14, and the python -m asyncio ps dump taken against an SDK-only repro (not the PrefectHQ fork — they ship a different sse_starlette pin and additional session middleware that may dominate the difference). until then this stays needs-repro.
repro script (run as `python repro.py`, set
SESSIONS/TIMEOUT/PORTenv vars to tune)""" Reproducer for issue #2653: FastMCP HTTP transport hang after ~5 sequential sessions. Spawns a streamable HTTP server with FastMCP, then runs N sequential sessions hitting initialize -> tools/list -> close. Reports the iteration each one completes at. If the bug reproduces, sessions stop completing partway through. """ from __future__ import annotations import asyncio import contextlib import os import sys import threading import time import uvicorn from mcp import ClientSession from mcp.client.streamable_http import streamablehttp_client from mcp.server.fastmcp import FastMCP SESSIONS = int(os.environ.get("SESSIONS", "30")) TIMEOUT_PER_SESSION = float(os.environ.get("TIMEOUT", "5")) PORT = int(os.environ.get("PORT", "8765")) def build_server() -> FastMCP: mcp = FastMCP(name="repro", port=PORT, host="127.0.0.1") @mcp.tool() def add(a: int, b: int) -> int: return a + b @mcp.tool() def echo(s: str) -> str: return s return mcp async def run_one_session(idx: int) -> None: url = f"http://127.0.0.1:{PORT}/mcp" async with streamablehttp_client(url) as (read, write, _get_session_id): async with ClientSession(read, write) as session: await session.initialize() tools = await session.list_tools() assert any(t.name == "add" for t in tools.tools) res = await session.call_tool("add", {"a": idx, "b": 1}) assert res.content async def driver() -> None: for i in range(SESSIONS): t0 = time.monotonic() try: await asyncio.wait_for(run_one_session(i), timeout=TIMEOUT_PER_SESSION) except asyncio.TimeoutError: elapsed = time.monotonic() - t0 print(f"HANG at session #{i + 1} after {elapsed:.1f}s", flush=True) return except Exception as e: elapsed = time.monotonic() - t0 print(f"ERROR at session #{i + 1} after {elapsed:.1f}s: {type(e).__name__}: {e}", flush=True) return elapsed = time.monotonic() - t0 print(f"session #{i + 1} ok in {elapsed * 1000:.0f}ms", flush=True) print(f"all {SESSIONS} sessions completed", flush=True) def run_server_in_thread(mcp: FastMCP) -> threading.Thread: def _run(): app = mcp.streamable_http_app() config = uvicorn.Config(app, host="127.0.0.1", port=PORT, log_level="warning") server = uvicorn.Server(config) server.run() t = threading.Thread(target=_run, daemon=True) t.start() return t async def wait_for_server() -> None: import httpx deadline = time.monotonic() + 10 while time.monotonic() < deadline: try: async with httpx.AsyncClient(timeout=1.0) as client: await client.get(f"http://127.0.0.1:{PORT}/mcp") return except (httpx.ConnectError, httpx.ReadTimeout): await asyncio.sleep(0.1) raise RuntimeError("server didn't come up") async def main() -> None: mcp = build_server() run_server_in_thread(mcp) await wait_for_server() print(f"server up, running {SESSIONS} sequential sessions", flush=True) await driver() if __name__ == "__main__": with contextlib.suppress(KeyboardInterrupt): asyncio.run(main()) sys.exit(0)
linux + cpython 3.14.5 run on
origin/v1.x(6213787) — 30/30 pass$ python repro.py server up, running 30 sequential sessions session #1 ok in 77ms session #2 ok in 83ms session #3 ok in 79ms session #4 ok in 80ms session #5 ok in 87ms ... session #28 ok in 74ms session #29 ok in 82ms session #30 ok in 72ms all 30 sessions completedasyncio policy in the venv is
_UnixDefaultEventLoopPolicy(epoll). onorigin/mainthe same shape withMCPServerandstreamable_http_clientalso passes 30/30.where EventSourceResponse actually lives
src/mcp/server/streamable_http.pyinstantiatessse_starlette.EventSourceResponseat:_handle_post_requestSSE branch (line ~617 on main, ~627 on v1.x) — per-POST stream_handle_get_request(line ~726 on main, ~736 on v1.x) — standalone server-initiated stream_replay_events(line ~931 on main, ~944 on v1.x) — last-event-id replay
FastMCP.streamable_http_app()(v1.x) andMCPServer.streamable_http_app()(main) both build a starlette app whose/mcpendpoint isStreamableHTTPASGIApp(self._session_manager)whereself._session_manageris aStreamableHTTPSessionManager. the raw lowlevel example you cite calls the sameStreamableHTTPSessionManager.handle_requestASGI entrypoint. there is no second EventSourceResponse code path.- addedbugSomething isn't workingSomething isn't workingneeds reproneeds additional information to be able to reproduce bugneeds additional information to be able to reproduce bugand removedtriageQueued for automated analysis — bot will process and remove this labelQueued for automated analysis — bot will process and remove this label
on Jun 1, 2026 A small follow-up — a re-confirmation and a caution that may save others time.
Re-confirmation: no repro at 30 sessions. On the same Windows 11 (26200) / Python 3.14 box, a fresh 30-cycle reconnect soak (sequential connect → call → disconnect against a Streamable-HTTP server) passed 30/30 with the standing GET stream live — consistent with the Linux 30/30 above and the earlier 110-session run, so the hang stays a Windows/Proactor timing heisenbug here. For completeness I also ran it with the standing GET stream retired (server returns
405onGET, so_handle_get_request()/EventSourceResponseis never entered): also 30/30. With the baseline already green that isn't decisive on its own — it's just the result you'd expect if the standalone GET-stream teardown is the locus (one of the threeEventSourceResponsesites noted above), and it lines up withstateless_http=Trueas the practical mitigation.Caution for anyone building a minimal reproduction. While writing the soak I hit a hang that looked exactly like the reported "~session #12" wall — and it turned out to be a confound in my own harness, not the server: I was capturing the server subprocess's stdout/stderr through an undrained
subprocess.PIPE. uvicorn emits ~5 access-log lines per reconnect cycle, and around cycle ~12 that fills the OS pipe buffer (~64 KB on Windows); the server then blocks on its own log write and stops servicing requests — presenting identically to the deadlock under test. Draining the sink (temp file /DEVNULL/ reader thread) made it disappear and the soak ran 30/30. I flag it only because a repro driver that pipes server output without draining will produce a false positive that mimics #12 — worth ruling out before attributing a new "#12 hang" to this issue. It does not explain away the original capture (the FastMCP companion, PrefectHQ/fastmcp#4192, has theasynciotask dump with non-pipe sinks).(Cross-posted to PrefectHQ/fastmcp#4192.)
I can reproduce this on Windows 11 + Python 3.13 — the deadlock at session #5 is consistent with the investigation in PrefectHQ/fastmcp#4192.
I have experience debugging concurrency/deadlock issues in Python async code (previously fixed a cron double-execution race condition). Running on Windows natively means I can iterate on the fix without cross-OS guesswork.
I'd like to work on this. Happy to start with a clean repro script and trace the deadlock through the HTTP transport layer. Let me know if I should go ahead.
- added a commit that references this issue
on Jul 7, 2026
mcp.server.fastmcp.FastMCPHTTP transport deadlocks after ~5 sequential client sessions on Windows (companion to PrefectHQ/fastmcp#4192)Summary
Filing here as a companion to PrefectHQ/fastmcp#4192, which contains the full investigation, cross-control matrix, stack-dump evidence, and minimal reproducers. The reason for the companion issue: the bug reproduces on Anthropic's
mcp.server.fastmcp.FastMCPshipped inside this SDK, faster than on PrefectHQ's downstream fork, and the SDK maintainers should see the signal directly rather than having to chase a downstream cross-reference.The one-line result that's yours
A 30-sequential-session driver running against an HTTP server built with
mcp.server.fastmcp.FastMCP1.27.1 on Windows + Python 3.14 hangs at session #5, with gradual slowdown visible from session #4 onwards. The same driver against PrefectHQfastmcp.FastMCP3.3.1 hangs at session #12; against rawmcp.server.lowlevel.Serverit passes 30/30; against the Node.js TypeScript reference server (@modelcontextprotocol/server-everything) it passes 30/30.That is: this SDK's
FastMCPwrapper exhibits the defect more aggressively than the downstream fork, and the SDK's own lower-level surface is unaffected.Cross-control matrix (excerpt; full matrix in PrefectHQ/fastmcp#4192)
mcp.server.fastmcp.FastMCP1.27.1mcp.server.fastmcp.FastMCP1.27.1fastmcp.FastMCP3.3.1 (PrefectHQ)mcp.server.lowlevel.Server+StreamableHTTPSessionManager@modelcontextprotocol/server-everything(Node.js streamableHttp)Python-version variance rules out a 3.14 regression. The Node.js control rules out a protocol-level defect. The raw-SDK control rules out the SDK's lower-level transport.
Mechanism (per stack-dump evidence in #4192)
python -m asyncio ps <PID>on the hung server process shows multiple Task entries (one per "closed" session) parked inTaskGroup.__aexit__, all with child tasks namedsse_starlette.sse.EventSourceResponse.__call__.<locals>.cancel_on_finishblocked inEvent.wait/sleepon_ping,_listen_for_disconnect,_stream_response, and the SDK'sstandalone_sse_writer.mcp.server.fastmcp.FastMCP.create_streamable_http_appinstantiatesEventSourceResponsefromsse_starletteas part of its ASGI app construction. The rawmcp.server.lowlevel.Serverpath writestext/event-streamdirectly via its own ASGI write loop without ever instantiatingEventSourceResponse— which is why it isn't affected. The defect is in the wrapper layer's interaction withsse_starlette, not the SDK's core transport.Reproducers
Two minimal reproducers in the PrefectHQ issue: one for PrefectHQ FastMCP, one for raw SDK (the passing control). A third targeting
mcp.server.fastmcp.FastMCPdirectly is trivially derivable — same shape, different import — and I'm happy to add it here if useful.Why the cap is lower on this SDK's wrapper
Working hypothesis (not yet verified):
mcp.server.fastmcp.FastMCPand PrefectHQ'sfastmcp.FastMCPuse slightly differentsse_starletteintegration shapes. Either version-pin differences, or an extra layer of session-lifecycle handling in one wrapper that creates more orphan tasks per session, would explain why the threshold lands at 5 here vs 12 downstream. Confirming this would need a side-by-side diff of the two wrappers'create_streamable_http_apppaths andsse_starletteversions, which I haven't done.Platform / environment
ProactorEventLoop)mcp1.27.1 (the SDK that shipsmcp.server.fastmcp.FastMCP)epoll-based loop semantics may drain orphan tasks faster. Maintainer Linux validation requested in #4192; same request stands here.The question for SDK maintainers
Is
mcp.server.fastmcp.FastMCPconsidered an actively-maintained surface that the SDK team will fix, or is it a thin reference wrapper where PrefectHQ's fork is the canonical production target? The answer changes where the fix should land and whether this issue stays open here or gets closed in favour of #4192.If it's in-scope here, the cross-controls in #4192 already narrow the surface to "
EventSourceResponseintegration in the FastMCP wrapper layer." The raw SDK path is the working reference.Workaround for users of this SDK
Migrate from
mcp.server.fastmcp.FastMCPtomcp.server.lowlevel.Server+ manually-constructedStreamableHTTPSessionManager+ Starlette + uvicorn. ~1 day's mechanical rewrite for a server with ~7 tools. Empirically 30/30 sessions passing on the same workload.