examples/stories/README.md
One feature per folder. Each story is a small, self-verifying program: a
server.py (plus, where the wire contract is worth seeing by hand, a
server_lowlevel.py) and a client.py whose main() makes assertions and
exits non-zero on failure. The code you read here is the same code CI runs —
there is no separate test double.
Every client.py starts from this skeleton — copy it, then replace the body
with the story's assertions:
"""One line: what this client proves."""
from mcp.client import Client
from stories._harness import Target, run_client
async def main(target: Target, *, mode: str = "auto") -> None:
async with Client(target, mode=mode) as client:
... # the story's assertions
if __name__ == "__main__":
run_client(main)
There are exactly two main shapes. A story that opens one connection
takes main(target: Target, ...). A story that opens more than one sets
multi_connection = true in manifest.toml, takes
main(targets: TargetFactory, ...), and calls targets() once per fresh
connection — a Client cannot be re-entered after exit. Nothing else changes
shape.
Story files import from stories._harness only these names: run_client,
target_from_args, Target, TargetFactory — plus AuthBuilder for the
auth stories. Everything else a story uses comes from public mcp.* modules.
The repetition this produces across stories is deliberate, not a refactor
waiting to happen: each client.py is a standalone, compiled doc page, so
when a public API changes, N red example files flag N doc pages. Don't pull
the Client(target, mode=mode) line (or anything around it) into a shared
helper. A story that can't be the canonical shape says why in its module
docstring's first line.
Start with the story's README, then server.py, then client.py. Every
client.py exports async def main(target, *, mode="auto") — or
main(targets, ...) for the stories that open more than one connection — and
constructs the Client itself, so the body opens with the one line a client
example exists to teach: async with Client(target, mode=mode) as client:.
The run_client(main) call in the __main__ block is only argv plumbing
(stdio vs --http, which mode to pass); it never hides how the client
connects.
From the repository root:
# stdio (default — the client spawns the server as a subprocess)
uv run python -m stories.tools.client
# HTTP, self-hosted — the client spawns the server on a real uvicorn socket on a
# port it owns, waits for it, runs, then terminates it. Nothing to background or kill.
uv run python -m stories.tools.client --http
# the same self-hosted run against the story's lowlevel-API server variant
uv run python -m stories.tools.client --http --server server_lowlevel
# HTTP against a server you run yourself
uv run python -m stories.tools.server --http --port 8000 # separate terminal
uv run python -m stories.tools.client --http http://127.0.0.1:8000/mcp
--http takes two forms. Bare --http is the canonical HTTP run — it is
complete on its own, and it is what every per-story README shows. --http <url> connects to a server you started yourself; the per-story READMEs spell
that out only where hosting is the lesson (the HTTP-hosting and auth stories).
--server <stem> swaps in a sibling server module on stdio and on the
self-hosted --http run; with --http <url> you already picked the server
when you started it. The auth stories (bearer_auth/, oauth/,
oauth_client_credentials/) self-host on their fixed :8000 instead of a
free port because their issuer/PRM metadata bake it in — :8000 must be
free, and the run refuses to start (rather than silently testing whatever is
there) if it is not.
The full matrix (every story × transport × era × server-variant) runs under pytest:
uv run --frozen pytest tests/examples/ # everything
uv run --frozen pytest tests/examples/ -k tools # one story
manifest.toml declares each story's transports, era, status,
and variants; tests/examples/ expands it.
_hosting.py adapts a story's build_server() / build_app() to argv (stdio
vs --http serving); _harness.py is the client-side mirror — it picks the
target that main() connects to (a stdio subprocess by default, a self-hosted
HTTP subprocess under bare --http, your URL under --http <url>). They
isolate the parts of the SDK's hosting surface
that are still moving — don't copy them into your own project; copy the
server.py / client.py bodies instead. _shared/ holds an in-process OAuth
authorization server reused by the auth stories.
The status column is the feature's standing in the protocol, from
manifest.toml: current, legacy (a 2025 handshake-era
mechanism with a 2026-era replacement), or deprecated (deprecated by
SEP-2577; functional through the deprecation window). Each non-current story's README
opens with a banner saying what replaces it.
| story | what it shows | status |
|---|---|---|
| — start here — | ||
tools | @mcp.tool(), schema inference, structured output, annotations | current |
prompts | @mcp.prompt(), list/get, argument completion | current |
resources | @mcp.resource(), list/read, URI templates | current |
lifespan | startup/shutdown lifespan, per-request state injection | current |
dual_era | one server factory serving both protocol eras; era-neutral accessors | current |
| — feature stories — | ||
streaming | progress notifications, in-flight logging, cancellation | current |
mrtr | InputRequiredResult round-trip: the Client auto-loop, a manual session-level loop, and the default requestState sealing (a tampered echo gets one frozen error) | current |
legacy_elicitation | server pauses a tool to ask the user (form + url) via a push request | legacy |
refund_desk | resolver DI: Annotated[T, Resolve(fn)] params filled server-side, hidden from the input schema | current |
sampling | server asks the client's LLM mid-tool (push request) | deprecated |
stickynotes | capstone: tools mutate state → resources + list_changed + elicit guard | current |
custom_methods | vendor-prefixed JSON-RPC via add_request_handler / send_request | current |
schema_validators | tool input schema from pydantic / TypedDict / dataclass / dict | current |
middleware | server-side request/response middleware | current |
parallel_calls | two clients rendezvous in one tool; per-call progress attribution | current |
roots | client-declared roots, server reads them via ctx | deprecated |
pagination | manual cursor loop over list endpoints | current |
error_handling | is_error results vs MCPError; ToolError | current |
serve_one | building a Connection by hand and calling serve_one directly | current |
| — HTTP hosting — | ||
stateless_legacy | streamable_http_app(stateless_http=True); the one-liner deploy | current |
json_response | json_response=True mode; raw 2026 POST envelope on the wire | current |
legacy_routing | classify_inbound_request() era routing in front of a sessionful 1.x deploy | current |
starlette_mount | mounting streamable_http_app() under a Starlette/FastAPI sub-path | current |
sse_polling | SEP-1699 closeSSE() + Last-Event-ID resume via EventStore | legacy |
standalone_get | server-initiated list_changed over the sessionful GET stream | legacy |
subscriptions | subscriptions/listen streams: ctx.notify_*, SubscriptionBus, ListenHandler | current |
reconnect | explicit discover(), persist DiscoverResult, zero-RTT reconnect | current |
bearer_auth | TokenVerifier + AuthSettings bearer gate, PRM metadata, get_access_token() | current |
oauth | full authorization_code grant against an in-process AS | current |
oauth_client_credentials | client_credentials grant; minimal in-process token endpoint | current |
identity_assertion | SEP-990 enterprise IdP flow: present an ID-JAG under the jwt-bearer grant | current |
| — deferred (README only) — | ||
caching | CacheableResult ttl/scope hints; client honouring | not yet implemented |
tasks | io.modelcontextprotocol/tasks extension | not yet implemented |
apps | MCP Apps: ui:// resource + _meta.ui | not yet implemented — #2896 |
skills | SEP-2640 skills extension | not yet implemented — #2896 |
events | io.modelcontextprotocol/events extension | not yet implemented |
The TypeScript SDK's repl, client-quickstart, and server-quickstart
examples are intentionally not ported (interactive / external network deps);
its hono example maps to starlette_mount/.