From 3898e62796ec1de42097a4ec569161e650307e47 Mon Sep 17 00:00:00 2001 From: kaXianc2-gom Date: Sun, 21 Jun 2026 21:53:57 +0800 Subject: [PATCH] fix: catch KeyboardInterrupt in FastMCP.run() for clean Ctrl+C exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When running a stdio server from the terminal, Ctrl+C previously produced a noisy multi-frame traceback through anyio.run() → asyncio.runners → anyio.streams.memory.receive. This change catches KeyboardInterrupt at the top-level run() method so the server exits quietly, matching user expectations for a terminal application. Closes #2663 Co-Authored-By: Claude --- src/mcp/server/mcpserver/server.py | 19 ++++++++++++------- tests/server/test_stdio.py | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index fdb69571d8..71285d719e 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -291,13 +291,18 @@ def run( if transport not in TRANSPORTS.__args__: # type: ignore # pragma: no cover raise ValueError(f"Unknown transport: {transport}") - 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: + # Ctrl+C should exit cleanly without a traceback when running + # a server from the terminal (e.g. stdio transport). + pass async def _handle_list_tools( self, ctx: ServerRequestContext[LifespanResultT], params: PaginatedRequestParams | None diff --git a/tests/server/test_stdio.py b/tests/server/test_stdio.py index 054a157b3b..1db80a012a 100644 --- a/tests/server/test_stdio.py +++ b/tests/server/test_stdio.py @@ -169,3 +169,20 @@ async def lifespan(server: MCPServer) -> AsyncIterator[None]: assert events == ["setup", "cleanup"] response = jsonrpc_message_adapter.validate_json(captured.getvalue().decode().strip()) assert response == JSONRPCResponse(jsonrpc="2.0", id=1, result={}) + + +def test_mcpserver_run_catches_keyboard_interrupt(monkeypatch: pytest.MonkeyPatch) -> None: + """Ctrl+C during `run()` should exit cleanly without a traceback. + + Regression test for #2663: when running a stdio server from the terminal, + KeyboardInterrupt (Ctrl+C) should not produce a multi-frame traceback + through anyio.run() → asyncio.runners. + """ + + def mock_anyio_run(*args: object, **kwargs: object) -> None: + raise KeyboardInterrupt() + + monkeypatch.setattr(anyio, "run", mock_anyio_run) + + # Should not raise — KeyboardInterrupt is caught inside run() + MCPServer(name="KeyboardInterruptServer").run("stdio")