Skip to content

Commit 3898e62

Browse files
kaXianc2-gomclaude
andcommitted
fix: catch KeyboardInterrupt in FastMCP.run() for clean Ctrl+C exit
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 <noreply@anthropic.com>
1 parent 734746a commit 3898e62

2 files changed

Lines changed: 29 additions & 7 deletions

File tree

src/mcp/server/mcpserver/server.py

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -291,13 +291,18 @@ def run(
291291
if transport not in TRANSPORTS.__args__: # type: ignore # pragma: no cover
292292
raise ValueError(f"Unknown transport: {transport}")
293293

294-
match transport:
295-
case "stdio":
296-
anyio.run(self.run_stdio_async)
297-
case "sse": # pragma: no cover
298-
anyio.run(lambda: self.run_sse_async(**kwargs))
299-
case "streamable-http": # pragma: no cover
300-
anyio.run(lambda: self.run_streamable_http_async(**kwargs))
294+
try:
295+
match transport:
296+
case "stdio":
297+
anyio.run(self.run_stdio_async)
298+
case "sse": # pragma: no cover
299+
anyio.run(lambda: self.run_sse_async(**kwargs))
300+
case "streamable-http": # pragma: no cover
301+
anyio.run(lambda: self.run_streamable_http_async(**kwargs))
302+
except KeyboardInterrupt:
303+
# Ctrl+C should exit cleanly without a traceback when running
304+
# a server from the terminal (e.g. stdio transport).
305+
pass
301306

302307
async def _handle_list_tools(
303308
self, ctx: ServerRequestContext[LifespanResultT], params: PaginatedRequestParams | None

tests/server/test_stdio.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,20 @@ async def lifespan(server: MCPServer) -> AsyncIterator[None]:
169169
assert events == ["setup", "cleanup"]
170170
response = jsonrpc_message_adapter.validate_json(captured.getvalue().decode().strip())
171171
assert response == JSONRPCResponse(jsonrpc="2.0", id=1, result={})
172+
173+
174+
def test_mcpserver_run_catches_keyboard_interrupt(monkeypatch: pytest.MonkeyPatch) -> None:
175+
"""Ctrl+C during `run()` should exit cleanly without a traceback.
176+
177+
Regression test for #2663: when running a stdio server from the terminal,
178+
KeyboardInterrupt (Ctrl+C) should not produce a multi-frame traceback
179+
through anyio.run() → asyncio.runners.
180+
"""
181+
182+
def mock_anyio_run(*args: object, **kwargs: object) -> None:
183+
raise KeyboardInterrupt()
184+
185+
monkeypatch.setattr(anyio, "run", mock_anyio_run)
186+
187+
# Should not raise — KeyboardInterrupt is caught inside run()
188+
MCPServer(name="KeyboardInterruptServer").run("stdio")

0 commit comments

Comments
 (0)