docs: restructure into topical sections and add the four most-asked-for pages (#3044)

This commit is contained in:
Max 2026-07-01 21:06:04 +01:00 • committed by GitHub
commit 220d362112
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 2403 additions and 343 deletions

View file

@ -1,4 +1,4 @@
"""`docs/advanced/authorization.md`: every claim the page makes, proved against the real SDK."""
"""`docs/run/authorization.md`: every claim the page makes, proved against the real SDK."""
import httpx
import pytest

View file

@ -1,4 +1,4 @@
"""`docs/advanced/caching.md`: every claim the page makes, proved against the real SDK."""
"""`docs/client/caching.md`: every claim the page makes, proved against the real SDK."""
from collections.abc import Mapping
from typing import Any, cast

View file

@ -1,4 +1,4 @@
"""`docs/tutorial/completions.md`: every claim the page makes, proved against the real SDK."""
"""`docs/servers/completions.md`: every claim the page makes, proved against the real SDK."""
import pytest
from inline_snapshot import snapshot

View file

@ -1,4 +1,4 @@
"""`docs/tutorial/context.md`: every claim the page makes, proved against the real SDK."""
"""`docs/handlers/context.md`: every claim the page makes, proved against the real SDK."""
import re

View file

@ -1,4 +1,4 @@
"""`docs/tutorial/dependencies.md`: every claim the page makes, proved against the real SDK."""
"""`docs/handlers/dependencies.md`: every claim the page makes, proved against the real SDK."""
from typing import Literal

View file

@ -0,0 +1,230 @@
"""`docs/run/deploy.md`: every claim the page makes, proved against the real SDK."""
import anyio
import httpx
import pytest
from mcp_types import (
INVALID_PARAMS,
CallToolResult,
ElicitResult,
InputRequiredResult,
ResourceUpdatedNotification,
SubscriptionFilter,
SubscriptionsListenRequest,
SubscriptionsListenRequestParams,
SubscriptionsListenResult,
TextContent,
)
from docs_src.deploy import tutorial001, tutorial002, tutorial003, tutorial004
from mcp import Client, MCPError
from mcp.server import MCPServer
from mcp.server.mcpserver import Context, RequestStateSecurity
from mcp.server.subscriptions import InMemorySubscriptionBus
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
_KEY = "0123456789abcdef0123456789abcdef" # 32 bytes: the smallest secret the SDK accepts.
INITIALIZE = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "b", "version": "1"}},
}
MCP_HEADERS = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}
# -- the Host allowlist ----------------------------------------------------------------
async def test_the_default_app_rejects_a_real_hostname_before_mcp_runs() -> None:
"""The section's `!!! check`: without `transport_security=`, a deployed hostname gets the page's exact 421."""
bare = MCPServer("Notes")
app = bare.streamable_http_app()
async with bare.session_manager.run():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://api.example.com") as h:
response = await h.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
assert (response.status_code, response.text) == (421, "Invalid Host header")
async def test_the_allowlisted_app_serves_its_hostname_and_still_rejects_others() -> None:
"""tutorial001: `allowed_hosts=` opens exactly the hostname you named, and nothing else."""
transport = httpx.ASGITransport(app=tutorial001.app)
async with tutorial001.mcp.session_manager.run():
async with httpx.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http:
allowed = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
async with httpx.AsyncClient(transport=transport, base_url="https://api.example.com") as http:
rejected = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
assert allowed.status_code == 200
assert allowed.headers["mcp-session-id"]
assert (rejected.status_code, rejected.text) == (421, "Invalid Host header")
# -- `requestState` across workers -----------------------------------------------------
async def _first_round(client: Client, amount: int) -> str:
"""Round one of `refund`: no answers yet, so the server returns the `InputRequiredResult`."""
first = await client.session.call_tool("refund", {"amount": amount}, allow_input_required=True)
assert isinstance(first, InputRequiredResult)
assert first.request_state is not None
return first.request_state
async def _retry(client: Client, amount: int, token: str) -> CallToolResult | InputRequiredResult:
"""The retry: same tool, same arguments, the elicited answer, and the echoed token."""
return await client.session.call_tool(
"refund",
{"amount": amount},
input_responses={"ok": ElicitResult(action="accept", content={"ok": True})},
request_state=token,
allow_input_required=True,
)
def _assert_frozen_rejection(exc: pytest.ExceptionInfo[MCPError]) -> None:
"""The one wire shape every inbound `requestState` verification failure produces."""
assert exc.value.error.code == INVALID_PARAMS
assert exc.value.error.message == "Invalid or expired requestState"
assert exc.value.error.data == {"reason": "invalid_request_state"}
async def test_a_retry_that_reaches_a_different_worker_is_rejected_by_default() -> None:
"""tutorial002: two default servers hold two `os.urandom(32)` keys, so a cross-instance retry is refused."""
worker_a = tutorial002.make_server()
worker_b = tutorial002.make_server()
with anyio.fail_after(5):
async with Client(worker_a) as on_a, Client(worker_b) as on_b:
token = await _first_round(on_a, 120)
with pytest.raises(MCPError) as exc:
await _retry(on_b, 120, token)
# Land back on the worker that minted the token and the identical retry completes.
second = await _retry(on_a, 120, token)
_assert_frozen_rejection(exc)
assert isinstance(second, CallToolResult)
assert second.content == [TextContent(type="text", text="refunded $120")]
async def test_a_refund_the_human_declined_is_not_issued() -> None:
"""tutorial002/003: the second round reads the answer, so anything but an accepted ok is no refund."""
server = tutorial002.make_server()
with anyio.fail_after(5):
async with Client(server) as client:
token = await _first_round(client, 120)
declined = await client.session.call_tool(
"refund",
{"amount": 120},
input_responses={"ok": ElicitResult(action="decline")},
request_state=token,
allow_input_required=True,
)
assert isinstance(declined, CallToolResult)
assert declined.content == [TextContent(type="text", text="refund cancelled")]
async def test_a_shared_key_and_name_let_any_worker_finish_a_round_trip() -> None:
"""tutorial003: instances built with the same key and the same name unseal what a sibling minted."""
worker_a = tutorial003.make_server(_KEY)
worker_b = tutorial003.make_server(_KEY)
with anyio.fail_after(5):
async with Client(worker_a) as on_a, Client(worker_b) as on_b:
token = await _first_round(on_a, 120)
second = await _retry(on_b, 120, token)
assert isinstance(second, CallToolResult)
assert not second.is_error
assert second.content == [TextContent(type="text", text="refunded $120")]
async def test_a_shared_key_is_not_enough_without_a_shared_name() -> None:
"""The `!!! warning`: the server name is the default `audience` claim, so keys alone don't cross instances."""
def named(name: str) -> MCPServer:
mcp = MCPServer(name, request_state_security=RequestStateSecurity(keys=[_KEY]))
@mcp.tool()
async def refund(amount: int, ctx: Context) -> str | InputRequiredResult:
if ctx.input_responses is None:
return InputRequiredResult(input_requests={"ok": tutorial002.CONFIRM}, request_state="pending")
return f"refunded ${amount}"
return mcp
with anyio.fail_after(5):
async with Client(named("billing-1")) as on_one, Client(named("billing-2")) as on_two:
token = await _first_round(on_one, 120)
with pytest.raises(MCPError) as exc:
await _retry(on_two, 120, token)
# Same keys AND the same name: back on the instance that minted it, the retry completes.
second = await _retry(on_one, 120, token)
_assert_frozen_rejection(exc)
assert isinstance(second, CallToolResult)
assert second.content == [TextContent(type="text", text="refunded $120")]
# -- change notifications across replicas ----------------------------------------------
class _Stream:
"""Collects a listen stream's frames and lets the test await arrival counts."""
def __init__(self) -> None:
self.received: list[object] = []
self._arrival = anyio.Event()
async def handler(self, message: object) -> None:
self.received.append(message)
self._arrival.set()
self._arrival = anyio.Event()
async def wait_for(self, count: int) -> None:
with anyio.fail_after(5):
while len(self.received) < count:
await self._arrival.wait()
async def test_one_bus_carries_a_publish_on_one_replica_to_a_stream_on_another() -> None:
"""tutorial004: a `subscriptions/listen` stream on replica A hears a publish that happened on replica B."""
bus = InMemorySubscriptionBus()
replica_a = tutorial004.make_server(bus)
replica_b = tutorial004.make_server(bus)
stream = _Stream()
with anyio.fail_after(10):
await _listen_and_edit(replica_a, replica_b, stream)
async def _listen_and_edit(replica_a: MCPServer, replica_b: MCPServer, stream: _Stream) -> None:
"""Open a listen stream on replica A, edit on replica B, and wait for the update to cross the bus."""
async with (
Client(replica_a, mode="2026-07-28", message_handler=stream.handler) as on_a,
Client(replica_b) as on_b,
):
async with anyio.create_task_group() as tg:
async def listen() -> None:
await on_a.session.send_request(
SubscriptionsListenRequest(
params=SubscriptionsListenRequestParams(
notifications=SubscriptionFilter(resource_subscriptions=["note://todo"])
)
),
SubscriptionsListenResult,
)
tg.start_soon(listen)
await stream.wait_for(1) # the acknowledgment: the stream is live on replica A
await on_b.call_tool("edit_note", {"name": "todo", "text": "water plants"})
await stream.wait_for(2)
updated = stream.received[1]
assert isinstance(updated, ResourceUpdatedNotification)
assert updated.params.uri == "note://todo"
tg.cancel_scope.cancel()

