From 8ee2cc6377dd3c591b4567c82ded3854aa6b45b5 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Mon, 1 Jun 2026 08:02:58 +0800 Subject: [PATCH 1/4] fix: exit stdio server cleanly on interrupt --- src/mcp/server/mcpserver/server.py | 17 ++++++++++------- tests/server/mcpserver/test_server.py | 8 ++++++++ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index ec2365810e..afa7a06e94 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 3457ec944a..deb91255ad 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -74,6 +74,14 @@ def test_dependencies(self): mcp_no_deps = MCPServer("test") assert mcp_no_deps.dependencies == [] + def test_stdio_keyboard_interrupt_exits_cleanly(self): + mcp = MCPServer("test") + + with patch("mcp.server.mcpserver.server.anyio.run", side_effect=KeyboardInterrupt) as run: + mcp.run("stdio") + + run.assert_called_once_with(mcp.run_stdio_async) + async def test_sse_app_returns_starlette_app(self): """Test that sse_app returns a Starlette application with correct routes.""" mcp = MCPServer("test") From be0221581001933dfb65f82f6c515095e88bf488 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:42:54 +0800 Subject: [PATCH 2/4] test: cover interrupt exit for all transports --- tests/server/mcpserver/test_server.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index deb91255ad..057e6d493c 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -1,6 +1,6 @@ import base64 from pathlib import Path -from typing import Any +from typing import Any, Literal from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -74,10 +74,24 @@ def test_dependencies(self): mcp_no_deps = MCPServer("test") assert mcp_no_deps.dependencies == [] - def test_stdio_keyboard_interrupt_exits_cleanly(self): + @pytest.mark.parametrize("transport", ["stdio", "sse", "streamable-http"]) + def test_keyboard_interrupt_exits_cleanly(self, transport: Literal["stdio", "sse", "streamable-http"]): mcp = MCPServer("test") with patch("mcp.server.mcpserver.server.anyio.run", side_effect=KeyboardInterrupt) as run: + mcp.run(transport) + + assert run.call_count == 1 + if transport == "stdio": + run.assert_called_once_with(mcp.run_stdio_async) + + def test_run_propagates_non_interrupt_errors(self): + mcp = MCPServer("test") + + with ( + patch("mcp.server.mcpserver.server.anyio.run", side_effect=RuntimeError("boom")) as run, + pytest.raises(RuntimeError, match="boom"), + ): mcp.run("stdio") run.assert_called_once_with(mcp.run_stdio_async) From 8da53867b6680757323da3bc7ee3c87ea13ae95c Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:24:22 +0800 Subject: [PATCH 3/4] test: align transport coverage markers --- src/mcp/server/mcpserver/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index afa7a06e94..9142552f41 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -295,9 +295,9 @@ def run( match transport: case "stdio": anyio.run(self.run_stdio_async) - case "sse": # pragma: no cover + case "sse": anyio.run(lambda: self.run_sse_async(**kwargs)) - case "streamable-http": # pragma: no cover + case "streamable-http": # pragma: no branch anyio.run(lambda: self.run_streamable_http_async(**kwargs)) except KeyboardInterrupt: return From 4655d30e097dd97980d380ead90e4616677d8839 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:38:50 +0800 Subject: [PATCH 4/4] test: tolerate dependency stderr warnings --- tests/interaction/transports/test_stdio.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/interaction/transports/test_stdio.py b/tests/interaction/transports/test_stdio.py index 27cc65de42..a02c9ebee4 100644 --- a/tests/interaction/transports/test_stdio.py +++ b/tests/interaction/transports/test_stdio.py @@ -21,6 +21,7 @@ import sys import tempfile from pathlib import Path +from typing import TextIO, cast import anyio import pytest @@ -71,7 +72,7 @@ async def collect(params: LoggingMessageNotificationParams) -> None: # so the server module is measured. Empty when not running under coverage. env={key: value for key, value in os.environ.items() if key.startswith("COVERAGE_")}, ), - errlog=errlog, + errlog=cast(TextIO, errlog), ) with anyio.fail_after(10): @@ -92,7 +93,8 @@ async def collect(params: LoggingMessageNotificationParams) -> None: # 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") + # Prerelease Python/lowest-direct dependency runs may print warnings before the server marker. + assert captured_stderr.endswith("stdio-echo: clean exit\n") @requirement("transport:stdio:stream-purity")