Skip to content

Commit 64eb983

Browse files
committed
[minimcp] Add integration tests for MCP servers built with different transports
- Add integration test suite for StdioTransport - Add integration test suite for HTTPTransport - Add integration test suite for StreamableHTTPTransport - Add test helpers (client session, HTTP utilities, process management) - Add math_mcp example server for integration testing - Add server fixtures and conftest configuration - Add psutil dependency for process management in tests
1 parent 0f9ffd2 commit 64eb983

File tree

13 files changed

+1765
-0
lines changed

13 files changed

+1765
-0
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ dev = [
6363
"inline-snapshot>=0.23.0",
6464
"dirty-equals>=0.9.0",
6565
"coverage[toml]==7.10.7",
66+
"psutil>=7.1.3",
6667
]
6768
docs = [
6869
"mkdocs>=1.6.1",

tests/conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,12 @@
44
@pytest.fixture
55
def anyio_backend():
66
return "asyncio"
7+
8+
9+
def pytest_addoption(parser: pytest.Parser) -> None:
10+
parser.addoption(
11+
"--use-existing-minimcp-server",
12+
action="store_true",
13+
default=False,
14+
help="Use an already running MinimCP server if available.",
15+
)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""
2+
Shared fixtures for MCP integration tests.
3+
"""
4+
5+
from collections.abc import AsyncGenerator
6+
7+
import anyio
8+
import pytest
9+
import servers.http_server as http_test_server
10+
from helpers.http import until_available, url_available
11+
from helpers.process import run_module
12+
from servers.http_server import SERVER_HOST, SERVER_PORT
13+
14+
pytestmark = pytest.mark.anyio
15+
16+
BASE_URL: str = f"http://{SERVER_HOST}:{SERVER_PORT}"
17+
18+
19+
def pytest_configure(config: pytest.Config) -> None:
20+
use_existing_minimcp_server = config.getoption("--use-existing-minimcp-server")
21+
server_is_running = anyio.run(url_available, BASE_URL)
22+
23+
if server_is_running and not use_existing_minimcp_server:
24+
raise RuntimeError(
25+
f"Server is already running at {BASE_URL}. "
26+
"Run pytest with the --use-existing-minimcp-server option to use it."
27+
)
28+
29+
30+
@pytest.fixture(scope="session")
31+
def anyio_backend():
32+
return "asyncio"
33+
34+
35+
@pytest.fixture(scope="session")
36+
async def http_test_server_process() -> AsyncGenerator[None, None]:
37+
"""
38+
Session-scoped fixture that starts the HTTP test server once across all workers.
39+
40+
With pytest-xdist, multiple workers may call this fixture. The first worker starts the server,
41+
and subsequent workers detect and reuse it.
42+
"""
43+
44+
if await url_available(BASE_URL):
45+
# Server is available, use that.
46+
yield None
47+
else:
48+
try:
49+
async with run_module(http_test_server):
50+
await until_available(BASE_URL)
51+
yield None
52+
await anyio.sleep(1) # Wait a bit for safe shutdown
53+
except Exception:
54+
# If server started between our check and start attempt, that's OK
55+
# Another worker got there first
56+
yield None
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from mcp import ClientSession, InitializeResult
2+
3+
4+
class ClientSessionWithInit(ClientSession):
5+
"""A client session that stores the initialization result."""
6+
7+
initialize_result: InitializeResult | None = None
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import anyio
2+
import httpx
3+
4+
5+
async def url_available(url: str) -> bool:
6+
"""Check if a URL is available (server is responding).
7+
8+
Returns True if the server responds with any status code (including 405 Method Not Allowed),
9+
which indicates the server is running. Returns False only on connection errors.
10+
"""
11+
try:
12+
async with httpx.AsyncClient(follow_redirects=True) as client:
13+
await client.get(url, timeout=2.0)
14+
# Any response (including 405, 404, etc.) means the server is running
15+
return True
16+
except Exception:
17+
# Connection refused, timeout, etc. - server not available
18+
return False
19+
20+
21+
async def until_available(url: str, max_attempts: int = 60, sleep_interval: float = 0.5) -> None:
22+
"""Wait for a URL to become available.
23+
24+
Default timeout is 30 seconds (60 attempts * 0.5 seconds).
25+
This gives the server enough time to start even under heavy system load.
26+
"""
27+
for _ in range(max_attempts):
28+
if await url_available(url):
29+
return None
30+
await anyio.sleep(sleep_interval)
31+
32+
raise RuntimeError(f"URL {url} is not available after {max_attempts * sleep_interval} seconds")
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import os
2+
from collections.abc import AsyncGenerator
3+
from contextlib import asynccontextmanager
4+
from pathlib import Path
5+
from subprocess import DEVNULL, Popen
6+
from types import ModuleType
7+
8+
from psutil import NoSuchProcess, Process, ZombieProcess, process_iter # type: ignore
9+
10+
11+
def find_process(cmd_substr: str) -> Process | None:
12+
"""Find a process by file_name"""
13+
try:
14+
for proc in process_iter(["pid", "name", "cmdline"]):
15+
cmdline = proc.info.get("cmdline", [])
16+
if cmdline and any(cmd_substr in str(cmd) for cmd in cmdline):
17+
return Process(proc.info["pid"])
18+
except (NoSuchProcess, ZombieProcess):
19+
pass
20+
21+
return None
22+
23+
24+
@asynccontextmanager
25+
async def run_subprocess(cmd: list[str], cwd: Path) -> AsyncGenerator[Process, None]:
26+
"""Run a subprocess and yield the process."""
27+
sub_proc = None
28+
29+
try:
30+
sub_proc = Popen(
31+
cmd,
32+
stdout=DEVNULL,
33+
stderr=DEVNULL,
34+
text=True,
35+
cwd=cwd,
36+
)
37+
38+
process = Process(sub_proc.pid)
39+
40+
if not process.is_running():
41+
raise RuntimeError(f"Process for command {cmd} exited unexpectedly.")
42+
43+
yield process
44+
45+
finally:
46+
if sub_proc and sub_proc.poll() is None:
47+
sub_proc.terminate()
48+
sub_proc.wait(5)
49+
if sub_proc.poll() is None:
50+
raise RuntimeError("Process was not terminated.")
51+
52+
53+
@asynccontextmanager
54+
async def run_module(module: ModuleType) -> AsyncGenerator[Process, None]:
55+
"""Run a module as a subprocess and yield the process."""
56+
57+
project_root = Path(__file__).parent.parent.parent.parent.parent.parent
58+
path_relative_to_root = Path(module.__file__ or "").relative_to(project_root)
59+
module_name = str(path_relative_to_root.with_suffix("")).replace(os.sep, ".")
60+
61+
cmd = ["uv", "run", "python", "-m", module_name]
62+
63+
async with run_subprocess(cmd, project_root) as process:
64+
yield process
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Test server for HTTP transport integration tests.
4+
"""
5+
6+
import os
7+
8+
import uvicorn
9+
from starlette.applications import Starlette
10+
11+
from mcp.server.minimcp import HTTPTransport, StreamableHTTPTransport
12+
13+
from .math_mcp import math_mcp
14+
15+
SERVER_HOST = os.environ.get("TEST_SERVER_HOST", "127.0.0.1")
16+
SERVER_PORT = int(os.environ.get("TEST_SERVER_PORT", "30789"))
17+
18+
HTTP_MCP_PATH = "/mcp/"
19+
STREAMABLE_HTTP_MCP_PATH = "/streamable-mcp/"
20+
21+
22+
def main():
23+
"""Main entry point for the test server."""
24+
25+
http_transport = HTTPTransport[None](math_mcp)
26+
streamable_http_transport = StreamableHTTPTransport[None](math_mcp)
27+
28+
# In Starlette, the lifespan events do not run for mounted sub-applications,
29+
# and this is expected behavior. Hence adding manually.
30+
app = Starlette(lifespan=streamable_http_transport.lifespan)
31+
32+
app.mount(HTTP_MCP_PATH, http_transport.as_starlette())
33+
app.mount(STREAMABLE_HTTP_MCP_PATH, streamable_http_transport.as_starlette())
34+
35+
uvicorn.run(
36+
app,
37+
host=SERVER_HOST,
38+
port=SERVER_PORT,
39+
log_level="info",
40+
access_log=False,
41+
)
42+
43+
44+
if __name__ == "__main__":
45+
main()
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""
2+
MiniMCP math server for integration tests.
3+
"""
4+
5+
from typing import Any
6+
7+
import anyio
8+
from pydantic import Field
9+
10+
from mcp.server.minimcp import MiniMCP
11+
12+
MATH_CONSTANTS = {
13+
"pi": 3.14159265359,
14+
"𝜋": 3.14159265359,
15+
"e": 2.71828182846,
16+
"𝚎": 2.71828182846,
17+
"golden_ratio": 1.61803398875,
18+
"𝚽": 1.61803398875,
19+
"sqrt_2": 1.41421356237,
20+
"√2": 1.41421356237,
21+
}
22+
23+
24+
# Create a simple math server for testing directly in this file
25+
math_mcp = MiniMCP[Any](
26+
name="TestMathServer",
27+
version="0.1.0",
28+
instructions="A simple MCP server for mathematical operations used in integration tests.",
29+
include_stack_trace=True,
30+
)
31+
32+
33+
# -- Tools --
34+
@math_mcp.tool()
35+
def add(a: float = Field(description="The first number"), b: float = Field(description="The second number")) -> float:
36+
"""Add two numbers"""
37+
return a + b
38+
39+
40+
@math_mcp.tool()
41+
def add_with_const(
42+
a: float | str = Field(description="The first value"), b: float | str = Field(description="The second value")
43+
) -> float:
44+
"""Add two values. Values can be numbers or mathematical constants."""
45+
if isinstance(a, str):
46+
a = float(MATH_CONSTANTS[a])
47+
if isinstance(b, str):
48+
b = float(MATH_CONSTANTS[b])
49+
return a + b
50+
51+
52+
@math_mcp.tool()
53+
def subtract(
54+
a: float = Field(description="The first number"), b: float = Field(description="The second number")
55+
) -> float:
56+
"""Subtract two numbers"""
57+
return a - b
58+
59+
60+
@math_mcp.tool()
61+
def multiply(
62+
a: float = Field(description="The first number"), b: float = Field(description="The second number")
63+
) -> float:
64+
"""Multiply two numbers"""
65+
return a * b
66+
67+
68+
@math_mcp.tool()
69+
def divide(
70+
a: float = Field(description="The first number"), b: float = Field(description="The second number")
71+
) -> float:
72+
"""Divide two numbers"""
73+
if b == 0:
74+
raise ValueError("Cannot divide by zero")
75+
return a / b
76+
77+
78+
@math_mcp.tool(description="Add two numbers")
79+
async def add_with_progress(
80+
a: float = Field(description="The first float number"), b: float = Field(description="The second float number")
81+
) -> float:
82+
responder = math_mcp.context.get_responder()
83+
await responder.report_progress(0.1, message="Adding numbers")
84+
await anyio.sleep(0.1)
85+
await responder.report_progress(0.4, message="Adding numbers")
86+
await anyio.sleep(0.1)
87+
await responder.report_progress(0.7, message="Adding numbers")
88+
await anyio.sleep(0.1)
89+
return a + b
90+
91+
92+
# -- Prompts --
93+
@math_mcp.prompt()
94+
def math_help(operation: str = Field(description="The mathematical operation to get help with")) -> str:
95+
"""Get help with mathematical operations"""
96+
return f"""You are a helpful math assistant.
97+
Provide guidance on how to perform the following mathematical operation: {operation}
98+
99+
Include:
100+
1. Step-by-step instructions
101+
2. Example calculations
102+
3. Common pitfalls to avoid
103+
"""
104+
105+
106+
# -- Resources --
107+
108+
109+
@math_mcp.resource("math://constants")
110+
def get_math_constants() -> dict[str, float]:
111+
"""Mathematical constants reference"""
112+
return MATH_CONSTANTS
113+
114+
115+
@math_mcp.resource("math://constants/{constant_name}")
116+
def get_math_constant(constant_name: str) -> float:
117+
"""Get a specific mathematical constant"""
118+
if constant_name not in MATH_CONSTANTS:
119+
raise ValueError(f"Unknown constant: {constant_name}")
120+
return MATH_CONSTANTS[constant_name]
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import logging
2+
import sys
3+
4+
import anyio
5+
6+
from mcp.server.minimcp import StdioTransport
7+
8+
from .math_mcp import math_mcp
9+
10+
# Configure logging for the test server
11+
logging.basicConfig(
12+
level=logging.DEBUG,
13+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
14+
handlers=[
15+
logging.StreamHandler(sys.stderr), # Log to stderr to avoid interfering with stdio transport
16+
],
17+
)
18+
logger = logging.getLogger(__name__)
19+
20+
21+
def main():
22+
"""Main entry point for the test math server"""
23+
24+
logger.info("Test MiniMCP: Starting stdio server, listening for messages...")
25+
26+
transport = StdioTransport[None](math_mcp)
27+
anyio.run(transport.run)
28+
29+
30+
if __name__ == "__main__":
31+
main()

0 commit comments

Comments
 (0)