chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
"""`docs/advanced/apps.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from mcp_types import TextContent, TextResourceContents
|
||||
|
||||
from docs_src.apps import tutorial001, tutorial002, tutorial003
|
||||
from mcp import Client
|
||||
from mcp.client import advertise
|
||||
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID
|
||||
|
||||
# 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_the_tool_carries_the_ui_resource_reference() -> None:
|
||||
"""tutorial001: `@apps.tool(resource_uri=...)` stamps `_meta.ui.resourceUri` on the tool."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
listed = await client.list_tools()
|
||||
assert listed.tools[0].meta == {"ui": {"resourceUri": "ui://clock/app.html"}}
|
||||
|
||||
|
||||
async def test_the_ui_resource_is_served_as_the_app_mime_type() -> None:
|
||||
"""tutorial001: `add_html_resource` serves the HTML at `text/html;profile=mcp-app`,
|
||||
the MIME type that tells a host "this is an app, render it"."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.read_resource("ui://clock/app.html")
|
||||
contents = result.contents[0]
|
||||
assert isinstance(contents, TextResourceContents)
|
||||
assert contents.mime_type == APP_MIME_TYPE
|
||||
assert contents.text == tutorial001.CLOCK_HTML
|
||||
|
||||
|
||||
async def test_one_tool_two_answers() -> None:
|
||||
"""tutorial001: the canonical degradation pattern: raw data for a client that
|
||||
negotiated Apps, a human sentence for one that did not."""
|
||||
async with Client(
|
||||
tutorial001.mcp, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]
|
||||
) as ui_client:
|
||||
rich = await ui_client.call_tool("get_time", {})
|
||||
async with Client(tutorial001.mcp) as text_client:
|
||||
plain = await text_client.call_tool("get_time", {})
|
||||
assert rich.content == [TextContent(type="text", text="2026-06-26T12:00:00Z")]
|
||||
assert plain.content == [TextContent(type="text", text="The time is 2026-06-26T12:00:00Z.")]
|
||||
|
||||
|
||||
async def test_the_clock_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""tutorial001: `main()` declares Apps support with the required `mimeTypes` and
|
||||
receives the rich answer the page promises."""
|
||||
await tutorial001.main()
|
||||
assert "2026-06-26T12:00:00Z" in capsys.readouterr().out
|
||||
|
||||
|
||||
async def test_capability_advertised_under_server_extensions() -> None:
|
||||
"""tutorial001: passing `extensions=[apps]` advertises `io.modelcontextprotocol/ui`."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
assert client.server_capabilities.extensions == {EXTENSION_ID: {}}
|
||||
|
||||
|
||||
async def test_csp_permissions_domain_and_border_ride_the_resource_meta() -> None:
|
||||
"""tutorial002: the iframe lockdown fields land under `_meta.ui` on both the list
|
||||
entry and the read content item, with the spec's camelCase wire keys."""
|
||||
expected: dict[str, Any] = {
|
||||
"ui": {
|
||||
"csp": {"connectDomains": ["https://api.example.com"]},
|
||||
"permissions": {"clipboardWrite": {}},
|
||||
"domain": "dashboard.example.com",
|
||||
"prefersBorder": True,
|
||||
}
|
||||
}
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
listed = await client.list_resources()
|
||||
result = await client.read_resource("ui://dashboard/app.html")
|
||||
assert listed.resources[0].meta == expected
|
||||
contents = result.contents[0]
|
||||
assert isinstance(contents, TextResourceContents)
|
||||
assert contents.meta == expected
|
||||
|
||||
|
||||
async def test_an_app_only_tool_is_still_listed_and_callable() -> None:
|
||||
"""tutorial002: `visibility=["app"]` is metadata for the host; the server lists the
|
||||
tool like any other and serves its calls. Filtering is the host's job."""
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
listed = await client.list_tools()
|
||||
result = await client.call_tool("refresh_dashboard", {})
|
||||
assert listed.tools[0].meta == {"ui": {"resourceUri": "ui://dashboard/app.html", "visibility": ["app"]}}
|
||||
assert result.content == [TextContent(type="text", text="refreshed")]
|
||||
|
||||
|
||||
async def test_a_file_resource_is_served_with_the_app_mime_type_filled_in() -> None:
|
||||
"""tutorial003: `add_resource` accepts a pre-built `FileResource` and fills in the
|
||||
`text/html;profile=mcp-app` MIME type the resource didn't set explicitly."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
listed = await client.list_tools()
|
||||
called = await client.call_tool("refresh_report", {})
|
||||
result = await client.read_resource("ui://report/app.html")
|
||||
assert listed.tools[0].meta == {"ui": {"resourceUri": "ui://report/app.html"}}
|
||||
assert called.content == [TextContent(type="text", text="report refreshed")]
|
||||
contents = result.contents[0]
|
||||
assert isinstance(contents, TextResourceContents)
|
||||
assert contents.mime_type == APP_MIME_TYPE
|
||||
assert contents.text == tutorial003.REPORT_HTML.read_text()
|
||||
@@ -0,0 +1,213 @@
|
||||
"""`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"
|
||||
@@ -0,0 +1,98 @@
|
||||
"""`docs/run/authorization.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import TextContent
|
||||
from starlette.routing import Route
|
||||
|
||||
from docs_src.authorization import tutorial001, tutorial002
|
||||
from mcp import Client
|
||||
from mcp.client.streamable_http import streamable_http_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_the_in_memory_client_never_authenticates() -> None:
|
||||
"""tutorial001: `Client(mcp)` connects to the server object directly, so no token is ever checked."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.call_tool("list_notes", {})
|
||||
assert not result.is_error
|
||||
assert result.structured_content == {"result": ["Buy milk", "Ship the release"]}
|
||||
|
||||
|
||||
async def test_token_verifier_and_auth_settings_must_travel_together() -> None:
|
||||
"""tutorial001: passing `token_verifier=` without `auth=` is refused at construction time."""
|
||||
with pytest.raises(ValueError, match="Cannot specify auth_server_provider or token_verifier without auth settings"):
|
||||
MCPServer("Notes", token_verifier=tutorial001.StaticTokenVerifier())
|
||||
|
||||
|
||||
async def test_the_app_grows_a_protected_resource_metadata_route() -> None:
|
||||
"""tutorial001: the HTTP app has the `/mcp` endpoint plus the RFC 9728 well-known route."""
|
||||
mcp_route, metadata_route = tutorial001.mcp.streamable_http_app().routes
|
||||
assert isinstance(mcp_route, Route)
|
||||
assert isinstance(metadata_route, Route)
|
||||
assert mcp_route.path == "/mcp"
|
||||
assert metadata_route.path == "/.well-known/oauth-protected-resource/mcp"
|
||||
|
||||
|
||||
async def test_the_metadata_document_is_built_from_auth_settings() -> None:
|
||||
"""tutorial001: `GET` on the well-known route returns the Protected Resource Metadata the page shows."""
|
||||
transport = httpx.ASGITransport(app=tutorial001.mcp.streamable_http_app())
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client:
|
||||
response = await http_client.get("/.well-known/oauth-protected-resource/mcp")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == snapshot(
|
||||
{
|
||||
"resource": "http://127.0.0.1:8000/mcp",
|
||||
"authorization_servers": ["https://auth.example.com/"],
|
||||
"scopes_supported": ["notes:read"],
|
||||
"bearer_methods_supported": ["header"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def test_a_request_without_a_token_never_reaches_the_protocol() -> None:
|
||||
"""The `!!! check`: no `Authorization` header means a 401 that points at the metadata document."""
|
||||
transport = httpx.ASGITransport(app=tutorial001.mcp.streamable_http_app())
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client:
|
||||
response = await http_client.post("/mcp", json={})
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"error": "invalid_token", "error_description": "Authentication required"}
|
||||
assert response.headers["www-authenticate"] == (
|
||||
'Bearer error="invalid_token", error_description="Authentication required", '
|
||||
'resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"'
|
||||
)
|
||||
|
||||
|
||||
async def test_a_token_the_verifier_rejects_gets_the_same_401() -> None:
|
||||
"""tutorial001: `verify_token` returning `None` and a missing header are indistinguishable to the caller."""
|
||||
transport = httpx.ASGITransport(app=tutorial001.mcp.streamable_http_app())
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client:
|
||||
response = await http_client.post("/mcp", json={}, headers={"Authorization": "Bearer not-a-real-token"})
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"error": "invalid_token", "error_description": "Authentication required"}
|
||||
|
||||
|
||||
async def test_get_access_token_is_none_outside_an_authenticated_request() -> None:
|
||||
"""tutorial002: in-memory there is no HTTP layer, so `get_access_token()` returns `None`."""
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
result = await client.call_tool("whoami", {})
|
||||
assert result.structured_content == {"result": "anonymous"}
|
||||
|
||||
|
||||
async def test_get_access_token_is_the_callers_access_token() -> None:
|
||||
"""tutorial002: over Streamable HTTP a valid bearer token reaches the tool as an `AccessToken`."""
|
||||
url = "http://127.0.0.1:8000/mcp"
|
||||
transport = httpx.ASGITransport(app=tutorial002.mcp.streamable_http_app())
|
||||
headers = {"Authorization": "Bearer alice-token"}
|
||||
async with tutorial002.mcp.session_manager.run():
|
||||
async with (
|
||||
httpx.AsyncClient(transport=transport, base_url=url, headers=headers) as http_client,
|
||||
Client(streamable_http_client(url, http_client=http_client)) as client,
|
||||
):
|
||||
result = await client.call_tool("whoami", {})
|
||||
assert result.content == [TextContent(type="text", text="alice (scopes: notes:read)")]
|
||||
assert result.structured_content == {"result": "alice (scopes: notes:read)"}
|
||||
@@ -0,0 +1,209 @@
|
||||
"""`docs/client/caching.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, cast
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import INTERNAL_ERROR, ListToolsResult, PaginatedRequestParams, Tool
|
||||
|
||||
from docs_src.caching import tutorial001, tutorial002, tutorial003
|
||||
from mcp import Client, MCPError
|
||||
from mcp.client import CacheConfig
|
||||
from mcp.client.caching import InMemoryResponseCacheStore
|
||||
from mcp.server import CacheHint, MCPServer, Server, ServerRequestContext
|
||||
from mcp.server.caching import CacheableMethod
|
||||
|
||||
# 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_a_mapped_method_carries_the_configured_hint() -> None:
|
||||
"""tutorial001: `tools/list` is in the map, so clients see one minute, public."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
tools = await client.list_tools()
|
||||
assert tools.ttl_ms == 60_000
|
||||
assert tools.cache_scope == "public"
|
||||
|
||||
|
||||
async def test_a_hint_without_a_scope_stays_private() -> None:
|
||||
"""tutorial001: `resources/read` set only `ttl_ms`; scope keeps the conservative default."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.read_resource("config://units")
|
||||
assert result.ttl_ms == 5_000
|
||||
assert result.cache_scope == "private"
|
||||
|
||||
|
||||
async def test_an_unmapped_method_stays_immediately_stale_and_private() -> None:
|
||||
"""tutorial001: `resources/list` is not in the map - the defaults hold."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
resources = await client.list_resources()
|
||||
assert resources.ttl_ms == 0
|
||||
assert resources.cache_scope == "private"
|
||||
|
||||
|
||||
async def test_a_non_cacheable_method_is_rejected_at_construction() -> None:
|
||||
"""The page's claim: anything but the six cacheable methods raises at construction."""
|
||||
with pytest.raises(ValueError) as exc:
|
||||
MCPServer("Weather", cache_hints=cast(Any, {"tools/call": CacheHint(ttl_ms=1_000)}))
|
||||
assert str(exc.value) == snapshot(
|
||||
"cache_hints keys must be cacheable methods (see CacheableMethod); got: 'tools/call'"
|
||||
)
|
||||
|
||||
|
||||
async def test_the_handler_value_wins_over_the_map_per_field() -> None:
|
||||
"""tutorial002: the handler's `ttl_ms=1_000` beats the map's `60_000`; the scope
|
||||
the handler left unset takes the map's `"public"`."""
|
||||
async with Client(tutorial002.server) as client:
|
||||
tools = await client.list_tools()
|
||||
assert tools.ttl_ms == 1_000
|
||||
assert tools.cache_scope == "public"
|
||||
|
||||
|
||||
async def test_the_client_program_on_the_page_makes_three_fetches_for_four_calls(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""tutorial003: a cache hit, an expiry, and `cache_mode="refresh"` make four calls cost three fetches."""
|
||||
await tutorial003.main()
|
||||
assert capsys.readouterr().out == "4 calls, 3 fetches\n"
|
||||
|
||||
|
||||
def _counting_tools_server(*, ttl_ms: int | None = 60_000) -> tuple[Server[Any], list[str | None]]:
|
||||
"""Each tools/list fetch returns a distinct tool name, so a cache hit is
|
||||
payload-distinguishable from a refetch; `ttl_ms=None` sends no hints."""
|
||||
fetches: list[str | None] = []
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult:
|
||||
fetches.append(params.cursor if params is not None else None)
|
||||
return ListToolsResult(tools=[Tool(name=f"t{len(fetches) - 1}", input_schema={"type": "object"})])
|
||||
|
||||
hints: Mapping[CacheableMethod, CacheHint] | None = None
|
||||
if ttl_ms is not None:
|
||||
hints = {"tools/list": CacheHint(ttl_ms=ttl_ms)}
|
||||
return Server("counting", on_list_tools=list_tools, cache_hints=hints), fetches
|
||||
|
||||
|
||||
async def test_caching_is_on_by_default_the_second_call_makes_no_fetch() -> None:
|
||||
server, fetches = _counting_tools_server()
|
||||
async with Client(server) as client:
|
||||
first = await client.list_tools()
|
||||
second = await client.list_tools()
|
||||
assert fetches == [None]
|
||||
assert second == first
|
||||
|
||||
|
||||
async def test_a_hintless_result_is_not_cached_by_default() -> None:
|
||||
"""`default_ttl_ms` defaults to 0, so a hintless server sees its usual call-for-call traffic."""
|
||||
server, fetches = _counting_tools_server(ttl_ms=None)
|
||||
async with Client(server) as client:
|
||||
await client.list_tools()
|
||||
await client.list_tools()
|
||||
assert fetches == [None, None]
|
||||
|
||||
|
||||
async def test_cache_false_makes_every_call_a_round_trip() -> None:
|
||||
server, fetches = _counting_tools_server()
|
||||
async with Client(server, cache=False) as client:
|
||||
await client.list_tools()
|
||||
await client.list_tools()
|
||||
assert fetches == [None, None]
|
||||
|
||||
|
||||
async def test_refresh_refetches_and_replaces_the_cached_entry() -> None:
|
||||
server, fetches = _counting_tools_server()
|
||||
async with Client(server) as client:
|
||||
await client.list_tools()
|
||||
refreshed = await client.list_tools(cache_mode="refresh")
|
||||
served = await client.list_tools()
|
||||
assert fetches == [None, None]
|
||||
assert [tool.name for tool in refreshed.tools] == ["t1"]
|
||||
assert served == refreshed
|
||||
|
||||
|
||||
async def test_bypass_fetches_without_reading_or_writing_the_cache() -> None:
|
||||
server, fetches = _counting_tools_server()
|
||||
async with Client(server) as client:
|
||||
first = await client.list_tools()
|
||||
bypassed = await client.list_tools(cache_mode="bypass")
|
||||
served = await client.list_tools()
|
||||
assert fetches == [None, None]
|
||||
assert [tool.name for tool in bypassed.tools] == ["t1"]
|
||||
assert served == first
|
||||
|
||||
|
||||
async def test_an_expired_entry_is_not_revived_when_the_refetch_fails() -> None:
|
||||
"""SDK ruling: no stale-if-error - the refetch failure propagates."""
|
||||
now = 1_000_000.0
|
||||
fetches: list[None] = []
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult:
|
||||
fetches.append(None)
|
||||
if len(fetches) > 1:
|
||||
raise MCPError(code=INTERNAL_ERROR, message="backend down")
|
||||
return ListToolsResult(tools=[Tool(name="t0", input_schema={"type": "object"})])
|
||||
|
||||
server = Server("flaky", on_list_tools=list_tools, cache_hints={"tools/list": CacheHint(ttl_ms=60_000)})
|
||||
async with Client(server, cache=CacheConfig(clock=lambda: now)) as client:
|
||||
await client.list_tools()
|
||||
now += 60.0 # past the 60s TTL
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await client.list_tools()
|
||||
assert exc.value.code == INTERNAL_ERROR
|
||||
assert len(fetches) == 2
|
||||
|
||||
|
||||
async def test_two_concurrent_identical_calls_are_two_fetches() -> None:
|
||||
"""SDK ruling: no coalescing. The handler barrier releases only once both
|
||||
calls are inside it, so the test passes only if the fetches were concurrent."""
|
||||
both_fetching = anyio.Event()
|
||||
fetches: list[None] = []
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult:
|
||||
fetches.append(None)
|
||||
if len(fetches) == 2:
|
||||
both_fetching.set()
|
||||
with anyio.fail_after(5):
|
||||
await both_fetching.wait()
|
||||
return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})])
|
||||
|
||||
server = Server("concurrent", on_list_tools=list_tools, cache_hints={"tools/list": CacheHint(ttl_ms=60_000)})
|
||||
async with Client(server) as client:
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(client.list_tools)
|
||||
tg.start_soon(client.list_tools)
|
||||
assert len(fetches) == 2
|
||||
|
||||
|
||||
async def test_a_session_tier_call_always_makes_the_round_trip() -> None:
|
||||
"""The cache lives on the `Client` verbs; `client.session` sits below it."""
|
||||
server, fetches = _counting_tools_server()
|
||||
async with Client(server) as client:
|
||||
await client.list_tools()
|
||||
await client.session.list_tools()
|
||||
assert fetches == [None, None]
|
||||
|
||||
|
||||
async def test_a_custom_store_requires_a_partition() -> None:
|
||||
with pytest.raises(ValueError) as exc:
|
||||
CacheConfig(store=InMemoryResponseCacheStore())
|
||||
assert str(exc.value) == snapshot("a custom store requires an explicit partition")
|
||||
|
||||
|
||||
async def test_a_custom_store_with_an_in_process_server_requires_target_id() -> None:
|
||||
server, _ = _counting_tools_server()
|
||||
with pytest.raises(ValueError) as exc:
|
||||
Client(server, cache=CacheConfig(store=InMemoryResponseCacheStore(), partition="user-1"))
|
||||
assert str(exc.value) == snapshot(
|
||||
"a custom cache store requires CacheConfig.target_id when the server is not a URL: in-process servers "
|
||||
"and Transport instances get a random per-client identity, so their entries in a shared store could "
|
||||
"never be served to another client"
|
||||
)
|
||||
|
||||
|
||||
async def test_the_wire_presence_check_the_page_recommends_works() -> None:
|
||||
"""The page's claim: `"ttl_ms" in result.model_fields_set` distinguishes a
|
||||
server that sent the field from one that said nothing (model defaults)."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
tools = await client.list_tools()
|
||||
assert "ttl_ms" in tools.model_fields_set
|
||||
@@ -0,0 +1,185 @@
|
||||
"""`docs/client/index.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import Prompt, PromptArgument, PromptReference, TextContent, TextResourceContents, Tool
|
||||
|
||||
from docs_src.client import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006, tutorial007
|
||||
from mcp import Client, MCPDeprecationWarning, MCPError
|
||||
from mcp.shared.metadata_utils import get_display_name
|
||||
|
||||
# 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_every_client_program_on_the_page_runs(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""Each `main()` is the literal client program shown on the page; all seven run clean in-memory."""
|
||||
await tutorial001.main()
|
||||
await tutorial002.main()
|
||||
await tutorial003.main()
|
||||
await tutorial004.main()
|
||||
await tutorial005.main()
|
||||
await tutorial006.main()
|
||||
await tutorial007.main()
|
||||
assert "Bookshop" in capsys.readouterr().out
|
||||
|
||||
|
||||
async def test_connected_properties_are_populated_inside_the_block() -> None:
|
||||
"""tutorial001: server_info, server_capabilities, protocol_version and instructions are just there."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
assert client.server_info.name == "Bookshop"
|
||||
assert client.protocol_version == "2026-07-28"
|
||||
assert client.instructions == "Search the catalog before recommending a book."
|
||||
assert client.server_capabilities.tools is not None
|
||||
assert client.server_capabilities.logging is None
|
||||
|
||||
|
||||
async def test_a_client_is_not_reusable_after_the_block_ends() -> None:
|
||||
"""tutorial001: `async with` is the whole lifecycle. Construct a new Client per connection."""
|
||||
client = Client(tutorial001.mcp)
|
||||
async with client:
|
||||
assert client.server_info.name == "Bookshop"
|
||||
with pytest.raises(RuntimeError, match="cannot reenter"):
|
||||
await client.__aenter__()
|
||||
|
||||
|
||||
async def test_list_tools_returns_the_full_definition() -> None:
|
||||
"""tutorial002: each listed tool carries its name, title, description and the derived input schema."""
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.name == "search_books"
|
||||
assert tool.title == "Search the catalog"
|
||||
assert tool.description == "Search the catalog by title or author."
|
||||
assert tool.input_schema == snapshot(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"title": "Query", "type": "string"},
|
||||
"limit": {"default": 10, "title": "Limit", "type": "integer"},
|
||||
},
|
||||
"required": ["query"],
|
||||
"title": "search_booksArguments",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_get_display_name_prefers_the_title() -> None:
|
||||
"""The `!!! tip`: get_display_name returns the title when there is one and the name when there isn't."""
|
||||
titled = Tool(name="search_books", title="Search the catalog", input_schema={"type": "object"})
|
||||
untitled = Tool(name="search_books", input_schema={"type": "object"})
|
||||
assert get_display_name(titled) == "Search the catalog"
|
||||
assert get_display_name(untitled) == "search_books"
|
||||
|
||||
|
||||
async def test_call_tool_result_has_three_things_to_read() -> None:
|
||||
"""tutorial003: content for the model, structured_content for code, is_error for both."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
result = await client.call_tool("lookup_book", {"title": "Dune"})
|
||||
assert not result.is_error
|
||||
(block,) = result.content
|
||||
assert isinstance(block, TextContent)
|
||||
assert block.text == '{\n "title": "Dune",\n "author": "Frank Herbert",\n "year": 1965\n}'
|
||||
assert result.structured_content == {"title": "Dune", "author": "Frank Herbert", "year": 1965}
|
||||
|
||||
|
||||
async def test_a_raising_tool_is_a_result_not_an_exception() -> None:
|
||||
"""tutorial003 `!!! check`: the exception's message comes back in content with is_error=True."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
result = await client.call_tool("lookup_book", {"title": "Solaris"})
|
||||
assert result.is_error
|
||||
(block,) = result.content
|
||||
assert isinstance(block, TextContent)
|
||||
assert block.text == "Error executing tool lookup_book: No book titled 'Solaris' in the catalog."
|
||||
assert result.structured_content is None
|
||||
|
||||
|
||||
async def test_an_unknown_tool_name_is_a_result_not_an_exception() -> None:
|
||||
"""The `!!! warning`: a tool the server doesn't have comes back as is_error=True, not as MCPError."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
result = await client.call_tool("does_not_exist", {})
|
||||
assert result.is_error
|
||||
(block,) = result.content
|
||||
assert isinstance(block, TextContent)
|
||||
assert block.text == "Unknown tool: does_not_exist"
|
||||
assert result.structured_content is None
|
||||
|
||||
|
||||
async def test_resources_and_templates_are_two_separate_lists() -> None:
|
||||
"""tutorial004: concrete resources and parameterised templates come back from different verbs."""
|
||||
async with Client(tutorial004.mcp) as client:
|
||||
(resource,) = (await client.list_resources()).resources
|
||||
assert resource.uri == "catalog://genres"
|
||||
(template,) = (await client.list_resource_templates()).resource_templates
|
||||
assert template.uri_template == "catalog://genres/{genre}"
|
||||
|
||||
|
||||
async def test_read_resource_fills_in_a_template() -> None:
|
||||
"""tutorial004: read_resource takes a plain str URI; narrow the contents with isinstance."""
|
||||
async with Client(tutorial004.mcp) as client:
|
||||
(contents,) = (await client.read_resource("catalog://genres/poetry")).contents
|
||||
assert isinstance(contents, TextResourceContents)
|
||||
assert contents.text == "3 books filed under poetry."
|
||||
|
||||
|
||||
async def test_resource_subscriptions_are_listen_based_on_the_modern_wire() -> None:
|
||||
"""The Resources section: at 2026-07-28 `resources.subscribe` is True (served via
|
||||
subscriptions/listen) while the legacy subscribe_resource verb answers -32601."""
|
||||
async with Client(tutorial004.mcp) as client:
|
||||
assert client.server_capabilities.resources is not None
|
||||
assert client.server_capabilities.resources.subscribe is True
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
# The verb is itself deprecated; the modern wire also rejects it.
|
||||
with pytest.warns(MCPDeprecationWarning, match="use Client.listen"):
|
||||
await client.subscribe_resource("catalog://genres") # pyright: ignore[reportDeprecated]
|
||||
assert exc_info.value.error.code == -32601
|
||||
assert exc_info.value.error.message == "Method not found"
|
||||
|
||||
|
||||
async def test_list_prompts_describes_the_arguments() -> None:
|
||||
"""tutorial005: a listed prompt carries its name, title and the arguments it needs."""
|
||||
async with Client(tutorial005.mcp) as client:
|
||||
(prompt,) = (await client.list_prompts()).prompts
|
||||
assert prompt == snapshot(
|
||||
Prompt(
|
||||
name="recommend",
|
||||
title="Recommend a book",
|
||||
description="Ask for a recommendation in a genre.",
|
||||
arguments=[PromptArgument(name="genre", required=True)],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_get_prompt_renders_the_messages() -> None:
|
||||
"""tutorial005: get_prompt returns the rendered messages a host hands to the model."""
|
||||
async with Client(tutorial005.mcp) as client:
|
||||
result = await client.get_prompt("recommend", {"genre": "poetry"})
|
||||
(message,) = result.messages
|
||||
assert message.role == "user"
|
||||
assert message.content == TextContent(
|
||||
type="text", text="Recommend one poetry book from the catalog and say why."
|
||||
)
|
||||
|
||||
|
||||
async def test_complete_suggests_values_for_an_argument() -> None:
|
||||
"""tutorial006: complete takes a ref and a name/value pair and returns the matching values."""
|
||||
async with Client(tutorial006.mcp) as client:
|
||||
result = await client.complete(
|
||||
ref=PromptReference(type="ref/prompt", name="recommend"),
|
||||
argument={"name": "genre", "value": "p"},
|
||||
)
|
||||
assert result.completion.values == ["poetry"]
|
||||
|
||||
|
||||
async def test_a_single_page_server_ends_the_pagination_loop_immediately() -> None:
|
||||
"""tutorial007: every list_* takes cursor=; next_cursor is None when there is nothing left."""
|
||||
async with Client(tutorial007.mcp) as client:
|
||||
page = await client.list_tools(cursor=None)
|
||||
assert page.next_cursor is None
|
||||
assert [tool.name for tool in page.tools] == ["search_books", "reserve_book"]
|
||||
|
||||
|
||||
async def test_raise_exceptions_is_a_constructor_flag() -> None:
|
||||
"""The `## In tests` section: `raise_exceptions=True` is accepted by the in-memory Client."""
|
||||
async with Client(tutorial001.mcp, raise_exceptions=True) as client:
|
||||
result = await client.call_tool("search_books", {"query": "dune"})
|
||||
assert result.structured_content == {"result": "Found 3 books matching 'dune'."}
|
||||
@@ -0,0 +1,129 @@
|
||||
"""`docs/client/callbacks.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import (
|
||||
INVALID_REQUEST,
|
||||
CreateMessageRequestParams,
|
||||
CreateMessageResult,
|
||||
ElicitRequestFormParams,
|
||||
ElicitRequestParams,
|
||||
ElicitResult,
|
||||
ErrorData,
|
||||
ListRootsResult,
|
||||
Root,
|
||||
SamplingMessage,
|
||||
TextContent,
|
||||
)
|
||||
from pydantic import FileUrl
|
||||
|
||||
from docs_src.client_callbacks import tutorial001, tutorial002, tutorial003, tutorial004
|
||||
from mcp import Client, MCPError
|
||||
from mcp.client import ClientRequestContext
|
||||
|
||||
# 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_the_callback_answers_the_servers_question() -> None:
|
||||
"""tutorial001+002: the server's `ctx.elicit` is resolved by the client's `elicitation_callback`."""
|
||||
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=tutorial002.handle_elicitation) as client:
|
||||
result = await client.call_tool("issue_card")
|
||||
assert not result.is_error
|
||||
assert result.content == [TextContent(type="text", text="Card issued to Ada Lovelace.")]
|
||||
|
||||
|
||||
async def test_the_callback_receives_the_servers_question_as_form_params() -> None:
|
||||
"""tutorial002: the callback gets `ElicitRequestFormParams` (the message and the requested schema)."""
|
||||
received: list[ElicitRequestParams] = []
|
||||
|
||||
async def recording(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
received.append(params)
|
||||
return await tutorial002.handle_elicitation(context, params)
|
||||
|
||||
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=recording) as client:
|
||||
await client.call_tool("issue_card")
|
||||
(params,) = received
|
||||
assert isinstance(params, ElicitRequestFormParams)
|
||||
assert params.mode == "form"
|
||||
assert params.message == "What name should go on the card?"
|
||||
assert params.requested_schema == snapshot(
|
||||
{
|
||||
"properties": {"name": {"title": "Name", "type": "string"}},
|
||||
"required": ["name"],
|
||||
"title": "CardHolder",
|
||||
"type": "object",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def test_returning_error_data_refuses_the_request_and_fails_the_call() -> None:
|
||||
"""The callback's only other return type: `ErrorData` refuses the request and fails the whole call."""
|
||||
|
||||
async def refuse(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult | ErrorData:
|
||||
return ErrorData(code=INVALID_REQUEST, message="No forms here.")
|
||||
|
||||
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=refuse) as client:
|
||||
with pytest.raises(MCPError, match="No forms here") as exc_info:
|
||||
await client.call_tool("issue_card")
|
||||
assert exc_info.value.error.code == INVALID_REQUEST
|
||||
|
||||
|
||||
async def test_without_the_callback_the_servers_request_is_refused() -> None:
|
||||
"""The `!!! check`: no `elicitation_callback` means the SDK answers with an error and the call fails."""
|
||||
async with Client(tutorial001.mcp, mode="legacy") as client:
|
||||
with pytest.raises(MCPError, match="Elicitation not supported") as exc_info:
|
||||
await client.call_tool("issue_card")
|
||||
assert exc_info.value.error.code == INVALID_REQUEST
|
||||
|
||||
|
||||
async def test_registering_the_callback_declares_the_capability() -> None:
|
||||
"""tutorial003: `elicitation_callback` alone advertises exactly the `elicitation` capability."""
|
||||
async with Client(tutorial003.mcp, mode="legacy", elicitation_callback=tutorial002.handle_elicitation) as client:
|
||||
result = await client.call_tool("client_features")
|
||||
assert result.structured_content == {"result": ["elicitation"]}
|
||||
|
||||
|
||||
async def test_no_callbacks_means_no_capabilities() -> None:
|
||||
"""tutorial003: a client constructed without callbacks declares nothing."""
|
||||
async with Client(tutorial003.mcp, mode="legacy") as client:
|
||||
result = await client.call_tool("client_features")
|
||||
assert result.structured_content == {"result": []}
|
||||
|
||||
|
||||
async def test_each_callback_declares_its_own_capability() -> None:
|
||||
"""The page's table: the elicitation, sampling, and roots callbacks each declare their capability."""
|
||||
async with Client(
|
||||
tutorial003.mcp,
|
||||
mode="legacy",
|
||||
elicitation_callback=tutorial002.handle_elicitation,
|
||||
sampling_callback=tutorial004.handle_sampling,
|
||||
list_roots_callback=tutorial004.handle_list_roots,
|
||||
) as client:
|
||||
result = await client.call_tool("client_features")
|
||||
assert result.structured_content == {"result": ["elicitation", "sampling", "roots"]}
|
||||
|
||||
|
||||
async def test_the_modern_in_memory_path_has_no_back_channel() -> None:
|
||||
"""The `!!! info`: under the default mode the negotiated path has no back-channel for `elicitation/create`."""
|
||||
async with Client(tutorial001.mcp, elicitation_callback=tutorial002.handle_elicitation) as client:
|
||||
with pytest.raises(MCPError, match="no back-channel"):
|
||||
await client.call_tool("issue_card")
|
||||
|
||||
|
||||
async def test_the_deprecated_callbacks_return_what_the_page_says() -> None:
|
||||
"""tutorial004: the sampling and roots callbacks produce the result types the page names."""
|
||||
async with Client(tutorial003.mcp, mode="legacy") as client:
|
||||
context = ClientRequestContext(session=client.session, request_id=1)
|
||||
params = CreateMessageRequestParams(
|
||||
messages=[SamplingMessage(role="user", content=TextContent(type="text", text="6 * 7?"))],
|
||||
max_tokens=16,
|
||||
)
|
||||
assert await tutorial004.handle_sampling(context, params) == snapshot(
|
||||
CreateMessageResult(
|
||||
role="assistant", content=TextContent(type="text", text="The answer is 42."), model="my-llm"
|
||||
)
|
||||
)
|
||||
assert await tutorial004.handle_list_roots(context) == snapshot(
|
||||
ListRootsResult(roots=[Root(uri=FileUrl("file:///home/ada/notebooks"), name="notebooks")])
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
"""`docs/client/transports.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
from docs_src.client_transports import tutorial001, tutorial004
|
||||
from mcp import Client
|
||||
from mcp.client.stdio import get_default_environment, stdio_client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
||||
# 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_the_in_memory_program_on_the_page_runs(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""tutorial001's `main()` is the literal client program on the page; it runs clean end to end."""
|
||||
await tutorial001.main()
|
||||
assert "Found 3 books matching 'dune'." in capsys.readouterr().out
|
||||
|
||||
|
||||
async def test_in_memory_client_talks_to_the_server_object() -> None:
|
||||
"""tutorial001: passing the server object connects in-process. No subprocess, no port."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
assert client.server_info.name == "Bookshop"
|
||||
assert client.protocol_version == "2026-07-28"
|
||||
result = await client.call_tool("search_books", {"query": "dune"})
|
||||
assert result.structured_content == {"result": "Found 3 books matching 'dune'."}
|
||||
|
||||
|
||||
async def test_constructing_a_client_does_not_connect_it() -> None:
|
||||
"""tutorial002: a URL string is accepted as-is, and nothing happens until `async with`."""
|
||||
client = Client("http://localhost:8000/mcp")
|
||||
with pytest.raises(RuntimeError, match="Client must be used within an async context manager"):
|
||||
client.session
|
||||
|
||||
|
||||
async def test_streamable_http_configuration_lives_on_the_httpx_client() -> None:
|
||||
"""tutorial003: `streamable_http_client` takes `http_client=`; there is no `headers=` or any other HTTP knob."""
|
||||
assert list(inspect.signature(streamable_http_client).parameters) == ["url", "http_client", "terminate_on_close"]
|
||||
|
||||
|
||||
async def test_stdio_parameters_are_wrapped_by_stdio_client() -> None:
|
||||
"""tutorial004: `stdio_client(params)` is the transport, and `Client` takes it like any other."""
|
||||
client = Client(stdio_client(tutorial004.server))
|
||||
with pytest.raises(RuntimeError, match="Client must be used within an async context manager"):
|
||||
client.session
|
||||
|
||||
|
||||
async def test_the_child_environment_is_an_allowlist(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""tutorial004: a variable set in the parent process is not inherited; `env=` adds it back explicitly."""
|
||||
monkeypatch.setenv("BOOKSHOP_API_KEY", "from-the-parent")
|
||||
inherited = get_default_environment()
|
||||
assert "PATH" in inherited
|
||||
assert "BOOKSHOP_API_KEY" not in inherited
|
||||
extra = tutorial004.server.env
|
||||
assert extra is not None
|
||||
assert (inherited | extra)["BOOKSHOP_API_KEY"] == "secret"
|
||||
@@ -0,0 +1,116 @@
|
||||
"""`docs/servers/completions.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import (
|
||||
Completion,
|
||||
CompletionContext,
|
||||
CompletionsCapability,
|
||||
ErrorData,
|
||||
PromptReference,
|
||||
ResourceTemplateReference,
|
||||
)
|
||||
|
||||
from docs_src.completions import tutorial001, tutorial002, tutorial003
|
||||
from mcp import Client, MCPError
|
||||
|
||||
# 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")]
|
||||
|
||||
TEMPLATE_REF = ResourceTemplateReference(uri="github://repos/{owner}/{repo}")
|
||||
PROMPT_REF = PromptReference(name="review_code")
|
||||
|
||||
|
||||
async def test_a_server_with_no_handler_has_no_completions_capability() -> None:
|
||||
"""tutorial001: there is something worth completing, but no handler and no advertised capability."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
(template,) = (await client.list_resource_templates()).resource_templates
|
||||
assert template.uri_template == "github://repos/{owner}/{repo}"
|
||||
(prompt,) = (await client.list_prompts()).prompts
|
||||
assert prompt.name == "review_code"
|
||||
assert client.server_capabilities.completions is None
|
||||
|
||||
|
||||
async def test_completing_without_a_handler_is_method_not_found() -> None:
|
||||
"""tutorial001: nothing handles `completion/complete`, so the request is a JSON-RPC error."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
with pytest.raises(MCPError) as excinfo:
|
||||
await client.complete(ref=PROMPT_REF, argument={"name": "language", "value": "py"})
|
||||
assert excinfo.value.error == ErrorData(code=-32601, message="Method not found", data="completion/complete")
|
||||
|
||||
|
||||
async def test_registering_the_handler_advertises_the_capability() -> None:
|
||||
"""tutorial002: `@mcp.completion()` is the whole declaration; the capability is derived from it."""
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
assert client.server_capabilities.completions == CompletionsCapability()
|
||||
|
||||
|
||||
async def test_prompt_argument_completion_filters_on_the_typed_prefix() -> None:
|
||||
"""tutorial002: the handler returns the languages that start with `argument.value`."""
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
result = await client.complete(ref=PROMPT_REF, argument={"name": "language", "value": "py"})
|
||||
assert result.completion == snapshot(Completion(values=["python"]))
|
||||
|
||||
|
||||
async def test_empty_value_returns_every_suggestion() -> None:
|
||||
"""tutorial002: an empty prefix matches everything, so the client gets the whole list."""
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
result = await client.complete(ref=PROMPT_REF, argument={"name": "language", "value": ""})
|
||||
assert result.completion.values == ["go", "javascript", "python", "rust", "typescript"]
|
||||
|
||||
|
||||
async def test_returning_none_is_an_empty_list_not_an_error() -> None:
|
||||
"""tutorial002: an argument the handler does not recognise produces `values=[]`, never a failure."""
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
result = await client.complete(ref=PROMPT_REF, argument={"name": "code", "value": "x"})
|
||||
assert result.completion == snapshot(Completion(values=[]))
|
||||
result = await client.complete(ref=TEMPLATE_REF, argument={"name": "repo", "value": ""})
|
||||
assert result.completion.values == []
|
||||
|
||||
|
||||
async def test_context_arguments_resolve_a_dependent_parameter() -> None:
|
||||
"""tutorial003: the already-resolved `owner` arrives in `context.arguments` and picks the repo list."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
result = await client.complete(
|
||||
ref=TEMPLATE_REF,
|
||||
argument={"name": "repo", "value": ""},
|
||||
context_arguments={"owner": "modelcontextprotocol"},
|
||||
)
|
||||
assert result.completion == snapshot(Completion(values=["python-sdk", "typescript-sdk", "inspector"]))
|
||||
|
||||
|
||||
async def test_the_typed_prefix_still_filters_a_dependent_parameter() -> None:
|
||||
"""tutorial003: `argument.value` narrows the owner's repos exactly as it narrows a prompt argument."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
result = await client.complete(
|
||||
ref=TEMPLATE_REF,
|
||||
argument={"name": "repo", "value": "py"},
|
||||
context_arguments={"owner": "modelcontextprotocol"},
|
||||
)
|
||||
assert result.completion.values == ["python-sdk"]
|
||||
|
||||
|
||||
def test_context_arguments_is_optional() -> None:
|
||||
"""tutorial003: `context.arguments` is `dict[str, str] | None`; the handler's `None` guard is required."""
|
||||
assert CompletionContext.model_fields["arguments"].annotation == (dict[str, str] | None)
|
||||
assert CompletionContext().arguments is None
|
||||
|
||||
|
||||
async def test_no_context_means_no_suggestions() -> None:
|
||||
"""tutorial003: without a resolved `owner` (or with an unknown one) the handler has nothing to offer."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
result = await client.complete(ref=TEMPLATE_REF, argument={"name": "repo", "value": ""})
|
||||
assert result.completion.values == []
|
||||
result = await client.complete(
|
||||
ref=TEMPLATE_REF,
|
||||
argument={"name": "repo", "value": ""},
|
||||
context_arguments={"owner": "nobody"},
|
||||
)
|
||||
assert result.completion.values == []
|
||||
|
||||
|
||||
async def test_the_prompt_branch_is_untouched_by_the_new_one() -> None:
|
||||
"""tutorial003: adding the resource-template branch leaves prompt-argument completion as it was."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
result = await client.complete(ref=PROMPT_REF, argument={"name": "language", "value": "type"})
|
||||
assert result.completion.values == ["typescript"]
|
||||
@@ -0,0 +1,88 @@
|
||||
"""`docs/handlers/context.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import TextContent, TextResourceContents, ToolListChangedNotification
|
||||
|
||||
from docs_src.context import tutorial001, tutorial002, tutorial003
|
||||
from mcp import Client
|
||||
|
||||
# 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_the_context_parameter_is_not_in_the_input_schema() -> None:
|
||||
"""tutorial001: the injected `Context` never appears in the schema the model sees."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.input_schema == snapshot(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"query": {"title": "Query", "type": "string"}},
|
||||
"required": ["query"],
|
||||
"title": "search_booksArguments",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def test_every_request_gets_its_own_context() -> None:
|
||||
"""tutorial001: `ctx.request_id` identifies the request being served, so it changes per call."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
first = await client.call_tool("search_books", {"query": "dune"})
|
||||
second = await client.call_tool("search_books", {"query": "dune"})
|
||||
assert isinstance(first.content[0], TextContent)
|
||||
assert isinstance(second.content[0], TextContent)
|
||||
assert re.fullmatch(r"\[request \d+\] Found 3 books matching 'dune'\.", first.content[0].text)
|
||||
assert first.content[0].text != second.content[0].text
|
||||
|
||||
|
||||
async def test_a_tool_reads_the_servers_own_resource() -> None:
|
||||
"""tutorial002: `ctx.read_resource` resolves the URI through the same registry `resources/read` uses."""
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
result = await client.call_tool("describe_catalog", {})
|
||||
assert not result.is_error
|
||||
assert result.content == [
|
||||
TextContent(type="text", text="The catalog is organised into: fiction, non-fiction, poetry")
|
||||
]
|
||||
(contents,) = (await client.read_resource("catalog://genres")).contents
|
||||
assert isinstance(contents, TextResourceContents)
|
||||
assert contents.text == "fiction, non-fiction, poetry"
|
||||
|
||||
|
||||
async def test_a_context_only_tool_takes_no_arguments() -> None:
|
||||
"""tutorial002: a tool whose only parameter is the `Context` has an empty input schema."""
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
tools = {tool.name: tool for tool in (await client.list_tools()).tools}
|
||||
assert tools["describe_catalog"].input_schema == snapshot(
|
||||
{"type": "object", "properties": {}, "title": "describe_catalogArguments"}
|
||||
)
|
||||
|
||||
|
||||
async def test_register_a_tool_at_runtime_and_notify_the_client() -> None:
|
||||
"""tutorial003: `mcp.add_tool` takes effect immediately and `send_tool_list_changed` reaches the client."""
|
||||
messages: list[object] = []
|
||||
|
||||
async def collect(message: object) -> None:
|
||||
messages.append(message)
|
||||
|
||||
async with Client(tutorial003.mcp, mode="legacy", message_handler=collect) as client:
|
||||
assert [tool.name for tool in (await client.list_tools()).tools] == ["enable_recommendations"]
|
||||
|
||||
missing = await client.call_tool("recommend_book", {"genre": "fiction"})
|
||||
assert missing.is_error
|
||||
assert missing.content == [TextContent(type="text", text="Unknown tool: recommend_book")]
|
||||
|
||||
enabled = await client.call_tool("enable_recommendations", {})
|
||||
assert enabled.content == [TextContent(type="text", text="Recommendations are now available.")]
|
||||
|
||||
assert [tool.name for tool in (await client.list_tools()).tools] == [
|
||||
"enable_recommendations",
|
||||
"recommend_book",
|
||||
]
|
||||
result = await client.call_tool("recommend_book", {"genre": "fiction"})
|
||||
assert result.content == [TextContent(type="text", text="In fiction, try 'Dune'.")]
|
||||
|
||||
(notification,) = messages
|
||||
assert isinstance(notification, ToolListChangedNotification)
|
||||
@@ -0,0 +1,158 @@
|
||||
"""`docs/handlers/dependencies.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import CreateMessageRequestParams, CreateMessageResult, ElicitRequestParams, ElicitResult, TextContent
|
||||
|
||||
from docs_src.dependencies import tutorial001, tutorial002, tutorial003, tutorial004
|
||||
from mcp import Client
|
||||
from mcp.client import ClientRequestContext
|
||||
|
||||
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
|
||||
|
||||
|
||||
async def test_the_resolver_fills_the_parameter_from_the_tools_own_argument() -> None:
|
||||
"""tutorial001: `check_stock` receives `title` by name and its return value becomes `stock`."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
in_stock = await client.call_tool("reserve_book", {"title": "Dune"})
|
||||
sold_out = await client.call_tool("reserve_book", {"title": "Neuromancer"})
|
||||
|
||||
assert in_stock.content == [TextContent(type="text", text="Reserved 'Dune' (6 copies left).")]
|
||||
assert sold_out.content == [TextContent(type="text", text="'Neuromancer' is out of stock.")]
|
||||
|
||||
|
||||
async def test_the_resolved_parameter_is_invisible_to_the_model() -> None:
|
||||
"""tutorial001: the input schema shown on the page is exactly what `tools/list` reports."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
|
||||
assert tool.input_schema == snapshot(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"title": {"title": "Title", "type": "string"}},
|
||||
"required": ["title"],
|
||||
"title": "reserve_bookArguments",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def test_a_client_supplied_value_for_a_resolved_parameter_is_ignored() -> None:
|
||||
"""tutorial001: the resolver's value is the only one the tool can receive."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.call_tool("reserve_book", {"title": "Dune", "stock": {"title": "Dune", "copies": 999}})
|
||||
|
||||
assert result.content == [TextContent(type="text", text="Reserved 'Dune' (6 copies left).")]
|
||||
|
||||
|
||||
async def test_a_resolver_can_depend_on_another_resolver() -> None:
|
||||
"""tutorial002: `estimate_delivery` consumes `check_stock`'s result, and the tool gets both."""
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
in_stock = await client.call_tool("order_book", {"title": "Dune"})
|
||||
backorder = await client.call_tool("order_book", {"title": "Neuromancer"})
|
||||
|
||||
assert in_stock.content == [TextContent(type="text", text="Ordered 'Dune'; it arrives tomorrow.")]
|
||||
assert backorder.content == [
|
||||
TextContent(type="text", text="'Neuromancer' is on backorder; it would arrive in 2-3 weeks.")
|
||||
]
|
||||
|
||||
|
||||
async def test_a_shared_dependency_runs_once_per_call(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""tutorial002: `stock` and `delivery` both need `check_stock`; one call, one inventory lookup."""
|
||||
|
||||
class CountingInventory:
|
||||
def __init__(self, data: dict[str, int]) -> None:
|
||||
self.data = data
|
||||
self.lookups: list[str] = []
|
||||
|
||||
def get(self, key: str, default: int) -> int:
|
||||
self.lookups.append(key)
|
||||
return self.data.get(key, default)
|
||||
|
||||
inventory = CountingInventory(dict(tutorial002.INVENTORY))
|
||||
monkeypatch.setattr(tutorial002, "INVENTORY", inventory)
|
||||
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
await client.call_tool("order_book", {"title": "Dune"})
|
||||
assert inventory.lookups == ["Dune"]
|
||||
# Memoization is per call, not per server: the next call looks the title up again.
|
||||
await client.call_tool("order_book", {"title": "Dune"})
|
||||
assert inventory.lookups == ["Dune", "Dune"]
|
||||
|
||||
|
||||
# The `!!! info` claims the tutorial003 behaviour is transport-independent, so each claim is
|
||||
# proved on both: mode="legacy" elicits synchronously mid-call (2025-11-25 and earlier), while
|
||||
# mode="auto" negotiates 2026-07-28, where the question rides a multi-round-trip `tools/call`
|
||||
# and `Client` drives the retries.
|
||||
@pytest.mark.parametrize("mode", ["legacy", "auto"])
|
||||
async def test_an_in_stock_order_asks_no_question(mode: Literal["legacy", "auto"]) -> None:
|
||||
"""tutorial003: `confirm_backorder` returns directly when stock exists - no round-trip."""
|
||||
|
||||
async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover
|
||||
raise AssertionError("an in-stock order must not elicit")
|
||||
|
||||
async with Client(tutorial003.mcp, mode=mode, elicitation_callback=never) as client:
|
||||
result = await client.call_tool("order_book", {"title": "Dune"})
|
||||
|
||||
assert result.content == [TextContent(type="text", text="Ordered 'Dune'.")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["legacy", "auto"])
|
||||
@pytest.mark.parametrize(
|
||||
("confirm", "expected"),
|
||||
[
|
||||
(True, "Backordered 'Neuromancer'; it ships in 2-3 weeks."),
|
||||
(False, "No order placed."),
|
||||
],
|
||||
)
|
||||
async def test_an_out_of_stock_order_asks_and_honours_the_answer(
|
||||
mode: Literal["legacy", "auto"], confirm: bool, expected: str
|
||||
) -> None:
|
||||
"""tutorial003: the resolver elicits, the SDK validates the answer, the tool reads it."""
|
||||
asked: list[str] = []
|
||||
|
||||
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
asked.append(params.message)
|
||||
return ElicitResult(action="accept", content={"confirm": confirm})
|
||||
|
||||
async with Client(tutorial003.mcp, mode=mode, elicitation_callback=on_elicit) as client:
|
||||
result = await client.call_tool("order_book", {"title": "Neuromancer"})
|
||||
|
||||
assert result.content == [TextContent(type="text", text=expected)]
|
||||
assert asked == ["'Neuromancer' is out of stock (2-3 weeks). Order anyway?"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["legacy", "auto"])
|
||||
async def test_declining_an_unwrapped_dependency_aborts_the_call(mode: Literal["legacy", "auto"]) -> None:
|
||||
"""tutorial003: no answer, no order - the error text on the page is the real one."""
|
||||
|
||||
async def decline(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
return ElicitResult(action="decline")
|
||||
|
||||
async with Client(tutorial003.mcp, mode=mode, elicitation_callback=decline) as client:
|
||||
result = await client.call_tool("order_book", {"title": "Neuromancer"})
|
||||
|
||||
assert result.is_error
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == (
|
||||
"Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["legacy", "auto"])
|
||||
async def test_a_resolver_can_sample_the_clients_llm(mode: Literal["legacy", "auto"]) -> None:
|
||||
"""tutorial004: `suggest_title` runs through the client's sampling callback on both eras."""
|
||||
prompts: list[str] = []
|
||||
|
||||
async def sampler(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult:
|
||||
content = params.messages[0].content
|
||||
assert isinstance(content, TextContent)
|
||||
prompts.append(content.text)
|
||||
return CreateMessageResult(role="assistant", content=TextContent(type="text", text="Dune"), model="m")
|
||||
|
||||
async with Client(tutorial004.mcp, mode=mode, sampling_callback=sampler) as client:
|
||||
result = await client.call_tool("recommend_book", {"genre": "sci-fi"})
|
||||
|
||||
assert result.content == [TextContent(type="text", text="Today's sci-fi pick: Dune")]
|
||||
assert prompts == ["Suggest one sci-fi book title. Answer with the title only."]
|
||||
@@ -0,0 +1,230 @@
|
||||
"""`docs/run/deploy.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import pytest
|
||||
from mcp_types import (
|
||||
INVALID_PARAMS,
|
||||
CallToolResult,
|
||||
ElicitResult,
|
||||
InputRequiredResult,
|
||||
ResourceUpdatedNotification,
|
||||
SubscriptionFilter,
|
||||
SubscriptionsListenRequest,
|
||||
SubscriptionsListenRequestParams,
|
||||
SubscriptionsListenResult,
|
||||
TextContent,
|
||||
)
|
||||
|
||||
from docs_src.deploy import tutorial001, tutorial002, tutorial003, tutorial004
|
||||
from mcp import Client, MCPError
|
||||
from mcp.server import MCPServer
|
||||
from mcp.server.mcpserver import Context, RequestStateSecurity
|
||||
from mcp.server.subscriptions import InMemorySubscriptionBus
|
||||
|
||||
# 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")]
|
||||
|
||||
_KEY = "0123456789abcdef0123456789abcdef" # 32 bytes: the smallest secret the SDK accepts.
|
||||
|
||||
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"}
|
||||
|
||||
|
||||
# -- the Host allowlist ----------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_the_default_app_rejects_a_real_hostname_before_mcp_runs() -> None:
|
||||
"""The section's `!!! check`: without `transport_security=`, a deployed hostname gets the page's exact 421."""
|
||||
bare = MCPServer("Notes")
|
||||
app = bare.streamable_http_app()
|
||||
async with bare.session_manager.run():
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://api.example.com") as h:
|
||||
response = await h.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
|
||||
assert (response.status_code, response.text) == (421, "Invalid Host header")
|
||||
|
||||
|
||||
async def test_the_allowlisted_app_serves_its_hostname_and_still_rejects_others() -> None:
|
||||
"""tutorial001: `allowed_hosts=` opens exactly the hostname you named, and nothing else."""
|
||||
transport = httpx.ASGITransport(app=tutorial001.app)
|
||||
async with tutorial001.mcp.session_manager.run():
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
|
||||
allowed = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://api.example.com") as http:
|
||||
rejected = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
|
||||
assert allowed.status_code == 200
|
||||
assert allowed.headers["mcp-session-id"]
|
||||
assert (rejected.status_code, rejected.text) == (421, "Invalid Host header")
|
||||
|
||||
|
||||
# -- `requestState` across workers -----------------------------------------------------
|
||||
|
||||
|
||||
async def _first_round(client: Client, amount: int) -> str:
|
||||
"""Round one of `refund`: no answers yet, so the server returns the `InputRequiredResult`."""
|
||||
first = await client.session.call_tool("refund", {"amount": amount}, allow_input_required=True)
|
||||
assert isinstance(first, InputRequiredResult)
|
||||
assert first.request_state is not None
|
||||
return first.request_state
|
||||
|
||||
|
||||
async def _retry(client: Client, amount: int, token: str) -> CallToolResult | InputRequiredResult:
|
||||
"""The retry: same tool, same arguments, the elicited answer, and the echoed token."""
|
||||
return await client.session.call_tool(
|
||||
"refund",
|
||||
{"amount": amount},
|
||||
input_responses={"ok": ElicitResult(action="accept", content={"ok": True})},
|
||||
request_state=token,
|
||||
allow_input_required=True,
|
||||
)
|
||||
|
||||
|
||||
def _assert_frozen_rejection(exc: pytest.ExceptionInfo[MCPError]) -> None:
|
||||
"""The one wire shape every inbound `requestState` verification failure produces."""
|
||||
assert exc.value.error.code == INVALID_PARAMS
|
||||
assert exc.value.error.message == "Invalid or expired requestState"
|
||||
assert exc.value.error.data == {"reason": "invalid_request_state"}
|
||||
|
||||
|
||||
async def test_a_retry_that_reaches_a_different_worker_is_rejected_by_default() -> None:
|
||||
"""tutorial002: two default servers hold two `os.urandom(32)` keys, so a cross-instance retry is refused."""
|
||||
worker_a = tutorial002.make_server()
|
||||
worker_b = tutorial002.make_server()
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(worker_a) as on_a, Client(worker_b) as on_b:
|
||||
token = await _first_round(on_a, 120)
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await _retry(on_b, 120, token)
|
||||
# Land back on the worker that minted the token and the identical retry completes.
|
||||
second = await _retry(on_a, 120, token)
|
||||
|
||||
_assert_frozen_rejection(exc)
|
||||
assert isinstance(second, CallToolResult)
|
||||
assert second.content == [TextContent(type="text", text="refunded $120")]
|
||||
|
||||
|
||||
async def test_a_refund_the_human_declined_is_not_issued() -> None:
|
||||
"""tutorial002/003: the second round reads the answer, so anything but an accepted ok is no refund."""
|
||||
server = tutorial002.make_server()
|
||||
with anyio.fail_after(5):
|
||||
async with Client(server) as client:
|
||||
token = await _first_round(client, 120)
|
||||
declined = await client.session.call_tool(
|
||||
"refund",
|
||||
{"amount": 120},
|
||||
input_responses={"ok": ElicitResult(action="decline")},
|
||||
request_state=token,
|
||||
allow_input_required=True,
|
||||
)
|
||||
assert isinstance(declined, CallToolResult)
|
||||
assert declined.content == [TextContent(type="text", text="refund cancelled")]
|
||||
|
||||
|
||||
async def test_a_shared_key_and_name_let_any_worker_finish_a_round_trip() -> None:
|
||||
"""tutorial003: instances built with the same key and the same name unseal what a sibling minted."""
|
||||
worker_a = tutorial003.make_server(_KEY)
|
||||
worker_b = tutorial003.make_server(_KEY)
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(worker_a) as on_a, Client(worker_b) as on_b:
|
||||
token = await _first_round(on_a, 120)
|
||||
second = await _retry(on_b, 120, token)
|
||||
|
||||
assert isinstance(second, CallToolResult)
|
||||
assert not second.is_error
|
||||
assert second.content == [TextContent(type="text", text="refunded $120")]
|
||||
|
||||
|
||||
async def test_a_shared_key_is_not_enough_without_a_shared_name() -> None:
|
||||
"""The `!!! warning`: the server name is the default `audience` claim, so keys alone don't cross instances."""
|
||||
|
||||
def named(name: str) -> MCPServer:
|
||||
mcp = MCPServer(name, request_state_security=RequestStateSecurity(keys=[_KEY]))
|
||||
|
||||
@mcp.tool()
|
||||
async def refund(amount: int, ctx: Context) -> str | InputRequiredResult:
|
||||
if ctx.input_responses is None:
|
||||
return InputRequiredResult(input_requests={"ok": tutorial002.CONFIRM}, request_state="pending")
|
||||
return f"refunded ${amount}"
|
||||
|
||||
return mcp
|
||||
|
||||
with anyio.fail_after(5):
|
||||
async with Client(named("billing-1")) as on_one, Client(named("billing-2")) as on_two:
|
||||
token = await _first_round(on_one, 120)
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await _retry(on_two, 120, token)
|
||||
# Same keys AND the same name: back on the instance that minted it, the retry completes.
|
||||
second = await _retry(on_one, 120, token)
|
||||
|
||||
_assert_frozen_rejection(exc)
|
||||
assert isinstance(second, CallToolResult)
|
||||
assert second.content == [TextContent(type="text", text="refunded $120")]
|
||||
|
||||
|
||||
# -- change notifications across replicas ----------------------------------------------
|
||||
|
||||
|
||||
class _Stream:
|
||||
"""Collects a listen stream's frames and lets the test await arrival counts."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.received: list[object] = []
|
||||
self._arrival = anyio.Event()
|
||||
|
||||
async def handler(self, message: object) -> None:
|
||||
self.received.append(message)
|
||||
self._arrival.set()
|
||||
self._arrival = anyio.Event()
|
||||
|
||||
async def wait_for(self, count: int) -> None:
|
||||
with anyio.fail_after(5):
|
||||
while len(self.received) < count:
|
||||
await self._arrival.wait()
|
||||
|
||||
|
||||
async def test_one_bus_carries_a_publish_on_one_replica_to_a_stream_on_another() -> None:
|
||||
"""tutorial004: a `subscriptions/listen` stream on replica A hears a publish that happened on replica B."""
|
||||
bus = InMemorySubscriptionBus()
|
||||
replica_a = tutorial004.make_server(bus)
|
||||
replica_b = tutorial004.make_server(bus)
|
||||
stream = _Stream()
|
||||
|
||||
with anyio.fail_after(10):
|
||||
await _listen_and_edit(replica_a, replica_b, stream)
|
||||
|
||||
|
||||
async def _listen_and_edit(replica_a: MCPServer, replica_b: MCPServer, stream: _Stream) -> None:
|
||||
"""Open a listen stream on replica A, edit on replica B, and wait for the update to cross the bus."""
|
||||
async with (
|
||||
Client(replica_a, mode="2026-07-28", message_handler=stream.handler) as on_a,
|
||||
Client(replica_b) as on_b,
|
||||
):
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def listen() -> None:
|
||||
await on_a.session.send_request(
|
||||
SubscriptionsListenRequest(
|
||||
params=SubscriptionsListenRequestParams(
|
||||
notifications=SubscriptionFilter(resource_subscriptions=["note://todo"])
|
||||
)
|
||||
),
|
||||
SubscriptionsListenResult,
|
||||
)
|
||||
|
||||
tg.start_soon(listen)
|
||||
await stream.wait_for(1) # the acknowledgment: the stream is live on replica A
|
||||
|
||||
await on_b.call_tool("edit_note", {"name": "todo", "text": "water plants"})
|
||||
await stream.wait_for(2)
|
||||
updated = stream.received[1]
|
||||
assert isinstance(updated, ResourceUpdatedNotification)
|
||||
assert updated.params.uri == "note://todo"
|
||||
|
||||
tg.cancel_scope.cancel()
|
||||
@@ -0,0 +1,144 @@
|
||||
"""`docs/deprecated.md`: the page's behavioural claims, executed against the live SDK.
|
||||
|
||||
This chapter has no `docs_src/` example by design: it is the one page allowed to name
|
||||
the deprecated methods, and a runnable example would teach exactly what the page tells
|
||||
the reader not to build. So instead of importing an example, each test here runs a
|
||||
claim the page states in prose (the warning category and text, the warn-*then*-raise
|
||||
order on a modern connection, the `ping` removal, and both `filterwarnings` recipes)
|
||||
so the prose cannot drift away from what the SDK does.
|
||||
"""
|
||||
|
||||
import warnings
|
||||
|
||||
import pytest
|
||||
from mcp_types import CreateMessageRequestParams, CreateMessageResult, SamplingMessage, TextContent
|
||||
|
||||
from mcp import Client, MCPDeprecationWarning, MCPError
|
||||
from mcp.client import ClientRequestContext
|
||||
from mcp.server import MCPServer
|
||||
from mcp.server.mcpserver import Context
|
||||
from mcp.shared.exceptions import NoBackChannelError
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
mcp = MCPServer("Deprecated")
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def ask_model(prompt: str, ctx: Context) -> str:
|
||||
"""A tool still built on server-initiated sampling."""
|
||||
result = await ctx.session.create_message( # pyright: ignore[reportDeprecated]
|
||||
messages=[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))],
|
||||
max_tokens=8,
|
||||
)
|
||||
return str(result.content)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def old_log(ctx: Context) -> str:
|
||||
"""A tool still built on protocol logging."""
|
||||
await ctx.info("hello") # pyright: ignore[reportDeprecated]
|
||||
return "ok"
|
||||
|
||||
|
||||
async def test_create_message_warns_and_then_raises_on_a_modern_connection() -> None:
|
||||
"""The `!!! warning`: on a modern connection sampling warns AND THEN the send raises.
|
||||
|
||||
The two signals are independent: `@deprecated` fires the moment the method is
|
||||
called, and only afterwards does the channel refuse the send. The page reports
|
||||
both, in that order.
|
||||
"""
|
||||
async with Client(mcp) as client:
|
||||
with (
|
||||
pytest.warns(
|
||||
MCPDeprecationWarning,
|
||||
match=r"^The sampling capability is deprecated as of 2026-07-28 \(SEP-2577\)\.$",
|
||||
),
|
||||
pytest.raises(NoBackChannelError) as exc,
|
||||
):
|
||||
await client.call_tool("ask_model", {"prompt": "hi"})
|
||||
assert str(exc.value) == (
|
||||
"Cannot send 'sampling/createMessage': "
|
||||
"this transport context has no back-channel for server-initiated requests."
|
||||
)
|
||||
|
||||
|
||||
async def test_a_deprecated_feature_still_works_on_a_legacy_session() -> None:
|
||||
"""The page's headline: the deprecation is advisory.
|
||||
|
||||
On a classic-handshake session, the same `ask_model` tool that fails on a modern
|
||||
connection runs to completion: sampling round-trips through the client's callback
|
||||
and the result comes back. The only difference is the visible warning.
|
||||
"""
|
||||
|
||||
async def canned_sampling(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult:
|
||||
return CreateMessageResult(
|
||||
role="assistant",
|
||||
content=TextContent(type="text", text="four"),
|
||||
model="canned",
|
||||
stop_reason="endTurn",
|
||||
)
|
||||
|
||||
async with Client(mcp, mode="legacy", sampling_callback=canned_sampling) as client:
|
||||
with pytest.warns(MCPDeprecationWarning, match=r"The sampling capability is deprecated"):
|
||||
result = await client.call_tool("ask_model", {"prompt": "What is 2 + 2?"})
|
||||
assert not result.is_error
|
||||
[content] = result.content
|
||||
assert isinstance(content, TextContent)
|
||||
assert "four" in content.text
|
||||
|
||||
|
||||
async def test_send_ping_still_carries_the_deprecation_warning() -> None:
|
||||
"""The opening sentence: every retired method carries an `MCPDeprecationWarning`.
|
||||
|
||||
`ping` is removed from the 2026-07-28 protocol rather than put in a deprecation
|
||||
window, but the SDK method is still decorated (its message says *removed*) and
|
||||
a modern connection answers the actual request with "Method not found".
|
||||
"""
|
||||
async with Client(mcp) as client:
|
||||
with (
|
||||
pytest.warns(
|
||||
MCPDeprecationWarning,
|
||||
match=r"^ping is removed as of 2026-07-28; the method only works under mode='legacy'\.$",
|
||||
),
|
||||
pytest.raises(MCPError, match="^Method not found$"),
|
||||
):
|
||||
await client.send_ping() # pyright: ignore[reportDeprecated]
|
||||
|
||||
|
||||
def test_mcp_deprecation_warning_is_a_user_warning() -> None:
|
||||
"""The "Deprecated is advisory" section: the category subclasses `UserWarning`.
|
||||
|
||||
Python's default filter hides `DeprecationWarning` outside `__main__`; deriving
|
||||
from `UserWarning` is what makes the warning visible with no `-W` flag.
|
||||
"""
|
||||
assert issubclass(MCPDeprecationWarning, UserWarning)
|
||||
assert not issubclass(MCPDeprecationWarning, DeprecationWarning)
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")
|
||||
async def test_error_filter_turns_the_deprecated_call_into_the_documented_tool_error() -> None:
|
||||
"""The `!!! check`: `"error::mcp.MCPDeprecationWarning"` makes `old_log` fail.
|
||||
|
||||
Under the error filter the warning becomes the raised exception, the tool manager
|
||||
wraps it, and the result is exactly the tool error the page quotes.
|
||||
"""
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("old_log", {})
|
||||
assert result.is_error
|
||||
[content] = result.content
|
||||
assert isinstance(content, TextContent)
|
||||
assert content.text == (
|
||||
"Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577)."
|
||||
)
|
||||
|
||||
|
||||
async def test_filterwarnings_ignore_silences_the_whole_category() -> None:
|
||||
"""The "Silencing the warning" snippet: one `filterwarnings` line quiets the category."""
|
||||
async with Client(mcp) as client:
|
||||
with warnings.catch_warnings(record=True) as caught:
|
||||
warnings.simplefilter("always")
|
||||
warnings.filterwarnings("ignore", category=MCPDeprecationWarning)
|
||||
result = await client.call_tool("old_log", {})
|
||||
assert not result.is_error
|
||||
assert not any(issubclass(w.category, MCPDeprecationWarning) for w in caught)
|
||||
@@ -0,0 +1,299 @@
|
||||
"""`docs/handlers/elicitation.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import (
|
||||
ElicitCompleteNotification,
|
||||
ElicitRequestFormParams,
|
||||
ElicitRequestParams,
|
||||
ElicitRequestURLParams,
|
||||
ElicitResult,
|
||||
TextContent,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
from docs_src.elicitation import tutorial001, tutorial002, tutorial003, tutorial004
|
||||
from mcp import Client, MCPError
|
||||
from mcp.client import ClientRequestContext
|
||||
from mcp.server import MCPServer
|
||||
from mcp.server.mcpserver import Context
|
||||
|
||||
# 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_an_accepted_answer_resumes_the_tool() -> None:
|
||||
"""tutorial001: the user's answer comes back into the same call as a validated model."""
|
||||
|
||||
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
return ElicitResult(action="accept", content={"accept_alternative": True, "date": "2025-12-26"})
|
||||
|
||||
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
|
||||
result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
|
||||
assert not result.is_error
|
||||
assert result.content == [TextContent(type="text", text="Booked a table for 2 on 2025-12-26.")]
|
||||
|
||||
|
||||
async def test_an_alternative_that_is_also_full_is_asked_about_again() -> None:
|
||||
"""tutorial001: the accepted date goes back through `book_table`, so a full date is re-asked, not booked."""
|
||||
asked: list[str] = []
|
||||
|
||||
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
asked.append(params.message)
|
||||
date = "2025-12-25" if len(asked) == 1 else "2025-12-27"
|
||||
return ElicitResult(action="accept", content={"accept_alternative": True, "date": date})
|
||||
|
||||
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
|
||||
result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
|
||||
assert result.content == [TextContent(type="text", text="Booked a table for 2 on 2025-12-27.")]
|
||||
assert asked == [
|
||||
"No tables for 2 on 2025-12-25. Would you like to try another date?",
|
||||
"No tables for 2 on 2025-12-25. Would you like to try another date?",
|
||||
]
|
||||
|
||||
|
||||
async def test_the_client_receives_the_message_and_the_generated_schema() -> None:
|
||||
"""tutorial001: form mode sends your message plus a JSON Schema built from the Pydantic model."""
|
||||
received: list[ElicitRequestParams] = []
|
||||
|
||||
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
received.append(params)
|
||||
return ElicitResult(action="accept", content={"accept_alternative": False})
|
||||
|
||||
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
|
||||
await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
|
||||
(params,) = received
|
||||
assert isinstance(params, ElicitRequestFormParams)
|
||||
assert params.message == "No tables for 2 on 2025-12-25. Would you like to try another date?"
|
||||
assert params.requested_schema == snapshot(
|
||||
{
|
||||
"properties": {
|
||||
"accept_alternative": {
|
||||
"description": "Try another date?",
|
||||
"title": "Accept Alternative",
|
||||
"type": "boolean",
|
||||
},
|
||||
"date": {
|
||||
"default": "2025-12-26",
|
||||
"description": "Alternative date (YYYY-MM-DD)",
|
||||
"title": "Date",
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"required": ["accept_alternative"],
|
||||
"title": "AlternativeDate",
|
||||
"type": "object",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def test_decline_and_cancel_are_ordinary_return_values() -> None:
|
||||
"""tutorial001: a refusal is not an error; the tool sees the action and answers the model normally."""
|
||||
|
||||
async def on_decline(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
return ElicitResult(action="decline")
|
||||
|
||||
async def on_cancel(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
return ElicitResult(action="cancel")
|
||||
|
||||
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_decline) as client:
|
||||
declined = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
|
||||
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_cancel) as client:
|
||||
cancelled = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
|
||||
assert declined.content == [TextContent(type="text", text="No booking made.")]
|
||||
assert not declined.is_error
|
||||
assert cancelled.content == [TextContent(type="text", text="No booking made.")]
|
||||
|
||||
|
||||
async def test_a_tool_that_does_not_ask_needs_nothing_from_the_client() -> None:
|
||||
"""tutorial001: the elicitation only happens on the path that needs it."""
|
||||
async with Client(tutorial001.mcp, mode="legacy") as client:
|
||||
result = await client.call_tool("book_table", {"date": "2025-12-30", "party_size": 4})
|
||||
assert result.content == [TextContent(type="text", text="Booked a table for 4 on 2025-12-30.")]
|
||||
|
||||
|
||||
async def test_an_answer_that_does_not_match_the_schema_never_reaches_the_tool_code() -> None:
|
||||
"""`!!! tip`: the client's content is validated against the model; a mismatch fails the call."""
|
||||
|
||||
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
return ElicitResult(action="accept", content={"accept_alternative": "maybe"})
|
||||
|
||||
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
|
||||
result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
|
||||
assert result.is_error
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert "does not match the requested schema" in result.content[0].text
|
||||
|
||||
|
||||
class Address(BaseModel):
|
||||
city: str
|
||||
|
||||
|
||||
class Applicant(BaseModel):
|
||||
name: str
|
||||
address: Address
|
||||
|
||||
|
||||
class Seating(BaseModel):
|
||||
area: Literal["inside", "terrace"]
|
||||
|
||||
|
||||
schema_gate_server = MCPServer("Bistro")
|
||||
"""The `!!! warning` claims: what the elicitation schema gate accepts and rejects."""
|
||||
|
||||
|
||||
@schema_gate_server.tool()
|
||||
async def sign_up(ctx: Context) -> str:
|
||||
"""Collect the new customer's details."""
|
||||
return str(await ctx.elicit(message="Who are you?", schema=Applicant))
|
||||
|
||||
|
||||
@schema_gate_server.tool()
|
||||
async def choose_seating(ctx: Context) -> str:
|
||||
"""Ask where the party wants to sit."""
|
||||
result = await ctx.elicit(message="Where would you like to sit?", schema=Seating)
|
||||
assert result.action == "accept"
|
||||
return result.data.area
|
||||
|
||||
|
||||
async def test_a_nested_model_is_rejected_before_anything_is_sent() -> None:
|
||||
"""`!!! warning`: a non-primitive field raises `TypeError` inside `ctx.elicit`, with this exact message."""
|
||||
async with Client(schema_gate_server, mode="legacy") as client:
|
||||
result = await client.call_tool("sign_up", {})
|
||||
assert result.is_error
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == (
|
||||
"Error executing tool sign_up: Elicitation schema field 'address' rendered as "
|
||||
"{'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition"
|
||||
)
|
||||
|
||||
|
||||
async def test_a_literal_field_passes_the_gate_as_an_enum() -> None:
|
||||
"""`!!! warning`: a `Literal[...]` of strings renders as a JSON Schema `enum`, which the spec allows."""
|
||||
received: list[ElicitRequestParams] = []
|
||||
|
||||
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
received.append(params)
|
||||
return ElicitResult(action="accept", content={"area": "terrace"})
|
||||
|
||||
async with Client(schema_gate_server, mode="legacy", elicitation_callback=on_elicit) as client:
|
||||
result = await client.call_tool("choose_seating", {})
|
||||
assert result.content == [TextContent(type="text", text="terrace")]
|
||||
(params,) = received
|
||||
assert isinstance(params, ElicitRequestFormParams)
|
||||
assert params.requested_schema["properties"]["area"] == snapshot(
|
||||
{"enum": ["inside", "terrace"], "title": "Area", "type": "string"}
|
||||
)
|
||||
|
||||
|
||||
async def test_url_mode_sends_a_url_and_gets_consent_back_not_data() -> None:
|
||||
"""tutorial002: the client receives the URL and the elicitation id; only the action comes back."""
|
||||
received: list[ElicitRequestParams] = []
|
||||
|
||||
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
received.append(params)
|
||||
return ElicitResult(action="accept")
|
||||
|
||||
async with Client(tutorial002.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
|
||||
result = await client.call_tool("pay_deposit", {"booking_id": "b42"})
|
||||
assert result.content == [TextContent(type="text", text="Complete the payment in your browser.")]
|
||||
(params,) = received
|
||||
assert isinstance(params, ElicitRequestURLParams)
|
||||
assert params.url == "https://pay.example.com/deposit/b42"
|
||||
assert params.elicitation_id == "deposit-b42"
|
||||
|
||||
|
||||
async def test_a_declined_url_elicitation_is_an_ordinary_return_value() -> None:
|
||||
"""tutorial002: the tool decides what a refusal means."""
|
||||
|
||||
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
return ElicitResult(action="decline")
|
||||
|
||||
async with Client(tutorial002.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
|
||||
result = await client.call_tool("pay_deposit", {"booking_id": "b42"})
|
||||
assert result.content == [TextContent(type="text", text="No deposit taken. The booking expires in one hour.")]
|
||||
|
||||
|
||||
async def test_send_elicit_complete_notifies_the_client_with_the_same_id() -> None:
|
||||
"""tutorial002: `send_elicit_complete` emits `notifications/elicitation/complete`."""
|
||||
notifications: list[object] = []
|
||||
|
||||
async def on_message(message: object) -> None:
|
||||
notifications.append(message)
|
||||
|
||||
async with Client(tutorial002.mcp, mode="legacy", message_handler=on_message) as client:
|
||||
result = await client.call_tool("confirm_deposit", {"booking_id": "b42"})
|
||||
assert result.content == [TextContent(type="text", text="Deposit received for booking b42.")]
|
||||
(notification,) = notifications
|
||||
assert isinstance(notification, ElicitCompleteNotification)
|
||||
assert notification.params.elicitation_id == "deposit-b42"
|
||||
|
||||
|
||||
async def test_the_docs_client_callback_handles_both_modes() -> None:
|
||||
"""tutorial003: one `elicitation_callback` answers the form and the URL consent."""
|
||||
async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=tutorial003.handle_elicitation) as client:
|
||||
booked = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
|
||||
async with Client(tutorial002.mcp, mode="legacy", elicitation_callback=tutorial003.handle_elicitation) as client:
|
||||
paid = await client.call_tool("pay_deposit", {"booking_id": "b42"})
|
||||
assert booked.content == [TextContent(type="text", text="Booked a table for 2 on 2025-12-27.")]
|
||||
assert paid.content == [TextContent(type="text", text="Complete the payment in your browser.")]
|
||||
|
||||
|
||||
async def test_a_client_without_the_callback_cannot_be_asked() -> None:
|
||||
"""`!!! check`: no `elicitation_callback` means no `elicitation` capability; the call is a protocol error."""
|
||||
async with Client(tutorial001.mcp, mode="legacy") as client:
|
||||
with pytest.raises(MCPError, match="Elicitation not supported"):
|
||||
await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2})
|
||||
|
||||
|
||||
async def test_resolver_asks_only_when_the_folder_is_not_empty() -> None:
|
||||
"""tutorial004: `confirm_delete` resolves an empty folder directly and elicits otherwise."""
|
||||
tutorial004._FOLDERS.update({"/tmp/empty": [], "/tmp/project": ["main.py", "README.md"]})
|
||||
asked: list[str] = []
|
||||
|
||||
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
assert isinstance(params, ElicitRequestFormParams)
|
||||
asked.append(params.message)
|
||||
return ElicitResult(action="accept", content={"ok": True})
|
||||
|
||||
async with Client(tutorial004.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
|
||||
empty = await client.call_tool("delete_folder", {"path": "/tmp/empty"})
|
||||
non_empty = await client.call_tool("delete_folder", {"path": "/tmp/project"})
|
||||
|
||||
assert empty.content == [TextContent(type="text", text="deleted /tmp/empty")]
|
||||
assert non_empty.content == [TextContent(type="text", text="deleted /tmp/project")]
|
||||
assert asked == ["/tmp/project has 2 file(s). Delete anyway?"] # the empty folder was not queried
|
||||
|
||||
|
||||
async def test_the_resolved_parameter_is_hidden_from_the_tool_schema() -> None:
|
||||
"""tutorial004: the `Resolve`-filled parameter never appears in the client-facing input schema."""
|
||||
async with Client(tutorial004.mcp, mode="legacy") as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.name == "delete_folder"
|
||||
assert set(tool.input_schema["properties"]) == {"path"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("action", "content", "expected"),
|
||||
[
|
||||
("accept", {"ok": False}, "kept the folder"),
|
||||
("decline", None, "declined: folder not deleted"),
|
||||
("cancel", None, "cancelled: folder not deleted"),
|
||||
],
|
||||
)
|
||||
async def test_the_tool_branches_on_every_elicitation_outcome(
|
||||
action: Literal["accept", "decline", "cancel"],
|
||||
content: dict[str, str | int | float | bool | list[str] | None] | None,
|
||||
expected: str,
|
||||
) -> None:
|
||||
"""tutorial004: annotating the result union lets the tool handle accept/decline/cancel."""
|
||||
tutorial004._FOLDERS["/tmp/project"] = ["main.py", "README.md"]
|
||||
|
||||
async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
return ElicitResult(action=action, content=content)
|
||||
|
||||
async with Client(tutorial004.mcp, mode="legacy", elicitation_callback=on_elicit) as client:
|
||||
result = await client.call_tool("delete_folder", {"path": "/tmp/project"})
|
||||
assert result.content == [TextContent(type="text", text=expected)]
|
||||
@@ -0,0 +1,132 @@
|
||||
"""`docs/advanced/extensions.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import METHOD_NOT_FOUND, MISSING_REQUIRED_CLIENT_CAPABILITY, TextContent
|
||||
|
||||
from docs_src.extensions import (
|
||||
tutorial001,
|
||||
tutorial002,
|
||||
tutorial003,
|
||||
tutorial004,
|
||||
tutorial005,
|
||||
tutorial006,
|
||||
tutorial007,
|
||||
)
|
||||
from mcp import Client, MCPError
|
||||
from mcp.client import advertise
|
||||
from mcp.server.extension import Extension
|
||||
|
||||
# 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_using_an_extension_advertises_its_capability() -> None:
|
||||
"""tutorial001: `extensions=[Apps()]` is all it takes for the server to advertise
|
||||
the extension under `capabilities.extensions`."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
assert client.server_capabilities.extensions == {"io.modelcontextprotocol/ui": {}}
|
||||
|
||||
|
||||
def test_a_prefixless_identifier_fails_at_class_definition() -> None:
|
||||
"""tutorial002 + the page's TypeError block: the identifier is validated when the
|
||||
subclass is defined, with the exact message the page shows."""
|
||||
assert tutorial002.Stamps.identifier == "com.example/stamps"
|
||||
with pytest.raises(TypeError) as exc_info:
|
||||
type("Stamps", (Extension,), {"identifier": "stamps"})
|
||||
assert str(exc_info.value) == snapshot(
|
||||
"Stamps.identifier must be a `vendor-prefix/name` string (reverse-DNS prefix required), got 'stamps'"
|
||||
)
|
||||
|
||||
|
||||
async def test_extension_settings_advertised_under_capabilities() -> None:
|
||||
"""tutorial003: `settings()` becomes the entry at `capabilities.extensions[identifier]`."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
assert client.server_capabilities.extensions == {"com.example/stamps": {"sealed": True}}
|
||||
|
||||
|
||||
async def test_contributed_tool_is_listed_and_callable() -> None:
|
||||
"""tutorial003: a `ToolBinding` registers like any `add_tool` call: listed and callable."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
listed = await client.list_tools()
|
||||
assert [tool.name for tool in listed.tools] == ["stamp"]
|
||||
result = await client.call_tool("stamp", {"text": "hello"})
|
||||
assert result.content == [TextContent(type="text", text="[stamped] hello")]
|
||||
|
||||
|
||||
async def test_the_stamps_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""tutorial003: `main()` is the literal client program on the page; both printed
|
||||
lines match the page's comments."""
|
||||
await tutorial003.main()
|
||||
out = capsys.readouterr().out
|
||||
assert "{'com.example/stamps': {'sealed': True}}" in out
|
||||
assert "[stamped] hello" in out
|
||||
|
||||
|
||||
async def test_the_search_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""tutorial004: `main()` declares the extension and gets the vendor method's result."""
|
||||
await tutorial004.main()
|
||||
assert "['mcp-0', 'mcp-1', 'mcp-2']" in capsys.readouterr().out
|
||||
|
||||
|
||||
async def test_vendor_method_rejects_a_non_declaring_client_with_32021() -> None:
|
||||
"""tutorial004: `require_client_extension` answers a non-declaring client with `-32021`
|
||||
and the machine-readable `requiredCapabilities` payload."""
|
||||
async with Client(tutorial004.mcp) as client:
|
||||
request = tutorial004.SearchRequest(params=tutorial004.SearchParams(query="mcp"))
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.session.send_request(request, tutorial004.SearchResult)
|
||||
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
|
||||
assert exc_info.value.error.data == {"requiredCapabilities": {"extensions": {"com.example/search": {}}}}
|
||||
|
||||
|
||||
async def test_version_pinned_method_is_not_found_on_a_legacy_connection() -> None:
|
||||
"""tutorial004: `protocol_versions={"2026-07-28"}` makes the method METHOD_NOT_FOUND
|
||||
at any other wire version; for a legacy client it doesn't exist."""
|
||||
async with Client(tutorial004.mcp, mode="legacy", extensions=[advertise(tutorial004.EXTENSION_ID)]) as client:
|
||||
request = tutorial004.SearchRequest(params=tutorial004.SearchParams(query="mcp"))
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.session.send_request(request, tutorial004.SearchResult)
|
||||
assert exc_info.value.code == METHOD_NOT_FOUND
|
||||
|
||||
|
||||
async def test_interceptor_observes_the_call_and_passes_the_result_through(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""tutorial005: the interceptor logs the tool name and returns `call_next`'s result unchanged."""
|
||||
with caplog.at_level(logging.INFO, logger=tutorial005.logger.name):
|
||||
async with Client(tutorial005.mcp) as client:
|
||||
result = await client.call_tool("add", {"a": 2, "b": 3})
|
||||
assert result.structured_content == {"result": 5}
|
||||
messages = [record.getMessage() for record in caplog.records if record.name == tutorial005.logger.name]
|
||||
assert messages == ["tool 'add' called"]
|
||||
|
||||
|
||||
async def test_the_receipts_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""tutorial006: `main()` runs as printed and the output is the redeemed result, never the claimed shape."""
|
||||
await tutorial006.main()
|
||||
assert "goods for r-117" in capsys.readouterr().out
|
||||
|
||||
|
||||
async def test_a_client_without_the_extension_is_refused_by_the_gate() -> None:
|
||||
"""The page's off-by-default claim: the server's capability gate refuses a non-declaring client."""
|
||||
async with Client(tutorial006.mcp) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("buy", {"item": "lamp"})
|
||||
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
|
||||
|
||||
|
||||
async def test_session_tier_allow_claimed_returns_the_raw_shape() -> None:
|
||||
"""The page's escape hatch: `allow_claimed=True` returns the parsed claim model, not the resolved result."""
|
||||
async with Client(tutorial006.mcp, extensions=[tutorial006.Receipts()]) as client:
|
||||
result = await client.session.call_tool("buy", {"item": "lamp"}, allow_claimed=True)
|
||||
assert isinstance(result, tutorial006.ReceiptResult)
|
||||
assert result.receipt_token == "r-117"
|
||||
|
||||
|
||||
async def test_the_jobs_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""tutorial007: a vendor request with `name_param` round-trips `send_request` with no registration."""
|
||||
await tutorial007.main()
|
||||
assert "job-7 is running" in capsys.readouterr().out
|
||||
@@ -0,0 +1,98 @@
|
||||
"""`docs/get-started/first-steps.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import (
|
||||
PromptArgument,
|
||||
PromptMessage,
|
||||
TextContent,
|
||||
TextResourceContents,
|
||||
)
|
||||
|
||||
from docs_src.first_steps import tutorial001
|
||||
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_each_decorator_registers_one_primitive() -> None:
|
||||
"""tutorial001: name, description and schema all come from the decorated function."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.name == "add"
|
||||
assert tool.description == "Add two numbers."
|
||||
assert tool.input_schema == snapshot(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"a": {"title": "A", "type": "integer"},
|
||||
"b": {"title": "B", "type": "integer"},
|
||||
},
|
||||
"required": ["a", "b"],
|
||||
"title": "addArguments",
|
||||
}
|
||||
)
|
||||
|
||||
(template,) = (await client.list_resource_templates()).resource_templates
|
||||
assert template.name == "greeting"
|
||||
assert template.uri_template == "greeting://{name}"
|
||||
assert template.description == "Greet someone by name."
|
||||
|
||||
(prompt,) = (await client.list_prompts()).prompts
|
||||
assert prompt.name == "summarize"
|
||||
assert prompt.description == "Summarize a piece of text in one sentence."
|
||||
assert prompt.arguments == [PromptArgument(name="text", required=True)]
|
||||
|
||||
|
||||
async def test_call_the_tool() -> None:
|
||||
"""tutorial001: the Inspector walkthrough. `add` with 1 and 2 answers 3."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.call_tool("add", {"a": 1, "b": 2})
|
||||
assert not result.is_error
|
||||
assert result.content == [TextContent(type="text", text="3")]
|
||||
assert result.structured_content == {"result": 3}
|
||||
|
||||
|
||||
async def test_templated_resource_is_a_template_not_a_resource() -> None:
|
||||
"""tutorial001: a `{param}` in the URI means the concrete-resource list stays empty."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
assert (await client.list_resources()).resources == []
|
||||
|
||||
|
||||
async def test_read_the_resource_template() -> None:
|
||||
"""tutorial001: supplying a `name` reads the template as a concrete resource."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.read_resource("greeting://World")
|
||||
assert result.contents == [
|
||||
TextResourceContents(uri="greeting://World", mime_type="text/plain", text="Hello, World!")
|
||||
]
|
||||
|
||||
|
||||
async def test_get_the_prompt() -> None:
|
||||
"""tutorial001: the returned string becomes a single user message."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.get_prompt("summarize", {"text": "MCP is a protocol."})
|
||||
rendered = "Summarize the following text in one sentence:\n\nMCP is a protocol."
|
||||
assert result.messages == [PromptMessage(role="user", content=TextContent(type="text", text=rendered))]
|
||||
|
||||
|
||||
async def test_the_three_primitive_capabilities_are_always_declared() -> None:
|
||||
"""tutorial001: `MCPServer` always declares tools/resources/prompts; only `completions` follows your code.
|
||||
|
||||
An `MCPServer` with nothing registered declares the same three, which is why the
|
||||
page ties registration to the *optional* capabilities only.
|
||||
"""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
declared = client.server_capabilities
|
||||
# The exact dictionary the page prints from `model_dump(exclude_none=True)`.
|
||||
assert declared.model_dump(exclude_none=True) == snapshot(
|
||||
{
|
||||
"prompts": {"list_changed": True},
|
||||
"resources": {"subscribe": True, "list_changed": True},
|
||||
"tools": {"list_changed": True},
|
||||
}
|
||||
)
|
||||
async with Client(MCPServer("Empty")) as client:
|
||||
assert client.server_capabilities == declared
|
||||
@@ -0,0 +1,86 @@
|
||||
"""`docs/servers/handling-errors.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import pytest
|
||||
from mcp_types import INVALID_PARAMS, ErrorData, TextContent, TextResourceContents
|
||||
|
||||
from docs_src.handling_errors import tutorial001, tutorial002, tutorial003
|
||||
from mcp import Client, MCPError
|
||||
|
||||
# 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_a_plain_exception_becomes_a_tool_error_the_model_reads() -> None:
|
||||
"""tutorial001: any non-`MCPError` exception comes back as `is_error=True` with the message in `content`."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.call_tool("get_author", {"title": "Nothing"})
|
||||
assert result.is_error
|
||||
assert result.content == [
|
||||
TextContent(type="text", text="Error executing tool get_author: No book titled 'Nothing' in the catalog.")
|
||||
]
|
||||
assert result.structured_content is None
|
||||
|
||||
|
||||
async def test_a_title_the_catalog_knows_is_an_ordinary_result() -> None:
|
||||
"""tutorial001: the non-raising path is a plain `is_error=False` result."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.call_tool("get_author", {"title": "Dune"})
|
||||
assert not result.is_error
|
||||
assert result.structured_content == {"result": "Frank Herbert"}
|
||||
|
||||
|
||||
async def test_a_bad_argument_never_reaches_the_function() -> None:
|
||||
"""tutorial001: schema validation rejects the call before `get_author` runs, as the same kind of tool error."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.call_tool("get_author", {"title": 42})
|
||||
assert result.is_error
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert "Input should be a valid string" in result.content[0].text
|
||||
|
||||
|
||||
async def test_mcp_error_makes_the_call_itself_fail() -> None:
|
||||
"""tutorial002: `MCPError` is not caught. It surfaces as a JSON-RPC error, with `code` and `message` intact."""
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("get_author", {"title": "Nothing"})
|
||||
assert exc_info.value.code == INVALID_PARAMS
|
||||
assert exc_info.value.message == "No book titled 'Nothing' in the catalog."
|
||||
|
||||
|
||||
async def test_mcp_error_only_fires_on_the_raising_path() -> None:
|
||||
"""tutorial002: a title the catalog knows still returns a normal result."""
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
result = await client.call_tool("get_author", {"title": "Dune"})
|
||||
assert not result.is_error
|
||||
assert result.structured_content == {"result": "Frank Herbert"}
|
||||
|
||||
|
||||
async def test_resource_not_found_error_maps_to_invalid_params() -> None:
|
||||
"""tutorial003: `ResourceNotFoundError` from a template handler is `-32602` with the URI in `data`."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.read_resource("books://Nothing")
|
||||
assert exc_info.value.error == ErrorData(
|
||||
code=INVALID_PARAMS,
|
||||
message="No book titled 'Nothing' in the catalog.",
|
||||
data={"uri": "books://Nothing"},
|
||||
)
|
||||
|
||||
|
||||
async def test_raise_exceptions_does_not_turn_a_tool_error_into_a_traceback() -> None:
|
||||
"""The closing `!!! info`: even `raise_exceptions=True` leaves a failing tool as the `is_error=True` result."""
|
||||
async with Client(tutorial001.mcp, raise_exceptions=True) as client:
|
||||
result = await client.call_tool("get_author", {"title": "Nothing"})
|
||||
assert result.is_error
|
||||
assert result.content == [
|
||||
TextContent(type="text", text="Error executing tool get_author: No book titled 'Nothing' in the catalog.")
|
||||
]
|
||||
|
||||
|
||||
async def test_a_title_the_template_knows_reads_normally() -> None:
|
||||
"""tutorial003: the non-raising path resolves the template and returns text contents."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
result = await client.read_resource("books://Dune")
|
||||
(contents,) = result.contents
|
||||
assert isinstance(contents, TextResourceContents)
|
||||
assert contents.text == "Dune by Frank Herbert"
|
||||
@@ -0,0 +1,196 @@
|
||||
"""`docs/client/identity-assertion.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import inspect
|
||||
from urllib.parse import parse_qsl
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.applications import Starlette
|
||||
|
||||
from docs_src.identity_assertion import tutorial001, tutorial002
|
||||
from docs_src.oauth_clients import tutorial001 as oauth_clients_tutorial001
|
||||
from mcp import Client
|
||||
from mcp.client.auth import OAuthClientProvider
|
||||
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.server import MCPServer
|
||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||
from mcp.server.auth.provider import IdentityAssertionParams, ProviderTokenVerifier, TokenError
|
||||
from mcp.server.auth.settings import AuthSettings
|
||||
|
||||
# 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")]
|
||||
|
||||
MCP_SERVER_URL = "http://localhost:8001/mcp"
|
||||
|
||||
|
||||
class RecordingASGITransport(httpx.ASGITransport):
|
||||
"""An `httpx.ASGITransport` that appends every (method, path, body) it carries to a shared log."""
|
||||
|
||||
def __init__(self, app: Starlette, log: list[tuple[str, str, bytes]]) -> None:
|
||||
super().__init__(app=app)
|
||||
self.log = log
|
||||
|
||||
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
||||
self.log.append((request.method, request.url.path, request.content))
|
||||
return await super().handle_async_request(request)
|
||||
|
||||
|
||||
async def test_the_provider_is_an_httpx_auth_but_not_an_oauth_client_provider() -> None:
|
||||
"""tutorial001: same `auth=` slot as the rest of OAuth clients, but nothing is discovered or registered."""
|
||||
assert isinstance(tutorial001.oauth, httpx.Auth)
|
||||
assert not isinstance(tutorial001.oauth, OAuthClientProvider)
|
||||
|
||||
|
||||
async def test_main_is_the_main_from_the_oauth_clients_page() -> None:
|
||||
"""The page says `main()` is unchanged to the character from the OAuth clients page."""
|
||||
assert inspect.getsource(tutorial001.main) == inspect.getsource(oauth_clients_tutorial001.main)
|
||||
|
||||
|
||||
async def test_a_client_secret_is_required() -> None:
|
||||
"""tutorial001: the provider refuses to be constructed as a public client."""
|
||||
with pytest.raises(ValueError, match="client_secret is required"):
|
||||
IdentityAssertionOAuthProvider(
|
||||
server_url=MCP_SERVER_URL,
|
||||
storage=tutorial001.InMemoryTokenStorage(),
|
||||
client_id="finance-agent",
|
||||
client_secret="",
|
||||
issuer=tutorial002.ISSUER,
|
||||
assertion_provider=tutorial001.fetch_id_jag,
|
||||
)
|
||||
|
||||
|
||||
async def test_an_issuer_is_required() -> None:
|
||||
"""tutorial001: the authorization server is configuration, not discovery."""
|
||||
with pytest.raises(ValueError, match="issuer is required"):
|
||||
IdentityAssertionOAuthProvider(
|
||||
server_url=MCP_SERVER_URL,
|
||||
storage=tutorial001.InMemoryTokenStorage(),
|
||||
client_id="finance-agent",
|
||||
client_secret="finance-agent-secret",
|
||||
issuer="",
|
||||
assertion_provider=tutorial001.fetch_id_jag,
|
||||
)
|
||||
|
||||
|
||||
async def test_the_id_jag_is_a_typed_jwt_carrying_the_claims_the_page_lists() -> None:
|
||||
"""tutorial001: the stand-in IdP signs a real ID-JAG; its header `typ` and claim set are the extension's."""
|
||||
assertion = tutorial001.idp_issue_id_jag("alice@example.com", tutorial002.ISSUER, MCP_SERVER_URL)
|
||||
assert jwt.get_unverified_header(assertion)["typ"] == "oauth-id-jag+jwt"
|
||||
claims = jwt.decode(assertion, tutorial001.IDP_SIGNING_KEY, algorithms=["HS256"], audience=tutorial002.ISSUER)
|
||||
assert list(claims) == snapshot(["iss", "sub", "aud", "client_id", "resource", "scope", "jti", "iat", "exp"])
|
||||
assert claims["client_id"] == "finance-agent"
|
||||
assert claims["resource"] == MCP_SERVER_URL
|
||||
|
||||
|
||||
async def test_a_forged_assertion_is_rejected() -> None:
|
||||
"""tutorial002: the signature check fails closed with `invalid_grant`."""
|
||||
client = tutorial002.REGISTERED_CLIENTS["finance-agent"]
|
||||
with pytest.raises(TokenError) as exc_info:
|
||||
await tutorial002.provider.exchange_identity_assertion(
|
||||
client, IdentityAssertionParams(assertion="not-an-id-jag")
|
||||
)
|
||||
assert exc_info.value.error == "invalid_grant"
|
||||
assert exc_info.value.error_description == "the assertion did not verify"
|
||||
|
||||
|
||||
async def test_an_assertion_for_another_audience_is_rejected() -> None:
|
||||
"""tutorial002: an ID-JAG whose `aud` is not this authorization server is `invalid_grant`."""
|
||||
client = tutorial002.REGISTERED_CLIENTS["finance-agent"]
|
||||
assertion = tutorial001.idp_issue_id_jag("alice@example.com", "https://other.example.com/", MCP_SERVER_URL)
|
||||
with pytest.raises(TokenError) as exc_info:
|
||||
await tutorial002.provider.exchange_identity_assertion(client, IdentityAssertionParams(assertion=assertion))
|
||||
assert exc_info.value.error == "invalid_grant"
|
||||
assert exc_info.value.error_description == "the assertion did not verify"
|
||||
|
||||
|
||||
async def test_an_assertion_for_an_unknown_resource_is_rejected() -> None:
|
||||
"""tutorial002: an ID-JAG naming a resource this server does not serve is `invalid_target`."""
|
||||
client = tutorial002.REGISTERED_CLIENTS["finance-agent"]
|
||||
assertion = tutorial001.idp_issue_id_jag("alice@example.com", tutorial002.ISSUER, "https://other.example.com/mcp")
|
||||
with pytest.raises(TokenError) as exc_info:
|
||||
await tutorial002.provider.exchange_identity_assertion(client, IdentityAssertionParams(assertion=assertion))
|
||||
assert exc_info.value.error == "invalid_target"
|
||||
assert exc_info.value.error_description == "the assertion is for a resource this server does not serve"
|
||||
|
||||
|
||||
async def test_a_replayed_assertion_is_rejected() -> None:
|
||||
"""tutorial002: `jti` is tracked, so presenting the same ID-JAG twice fails the second time."""
|
||||
client = tutorial002.REGISTERED_CLIENTS["finance-agent"]
|
||||
assertion = tutorial001.idp_issue_id_jag("alice@example.com", tutorial002.ISSUER, MCP_SERVER_URL)
|
||||
params = IdentityAssertionParams(assertion=assertion)
|
||||
first = await tutorial002.provider.exchange_identity_assertion(client, params)
|
||||
assert first.token_type == "Bearer"
|
||||
with pytest.raises(TokenError) as exc_info:
|
||||
await tutorial002.provider.exchange_identity_assertion(client, params)
|
||||
assert exc_info.value.error == "invalid_grant"
|
||||
assert exc_info.value.error_description == "the assertion has already been used"
|
||||
|
||||
|
||||
async def test_the_metadata_advertises_the_grant_type_and_the_id_jag_profile() -> None:
|
||||
"""tutorial002: the flag turns on both the `jwt-bearer` grant type and the grant-profile advertisement."""
|
||||
transport = httpx.ASGITransport(app=tutorial002.auth_app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://auth.example.com") as http_client:
|
||||
response = await http_client.get("/.well-known/oauth-authorization-server")
|
||||
assert response.status_code == 200
|
||||
metadata = response.json()
|
||||
assert metadata["issuer"] == "https://auth.example.com/"
|
||||
assert "urn:ietf:params:oauth:grant-type:jwt-bearer" in metadata["grant_types_supported"]
|
||||
assert metadata["authorization_grant_profiles_supported"] == ["urn:ietf:params:oauth:grant-profile:id-jag"]
|
||||
|
||||
|
||||
async def test_the_whole_grant_is_one_token_request() -> None:
|
||||
"""The `!!! check`: a 401, the well-known fetch, one `POST /token`, the retry; the subject reaches the tool."""
|
||||
mcp = MCPServer(
|
||||
"Notes",
|
||||
token_verifier=ProviderTokenVerifier(tutorial002.provider),
|
||||
auth=AuthSettings(
|
||||
issuer_url=AnyHttpUrl(tutorial002.ISSUER),
|
||||
resource_server_url=AnyHttpUrl(MCP_SERVER_URL),
|
||||
required_scopes=["notes:read"],
|
||||
),
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
def whoami() -> str:
|
||||
"""Report which end user the ID-JAG named."""
|
||||
token = get_access_token()
|
||||
assert token is not None
|
||||
assert token.subject is not None
|
||||
return f"{token.subject} ({', '.join(token.scopes)})"
|
||||
|
||||
log: list[tuple[str, str, bytes]] = []
|
||||
transport = RecordingASGITransport(mcp.streamable_http_app(), log)
|
||||
mounts = {"https://auth.example.com": RecordingASGITransport(tutorial002.auth_app, log)}
|
||||
async with mcp.session_manager.run():
|
||||
async with (
|
||||
httpx.AsyncClient(auth=tutorial001.oauth, transport=transport, mounts=mounts) as http_client,
|
||||
Client(streamable_http_client(MCP_SERVER_URL, http_client=http_client)) as client,
|
||||
):
|
||||
result = await client.call_tool("whoami", {})
|
||||
assert result.structured_content == {"result": "alice@example.com (notes:read)"}
|
||||
|
||||
assert [(method, path) for method, path, _ in log] == snapshot(
|
||||
[
|
||||
("POST", "/mcp"),
|
||||
("GET", "/.well-known/oauth-authorization-server"),
|
||||
("POST", "/token"),
|
||||
("POST", "/mcp"),
|
||||
("POST", "/mcp"),
|
||||
("POST", "/mcp"),
|
||||
]
|
||||
)
|
||||
token_request = dict(parse_qsl(log[2][2].decode()))
|
||||
assert sorted(token_request) == snapshot(
|
||||
["assertion", "client_id", "client_secret", "grant_type", "resource", "scope"]
|
||||
)
|
||||
assert token_request["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer"
|
||||
assert token_request["client_id"] == "finance-agent"
|
||||
assert token_request["resource"] == MCP_SERVER_URL
|
||||
assert token_request["scope"] == "notes:read"
|
||||
assert jwt.get_unverified_header(token_request["assertion"]) == snapshot(
|
||||
{"alg": "HS256", "typ": "oauth-id-jag+jwt"}
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
"""`docs/index.md`: the landing-page server does exactly what the page says it does."""
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import CallToolResult, TextContent, TextResourceContents
|
||||
|
||||
from docs_src.index.tutorial001 import mcp
|
||||
from mcp import Client
|
||||
|
||||
# `pyproject.toml` globally downgrades `mcp.MCPDeprecationWarning` to *ignore* because the
|
||||
# SDK still calls those methods internally. A documentation example must never lean on
|
||||
# that allowance, so every test that runs one re-arms the warning as an error. This is a
|
||||
# per-module mark, not a conftest hook, because `pytest_collection_modifyitems` receives
|
||||
# every item in the session. A hook here would break unrelated tests across the repo.
|
||||
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
|
||||
|
||||
|
||||
async def test_add_tool() -> None:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.call_tool("add", {"a": 1, "b": 2})
|
||||
assert result == snapshot(
|
||||
CallToolResult(content=[TextContent(type="text", text="3")], structured_content={"result": 3})
|
||||
)
|
||||
|
||||
|
||||
async def test_greeting_resource_template() -> None:
|
||||
async with Client(mcp) as client:
|
||||
result = await client.read_resource("greeting://World")
|
||||
assert result.contents == snapshot(
|
||||
[TextResourceContents(uri="greeting://World", mime_type="text/plain", text="Hello, World!")]
|
||||
)
|
||||
@@ -0,0 +1,136 @@
|
||||
"""`docs/run/legacy-clients.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import inspect
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from mcp_types import INVALID_REQUEST, ResourceUpdatedNotification, TextContent
|
||||
|
||||
from docs_src.legacy_clients import tutorial001, tutorial002, tutorial003
|
||||
from mcp import Client, MCPError
|
||||
from mcp.client.streamable_http import streamable_http_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")]
|
||||
|
||||
INITIALIZE = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "b", "version": "1"}},
|
||||
}
|
||||
LIST_TOOLS = {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}
|
||||
MCP_HEADERS = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}
|
||||
URL = "http://localhost:8000/mcp"
|
||||
|
||||
|
||||
async def test_one_resolve_tool_serves_a_legacy_and_a_modern_client_at_once(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""tutorial001's `main()`, exactly as the page renders it: two eras of client, one server, one answer."""
|
||||
await tutorial001.main()
|
||||
assert capsys.readouterr().out == (
|
||||
"""2025-11-25 {'result': "Reserved 2 of 'Dune'."}\n2026-07-28 {'result': "Reserved 2 of 'Dune'."}\n"""
|
||||
)
|
||||
|
||||
|
||||
async def test_neither_era_of_client_sees_the_resolved_parameter() -> None:
|
||||
"""tutorial001: there is one tool schema. The `Resolve`-filled parameter is hidden from both eras."""
|
||||
async with Client(tutorial001.mcp, mode="legacy") as legacy, Client(tutorial001.mcp) as modern:
|
||||
for client in (legacy, modern):
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert set(tool.input_schema["properties"]) == {"title"}
|
||||
|
||||
|
||||
def test_streamable_http_app_has_no_era_knob() -> None:
|
||||
"""The opener: nothing in `streamable_http_app()`'s signature selects, rejects, or configures an era."""
|
||||
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_legacy_session_is_minted_in_process_and_a_stray_session_id_is_a_404() -> None:
|
||||
"""The cost section: a legacy `initialize` gets an `Mcp-Session-Id`, and a request naming a session
|
||||
this process never minted gets a `404`. That miss is exactly what a load balancer without sticky
|
||||
routing produces."""
|
||||
app = MCPServer("Bookshop").streamable_http_app()
|
||||
async with (
|
||||
app.router.lifespan_context(app),
|
||||
httpx.ASGITransport(app) as transport,
|
||||
httpx.AsyncClient(transport=transport, base_url="http://localhost:8000") as http,
|
||||
):
|
||||
opened = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
|
||||
assert opened.status_code == 200
|
||||
assert opened.headers["mcp-session-id"]
|
||||
|
||||
stray = await http.post("/mcp", json=LIST_TOOLS, headers={**MCP_HEADERS, "Mcp-Session-Id": 32 * "f"})
|
||||
assert stray.status_code == 404
|
||||
|
||||
|
||||
async def test_stateless_http_never_mints_a_session() -> None:
|
||||
"""The `stateless_http=True` section: the same legacy `initialize` no longer gets an `Mcp-Session-Id`."""
|
||||
app = MCPServer("Bookshop").streamable_http_app(stateless_http=True)
|
||||
async with (
|
||||
app.router.lifespan_context(app),
|
||||
httpx.ASGITransport(app) as transport,
|
||||
httpx.AsyncClient(transport=transport, base_url="http://localhost:8000") as http,
|
||||
):
|
||||
opened = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
|
||||
assert opened.status_code == 200
|
||||
assert "mcp-session-id" not in opened.headers
|
||||
|
||||
|
||||
async def test_stateless_http_kills_the_legacy_back_channel_and_only_the_legacy_one() -> None:
|
||||
"""tutorial002: over the same `stateless_http=True` app, the modern client still gets its answer and
|
||||
the legacy client's call fails as the top-level `MCPError` the `!!! check` quotes."""
|
||||
async with (
|
||||
tutorial002.app.router.lifespan_context(tutorial002.app),
|
||||
httpx.ASGITransport(tutorial002.app) as transport,
|
||||
httpx.AsyncClient(transport=transport) as http,
|
||||
):
|
||||
modern_target = streamable_http_client(URL, http_client=http)
|
||||
async with Client(modern_target, elicitation_callback=tutorial001.answer) as modern:
|
||||
assert modern.protocol_version == "2026-07-28"
|
||||
result = await modern.call_tool("reserve", {"title": "Dune"})
|
||||
assert result.content == [TextContent(type="text", text="Reserved 2 of 'Dune'.")]
|
||||
|
||||
legacy_target = streamable_http_client(URL, http_client=http)
|
||||
async with Client(legacy_target, mode="legacy", elicitation_callback=tutorial001.answer) as legacy:
|
||||
assert legacy.protocol_version == "2025-11-25"
|
||||
with pytest.raises(MCPError) as exc_info: # pragma: no branch
|
||||
await legacy.call_tool("reserve", {"title": "Dune"})
|
||||
assert exc_info.value.error.code == INVALID_REQUEST
|
||||
assert exc_info.value.error.message == (
|
||||
"Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests."
|
||||
)
|
||||
|
||||
|
||||
async def test_the_legacy_notification_verb_reaches_a_legacy_client() -> None:
|
||||
"""tutorial003: `ctx.session.send_resource_updated` lands on the legacy client's standalone stream."""
|
||||
received: list[object] = []
|
||||
|
||||
async def on_message(message: object) -> None:
|
||||
received.append(message)
|
||||
|
||||
async with Client(tutorial003.mcp, mode="legacy", message_handler=on_message) as client:
|
||||
result = await client.call_tool("restock", {"title": "Dune", "copies": 2})
|
||||
assert not result.is_error
|
||||
(notification,) = received
|
||||
assert isinstance(notification, ResourceUpdatedNotification)
|
||||
assert notification.params.uri == "stock://Dune"
|
||||
|
||||
|
||||
async def test_calling_both_notification_verbs_is_safe_on_both_eras() -> None:
|
||||
"""tutorial003: the two-line fork never errors, whichever era the caller is on."""
|
||||
async with Client(tutorial003.mcp, mode="legacy") as legacy, Client(tutorial003.mcp) as modern:
|
||||
for client in (legacy, modern):
|
||||
result = await client.call_tool("restock", {"title": "Dune", "copies": 1})
|
||||
assert not result.is_error
|
||||
@@ -0,0 +1,113 @@
|
||||
"""`docs/handlers/lifespan.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import TextContent, TextResourceContents
|
||||
|
||||
from docs_src.lifespan import tutorial001, tutorial002
|
||||
from mcp import Client, MCPError
|
||||
from mcp.server import MCPServer
|
||||
from mcp.server.mcpserver import Context
|
||||
|
||||
# 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_lifespan_object_reaches_the_tool() -> None:
|
||||
"""tutorial001: the object the lifespan yields is `ctx.request_context.lifespan_context`."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.call_tool("count_books", {"genre": "poetry"})
|
||||
assert not result.is_error
|
||||
assert result.content == [TextContent(type="text", text="3 books in 'poetry'.")]
|
||||
assert result.structured_content == {"result": "3 books in 'poetry'."}
|
||||
|
||||
|
||||
async def test_context_parameter_never_reaches_the_input_schema() -> None:
|
||||
"""tutorial001: `ctx` is injected by the SDK, so `genre` is the only argument the model sees."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.input_schema == snapshot(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"genre": {"title": "Genre", "type": "string"}},
|
||||
"required": ["genre"],
|
||||
"title": "count_booksArguments",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def test_startup_runs_before_the_first_request_and_shutdown_after_the_last() -> None:
|
||||
"""tutorial002: `connect()` runs at startup, the `finally` runs `disconnect()` at shutdown."""
|
||||
assert not tutorial002.database.connected
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
assert tutorial002.database.connected
|
||||
result = await client.call_tool("database_status", {})
|
||||
assert result.structured_content == {"result": "connected"}
|
||||
assert not tutorial002.database.connected
|
||||
|
||||
|
||||
async def test_bare_context_reaches_the_lifespan_object_in_resources_and_prompts() -> None:
|
||||
"""A resource or prompt declaring a bare `ctx: Context` gets the same lifespan object a tool gets."""
|
||||
mcp = MCPServer("Bookshop", lifespan=tutorial001.app_lifespan)
|
||||
|
||||
@mcp.resource("books://{genre}/count")
|
||||
def genre_count(genre: str, ctx: Context) -> str:
|
||||
"""Count the books in a genre."""
|
||||
app = ctx.request_context.lifespan_context
|
||||
assert isinstance(app, tutorial001.AppContext)
|
||||
return f"{app.db.query()} books in {genre!r}."
|
||||
|
||||
@mcp.prompt()
|
||||
def stock_report(ctx: Context) -> str:
|
||||
"""Ask for a stock report."""
|
||||
app = ctx.request_context.lifespan_context
|
||||
assert isinstance(app, tutorial001.AppContext)
|
||||
return f"Summarise a shelf of {app.db.query()} books."
|
||||
|
||||
async with Client(mcp) as client:
|
||||
resource = await client.read_resource("books://poetry/count")
|
||||
assert resource.contents == [
|
||||
TextResourceContents(uri="books://poetry/count", mime_type="text/plain", text="3 books in 'poetry'.")
|
||||
]
|
||||
prompt = await client.get_prompt("stock_report")
|
||||
(message,) = prompt.messages
|
||||
assert message.content == TextContent(type="text", text="Summarise a shelf of 3 books.")
|
||||
|
||||
|
||||
async def test_parameterized_context_is_tool_only(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""`Context[AppContext]` on a resource or prompt fails every call; the server logs the `ValueError`."""
|
||||
mcp = MCPServer("Bookshop", lifespan=tutorial001.app_lifespan)
|
||||
|
||||
@mcp.resource("books://{genre}/count")
|
||||
def genre_count(genre: str, ctx: Context[tutorial001.AppContext]) -> str:
|
||||
"""Count the books in a genre."""
|
||||
return f"{ctx.request_context.lifespan_context.db.query()} books in {genre!r}."
|
||||
|
||||
@mcp.prompt()
|
||||
def stock_report(ctx: Context[tutorial001.AppContext]) -> str:
|
||||
"""Ask for a stock report."""
|
||||
return f"Summarise a shelf of {ctx.request_context.lifespan_context.db.query()} books."
|
||||
|
||||
async with Client(mcp) as client:
|
||||
with pytest.raises(MCPError, match="Error creating resource from template"):
|
||||
await client.read_resource("books://poetry/count")
|
||||
assert "ValueError: Context is not available outside of a request" in caplog.text
|
||||
|
||||
caplog.clear()
|
||||
with pytest.raises(MCPError):
|
||||
await client.get_prompt("stock_report")
|
||||
assert "ValueError: Context is not available outside of a request" in caplog.text
|
||||
|
||||
|
||||
async def test_default_lifespan_yields_an_empty_dict() -> None:
|
||||
"""No `lifespan=`: the SDK's default yields `{}`, so `lifespan_context` is never `None`."""
|
||||
bare = MCPServer("Bare")
|
||||
|
||||
@bare.tool()
|
||||
def show(ctx: Context) -> str:
|
||||
"""Show the lifespan context."""
|
||||
return repr(ctx.request_context.lifespan_context)
|
||||
|
||||
async with Client(bare) as client:
|
||||
result = await client.call_tool("show", {})
|
||||
assert result.structured_content == {"result": "{}"}
|
||||
@@ -0,0 +1,62 @@
|
||||
"""`docs/handlers/logging.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import CallToolResult, TextContent
|
||||
|
||||
from docs_src.logging import tutorial001
|
||||
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_the_tool_logs_through_the_standard_library(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""tutorial001: `logger.info(...)` inside a tool emits an ordinary stdlib record named after the module."""
|
||||
caplog.set_level(logging.INFO)
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
await client.call_tool("search_books", {"query": "dune"})
|
||||
(record,) = list(filter(lambda r: r.name == tutorial001.logger.name, caplog.records))
|
||||
assert record.levelname == "INFO"
|
||||
assert record.getMessage() == "Searching for 'dune'"
|
||||
|
||||
|
||||
async def test_the_log_line_never_reaches_the_client() -> None:
|
||||
"""tutorial001: the result is only the return value. Log output is invisible to the model."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.call_tool("search_books", {"query": "dune"})
|
||||
assert result == snapshot(
|
||||
CallToolResult(
|
||||
content=[TextContent(type="text", text="Found 3 books matching 'dune'.")],
|
||||
structured_content={"result": "Found 3 books matching 'dune'."},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_log_level_configures_the_root_logger() -> None:
|
||||
"""`MCPServer(log_level=...)` calls `logging.basicConfig()` when nothing has configured logging yet."""
|
||||
root = logging.getLogger()
|
||||
handlers, level = root.handlers[:], root.level
|
||||
root.handlers = []
|
||||
try:
|
||||
MCPServer("Bookshop", log_level="DEBUG")
|
||||
assert root.level == logging.DEBUG
|
||||
assert len(root.handlers) == 1
|
||||
finally:
|
||||
root.handlers, root.level = handlers, level
|
||||
|
||||
|
||||
def test_an_existing_logging_configuration_wins() -> None:
|
||||
"""`logging.basicConfig()` is a no-op once a handler is installed, so your own setup is not overridden."""
|
||||
root = logging.getLogger()
|
||||
handlers, level = root.handlers[:], root.level
|
||||
root.handlers, root.level = [logging.NullHandler()], logging.WARNING
|
||||
try:
|
||||
MCPServer("Bookshop", log_level="DEBUG")
|
||||
assert root.level == logging.WARNING
|
||||
assert len(root.handlers) == 1
|
||||
finally:
|
||||
root.handlers, root.level = handlers, level
|
||||
@@ -0,0 +1,143 @@
|
||||
"""`docs/advanced/low-level-server.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import INTERNAL_ERROR, CallToolRequestParams, CallToolResult, ErrorData, RequestParams, TextContent
|
||||
|
||||
from docs_src.lowlevel import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006
|
||||
from mcp import Client, MCPError
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
|
||||
# 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_the_input_schema_on_the_wire_is_the_dict_you_wrote() -> None:
|
||||
"""tutorial001: nothing is derived. `tools/list` returns the literal `input_schema` dict."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.name == "search_books"
|
||||
assert tool.description == "Search the catalog by title or author."
|
||||
assert tool.input_schema == snapshot(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}, "limit": {"type": "integer"}},
|
||||
"required": ["query", "limit"],
|
||||
}
|
||||
)
|
||||
assert tool.output_schema is None
|
||||
|
||||
|
||||
async def test_the_client_does_not_care_which_server_class_it_connects_to() -> None:
|
||||
"""tutorial001: `Client(server)` accepts a low-level `Server` and the call answers like **Tools**."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
result = await client.call_tool("search_books", {"query": "dune", "limit": 5})
|
||||
assert not result.is_error
|
||||
assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune' (showing up to 5).")]
|
||||
assert result.structured_content is None
|
||||
|
||||
|
||||
async def test_only_the_handlers_you_passed_become_capabilities() -> None:
|
||||
"""tutorial001: two tool handlers advertise `tools` and nothing else."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
assert client.server_capabilities.model_dump(exclude_none=True) == snapshot({"tools": {"list_changed": False}})
|
||||
|
||||
|
||||
async def test_arguments_are_not_validated_against_your_schema() -> None:
|
||||
"""tutorial001: a call missing a `required` argument still reaches the handler and blows up there."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("search_books", {"query": "dune"})
|
||||
assert exc_info.value.error == ErrorData(code=INTERNAL_ERROR, message="Internal server error", data=None)
|
||||
|
||||
|
||||
async def test_one_handler_routes_every_tool() -> None:
|
||||
"""tutorial002: `on_call_tool` is the single entry point; it dispatches on `params.name`."""
|
||||
async with Client(tutorial002.server) as client:
|
||||
assert [tool.name for tool in (await client.list_tools()).tools] == ["search_books", "add_book"]
|
||||
result = await client.call_tool("add_book", {"title": "Dune", "author": "Frank Herbert", "year": 1965})
|
||||
assert result.content == [TextContent(type="text", text="Added 'Dune' by Frank Herbert (1965).")]
|
||||
|
||||
|
||||
async def test_an_unknown_tool_name_becomes_a_protocol_error_not_a_tool_error() -> None:
|
||||
"""tutorial002: raising from a handler is a `-32603` JSON-RPC error, never an `is_error` result."""
|
||||
async with Client(tutorial002.server) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("does_not_exist", {})
|
||||
assert exc_info.value.error == ErrorData(code=INTERNAL_ERROR, message="Internal server error", data=None)
|
||||
|
||||
|
||||
async def test_output_schema_and_structured_content_are_both_yours_to_build() -> None:
|
||||
"""tutorial003: you declare the schema on the `Tool` and you build the matching payload."""
|
||||
async with Client(tutorial003.server) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.output_schema == snapshot(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"matches": {"type": "integer"}, "query": {"type": "string"}},
|
||||
"required": ["matches", "query"],
|
||||
}
|
||||
)
|
||||
result = await client.call_tool("search_books", {"query": "dune", "limit": 5})
|
||||
assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune'.")]
|
||||
assert result.structured_content == {"matches": 3, "query": "dune"}
|
||||
|
||||
|
||||
async def test_the_client_checks_the_schema_you_promised() -> None:
|
||||
"""The page's warning: a `structured_content` that violates your `output_schema` fails in `call_tool`."""
|
||||
|
||||
async def promise_breaker(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
|
||||
return CallToolResult(content=[TextContent(type="text", text="oops")], structured_content={"matches": "three"})
|
||||
|
||||
lying = Server("Bookshop", on_list_tools=tutorial003.list_tools, on_call_tool=promise_breaker)
|
||||
async with Client(lying) as client:
|
||||
with pytest.raises(RuntimeError, match="Invalid structured content returned by tool search_books"):
|
||||
await client.call_tool("search_books", {"query": "dune", "limit": 5})
|
||||
|
||||
|
||||
async def test_meta_reaches_the_client_application() -> None:
|
||||
"""tutorial004: `_meta=` on the result comes back as `result.meta` and serialises under `_meta`."""
|
||||
async with Client(tutorial004.server) as client:
|
||||
result = await client.call_tool("search_books", {"query": "dune", "limit": 5})
|
||||
assert result.meta == {"bookshop/record_ids": ["bk_17", "bk_42", "bk_99"]}
|
||||
assert result.model_dump(by_alias=True, exclude_none=True) == snapshot(
|
||||
{
|
||||
"_meta": {"bookshop/record_ids": ["bk_17", "bk_42", "bk_99"]},
|
||||
"content": [{"type": "text", "text": "Found 3 books matching 'dune'."}],
|
||||
"structuredContent": {"matches": 3, "query": "dune"},
|
||||
"isError": False,
|
||||
"resultType": "complete",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def test_the_lifespan_object_reaches_every_handler_with_its_type() -> None:
|
||||
"""tutorial005: what the lifespan yields is `ctx.lifespan_context`, typed by `Server[Catalog]`."""
|
||||
async with Client(tutorial005.server) as client:
|
||||
result = await client.call_tool("search_books", {"query": "dune"})
|
||||
assert result.content == [TextContent(type="text", text="Found 3 books: Dune, Dune Messiah, Children of Dune.")]
|
||||
|
||||
|
||||
async def test_add_request_handler_registers_a_method_the_constructor_does_not_know() -> None:
|
||||
"""tutorial006: the registry holds the handler and the params model it validates against."""
|
||||
entry = tutorial006.server.get_request_handler("bookshop/reindex")
|
||||
assert entry is not None
|
||||
assert entry.params_type is tutorial006.ReindexParams
|
||||
assert tutorial006.server.get_request_handler("bookshop/burn") is None
|
||||
|
||||
|
||||
async def test_a_custom_method_never_changes_the_advertised_capabilities() -> None:
|
||||
"""tutorial006: only the spec's method families map to capabilities. `bookshop/reindex` is invisible."""
|
||||
async with Client(tutorial006.server) as client:
|
||||
assert client.server_capabilities.model_dump(exclude_none=True) == snapshot({"tools": {"list_changed": False}})
|
||||
|
||||
|
||||
def test_initialize_is_reserved() -> None:
|
||||
"""The page's `ValueError`: the handshake belongs to the runner, not to `add_request_handler`."""
|
||||
server = Server("Bookshop")
|
||||
|
||||
async def grab_the_handshake(ctx: ServerRequestContext, params: RequestParams) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
with pytest.raises(ValueError, match="'initialize' is handled by the server runner"):
|
||||
server.add_request_handler("initialize", RequestParams, grab_the_handshake)
|
||||
@@ -0,0 +1,62 @@
|
||||
"""`docs/servers/media.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
from mcp_types import AudioContent, Icon, ImageContent
|
||||
|
||||
from docs_src.media import tutorial001, tutorial002, tutorial003
|
||||
from mcp import Client
|
||||
from mcp.server.mcpserver import Audio, Image
|
||||
|
||||
# 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_image_return_becomes_an_image_content_block() -> None:
|
||||
"""tutorial001: `-> Image` reaches the client as a base64 `ImageContent` block, not text."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.call_tool("logo", {})
|
||||
assert not result.is_error
|
||||
assert result.content == [
|
||||
ImageContent(type="image", data=base64.b64encode(tutorial001.LOGO_PNG).decode(), mime_type="image/png")
|
||||
]
|
||||
|
||||
|
||||
async def test_image_result_has_no_structured_content_and_no_output_schema() -> None:
|
||||
"""tutorial001: media is content for the model, not data for the application."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.output_schema is None
|
||||
result = await client.call_tool("logo", {})
|
||||
assert result.structured_content is None
|
||||
|
||||
|
||||
async def test_audio_return_becomes_an_audio_content_block() -> None:
|
||||
"""tutorial002: `Audio` is the same shape as `Image`."""
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
result = await client.call_tool("chime", {})
|
||||
assert not result.is_error
|
||||
assert result.content == [
|
||||
AudioContent(type="audio", data=base64.b64encode(tutorial002.CHIME_WAV).decode(), mime_type="audio/wav")
|
||||
]
|
||||
assert result.structured_content is None
|
||||
|
||||
|
||||
def test_raw_data_without_a_format_falls_back_to_a_default_mime_type() -> None:
|
||||
"""The `!!! check`: with `data=` there is no suffix to guess from, so `format=` decides."""
|
||||
assert Image(data=b"\x89PNG\r\n\x1a\n", format="png").to_image_content().mime_type == "image/png"
|
||||
assert Image(data=b"\x89PNG\r\n\x1a\n").to_image_content().mime_type == "image/png"
|
||||
assert Audio(data=b"\xff\xfb").to_audio_content().mime_type == "audio/wav"
|
||||
|
||||
|
||||
async def test_icons_are_visible_where_they_were_declared() -> None:
|
||||
"""tutorial003: server icons land on `server_info`, tool icons on the `Tool`, resource icons on the `Resource`."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
assert client.server_info.icons == [
|
||||
Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])
|
||||
]
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.icons == [Icon(src="https://example.com/palette.svg", mime_type="image/svg+xml", sizes=["any"])]
|
||||
(resource,) = (await client.list_resources()).resources
|
||||
assert resource.icons == [Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])]
|
||||
@@ -0,0 +1,116 @@
|
||||
"""`docs/advanced/middleware.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from mcp_types import (
|
||||
INVALID_REQUEST,
|
||||
METHOD_NOT_FOUND,
|
||||
CallToolRequestParams,
|
||||
ErrorData,
|
||||
RequestId,
|
||||
TextContent,
|
||||
)
|
||||
|
||||
from docs_src.middleware import tutorial001
|
||||
from mcp import Client, MCPError
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from mcp.server.context import CallNext, HandlerResult
|
||||
|
||||
# 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")]
|
||||
|
||||
|
||||
def _is_timing_record(record: logging.LogRecord) -> bool:
|
||||
"""A record emitted by tutorial001's `log_timing` middleware (and nothing else caplog caught)."""
|
||||
return record.name == tutorial001.logger.name
|
||||
|
||||
|
||||
def test_timing_record_predicate() -> None:
|
||||
"""The caplog filter keeps the middleware's own records and drops everyone else's."""
|
||||
args = (logging.INFO, __file__, 1, "msg", None, None)
|
||||
assert _is_timing_record(logging.LogRecord(tutorial001.logger.name, *args))
|
||||
assert not _is_timing_record(logging.LogRecord("somebody.elses.logger", *args))
|
||||
|
||||
|
||||
async def test_middleware_observes_every_inbound_message(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""tutorial001: two client calls produce three timed lines. `server/discover` is wrapped too."""
|
||||
with caplog.at_level(logging.INFO, logger=tutorial001.logger.name):
|
||||
async with Client(tutorial001.server) as client:
|
||||
await client.list_tools()
|
||||
await client.call_tool("search_books", {"query": "dune"})
|
||||
messages = [record.getMessage() for record in filter(_is_timing_record, caplog.records)]
|
||||
assert [message.split(" took ")[0] for message in messages] == ["server/discover", "tools/list", "tools/call"]
|
||||
assert re.fullmatch(r"tools/call took \d+\.\d ms", messages[-1])
|
||||
|
||||
|
||||
async def test_the_result_passes_through_unchanged() -> None:
|
||||
"""tutorial001: `log_timing` returns what `call_next` returned, so the client sees the real result."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
result = await client.call_tool("search_books", {"query": "dune"})
|
||||
assert not result.is_error
|
||||
assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune'.")]
|
||||
|
||||
|
||||
async def test_a_notification_has_no_request_id() -> None:
|
||||
"""`ctx.request_id is None` is how middleware tells a notification from a request."""
|
||||
seen: list[tuple[str, RequestId | None]] = []
|
||||
|
||||
async def spy(ctx: ServerRequestContext, call_next: CallNext) -> HandlerResult:
|
||||
seen.append((ctx.method, ctx.request_id))
|
||||
return await call_next(ctx)
|
||||
|
||||
server = Server("Bookshop", on_list_tools=tutorial001.on_list_tools, on_call_tool=tutorial001.on_call_tool)
|
||||
server.middleware.append(spy)
|
||||
async with Client(server, mode="legacy") as client:
|
||||
await client.list_tools()
|
||||
assert seen == [("initialize", 1), ("notifications/initialized", None), ("tools/list", 2)]
|
||||
|
||||
|
||||
async def test_raising_before_call_next_refuses_the_message() -> None:
|
||||
"""A middleware that raises instead of calling `call_next` answers with a JSON-RPC error."""
|
||||
|
||||
async def gate(ctx: ServerRequestContext, call_next: CallNext) -> HandlerResult:
|
||||
if ctx.method == "tools/call":
|
||||
raise MCPError(code=INVALID_REQUEST, message="No calls on Sundays.")
|
||||
return await call_next(ctx)
|
||||
|
||||
server = Server("Bookshop", on_list_tools=tutorial001.on_list_tools, on_call_tool=tutorial001.on_call_tool)
|
||||
server.middleware.append(gate)
|
||||
async with Client(server) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("search_books", {"query": "dune"})
|
||||
assert exc_info.value.error.code == INVALID_REQUEST
|
||||
assert exc_info.value.error.message == "No calls on Sundays."
|
||||
assert len((await client.list_tools()).tools) == 1
|
||||
|
||||
|
||||
async def test_an_unhandled_method_raises_through_the_middleware() -> None:
|
||||
"""A method without a handler raises `METHOD_NOT_FOUND` out of `call_next`, through the middleware."""
|
||||
seen: list[tuple[str, int]] = []
|
||||
|
||||
async def spy(ctx: ServerRequestContext, call_next: CallNext) -> HandlerResult:
|
||||
try:
|
||||
return await call_next(ctx)
|
||||
except MCPError as exc:
|
||||
seen.append((ctx.method, exc.error.code))
|
||||
raise
|
||||
|
||||
server = Server("Bookshop", on_list_tools=tutorial001.on_list_tools, on_call_tool=tutorial001.on_call_tool)
|
||||
server.middleware.append(spy)
|
||||
async with Client(server) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.read_resource("config://settings")
|
||||
assert exc_info.value.error == ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="resources/read")
|
||||
assert seen == [("resources/read", METHOD_NOT_FOUND)]
|
||||
|
||||
|
||||
async def test_initialize_cannot_be_replaced_only_wrapped() -> None:
|
||||
"""`add_request_handler("initialize", ...)` is rejected: middleware is the sanctioned hook."""
|
||||
expected = (
|
||||
"'initialize' is handled by the server runner and cannot be overridden; "
|
||||
"use Server.middleware to observe or wrap initialization"
|
||||
)
|
||||
with pytest.raises(ValueError, match=re.escape(expected)):
|
||||
tutorial001.server.add_request_handler("initialize", CallToolRequestParams, tutorial001.on_call_tool)
|
||||
@@ -0,0 +1,197 @@
|
||||
"""`docs/handlers/multi-round-trip.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import (
|
||||
INTERNAL_ERROR,
|
||||
INVALID_REQUEST,
|
||||
CallToolResult,
|
||||
CreateMessageRequest,
|
||||
CreateMessageRequestParams,
|
||||
ElicitRequest,
|
||||
ElicitRequestFormParams,
|
||||
ElicitRequestParams,
|
||||
ElicitResult,
|
||||
GetPromptResult,
|
||||
InputRequiredResult,
|
||||
PromptMessage,
|
||||
TextContent,
|
||||
)
|
||||
|
||||
from docs_src.mrtr import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005
|
||||
from mcp import Client, MCPError
|
||||
from mcp.client import ClientRequestContext
|
||||
from mcp.server.mcpserver import InvalidRequestState
|
||||
|
||||
# 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_first_call_returns_an_input_required_result() -> None:
|
||||
"""tutorial001: a tool that is missing input returns `InputRequiredResult` instead of calling back."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
result = await client.session.call_tool("provision", {"name": "orders"}, allow_input_required=True)
|
||||
assert result == snapshot(
|
||||
InputRequiredResult(
|
||||
result_type="input_required",
|
||||
input_requests={
|
||||
"region": ElicitRequest(
|
||||
method="elicitation/create",
|
||||
params=ElicitRequestFormParams(
|
||||
mode="form",
|
||||
message="Which region should the database live in?",
|
||||
requested_schema={
|
||||
"type": "object",
|
||||
"properties": {"region": {"type": "string"}},
|
||||
"required": ["region"],
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
request_state="provision-v1",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_the_auto_loop_drives_the_call_to_completion() -> None:
|
||||
"""tutorial003: register `elicitation_callback`, call the tool, get a plain `CallToolResult` back."""
|
||||
async with Client(tutorial001.server, elicitation_callback=tutorial003.handle_elicitation) as client:
|
||||
result = await client.call_tool("provision", {"name": "orders"})
|
||||
assert result == snapshot(
|
||||
CallToolResult(content=[TextContent(type="text", text="Provisioned 'orders' in eu-west-1.")])
|
||||
)
|
||||
|
||||
|
||||
async def test_the_auto_loop_without_a_callback_raises_mcp_error() -> None:
|
||||
"""The page's `!!! check`: no `elicitation_callback` means the SDK's stand-in answers with an error."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await client.call_tool("provision", {"name": "orders"})
|
||||
assert exc.value.error.code == INVALID_REQUEST
|
||||
assert exc.value.error.message == "Elicitation not supported"
|
||||
|
||||
|
||||
async def test_retry_with_input_responses_and_request_state_completes_the_call() -> None:
|
||||
"""tutorial001: the retry carries `input_responses` keyed like `input_requests` plus the echoed token."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
result = await client.call_tool(
|
||||
"provision",
|
||||
{"name": "orders"},
|
||||
input_responses={"region": ElicitResult(action="accept", content={"region": "eu-west-1"})},
|
||||
request_state="provision-v1",
|
||||
)
|
||||
assert result == snapshot(
|
||||
CallToolResult(content=[TextContent(type="text", text="Provisioned 'orders' in eu-west-1.")])
|
||||
)
|
||||
|
||||
|
||||
async def test_the_manual_loop_drives_the_call_to_completion() -> None:
|
||||
"""tutorial002: `client.session.call_tool(..., allow_input_required=True)` for callers who own the loop."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
result = await tutorial002.provision(client, "billing")
|
||||
assert result == snapshot(
|
||||
CallToolResult(content=[TextContent(type="text", text="Provisioned 'billing' in eu-west-1.")])
|
||||
)
|
||||
|
||||
|
||||
async def test_the_in_memory_client_negotiates_2026_07_28() -> None:
|
||||
"""`InputRequiredResult` only exists at 2026-07-28; `Client(server)` lands there without being asked."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
assert client.protocol_version == "2026-07-28"
|
||||
|
||||
|
||||
async def test_a_pre_2026_session_has_nowhere_to_put_the_result() -> None:
|
||||
"""The page's `!!! warning`: on a legacy session the runner cannot serialize an `InputRequiredResult`."""
|
||||
async with Client(tutorial001.server, mode="legacy") as client:
|
||||
with pytest.raises(MCPError) as exc:
|
||||
await client.call_tool("provision", {"name": "orders"})
|
||||
assert exc.value.error.code == INTERNAL_ERROR
|
||||
assert exc.value.error.message == "Handler returned an invalid result"
|
||||
|
||||
|
||||
def test_fulfil_refuses_a_request_it_cannot_answer() -> None:
|
||||
"""tutorial002: `fulfil` is the dispatch point. This client only knows how to answer an `ElicitRequest`."""
|
||||
request = CreateMessageRequest(params=CreateMessageRequestParams(messages=[], max_tokens=64))
|
||||
with pytest.raises(NotImplementedError, match="sampling/createMessage"):
|
||||
tutorial002.fulfil(request)
|
||||
|
||||
|
||||
async def test_a_prompt_returns_an_input_required_result_on_the_first_round() -> None:
|
||||
"""tutorial004: `prompts/get` participates in the same flow — the `@mcp.prompt()` function
|
||||
returns the `InputRequiredResult` itself."""
|
||||
async with Client(tutorial004.mcp) as client:
|
||||
result = await client.session.get_prompt("briefing", allow_input_required=True)
|
||||
assert result == snapshot(
|
||||
InputRequiredResult(
|
||||
result_type="input_required",
|
||||
input_requests={
|
||||
"audience": ElicitRequest(
|
||||
method="elicitation/create",
|
||||
params=ElicitRequestFormParams(
|
||||
mode="form",
|
||||
message="Who is the briefing for?",
|
||||
requested_schema={
|
||||
"type": "object",
|
||||
"properties": {"audience": {"type": "string"}},
|
||||
"required": ["audience"],
|
||||
},
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _answer_audience(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
return ElicitResult(action="accept", content={"audience": "the board"})
|
||||
|
||||
|
||||
async def test_the_prompt_auto_loop_returns_the_final_messages() -> None:
|
||||
"""tutorial004 + the page's client-side claim: `get_prompt` drives the same loop, so the
|
||||
caller sees only the complete `GetPromptResult`."""
|
||||
async with Client(tutorial004.mcp, elicitation_callback=_answer_audience) as client:
|
||||
result = await client.get_prompt("briefing")
|
||||
assert result == snapshot(
|
||||
GetPromptResult(
|
||||
description="Draft a briefing tuned to its audience.",
|
||||
messages=[
|
||||
PromptMessage(
|
||||
role="user",
|
||||
content=TextContent(type="text", text="Write a briefing for the board."),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_a_custom_codec_round_trips_what_it_sealed() -> None:
|
||||
"""tutorial005: `unseal(seal(payload))` returns the payload; the token itself is opaque hex."""
|
||||
codec = tutorial005.EnvelopeCodec(tutorial005.unwrap_data_key())
|
||||
token = codec.seal(b"round-1")
|
||||
assert token.startswith(tutorial005.PREFIX)
|
||||
assert b"round-1" not in token.encode()
|
||||
assert codec.unseal(token) == b"round-1"
|
||||
|
||||
|
||||
def test_a_custom_codec_raises_invalid_request_state_for_any_bad_token() -> None:
|
||||
"""tutorial005: any token the codec did not mint intact raises `InvalidRequestState`."""
|
||||
codec = tutorial005.EnvelopeCodec(tutorial005.unwrap_data_key())
|
||||
token = codec.seal(b"round-1")
|
||||
with pytest.raises(InvalidRequestState):
|
||||
codec.unseal(token + "00")
|
||||
with pytest.raises(InvalidRequestState):
|
||||
codec.unseal("not-a-token")
|
||||
|
||||
|
||||
def test_a_custom_codec_rejects_every_alias_of_a_minted_token() -> None:
|
||||
"""tutorial005: only the exact minted string verifies; rewritten spellings of it do not."""
|
||||
codec = tutorial005.EnvelopeCodec(tutorial005.unwrap_data_key())
|
||||
token = codec.seal(b"round-1")
|
||||
body = token.removeprefix(tutorial005.PREFIX)
|
||||
for alias in (
|
||||
body, # prefix stripped
|
||||
tutorial005.PREFIX + body.upper(), # non-canonical hex case
|
||||
tutorial005.PREFIX + body[:8] + " " + body[8:], # whitespace bytes.fromhex would skip
|
||||
):
|
||||
with pytest.raises(InvalidRequestState):
|
||||
codec.unseal(alias)
|
||||
@@ -0,0 +1,132 @@
|
||||
"""`docs/client/oauth-clients.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import inspect
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import AnyUrl, ValidationError
|
||||
|
||||
from docs_src.oauth_clients import tutorial001, tutorial002
|
||||
from mcp.client.auth import OAuthClientProvider, OAuthFlowError, OAuthRegistrationError, OAuthTokenError, TokenStorage
|
||||
from mcp.client.auth.extensions.client_credentials import (
|
||||
PrivateKeyJWTOAuthProvider,
|
||||
RFC7523OAuthClientProvider,
|
||||
static_assertion_provider,
|
||||
)
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
|
||||
from mcp.shared.exceptions import MCPDeprecationWarning
|
||||
|
||||
# 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_in_memory_storage_satisfies_the_token_storage_protocol() -> None:
|
||||
"""tutorial001: `TokenStorage` is a Protocol: four async methods, no base class."""
|
||||
storage: TokenStorage = tutorial001.InMemoryTokenStorage()
|
||||
assert await storage.get_tokens() is None
|
||||
assert await storage.get_client_info() is None
|
||||
|
||||
|
||||
async def test_storage_round_trips_tokens_and_client_info() -> None:
|
||||
"""tutorial001: whatever the provider stores, it gets back: the whole persistence contract."""
|
||||
storage = tutorial001.InMemoryTokenStorage()
|
||||
tokens = OAuthToken(access_token="at-123", refresh_token="rt-456", expires_in=3600, scope="user")
|
||||
client_info = OAuthClientInformationFull(
|
||||
client_id="generated-by-the-as",
|
||||
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
|
||||
)
|
||||
await storage.set_tokens(tokens)
|
||||
await storage.set_client_info(client_info)
|
||||
assert await storage.get_tokens() == tokens
|
||||
assert await storage.get_client_info() == client_info
|
||||
|
||||
|
||||
async def test_the_provider_is_an_httpx_auth() -> None:
|
||||
"""tutorial001: `OAuthClientProvider` plugs into httpx, not into MCP."""
|
||||
assert isinstance(tutorial001.oauth, httpx.Auth)
|
||||
|
||||
|
||||
async def test_the_metadata_defaults_are_the_authorization_code_flow() -> None:
|
||||
"""tutorial001: `grant_types` and `response_types` default to code + refresh: nothing to set."""
|
||||
metadata = tutorial001.oauth.context.client_metadata
|
||||
assert metadata.grant_types == ["authorization_code", "refresh_token"]
|
||||
assert metadata.response_types == ["code"]
|
||||
|
||||
|
||||
async def test_redirect_uris_is_required() -> None:
|
||||
"""The `!!! check`: registration metadata is validated locally, before any network."""
|
||||
with pytest.raises(ValidationError, match="redirect_uris\n Field required"):
|
||||
OAuthClientMetadata.model_validate({"client_name": "Bookshop Agent"})
|
||||
|
||||
|
||||
async def test_the_redirect_handler_receives_the_authorization_url(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""tutorial001: `redirect_handler` is the one place the authorization URL surfaces."""
|
||||
await tutorial001.open_browser("https://auth.example.com/authorize?client_id=abc")
|
||||
assert capsys.readouterr().out == "Visit: https://auth.example.com/authorize?client_id=abc\n"
|
||||
|
||||
|
||||
async def test_client_credentials_provider_has_no_human_in_the_loop() -> None:
|
||||
"""tutorial002: `ClientCredentialsOAuthProvider` is the same `httpx.Auth`, minus the handlers."""
|
||||
assert isinstance(tutorial002.oauth, OAuthClientProvider)
|
||||
assert isinstance(tutorial002.oauth, httpx.Auth)
|
||||
assert tutorial002.oauth.context.redirect_handler is None
|
||||
assert tutorial002.oauth.context.callback_handler is None
|
||||
|
||||
|
||||
async def test_client_credentials_provider_builds_its_own_metadata() -> None:
|
||||
"""tutorial002: the grant is `client_credentials`, there is nothing to redirect to."""
|
||||
metadata = tutorial002.oauth.context.client_metadata
|
||||
assert metadata.grant_types == ["client_credentials"]
|
||||
assert metadata.token_endpoint_auth_method == "client_secret_basic"
|
||||
assert metadata.redirect_uris is None
|
||||
assert metadata.scope == "user"
|
||||
|
||||
|
||||
async def test_the_three_remaining_keyword_arguments_have_defaults() -> None:
|
||||
"""The page names `timeout`, `client_metadata_url` and `validate_resource_url` as the remainder."""
|
||||
parameters = inspect.signature(OAuthClientProvider.__init__).parameters
|
||||
supplied = ["server_url", "client_metadata", "storage", "redirect_handler", "callback_handler"]
|
||||
remainder = ["timeout", "client_metadata_url", "validate_resource_url"]
|
||||
assert list(parameters) == ["self", *supplied, *remainder]
|
||||
assert all(parameters[name].default is not inspect.Parameter.empty for name in remainder)
|
||||
|
||||
|
||||
async def test_the_one_more_provider_is_private_key_jwt() -> None:
|
||||
"""The `!!! info`: `PrivateKeyJWTOAuthProvider` is the same `httpx.Auth`, built the same way."""
|
||||
provider = PrivateKeyJWTOAuthProvider(
|
||||
server_url="http://localhost:8001/mcp",
|
||||
storage=tutorial002.InMemoryTokenStorage(),
|
||||
client_id="reporting-agent",
|
||||
assertion_provider=static_assertion_provider("a.prebuilt.jwt"),
|
||||
)
|
||||
assert isinstance(provider, OAuthClientProvider)
|
||||
assert isinstance(provider, httpx.Auth)
|
||||
assert provider.context.client_metadata.token_endpoint_auth_method == "private_key_jwt"
|
||||
|
||||
|
||||
async def test_the_page_does_not_count_the_deprecated_provider() -> None:
|
||||
"""Why the `!!! info` says *one* more provider: `RFC7523OAuthClientProvider` warns on construction."""
|
||||
with pytest.warns(MCPDeprecationWarning, match="RFC7523OAuthClientProvider is deprecated"):
|
||||
RFC7523OAuthClientProvider(
|
||||
server_url="http://localhost:8001/mcp",
|
||||
client_metadata=tutorial001.oauth.context.client_metadata,
|
||||
storage=tutorial001.InMemoryTokenStorage(),
|
||||
)
|
||||
|
||||
|
||||
async def test_every_oauth_error_is_an_oauth_flow_error() -> None:
|
||||
"""Catch `OAuthFlowError` and you have caught registration and token failures too."""
|
||||
assert issubclass(OAuthRegistrationError, OAuthFlowError)
|
||||
assert issubclass(OAuthTokenError, OAuthFlowError)
|
||||
|
||||
|
||||
async def test_not_everything_is_a_flow_error() -> None:
|
||||
"""A bad argument is a `ValueError`, not an `OAuthFlowError`: the page says *OAuth* failures."""
|
||||
with pytest.raises(ValueError, match="client_metadata_url must be a valid HTTPS URL") as exc_info:
|
||||
OAuthClientProvider(
|
||||
server_url="http://localhost:8001/mcp",
|
||||
client_metadata=tutorial001.oauth.context.client_metadata,
|
||||
storage=tutorial001.InMemoryTokenStorage(),
|
||||
client_metadata_url="http://not-https.example/client.json",
|
||||
)
|
||||
assert not isinstance(exc_info.value, OAuthFlowError)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""`docs/run/opentelemetry.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import pytest
|
||||
from logfire.testing import CaptureLogfire
|
||||
|
||||
from docs_src.opentelemetry import tutorial001
|
||||
from mcp import Client
|
||||
|
||||
# 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_a_plain_server_is_traced_with_no_extra_code(capfire: CaptureLogfire) -> None:
|
||||
"""tutorial001: calling a tool emits a `tools/call` SERVER span, though the example adds no middleware."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
await client.call_tool("search_books", {"query": "dune"})
|
||||
|
||||
spans = {s["name"]: s for s in capfire.exporter.exported_spans_as_dict()}
|
||||
assert "tools/call search_books" in spans
|
||||
|
||||
attributes = spans["tools/call search_books"]["attributes"]
|
||||
assert attributes["mcp.method.name"] == "tools/call"
|
||||
assert attributes["gen_ai.operation.name"] == "execute_tool"
|
||||
assert attributes["gen_ai.tool.name"] == "search_books"
|
||||
|
||||
|
||||
async def test_client_and_server_share_one_trace(capfire: CaptureLogfire) -> None:
|
||||
"""When both sides run the SDK, the client and server spans land in one trace (SEP-414)."""
|
||||
async with Client(tutorial001.mcp, mode="legacy") as client:
|
||||
await client.call_tool("search_books", {"query": "dune"})
|
||||
|
||||
spans = {s["name"]: s for s in capfire.exporter.exported_spans_as_dict()}
|
||||
client_span = spans["MCP send tools/call search_books"]
|
||||
server_span = spans["tools/call search_books"]
|
||||
assert server_span["context"]["trace_id"] == client_span["context"]["trace_id"]
|
||||
@@ -0,0 +1,80 @@
|
||||
"""`docs/advanced/pagination.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import pytest
|
||||
from mcp_types import Resource
|
||||
|
||||
from docs_src.pagination import tutorial001, tutorial002
|
||||
from mcp import Client, MCPError
|
||||
from mcp.server import MCPServer
|
||||
from mcp.server.mcpserver.resources import TextResource
|
||||
|
||||
# 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")]
|
||||
|
||||
mcp = MCPServer("Bookshop")
|
||||
for n in range(1, 101):
|
||||
mcp.add_resource(TextResource(uri=f"books://catalog/book-{n}", name=f"book-{n}", text=f"book-{n}"))
|
||||
|
||||
|
||||
async def test_mcpserver_never_pages() -> None:
|
||||
"""The page's framing: `MCPServer` answers `resources/list` in one page with `next_cursor=None`."""
|
||||
async with Client(mcp) as client:
|
||||
result = await client.list_resources()
|
||||
assert len(result.resources) == 100
|
||||
assert result.next_cursor is None
|
||||
|
||||
|
||||
async def test_first_page_has_ten_resources_and_a_cursor() -> None:
|
||||
"""tutorial001: no cursor means page one: ten resources and a `next_cursor` the client may ignore."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
page = await client.list_resources()
|
||||
assert [resource.name for resource in page.resources] == [f"book-{n}" for n in range(1, 11)]
|
||||
assert page.next_cursor == "10"
|
||||
|
||||
|
||||
async def test_the_cursor_resumes_where_the_last_page_stopped() -> None:
|
||||
"""tutorial001: handing `next_cursor` straight back yields the next page, no overlap."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
page = await client.list_resources(cursor="10")
|
||||
assert page.resources[0].name == "book-11"
|
||||
assert page.next_cursor == "20"
|
||||
|
||||
|
||||
async def test_the_last_page_carries_no_cursor() -> None:
|
||||
"""tutorial001: `next_cursor=None` is the only end-of-list signal."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
page = await client.list_resources(cursor="90")
|
||||
assert len(page.resources) == 10
|
||||
assert page.next_cursor is None
|
||||
|
||||
|
||||
async def test_the_loop_collects_all_one_hundred() -> None:
|
||||
"""tutorial001: the `cursor=` loop visits ten pages and reassembles the whole catalog."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
resources: list[Resource] = []
|
||||
cursor: str | None = None
|
||||
pages = 0
|
||||
while True:
|
||||
page = await client.list_resources(cursor=cursor)
|
||||
resources.extend(page.resources)
|
||||
pages += 1
|
||||
if page.next_cursor is None:
|
||||
break
|
||||
cursor = page.next_cursor
|
||||
assert pages == 10
|
||||
assert len({resource.uri for resource in resources}) == 100
|
||||
|
||||
|
||||
async def test_the_client_program_on_the_page_runs(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""tutorial002: `main()` is the literal client program on the page and prints the stitched total."""
|
||||
await tutorial002.main()
|
||||
assert capsys.readouterr().out == "100 resources\n"
|
||||
|
||||
|
||||
async def test_an_invented_cursor_is_an_error() -> None:
|
||||
"""Cursors are opaque: a string the server never minted blows up inside the handler."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
with pytest.raises(MCPError) as excinfo:
|
||||
await client.list_resources(cursor="page-2")
|
||||
assert excinfo.value.code == -32603
|
||||
assert str(excinfo.value) == "Internal server error"
|
||||
@@ -0,0 +1,102 @@
|
||||
"""`docs/handlers/progress.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import inspect
|
||||
|
||||
import anyio
|
||||
import pytest
|
||||
from mcp_types import TextContent
|
||||
|
||||
from docs_src.progress import tutorial001, tutorial002
|
||||
from mcp import Client
|
||||
|
||||
# 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")]
|
||||
|
||||
URLS = ["https://example.com/a.json", "https://example.com/b.json"]
|
||||
|
||||
|
||||
async def test_context_parameter_is_invisible_to_the_model() -> None:
|
||||
"""tutorial001: `ctx` comes from the type hint and never reaches the input schema."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.input_schema["properties"] == {
|
||||
"urls": {"items": {"type": "string"}, "title": "Urls", "type": "array"}
|
||||
}
|
||||
assert tool.input_schema["required"] == ["urls"]
|
||||
|
||||
|
||||
async def test_each_report_becomes_one_callback_invocation_in_order() -> None:
|
||||
"""tutorial001: `progress_callback` receives every `(progress, total, message)` the tool reported."""
|
||||
updates: list[tuple[float, float | None, str | None]] = []
|
||||
|
||||
async def show(progress: float, total: float | None, message: str | None) -> None:
|
||||
updates.append((progress, total, message))
|
||||
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.call_tool("import_catalog", {"urls": URLS}, progress_callback=show)
|
||||
assert updates == [
|
||||
(1, 2, "Imported https://example.com/a.json"),
|
||||
(2, 2, "Imported https://example.com/b.json"),
|
||||
]
|
||||
assert result.content == [TextContent(type="text", text="Imported 2 records.")]
|
||||
assert result.structured_content == {"result": "Imported 2 records."}
|
||||
|
||||
|
||||
async def test_over_a_wire_dispatcher_callbacks_race_the_result() -> None:
|
||||
"""The `!!! info`: only the in-memory connection runs the callback inline.
|
||||
|
||||
On a wire dispatcher (`mode="legacy"` here) each progress notification starts its own task, so
|
||||
`call_tool` can return while a slow callback is still running. The callbacks below block on an
|
||||
event that is only set *after* `call_tool` has returned: exactly the situation the page tells
|
||||
you not to rule out.
|
||||
"""
|
||||
release = anyio.Event()
|
||||
done = anyio.Event()
|
||||
finished: list[float] = []
|
||||
|
||||
async def gated(progress: float, total: float | None, message: str | None) -> None:
|
||||
await release.wait()
|
||||
finished.append(progress)
|
||||
if len(finished) == 2:
|
||||
done.set()
|
||||
|
||||
async with Client(tutorial001.mcp, mode="legacy") as client:
|
||||
with anyio.fail_after(5):
|
||||
result = await client.call_tool("import_catalog", {"urls": URLS}, progress_callback=gated)
|
||||
assert finished == []
|
||||
release.set()
|
||||
with anyio.fail_after(5):
|
||||
await done.wait()
|
||||
assert sorted(finished) == [1, 2]
|
||||
assert result.structured_content == {"result": "Imported 2 records."}
|
||||
|
||||
|
||||
async def test_without_a_callback_report_progress_is_a_no_op() -> None:
|
||||
"""The `!!! check`: omit `progress_callback` and the tool runs to the same result, no error."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.call_tool("import_catalog", {"urls": URLS})
|
||||
assert not result.is_error
|
||||
assert result.structured_content == {"result": "Imported 2 records."}
|
||||
|
||||
|
||||
def test_progress_callback_is_per_call_not_per_client() -> None:
|
||||
"""The `!!! warning`: `call_tool` takes `progress_callback`; the `Client` constructor does not."""
|
||||
assert "progress_callback" in inspect.signature(Client.call_tool).parameters
|
||||
assert "progress_callback" not in inspect.signature(Client.__init__).parameters
|
||||
|
||||
|
||||
async def test_omitting_total_reaches_the_callback_as_none() -> None:
|
||||
"""tutorial002: a report without `total` arrives as `total=None`: activity, not a percentage."""
|
||||
updates: list[tuple[float, float | None, str | None]] = []
|
||||
|
||||
async def show(progress: float, total: float | None, message: str | None) -> None:
|
||||
updates.append((progress, total, message))
|
||||
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
result = await client.call_tool("import_feed", {"feed_url": "https://example.com/feed"}, progress_callback=show)
|
||||
assert updates == [
|
||||
(1, None, "Imported https://example.com/feed#Dune"),
|
||||
(2, None, "Imported https://example.com/feed#Neuromancer"),
|
||||
(3, None, "Imported https://example.com/feed#Hyperion"),
|
||||
]
|
||||
assert result.structured_content == {"result": "Imported 3 records."}
|
||||
@@ -0,0 +1,101 @@
|
||||
"""`docs/servers/prompts.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import traceback
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import PromptArgument, PromptMessage, TextContent
|
||||
|
||||
from docs_src.prompts import tutorial001, tutorial002, tutorial003
|
||||
from mcp import Client, MCPError
|
||||
|
||||
# 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_function_becomes_the_prompt() -> None:
|
||||
"""tutorial001: the name, the docstring and the parameters are the whole `prompts/list` entry."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
(prompt,) = (await client.list_prompts()).prompts
|
||||
assert prompt.model_dump(mode="json", by_alias=True, exclude_none=True) == snapshot(
|
||||
{
|
||||
"name": "review_code",
|
||||
"description": "Review a piece of code.",
|
||||
"arguments": [{"name": "code", "required": True}],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def test_returned_string_becomes_one_user_message() -> None:
|
||||
"""tutorial001: a `str` return value is rendered as a single `user` message."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.get_prompt("review_code", {"code": "def add(a, b): return a + b"})
|
||||
assert result.model_dump(mode="json", by_alias=True, exclude_none=True) == snapshot(
|
||||
{
|
||||
"description": "Review a piece of code.",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": {
|
||||
"type": "text",
|
||||
"text": "Please review this code:\n\ndef add(a, b): return a + b",
|
||||
},
|
||||
}
|
||||
],
|
||||
"resultType": "complete",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def test_missing_required_argument_is_a_protocol_error() -> None:
|
||||
"""tutorial001: omitting a required argument fails the request itself. There is no error result."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.get_prompt("review_code")
|
||||
assert exc_info.value.code == -32603
|
||||
assert exc_info.value.message == "Internal server error"
|
||||
# The line a traceback prints, exactly as the page quotes it: the code is not in the message.
|
||||
assert traceback.format_exception_only(exc_info.value) == snapshot(
|
||||
["mcp.shared.exceptions.MCPError: Internal server error\n"]
|
||||
)
|
||||
|
||||
|
||||
async def test_message_list_becomes_a_multi_turn_template() -> None:
|
||||
"""tutorial002: a list of `UserMessage` / `AssistantMessage` renders in order, roles intact."""
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
assert [p.name for p in (await client.list_prompts()).prompts] == ["review_code", "debug_error"]
|
||||
result = await client.get_prompt("debug_error", {"error": "TypeError: 'int' object is not iterable"})
|
||||
assert result.messages == [
|
||||
PromptMessage(role="user", content=TextContent(type="text", text="I'm seeing this error:")),
|
||||
PromptMessage(
|
||||
role="user",
|
||||
content=TextContent(type="text", text="TypeError: 'int' object is not iterable"),
|
||||
),
|
||||
PromptMessage(
|
||||
role="assistant",
|
||||
content=TextContent(type="text", text="I'll help debug that. What have you tried so far?"),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def test_title_and_argument_descriptions() -> None:
|
||||
"""tutorial003: `title=` and `Field(description=...)` land in the `prompts/list` entry."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
(prompt,) = (await client.list_prompts()).prompts
|
||||
assert prompt.title == "Code review"
|
||||
assert prompt.arguments == [
|
||||
PromptArgument(name="code", description="The code to review.", required=True),
|
||||
PromptArgument(name="language", description="The language the code is written in.", required=False),
|
||||
]
|
||||
|
||||
|
||||
async def test_default_value_makes_the_argument_optional() -> None:
|
||||
"""tutorial003: a parameter with a default can be omitted and the default is used in the render."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
result = await client.get_prompt("review_code", {"code": "x = 1"})
|
||||
assert result.messages == [
|
||||
PromptMessage(
|
||||
role="user",
|
||||
content=TextContent(type="text", text="Please review this python code:\n\nx = 1"),
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,94 @@
|
||||
"""`docs/protocol-versions.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from mcp_types import DiscoverResult, Implementation, ServerCapabilities
|
||||
|
||||
from docs_src.protocol_versions import tutorial001, tutorial002, tutorial003, tutorial004
|
||||
from mcp import Client
|
||||
|
||||
# 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_auto_lands_on_the_modern_version() -> None:
|
||||
"""tutorial001: the default `mode="auto"` probes `server/discover` and adopts the result."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
assert client.protocol_version == "2026-07-28"
|
||||
assert client.server_info.name == "Bookshop"
|
||||
assert client.session.discover_result is not None
|
||||
assert client.session.initialize_result is None
|
||||
|
||||
|
||||
async def test_legacy_forces_the_initialize_handshake() -> None:
|
||||
"""tutorial002: `mode="legacy"` runs `initialize` against the very same server."""
|
||||
async with Client(tutorial002.mcp, mode="legacy") as client:
|
||||
assert client.protocol_version == "2025-11-25"
|
||||
assert client.server_info.name == "Bookshop"
|
||||
assert client.session.initialize_result is not None
|
||||
assert client.session.discover_result is None
|
||||
|
||||
|
||||
async def test_version_pin_sends_nothing_and_knows_nothing() -> None:
|
||||
"""tutorial003: a pin adopts the version locally; `server_info` and capabilities are blank."""
|
||||
async with Client(tutorial003.mcp, mode="2026-07-28") as client:
|
||||
assert client.protocol_version == "2026-07-28"
|
||||
assert client.server_info == Implementation(name="", version="")
|
||||
# The `!!! check` fence is the literal `print(client.server_info)` output.
|
||||
assert str(client.server_info) == "name='' title=None version='' description=None website_url=None icons=None"
|
||||
assert client.server_capabilities == ServerCapabilities()
|
||||
result = await client.call_tool("search_books", {"query": "dune"})
|
||||
assert result.structured_content == {"result": "Found 3 books matching 'dune'."}
|
||||
|
||||
|
||||
def test_handshake_era_version_is_not_a_valid_pin() -> None:
|
||||
"""A pre-2026 version string is rejected at construction with the exact error the page shows."""
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match=re.escape(
|
||||
"mode must be 'legacy', 'auto', or one of ['2026-07-28']; "
|
||||
"got '2025-06-18' ('2025-06-18' is a handshake-era version; use mode='legacy')"
|
||||
),
|
||||
):
|
||||
Client(tutorial003.mcp, mode="2025-06-18")
|
||||
|
||||
|
||||
async def test_prior_discover_round_trips() -> None:
|
||||
"""tutorial004: save `discover_result`, reconnect with it, and the identity comes back."""
|
||||
async with Client(tutorial004.mcp) as client:
|
||||
saved = client.session.discover_result
|
||||
assert saved is not None
|
||||
assert saved.supported_versions == ["2026-07-28"]
|
||||
|
||||
async with Client(tutorial004.mcp, mode="2026-07-28", prior_discover=saved) as client:
|
||||
assert client.protocol_version == "2026-07-28"
|
||||
assert client.server_info.name == "Bookshop"
|
||||
assert client.server_capabilities.tools is not None
|
||||
|
||||
|
||||
async def test_discover_result_survives_json() -> None:
|
||||
"""`DiscoverResult` is a Pydantic model: dump it to JSON, validate it back, reconnect with it."""
|
||||
async with Client(tutorial004.mcp) as client:
|
||||
saved = client.session.discover_result
|
||||
assert saved is not None
|
||||
|
||||
restored = DiscoverResult.model_validate_json(saved.model_dump_json())
|
||||
assert restored == saved
|
||||
|
||||
async with Client(tutorial004.mcp, mode="2026-07-28", prior_discover=restored) as client:
|
||||
assert client.server_info.name == "Bookshop"
|
||||
|
||||
|
||||
async def test_prior_discover_is_ignored_unless_mode_is_a_pin() -> None:
|
||||
"""The `!!! tip`: under `auto` the client probes anyway; under `legacy` it never discovers."""
|
||||
stale = DiscoverResult(
|
||||
supported_versions=["2026-07-28"],
|
||||
capabilities=ServerCapabilities(),
|
||||
server_info=Implementation(name="Stale", version="0.0.0"),
|
||||
)
|
||||
async with Client(tutorial004.mcp, prior_discover=stale) as client:
|
||||
assert client.server_info.name == "Bookshop"
|
||||
async with Client(tutorial004.mcp, mode="legacy", prior_discover=stale) as client:
|
||||
assert client.session.discover_result is None
|
||||
assert client.protocol_version == "2025-11-25"
|
||||
@@ -0,0 +1,54 @@
|
||||
"""`docs/get-started/real-host.md`: the one server every host section on the page launches, driven in memory."""
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import TextContent, TextResourceContents
|
||||
|
||||
from docs_src.real_host import tutorial001
|
||||
from mcp import Client
|
||||
|
||||
# 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_the_host_sees_exactly_what_the_decorators_registered() -> None:
|
||||
"""tutorial001: `tools/list` is what a host hands its model. Name, description, and schema come from the code."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
search, get = (await client.list_tools()).tools
|
||||
assert search.name == "search_books"
|
||||
assert search.description == "Search the catalog by title or author."
|
||||
assert search.input_schema == snapshot(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"query": {"title": "Query", "type": "string"}},
|
||||
"required": ["query"],
|
||||
"title": "search_booksArguments",
|
||||
}
|
||||
)
|
||||
assert get.name == "get_author"
|
||||
|
||||
|
||||
async def test_a_tool_call_round_trips_the_way_a_host_drives_it() -> None:
|
||||
"""tutorial001: `tools/call` sends arguments in; the function's return value comes back as the result."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.call_tool("search_books", {"query": "gibson"})
|
||||
assert not result.is_error
|
||||
assert result.structured_content == {"result": ["Neuromancer"]}
|
||||
|
||||
author = await client.call_tool("get_author", {"title": "Dune"})
|
||||
assert author.content == [TextContent(type="text", text="Frank Herbert")]
|
||||
|
||||
|
||||
async def test_the_resource_a_host_can_attach_to_context() -> None:
|
||||
"""tutorial001: `catalog://titles` has no parameter, so it is a concrete, listable, readable resource."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
(resource,) = (await client.list_resources()).resources
|
||||
assert str(resource.uri) == "catalog://titles"
|
||||
result = await client.read_resource("catalog://titles")
|
||||
assert result.contents == [
|
||||
TextResourceContents(
|
||||
uri="catalog://titles",
|
||||
mime_type="text/plain",
|
||||
text="Dune\nNeuromancer\nThe Left Hand of Darkness",
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,119 @@
|
||||
"""`docs/servers/resources.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import BlobResourceContents, Resource, ResourceTemplate, TextResourceContents
|
||||
|
||||
from docs_src.resources import tutorial001, tutorial002, tutorial003
|
||||
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_function_becomes_a_listed_resource() -> None:
|
||||
"""tutorial001: the URI, the function name and the docstring are the whole listing entry."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
(resource,) = (await client.list_resources()).resources
|
||||
assert resource == snapshot(
|
||||
Resource(
|
||||
name="get_config",
|
||||
uri="config://app",
|
||||
description="The active shop configuration.",
|
||||
mime_type="text/plain",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_read_returns_the_return_value_as_text() -> None:
|
||||
"""tutorial001: reading the URI runs the function and wraps the `str` in `TextResourceContents`."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.read_resource("config://app")
|
||||
assert result.contents == [
|
||||
TextResourceContents(uri="config://app", mime_type="text/plain", text="theme=dark\nlanguage=en")
|
||||
]
|
||||
|
||||
|
||||
async def test_template_is_listed_separately_from_resources() -> None:
|
||||
"""tutorial002: a `{placeholder}` moves the entry from `resources/list` to `resources/templates/list`."""
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
assert [r.uri for r in (await client.list_resources()).resources] == ["config://app"]
|
||||
(template,) = (await client.list_resource_templates()).resource_templates
|
||||
assert template == snapshot(
|
||||
ResourceTemplate(
|
||||
name="get_user_profile",
|
||||
uri_template="users://{user_id}/profile",
|
||||
description="A customer's profile.",
|
||||
mime_type="text/plain",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_reading_a_template_fills_the_placeholder() -> None:
|
||||
"""tutorial002: the client reads a concrete URI; the matched value arrives as the function argument."""
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
result = await client.read_resource("users://42/profile")
|
||||
assert result.contents == [
|
||||
TextResourceContents(
|
||||
uri="users://42/profile", mime_type="text/plain", text="User 42: 12 orders since 2021."
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_uri_params_must_match_function_params() -> None:
|
||||
"""The `!!! check`: a placeholder/parameter mismatch is rejected at decoration time, not at read time."""
|
||||
broken = MCPServer("Bookshop")
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
|
||||
@broken.resource("users://{user_id}/profile")
|
||||
def get_user_profile(user: str) -> None:
|
||||
"""A customer's profile."""
|
||||
|
||||
assert str(exc_info.value) == snapshot(
|
||||
"Mismatch between URI parameters {'user_id'} and function parameters {'user'}"
|
||||
)
|
||||
|
||||
|
||||
async def test_mime_type_is_what_you_declare() -> None:
|
||||
"""tutorial003: `mime_type=` lands in the listing verbatim; the SDK never guesses it from the value."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
resources = (await client.list_resources()).resources
|
||||
assert {r.uri: r.mime_type for r in resources} == snapshot(
|
||||
{
|
||||
"docs://readme": "text/markdown",
|
||||
"stats://catalog": "application/json",
|
||||
"covers://placeholder": "image/gif",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def test_str_return_is_sent_as_is() -> None:
|
||||
"""tutorial003: a `str` return value is the text content, untouched."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
(content,) = (await client.read_resource("docs://readme")).contents
|
||||
assert isinstance(content, TextResourceContents)
|
||||
assert content.text == "# Bookshop\n\nSearch the catalog with the `search_books` tool."
|
||||
|
||||
|
||||
async def test_dict_return_becomes_json_text() -> None:
|
||||
"""tutorial003: a non-`str`, non-`bytes` return value is serialised to JSON text."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
(content,) = (await client.read_resource("stats://catalog")).contents
|
||||
assert isinstance(content, TextResourceContents)
|
||||
assert content.text == snapshot('{\n "books": 1204,\n "authors": 391\n}')
|
||||
|
||||
|
||||
async def test_bytes_return_becomes_a_blob() -> None:
|
||||
"""tutorial003: a `bytes` return value arrives as `BlobResourceContents`, base64-encoded in `blob`."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
(content,) = (await client.read_resource("covers://placeholder")).contents
|
||||
assert isinstance(content, BlobResourceContents)
|
||||
assert content == BlobResourceContents(
|
||||
uri="covers://placeholder",
|
||||
mime_type="image/gif",
|
||||
blob="R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
|
||||
)
|
||||
assert base64.b64decode(content.blob) == tutorial003.placeholder_cover()
|
||||
@@ -0,0 +1,52 @@
|
||||
"""`docs/run/index.md`: every claim the page makes that is observable without a transport."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import CallToolResult, TextContent
|
||||
|
||||
from docs_src.run import tutorial001, tutorial002, tutorial003
|
||||
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_the_run_call_is_guarded_so_importing_does_not_start_a_server() -> None:
|
||||
"""tutorial001: `run()` sits under `__main__`, so the module imports cleanly and serves in-memory."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.call_tool("search_books", {"query": "dune"})
|
||||
assert result == snapshot(
|
||||
CallToolResult(
|
||||
content=[TextContent(type="text", text="Found 3 books matching 'dune'.")],
|
||||
structured_content={"result": "Found 3 books matching 'dune'."},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_the_transport_never_changes_what_the_server_is() -> None:
|
||||
"""tutorial001/002/003 differ only in how they run: every client sees the identical tool."""
|
||||
async with (
|
||||
Client(tutorial001.mcp) as stdio_client,
|
||||
Client(tutorial002.mcp) as http_client,
|
||||
Client(tutorial003.mcp) as configured_client,
|
||||
):
|
||||
baseline = await stdio_client.list_tools()
|
||||
assert baseline == await http_client.list_tools()
|
||||
assert baseline == await configured_client.list_tools()
|
||||
|
||||
|
||||
def test_transport_options_are_not_constructor_options() -> None:
|
||||
"""The page's warning: `port=` belongs to `run()`; the constructor rejects it."""
|
||||
options: dict[str, Any] = {"port": 3001}
|
||||
with pytest.raises(TypeError, match="unexpected keyword argument 'port'"):
|
||||
MCPServer("Bookshop", **options)
|
||||
|
||||
|
||||
def test_settings_are_constructor_arguments_and_land_on_settings() -> None:
|
||||
"""tutorial003: `log_level=` ends up on `mcp.settings`; the defaults are INFO and not-debug."""
|
||||
assert tutorial001.mcp.settings.log_level == "INFO"
|
||||
assert tutorial001.mcp.settings.debug is False
|
||||
assert tutorial003.mcp.settings.log_level == "DEBUG"
|
||||
@@ -0,0 +1,62 @@
|
||||
"""`docs/handlers/sampling-and-roots.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
import pytest
|
||||
from mcp_types import (
|
||||
MISSING_REQUIRED_CLIENT_CAPABILITY,
|
||||
CreateMessageRequestParams,
|
||||
CreateMessageResult,
|
||||
ListRootsResult,
|
||||
Root,
|
||||
TextContent,
|
||||
)
|
||||
from pydantic import FileUrl
|
||||
|
||||
from docs_src.sampling_and_roots import tutorial001, tutorial002
|
||||
from mcp import Client
|
||||
from mcp.client import ClientRequestContext
|
||||
from mcp.shared.exceptions import MCPError
|
||||
|
||||
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["legacy", "auto"])
|
||||
async def test_a_sampling_dependency_receives_the_clients_completion(mode: Literal["legacy", "auto"]) -> None:
|
||||
"""tutorial001: `draft_blurb` runs through the client's model on both protocol versions."""
|
||||
prompts: list[str] = []
|
||||
|
||||
async def sampler(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult:
|
||||
content = params.messages[0].content
|
||||
assert isinstance(content, TextContent)
|
||||
prompts.append(content.text)
|
||||
return CreateMessageResult(
|
||||
role="assistant", content=TextContent(type="text", text="A desert planet holds the key."), model="m"
|
||||
)
|
||||
|
||||
async with Client(tutorial001.mcp, mode=mode, sampling_callback=sampler) as client:
|
||||
result = await client.call_tool("blurb", {"title": "Dune"})
|
||||
|
||||
assert result.content == [TextContent(type="text", text="A desert planet holds the key.")]
|
||||
assert prompts == ["Write a one-sentence blurb for the book 'Dune'."]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["legacy", "auto"])
|
||||
async def test_a_roots_dependency_receives_the_clients_folders(mode: Literal["legacy", "auto"]) -> None:
|
||||
"""tutorial002: `workspace_roots` fetches the client's roots list."""
|
||||
|
||||
async def client_roots(context: ClientRequestContext) -> ListRootsResult:
|
||||
return ListRootsResult(roots=[Root(uri=FileUrl("file:///workspace/catalog"), name="catalog")])
|
||||
|
||||
async with Client(tutorial002.mcp, mode=mode, list_roots_callback=client_roots) as client:
|
||||
result = await client.call_tool("catalog_folder", {})
|
||||
|
||||
assert result.content == [TextContent(type="text", text="file:///workspace/catalog")]
|
||||
|
||||
|
||||
async def test_an_undeclared_capability_fails_before_a_request_is_sent() -> None:
|
||||
"""The page's gate claim: no `sampling` capability means a -32021 protocol error."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("blurb", {"title": "Dune"})
|
||||
assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY
|
||||
@@ -0,0 +1,98 @@
|
||||
"""`docs/client/session-groups.md`: every claim the page makes, proved against the real SDK.
|
||||
|
||||
`connect_to_server` opens a real transport (a subprocess or a socket), so these tests drive the
|
||||
exact same aggregation path through `connect_with_session` with in-memory sessions instead.
|
||||
"""
|
||||
|
||||
import traceback
|
||||
|
||||
import pytest
|
||||
from mcp_types import INVALID_PARAMS, Implementation
|
||||
|
||||
from docs_src.session_groups import tutorial001, tutorial002, tutorial004
|
||||
from mcp import Client, ClientSessionGroup, MCPError
|
||||
|
||||
# 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_both_servers_call_their_tool_search() -> None:
|
||||
"""tutorial001 + tutorial002: two unrelated servers, one colliding tool name."""
|
||||
async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web:
|
||||
(library_tool,) = (await library.list_tools()).tools
|
||||
(web_tool,) = (await web.list_tools()).tools
|
||||
assert library_tool.name == "search"
|
||||
assert web_tool.name == "search"
|
||||
|
||||
|
||||
async def test_a_connected_server_is_aggregated_into_the_group() -> None:
|
||||
"""tutorial003: the group exposes every component of every connected server as a dict."""
|
||||
async with Client(tutorial001.mcp) as library:
|
||||
group = ClientSessionGroup()
|
||||
await group.connect_with_session(library.server_info, library.session)
|
||||
assert sorted(group.tools) == ["search"]
|
||||
assert sorted(group.resources) == ["hours"]
|
||||
assert group.prompts == {}
|
||||
assert group.tools["search"].description == "Search the library catalog."
|
||||
|
||||
|
||||
async def test_colliding_names_are_rejected() -> None:
|
||||
"""tutorial003: without a hook the second `search` raises, and nothing from `Web` is kept."""
|
||||
async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web:
|
||||
group = ClientSessionGroup()
|
||||
await group.connect_with_session(library.server_info, library.session)
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await group.connect_with_session(web.server_info, web.session)
|
||||
assert str(exc_info.value) == "{'search'} already exist in group tools."
|
||||
assert exc_info.value.error.code == INVALID_PARAMS
|
||||
assert sorted(group.tools) == ["search"]
|
||||
# The page's `!!! check` fence is the last line of the traceback, verbatim.
|
||||
assert traceback.format_exception_only(exc_info.value) == [
|
||||
"mcp.shared.exceptions.MCPError: {'search'} already exist in group tools.\n"
|
||||
]
|
||||
|
||||
|
||||
async def test_component_name_hook_prefixes_every_name() -> None:
|
||||
"""tutorial004: the hook rewrites every registered name, so both servers coexist."""
|
||||
async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web:
|
||||
group = ClientSessionGroup(component_name_hook=tutorial004.by_server)
|
||||
await group.connect_with_session(library.server_info, library.session)
|
||||
await group.connect_with_session(web.server_info, web.session)
|
||||
assert sorted(group.tools) == ["Library.search", "Web.search"]
|
||||
assert sorted(group.resources) == ["Library.hours"]
|
||||
|
||||
|
||||
def test_the_hook_is_a_plain_function_of_name_and_server_info() -> None:
|
||||
"""tutorial004: `by_server` builds the key from `server_info.name`."""
|
||||
assert tutorial004.by_server("search", Implementation(name="Web", version="1.0.0")) == "Web.search"
|
||||
|
||||
|
||||
async def test_the_key_is_prefixed_but_the_wire_name_is_not() -> None:
|
||||
"""tutorial004: the dict key is yours; the `Tool` inside keeps the name the server declared."""
|
||||
async with Client(tutorial002.mcp) as web:
|
||||
group = ClientSessionGroup(component_name_hook=tutorial004.by_server)
|
||||
await group.connect_with_session(web.server_info, web.session)
|
||||
assert group.tools["Web.search"].name == "search"
|
||||
|
||||
|
||||
async def test_call_tool_routes_to_the_owning_server() -> None:
|
||||
"""tutorial004: `group.call_tool` resolves the prefixed name to the session that owns it."""
|
||||
async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web:
|
||||
group = ClientSessionGroup(component_name_hook=tutorial004.by_server)
|
||||
await group.connect_with_session(library.server_info, library.session)
|
||||
await group.connect_with_session(web.server_info, web.session)
|
||||
web_result = await group.call_tool("Web.search", {"query": "model context protocol"})
|
||||
assert web_result.structured_content == {"result": "12 pages match 'model context protocol'."}
|
||||
library_result = await group.call_tool("Library.search", {"query": "dune"})
|
||||
assert library_result.structured_content == {"result": "3 books match 'dune'."}
|
||||
|
||||
|
||||
async def test_disconnect_removes_every_component_of_that_server() -> None:
|
||||
"""tutorial004: `disconnect_from_server` takes the session back out of all three dicts."""
|
||||
async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web:
|
||||
group = ClientSessionGroup(component_name_hook=tutorial004.by_server)
|
||||
await group.connect_with_session(library.server_info, library.session)
|
||||
web_session = await group.connect_with_session(web.server_info, web.session)
|
||||
await group.disconnect_from_server(web_session)
|
||||
assert sorted(group.tools) == ["Library.search"]
|
||||
assert sorted(group.resources) == ["Library.hours"]
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Structural invariants every `docs_src/` example must satisfy.
|
||||
|
||||
These are deliberately string/regex checks, not an AST analyzer: each predicate
|
||||
is branch-free at the call site so the suite stays compatible with the repo's
|
||||
100% branch-coverage gate, and a contributor whose doc PR goes red gets a
|
||||
one-line reason, not a parser traceback.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import re
|
||||
from itertools import filterfalse
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# See test_index.py for why this is a per-module mark and not a conftest hook.
|
||||
pytestmark = pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")
|
||||
|
||||
REPO_ROOT = Path(__file__).parent.parent.parent
|
||||
DOCS_SRC = REPO_ROOT / "docs_src"
|
||||
|
||||
EXAMPLE_FILES = sorted(p for p in DOCS_SRC.rglob("*.py") if p.name != "__init__.py")
|
||||
"""Every example module under `docs_src/` (the `__init__.py` scaffolding is not an example)."""
|
||||
|
||||
_PRIVATE_MCP_IMPORT = re.compile(r"^\s*(?:from|import)\s+(mcp(?:\.\w+)*\._\w+)", re.MULTILINE)
|
||||
"""A `_`-private segment inside the imported MODULE path: `from mcp.client._memory import X`."""
|
||||
|
||||
_PRIVATE_MCP_NAME = re.compile(r"^\s*from\s+(mcp(?:\.\w+)*)\s+import\s+[^#\n]*?\b(_\w+)\b", re.MULTILINE)
|
||||
"""A `_`-private NAME imported from a public `mcp` module: `from mcp.client import _memory`."""
|
||||
|
||||
RETIRED_NAMES = ("UrlElicitationRequiredError",)
|
||||
"""Public SDK names built on protocol surfaces retired by the 2026-07-28 spec.
|
||||
|
||||
`UrlElicitationRequiredError` is the `-32042` flow; the spec lists that code as
|
||||
reserved-never-reused, so no documentation example may teach it even while the
|
||||
symbol is still exported.
|
||||
"""
|
||||
|
||||
_INCLUDE_DIRECTIVE = re.compile(r"(?:--8<--\s*\"|<!-- snippet-source\s+)(docs_src/[^\s\"]+)")
|
||||
"""A `--8<-- "docs_src/..."` mkdocs include or a `<!-- snippet-source docs_src/... -->` README marker."""
|
||||
|
||||
|
||||
def _rel(path: Path) -> str:
|
||||
"""A repo-relative path, used as the parametrize id so failures name the file."""
|
||||
return path.relative_to(REPO_ROOT).as_posix()
|
||||
|
||||
|
||||
def _module_name(path: Path) -> str:
|
||||
"""The dotted import name of an example, derived from its repo-relative path."""
|
||||
return _rel(path).removesuffix(".py").replace("/", ".")
|
||||
|
||||
|
||||
def _private_mcp_imports(source: str) -> list[str]:
|
||||
"""Every `mcp.*` import in `source` that reaches a `_`-private module OR name.
|
||||
|
||||
Two single-line spellings are covered: a private segment in the module path
|
||||
(`from mcp.client._memory import X`, `import mcp.server._otel`) and a private
|
||||
name pulled from a public module (`from mcp.client import _memory`).
|
||||
"""
|
||||
named = [f"{module}.{name}" for module, name in _PRIVATE_MCP_NAME.findall(source)]
|
||||
return _PRIVATE_MCP_IMPORT.findall(source) + named
|
||||
|
||||
|
||||
def _retired_names_used(source: str) -> list[str]:
|
||||
"""The retired SDK names that appear anywhere in `source`."""
|
||||
return [name for name in RETIRED_NAMES if name in source]
|
||||
|
||||
|
||||
def _referenced_examples() -> set[str]:
|
||||
"""Every `docs_src/...` path that some docs page or the README actually includes."""
|
||||
pages = [*sorted((REPO_ROOT / "docs").rglob("*.md")), REPO_ROOT / "README.md"]
|
||||
return {ref for page in pages for ref in _INCLUDE_DIRECTIVE.findall(page.read_text(encoding="utf-8"))}
|
||||
|
||||
|
||||
def _is_real_file(rel: str) -> bool:
|
||||
"""Whether a repo-relative path exists on disk."""
|
||||
return (REPO_ROOT / rel).is_file()
|
||||
|
||||
|
||||
def test_private_mcp_import_detector() -> None:
|
||||
"""The detector flags both single-line spellings of a private `mcp` reach-in, and only those.
|
||||
|
||||
It does not parse Python: a private name hidden behind an `as` alias or inside a
|
||||
parenthesised multi-line `import` would slip through. Examples are short single-line
|
||||
imports, so the cheap detector is the right trade against a 100-line AST analyzer.
|
||||
"""
|
||||
assert _private_mcp_imports("from mcp.client._memory import InMemoryTransport") == ["mcp.client._memory"]
|
||||
assert _private_mcp_imports("import mcp.server._otel") == ["mcp.server._otel"]
|
||||
assert _private_mcp_imports("from mcp.client import _memory") == ["mcp.client._memory"]
|
||||
assert _private_mcp_imports("from mcp.server import MCPServer\nfrom mcp.client.client import Client") == []
|
||||
# only `mcp` is policed: another library's private module is not this test's business
|
||||
assert _private_mcp_imports("from pydantic._internal import _fields") == []
|
||||
|
||||
|
||||
def test_retired_name_detector() -> None:
|
||||
"""The detector flags a retired name and stays quiet on clean source."""
|
||||
assert _retired_names_used("raise UrlElicitationRequiredError([])") == ["UrlElicitationRequiredError"]
|
||||
assert _retired_names_used("from mcp.server import MCPServer") == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", EXAMPLE_FILES, ids=_rel)
|
||||
def test_example_imports(path: Path) -> None:
|
||||
"""The example imports cleanly against the current SDK.
|
||||
|
||||
A renamed symbol, a moved import path, or a changed keyword argument breaks an
|
||||
example at import time, long before anyone reads the page it appears on.
|
||||
|
||||
Honest scope: an example another test in this directory already imported is a
|
||||
`sys.modules` cache hit here and its real coverage is that behavioural test.
|
||||
This test is the floor for the example that has a page but no test yet.
|
||||
"""
|
||||
importlib.import_module(_module_name(path))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", EXAMPLE_FILES, ids=_rel)
|
||||
def test_example_uses_only_public_mcp_modules(path: Path) -> None:
|
||||
"""An example is the public API contract: it must never import a `_`-private `mcp` module."""
|
||||
assert not _private_mcp_imports(path.read_text(encoding="utf-8")), f"{_rel(path)} reaches into private mcp"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", EXAMPLE_FILES, ids=_rel)
|
||||
def test_example_avoids_retired_api(path: Path) -> None:
|
||||
"""An example must not teach an API the 2026-07-28 spec retired, even while it is still exported."""
|
||||
assert not _retired_names_used(path.read_text(encoding="utf-8")), f"{_rel(path)} uses a retired API"
|
||||
|
||||
|
||||
def test_every_example_is_included_by_a_page() -> None:
|
||||
"""Every `docs_src/` example is shown by at least one docs page or the README.
|
||||
|
||||
An orphan example is dead documentation: it gets type-checked and tested
|
||||
but no reader ever sees it, so it silently stops describing anything.
|
||||
"""
|
||||
examples = {_rel(p) for p in EXAMPLE_FILES}
|
||||
orphans = sorted(examples - _referenced_examples())
|
||||
assert not orphans, f"docs_src files no page includes: {orphans}"
|
||||
|
||||
|
||||
def test_every_included_path_exists() -> None:
|
||||
"""Every `docs_src/` path a page includes exists on disk.
|
||||
|
||||
`zensical build --strict` also enforces this, but only when the docs are
|
||||
built; this puts the same guarantee inside the ordinary `pytest` run.
|
||||
"""
|
||||
missing = sorted(filterfalse(_is_real_file, _referenced_examples()))
|
||||
assert not missing, f"pages include docs_src files that do not exist: {missing}"
|
||||
@@ -0,0 +1,192 @@
|
||||
"""`docs/servers/structured-output.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import TextContent
|
||||
|
||||
from docs_src.structured_output import (
|
||||
tutorial001,
|
||||
tutorial002,
|
||||
tutorial003,
|
||||
tutorial004,
|
||||
tutorial005,
|
||||
tutorial006,
|
||||
tutorial007,
|
||||
tutorial008,
|
||||
tutorial009,
|
||||
)
|
||||
from mcp import Client
|
||||
from mcp.server import MCPServer
|
||||
from mcp.server.mcpserver.exceptions import InvalidSignature
|
||||
|
||||
# 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_scalar_return_is_wrapped() -> None:
|
||||
"""tutorial001: `-> int` becomes a `{"result": ...}` output schema and fills both channels."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.output_schema == snapshot(
|
||||
{
|
||||
"properties": {"result": {"title": "Result", "type": "integer"}},
|
||||
"required": ["result"],
|
||||
"title": "get_temperatureOutput",
|
||||
"type": "object",
|
||||
}
|
||||
)
|
||||
result = await client.call_tool("get_temperature", {"city": "London"})
|
||||
assert not result.is_error
|
||||
assert result.content == [TextContent(type="text", text="17")]
|
||||
assert result.structured_content == {"result": 17}
|
||||
|
||||
|
||||
async def test_basemodel_is_the_schema() -> None:
|
||||
"""tutorial002: a `BaseModel` return type is the output schema itself: no wrapper."""
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.output_schema == snapshot(
|
||||
{
|
||||
"properties": {
|
||||
"temperature": {"description": "Degrees Celsius.", "title": "Temperature", "type": "number"},
|
||||
"humidity": {"description": "Relative humidity, 0 to 1.", "title": "Humidity", "type": "number"},
|
||||
"conditions": {"title": "Conditions", "type": "string"},
|
||||
},
|
||||
"required": ["temperature", "humidity", "conditions"],
|
||||
"title": "WeatherData",
|
||||
"type": "object",
|
||||
}
|
||||
)
|
||||
result = await client.call_tool("get_weather", {"city": "London"})
|
||||
assert result.structured_content == {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"}
|
||||
serialized = '{\n "temperature": 16.2,\n "humidity": 0.83,\n "conditions": "Overcast"\n}'
|
||||
assert result.content == [TextContent(type="text", text=serialized)]
|
||||
|
||||
|
||||
async def test_typeddict_produces_the_same_schema() -> None:
|
||||
"""tutorial003: a `TypedDict` return type produces the same object schema as the `BaseModel`."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.output_schema == snapshot(
|
||||
{
|
||||
"properties": {
|
||||
"temperature": {"title": "Temperature", "type": "number"},
|
||||
"humidity": {"title": "Humidity", "type": "number"},
|
||||
"conditions": {"title": "Conditions", "type": "string"},
|
||||
},
|
||||
"required": ["temperature", "humidity", "conditions"],
|
||||
"title": "WeatherData",
|
||||
"type": "object",
|
||||
}
|
||||
)
|
||||
result = await client.call_tool("get_weather", {"city": "London"})
|
||||
assert result.structured_content == {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"}
|
||||
|
||||
|
||||
async def test_dataclass_produces_the_same_schema() -> None:
|
||||
"""tutorial004: a dataclass (an annotated class) produces the same object schema again."""
|
||||
async with Client(tutorial004.mcp) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.output_schema == snapshot(
|
||||
{
|
||||
"properties": {
|
||||
"temperature": {"title": "Temperature", "type": "number"},
|
||||
"humidity": {"title": "Humidity", "type": "number"},
|
||||
"conditions": {"title": "Conditions", "type": "string"},
|
||||
},
|
||||
"required": ["temperature", "humidity", "conditions"],
|
||||
"title": "WeatherData",
|
||||
"type": "object",
|
||||
}
|
||||
)
|
||||
result = await client.call_tool("get_weather", {"city": "London"})
|
||||
assert result.structured_content == {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"}
|
||||
|
||||
|
||||
async def test_list_return_is_wrapped() -> None:
|
||||
"""tutorial005: `-> list[WeatherData]` is wrapped in `{"result": ...}` and flattened into one block per item."""
|
||||
async with Client(tutorial005.mcp) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.output_schema == snapshot(
|
||||
{
|
||||
"$defs": {
|
||||
"WeatherData": {
|
||||
"properties": {
|
||||
"temperature": {"title": "Temperature", "type": "number"},
|
||||
"humidity": {"title": "Humidity", "type": "number"},
|
||||
"conditions": {"title": "Conditions", "type": "string"},
|
||||
},
|
||||
"required": ["temperature", "humidity", "conditions"],
|
||||
"title": "WeatherData",
|
||||
"type": "object",
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"result": {"items": {"$ref": "#/$defs/WeatherData"}, "title": "Result", "type": "array"}
|
||||
},
|
||||
"required": ["result"],
|
||||
"title": "get_forecastOutput",
|
||||
"type": "object",
|
||||
}
|
||||
)
|
||||
result = await client.call_tool("get_forecast", {"city": "London", "days": 2})
|
||||
assert result.structured_content == {
|
||||
"result": [
|
||||
{"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"},
|
||||
{"temperature": 17.2, "humidity": 0.83, "conditions": "Overcast"},
|
||||
]
|
||||
}
|
||||
assert len(result.content) == 2
|
||||
|
||||
|
||||
async def test_dict_str_return_is_not_wrapped() -> None:
|
||||
"""tutorial006: `dict[str, float]` is already a JSON object, so there is no `result` wrapper."""
|
||||
async with Client(tutorial006.mcp) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.output_schema == snapshot(
|
||||
{"additionalProperties": {"type": "number"}, "title": "get_temperaturesDictOutput", "type": "object"}
|
||||
)
|
||||
result = await client.call_tool("get_temperatures", {"cities": ["London", "Reykjavik"]})
|
||||
assert result.structured_content == {"London": 16.2, "Reykjavik": 4.4}
|
||||
|
||||
|
||||
async def test_return_value_is_validated_against_the_schema() -> None:
|
||||
"""tutorial007: a return value that does not match the output schema is a tool error, not a result."""
|
||||
async with Client(tutorial007.mcp) as client:
|
||||
result = await client.call_tool("get_weather", {"city": "London"})
|
||||
assert result.is_error
|
||||
assert result.structured_content is None
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text.startswith("Error executing tool get_weather: 1 validation error for WeatherData")
|
||||
assert "humidity\n Field required" in result.content[0].text
|
||||
|
||||
|
||||
async def test_structured_output_false_opts_out() -> None:
|
||||
"""tutorial008: `structured_output=False` drops the schema and the structured channel entirely."""
|
||||
async with Client(tutorial008.mcp) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.output_schema is None
|
||||
result = await client.call_tool("weather_report", {"city": "London"})
|
||||
assert result.structured_content is None
|
||||
assert result.content == [
|
||||
TextContent(type="text", text="London: 17 degrees, overcast, light rain easing by evening.")
|
||||
]
|
||||
|
||||
|
||||
async def test_class_without_type_hints_is_silently_unstructured() -> None:
|
||||
"""tutorial009: a class with no annotations on its body gets no schema, and the model gets a `repr`."""
|
||||
async with Client(tutorial009.mcp) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.output_schema is None
|
||||
result = await client.call_tool("get_station", {"name": "north"})
|
||||
assert not result.is_error
|
||||
assert result.structured_content is None
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text.startswith('"<docs_src.structured_output.tutorial009.Station object at 0x')
|
||||
|
||||
|
||||
def test_structured_output_true_makes_the_silence_an_error() -> None:
|
||||
"""tutorial009: `structured_output=True` refuses a return type it cannot build a schema for."""
|
||||
mcp = MCPServer("Weather")
|
||||
with pytest.raises(InvalidSignature, match="is not serializable for structured output"):
|
||||
mcp.add_tool(tutorial009.get_station, structured_output=True)
|
||||
@@ -0,0 +1,303 @@
|
||||
"""`docs/{handlers,client}/subscriptions.md`: every claim the two pages make, proved against the real SDK."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
import mcp_types as types
|
||||
import pytest
|
||||
from trio.testing import MockClock
|
||||
|
||||
from docs_src.subscriptions import (
|
||||
tutorial001,
|
||||
tutorial002,
|
||||
tutorial003,
|
||||
tutorial004_anyio,
|
||||
tutorial004_asyncio,
|
||||
tutorial004_trio,
|
||||
tutorial005,
|
||||
)
|
||||
from mcp import Client
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.lowlevel import Server
|
||||
from mcp.server.subscriptions import SUBSCRIPTION_ID_META_KEY, ListenHandler, ToolsListChanged
|
||||
|
||||
_ReadResource = Callable[
|
||||
[ServerRequestContext[Any], types.ReadResourceRequestParams], Awaitable[types.ReadResourceResult]
|
||||
]
|
||||
|
||||
# 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")]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _module_runner_lease() -> None:
|
||||
"""Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`."""
|
||||
|
||||
|
||||
class _Stream:
|
||||
"""Collects listen-stream notifications and lets tests await arrival counts."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.received: list[types.ServerNotification] = []
|
||||
self._arrival = anyio.Event()
|
||||
|
||||
async def handler(
|
||||
self,
|
||||
message: object,
|
||||
) -> None:
|
||||
# The only messages these connections produce are the stream's frames.
|
||||
assert isinstance(
|
||||
message,
|
||||
types.SubscriptionsAcknowledgedNotification
|
||||
| types.ResourceUpdatedNotification
|
||||
| types.ToolListChangedNotification,
|
||||
), message
|
||||
self.received.append(message)
|
||||
self._arrival.set()
|
||||
self._arrival = anyio.Event()
|
||||
|
||||
async def wait_for(self, count: int) -> None:
|
||||
with anyio.fail_after(5):
|
||||
while len(self.received) < count:
|
||||
await self._arrival.wait()
|
||||
|
||||
|
||||
class _Reads:
|
||||
"""Counts server-side resource reads so a test can await the Nth refetch."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.count = 0
|
||||
self._bump = anyio.Event()
|
||||
|
||||
def counting(self, handler: _ReadResource) -> _ReadResource:
|
||||
async def counted(
|
||||
ctx: ServerRequestContext[Any], params: types.ReadResourceRequestParams
|
||||
) -> types.ReadResourceResult:
|
||||
result = await handler(ctx, params)
|
||||
self.count += 1
|
||||
self._bump.set()
|
||||
self._bump = anyio.Event()
|
||||
return result
|
||||
|
||||
return counted
|
||||
|
||||
async def wait_for(self, count: int) -> None:
|
||||
with anyio.fail_after(5):
|
||||
while self.count < count:
|
||||
await self._bump.wait()
|
||||
|
||||
|
||||
def _listen_request(**fields: Any) -> types.SubscriptionsListenRequest:
|
||||
return types.SubscriptionsListenRequest(
|
||||
params=types.SubscriptionsListenRequestParams(notifications=types.SubscriptionFilter(**fields))
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fresh_server_state() -> Any:
|
||||
"""Each test starts from an all-unfinished board and the base tool set.
|
||||
|
||||
The tutorials mutate module state deliberately (that is what publishes events), so the
|
||||
board contents and the `enable_reports` registration have to be undone between tests.
|
||||
"""
|
||||
boards = {name: dict(tasks) for name, tasks in tutorial001.BOARDS.items()}
|
||||
lowlevel_board = dict(tutorial002.BOARD)
|
||||
tools = dict(tutorial001.mcp._tool_manager._tools) # pyright: ignore[reportPrivateUsage]
|
||||
yield
|
||||
tutorial001.BOARDS.clear()
|
||||
tutorial001.BOARDS.update(boards)
|
||||
tutorial002.BOARD.clear()
|
||||
tutorial002.BOARD.update(lowlevel_board)
|
||||
tutorial001.mcp._tool_manager._tools.clear() # pyright: ignore[reportPrivateUsage]
|
||||
tutorial001.mcp._tool_manager._tools.update(tools) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
async def test_publishes_reach_the_stream_filtered_and_tagged() -> None:
|
||||
"""tutorial001: the full arc - ack first, exact-URI filtering, list_changed
|
||||
leading to a refreshed tool list, and client-side close."""
|
||||
stream = _Stream()
|
||||
async with Client(tutorial001.mcp, mode="2026-07-28", message_handler=stream.handler) as client:
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def listen() -> None:
|
||||
await client.session.send_request(
|
||||
_listen_request(tools_list_changed=True, resource_subscriptions=["board://sprint"]),
|
||||
types.SubscriptionsListenResult,
|
||||
)
|
||||
|
||||
tg.start_soon(listen)
|
||||
await stream.wait_for(1)
|
||||
|
||||
ack = stream.received[0]
|
||||
assert isinstance(ack, types.SubscriptionsAcknowledgedNotification)
|
||||
assert ack.params.notifications == types.SubscriptionFilter(
|
||||
tools_list_changed=True, resource_subscriptions=["board://sprint"]
|
||||
)
|
||||
assert ack.params.meta is not None and SUBSCRIPTION_ID_META_KEY in ack.params.meta
|
||||
|
||||
# An edit to a URI the stream did not subscribe to stays silent...
|
||||
await client.call_tool("complete_task", {"board": "backlog", "task": "tidy docs"})
|
||||
# ...and the subscribed URI delivers, tagged with the same subscription id.
|
||||
await client.call_tool("complete_task", {"board": "sprint", "task": "design"})
|
||||
await stream.wait_for(2)
|
||||
updated = stream.received[1]
|
||||
assert isinstance(updated, types.ResourceUpdatedNotification)
|
||||
assert updated.params.uri == "board://sprint"
|
||||
assert updated.params.meta == ack.params.meta
|
||||
|
||||
await client.call_tool("enable_reports", {})
|
||||
await stream.wait_for(3)
|
||||
assert isinstance(stream.received[2], types.ToolListChangedNotification)
|
||||
|
||||
# The client ends the stream by closing it - cancel the parked request.
|
||||
tg.cancel_scope.cancel()
|
||||
|
||||
# The list_changed told us to re-fetch: the new tool is there, and the
|
||||
# session outlives the closed stream.
|
||||
tools = await client.list_tools()
|
||||
assert "sprint_report" in {tool.name for tool in tools.tools}
|
||||
contents = (await client.read_resource("board://sprint")).contents[0]
|
||||
assert isinstance(contents, types.TextResourceContents)
|
||||
assert contents.text == "[x] design\n[ ] build\n[ ] ship"
|
||||
|
||||
|
||||
async def test_publish_with_no_subscribers_is_a_no_op() -> None:
|
||||
"""tutorial001: publishing to an idle server does nothing and breaks nothing."""
|
||||
async with Client(tutorial001.mcp, mode="2026-07-28") as client:
|
||||
result = await client.call_tool("complete_task", {"board": "sprint", "task": "design"})
|
||||
assert result.is_error is not True
|
||||
|
||||
|
||||
async def test_lowlevel_composition_serves_the_same_stream() -> None:
|
||||
"""tutorial002: bus + ListenHandler on the lowlevel Server is the same machinery."""
|
||||
stream = _Stream()
|
||||
async with Client(tutorial002.server, mode="2026-07-28", message_handler=stream.handler) as client:
|
||||
tools = await client.list_tools()
|
||||
assert [tool.name for tool in tools.tools] == ["complete_task"]
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
|
||||
async def listen() -> None:
|
||||
await client.session.send_request(
|
||||
_listen_request(resource_subscriptions=["board://sprint"]),
|
||||
types.SubscriptionsListenResult,
|
||||
)
|
||||
|
||||
tg.start_soon(listen)
|
||||
await stream.wait_for(1)
|
||||
|
||||
await client.call_tool("complete_task", {"task": "design"})
|
||||
await stream.wait_for(2)
|
||||
updated = stream.received[1]
|
||||
assert isinstance(updated, types.ResourceUpdatedNotification)
|
||||
assert updated.params.uri == "board://sprint"
|
||||
|
||||
# The bus you constructed is also the publish surface outside a
|
||||
# request; an unrequested kind never reaches this stream.
|
||||
await tutorial002.bus.publish(ToolsListChanged())
|
||||
await client.call_tool("complete_task", {"task": "build"})
|
||||
await stream.wait_for(3)
|
||||
assert isinstance(stream.received[2], types.ResourceUpdatedNotification)
|
||||
|
||||
tg.cancel_scope.cancel()
|
||||
|
||||
|
||||
async def test_follow_board_prints_the_refetched_board_and_the_new_tool_list(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""tutorial003: each event drives a refetch - the board reprints, and a tools change reprints the tool names."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(tutorial003.follow_board, client)
|
||||
# Let the watcher park on its stream (ack complete) before publishing.
|
||||
await anyio.wait_all_tasks_blocked()
|
||||
await client.call_tool("complete_task", {"board": "sprint", "task": "design"})
|
||||
await anyio.wait_all_tasks_blocked()
|
||||
await client.call_tool("enable_reports", {})
|
||||
await anyio.wait_all_tasks_blocked()
|
||||
tg.cancel_scope.cancel()
|
||||
|
||||
printed = capsys.readouterr().out
|
||||
assert "[x] design\n[ ] build\n[ ] ship" in printed
|
||||
assert "sprint_report" in printed
|
||||
|
||||
|
||||
EMPTY_BOARD = "[ ] design\n[ ] build\n[ ] ship"
|
||||
FINISHED_BOARD = "[x] design\n[x] build\n[x] ship"
|
||||
|
||||
|
||||
def _assert_snapshot_then_current_board(printed: str) -> None:
|
||||
"""The snapshot taken inside the open subscription came first, and the watcher ended up current.
|
||||
|
||||
How many times the watcher printed is deliberately not asserted: identical events that pile up
|
||||
unconsumed coalesce, so a fast main flow can turn three completions into one refetch. What the
|
||||
stream guarantees is that no change after the acknowledgment is missed.
|
||||
"""
|
||||
assert printed.startswith(EMPTY_BOARD), printed
|
||||
assert printed.strip().endswith(FINISHED_BOARD), printed
|
||||
|
||||
|
||||
async def test_the_asyncio_watcher_runs_beside_the_main_flow(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""tutorial004 (asyncio tab): run_sprint opens the subscription, snapshots the board, then a watcher
|
||||
task reprints it while the main flow keeps calling tools.
|
||||
|
||||
The example connects over HTTP; the in-memory client here is the maintainer-side stand-in."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
await tutorial004_asyncio.run_sprint(client)
|
||||
_assert_snapshot_then_current_board(capsys.readouterr().out)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("anyio_backend", [pytest.param("trio", id="trio")])
|
||||
async def test_the_trio_watcher_runs_beside_the_main_flow(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""tutorial004 (trio tab): the same shape as the asyncio tab, with a nursery owning the watcher."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
await tutorial004_trio.run_sprint(client)
|
||||
_assert_snapshot_then_current_board(capsys.readouterr().out)
|
||||
|
||||
|
||||
async def test_the_anyio_watcher_runs_beside_the_main_flow(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""tutorial004 (anyio tab): the same shape again, with a task group owning the watcher."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
await tutorial004_anyio.run_sprint(client)
|
||||
_assert_snapshot_then_current_board(capsys.readouterr().out)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"anyio_backend",
|
||||
[pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")],
|
||||
)
|
||||
async def test_the_follower_re_listens_after_the_stream_ends(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""tutorial005: a graceful server close ends one stream; the loop backs off, re-listens, and refetches.
|
||||
|
||||
Runs on trio's autojumping MockClock so the loop's backoff sleep takes no wall-clock time.
|
||||
"""
|
||||
reads = _Reads()
|
||||
handler = ListenHandler(tutorial002.bus)
|
||||
server = Server(
|
||||
"sprint-board",
|
||||
on_read_resource=reads.counting(tutorial002.read_resource),
|
||||
on_list_tools=tutorial002.list_tools,
|
||||
on_call_tool=tutorial002.call_tool,
|
||||
on_subscriptions_listen=handler,
|
||||
)
|
||||
|
||||
async with Client(server) as client:
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(tutorial005.keep_following, client)
|
||||
# First stream: the entry refetch reads the board, then an event reads it again.
|
||||
await reads.wait_for(1)
|
||||
await client.call_tool("complete_task", {"task": "design"})
|
||||
await reads.wait_for(2)
|
||||
|
||||
# End that stream gracefully. The loop backs off (the mock clock jumps the
|
||||
# sleep), re-listens, and refetches on entry: that is the third read.
|
||||
handler.close()
|
||||
await reads.wait_for(3)
|
||||
await client.call_tool("complete_task", {"task": "build"})
|
||||
await reads.wait_for(4)
|
||||
tg.cancel_scope.cancel()
|
||||
|
||||
printed = capsys.readouterr().out
|
||||
assert "[x] design\n[ ] build" in printed # first stream, after design
|
||||
assert "[x] design\n[x] build" in printed # second stream, after build
|
||||
@@ -0,0 +1,23 @@
|
||||
"""`docs/get-started/testing.md`: the page's own test, run for real.
|
||||
|
||||
The page shows this test against a `server.py` next to it; here the import path
|
||||
is the only difference.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import CallToolResult, TextContent
|
||||
|
||||
from docs_src.testing.tutorial001 import mcp
|
||||
from mcp import Client
|
||||
|
||||
# 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_call_add_tool() -> None:
|
||||
async with Client(mcp, raise_exceptions=True) as client:
|
||||
result = await client.call_tool("add", {"a": 1, "b": 2})
|
||||
assert result == snapshot(
|
||||
CallToolResult(content=[TextContent(type="text", text="3")], structured_content={"result": 3})
|
||||
)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""`docs/servers/tools.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import TextContent, ToolAnnotations
|
||||
|
||||
from docs_src.tools import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005
|
||||
from mcp import Client
|
||||
|
||||
# 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_signature_becomes_the_schema() -> None:
|
||||
"""tutorial001: the function name, the docstring and the type hints are the whole tool definition."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.name == "search_books"
|
||||
assert tool.description == "Search the catalog by title or author."
|
||||
assert tool.input_schema == snapshot(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"title": "Query", "type": "string"},
|
||||
"limit": {"title": "Limit", "type": "integer"},
|
||||
},
|
||||
"required": ["query", "limit"],
|
||||
"title": "search_booksArguments",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def test_call_returns_text_and_structured_content() -> None:
|
||||
"""tutorial001: the return value reaches the model as text and the client as typed data."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.call_tool("search_books", {"query": "dune", "limit": 5})
|
||||
assert not result.is_error
|
||||
assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune' (showing up to 5).")]
|
||||
assert result.structured_content == {"result": "Found 3 books matching 'dune' (showing up to 5)."}
|
||||
|
||||
|
||||
async def test_default_value_makes_the_argument_optional() -> None:
|
||||
"""tutorial002: a plain Python default drops the argument from `required` and lands in the schema.
|
||||
|
||||
The whole schema is pinned because the page quotes it verbatim.
|
||||
"""
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.input_schema == snapshot(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"title": "Query", "type": "string"},
|
||||
"limit": {"default": 10, "title": "Limit", "type": "integer"},
|
||||
},
|
||||
"required": ["query"],
|
||||
"title": "search_booksArguments",
|
||||
}
|
||||
)
|
||||
result = await client.call_tool("search_books", {"query": "dune"})
|
||||
assert result.structured_content == {"result": "Found 3 books matching 'dune' (showing up to 10)."}
|
||||
|
||||
|
||||
async def test_field_constraints_land_in_the_schema() -> None:
|
||||
"""tutorial003: `Field(...)` metadata and `Literal` choices become JSON Schema the model can see."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
props = tool.input_schema["properties"]
|
||||
assert props["query"]["description"] == "Title or author to search for."
|
||||
assert props["limit"] == snapshot(
|
||||
{
|
||||
"default": 10,
|
||||
"description": "Maximum number of results.",
|
||||
"maximum": 50,
|
||||
"minimum": 1,
|
||||
"title": "Limit",
|
||||
"type": "integer",
|
||||
}
|
||||
)
|
||||
assert props["genre"]["anyOf"][0]["enum"] == ["fiction", "non-fiction", "poetry"]
|
||||
|
||||
|
||||
async def test_constraint_violation_is_an_error_the_model_can_read() -> None:
|
||||
"""tutorial003: an out-of-range argument is rejected by the schema, not by your code."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
result = await client.call_tool("search_books", {"query": "dune", "limit": 999})
|
||||
assert result.is_error
|
||||
assert isinstance(result.content[0], TextContent)
|
||||
assert "less than or equal to 50" in result.content[0].text
|
||||
|
||||
|
||||
async def test_pydantic_model_parameter() -> None:
|
||||
"""tutorial004: a `BaseModel` parameter nests its own schema and arrives as a real instance."""
|
||||
async with Client(tutorial004.mcp) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.input_schema["$defs"]["Book"]["required"] == ["title", "author", "year"]
|
||||
book = {"title": "Dune", "author": "Frank Herbert", "year": 1965}
|
||||
result = await client.call_tool("add_book", {"book": book})
|
||||
assert result.structured_content == {"result": "Added 'Dune' by Frank Herbert (1965)."}
|
||||
|
||||
|
||||
async def test_title_and_annotations() -> None:
|
||||
"""tutorial005: `title` and `ToolAnnotations` are display and behaviour metadata for the client."""
|
||||
async with Client(tutorial005.mcp) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.title == "Search the catalog"
|
||||
assert tool.annotations == ToolAnnotations(read_only_hint=True, open_world_hint=False)
|
||||
@@ -0,0 +1,281 @@
|
||||
"""`docs/troubleshooting.md`: every error string the page names, reproduced against the real SDK."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from mcp_types import (
|
||||
INVALID_PARAMS,
|
||||
INVALID_REQUEST,
|
||||
MISSING_REQUIRED_CLIENT_CAPABILITY,
|
||||
ElicitRequestParams,
|
||||
ElicitResult,
|
||||
ErrorData,
|
||||
TextContent,
|
||||
)
|
||||
|
||||
from docs_src.troubleshooting import (
|
||||
tutorial001,
|
||||
tutorial002,
|
||||
tutorial003,
|
||||
tutorial004,
|
||||
tutorial005,
|
||||
tutorial006,
|
||||
tutorial007,
|
||||
tutorial008,
|
||||
)
|
||||
from mcp import Client, MCPError
|
||||
from mcp.client import ClientRequestContext
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.server import MCPServer
|
||||
from mcp.server.mcpserver import RequestStateSecurity
|
||||
|
||||
# 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")]
|
||||
|
||||
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 _confirm(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
|
||||
"""The page's one `elicitation_callback`: always accept the booking."""
|
||||
return ElicitResult(action="accept", content={"confirm": True})
|
||||
|
||||
|
||||
async def test_an_error_leaving_the_async_with_block_arrives_wrapped_in_an_exception_group() -> None:
|
||||
"""The `unhandled errors in a TaskGroup` entry: anyio group-wraps whatever escapes the block."""
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
await client.read_resource("weather://Atlantis")
|
||||
assert not isinstance(exc_info.value, MCPError)
|
||||
assert exc_info.group_contains(MCPError, match=r"^No forecast for 'Atlantis'\.$")
|
||||
|
||||
|
||||
async def test_the_same_error_caught_inside_the_block_is_the_bare_mcp_error() -> None:
|
||||
"""The fix on the page: `except MCPError` inside the `async with` never sees an `ExceptionGroup`."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.read_resource("weather://Atlantis")
|
||||
assert str(exc_info.value) == "No forecast for 'Atlantis'."
|
||||
assert exc_info.value.error.code == INVALID_PARAMS
|
||||
|
||||
|
||||
async def test_a_client_outside_its_async_with_refuses_every_call() -> None:
|
||||
"""`Client(...)` only constructs. Nothing connects until `async with`, so every call refuses."""
|
||||
client = Client(tutorial001.mcp)
|
||||
with pytest.raises(RuntimeError, match="^Client must be used within an async context manager$"):
|
||||
await client.list_tools()
|
||||
|
||||
|
||||
async def test_a_failing_tool_returns_is_error_true_instead_of_raising() -> None:
|
||||
"""The `Error executing tool` entry: it is a result, not an exception. Nothing to `except`."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.call_tool("forecast", {"city": "Atlantis"})
|
||||
assert result.is_error
|
||||
assert result.content == [
|
||||
TextContent(type="text", text="Error executing tool forecast: No forecast for 'Atlantis'.")
|
||||
]
|
||||
|
||||
|
||||
async def test_an_unknown_tool_is_the_same_kind_of_result() -> None:
|
||||
"""`Unknown tool: <name>` travels the same `is_error=True` path as a failing tool."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.call_tool("get_forecast", {"city": "London"})
|
||||
assert result.is_error
|
||||
assert result.content == [TextContent(type="text", text="Unknown tool: get_forecast")]
|
||||
|
||||
|
||||
async def test_the_tool_decorator_without_parentheses_raises_at_import_time() -> None:
|
||||
"""`@mcp.tool` (no parentheses) hands the function itself to `name=`; the SDK refuses immediately."""
|
||||
mcp = MCPServer("Weather")
|
||||
undecorated: Any = mcp.tool
|
||||
with pytest.raises(TypeError, match=r"Use @tool\(\) instead of @tool"):
|
||||
|
||||
@undecorated
|
||||
def forecast(city: str) -> None:
|
||||
"""Today's forecast for one city. Never called: the decoration itself is what raises."""
|
||||
|
||||
|
||||
async def test_a_duplicate_tool_name_keeps_the_first_and_drops_the_second() -> None:
|
||||
"""tutorial002: `tools/list` reports one `forecast`, and it is the first registration that won."""
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.name == "forecast"
|
||||
assert tool.description == "Today's forecast for one city."
|
||||
|
||||
|
||||
async def test_a_duplicate_registration_logs_tool_already_exists(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""The only signal for a dropped duplicate is the `Tool already exists:` warning in the server log."""
|
||||
with caplog.at_level(logging.WARNING, logger="mcp.server.mcpserver.tools.tool_manager"):
|
||||
|
||||
@tutorial002.mcp.tool(name="forecast")
|
||||
def forecast_weekly(city: str) -> None:
|
||||
"""The week ahead for one city. Never called: it is the duplicate that gets dropped."""
|
||||
|
||||
assert "Tool already exists: forecast" in caplog.messages
|
||||
|
||||
|
||||
async def test_the_default_streamable_http_app_answers_a_real_hostname_with_421(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""tutorial003: one 421, three spellings. The page presents all three as the same event."""
|
||||
transport = httpx.ASGITransport(app=tutorial003.app)
|
||||
async with tutorial003.mcp.session_manager.run():
|
||||
# What curl (or the reverse proxy's access log) shows: the status and the plain-text body.
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://mcp.example.com") as raw:
|
||||
with caplog.at_level(logging.WARNING, logger="mcp.server.transport_security"):
|
||||
response = await raw.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
|
||||
assert (response.status_code, response.text) == (421, "Invalid Host header")
|
||||
# No `Content-Type: application/json`, which is exactly why the python client cannot show the body.
|
||||
assert response.headers.get("content-type") is None
|
||||
# What the server operator finds by grepping the server log.
|
||||
assert "Invalid Host header: mcp.example.com" in caplog.messages
|
||||
# What the python `Client` raises instead: the generic stand-in, wrapped by the task group.
|
||||
async with httpx.AsyncClient(transport=transport) as http_client:
|
||||
client = Client(streamable_http_client("http://mcp.example.com/mcp", http_client=http_client))
|
||||
with pytest.raises(Exception) as exc_info: # pragma: no branch
|
||||
await client.__aenter__() # the connection attempt itself is what fails
|
||||
assert not isinstance(exc_info.value, MCPError)
|
||||
assert exc_info.group_contains(MCPError, match="^Server returned an error response$")
|
||||
|
||||
|
||||
async def test_an_allowlisted_hostname_connects_and_calls_a_tool() -> None:
|
||||
"""tutorial004: `transport_security=` names the deployed hostname, and the same client connects."""
|
||||
transport = httpx.ASGITransport(app=tutorial004.app)
|
||||
async with tutorial004.mcp.session_manager.run():
|
||||
async with httpx.AsyncClient(transport=transport) as http_client:
|
||||
allowed = streamable_http_client("http://mcp.example.com/mcp", http_client=http_client)
|
||||
async with Client(allowed) as c: # pragma: no branch
|
||||
assert c.protocol_version == "2026-07-28"
|
||||
result = await c.call_tool("forecast", {"city": "London"})
|
||||
assert result.structured_content == {"result": "London: Rain."}
|
||||
|
||||
|
||||
async def test_a_mounted_app_without_a_lifespan_fails_on_the_first_request() -> None:
|
||||
"""tutorial005: Starlette never runs a mounted sub-app's lifespan, so nothing starts the manager."""
|
||||
transport = httpx.ASGITransport(app=tutorial005.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") 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_a_session_id_the_server_never_issued_gets_a_404_session_not_found() -> None:
|
||||
"""`Session not found` is a 404 with a JSON-RPC body, so the python `Client` surfaces it verbatim."""
|
||||
mcp = MCPServer("Weather")
|
||||
app = mcp.streamable_http_app()
|
||||
async with mcp.session_manager.run():
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://127.0.0.1:8000") as h:
|
||||
response = await h.post(
|
||||
"/mcp",
|
||||
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}},
|
||||
headers={**MCP_HEADERS, "mcp-session-id": "deadbeef"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert response.headers["content-type"] == "application/json"
|
||||
assert response.json() == {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Session not found"}}
|
||||
|
||||
|
||||
async def test_ctx_elicit_at_2026_has_no_back_channel() -> None:
|
||||
"""tutorial006: at 2026-07-28 the server refuses to send `elicitation/create` at all."""
|
||||
async with Client(tutorial006.mcp) as client:
|
||||
assert client.protocol_version == "2026-07-28"
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("book_table", {"date": "Friday"})
|
||||
assert exc_info.value.error == ErrorData(
|
||||
code=INVALID_REQUEST,
|
||||
message=(
|
||||
"Cannot send 'elicitation/create': "
|
||||
"this transport context has no back-channel for server-initiated requests."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def test_an_elicitation_callback_does_not_fix_ctx_elicit_at_2026() -> None:
|
||||
"""The page's claim: registering the callback changes nothing. No request ever reaches the client."""
|
||||
async with Client(tutorial006.mcp, elicitation_callback=_confirm) as client:
|
||||
with pytest.raises(MCPError, match="no back-channel for server-initiated requests"):
|
||||
await client.call_tool("book_table", {"date": "Friday"})
|
||||
|
||||
|
||||
async def test_ctx_elicit_on_a_legacy_connection_works() -> None:
|
||||
"""The legacy aside: `ctx.elicit` is a server-to-client request, and only a legacy session has those."""
|
||||
async with Client(tutorial006.mcp, mode="legacy", elicitation_callback=_confirm) as client:
|
||||
result = await client.call_tool("book_table", {"date": "Friday"})
|
||||
assert result.structured_content == {"result": "Booked for Friday."}
|
||||
|
||||
|
||||
async def test_the_resolver_form_works_on_a_2026_connection() -> None:
|
||||
"""tutorial007: the fix. Same question, same callback, but the server returns it instead of calling back."""
|
||||
async with Client(tutorial007.mcp, elicitation_callback=_confirm) as client:
|
||||
assert client.protocol_version == "2026-07-28"
|
||||
result = await client.call_tool("book_table", {"date": "Friday"})
|
||||
assert result.structured_content == {"result": "Booked for Friday."}
|
||||
|
||||
|
||||
async def test_the_resolver_form_without_a_callback_names_the_missing_capability() -> None:
|
||||
"""The `-32021` entry: the server refuses up front, and `data` names the capability to declare."""
|
||||
async with Client(tutorial007.mcp) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("book_table", {"date": "Friday"})
|
||||
assert exc_info.value.error == ErrorData(
|
||||
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
|
||||
message=(
|
||||
"Client did not declare the form elicitation capability required by resolver "
|
||||
"'docs_src.troubleshooting.tutorial007:ask_to_confirm'"
|
||||
),
|
||||
data={"requiredCapabilities": {"elicitation": {"form": {}}}},
|
||||
)
|
||||
|
||||
|
||||
async def test_a_legacy_ctx_elicit_without_a_callback_says_elicitation_not_supported() -> None:
|
||||
"""The `Elicitation not supported` entry: no `elicitation_callback` means nobody to ask."""
|
||||
async with Client(tutorial006.mcp, mode="legacy") as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("book_table", {"date": "Friday"})
|
||||
assert exc_info.value.error == ErrorData(code=INVALID_REQUEST, message="Elicitation not supported")
|
||||
|
||||
|
||||
async def test_ctx_elicit_over_stateless_http_has_no_back_channel() -> None:
|
||||
"""tutorial008: `stateless_http=True` leaves the server no channel to send `elicitation/create`."""
|
||||
transport = httpx.ASGITransport(app=tutorial008.app)
|
||||
async with tutorial008.mcp.session_manager.run():
|
||||
async with httpx.AsyncClient(transport=transport) as http_client:
|
||||
stateless = streamable_http_client("http://127.0.0.1:8000/mcp", http_client=http_client)
|
||||
async with Client(stateless) as c: # pragma: no branch
|
||||
with pytest.raises(MCPError) as exc_info: # pragma: no branch
|
||||
await c.call_tool("book_table", {"date": "Friday"})
|
||||
assert exc_info.value.error == ErrorData(
|
||||
code=INVALID_REQUEST,
|
||||
message=(
|
||||
"Cannot send 'elicitation/create': "
|
||||
"this transport context has no back-channel for server-initiated requests."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def test_a_request_state_the_server_did_not_mint_is_rejected(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""The wire message is deliberately frozen; the real reason goes only to the server log."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
with caplog.at_level(logging.WARNING, logger="mcp.server.request_state"):
|
||||
with pytest.raises(MCPError) as exc_info: # pragma: no branch
|
||||
await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a")
|
||||
assert exc_info.value.error == ErrorData(
|
||||
code=INVALID_PARAMS, message="Invalid or expired requestState", data={"reason": "invalid_request_state"}
|
||||
)
|
||||
assert "requestState rejected on tools/call: malformed" in caplog.messages
|
||||
|
||||
|
||||
async def test_a_short_request_state_key_is_rejected_at_construction() -> None:
|
||||
"""`RequestStateSecurity(keys=[...])` refuses anything under 32 bytes and says how to make one."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
RequestStateSecurity(keys=[b"hunter2"])
|
||||
assert str(exc_info.value) == (
|
||||
"request-state keys must be at least 32 bytes of secret randomness; keys[0] is 7 bytes. "
|
||||
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
|
||||
)
|
||||
@@ -0,0 +1,214 @@
|
||||
"""`docs/servers/uri-templates.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import INVALID_PARAMS, ErrorData, ResourceTemplate, TextResourceContents
|
||||
|
||||
from docs_src.uri_templates import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005
|
||||
from mcp import Client, MCPError
|
||||
from mcp.server import MCPServer
|
||||
from mcp.shared.path_security import PathEscapeError, contains_path_traversal, safe_join
|
||||
from mcp.shared.uri_template import InvalidUriTemplate, UriTemplate
|
||||
|
||||
# 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_simple_expansion_maps_the_segment_to_the_argument() -> None:
|
||||
"""tutorial001: `books://{isbn}` reads `books://978-...` and the matched string is the argument."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
(content,) = (await client.read_resource("books://978-0441172719")).contents
|
||||
assert isinstance(content, TextResourceContents)
|
||||
assert content.text == snapshot('{\n "title": "Dune",\n "author": "Frank Herbert"\n}')
|
||||
|
||||
|
||||
async def test_an_int_parameter_is_converted_from_the_uri_string() -> None:
|
||||
"""tutorial001: `order_id: int` receives `12345`, not `"12345"`, so `order_id + 1` is `12346`."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
(content,) = (await client.read_resource("orders://12345")).contents
|
||||
assert isinstance(content, TextResourceContents)
|
||||
assert content.text == snapshot('{\n "order_id": 12345,\n "next_order": 12346,\n "status": "shipped"\n}')
|
||||
|
||||
|
||||
async def test_plus_keeps_the_slashes_in_the_captured_value() -> None:
|
||||
"""tutorial001: `{+path}` matches `printing/setup.md` as one value; a plain `{path}` would not."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
(content,) = (await client.read_resource("manuals://printing/setup.md")).contents
|
||||
assert isinstance(content, TextResourceContents)
|
||||
assert content.text == "# Printer setup\n\nLoad paper, then power on."
|
||||
|
||||
|
||||
async def test_omitted_query_params_fall_through_to_function_defaults() -> None:
|
||||
"""tutorial001: `{?limit,sort}` is lenient. No query string means `limit=10, sort="newest"`."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
(content,) = (await client.read_resource("reviews://978-0441172719")).contents
|
||||
assert isinstance(content, TextResourceContents)
|
||||
assert content.text == "10 newest reviews of Dune"
|
||||
|
||||
|
||||
async def test_a_query_param_overrides_only_the_default_it_names() -> None:
|
||||
"""tutorial001: `?sort=top` sets `sort` and leaves `limit` at its default."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
(content,) = (await client.read_resource("reviews://978-0441172719?sort=top")).contents
|
||||
assert isinstance(content, TextResourceContents)
|
||||
assert content.text == "10 top reviews of Dune"
|
||||
|
||||
|
||||
async def test_exploded_path_arrives_as_a_list_of_segments() -> None:
|
||||
"""tutorial001: `{/path*}` splits `/fiction/sci-fi` into `["fiction", "sci-fi"]`."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
(content,) = (await client.read_resource("shelves://browse/fiction/sci-fi")).contents
|
||||
assert isinstance(content, TextResourceContents)
|
||||
assert content.text == "catalog > fiction > sci-fi"
|
||||
|
||||
|
||||
def test_two_adjacent_variables_are_rejected_at_parse_time() -> None:
|
||||
"""'What the parser rejects': nothing separates `path` from `ext`, so the template is refused."""
|
||||
with pytest.raises(InvalidUriTemplate) as exc_info:
|
||||
UriTemplate.parse("manuals://{+path}{ext}")
|
||||
assert str(exc_info.value) == snapshot(
|
||||
"Variables 'path' and 'ext' are adjacent with no literal separator; matching cannot "
|
||||
"determine where one ends and the other begins. Add a literal between them or use a single variable."
|
||||
)
|
||||
|
||||
|
||||
def test_a_self_delimiting_operator_supplies_the_separator() -> None:
|
||||
"""'What the parser rejects': `{.ext}` contributes the `.` itself, so `{+path}{.ext}` is accepted."""
|
||||
template = UriTemplate.parse("manuals://{+path}{.ext}")
|
||||
assert template.match("manuals://printing/setup.md") == {"path": "printing/setup", "ext": "md"}
|
||||
|
||||
|
||||
def test_a_second_multi_segment_variable_is_rejected_at_parse_time() -> None:
|
||||
"""'What the parser rejects': two `{+...}` are ambiguous about which one absorbs an extra segment."""
|
||||
with pytest.raises(InvalidUriTemplate) as exc_info:
|
||||
UriTemplate.parse("copy://{+source}/to/{+destination}")
|
||||
assert str(exc_info.value) == snapshot(
|
||||
"Template contains more than one multi-segment variable ({+var}, {#var}, or explode modifier); "
|
||||
"matching would be ambiguous"
|
||||
)
|
||||
|
||||
|
||||
def test_a_query_parameter_without_a_python_default_is_rejected_at_decoration_time() -> None:
|
||||
"""'What the parser rejects': a client may omit `{?limit}`, so the bound parameter must declare a default."""
|
||||
strict = MCPServer("Bookshop")
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
|
||||
@strict.resource("reviews://{isbn}{?limit}")
|
||||
def list_reviews(isbn: str, limit: int) -> None:
|
||||
"""Reviews of a book."""
|
||||
|
||||
assert str(exc_info.value) == snapshot(
|
||||
"Resource 'reviews://{isbn}{?limit}': query parameter(s) ['limit'] have no default value. "
|
||||
"A client may omit a {?...}/{&...} query parameter, so the matching handler parameter "
|
||||
"must declare a default."
|
||||
)
|
||||
|
||||
|
||||
async def test_traversal_is_rejected_before_the_handler_runs() -> None:
|
||||
"""The `!!! check`: `../` triggers `-32602` "Unknown resource" and `read_manual` is never called."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.read_resource("manuals://../etc/passwd")
|
||||
assert exc_info.value.error == snapshot(
|
||||
ErrorData(
|
||||
code=INVALID_PARAMS,
|
||||
message="Unknown resource: manuals://../etc/passwd",
|
||||
data={"uri": "manuals://../etc/passwd"},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_dotdot_is_a_component_check_not_a_substring_scan() -> None:
|
||||
"""The page's prose: `v1.0..v2.0` passes because `..` is not a standalone path segment."""
|
||||
assert contains_path_traversal("../etc") is True
|
||||
assert contains_path_traversal("v1.0..v2.0") is False
|
||||
|
||||
|
||||
async def test_safe_join_serves_a_file_inside_the_base_directory(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""tutorial002: `safe_join(DOCS_ROOT, path).read_text()` returns the file under the base."""
|
||||
(tmp_path / "printing").mkdir()
|
||||
(tmp_path / "printing" / "setup.md").write_text("# Printer setup")
|
||||
monkeypatch.setattr(tutorial002, "DOCS_ROOT", tmp_path)
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
(content,) = (await client.read_resource("manuals://printing/setup.md")).contents
|
||||
assert isinstance(content, TextResourceContents)
|
||||
assert content.text == "# Printer setup"
|
||||
|
||||
|
||||
def test_safe_join_raises_when_the_resolved_path_escapes_the_base(tmp_path: Path) -> None:
|
||||
"""tutorial002: a path that climbs out of `DOCS_ROOT` raises `PathEscapeError`."""
|
||||
with pytest.raises(PathEscapeError):
|
||||
safe_join(tmp_path, "../etc/passwd")
|
||||
|
||||
|
||||
async def test_exempt_params_lets_an_absolute_path_through() -> None:
|
||||
"""tutorial003: `exempt_params={"source"}` skips the checks for that one parameter."""
|
||||
async with Client(tutorial003.mcp) as client:
|
||||
(content,) = (await client.read_resource("imports://preview//srv/incoming/catalog.csv")).contents
|
||||
assert isinstance(content, TextResourceContents)
|
||||
assert content.text == "Would import from /srv/incoming/catalog.csv"
|
||||
|
||||
|
||||
async def test_server_wide_resource_security_relaxes_every_resource() -> None:
|
||||
"""tutorial003: `resource_security=ResourceSecurity(reject_path_traversal=False)` exempts the whole server."""
|
||||
async with Client(tutorial003.relaxed) as client:
|
||||
(content,) = (await client.read_resource("imports://preview/../sibling/catalog.csv")).contents
|
||||
assert isinstance(content, TextResourceContents)
|
||||
assert content.text == "Would import from ../sibling/catalog.csv"
|
||||
|
||||
|
||||
async def test_lowlevel_static_dispatch_lists_and_reads_by_exact_uri() -> None:
|
||||
"""tutorial004: the registry is the listing, and a known URI returns its text."""
|
||||
async with Client(tutorial004.server) as client:
|
||||
listed = (await client.list_resources()).resources
|
||||
assert [r.uri for r in listed] == ["config://shop", "status://health"]
|
||||
(content,) = (await client.read_resource("status://health")).contents
|
||||
assert content == TextResourceContents(uri="status://health", text="ok")
|
||||
|
||||
|
||||
async def test_lowlevel_unknown_uri_raises() -> None:
|
||||
"""tutorial004: a URI outside the registry raises and surfaces as a protocol error."""
|
||||
async with Client(tutorial004.server) as client:
|
||||
with pytest.raises(MCPError):
|
||||
await client.read_resource("config://missing")
|
||||
|
||||
|
||||
def test_uritemplate_match_returns_a_dict_or_none() -> None:
|
||||
"""tutorial005: `match()` extracts decoded variables, or `None` when the URI doesn't fit."""
|
||||
assert tutorial005.TEMPLATES["manuals"].match("manuals://printing/setup.md") == {"path": "printing/setup.md"}
|
||||
assert tutorial005.TEMPLATES["books"].match("manuals://nope") is None
|
||||
|
||||
|
||||
async def test_lowlevel_match_routes_the_request_to_the_right_template() -> None:
|
||||
"""tutorial005: two templates, one handler. Each concrete URI lands in its own branch."""
|
||||
async with Client(tutorial005.server) as client:
|
||||
(manual,) = (await client.read_resource("manuals://printing/setup.md")).contents
|
||||
assert manual == TextResourceContents(uri="manuals://printing/setup.md", text="# Printer setup")
|
||||
(book,) = (await client.read_resource("books://978-0441172719")).contents
|
||||
assert book == TextResourceContents(uri="books://978-0441172719", text="Dune by Frank Herbert")
|
||||
|
||||
|
||||
async def test_lowlevel_handler_applies_the_safety_checks_itself() -> None:
|
||||
"""tutorial005: there is no default policy down here; `read_manual_safely` is the gate."""
|
||||
async with Client(tutorial005.server) as client:
|
||||
with pytest.raises(MCPError):
|
||||
await client.read_resource("manuals://../etc/passwd")
|
||||
with pytest.raises(MCPError):
|
||||
await client.read_resource("nothing://matches")
|
||||
|
||||
|
||||
async def test_str_of_a_template_round_trips_to_the_original_string() -> None:
|
||||
"""tutorial005: `str(template)` is the source string, so the listing reuses the parsed templates."""
|
||||
assert str(tutorial005.TEMPLATES["manuals"]) == "manuals://{+path}"
|
||||
async with Client(tutorial005.server) as client:
|
||||
result = await client.list_resource_templates()
|
||||
assert result.resource_templates == snapshot(
|
||||
[
|
||||
ResourceTemplate(name="manuals", uri_template="manuals://{+path}"),
|
||||
ResourceTemplate(name="books", uri_template="books://{isbn}"),
|
||||
]
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""`docs/whats-new.md`: the v2 half of the low-level before/after example, proved against the real SDK.
|
||||
|
||||
The v1 half of that example targets the 1.x line and cannot run here; it was
|
||||
validated by running it verbatim against a real `mcp==1.28.1` install.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from mcp_types import INTERNAL_ERROR, INVALID_PARAMS, TextContent
|
||||
|
||||
from docs_src.whats_new import tutorial001
|
||||
from mcp import Client, MCPError
|
||||
|
||||
# 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_the_advertised_schema_is_the_literal_dict() -> None:
|
||||
"""Annotation 1: the schema is advertised to clients exactly as written."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.name == "search_books"
|
||||
assert tool.input_schema == {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
"required": ["query"],
|
||||
}
|
||||
|
||||
|
||||
async def test_a_valid_call_answers() -> None:
|
||||
"""The example works end to end through the in-process `Client`."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
result = await client.call_tool("search_books", {"query": "dune"})
|
||||
assert not result.is_error
|
||||
assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune'.")]
|
||||
|
||||
|
||||
async def test_arguments_are_not_validated_and_a_handler_exception_is_sanitized() -> None:
|
||||
"""Annotations 1, 6, and 7, in one flow.
|
||||
|
||||
A call missing the required `query` REACHES the handler (nothing validates
|
||||
arguments against `input_schema`; v1 rejected this call before the handler
|
||||
ran). The handler's own `KeyError` then comes back as a sanitized protocol
|
||||
error, never an `is_error=True` result the model could read. A call with no
|
||||
arguments at all exercises `params.arguments or {}` the same way.
|
||||
"""
|
||||
async with Client(tutorial001.server) as client:
|
||||
with pytest.raises(MCPError) as excinfo:
|
||||
await client.call_tool("search_books", {"limit": 5})
|
||||
assert excinfo.value.code == INTERNAL_ERROR
|
||||
assert excinfo.value.message == "Internal server error"
|
||||
|
||||
with pytest.raises(MCPError) as excinfo:
|
||||
await client.call_tool("search_books")
|
||||
assert excinfo.value.code == INTERNAL_ERROR
|
||||
assert excinfo.value.message == "Internal server error"
|
||||
|
||||
|
||||
async def test_an_unknown_tool_is_a_deliberate_wire_error() -> None:
|
||||
"""Annotation 5: a raised `MCPError` passes through with its code and message
|
||||
intact (the spec's answer for an unknown tool), unlike the sanitized path."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
with pytest.raises(MCPError) as excinfo:
|
||||
await client.call_tool("shelve_book", {"query": "dune"})
|
||||
assert excinfo.value.code == INVALID_PARAMS
|
||||
assert excinfo.value.message == "Unknown tool: shelve_book"
|
||||
Reference in New Issue
Block a user