From 30a61fd1164ac640c0f0ffff4107d195d3f0d7fe Mon Sep 17 00:00:00 2001 From: kigland Date: Thu, 4 Jun 2026 10:40:53 +0800 Subject: [PATCH 1/2] Handle KeyboardInterrupt in server run --- src/mcp/server/mcpserver/server.py | 17 ++++++++++------- tests/server/mcpserver/test_server.py | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index ec2365810..afa7a06e9 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -291,13 +291,16 @@ 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: + return async def _handle_list_tools( self, ctx: ServerRequestContext[LifespanResultT], params: PaginatedRequestParams | None diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 21352b5f2..02b51a357 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -73,6 +73,27 @@ def test_dependencies(self): mcp_no_deps = MCPServer("test") assert mcp_no_deps.dependencies == [] + def test_run_suppresses_keyboard_interrupt(self, monkeypatch: pytest.MonkeyPatch): + mcp = MCPServer("test") + + def raise_keyboard_interrupt(*args: Any, **kwargs: Any) -> None: + raise KeyboardInterrupt + + monkeypatch.setattr("mcp.server.mcpserver.server.anyio.run", raise_keyboard_interrupt) + + assert mcp.run(transport="stdio") is None + + def test_run_reraises_other_exceptions(self, monkeypatch: pytest.MonkeyPatch): + mcp = MCPServer("test") + + def raise_runtime_error(*args: Any, **kwargs: Any) -> None: + raise RuntimeError("startup failed") + + monkeypatch.setattr("mcp.server.mcpserver.server.anyio.run", raise_runtime_error) + + with pytest.raises(RuntimeError, match="startup failed"): + mcp.run(transport="stdio") + async def test_sse_app_returns_starlette_app(self): """Test that sse_app returns a Starlette application with correct routes.""" mcp = MCPServer("test") From 2ef65c1b09916dd9a975bdae3c3b47108ae2580d Mon Sep 17 00:00:00 2001 From: kigland Date: Thu, 4 Jun 2026 16:04:20 +0800 Subject: [PATCH 2/2] tolerate stdio subprocess warnings --- tests/interaction/transports/test_stdio.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/interaction/transports/test_stdio.py b/tests/interaction/transports/test_stdio.py index 27cc65de4..c934c3b8a 100644 --- a/tests/interaction/transports/test_stdio.py +++ b/tests/interaction/transports/test_stdio.py @@ -89,10 +89,12 @@ async def collect(params: LoggingMessageNotificationParams) -> None: [LoggingMessageNotificationParams(level="info", logger="echo", data="echoing across\nprocesses")] ) # The server writes this line only after its run loop returns, which happens when stdin closes: - # seeing it proves the process exited on its own rather than via the transport's terminate - # escalation, without a timing-based assertion. The capture itself proves stderr passthrough: - # the transport routes the child's stderr to the caller's `errlog` without consuming it. - assert captured_stderr == snapshot("stdio-echo: clean exit\n") + # seeing it as the final stderr line proves the process exited on its own rather than via the + # transport's terminate escalation, without a timing-based assertion. The capture itself proves + # stderr passthrough: the transport routes the child's stderr to the caller's `errlog` without + # consuming it. Some lowest-direct dependency combinations can emit import-time warnings before + # the server starts, so do not require this to be the only stderr line. + assert captured_stderr.splitlines(keepends=True)[-1:] == snapshot(["stdio-echo: clean exit\n"]) @requirement("transport:stdio:stream-purity")