View file

@ -1,4 +1,4 @@
"""`docs/advanced/deprecated.md`: the page's behavioural claims, executed against the live SDK.
"""`docs/deprecated.md`: the page's behavioural claims, executed against the live SDK.
This chapter has no `docs_src/` example by design: it is the one page allowed to name
the deprecated methods, and a runnable example would teach exactly what the page tells

View file

@ -1,4 +1,4 @@
"""`docs/tutorial/elicitation.md`: every claim the page makes, proved against the real SDK."""
"""`docs/handlers/elicitation.md`: every claim the page makes, proved against the real SDK."""
from typing import Literal

View file

@ -1,4 +1,4 @@
"""`docs/tutorial/first-steps.md`: every claim the page makes, proved against the real SDK."""
"""`docs/get-started/first-steps.md`: every claim the page makes, proved against the real SDK."""
import pytest
from inline_snapshot import snapshot

View file

@ -1,4 +1,4 @@
"""`docs/tutorial/handling-errors.md`: every claim the page makes, proved against the real SDK."""
"""`docs/servers/handling-errors.md`: every claim the page makes, proved against the real SDK."""
import pytest
from mcp_types import INVALID_PARAMS, ErrorData, TextContent, TextResourceContents

View file

@ -1,4 +1,4 @@
"""`docs/advanced/identity-assertion.md`: every claim the page makes, proved against the real SDK."""
"""`docs/client/identity-assertion.md`: every claim the page makes, proved against the real SDK."""
import inspect
from urllib.parse import parse_qsl

