mcp-calculator/tests/test_transports.py
2026-09-15 20:38:16 +02:00

206 lines
9 KiB
Python

"""End-to-end tests with real subprocesses: worker pool, backpressure, HTTP and stdio transport."""
from __future__ import annotations
import asyncio
import json
import os
import signal
import socket
import subprocess
import sys
import time
import unittest
import urllib.error
import urllib.request
import httpx2
from mcp import Client, StdioServerParameters
from mcp.client.streamable_http import streamable_http_client
from mcp_calculator.config import load_settings
from mcp_calculator.executor import ComputationError, Executor
from tests.support import slow
TOKEN = "test-token-0123456789"
def _free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def _http(method: str, url: str, body: dict | None = None, token: str | None = None) -> tuple[int, str]:
headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
if token:
headers["Authorization"] = f"Bearer {token}"
data = json.dumps(body).encode() if body is not None else None
try:
with urllib.request.urlopen(urllib.request.Request(url, data=data, headers=headers, method=method), timeout=30) as r:
return r.status, r.read().decode()
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode()
def _start_server(port: int, **env: str) -> subprocess.Popen:
full_env = {**os.environ, "MCP_CALC_TRANSPORT": "http", "MCP_CALC_PORT": str(port), "MCP_CALC_WORKERS": "1", **env}
proc = subprocess.Popen([sys.executable, "-m", "mcp_calculator"], env=full_env, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
# Poll readiness (503 until the workers are warm). Always pause between polls: every request
# is access-logged, and a flood of log lines would fill the stderr pipe and block the server.
deadline = time.time() + 30
while time.time() < deadline:
try:
if _http("GET", f"http://127.0.0.1:{port}/readyz")[0] == 200:
return proc
except OSError:
pass
time.sleep(0.2)
proc.kill()
raise RuntimeError(f"server did not start: {proc.communicate()[1]}")
# --------------------------------------------------------------------------- worker pool
@slow
class WorkerPoolTest(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.executor = Executor(load_settings([], env={"MCP_CALC_TIMEOUT_SECONDS": "2", "MCP_CALC_WORKERS": "1"}))
self.executor.start()
self.addCleanup(self.executor.stop)
async def test_computes(self):
self.assertEqual((await self.executor.run("algebra.evaluate", expression="2^10"))["exact"], "1024")
async def test_timeout_kills_worker_and_recovers(self):
started = time.perf_counter()
with self.assertRaisesRegex(ComputationError, "timeout") as ctx:
await self.executor.run("algebra.evaluate", expression="9^9^9^9")
self.assertEqual(ctx.exception.kind, "timeout")
self.assertLess(time.perf_counter() - started, 5)
# the killed worker is replaced transparently
self.assertEqual((await self.executor.run("algebra.evaluate", expression="1+1"))["exact"], "2")
self.assertTrue(self.executor.healthy)
async def test_ready_only_after_warm_up(self):
executor = Executor(load_settings([], env={"MCP_CALC_WORKERS": "1"}))
executor.start()
self.addCleanup(executor.stop)
self.assertFalse(executor.healthy, "workers cannot have loaded SymPy/SciPy yet")
deadline = time.time() + 30
while not executor.healthy and time.time() < deadline:
await asyncio.sleep(0.05)
self.assertTrue(executor.healthy)
async def test_math_error(self):
with self.assertRaisesRegex(ComputationError, "unsupported character") as ctx:
await self.executor.run("algebra.evaluate", expression="'x'")
self.assertEqual(ctx.exception.kind, "error")
@slow
class BackpressureTest(unittest.IsolatedAsyncioTestCase):
async def test_requests_beyond_capacity_are_rejected_immediately(self):
env = {"MCP_CALC_TIMEOUT_SECONDS": "1.5", "MCP_CALC_WORKERS": "1", "MCP_CALC_MAX_QUEUE": "1"}
executor = Executor(load_settings([], env=env))
executor.start()
self.addCleanup(executor.stop)
self.assertEqual(executor.capacity, 2)
slow_tasks = [asyncio.create_task(executor.run("algebra.evaluate", expression="9^9^9^9")) for _ in range(2)]
await asyncio.sleep(0.1)
self.assertEqual(executor.inflight, 2)
started = time.perf_counter()
with self.assertRaisesRegex(ComputationError, "busy") as ctx:
await executor.run("algebra.evaluate", expression="1+1")
self.assertEqual(ctx.exception.kind, "busy")
self.assertLess(time.perf_counter() - started, 0.5, "busy rejection must not wait")
results = await asyncio.gather(*slow_tasks, return_exceptions=True)
self.assertTrue(all(isinstance(r, ComputationError) and r.kind == "timeout" for r in results), results)
self.assertEqual(executor.inflight, 0)
# capacity is free again
self.assertEqual((await executor.run("algebra.evaluate", expression="1+1"))["exact"], "2")
# --------------------------------------------------------------------------- HTTP
@slow
class HttpTransportTest(unittest.IsolatedAsyncioTestCase):
@classmethod
def setUpClass(cls) -> None:
port = _free_port()
cls.base = f"http://127.0.0.1:{port}"
cls.proc = _start_server(port, MCP_CALC_AUTH_TOKENS=f"tests:{TOKEN}", MCP_CALC_LOG_LEVEL="WARNING")
@classmethod
def tearDownClass(cls) -> None:
cls.proc.terminate()
cls.proc.communicate(timeout=30)
def test_health_endpoints_are_public(self):
self.assertEqual(json.loads(_http("GET", f"{self.base}/healthz")[1])["status"], "ok")
self.assertEqual(json.loads(_http("GET", f"{self.base}/readyz")[1])["status"], "ready")
def test_mcp_endpoint_requires_token(self):
self.assertEqual(_http("POST", f"{self.base}/mcp", {"jsonrpc": "2.0", "id": 1, "method": "tools/list"})[0], 401)
self.assertEqual(_http("POST", f"{self.base}/mcp", {}, token="wrong")[0], 401)
async def test_mcp_over_http(self):
async with httpx2.AsyncClient(headers={"Authorization": f"Bearer {TOKEN}"}, timeout=30) as http:
async with Client(streamable_http_client(f"{self.base}/mcp", http_client=http)) as client:
self.assertEqual(len((await client.list_tools()).tools), 13)
result = await client.call_tool("evaluate", {"expression": "sqrt(2)", "precision": 30})
self.assertEqual(result.structured_content["numeric"], "1.41421356237309504880168872421")
def test_metrics_endpoint(self):
body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "convert_units", "arguments": {"quantity": "1 m", "to_unit": "cm"}}}
self.assertEqual(_http("POST", f"{self.base}/mcp", body, token=TOKEN)[0], 200)
status, text = _http("GET", f"{self.base}/metrics") # public, like the probes
self.assertEqual(status, 200)
self.assertIn('mcp_calc_tool_calls_total{client="tests",status="ok",tool="convert_units"}', text)
for name in ("mcp_calc_tool_duration_seconds_bucket", "mcp_calc_inflight_computations", "mcp_calc_capacity 21.0", "mcp_calc_build_info"):
self.assertIn(name, text)
@slow
class HttpLifecycleTest(unittest.TestCase):
def test_metrics_can_be_disabled(self):
port = _free_port()
proc = _start_server(port, MCP_CALC_METRICS="false")
try:
self.assertEqual(_http("GET", f"http://127.0.0.1:{port}/metrics")[0], 404)
finally:
proc.terminate()
proc.communicate(timeout=30)
def test_sigterm_stops_worker_pool_cleanly(self):
port = _free_port()
proc = _start_server(port, MCP_CALC_LOG_LEVEL="INFO")
body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "evaluate", "arguments": {"expression": "1+1"}}}
self.assertEqual(_http("POST", f"http://127.0.0.1:{port}/mcp", body)[0], 200)
proc.send_signal(signal.SIGTERM)
# communicate() also waits for the multiprocessing resource tracker, which shares stderr
# and reports leaked semaphores only when it exits.
_, stderr = proc.communicate(timeout=30)
self.assertIn("worker pool stopped", stderr)
self.assertNotIn("leaked", stderr)
# --------------------------------------------------------------------------- stdio
@slow
class StdioTransportTest(unittest.IsolatedAsyncioTestCase):
async def test_mcp_over_stdio(self):
params = StdioServerParameters(
command=sys.executable,
args=["-m", "mcp_calculator"],
env={**os.environ, "MCP_CALC_WORKERS": "1", "MCP_CALC_LOG_LEVEL": "WARNING"},
)
async with Client(params) as client:
result = await client.call_tool("solve", {"equations": ["x + y = 3", "x - y = 1"]})
self.assertEqual(result.structured_content["solutions"], [{"x": "2", "y": "1"}])