Back to Python Sdk

Story examples

examples/stories/README.md

2.0.0b19.4 KB
Original Source

Story examples

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.

Canonical shape

Every client.py starts from this skeleton — copy it, then replace the body with the story's assertions:

python
"""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.

How to read a story

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.

Running a story

From the repository root:

bash
# 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:

bash
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.

Layout

_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.

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.

storywhat it showsstatus
— start here —
tools@mcp.tool(), schema inference, structured output, annotationscurrent
prompts@mcp.prompt(), list/get, argument completioncurrent
resources@mcp.resource(), list/read, URI templatescurrent
lifespanstartup/shutdown lifespan, per-request state injectioncurrent
dual_eraone server factory serving both protocol eras; era-neutral accessorscurrent
— feature stories —
streamingprogress notifications, in-flight logging, cancellationcurrent
mrtrInputRequiredResult 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_elicitationserver pauses a tool to ask the user (form + url) via a push requestlegacy
refund_deskresolver DI: Annotated[T, Resolve(fn)] params filled server-side, hidden from the input schemacurrent
samplingserver asks the client's LLM mid-tool (push request)deprecated
stickynotescapstone: tools mutate state → resources + list_changed + elicit guardcurrent
custom_methodsvendor-prefixed JSON-RPC via add_request_handler / send_requestcurrent
schema_validatorstool input schema from pydantic / TypedDict / dataclass / dictcurrent
middlewareserver-side request/response middlewarecurrent
parallel_callstwo clients rendezvous in one tool; per-call progress attributioncurrent
rootsclient-declared roots, server reads them via ctxdeprecated
paginationmanual cursor loop over list endpointscurrent
error_handlingis_error results vs MCPError; ToolErrorcurrent
serve_onebuilding a Connection by hand and calling serve_one directlycurrent
— HTTP hosting —
stateless_legacystreamable_http_app(stateless_http=True); the one-liner deploycurrent
json_responsejson_response=True mode; raw 2026 POST envelope on the wirecurrent
legacy_routingclassify_inbound_request() era routing in front of a sessionful 1.x deploycurrent
starlette_mountmounting streamable_http_app() under a Starlette/FastAPI sub-pathcurrent
sse_pollingSEP-1699 closeSSE() + Last-Event-ID resume via EventStorelegacy
standalone_getserver-initiated list_changed over the sessionful GET streamlegacy
subscriptionssubscriptions/listen streams: ctx.notify_*, SubscriptionBus, ListenHandlercurrent
reconnectexplicit discover(), persist DiscoverResult, zero-RTT reconnectcurrent
bearer_authTokenVerifier + AuthSettings bearer gate, PRM metadata, get_access_token()current
oauthfull authorization_code grant against an in-process AScurrent
oauth_client_credentialsclient_credentials grant; minimal in-process token endpointcurrent
identity_assertionSEP-990 enterprise IdP flow: present an ID-JAG under the jwt-bearer grantcurrent
— deferred (README only) —
cachingCacheableResult ttl/scope hints; client honouringnot yet implemented
tasksio.modelcontextprotocol/tasks extensionnot yet implemented
appsMCP Apps: ui:// resource + _meta.uinot yet implemented — #2896
skillsSEP-2640 skills extensionnot yet implemented — #2896
eventsio.modelcontextprotocol/events extensionnot 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/.