View file

@ -0,0 +1,136 @@
"""`docs/run/legacy-clients.md`: every claim the page makes, proved against the real SDK."""
import inspect
import httpx
import pytest
from mcp_types import INVALID_REQUEST, ResourceUpdatedNotification, TextContent
from docs_src.legacy_clients import tutorial001, tutorial002, tutorial003
from mcp import Client, MCPError
from mcp.client.streamable_http import streamable_http_client
from mcp.server import MCPServer
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
INITIALIZE = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "b", "version": "1"}},
}
LIST_TOOLS = {"jsonrpc": "2.0", "id": 2, "method": "tools/list"}
MCP_HEADERS = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}
URL = "http://localhost:8000/mcp"
async def test_one_resolve_tool_serves_a_legacy_and_a_modern_client_at_once(
capsys: pytest.CaptureFixture[str],
) -> None:
"""tutorial001's `main()`, exactly as the page renders it: two eras of client, one server, one answer."""
await tutorial001.main()
assert capsys.readouterr().out == (
"""2025-11-25 {'result': "Reserved 2 of 'Dune'."}\n2026-07-28 {'result': "Reserved 2 of 'Dune'."}\n"""
)
async def test_neither_era_of_client_sees_the_resolved_parameter() -> None:
"""tutorial001: there is one tool schema. The `Resolve`-filled parameter is hidden from both eras."""
async with Client(tutorial001.mcp, mode="legacy") as legacy, Client(tutorial001.mcp) as modern:
for client in (legacy, modern):
(tool,) = (await client.list_tools()).tools
assert set(tool.input_schema["properties"]) == {"title"}
def test_streamable_http_app_has_no_era_knob() -> None:
"""The opener: nothing in `streamable_http_app()`'s signature selects, rejects, or configures an era."""
parameters = set(inspect.signature(MCPServer.streamable_http_app).parameters) - {"self"}
assert parameters == {
"streamable_http_path",
"json_response",
"stateless_http",
"event_store",
"retry_interval",
"transport_security",
"host",
}
async def test_a_legacy_session_is_minted_in_process_and_a_stray_session_id_is_a_404() -> None:
"""The cost section: a legacy `initialize` gets an `Mcp-Session-Id`, and a request naming a session
this process never minted gets a `404`. That miss is exactly what a load balancer without sticky
routing produces."""
app = MCPServer("Bookshop").streamable_http_app()
async with (
app.router.lifespan_context(app),
httpx.ASGITransport(app) as transport,
httpx.AsyncClient(transport=transport, base_url="http://localhost:8000") as http,
):
opened = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
assert opened.status_code == 200
assert opened.headers["mcp-session-id"]
stray = await http.post("/mcp", json=LIST_TOOLS, headers={**MCP_HEADERS, "Mcp-Session-Id": 32 * "f"})
assert stray.status_code == 404
async def test_stateless_http_never_mints_a_session() -> None:
"""The `stateless_http=True` section: the same legacy `initialize` no longer gets an `Mcp-Session-Id`."""
app = MCPServer("Bookshop").streamable_http_app(stateless_http=True)
async with (
app.router.lifespan_context(app),
httpx.ASGITransport(app) as transport,
httpx.AsyncClient(transport=transport, base_url="http://localhost:8000") as http,
):
opened = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS)
assert opened.status_code == 200
assert "mcp-session-id" not in opened.headers
async def test_stateless_http_kills_the_legacy_back_channel_and_only_the_legacy_one() -> None:
"""tutorial002: over the same `stateless_http=True` app, the modern client still gets its answer and
the legacy client's call fails as the top-level `MCPError` the `!!! check` quotes."""
async with (
tutorial002.app.router.lifespan_context(tutorial002.app),
httpx.ASGITransport(tutorial002.app) as transport,
httpx.AsyncClient(transport=transport) as http,
):
modern_target = streamable_http_client(URL, http_client=http)
async with Client(modern_target, elicitation_callback=tutorial001.answer) as modern:
assert modern.protocol_version == "2026-07-28"
result = await modern.call_tool("reserve", {"title": "Dune"})
assert result.content == [TextContent(type="text", text="Reserved 2 of 'Dune'.")]
legacy_target = streamable_http_client(URL, http_client=http)
async with Client(legacy_target, mode="legacy", elicitation_callback=tutorial001.answer) as legacy:
assert legacy.protocol_version == "2025-11-25"
with pytest.raises(MCPError) as exc_info: # pragma: no branch
await legacy.call_tool("reserve", {"title": "Dune"})
assert exc_info.value.error.code == INVALID_REQUEST
assert exc_info.value.error.message == (
"Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests."
)
async def test_the_legacy_notification_verb_reaches_a_legacy_client() -> None:
"""tutorial003: `ctx.session.send_resource_updated` lands on the legacy client's standalone stream."""
received: list[object] = []
async def on_message(message: object) -> None:
received.append(message)
async with Client(tutorial003.mcp, mode="legacy", message_handler=on_message) as client:
result = await client.call_tool("restock", {"title": "Dune", "copies": 2})
assert not result.is_error
(notification,) = received
assert isinstance(notification, ResourceUpdatedNotification)
assert notification.params.uri == "stock://Dune"
async def test_calling_both_notification_verbs_is_safe_on_both_eras() -> None:
"""tutorial003: the two-line fork never errors, whichever era the caller is on."""
async with Client(tutorial003.mcp, mode="legacy") as legacy, Client(tutorial003.mcp) as modern:
for client in (legacy, modern):
result = await client.call_tool("restock", {"title": "Dune", "copies": 1})
assert not result.is_error

