Add story-style examples suite (27 stories + harness + CI) (#2957)

This commit is contained in:
Max 2026-06-26 12:02:27 +02:00 • committed by GitHub
commit 4caa41f6d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
160 changed files with 6801 additions and 26 deletions

View file

@ -79,6 +79,10 @@ jobs:
- name: Run pytest with coverage
shell: bash
env:
# tests/examples/test_stories_smoke.py is gated on this var; it spawns real
# stdio + uvicorn subprocesses, so run it on exactly one matrix cell.
MCP_EXAMPLES_SMOKE: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' && matrix.dep-resolution.name == 'locked' && '1' || '' }}
run: |
uv run --frozen --no-sync coverage erase
uv run --frozen --no-sync coverage run -m pytest -n auto

View file

@ -1,5 +1,22 @@
# Python SDK Examples
# Python SDK examples
This folders aims to provide simple examples of using the Python SDK. Please refer to the
[servers repository](https://github.com/modelcontextprotocol/servers)
for real-world servers.
- [`stories/`](stories/) — **the canonical reference.** One self-verifying
example per protocol feature, each with its own README. Start with
[`stories/tools/`](stories/tools/); the [stories README](stories/README.md)
has the full table and how to run them.
- [`snippets/`](snippets/) — short extracts embedded into `README.v2.md`. Kept
minimal and in sync with the top-level README; not intended to be run
standalone.
- [`servers/everything-server/`](servers/everything-server/) — the conformance
target for the cross-SDK
[conformance suite](https://github.com/modelcontextprotocol/conformance).
Exercises every server capability in one process.
- [`mcpserver/`](mcpserver/) — single-file v1-era examples retained for the
migration guide; superseded by `stories/` and slated for removal.
- [`clients/`](clients/) and the remaining [`servers/`](servers/) directories
(`simple-*`, `sse-polling-demo`, `structured-output-lowlevel`) — standalone
v1-era projects still linked from `README.v2.md`; retained pending
consolidation into `stories/`.
For real-world servers see the
[servers repository](https://github.com/modelcontextprotocol/servers).

16
examples/pyproject.toml Normal file
View file

@ -0,0 +1,16 @@
[project]
name = "mcp-example-stories"
version = "0.0.0"
description = "Self-verifying example suite for the MCP Python SDK (dev-only, not published)"
requires-python = ">=3.10"
dependencies = [
"mcp",
"tomli>=2.0; python_version < '3.11'",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["stories"]

164
examples/stories/README.md Normal file
View file

@ -0,0 +1,164 @@
# 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`](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`](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`](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`](tools/) | `@mcp.tool()`, schema inference, structured output, annotations | current |
| [`prompts`](prompts/) | `@mcp.prompt()`, list/get, argument completion | current |
| [`resources`](resources/) | `@mcp.resource()`, list/read, URI templates | current |
| [`lifespan`](lifespan/) | startup/shutdown lifespan, per-request state injection | current |
| [`dual_era`](dual_era/) | one server factory serving both protocol eras; era-neutral accessors | current |
| **— feature stories —** | | |
| [`streaming`](streaming/) | progress notifications, in-flight logging, cancellation | current |
| [`legacy_elicitation`](legacy_elicitation/) | server pauses a tool to ask the user (form + url) via a push request | legacy |
| [`sampling`](sampling/) | server asks the client's LLM mid-tool (push request) | deprecated |
| [`stickynotes`](stickynotes/) | capstone: tools mutate state → resources + `list_changed` + elicit guard | current |
| [`custom_methods`](custom_methods/) | vendor-prefixed JSON-RPC via `add_request_handler` / `send_request` | current |
| [`schema_validators`](schema_validators/) | tool input schema from pydantic / TypedDict / dataclass / dict | current |
| [`middleware`](middleware/) | server-side request/response middleware | current |
| [`parallel_calls`](parallel_calls/) | two clients rendezvous in one tool; per-call progress attribution | current |
| [`roots`](roots/) | client-declared roots, server reads them via `ctx` | deprecated |
| [`pagination`](pagination/) | manual cursor loop over list endpoints | current |
| [`error_handling`](error_handling/) | `is_error` results vs `MCPError`; `ToolError` | current |
| [`serve_one`](serve_one/) | building a `Connection` by hand and calling `serve_one` directly | current |
| **— HTTP hosting —** | | |
| [`stateless_legacy`](stateless_legacy/) | `streamable_http_app(stateless_http=True)`; the one-liner deploy | current |
| [`json_response`](json_response/) | `json_response=True` mode; raw 2026 POST envelope on the wire | current |
| [`legacy_routing`](legacy_routing/) | `classify_inbound_request()` era routing in front of a sessionful 1.x deploy | current |
| [`starlette_mount`](starlette_mount/) | mounting `streamable_http_app()` under a Starlette/FastAPI sub-path | current |
| [`sse_polling`](sse_polling/) | SEP-1699 `closeSSE()` + `Last-Event-ID` resume via `EventStore` | legacy |
| [`standalone_get`](standalone_get/) | server-initiated `list_changed` over the sessionful GET stream | legacy |
| [`reconnect`](reconnect/) | explicit `discover()`, persist `DiscoverResult`, zero-RTT reconnect | current |
| [`bearer_auth`](bearer_auth/) | `TokenVerifier` + `AuthSettings` bearer gate, PRM metadata, `get_access_token()` | current |
| [`oauth`](oauth/) | full `authorization_code` grant against an in-process AS | current |
| [`oauth_client_credentials`](oauth_client_credentials/) | `client_credentials` grant; minimal in-process token endpoint | current |
| **— deferred (README only) —** | | |
| [`caching`](caching/) | `CacheableResult` ttl/scope hints; client honouring | not yet implemented |
| [`mrtr`](mrtr/) | `InputRequiredResult` round-trip with `requestState` HMAC | not yet implemented — [#2898](https://github.com/modelcontextprotocol/python-sdk/issues/2898) |
| [`subscriptions`](subscriptions/) | `subscriptions/listen`, `ServerEventBus`, `Client.listen()` | not yet implemented — [#2901](https://github.com/modelcontextprotocol/python-sdk/issues/2901) |
| [`tasks`](tasks/) | `io.modelcontextprotocol/tasks` extension | not yet implemented |
| [`apps`](apps/) | MCP Apps: `ui://` resource + `_meta.ui` | not yet implemented — [#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896) |
| [`skills`](skills/) | SEP-2640 skills extension | not yet implemented — [#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896) |
| [`events`](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/`.

View file

@ -0,0 +1,6 @@
"""Self-verifying example suite for the MCP Python SDK.
Each story directory holds a ``server.py`` (and usually ``server_lowlevel.py``)
plus a ``client.py`` whose ``main(target, *, mode)`` runs against both.
``tests/examples/`` drives every story over an in-process matrix.
"""

View file

@ -0,0 +1,203 @@
"""Client-side scaffold for story examples.
A story's ``client.py`` imports ``Target`` (or ``TargetFactory``) for its ``main``
signature and calls ``run_client(main)`` from ``__main__``. The story owns the
``Client(target, mode=...)`` construction; this module only decides WHICH target
``__main__`` hands it.
"""
from __future__ import annotations
import socket
import sys
import traceback
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import AsyncExitStack, asynccontextmanager
from pathlib import Path
from typing import Any, TypeAlias
from urllib.parse import urlsplit
import anyio
import httpx
from mcp_types.version import LATEST_MODERN_VERSION
from mcp import StdioServerParameters, stdio_client
from mcp.client import Transport
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server
from mcp.server.mcpserver import MCPServer
if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib
Target: TypeAlias = "Server[Any] | MCPServer | Transport | str"
"""Anything ``Client(...)`` accepts: an in-process server, a ``Transport``, or an HTTP URL."""
TargetFactory = Callable[[], Target]
"""Yields a FRESH target against the same server/app on every call (``multi_connection`` stories)."""
AuthBuilder = Callable[[httpx.AsyncClient], httpx.Auth]
"""Builds an ``httpx.Auth`` bound to the in-process HTTP client (auth-story harness seam)."""
def argv_after(flag: str, *, default: str | None = None) -> str:
"""Return the argv token following ``flag``, or ``default`` when the flag is absent."""
try:
return sys.argv[sys.argv.index(flag) + 1]
except ValueError:
if default is None:
raise SystemExit(f"missing required {flag}") from None
return default
def target_from_args(file: str, url: str | None) -> TargetFactory:
"""Build a ``TargetFactory`` for the sibling server of the ``client.py`` at ``file``.
``url`` (already resolved by ``run_client``) targets that streamable-HTTP endpoint; ``None``
spawns ``<stem>.py`` over stdio per call, ``<stem>`` from ``--server`` (default ``server``).
"""
if url is not None:
return lambda: url
# stdio is legacy-only until serve_stdio() lands; the modern arm is --http only for now.
server = Path(file).parent / f"{argv_after('--server', default='server')}.py"
params = StdioServerParameters(command=sys.executable, args=[str(server)])
return lambda: stdio_client(params) # becomes Client(params) once that overload lands
def _explicit_http_url() -> str | None:
"""The URL token after ``--http``, or ``None`` when the flag stands alone (self-host)."""
rest = sys.argv[sys.argv.index("--http") + 1 :]
return rest[0] if rest and not rest[0].startswith("-") else None
def _free_port() -> int:
"""An OS-assigned free TCP port, released for the server subprocess to re-bind."""
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
async def _accepting(port: int) -> bool:
"""Whether something accepts a TCP connect on ``127.0.0.1:port`` right now."""
try:
stream = await anyio.connect_tcp("127.0.0.1", port)
except OSError:
return False
await stream.aclose()
return True
@asynccontextmanager
async def _self_hosted(name: str, cfg: dict[str, Any]) -> AsyncIterator[str]:
"""Serve the story's sibling server from a subprocess on a port this process owns; yield its URL.
Readiness is the first accepted TCP connect (bounded by ``run_client``'s
``anyio.fail_after``); exiting terminates the subprocess. Nothing to background or kill.
A subprocess that dies before serving, or a ``fixed_port`` someone else already holds,
is a loud ``SystemExit`` rather than a hang or a run against the wrong server.
"""
port: int = cfg["fixed_port"] or _free_port()
if cfg["fixed_port"] and await _accepting(port):
# The readiness probe below can't tell our child from a server already on the
# story's pinned port, so a foreign listener would be tested in its place.
raise SystemExit(
f"{name} self-hosts on :{port} but something is already serving there; "
f"stop it, or connect to it with --http <url>"
)
module = f"stories.{name}.{argv_after('--server', default='server')}"
serve = ["--http"] if cfg["server_export"] == "factory" else []
argv = [sys.executable, "-m", module, *serve, "--port", str(port)]
async with await anyio.open_process(argv, stdout=None, stderr=None) as server:
try:
while server.returncode is None and not await _accepting(port):
await anyio.sleep(0.05)
if server.returncode is not None:
raise SystemExit(f"{module} exited {server.returncode} before serving on :{port}")
yield f"http://127.0.0.1:{port}{cfg['mcp_path']}"
finally:
if server.returncode is None:
server.terminate()
def _story_cfg(name: str) -> dict[str, Any]:
"""The manifest entry for the story ``name`` with ``[defaults]`` applied."""
manifest: dict[str, Any] = tomllib.loads((Path(__file__).parent / "manifest.toml").read_text())
return manifest["defaults"] | manifest["story"].get(name, {})
def _authed_targets(url: str, http: httpx.AsyncClient) -> TargetFactory:
"""Fresh streamable-HTTP transports over an already-authed ``httpx`` client."""
return lambda: streamable_http_client(url, http_client=http)
def run_client(main: Callable[..., Awaitable[None]]) -> None:
"""Entry point for ``if __name__ == "__main__"`` in every ``client.py``.
Resolves the argv target — stdio (the default), ``--http <url>`` for a server you run, or
bare ``--http`` to self-host the sibling server in a subprocess it owns — and calls ``main``
with an explicit ``mode=``. A ``build_auth`` export auths the HTTP target. ``OK``/``FAIL``, exit 0/1.
"""
globals_ = getattr(main, "__globals__", {})
file = str(globals_.get("__file__", "<unknown>"))
name = Path(file).parent.name
cfg = _story_cfg(name)
build_auth: AuthBuilder | None = globals_.get("build_auth")
transport = "http" if "--http" in sys.argv else "stdio"
if cfg["server_export"] == "app" and transport != "http":
raise SystemExit(
f"{name} exports an ASGI app (no stdio entry point); self-host it over HTTP:\n"
f" python -m stories.{name}.client --http"
)
if cfg["needs_http"] and transport != "http":
raise SystemExit(f"{name} asserts on raw HTTP responses; run it with --http")
explicit_url = _explicit_http_url() if transport == "http" else None
# The era is an axis of the story matrix, so ``mode=`` is always passed explicitly
# even though it often matches the ``Client`` default of "auto". stdio is legacy-only
# until the SDK's stdio entry can negotiate the era, so only --http gets a modern arm.
era = "modern" if transport == "http" and "--legacy" not in sys.argv else "legacy"
if cfg["era"] in ("legacy", "modern"):
era = cfg["era"]
if cfg["era"] == "dual-in-body":
# The story pins its connection modes inside ``main`` itself, so hand it "auto"
# (the ``Client`` default) and let those in-body pins decide. A hard version pin
# here would skip the discover probe and leave ``server_info`` blank.
era = "in-body"
mode = {"modern": LATEST_MODERN_VERSION, "legacy": "legacy", "in-body": "auto"}[era]
async def _run() -> None:
with anyio.fail_after(cfg["timeout_s"]):
async with AsyncExitStack() as stack:
url = explicit_url
if transport == "http" and url is None:
url = await stack.enter_async_context(_self_hosted(name, cfg))
targets = target_from_args(file, url)
if url is None or (build_auth is None and not cfg["needs_http"]):
await main(targets if cfg["multi_connection"] else targets(), mode=mode)
return
# Auth and needs_http stories want the raw httpx client underneath the transport:
# build_auth threads an httpx.Auth onto it (Client(url, auth=...) doesn't exist
# yet), and needs_http stories assert on raw responses, so root the client at the
# server origin and relative paths like "/mcp" resolve.
parts = urlsplit(url)
base = f"{parts.scheme}://{parts.netloc}"
http = await stack.enter_async_context(httpx.AsyncClient(base_url=base))
make = targets
if build_auth is not None:
http.auth = build_auth(http)
make = _authed_targets(url, http)
target: Any = make if cfg["multi_connection"] else make()
if cfg["needs_http"]:
await main(target, mode=mode, http=http)
else:
await main(target, mode=mode)
try:
anyio.run(_run)
except Exception:
print(f"FAIL: {name} ({transport}/{era})", file=sys.stderr)
traceback.print_exc()
raise SystemExit(1) from None
print(f"OK: {name} ({transport}/{era})", file=sys.stderr)
raise SystemExit(0)

View file

@ -0,0 +1,87 @@
"""Server-side hosting scaffold for story examples.
A story's ``server.py`` / ``server_lowlevel.py`` imports only from here. The
marked lines touch entry-point APIs that a later release reshapes into
free-function entries; isolating them here keeps story bodies stable.
"""
from __future__ import annotations
import sys
from collections.abc import Callable
from typing import Any, TypeAlias
import anyio
import uvicorn
from starlette.applications import Starlette
from mcp.server.lowlevel import Server
from mcp.server.mcpserver import MCPServer
from mcp.server.stdio import stdio_server
from mcp.server.transport_security import TransportSecuritySettings
AnyServer: TypeAlias = "MCPServer | Server[Any]"
ServerFactory = Callable[[], AnyServer]
AppFactory = Callable[[], Starlette]
NO_DNS_REBIND = TransportSecuritySettings(enable_dns_rebinding_protection=False)
"""Harness servers bind 127.0.0.1 and the in-process httpx client sends no Origin header."""
def argv_after(flag: str, *, default: str | None = None) -> str:
"""Return the argv token following ``flag``, or ``default`` when the flag is absent."""
try:
return sys.argv[sys.argv.index(flag) + 1]
except ValueError:
if default is None:
raise SystemExit(f"missing required {flag}") from None
return default
def asgi_from(server: AnyServer, *, path: str = "/mcp") -> Starlette:
"""Wrap a server instance in its streamable-HTTP ASGI app for in-process driving."""
return server.streamable_http_app( # becomes free fn streamable_http(server, legacy=...)
streamable_http_path=path,
stateless_http=False, # bool folds into a legacy= enum in a later release
transport_security=NO_DNS_REBIND,
)
def run_server_from_args(build_server: ServerFactory) -> None:
"""Entry point for ``if __name__ == "__main__"`` in every ``server*.py``.
Bare argv serves over stdio; ``--http --port N [--path /mcp]`` serves over
uvicorn on 127.0.0.1:N.
"""
server = build_server()
if "--http" in sys.argv:
port = int(argv_after("--port", default="8000"))
path = argv_after("--path", default="/mcp")
anyio.run(_serve_http, server, port, path)
else:
anyio.run(_serve_stdio, server)
async def _serve_stdio(server: AnyServer) -> None:
if isinstance(server, MCPServer):
await server.run_stdio_async() # becomes await serve_stdio(server)
else:
async with stdio_server() as (read, write): # becomes await serve_stdio(server)
await server.run(read, write, server.create_initialization_options())
async def _serve_http(server: AnyServer, port: int, path: str) -> None:
app = asgi_from(server, path=path)
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
await uvicorn.Server(config).serve()
def run_app_from_args(build_app: AppFactory) -> None:
"""Entry point for ``if __name__ == "__main__"`` in app-exporting ``server*.py``.
App-exporting stories are HTTP-only; ``--port N`` serves the Starlette app over
uvicorn on 127.0.0.1:N (uvicorn drives the app's own lifespan). No stdio leg.
"""
port = int(argv_after("--port", default="8000"))
config = uvicorn.Config(build_app(), host="127.0.0.1", port=port, log_level="error")
anyio.run(uvicorn.Server(config).serve)

View file

@ -0,0 +1 @@
"""Shared scaffolding the auth/hosting stories import (not teaching surface)."""

View file

@ -0,0 +1,159 @@
"""Minimal in-process OAuth pieces for the auth stories.
A story-shaped subset; ``tests/interaction/auth`` keeps its own (richer) provider.
"""
from __future__ import annotations
import os
import secrets
import time
from urllib.parse import parse_qs, urlsplit
import httpx
from pydantic import AnyHttpUrl
from mcp.server.auth.provider import (
AccessToken,
AuthorizationCode,
AuthorizationParams,
OAuthAuthorizationServerProvider,
RefreshToken,
construct_redirect_uri,
)
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions
from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthToken
BASE_URL = "http://127.0.0.1:8000"
MCP_URL = f"{BASE_URL}/mcp"
REDIRECT_URI = f"{BASE_URL}/oauth/callback"
class InMemoryTokenStorage:
"""A ``TokenStorage`` that keeps tokens and DCR client info on instance attributes."""
tokens: OAuthToken | None = None
client_info: OAuthClientInformationFull | None = None
async def get_tokens(self) -> OAuthToken | None:
return self.tokens
async def set_tokens(self, tokens: OAuthToken) -> None:
self.tokens = tokens
async def get_client_info(self) -> OAuthClientInformationFull | None:
return self.client_info
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
self.client_info = client_info
class HeadlessOAuth:
"""Completes the authorize redirect in-process via the bound ``httpx`` client."""
def __init__(self) -> None:
self.authorize_url: str | None = None
self._http: httpx.AsyncClient | None = None
self._result = AuthorizationCodeResult(code="", state=None)
def bind(self, http_client: httpx.AsyncClient) -> None:
self._http = http_client
async def redirect_handler(self, authorization_url: str) -> None:
assert self._http is not None
self.authorize_url = authorization_url
# ``auth=None`` is load-bearing: re-entering the locked auth flow would deadlock.
response = await self._http.get(authorization_url, follow_redirects=False, auth=None)
assert response.status_code == 302, f"authorize returned {response.status_code}: {response.text}"
params = parse_qs(urlsplit(response.headers["location"]).query)
self._result = AuthorizationCodeResult(code=params.get("code", [""])[0], state=params.get("state", [None])[0])
async def callback_handler(self) -> AuthorizationCodeResult:
return self._result
class InMemoryAuthorizationServerProvider(
OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]
):
"""Minimal demo AS: DCR + authorize + auth-code exchange held in instance dicts.
``authorize`` auto-consents only when ``OAUTH_DEMO_AUTO_CONSENT=1``; otherwise it redirects
with ``error=interaction_required`` so a manual run shows where a real browser would open.
"""
def __init__(self) -> None:
self.clients: dict[str, OAuthClientInformationFull] = {}
self.codes: dict[str, AuthorizationCode] = {}
self.access_tokens: dict[str, AccessToken] = {}
def mint_access_token(self, *, client_id: str, scopes: list[str], resource: str | None = None) -> str:
access = f"access_{secrets.token_hex(16)}"
self.access_tokens[access] = AccessToken(
token=access, client_id=client_id, scopes=scopes, expires_at=int(time.time()) + 3600, resource=resource
)
return access
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
return self.clients.get(client_id)
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
assert client_info.client_id is not None
self.clients[client_info.client_id] = client_info
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
target = str(params.redirect_uri)
if os.environ.get("OAUTH_DEMO_AUTO_CONSENT") != "1":
return construct_redirect_uri(target, error="interaction_required", state=params.state)
assert client.client_id is not None
code = AuthorizationCode(
code=f"code_{secrets.token_hex(16)}",
client_id=client.client_id,
scopes=params.scopes or ["mcp"],
expires_at=time.time() + 300,
code_challenge=params.code_challenge,
redirect_uri=params.redirect_uri,
redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
resource=params.resource,
)
self.codes[code.code] = code
return construct_redirect_uri(target, code=code.code, state=params.state)
async def load_authorization_code(
self, client: OAuthClientInformationFull, authorization_code: str
) -> AuthorizationCode | None:
return self.codes.get(authorization_code)
async def exchange_authorization_code(
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
) -> OAuthToken:
scopes = authorization_code.scopes
access = self.mint_access_token(
client_id=authorization_code.client_id, scopes=scopes, resource=authorization_code.resource
)
del self.codes[authorization_code.code]
return OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=" ".join(scopes))
async def load_access_token(self, token: str) -> AccessToken | None:
return self.access_tokens.get(token)
async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None:
raise NotImplementedError
async def exchange_refresh_token(
self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]
) -> OAuthToken:
raise NotImplementedError
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
raise NotImplementedError
def auth_settings(*, required_scopes: list[str] | None = None) -> AuthSettings:
"""``AuthSettings`` for the co-hosted demo AS+RS on the loopback origin, DCR enabled."""
scopes = required_scopes or ["mcp"]
return AuthSettings(
issuer_url=AnyHttpUrl(BASE_URL),
resource_server_url=AnyHttpUrl(MCP_URL),
required_scopes=scopes,
client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=scopes, default_scopes=scopes),
)

View file

@ -0,0 +1,14 @@
# apps
MCP Apps: a tool result carries a `_meta.ui` reference to a `ui://` resource
that the host renders as an interactive surface. The story will register a
`@ui` resource and return it from a tool.
**Status: not yet implemented** ([#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896)).
The `extensions` capability map is not yet surfaced on `MCPServer`, so a server
cannot advertise Apps support and a client cannot negotiate it.
## Spec
[MCP Apps — extensions](https://modelcontextprotocol.io/specification/draft/extensions/apps)
· [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133)

View file

@ -0,0 +1,96 @@
# bearer-auth
Resource-server-only bearer auth. Pass a `TokenVerifier` + `AuthSettings`
(issuer, resource URL, required scopes) when building the streamable-HTTP app
and the SDK wires three things automatically: a bearer gate that answers 401 +
`WWW-Authenticate: Bearer ... resource_metadata=...` (or 403 `insufficient_scope`),
the RFC 9728 protected-resource-metadata document at
`/.well-known/oauth-protected-resource/mcp`, and the verified `AccessToken`
inside tool handlers via `get_access_token()`. The verifier here accepts one
static token — replace it with JWT verification or RFC 7662 introspection. No
authorization server; see `../oauth/` for the full grant flow.
## Run it
```bash
# HTTP — the client self-hosts the bearer-gated app, connects with the demo
# bearer token, then tears it down. Self-hosting uses this story's fixed :8000
# (the issuer/PRM metadata pin it), so :8000 must be free.
uv run python -m stories.bearer_auth.client --http
# same, against the lowlevel-API server variant
uv run python -m stories.bearer_auth.client --http --server server_lowlevel
# against a server you run yourself (real uvicorn on :8000). The next section's
# curl probes use it too and `kill` it when done. While it is up it owns :8000,
# so the two self-host lines above refuse to run rather than test it by mistake.
uv run python -m stories.bearer_auth.server --port 8000 &
SERVER_PID=$!
uv run python -m stories.bearer_auth.client --http http://127.0.0.1:8000/mcp
```
`Client(url)` has no `auth=` passthrough, so a target built from a bare URL
can't carry the token. Both runners close that gap the same way: `run_client`
(above) and the pytest harness thread the module's `build_auth` export onto the
`httpx.AsyncClient` underneath the transport and hand `main` a target that is
already routed through it.
## Try it without the SDK client
```bash
# no token → 401 + WWW-Authenticate pointing at the PRM document
curl -i -X POST http://127.0.0.1:8000/mcp \
-H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"ping"}'
# the RFC 9728 protected-resource-metadata document
curl -s http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp | jq
# done with the server you started in "Run it"
kill "$SERVER_PID"
```
## What to look at
- `client.py` `main` — opens with `async with Client(target, mode=mode) as
client:` and that is the whole program. The `target` it receives is a
transport that already carries the bearer token; nothing in the body knows
auth exists.
- `client.py` `build_auth` / `StaticBearerAuth` — bearer auth client-side is
five lines of `httpx.Auth`. `Client(url, auth=...)` is the ergonomic the SDK
is missing; until it lands, the auth has to be threaded onto the
`httpx.AsyncClient` underneath the transport, outside `main`.
- `server.py` — `MCPServer(token_verifier=..., auth=AuthSettings(...))` is the
whole recipe; `streamable_http_app()` reads those constructor kwargs and
mounts the bearer gate + PRM route.
- `server_lowlevel.py` — same gate, but `lowlevel.Server` takes
`auth=` / `token_verifier=` at **`streamable_http_app(...)` time**, not in the
constructor. `mcp.server.auth.*` imports are allowed in lowlevel files
(helper-tier).
- `whoami()` — `get_access_token()` returns the per-HTTP-request `AccessToken`.
It is **not** on `Context` (unlike other SDKs' `ctx.authInfo`); a later
release will namespace it as `ctx.transport.auth`.
## Caveats
- `transport_security=NO_DNS_REBIND` — DNS-rebinding protection is on by default
for localhost binds; the harness disables it because the in-process httpx
client sends no `Origin` header. Drop the kwarg for a real deployment.
- `RESOURCE_URL` is hard-coded to port 8000 (the harness's in-process origin).
If you change `--port`, edit `RESOURCE_URL` to match or the PRM document's
`resource` field will be wrong.
- Auth is HTTP-only; over stdio or the in-memory transport `get_access_token()`
returns `None` and there is no gate.
- The 401/403 status codes and `WWW-Authenticate` header are HTTP-level and
`Client` cannot observe them; they are pinned by
`tests/interaction/auth/test_bearer.py` and shown via `curl` above.
## Spec
[Authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)
· RFC 9728 (Protected Resource Metadata) · RFC 6750 (`WWW-Authenticate: Bearer`)
## See also
`oauth/` (full authorization-code grant with an in-process AS) ·
`oauth_client_credentials/` (M2M `client_credentials` grant) ·
`stateless_legacy/` (the un-gated hosting baseline).

View file

View file

@ -0,0 +1,48 @@
"""Call the bearer-gated server through an already-authed (``build_auth``, HTTP-only) transport; assert ``whoami``."""
from collections.abc import Generator
import httpx
from mcp.client import Client
from stories._harness import Target, run_client
from .server import DEMO_TOKEN, REQUIRED_SCOPE
class StaticBearerAuth(httpx.Auth):
"""``httpx.Auth`` that attaches a fixed ``Authorization: Bearer <token>`` to every request."""
def __init__(self, token: str) -> None:
self.token = token
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
request.headers["Authorization"] = f"Bearer {self.token}"
yield request
def build_auth(_http: httpx.AsyncClient) -> httpx.Auth:
"""The demo bearer token as an ``httpx.Auth``.
``Client(url, auth=...)`` doesn't exist yet, so the harness threads this onto the underlying
``httpx.AsyncClient`` and the target ``main`` receives is already routed through it.
"""
return StaticBearerAuth(DEMO_TOKEN)
async def main(target: Target, *, mode: str = "auto") -> None:
async with Client(target, mode=mode) as client:
listed = await client.list_tools()
assert [t.name for t in listed.tools] == ["whoami"]
result = await client.call_tool("whoami", {})
assert not result.is_error, result
assert result.structured_content == {
"subject": "demo-user",
"client_id": "demo-client",
"scopes": [REQUIRED_SCOPE],
}, result.structured_content
if __name__ == "__main__":
run_client(main)

View file

@ -0,0 +1,56 @@
"""Resource-server-only bearer auth: ``TokenVerifier``/``AuthSettings`` → 401/PRM/principal. Exports ``build_app()``."""
import time
from pydantic import AnyHttpUrl
from starlette.applications import Starlette
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken, TokenVerifier
from mcp.server.auth.settings import AuthSettings
from mcp.server.mcpserver import MCPServer
from stories._hosting import NO_DNS_REBIND, run_app_from_args
ISSUER = "https://auth.example.com"
RESOURCE_URL = "http://127.0.0.1:8000/mcp"
REQUIRED_SCOPE = "mcp:read"
DEMO_TOKEN = "demo-token"
class StaticTokenVerifier(TokenVerifier):
"""Accepts one hard-coded token. Replace with JWT verification or RFC 7662 introspection."""
async def verify_token(self, token: str) -> AccessToken | None:
if token != DEMO_TOKEN:
return None
return AccessToken(
token=token,
client_id="demo-client",
scopes=[REQUIRED_SCOPE],
expires_at=int(time.time()) + 3600,
subject="demo-user",
)
def build_app() -> Starlette:
mcp = MCPServer(
"bearer-auth-example",
token_verifier=StaticTokenVerifier(),
auth=AuthSettings(
issuer_url=AnyHttpUrl(ISSUER),
resource_server_url=AnyHttpUrl(RESOURCE_URL),
required_scopes=[REQUIRED_SCOPE],
),
)
@mcp.tool(description="Return the authenticated principal.")
def whoami() -> dict[str, str | list[str]]:
token = get_access_token()
assert token is not None # the bearer gate guarantees this on the HTTP path
return {"subject": token.subject or "", "client_id": token.client_id, "scopes": token.scopes}
return mcp.streamable_http_app(transport_security=NO_DNS_REBIND)
if __name__ == "__main__":
run_app_from_args(build_app)

View file

@ -0,0 +1,56 @@
"""Resource-server-only bearer auth (lowlevel API): same gate, hand-built ``CallToolResult``."""
from typing import Any
import mcp_types as types
from pydantic import AnyHttpUrl
from starlette.applications import Starlette
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.settings import AuthSettings
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from stories._hosting import NO_DNS_REBIND, run_app_from_args
from .server import ISSUER, REQUIRED_SCOPE, RESOURCE_URL, StaticTokenVerifier
def build_app() -> Starlette:
async def list_tools(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[
types.Tool(
name="whoami",
description="Return the authenticated principal.",
input_schema={"type": "object"},
),
]
)
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
assert params.name == "whoami"
token = get_access_token()
assert token is not None # the bearer gate guarantees this on the HTTP path
payload = {"subject": token.subject or "", "client_id": token.client_id, "scopes": token.scopes}
return types.CallToolResult(
content=[types.TextContent(text=f"{token.subject} via {token.client_id}")],
structured_content=payload,
)
server = Server("bearer-auth-example", on_list_tools=list_tools, on_call_tool=call_tool)
# lowlevel.Server takes auth at app-build time, not in the constructor (cf. MCPServer).
return server.streamable_http_app(
auth=AuthSettings(
issuer_url=AnyHttpUrl(ISSUER),
resource_server_url=AnyHttpUrl(RESOURCE_URL),
required_scopes=[REQUIRED_SCOPE],
),
token_verifier=StaticTokenVerifier(),
transport_security=NO_DNS_REBIND,
)
if __name__ == "__main__":
run_app_from_args(build_app)

View file

@ -0,0 +1,20 @@
# caching
A server stamps `CacheableResult` hints (`ttl_ms`, `cache_scope`) onto list and
read responses; a client honours them to skip redundant round-trips. The story
will show per-result overrides on `@mcp.resource()` / `@mcp.tool()` and the
client-side cache hit/miss path.
**Status: not yet implemented.** Server-side stamping landed (defaults
`ttl_ms=0`, `cache_scope="private"`), but the per-result override hook and the
client honouring path are not implemented yet. An example today could only show
the defaults being emitted, not acted on.
## Spec
[Caching — basic utilities](https://modelcontextprotocol.io/specification/draft/basic/utilities/caching)
## Working example elsewhere
The TypeScript SDK ships a runnable `caching` story:
[typescript-sdk/examples/caching](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/caching).

View file

@ -0,0 +1,53 @@
# custom-methods
Register and call a vendor-prefixed JSON-RPC method that is not part of the
MCP spec. The server uses the low-level `Server.add_request_handler` (there is
no `MCPServer` surface for this, so `server.py` is lowlevel-native and there is
no `server_lowlevel.py` sibling); the client drops to `client.session` to send
it.
## Run it
```bash
# stdio (default — the client spawns the server as a subprocess)
uv run python -m stories.custom_methods.client
# HTTP — the client self-hosts the server on a free port, runs, then tears it down
uv run python -m stories.custom_methods.client --http
```
## What to look at
- `client.py` `main` — the body opens with `Client(target, mode=mode)`. The
vendor request rides whichever protocol era `mode` selects; nothing else in
the story changes between eras.
- `server.py` `SearchParams` — subclasses `types.RequestParams` so `_meta`
(and on a 2026-07-28 connection, the reserved `io.modelcontextprotocol/*`
envelope keys) parse uniformly without extra code.
- `server.py` `add_request_handler("acme/search", SearchParams, search)` — the
method string is the wire `method`; use a vendor prefix so it can never
collide with a future spec method.
- `client.py` `client.session.send_request(...)` — `Client` only exposes spec
verbs, so vendor methods go through the underlying `ClientSession`. The
`cast("types.ClientRequest", ...)` is needed because `send_request`'s
`request` parameter is currently typed as the closed spec union; widening it
(or adding `Client.send_request`) is tracked for beta.
## Caveats
- The TypeScript SDK's equivalent example also shows a custom server→client
**notification** (`acme/searchProgress`). The Python client currently drops
any notification whose method is not in the spec registry
(`ClientSession._on_notify` → `KeyError` → silent drop), and there is no
`set_notification_handler` analogue. That half is omitted here.
## Spec
[Requests — basic protocol](https://modelcontextprotocol.io/specification/2025-11-25/basic#requests)
(JSON-RPC request shape; vendor method names live outside the spec's reserved
set).
## See also
`serve_one/` (the per-exchange driver that runs registered handlers),
`middleware/` (wrapping every registered handler, including vendor methods).

View file

@ -0,0 +1,39 @@
"""Send a vendor-prefixed request via the `client.session` escape hatch."""
from typing import Literal, cast
import mcp_types as types
from mcp.client import Client
from stories._harness import Target, run_client
class SearchParams(types.RequestParams):
query: str
limit: int = 10
class SearchRequest(types.Request[SearchParams, Literal["acme/search"]]):
method: Literal["acme/search"] = "acme/search"
params: SearchParams
class SearchResult(types.Result):
items: list[str]
async def main(target: Target, *, mode: str = "auto") -> None:
async with Client(target, mode=mode) as client:
# `Client` only exposes spec-defined verbs, so vendor methods have to drop one
# layer to `client.session` today — there is no `Client`-level API for them
# yet, and whether `.session` stays public is undecided. `send_request` is
# typed against the closed `ClientRequest` union, hence the cast; at runtime
# the body only calls `.model_dump()` and the unknown method skips the
# per-spec result-validation registry.
request = SearchRequest(params=SearchParams(query="mcp", limit=3))
result = await client.session.send_request(cast("types.ClientRequest", request), SearchResult)
assert result.items == ["mcp-0", "mcp-1", "mcp-2"], result
if __name__ == "__main__":
run_client(main)

View file

@ -0,0 +1,39 @@
"""Register a vendor-prefixed JSON-RPC method on the low-level Server.
`MCPServer` has no public surface for arbitrary method registration, so this
story's `server.py` is lowlevel-native (no `server_lowlevel.py` sibling).
"""
from typing import Any
import mcp_types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from stories._hosting import run_server_from_args
class SearchParams(types.RequestParams):
"""Subclass `RequestParams` so `_meta` (and the 2026 envelope keys) parse uniformly."""
query: str
limit: int = 10
class SearchResult(types.Result):
items: list[str]
def build_server() -> Server[Any]:
server = Server("custom-methods-example")
async def search(ctx: ServerRequestContext[Any], params: SearchParams) -> SearchResult:
items = [f"{params.query}-{i}" for i in range(params.limit)]
return SearchResult(items=items)
server.add_request_handler("acme/search", SearchParams, search)
return server
if __name__ == "__main__":
run_server_from_args(build_server)

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