Rebuild the docs around tested examples; shrink README.v2.md to a pitch (#2978)

This commit is contained in:
Max 2026-06-26 12:49:19 +02:00 • committed by GitHub
commit 411a6d3980
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 9510 additions and 2639 deletions

View file

213
tests/docs_src/test_asgi.py Normal file
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"

View file

@ -0,0 +1,98 @@
"""`docs/advanced/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)"}

View file

@ -0,0 +1,182 @@
"""`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, 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_mcpserver_does_not_implement_resource_subscriptions() -> None:
"""The Resources section: MCPServer advertises subscribe=False and rejects subscribe_resource with -32601."""
async with Client(tutorial004.mcp) as client:
assert client.server_capabilities.resources is not None
assert client.server_capabilities.resources.subscribe is False
with pytest.raises(MCPError) as exc_info:
await client.subscribe_resource("catalog://genres")
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'."}

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="Method not found"):
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")])
)

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"

View file

@ -0,0 +1,116 @@
"""`docs/tutorial/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"]

View file

@ -0,0 +1,88 @@
"""`docs/tutorial/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)

View file

@ -0,0 +1,144 @@
"""`docs/advanced/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)

View file

@ -0,0 +1,248 @@
"""`docs/tutorial/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
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 "Input should be a valid boolean" 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})

View file

@ -0,0 +1,98 @@
"""`docs/tutorial/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": False},
"resources": {"subscribe": False, "list_changed": False},
"tools": {"list_changed": False},
}
)
async with Client(MCPServer("Empty")) as client:
assert client.server_capabilities == declared

View file

@ -0,0 +1,86 @@
"""`docs/tutorial/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"

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!")]
)

View file

@ -0,0 +1,113 @@
"""`docs/tutorial/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": "{}"}

View file

@ -0,0 +1,62 @@
"""`docs/tutorial/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

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)

View file

@ -0,0 +1,62 @@
"""`docs/tutorial/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"])]

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)

103
tests/docs_src/test_mrtr.py Normal file
View file

@ -0,0 +1,103 @@
"""`docs/advanced/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,
CallToolResult,
CreateMessageRequest,
CreateMessageRequestParams,
ElicitRequest,
ElicitRequestFormParams,
ElicitResult,
InputRequiredResult,
TextContent,
)
from docs_src.mrtr import tutorial001, tutorial002
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_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.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_call_tool_raises_without_the_opt_in() -> None:
"""The page's `!!! check`: `allow_input_required` defaults to `False` and the result is a hard error."""
async with Client(tutorial001.server) as client:
with pytest.raises(RuntimeError) as exc:
await client.call_tool("provision", {"name": "orders"})
assert str(exc.value) == (
"Server returned InputRequiredResult; pass allow_input_required=True to receive it "
"and retry call_tool(..., input_responses=..., request_state=result.request_state)."
)
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: `while isinstance(result, InputRequiredResult)` is the whole client API, and it terminates."""
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"}, allow_input_required=True)
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)

View file

@ -0,0 +1,131 @@
"""`docs/advanced/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
# 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(DeprecationWarning, 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)

Some files were not shown because too many files have changed in this diff Show more