View file

@ -1,4 +1,4 @@
"""`docs/tutorial/lifespan.md`: every claim the page makes, proved against the real SDK."""
"""`docs/handlers/lifespan.md`: every claim the page makes, proved against the real SDK."""
import pytest
from inline_snapshot import snapshot

View file

@ -1,4 +1,4 @@
"""`docs/tutorial/logging.md`: every claim the page makes, proved against the real SDK."""
"""`docs/handlers/logging.md`: every claim the page makes, proved against the real SDK."""
import logging

View file

@ -1,4 +1,4 @@
"""`docs/tutorial/media.md`: every claim the page makes, proved against the real SDK."""
"""`docs/servers/media.md`: every claim the page makes, proved against the real SDK."""
import base64

View file

@ -1,4 +1,4 @@
"""`docs/advanced/multi-round-trip.md`: every claim the page makes, proved against the real SDK."""
"""`docs/handlers/multi-round-trip.md`: every claim the page makes, proved against the real SDK."""
import pytest
from inline_snapshot import snapshot

View file

@ -1,4 +1,4 @@
"""`docs/advanced/oauth-clients.md`: every claim the page makes, proved against the real SDK."""
"""`docs/client/oauth-clients.md`: every claim the page makes, proved against the real SDK."""
import inspect

View file

@ -1,4 +1,4 @@
"""`docs/advanced/opentelemetry.md`: every claim the page makes, proved against the real SDK."""
"""`docs/run/opentelemetry.md`: every claim the page makes, proved against the real SDK."""
import pytest
from logfire.testing import CaptureLogfire

View file

@ -1,4 +1,4 @@
"""`docs/tutorial/progress.md`: every claim the page makes, proved against the real SDK."""
"""`docs/handlers/progress.md`: every claim the page makes, proved against the real SDK."""
import inspect

View file

@ -1,4 +1,4 @@
"""`docs/tutorial/prompts.md`: every claim the page makes, proved against the real SDK."""
"""`docs/servers/prompts.md`: every claim the page makes, proved against the real SDK."""
import traceback

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