Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions src/mcp/server/mcpserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
anyio.run(lambda: self.run_sse_async(**kwargs))
case "streamable-http": # pragma: no branch
anyio.run(lambda: self.run_streamable_http_async(**kwargs))
except KeyboardInterrupt:
return

async def _handle_list_tools(
self, ctx: ServerRequestContext[LifespanResultT], params: PaginatedRequestParams | None
Expand Down
6 changes: 4 additions & 2 deletions tests/interaction/transports/test_stdio.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import sys
import tempfile
from pathlib import Path
from typing import TextIO, cast

import anyio
import pytest
Expand Down Expand Up @@ -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):
Expand All @@ -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")
Expand Down
24 changes: 23 additions & 1 deletion tests/server/mcpserver/test_server.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -74,6 +74,28 @@ def test_dependencies(self):
mcp_no_deps = MCPServer("test")
assert mcp_no_deps.dependencies == []

@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)

async def test_sse_app_returns_starlette_app(self):
"""Test that sse_app returns a Starlette application with correct routes."""
mcp = MCPServer("test")
Expand Down
Loading