Files
modelcontextprotocol--pytho…/tests/docs_src/test_asgi.py
T
wehub-resource-sync 49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

214 lines
10 KiB
Python

"""`docs/run/asgi.md`: every claim the page makes, proved against the real SDK."""
import inspect
import httpx
import pytest
from mcp_types import TextContent
from starlette.applications import Starlette
from starlette.middleware.cors import CORSMiddleware
from starlette.requests import Request
from starlette.responses import PlainTextResponse, Response
from starlette.routing import Mount, Route
from docs_src.asgi import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006
from mcp import Client
from mcp.server import MCPServer
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
async def test_streamable_http_app_is_a_starlette_app_with_one_route() -> None:
"""tutorial001: the factory returns a Starlette application with a single route at `/mcp`."""
(route,) = tutorial001.app.routes
assert isinstance(route, Route)
assert route.path == "/mcp"
async def test_the_server_behind_the_app_is_unchanged() -> None:
"""tutorial001: wrapping the server in an ASGI app changes nothing about its tools."""
async with Client(tutorial001.mcp) as client:
result = await client.call_tool("add_note", {"text": "milk"})
assert result.content == [TextContent(type="text", text="Saved: milk")]
assert result.structured_content == {"result": "Saved: milk"}
async def test_streamable_http_app_takes_runs_options_except_port() -> None:
"""The tip: every `run("streamable-http", ...)` option is here except `port`. `host` is one of them."""
parameters = set(inspect.signature(MCPServer.streamable_http_app).parameters) - {"self"}
assert parameters == {
"streamable_http_path",
"json_response",
"stateless_http",
"event_store",
"retry_interval",
"transport_security",
"host",
}
async def test_a_request_before_the_session_manager_runs_is_rejected() -> None:
"""The `!!! check`: nothing starts the session manager except its lifespan."""
transport = httpx.ASGITransport(app=tutorial001.app)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
with pytest.raises(RuntimeError, match=r"Task group is not initialized\. Make sure to use run\(\)\."):
await http.post("/mcp")
async def test_mounting_at_the_root_keeps_the_default_path() -> None:
"""tutorial002: `Mount("/")` plus the default `streamable_http_path` leaves the endpoint at `/mcp`."""
(mount,) = tutorial002.app.routes
assert isinstance(mount, Mount)
assert mount.path == ""
(inner,) = mount.routes
assert isinstance(inner, Route)
assert inner.path == "/mcp"
async def test_a_root_mount_swallows_routes_listed_after_it() -> None:
"""The mounting bullet: `Mount("/")` matches every path, so your own routes go before it in the list."""
async def about(request: Request) -> Response:
return PlainTextResponse("about")
mcp_app = MCPServer("Notes").streamable_http_app()
listed_after = Starlette(routes=[Mount("/", app=mcp_app), Route("/about", about)])
listed_before = Starlette(routes=[Route("/about", about), Mount("/", app=mcp_app)])
transport = httpx.ASGITransport(app=listed_after)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
assert (await http.get("/about")).status_code == 404
transport = httpx.ASGITransport(app=listed_before)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
assert (await http.get("/about")).status_code == 200
async def test_the_host_lifespan_enters_the_session_manager() -> None:
"""tutorial002: the host app's lifespan owns `session_manager.run()` and starts and stops cleanly."""
async with tutorial002.lifespan(tutorial002.app):
async with Client(tutorial002.mcp) as client:
result = await client.call_tool("add_note", {"text": "milk"})
assert result.structured_content == {"result": "Saved: milk"}
async def test_two_servers_get_two_mounts() -> None:
"""tutorial003: each server is mounted under its own prefix, each still ending in `/mcp`."""
notes_mount, tasks_mount = tutorial003.app.routes
assert isinstance(notes_mount, Mount)
assert isinstance(tasks_mount, Mount)
assert notes_mount.path == "/notes"
assert tasks_mount.path == "/tasks"
async def test_one_lifespan_starts_both_session_managers() -> None:
"""tutorial003: a single `AsyncExitStack` lifespan runs both managers; both servers answer."""
async with tutorial003.lifespan(tutorial003.app):
async with Client(tutorial003.notes) as client:
notes_result = await client.call_tool("add_note", {"text": "milk"})
assert notes_result.structured_content == {"result": "Saved: milk"}
async with Client(tutorial003.tasks) as client:
tasks_result = await client.call_tool("add_task", {"title": "ship"})
assert tasks_result.structured_content == {"result": "Created: ship"}
async def test_streamable_http_path_moves_the_endpoint_to_the_mount_prefix() -> None:
"""tutorial004: `streamable_http_path="/"` makes the `Mount` prefix the whole public path."""
(mount,) = tutorial004.app.routes
assert isinstance(mount, Mount)
assert mount.path == "/notes"
(inner,) = mount.routes
assert isinstance(inner, Route)
assert inner.path == "/"
async def test_cors_exposes_the_session_id_header() -> None:
"""tutorial005: the browser origin gets the three MCP methods and can read `Mcp-Session-Id`."""
(middleware,) = tutorial005.app.user_middleware
assert middleware.cls is CORSMiddleware
transport = httpx.ASGITransport(app=tutorial005.app)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
preflight = await http.options(
"/mcp",
headers={"Origin": "https://app.example.com", "Access-Control-Request-Method": "POST"},
)
assert preflight.status_code == 200
assert preflight.headers["access-control-allow-methods"] == "GET, POST, DELETE"
response = await http.get("/not-the-endpoint", headers={"Origin": "https://app.example.com"})
assert response.headers["access-control-allow-origin"] == "https://app.example.com"
assert response.headers["access-control-expose-headers"] == "Mcp-Session-Id"
async def test_custom_route_lands_next_to_the_mcp_endpoint() -> None:
"""tutorial006: `@mcp.custom_route()` adds a plain Starlette route to the returned app."""
mcp_route, health_route = tutorial006.app.routes
assert isinstance(mcp_route, Route)
assert isinstance(health_route, Route)
assert mcp_route.path == "/mcp"
assert health_route.path == "/health"
async def test_the_health_check_answers_outside_the_protocol() -> None:
"""tutorial006: `GET /health` is ordinary HTTP, with no session manager and no MCP."""
transport = httpx.ASGITransport(app=tutorial006.app)
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
response = await http.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "ok"}
INITIALIZE = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "b", "version": "1"}},
}
MCP_HEADERS = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}
async def test_the_default_app_is_localhost_only() -> None:
"""The "Localhost only" section: with no `transport_security=`, the app answers a real hostname
with the page's `421 Invalid Host header` and a foreign Origin with `403 Invalid Origin header`,
before any MCP code runs."""
bare = MCPServer("Notes")
app = bare.streamable_http_app()
transport = httpx.ASGITransport(app=app)
async with bare.session_manager.run():
async with httpx.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
wrong_host = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
async with httpx.AsyncClient(transport=transport, base_url="http://localhost:8000") as http:
wrong_origin = await http.post(
"/mcp", json=INITIALIZE, headers={**MCP_HEADERS, "Origin": "https://app.example.com"}
)
assert (wrong_host.status_code, wrong_host.text) == (421, "Invalid Host header")
assert (wrong_origin.status_code, wrong_origin.text) == (403, "Invalid Origin header")
async def test_the_documented_browser_origin_works_end_to_end() -> None:
"""tutorial005: the page's scenario for real. The public hostname, the browser origin, a
realistic preflight naming the `Mcp-*` headers, then the actual request."""
transport = httpx.ASGITransport(app=tutorial005.app)
async with tutorial005.lifespan(tutorial005.app):
async with httpx.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
preflight = await http.options(
"/mcp",
headers={
"Origin": "https://app.example.com",
"Access-Control-Request-Method": "POST",
"Access-Control-Request-Headers": "content-type, mcp-protocol-version, mcp-session-id",
},
)
assert preflight.status_code == 200
allowed = {h.strip().lower() for h in preflight.headers["access-control-allow-headers"].split(",")}
assert {"content-type", "mcp-protocol-version", "mcp-session-id"} <= allowed
response = await http.post(
"/mcp", json=INITIALIZE, headers={**MCP_HEADERS, "Origin": "https://app.example.com"}
)
assert response.status_code == 200
assert response.headers["mcp-session-id"]
assert response.headers["access-control-allow-origin"] == "https://app.example.com"
assert response.headers["access-control-expose-headers"] == "Mcp-Session-Id"