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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:10:27 +08:00
commit 49b9bb6724
992 changed files with 161690 additions and 0 deletions
View File
+103
View File
@@ -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()
+213
View File
@@ -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"
+98
View File
@@ -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)"}
+209
View File
@@ -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
+185
View File
@@ -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'."}
+129
View File
@@ -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")])
)
+58
View File
@@ -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"
+116
View File
@@ -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"]
+88
View File
@@ -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)
+158
View File
@@ -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."]
+230
View File
@@ -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()
+144
View File
@@ -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)
+299
View File
@@ -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)]
+132
View File
@@ -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
+98
View File
@@ -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
+86
View File
@@ -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"
+196
View File
@@ -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"}
)
+31
View File
@@ -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!")]
)
+136
View File
@@ -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
+113
View File
@@ -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": "{}"}
+62
View File
@@ -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
+143
View File
@@ -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)
+62
View File
@@ -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"])]
+116
View File
@@ -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)
+197
View File
@@ -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)
+132
View File
@@ -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)
+35
View File
@@ -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"]
+80
View File
@@ -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"
+102
View File
@@ -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."}
+101
View File
@@ -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"),
)
]
+94
View File
@@ -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"
+54
View File
@@ -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",
)
]
+119
View File
@@ -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()
+52
View File
@@ -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"
+62
View File
@@ -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
+98
View File
@@ -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"]
+145
View File
@@ -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}"
+192
View File
@@ -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)
+303
View File
@@ -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
+23
View File
@@ -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})
)
+107
View File
@@ -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)
+281
View File
@@ -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))"'
)
+214
View File
@@ -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}"),
]
)
+65
View File
@@ -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"