Rebuild the docs around tested examples; shrink README.v2.md to a pitch (#2978)
This commit is contained in:
parent
4caa41f6d5
commit
411a6d3980
33 changed files with 9510 additions and 2639 deletions
|
|
@ -509,7 +509,7 @@ def test_client_rejects_handshake_era_mode_at_construction() -> None:
|
|||
`__post_init__` with a hint to use `mode='legacy'` — the version-pin path is
|
||||
modern-only."""
|
||||
server = MCPServer("test")
|
||||
with pytest.raises(ValueError, match=r"handshake-era version — use mode='legacy'"):
|
||||
with pytest.raises(ValueError, match=r"handshake-era version; use mode='legacy'"):
|
||||
Client(server, mode="2025-06-18")
|
||||
with pytest.raises(ValueError, match=r"mode must be 'legacy', 'auto', or one of"):
|
||||
Client(server, mode="not-a-version")
|
||||
|
|
|
|||
0
tests/docs_src/__init__.py
Normal file
0
tests/docs_src/__init__.py
Normal file
213
tests/docs_src/test_asgi.py
Normal file
213
tests/docs_src/test_asgi.py
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
"""`docs/run/asgi.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import inspect
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from mcp_types import TextContent
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import PlainTextResponse, Response
|
||||
from starlette.routing import Mount, Route
|
||||
|
||||
from docs_src.asgi import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006
|
||||
from mcp import Client
|
||||
from mcp.server import MCPServer
|
||||
|
||||
# See test_index.py for why this is a per-module mark and not a conftest hook.
|
||||
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
|
||||
|
||||
|
||||
async def test_streamable_http_app_is_a_starlette_app_with_one_route() -> None:
|
||||
"""tutorial001: the factory returns a Starlette application with a single route at `/mcp`."""
|
||||
(route,) = tutorial001.app.routes
|
||||
assert isinstance(route, Route)
|
||||
assert route.path == "/mcp"
|
||||
|
||||
|
||||
async def test_the_server_behind_the_app_is_unchanged() -> None:
|
||||
"""tutorial001: wrapping the server in an ASGI app changes nothing about its tools."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
result = await client.call_tool("add_note", {"text": "milk"})
|
||||
assert result.content == [TextContent(type="text", text="Saved: milk")]
|
||||
assert result.structured_content == {"result": "Saved: milk"}
|
||||
|
||||
|
||||
async def test_streamable_http_app_takes_runs_options_except_port() -> None:
|
||||
"""The tip: every `run("streamable-http", ...)` option is here except `port`. `host` is one of them."""
|
||||
parameters = set(inspect.signature(MCPServer.streamable_http_app).parameters) - {"self"}
|
||||
assert parameters == {
|
||||
"streamable_http_path",
|
||||
"json_response",
|
||||
"stateless_http",
|
||||
"event_store",
|
||||
"retry_interval",
|
||||
"transport_security",
|
||||
"host",
|
||||
}
|
||||
|
||||
|
||||
async def test_a_request_before_the_session_manager_runs_is_rejected() -> None:
|
||||
"""The `!!! check`: nothing starts the session manager except its lifespan."""
|
||||
transport = httpx.ASGITransport(app=tutorial001.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
|
||||
with pytest.raises(RuntimeError, match=r"Task group is not initialized\. Make sure to use run\(\)\."):
|
||||
await http.post("/mcp")
|
||||
|
||||
|
||||
async def test_mounting_at_the_root_keeps_the_default_path() -> None:
|
||||
"""tutorial002: `Mount("/")` plus the default `streamable_http_path` leaves the endpoint at `/mcp`."""
|
||||
(mount,) = tutorial002.app.routes
|
||||
assert isinstance(mount, Mount)
|
||||
assert mount.path == ""
|
||||
(inner,) = mount.routes
|
||||
assert isinstance(inner, Route)
|
||||
assert inner.path == "/mcp"
|
||||
|
||||
|
||||
async def test_a_root_mount_swallows_routes_listed_after_it() -> None:
|
||||
"""The mounting bullet: `Mount("/")` matches every path, so your own routes go before it in the list."""
|
||||
|
||||
async def about(request: Request) -> Response:
|
||||
return PlainTextResponse("about")
|
||||
|
||||
mcp_app = MCPServer("Notes").streamable_http_app()
|
||||
listed_after = Starlette(routes=[Mount("/", app=mcp_app), Route("/about", about)])
|
||||
listed_before = Starlette(routes=[Route("/about", about), Mount("/", app=mcp_app)])
|
||||
|
||||
transport = httpx.ASGITransport(app=listed_after)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
|
||||
assert (await http.get("/about")).status_code == 404
|
||||
|
||||
transport = httpx.ASGITransport(app=listed_before)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
|
||||
assert (await http.get("/about")).status_code == 200
|
||||
|
||||
|
||||
async def test_the_host_lifespan_enters_the_session_manager() -> None:
|
||||
"""tutorial002: the host app's lifespan owns `session_manager.run()` and starts and stops cleanly."""
|
||||
async with tutorial002.lifespan(tutorial002.app):
|
||||
async with Client(tutorial002.mcp) as client:
|
||||
result = await client.call_tool("add_note", {"text": "milk"})
|
||||
assert result.structured_content == {"result": "Saved: milk"}
|
||||
|
||||
|
||||
async def test_two_servers_get_two_mounts() -> None:
|
||||
"""tutorial003: each server is mounted under its own prefix, each still ending in `/mcp`."""
|
||||
notes_mount, tasks_mount = tutorial003.app.routes
|
||||
assert isinstance(notes_mount, Mount)
|
||||
assert isinstance(tasks_mount, Mount)
|
||||
assert notes_mount.path == "/notes"
|
||||
assert tasks_mount.path == "/tasks"
|
||||
|
||||
|
||||
async def test_one_lifespan_starts_both_session_managers() -> None:
|
||||
"""tutorial003: a single `AsyncExitStack` lifespan runs both managers; both servers answer."""
|
||||
async with tutorial003.lifespan(tutorial003.app):
|
||||
async with Client(tutorial003.notes) as client:
|
||||
notes_result = await client.call_tool("add_note", {"text": "milk"})
|
||||
assert notes_result.structured_content == {"result": "Saved: milk"}
|
||||
async with Client(tutorial003.tasks) as client:
|
||||
tasks_result = await client.call_tool("add_task", {"title": "ship"})
|
||||
assert tasks_result.structured_content == {"result": "Created: ship"}
|
||||
|
||||
|
||||
async def test_streamable_http_path_moves_the_endpoint_to_the_mount_prefix() -> None:
|
||||
"""tutorial004: `streamable_http_path="/"` makes the `Mount` prefix the whole public path."""
|
||||
(mount,) = tutorial004.app.routes
|
||||
assert isinstance(mount, Mount)
|
||||
assert mount.path == "/notes"
|
||||
(inner,) = mount.routes
|
||||
assert isinstance(inner, Route)
|
||||
assert inner.path == "/"
|
||||
|
||||
|
||||
async def test_cors_exposes_the_session_id_header() -> None:
|
||||
"""tutorial005: the browser origin gets the three MCP methods and can read `Mcp-Session-Id`."""
|
||||
(middleware,) = tutorial005.app.user_middleware
|
||||
assert middleware.cls is CORSMiddleware
|
||||
transport = httpx.ASGITransport(app=tutorial005.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
|
||||
preflight = await http.options(
|
||||
"/mcp",
|
||||
headers={"Origin": "https://app.example.com", "Access-Control-Request-Method": "POST"},
|
||||
)
|
||||
assert preflight.status_code == 200
|
||||
assert preflight.headers["access-control-allow-methods"] == "GET, POST, DELETE"
|
||||
|
||||
response = await http.get("/not-the-endpoint", headers={"Origin": "https://app.example.com"})
|
||||
assert response.headers["access-control-allow-origin"] == "https://app.example.com"
|
||||
assert response.headers["access-control-expose-headers"] == "Mcp-Session-Id"
|
||||
|
||||
|
||||
async def test_custom_route_lands_next_to_the_mcp_endpoint() -> None:
|
||||
"""tutorial006: `@mcp.custom_route()` adds a plain Starlette route to the returned app."""
|
||||
mcp_route, health_route = tutorial006.app.routes
|
||||
assert isinstance(mcp_route, Route)
|
||||
assert isinstance(health_route, Route)
|
||||
assert mcp_route.path == "/mcp"
|
||||
assert health_route.path == "/health"
|
||||
|
||||
|
||||
async def test_the_health_check_answers_outside_the_protocol() -> None:
|
||||
"""tutorial006: `GET /health` is ordinary HTTP, with no session manager and no MCP."""
|
||||
transport = httpx.ASGITransport(app=tutorial006.app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http:
|
||||
response = await http.get("/health")
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
|
||||
INITIALIZE = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "b", "version": "1"}},
|
||||
}
|
||||
MCP_HEADERS = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}
|
||||
|
||||
|
||||
async def test_the_default_app_is_localhost_only() -> None:
|
||||
"""The "Localhost only" section: with no `transport_security=`, the app answers a real hostname
|
||||
with the page's `421 Invalid Host header` and a foreign Origin with `403 Invalid Origin header`,
|
||||
before any MCP code runs."""
|
||||
bare = MCPServer("Notes")
|
||||
app = bare.streamable_http_app()
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with bare.session_manager.run():
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
|
||||
wrong_host = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://localhost:8000") as http:
|
||||
wrong_origin = await http.post(
|
||||
"/mcp", json=INITIALIZE, headers={**MCP_HEADERS, "Origin": "https://app.example.com"}
|
||||
)
|
||||
assert (wrong_host.status_code, wrong_host.text) == (421, "Invalid Host header")
|
||||
assert (wrong_origin.status_code, wrong_origin.text) == (403, "Invalid Origin header")
|
||||
|
||||
|
||||
async def test_the_documented_browser_origin_works_end_to_end() -> None:
|
||||
"""tutorial005: the page's scenario for real. The public hostname, the browser origin, a
|
||||
realistic preflight naming the `Mcp-*` headers, then the actual request."""
|
||||
transport = httpx.ASGITransport(app=tutorial005.app)
|
||||
async with tutorial005.lifespan(tutorial005.app):
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
|
||||
preflight = await http.options(
|
||||
"/mcp",
|
||||
headers={
|
||||
"Origin": "https://app.example.com",
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "content-type, mcp-protocol-version, mcp-session-id",
|
||||
},
|
||||
)
|
||||
assert preflight.status_code == 200
|
||||
allowed = {h.strip().lower() for h in preflight.headers["access-control-allow-headers"].split(",")}
|
||||
assert {"content-type", "mcp-protocol-version", "mcp-session-id"} <= allowed
|
||||
|
||||
response = await http.post(
|
||||
"/mcp", json=INITIALIZE, headers={**MCP_HEADERS, "Origin": "https://app.example.com"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.headers["mcp-session-id"]
|
||||
assert response.headers["access-control-allow-origin"] == "https://app.example.com"
|
||||
assert response.headers["access-control-expose-headers"] == "Mcp-Session-Id"
|
||||
98
tests/docs_src/test_authorization.py
Normal file
98
tests/docs_src/test_authorization.py
Normal 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)"}
|
||||
182
tests/docs_src/test_client.py
Normal file
182
tests/docs_src/test_client.py
Normal 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'."}
|
||||
129
tests/docs_src/test_client_callbacks.py
Normal file
129
tests/docs_src/test_client_callbacks.py
Normal 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")])
|
||||
)
|
||||
58
tests/docs_src/test_client_transports.py
Normal file
58
tests/docs_src/test_client_transports.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"""`docs/client/transports.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
from docs_src.client_transports import tutorial001, tutorial004
|
||||
from mcp import Client
|
||||
from mcp.client.stdio import get_default_environment, stdio_client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
||||
# See test_index.py for why this is a per-module mark and not a conftest hook.
|
||||
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
|
||||
|
||||
|
||||
async def test_the_in_memory_program_on_the_page_runs(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""tutorial001's `main()` is the literal client program on the page; it runs clean end to end."""
|
||||
await tutorial001.main()
|
||||
assert "Found 3 books matching 'dune'." in capsys.readouterr().out
|
||||
|
||||
|
||||
async def test_in_memory_client_talks_to_the_server_object() -> None:
|
||||
"""tutorial001: passing the server object connects in-process. No subprocess, no port."""
|
||||
async with Client(tutorial001.mcp) as client:
|
||||
assert client.server_info.name == "Bookshop"
|
||||
assert client.protocol_version == "2026-07-28"
|
||||
result = await client.call_tool("search_books", {"query": "dune"})
|
||||
assert result.structured_content == {"result": "Found 3 books matching 'dune'."}
|
||||
|
||||
|
||||
async def test_constructing_a_client_does_not_connect_it() -> None:
|
||||
"""tutorial002: a URL string is accepted as-is, and nothing happens until `async with`."""
|
||||
client = Client("http://localhost:8000/mcp")
|
||||
with pytest.raises(RuntimeError, match="Client must be used within an async context manager"):
|
||||
client.session
|
||||
|
||||
|
||||
async def test_streamable_http_configuration_lives_on_the_httpx_client() -> None:
|
||||
"""tutorial003: `streamable_http_client` takes `http_client=`; there is no `headers=` or any other HTTP knob."""
|
||||
assert list(inspect.signature(streamable_http_client).parameters) == ["url", "http_client", "terminate_on_close"]
|
||||
|
||||
|
||||
async def test_stdio_parameters_are_wrapped_by_stdio_client() -> None:
|
||||
"""tutorial004: `stdio_client(params)` is the transport, and `Client` takes it like any other."""
|
||||
client = Client(stdio_client(tutorial004.server))
|
||||
with pytest.raises(RuntimeError, match="Client must be used within an async context manager"):
|
||||
client.session
|
||||
|
||||
|
||||
async def test_the_child_environment_is_an_allowlist(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""tutorial004: a variable set in the parent process is not inherited; `env=` adds it back explicitly."""
|
||||
monkeypatch.setenv("BOOKSHOP_API_KEY", "from-the-parent")
|
||||
inherited = get_default_environment()
|
||||
assert "PATH" in inherited
|
||||
assert "BOOKSHOP_API_KEY" not in inherited
|
||||
extra = tutorial004.server.env
|
||||
assert extra is not None
|
||||
assert (inherited | extra)["BOOKSHOP_API_KEY"] == "secret"
|
||||
116
tests/docs_src/test_completions.py
Normal file
116
tests/docs_src/test_completions.py
Normal 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"]
|
||||
88
tests/docs_src/test_context.py
Normal file
88
tests/docs_src/test_context.py
Normal 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)
|
||||
144
tests/docs_src/test_deprecated.py
Normal file
144
tests/docs_src/test_deprecated.py
Normal 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)
|
||||
248
tests/docs_src/test_elicitation.py
Normal file
248
tests/docs_src/test_elicitation.py
Normal 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})
|
||||
98
tests/docs_src/test_first_steps.py
Normal file
98
tests/docs_src/test_first_steps.py
Normal 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
|
||||
86
tests/docs_src/test_handling_errors.py
Normal file
86
tests/docs_src/test_handling_errors.py
Normal 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"
|
||||
31
tests/docs_src/test_index.py
Normal file
31
tests/docs_src/test_index.py
Normal 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!")]
|
||||
)
|
||||
113
tests/docs_src/test_lifespan.py
Normal file
113
tests/docs_src/test_lifespan.py
Normal 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": "{}"}
|
||||
62
tests/docs_src/test_logging.py
Normal file
62
tests/docs_src/test_logging.py
Normal 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
|
||||
143
tests/docs_src/test_lowlevel.py
Normal file
143
tests/docs_src/test_lowlevel.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"""`docs/advanced/low-level-server.md`: every claim the page makes, proved against the real SDK."""
|
||||
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import INTERNAL_ERROR, CallToolRequestParams, CallToolResult, ErrorData, RequestParams, TextContent
|
||||
|
||||
from docs_src.lowlevel import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006
|
||||
from mcp import Client, MCPError
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
|
||||
# See test_index.py for why this is a per-module mark and not a conftest hook.
|
||||
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
|
||||
|
||||
|
||||
async def test_the_input_schema_on_the_wire_is_the_dict_you_wrote() -> None:
|
||||
"""tutorial001: nothing is derived. `tools/list` returns the literal `input_schema` dict."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.name == "search_books"
|
||||
assert tool.description == "Search the catalog by title or author."
|
||||
assert tool.input_schema == snapshot(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}, "limit": {"type": "integer"}},
|
||||
"required": ["query", "limit"],
|
||||
}
|
||||
)
|
||||
assert tool.output_schema is None
|
||||
|
||||
|
||||
async def test_the_client_does_not_care_which_server_class_it_connects_to() -> None:
|
||||
"""tutorial001: `Client(server)` accepts a low-level `Server` and the call answers like **Tools**."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
result = await client.call_tool("search_books", {"query": "dune", "limit": 5})
|
||||
assert not result.is_error
|
||||
assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune' (showing up to 5).")]
|
||||
assert result.structured_content is None
|
||||
|
||||
|
||||
async def test_only_the_handlers_you_passed_become_capabilities() -> None:
|
||||
"""tutorial001: two tool handlers advertise `tools` and nothing else."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
assert client.server_capabilities.model_dump(exclude_none=True) == snapshot({"tools": {"list_changed": False}})
|
||||
|
||||
|
||||
async def test_arguments_are_not_validated_against_your_schema() -> None:
|
||||
"""tutorial001: a call missing a `required` argument still reaches the handler and blows up there."""
|
||||
async with Client(tutorial001.server) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("search_books", {"query": "dune"})
|
||||
assert exc_info.value.error == ErrorData(code=INTERNAL_ERROR, message="Internal server error", data=None)
|
||||
|
||||
|
||||
async def test_one_handler_routes_every_tool() -> None:
|
||||
"""tutorial002: `on_call_tool` is the single entry point; it dispatches on `params.name`."""
|
||||
async with Client(tutorial002.server) as client:
|
||||
assert [tool.name for tool in (await client.list_tools()).tools] == ["search_books", "add_book"]
|
||||
result = await client.call_tool("add_book", {"title": "Dune", "author": "Frank Herbert", "year": 1965})
|
||||
assert result.content == [TextContent(type="text", text="Added 'Dune' by Frank Herbert (1965).")]
|
||||
|
||||
|
||||
async def test_an_unknown_tool_name_becomes_a_protocol_error_not_a_tool_error() -> None:
|
||||
"""tutorial002: raising from a handler is a `-32603` JSON-RPC error, never an `is_error` result."""
|
||||
async with Client(tutorial002.server) as client:
|
||||
with pytest.raises(MCPError) as exc_info:
|
||||
await client.call_tool("does_not_exist", {})
|
||||
assert exc_info.value.error == ErrorData(code=INTERNAL_ERROR, message="Internal server error", data=None)
|
||||
|
||||
|
||||
async def test_output_schema_and_structured_content_are_both_yours_to_build() -> None:
|
||||
"""tutorial003: you declare the schema on the `Tool` and you build the matching payload."""
|
||||
async with Client(tutorial003.server) as client:
|
||||
(tool,) = (await client.list_tools()).tools
|
||||
assert tool.output_schema == snapshot(
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {"matches": {"type": "integer"}, "query": {"type": "string"}},
|
||||
"required": ["matches", "query"],
|
||||
}
|
||||
)
|
||||
result = await client.call_tool("search_books", {"query": "dune", "limit": 5})
|
||||
assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune'.")]
|
||||
assert result.structured_content == {"matches": 3, "query": "dune"}
|
||||
|
||||
|
||||
async def test_the_client_checks_the_schema_you_promised() -> None:
|
||||
"""The page's warning: a `structured_content` that violates your `output_schema` fails in `call_tool`."""
|
||||
|
||||
async def promise_breaker(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
|
||||
return CallToolResult(content=[TextContent(type="text", text="oops")], structured_content={"matches": "three"})
|
||||
|
||||
lying = Server("Bookshop", on_list_tools=tutorial003.list_tools, on_call_tool=promise_breaker)
|
||||
async with Client(lying) as client:
|
||||
with pytest.raises(RuntimeError, match="Invalid structured content returned by tool search_books"):
|
||||
await client.call_tool("search_books", {"query": "dune", "limit": 5})
|
||||
|
||||
|
||||
async def test_meta_reaches_the_client_application() -> None:
|
||||
"""tutorial004: `_meta=` on the result comes back as `result.meta` and serialises under `_meta`."""
|
||||
async with Client(tutorial004.server) as client:
|
||||
result = await client.call_tool("search_books", {"query": "dune", "limit": 5})
|
||||
assert result.meta == {"bookshop/record_ids": ["bk_17", "bk_42", "bk_99"]}
|
||||
assert result.model_dump(by_alias=True, exclude_none=True) == snapshot(
|
||||
{
|
||||
"_meta": {"bookshop/record_ids": ["bk_17", "bk_42", "bk_99"]},
|
||||
"content": [{"type": "text", "text": "Found 3 books matching 'dune'."}],
|
||||
"structuredContent": {"matches": 3, "query": "dune"},
|
||||
"isError": False,
|
||||
"resultType": "complete",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def test_the_lifespan_object_reaches_every_handler_with_its_type() -> None:
|
||||
"""tutorial005: what the lifespan yields is `ctx.lifespan_context`, typed by `Server[Catalog]`."""
|
||||
async with Client(tutorial005.server) as client:
|
||||
result = await client.call_tool("search_books", {"query": "dune"})
|
||||
assert result.content == [TextContent(type="text", text="Found 3 books: Dune, Dune Messiah, Children of Dune.")]
|
||||
|
||||
|
||||
async def test_add_request_handler_registers_a_method_the_constructor_does_not_know() -> None:
|
||||
"""tutorial006: the registry holds the handler and the params model it validates against."""
|
||||
entry = tutorial006.server.get_request_handler("bookshop/reindex")
|
||||
assert entry is not None
|
||||
assert entry.params_type is tutorial006.ReindexParams
|
||||
assert tutorial006.server.get_request_handler("bookshop/burn") is None
|
||||
|
||||
|
||||
async def test_a_custom_method_never_changes_the_advertised_capabilities() -> None:
|
||||
"""tutorial006: only the spec's method families map to capabilities. `bookshop/reindex` is invisible."""
|
||||
async with Client(tutorial006.server) as client:
|
||||
assert client.server_capabilities.model_dump(exclude_none=True) == snapshot({"tools": {"list_changed": False}})
|
||||
|
||||
|
||||
def test_initialize_is_reserved() -> None:
|
||||
"""The page's `ValueError`: the handshake belongs to the runner, not to `add_request_handler`."""
|
||||
server = Server("Bookshop")
|
||||
|
||||
async def grab_the_handshake(ctx: ServerRequestContext, params: RequestParams) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
with pytest.raises(ValueError, match="'initialize' is handled by the server runner"):
|
||||
server.add_request_handler("initialize", RequestParams, grab_the_handshake)
|
||||
62
tests/docs_src/test_media.py
Normal file
62
tests/docs_src/test_media.py
Normal 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"])]
|
||||
116
tests/docs_src/test_middleware.py
Normal file
116
tests/docs_src/test_middleware.py
Normal 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
103
tests/docs_src/test_mrtr.py
Normal 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)
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue