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
214 lines
10 KiB
Python
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"
|