Repository navigation
FastMCP.run(transport="stdio") produces noisy traceback on KeyboardInterrupt instead of clean exit #2663
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 reproduced on main (commit 616476f) and v1.x (commit 6213787). root cause: the sync
run(...)entry point wrapsanyio.run(self.run_stdio_async)with noKeyboardInterruptguard, so a ctrl-c during the stdio receive loop propagates asasyncio.CancelledError→WouldBlock→KeyboardInterruptthrough asyncio/anyio internals before reaching user code. same pattern affects thesseandstreamable-httpcases on the same dispatch. workaround for users: catchKeyboardInterruptin the caller or run viaasyncio.run(mcp.run_stdio_async())and trap there.repro
repro_server.py(main /MCPServer):import sys from mcp.server.mcpserver import MCPServer mcp = MCPServer("repro") @mcp.tool() def echo(s: str) -> str: return s if __name__ == "__main__": print("READY", file=sys.stderr, flush=True) mcp.run(transport="stdio")
repro_server_v1x.py(v1.x /FastMCP):import sys from mcp.server.fastmcp import FastMCP mcp = FastMCP("repro") @mcp.tool() def echo(s: str) -> str: return s if __name__ == "__main__": print("READY", file=sys.stderr, flush=True) mcp.run(transport="stdio")
repro.py— spawns the server in its own process group, waits forREADY, sends SIGINT, captures stderr:"""Reproduce: KeyboardInterrupt during stdio run produces a noisy traceback.""" import os import signal import subprocess import sys import time def main() -> int: proc = subprocess.Popen( [sys.executable, "repro_server.py"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True, ) deadline = time.monotonic() + 30.0 while time.monotonic() < deadline: line = proc.stderr.readline() if b"READY" in line: break time.sleep(0.05) time.sleep(0.5) print(f"sending SIGINT to pid {proc.pid}", file=sys.stderr) os.killpg(proc.pid, signal.SIGINT) try: stdout, stderr = proc.communicate(timeout=10) except subprocess.TimeoutExpired: proc.kill() stdout, stderr = proc.communicate() print(f"--- exit code: {proc.returncode} ---") print("--- stdout ---") sys.stdout.write(stdout.decode(errors="replace")) print("--- stderr ---") sys.stdout.write(stderr.decode(errors="replace")) print("--- end ---") return 0 if __name__ == "__main__": sys.exit(main())
run:
$ uv run python repro.pytail of stderr from the spawned process:
File "src/mcp/server/lowlevel/server.py", line 394, in run async for message in session.incoming_messages: ... File "anyio/streams/memory.py", line 119, in receive await receive_event.wait() ... asyncio.exceptions.CancelledError During handling of the above exception, another exception occurred: Traceback (most recent call last): File "repro_server.py", line 15, in <module> mcp.run(transport="stdio") File "src/mcp/server/mcpserver/server.py", line 296, in run anyio.run(self.run_stdio_async) ... File "asyncio/runners.py", line 123, in run raise KeyboardInterrupt() KeyboardInterruptcode path
main —
src/mcp/server/mcpserver/server.py:294-300:match transport: case "stdio": anyio.run(self.run_stdio_async) case "sse": anyio.run(lambda: self.run_sse_async(**kwargs)) case "streamable-http": anyio.run(lambda: self.run_streamable_http_async(**kwargs))
v1.x —
src/mcp/server/fastmcp/server.py:294-300is identical in shape. wrapping eachanyio.run(...)intry/except KeyboardInterrupt: return(or a single guard around thematch) is sufficient —run_stdio_async/run_sse_async/run_streamable_http_asyncalready let cancellation unwind their context managers cleanly; only the outer print of the traceback needs suppressing. needs the same change onv1.x.suggested fix
// src/mcp/server/mcpserver/server.py (and the analogous block in src/mcp/server/fastmcp/server.py on v1.x) - match transport: - case "stdio": - anyio.run(self.run_stdio_async) - case "sse": # pragma: no cover - anyio.run(lambda: self.run_sse_async(**kwargs)) - case "streamable-http": # pragma: no cover - anyio.run(lambda: self.run_streamable_http_async(**kwargs)) + try: + match transport: + case "stdio": + anyio.run(self.run_stdio_async) + case "sse": # pragma: no cover + anyio.run(lambda: self.run_sse_async(**kwargs)) + case "streamable-http": # pragma: no cover + anyio.run(lambda: self.run_streamable_http_async(**kwargs)) + except KeyboardInterrupt: + pass
test to verify: patches
anyio.runto raiseKeyboardInterruptand assertsmcp.run(transport=...)returns cleanly for each of the three transports.- addedbugSomething isn't workingSomething isn't workingready for workEnough information for someone to start working onEnough information for someone to start working onP3Nice to haves, rare edge casesNice to haves, rare edge casesfix proposedBot has a verified fix diff in the commentBot has a verified fix diff in the commentand 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 Picking this up — catching
KeyboardInterruptat theMCPServer.run()boundary so stdio servers exit cleanly on Ctrl-C.- added a commit that references this issue
on Jun 30, 2026 Ten PRs are linked to this issue and nine of them are noise from the reader's point of view. Putting the state in one place so it can actually be closed.
First, a linkage artifact worth knowing about: #3005 ("Add the SEP-2663 Tasks extension") is not a fix for this issue. It's linked because the SEP number and this issue number are both 2663, so GitHub's cross-reference picked it up. Anyone triaging by "PRs linked to #2663" will keep hitting it. Worth ignoring in this context.
That leaves three live candidates, and their source change is byte-identical. I diffed all three against
main:PR date author source diff tests #2745 Jun 1 @he-yufeng try/except KeyboardInterrupt: returnaround thematch2 files #2778 Jun 4 @koriyoshi2041 same 2 files #3027 Jun 30 @devansh-dek same 1 file All three wrap the whole
match transport:block and swallowKeyboardInterruptwith a barereturn. Same 10 added, 7 removed lines, same position. There is no behavioural difference to choose between.The differences are only in tests:
- fix: exit stdio server cleanly on interrupt #2745 parametrises over all three transports (
stdio,sse,streamable-http) and also assertsrun.assert_called_once_with(mcp.run_stdio_async). It additionally touchestests/interaction/transports/test_stdio.pywith acast(TextIO, errlog)typing fix, which is unrelated to this bug and probably belongs elsewhere. - Suppress KeyboardInterrupt tracebacks from MCPServer.run #2778 and fix(server): exit cleanly on KeyboardInterrupt in MCPServer.run #3027 have effectively the same two tests (suppress
KeyboardInterrupt, re-raiseRuntimeError), single-transport. Suppress KeyboardInterrupt tracebacks from MCPServer.run #2778 also rewrites a comment in the stdio interaction test. - fix(server): exit cleanly on KeyboardInterrupt in MCPServer.run #3027 is the tightest diff: one test file, no unrelated edits.
One substantive note that applies to all three, and is worth resolving before whichever one lands.
except KeyboardInterrupt: returnmakesrun()return normally, so the process exits 0. Conventionally a program killed by SIGINT exits128 + 2 = 130, and shell scripts or supervisors that check the exit code will now read a Ctrl-C as a successful run. If the intent is purely to suppress the traceback,raise SystemExit(130)keeps the quiet exit while preserving the signal semantics. If exiting 0 is deliberate, worth a comment saying so, because it's the kind of thing that gets "fixed" later by someone who assumes it was an oversight.Also, #2745 changes two
# pragma: no covermarkers (sseto nothing,streamable-httpto# pragma: no branch). That's a coverage-config side effect of parametrising over all three transports rather than part of the fix, and it'd be worth calling out explicitly rather than leaving it in the diff unexplained.Given the source is identical, I'd suggest picking on tests and diff hygiene rather than re-reviewing the fix three times: #3027 if you want the minimal diff, #2745 if the three-transport parametrisation is worth the extra unrelated hunk. #2745 has priority by date.
@Kludex @ihrpr this one has been sitting since May with three interchangeable fixes; a one-line "take #X, close the rest" would clear it and free up the contributors.
- fix: exit stdio server cleanly on interrupt #2745 parametrises over all three transports (
Initial Checks
Description
Running a FastMCP stdio server from the terminal and interrupting with Ctrl-C produces a multi-frame traceback through anyio.run() → asyncio.runners → mcp.server.lowlevel.server.run → anyio.streams.memory.receive. The server is correctly waiting on stdin in an async loop; the trace surfaces because anyio/asyncio don't suppress the cancellation chain. Suggested fix: catch KeyboardInterrupt (or asyncio.CancelledError at the outer boundary) inside FastMCP.run and exit cleanly. Cosmetic but reduces user confusion when testing MCP servers from the terminal.
Example Code
Python & MCP Python SDK