chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run

This commit is contained in:
wehub-resource-sync
2026-07-13 12:10:27 +08:00
commit 49b9bb6724
992 changed files with 161690 additions and 0 deletions
+166
View File
@@ -0,0 +1,166 @@
# 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 |
| [`mrtr`](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`](legacy_elicitation/) | server pauses a tool to ask the user (form + url) via a push request | legacy |
| [`refund_desk`](refund_desk/) | resolver DI: `Annotated[T, Resolve(fn)]` params filled server-side, hidden from the input schema | current |
| [`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 |
| [`subscriptions`](subscriptions/) | `subscriptions/listen` streams: `ctx.notify_*`, `SubscriptionBus`, `ListenHandler` | current |
| [`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 |
| [`identity_assertion`](identity_assertion/) | SEP-990 enterprise IdP flow: present an ID-JAG under the `jwt-bearer` grant | current |
| **— deferred (README only) —** | | |
| [`caching`](caching/) | `CacheableResult` ttl/scope hints; client honouring | not yet implemented |
| [`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/`.
+6
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.
"""
+203
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)
+87
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)
+1
View File
@@ -0,0 +1 @@
"""Shared scaffolding the auth/hosting stories import (not teaching surface)."""
+172
View File
@@ -0,0 +1,172 @@
"""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, subject: 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,
subject=subject,
)
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, identity_assertion_enabled: bool = False
) -> AuthSettings:
"""``AuthSettings`` for the co-hosted demo AS+RS on the loopback origin, DCR enabled.
``identity_assertion_enabled`` passes through to the SEP-990 jwt-bearer grant flag.
"""
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),
identity_assertion_enabled=identity_assertion_enabled,
)
+40
View File
@@ -0,0 +1,40 @@
# apps
MCP Apps: a tool carries a `_meta.ui.resourceUri` reference to a `ui://`
resource that the host renders as an interactive surface. The server opts in via
the `Apps` extension (`io.modelcontextprotocol/ui`); the client negotiates it by
advertising the `text/html;profile=mcp-app` MIME type.
## Run it
```bash
# stdio (default — the client spawns the server as a subprocess)
uv run python -m stories.apps.client
# HTTP — the client self-hosts the server on a free port, runs, then tears it down
uv run python -m stories.apps.client --http
```
## What to look at
- `server.py` `MCPServer("apps-example", extensions=[apps])` — the extension
advertises `io.modelcontextprotocol/ui` under `ServerCapabilities.extensions`
and contributes the UI-bound tool and its `ui://` resource. `MCPServer` itself
never learns about "ui"; it applies a closed set of contributions.
- `server.py` `@apps.tool(resource_uri=...)` — stamps `_meta.ui.resourceUri` on
the tool; `add_html_resource` registers the matching `ui://` resource at
`text/html;profile=mcp-app`.
- `server.py` `client_supports_apps(ctx)` — SEP-2133 graceful degradation: a
client that did not negotiate Apps gets a text-only result.
- `client.py` `Client(target, extensions=[advertise(...)])` — the client advertises Apps
support so the server returns the UI-enabled result, then reads the tool's
`_meta.ui.resourceUri` and fetches that resource.
## Spec
[MCP Apps — extensions](https://modelcontextprotocol.io/specification/draft/extensions/apps)
· [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133)
## See also
`custom_methods/` (registering a non-spec method without an extension).
View File
+37
View File
@@ -0,0 +1,37 @@
"""Negotiate MCP Apps, discover a tool's `ui://` UI, fetch it, and call the tool."""
from mcp_types import TextContent, TextResourceContents
from mcp.client import Client, advertise
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID
from stories._harness import Target, run_client
async def main(target: Target, *, mode: str = "auto") -> None:
# Advertise MCP Apps support so the server returns the UI-enabled result; a
# client that omits this gets the text-only fallback (graceful degradation).
async with Client(
target, mode=mode, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]
) as client:
# The extensions capability map rides `server/discover` (modern only). On a
# legacy connection (today's stdio) it is absent, so assert it only when present.
if client.server_capabilities.extensions is not None:
assert client.server_capabilities.extensions == {EXTENSION_ID: {}}, client.server_capabilities.extensions
listed = await client.list_tools()
tool = next(t for t in listed.tools if t.name == "get_time")
assert tool.meta is not None, tool
assert tool.meta["ui"]["resourceUri"] == "ui://get-time/app.html", tool.meta
ui = await client.read_resource("ui://get-time/app.html")
contents = ui.contents[0]
assert isinstance(contents, TextResourceContents)
assert contents.mime_type == APP_MIME_TYPE, contents.mime_type
result = await client.call_tool("get_time", {})
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "2026-06-26T00:00:00Z", result.content[0].text
if __name__ == "__main__":
run_client(main)
+43
View File
@@ -0,0 +1,43 @@
"""MCP Apps: a tool bound to a `ui://` resource the host renders as an interactive surface.
`Apps` is an opt-in `Extension` passed to `MCPServer(extensions=[...])`. The
`@apps.tool(resource_uri=...)` decorator stamps `_meta.ui.resourceUri` onto the
tool; `add_html_resource` registers the matching `ui://` HTML resource. The tool
degrades gracefully: `client_supports_apps(ctx)` reports whether the client
negotiated Apps, so it returns text-only output otherwise.
"""
from mcp.server.apps import Apps, client_supports_apps
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.context import Context
from stories._hosting import run_server_from_args
RESOURCE_URI = "ui://get-time/app.html"
CLOCK_HTML = """<!doctype html>
<title>Current time</title>
<h1 id="now">…</h1>
<script>
window.addEventListener("message", (event) => {
const text = event.data?.result?.content?.[0]?.text;
if (text) document.getElementById("now").textContent = text;
});
</script>
"""
def build_server() -> MCPServer:
apps = Apps()
@apps.tool(resource_uri=RESOURCE_URI, title="Get Time", description="Return the current time.")
def get_time(ctx: Context) -> str:
now = "2026-06-26T00:00:00Z"
if not client_supports_apps(ctx):
return f"The time is {now}."
return now
apps.add_html_resource(RESOURCE_URI, CLOCK_HTML, title="Clock")
return MCPServer("apps-example", extensions=[apps])
if __name__ == "__main__":
run_server_from_args(build_server)
+96
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).
+48
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)
+56
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)
@@ -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)
+20
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).
+51
View File
@@ -0,0 +1,51 @@
# 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`.
`send_request` accepts any `types.Request` subclass.
## Caveats
- The TypeScript SDK's equivalent example also shows a custom server→client
**notification** (`acme/searchProgress`). The Python client can observe
vendor notifications via `NotificationBinding` (see
`docs/advanced/extensions.md`). That half is omitted here because the
lowlevel server has no surface for emitting vendor notifications yet.
## 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).
+37
View File
@@ -0,0 +1,37 @@
"""Send a vendor-prefixed request via the `client.session` escape hatch."""
from typing import Literal
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`
# accepts any `Request` subclass.
request = SearchRequest(params=SearchParams(query="mcp", limit=3))
result = await client.session.send_request(request, SearchResult)
assert result.items == ["mcp-0", "mcp-1", "mcp-2"], result
if __name__ == "__main__":
run_client(main)
+39
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)
+58
View File
@@ -0,0 +1,58 @@
# dual-era
One server factory, both protocol eras. A `mode="legacy"` client runs the
`initialize` handshake; a `mode="auto"` client probes `server/discover` and
adopts the 2026 stateless era — the same `greet` tool answers both and reports
which era served it via `ctx.request_context.protocol_version`. **Start here**
when migrating a v1 server: the entry owns the era decision, the server body
stays era-agnostic.
## Run it
```bash
# over HTTP — the same /mcp endpoint serves both eras; the client self-hosts
# the server on a free port, runs, then tears it down
uv run python -m stories.dual_era.client --http
# same, against the lowlevel-API server variant
uv run python -m stories.dual_era.client --http --server server_lowlevel
```
The bare stdio invocation (`uv run python -m stories.dual_era.client`) is
legacy-only until the SDK's stdio entry can negotiate the era, so the modern
leg fails there today — run over `--http`.
## What to look at
- `client.py` — both connections are visible, against the same `targets()`
factory: `Client(targets(), mode=mode)` (default `"auto"`, the
discover-then-fallback ladder) and `Client(targets(), mode="legacy")` (forces
the `initialize` handshake). The era decision is one explicit `mode=` argument
at construction; no date strings appear in the body.
- `client.py``client.protocol_version` / `client.server_info` /
`client.server_capabilities` are era-neutral: populated by `initialize` *or*
`server/discover`, whichever ran.
- `server.py``ctx.request_context.protocol_version` is the era branch key
(lowlevel: `ctx.protocol_version` directly). Compare against
`MODERN_PROTOCOL_VERSIONS`, never a date literal.
- **Where to read the negotiated version.** One value, three read paths:
`client.protocol_version` on the client after connect; `ctx.protocol_version`
inside a lowlevel handler; `ctx.request_context.protocol_version` inside an
`MCPServer` handler.
## Caveats
- `ctx.request_context.protocol_version` is the current way to read the
negotiated version; a later release will shorten it to `ctx.transport.*`.
- Over HTTP the built-in era branch is currently header-only — a 2026 client
that omits the `MCP-Protocol-Version` header is mis-routed to the legacy
path. The body-primary classifier lands in a later release.
## Spec
- [Versioning — backward compatibility](https://modelcontextprotocol.io/specification/draft/basic/versioning)
- [`server/discover`](https://modelcontextprotocol.io/specification/draft/server/discover)
## See also
`legacy_routing/` (route eras yourself), `reconnect/` (persist `DiscoverResult`
for zero-RTT reconnect).
+41
View File
@@ -0,0 +1,41 @@
"""Connect to the same server factory twice — once per era, so `main` takes `targets` — and assert both are served."""
import mcp_types as types
from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
from mcp.client import Client
from stories._harness import TargetFactory, run_client
async def main(targets: TargetFactory, *, mode: str = "auto") -> None:
# ── modern arm: the caller's mode (the real-user "auto" default) probes
# ``server/discover`` and adopts the result — no ``initialize`` handshake runs.
# The version/info/capabilities accessors are era-neutral.
async with Client(targets(), mode=mode) as modern:
assert modern.protocol_version == LATEST_MODERN_VERSION
assert modern.server_info.name == "dual-era-example"
assert modern.server_capabilities.tools is not None
listed = await modern.list_tools()
assert [t.name for t in listed.tools] == ["greet"]
result = await modern.call_tool("greet", {"name": "2026 client"})
first = result.content[0]
assert isinstance(first, types.TextContent)
assert first.text == f"Hello, 2026 client! (served on the modern era at {LATEST_MODERN_VERSION})"
# ── legacy arm: a fresh connection to the SAME server, pinned to the handshake era.
# The same accessors are populated identically — here by ``initialize``.
async with Client(targets(), mode="legacy") as legacy:
assert legacy.protocol_version == LATEST_HANDSHAKE_VERSION
assert legacy.server_info.name == "dual-era-example"
assert legacy.server_capabilities.tools is not None
result = await legacy.call_tool("greet", {"name": "2025 client"})
first = result.content[0]
assert isinstance(first, types.TextContent)
assert first.text == f"Hello, 2025 client! (served on the legacy era at {LATEST_HANDSHAKE_VERSION})"
if __name__ == "__main__":
run_client(main)
+25
View File
@@ -0,0 +1,25 @@
"""One MCPServer factory that serves both the 2025 handshake era and the 2026 stateless era."""
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from mcp.server.mcpserver import Context, MCPServer
from stories._hosting import run_server_from_args
def build_server() -> MCPServer:
# The same factory serves both eras with no configuration. Which era a request is
# on is decided by the entry point / transport, never by the server.
mcp = MCPServer("dual-era-example", instructions="A small dual-era demo server.")
@mcp.tool()
async def greet(name: str, ctx: Context) -> str:
"""Greet the caller and report which protocol era served the request."""
pv = ctx.request_context.protocol_version
era = "modern" if pv in MODERN_PROTOCOL_VERSIONS else "legacy"
return f"Hello, {name}! (served on the {era} era at {pv})"
return mcp
if __name__ == "__main__":
run_server_from_args(build_server)
@@ -0,0 +1,50 @@
"""One lowlevel Server factory that serves both the 2025 handshake era and the 2026 stateless era."""
from typing import Any
import mcp_types as types
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from stories._hosting import run_server_from_args
GREET_INPUT_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
}
def build_server() -> Server[Any]:
async def list_tools(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[
types.Tool(
name="greet",
description="Greet the caller and report which protocol era served the request.",
input_schema=GREET_INPUT_SCHEMA,
),
]
)
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
assert params.name == "greet" and params.arguments is not None
era = "modern" if ctx.protocol_version in MODERN_PROTOCOL_VERSIONS else "legacy"
text = f"Hello, {params.arguments['name']}! (served on the {era} era at {ctx.protocol_version})"
return types.CallToolResult(content=[types.TextContent(text=text)])
# The same factory serves both eras with no configuration. Which era a request is
# on is decided by the entry point / transport, never by the server.
return Server(
"dual-era-example",
instructions="A small dual-era demo server.",
on_list_tools=list_tools,
on_call_tool=call_tool,
)
if __name__ == "__main__":
run_server_from_args(build_server)
+52
View File
@@ -0,0 +1,52 @@
# error-handling
Tool *execution* failures travel as a successful `CallToolResult` with
`is_error=True` so the LLM can read the message and self-correct.
*Protocol* failures travel as a JSON-RPC error that the client catches as
`MCPError`. This story shows how to produce each from a tool body — `raise
ToolError(...)` vs `raise MCPError(...)` on `MCPServer`; an explicit
`is_error=True` return vs `raise MCPError` on `lowlevel.Server` — and how a
client tells them apart.
## Run it
```bash
# stdio (default — the client spawns the server as a subprocess)
uv run python -m stories.error_handling.client
# HTTP — the client self-hosts the server on a free port, runs, then tears it down
uv run python -m stories.error_handling.client --http
# same, against the lowlevel-API server variant
uv run python -m stories.error_handling.client --http --server server_lowlevel
```
## What to look at
- `client.py` `main` — opens with `async with Client(target, mode=mode) as
client:`. Inside it, `await` returns for `is_error` results and
`except MCPError` catches protocol errors; the client never auto-raises on
`is_error`.
- `server.py` — `raise ToolError(...)` vs `raise MCPError(...)`: same `raise`
keyword, opposite wire channel. The tool wrapper re-raises `MCPError`
verbatim and wraps everything else as an `is_error` result.
- `server_lowlevel.py` — no wrapper: you build `CallToolResult(is_error=True)`
yourself, and `MCPError` is the only way to pick a JSON-RPC error code.
## Caveats
- The "any other exception → `is_error` result" contract on `MCPServer` and the
"uncaught exception → `code=0`" behaviour on `lowlevel.Server` are **not
shown** — the contract is under design and the legacy code is a known spec
divergence. This story will grow those cases once the contract lands.
- `MCPServer` prefixes the execution-error message with
`"Error executing tool {name}: "`; build a `CallToolResult` directly from a
lowlevel handler if you need verbatim control.
## Spec
[Tools — error handling](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#error-handling)
## See also
`tools/` (the happy path), `streaming/` (cancellation as a third error-adjacent
surface).
+38
View File
@@ -0,0 +1,38 @@
"""Prove the two error channels: is_error results return; MCPError raises."""
from mcp_types import INVALID_PARAMS, TextContent
from mcp import MCPError
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:
# Success: is_error defaults to False.
ok = await client.call_tool("divide", {"a": 6, "b": 2})
assert ok.is_error is False, ok
assert isinstance(ok.content[0], TextContent)
assert ok.content[0].text == "3.0"
# Execution error: arrives as a *result* — await returns, no exception.
failed = await client.call_tool("divide", {"a": 1, "b": 0})
assert failed.is_error is True, "execution errors ride CallToolResult, not an exception"
assert isinstance(failed.content[0], TextContent)
# MCPServer prefixes "Error executing tool divide: ..."; lowlevel returns
# the message verbatim. Assert the substring both produce.
assert "cannot divide by zero" in failed.content[0].text
# Protocol error: arrives as a raised MCPError.
try:
await client.call_tool("restricted", {})
except MCPError as e:
assert e.code == INVALID_PARAMS
assert e.message == "this tool is gated"
assert e.data == {"reason": "demo"}
else:
raise AssertionError("expected MCPError for a protocol-level rejection")
if __name__ == "__main__":
run_client(main)
+35
View File
@@ -0,0 +1,35 @@
"""Two error channels: ToolError -> is_error result; MCPError -> JSON-RPC protocol error."""
from mcp_types import INVALID_PARAMS
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.exceptions import ToolError
from mcp.shared.exceptions import MCPError
from stories._hosting import run_server_from_args
def build_server() -> MCPServer:
mcp = MCPServer("error-handling-example")
@mcp.tool()
def divide(a: float, b: float) -> float:
"""Divide a by b. Division by zero is an execution error the LLM should see."""
if b == 0:
# ToolError is caught by the tool wrapper and returned as
# CallToolResult(is_error=True) — the LLM reads the message and can
# self-correct.
raise ToolError("cannot divide by zero")
return a / b
@mcp.tool()
def restricted() -> str:
"""A tool that always rejects the caller at the protocol level."""
# MCPError escapes the tool wrapper and becomes a JSON-RPC error
# response — the *host* sees code/message/data, not the LLM.
raise MCPError(code=INVALID_PARAMS, message="this tool is gated", data={"reason": "demo"})
return mcp
if __name__ == "__main__":
run_server_from_args(build_server)
@@ -0,0 +1,45 @@
"""Two error channels on lowlevel.Server: return is_error=True yourself, or raise MCPError."""
from typing import Any
import mcp_types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from mcp.shared.exceptions import MCPError
from stories._hosting import run_server_from_args
_TOOLS = [
types.Tool(name="divide", description="Divide a by b.", input_schema={"type": "object"}),
types.Tool(name="restricted", description="Always rejects.", input_schema={"type": "object"}),
]
def build_server() -> Server[Any]:
async def list_tools(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=_TOOLS)
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
args = params.arguments or {}
if params.name == "divide":
a, b = float(args["a"]), float(args["b"])
if b == 0:
# Execution error: build the is_error result yourself.
return types.CallToolResult(
content=[types.TextContent(text="cannot divide by zero")],
is_error=True,
)
return types.CallToolResult(content=[types.TextContent(text=str(a / b))])
if params.name == "restricted":
# Protocol error: raise MCPError; the dispatcher serialises it as a
# JSON-RPC error response with this code/message/data.
raise MCPError(code=types.INVALID_PARAMS, message="this tool is gated", data={"reason": "demo"})
raise MCPError(code=types.INVALID_PARAMS, message=f"Unknown tool: {params.name}")
return Server("error-handling-example", on_list_tools=list_tools, on_call_tool=call_tool)
if __name__ == "__main__":
run_server_from_args(build_server)
+21
View File
@@ -0,0 +1,21 @@
# events
The `io.modelcontextprotocol/events` extension: poll, push, and webhook
delivery of server-originated events on top of the `subscriptions/listen`
channel. The story will show a server emitting events and a client consuming
them over each delivery mode.
**Status: not yet implemented.** Depends on both the `subscriptions/listen`
runtime ([#2901](https://github.com/modelcontextprotocol/python-sdk/issues/2901))
and the `extensions` capability map
([#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896)) —
neither has landed.
## Spec
[Events — extensions](https://modelcontextprotocol.io/specification/draft/extensions/events)
· [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133)
## See also
`subscriptions/` (the listen channel this builds on).
+41
View File
@@ -0,0 +1,41 @@
# extensions
Writing your own extension (SEP-2133): one identifier bundles a settings entry
under `ServerCapabilities.extensions`, a contributed tool, and a vendor request
method gated on the client declaring the extension back.
## Run it
```bash
# stdio (default — the client spawns the server as a subprocess)
uv run python -m stories.extensions.client
# HTTP — the client self-hosts the server on a free port, runs, then tears it down
uv run python -m stories.extensions.client --http
```
## What to look at
- `server.py` `class Catalog(Extension)` — the whole extension: `settings()`
becomes the advertised capability entry, `tools()` contributes a regular tool,
`methods()` registers a vendor verb. The extension never holds the server; it
declares contributions and `MCPServer(extensions=[...])` consumes them.
- `server.py` `require_client_extension(ctx, EXTENSION_ID)` — the vendor method
rejects clients that did not declare the extension with `-32021` (missing
required client capability) and a machine-readable `requiredCapabilities`
payload.
- `client.py` `Client(target, extensions=[advertise(EXTENSION_ID)])` — the client-side
half of the negotiation; on 2026-07-28 it travels in the per-request `_meta`
envelope.
- `client.py` `client.session.send_request(...)` — vendor methods have no
`Client`-level helper; the session escape hatch sends them.
## Spec
[SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133)
· [Capabilities — `_meta` key grammar](https://modelcontextprotocol.io/specification/draft/basic/index)
## See also
`apps/` (the built-in MCP Apps extension) · `custom_methods/` (the same verb
registered on the lowlevel `Server` by hand, without an extension).
+53
View File
@@ -0,0 +1,53 @@
"""Discover an extension's capability entry, call its tool, then send its vendor method."""
from typing import Literal
import mcp_types as types
from mcp_types import TextContent
from mcp.client import Client, advertise
from stories._harness import Target, run_client
EXTENSION_ID = "com.example/catalog"
class SearchParams(types.RequestParams):
query: str
limit: int = 3
class SearchRequest(types.Request[SearchParams, Literal["com.example/search"]]):
method: Literal["com.example/search"] = "com.example/search"
params: SearchParams
class SearchResult(types.Result):
items: list[str]
async def main(target: Target, *, mode: str = "auto") -> None:
# Declare the extension client-side so the server's `require_client_extension`
# gate on `com.example/search` passes.
async with Client(target, mode=mode, extensions=[advertise(EXTENSION_ID)]) as client:
# The extensions capability map rides `server/discover` (modern only). On a
# legacy connection it is absent, so assert it only when present.
if client.server_capabilities.extensions is not None:
assert client.server_capabilities.extensions == {EXTENSION_ID: {"suggest": True}}, (
client.server_capabilities.extensions
)
# The extension's tool is a regular tool: listed and callable like any other.
listed = await client.list_tools()
assert [tool.name for tool in listed.tools] == ["suggest"], listed
result = await client.call_tool("suggest", {"prefix": "mcp"})
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "mcp-suggestion", result.content[0].text
# Vendor methods drop one layer to `client.session` (see custom_methods/).
request = SearchRequest(params=SearchParams(query="mcp", limit=3))
found = await client.session.send_request(request, SearchResult)
assert found.items == ["mcp-0", "mcp-1", "mcp-2"], found
if __name__ == "__main__":
run_client(main)
+64
View File
@@ -0,0 +1,64 @@
"""Package a vendor verb and a tool as a reusable, advertised extension (SEP-2133).
`custom_methods/` registers a verb on the lowlevel `Server` by hand; this story
bundles the same idea as an `Extension`: declared contributions, a settings entry
under `ServerCapabilities.extensions`, and a `require_client_extension` gate on
the vendor method.
"""
from collections.abc import Sequence
from typing import Any
import mcp_types as types
from pydantic import Field
from mcp.server.context import ServerRequestContext
from mcp.server.extension import Extension, MethodBinding, ToolBinding
from mcp.server.mcpserver import MCPServer, require_client_extension
from stories._hosting import run_server_from_args
EXTENSION_ID = "com.example/catalog"
class SearchParams(types.RequestParams):
"""Subclass `RequestParams` so `_meta` (and the 2026 envelope keys) parse uniformly."""
query: str
limit: int = Field(default=3, ge=1, le=25)
class SearchResult(types.Result):
items: list[str]
def suggest(prefix: str) -> str:
"""Suggest a catalog entry for a prefix."""
return f"{prefix}-suggestion"
async def search(ctx: ServerRequestContext[Any, Any], params: SearchParams) -> SearchResult:
require_client_extension(ctx, EXTENSION_ID)
return SearchResult(items=[f"{params.query}-{n}" for n in range(params.limit)])
class Catalog(Extension):
"""One identifier, three contributions: settings, a tool, a vendor method."""
identifier = EXTENSION_ID
def settings(self) -> dict[str, Any]:
return {"suggest": True}
def tools(self) -> Sequence[ToolBinding]:
return [ToolBinding(fn=suggest)]
def methods(self) -> Sequence[MethodBinding]:
return [MethodBinding("com.example/search", SearchParams, search)]
def build_server() -> MCPServer:
return MCPServer("extensions-example", extensions=[Catalog()])
if __name__ == "__main__":
run_server_from_args(build_server)
@@ -0,0 +1,103 @@
# identity-assertion
SEP-990 (Enterprise-Managed Authorization): the enterprise identity provider,
not the end user, decides which MCP servers a client may reach. The IdP signs
that decision into an Identity Assertion JWT Authorization Grant (an ID-JAG);
the client presents it to the MCP authorization server under the RFC 7523
`jwt-bearer` grant and gets an ordinary, audience-restricted access token back.
No browser, no consent screen, no dynamic client registration, no refresh
token. This story co-hosts the authorization server and the bearer-gated MCP
server on one app, stands in for the IdP with an in-process signer, and proves
the user the IdP named is the user the tool sees.
## Run it
```bash
# HTTP, self-hosted: the client spawns the co-hosted AS + MCP app, presents an
# ID-JAG, and asserts `whoami` reports the IdP's subject. Self-hosting uses
# this story's fixed :8000 (the issuer/PRM metadata bake it in), so :8000 must
# be free.
uv run python -m stories.identity_assertion.client --http
# same, against the lowlevel-API server variant
uv run python -m stories.identity_assertion.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.
uv run python -m stories.identity_assertion.server --port 8000 &
SERVER_PID=$!
uv run python -m stories.identity_assertion.client --http http://127.0.0.1:8000/mcp
```
`Client(url)` has no `auth=` passthrough, so both runners thread the module's
`build_auth` export (an `IdentityAssertionOAuthProvider`) 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
# the AS metadata advertises the jwt-bearer grant AND the ID-JAG grant profile
curl -s http://127.0.0.1:8000/.well-known/oauth-authorization-server \
| jq '{grant_types_supported, authorization_grant_profiles_supported}'
# dynamic client registration refuses the jwt-bearer grant: an ID-JAG client
# must be pre-registered out of band
curl -si http://127.0.0.1:8000/register -H 'content-type: application/json' \
-d '{"redirect_uris":["http://localhost:3030/cb"],"grant_types":["authorization_code","urn:ietf:params:oauth:grant-type:jwt-bearer"]}' \
| head -1
# done with the server you started in "Run it"
kill "$SERVER_PID"
```
## What to look at
- `client.py` `fetch_id_jag` — the one seam the SDK leaves you: given the
authorization server's issuer and the MCP server's resource identifier,
return a fresh ID-JAG. In production this is an RFC 8693 token exchange
against your IdP; here it calls the stand-in signer in `idp.py`.
- `client.py` `build_auth``IdentityAssertionOAuthProvider` is the same
`httpx.Auth` shape as every other provider. Note `issuer=ISSUER` with the
trailing slash: the provider compares it to the metadata document's `issuer`
by simple string comparison and refuses a mismatch before sending anything.
- `server.py` `exchange_identity_assertion` — the whole authorization-server
hook. The SDK authenticates the client and gates the grant; the signature,
`typ`, `aud`, `client_id`-match, `jti`-replay, and audience-restriction
checks inside the hook are the implementation's job.
- `server.py` `build_app``auth_settings(identity_assertion_enabled=True)`
is the one flag. Off (the default), `/token` answers the grant with
`unsupported_grant_type` even when the hook is implemented.
- `idp.py` — the claims an ID-JAG carries (`iss`, `sub`, `aud`, `client_id`,
`resource`, `scope`, `jti`, `iat`, `exp`) and its `typ: oauth-id-jag+jwt`
header.
## Caveats
- The IdP here is a module, not a service, and it signs with a shared HMAC
secret so the client process and a separately launched server process agree
on it. A real IdP signs with its private key, the authorization server
verifies against the IdP's published JWKS, and the client obtains the ID-JAG
over the network with an RFC 8693 token exchange.
- Co-hosting the authorization server and the MCP server on one app
(`auth_server_provider=`) is a demo convenience. SEP-990's model keeps them
separate, and either way the client only ever learns about the authorization
server from its own configuration, never from the MCP server.
- The provider's state is in-memory demo state: `seen_jtis` and the issued
tokens only ever grow. A real server evicts a `jti` once the assertion's
`exp` has passed and expires tokens out of its own store.
- `transport_security=NO_DNS_REBIND` is harness-only; drop it for a real
deployment.
- Auth is HTTP-only; over stdio or the in-memory transport there is no gate.
## Spec
[Enterprise-Managed Authorization (SEP-990)](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization)
· RFC 7523 (JWT bearer grant: the leg the SDK implements)
· RFC 8693 (token exchange: the IdP leg the SDK leaves to you)
· `draft-ietf-oauth-identity-assertion-authz-grant` (the ID-JAG profile)
## See also
`oauth/` (the interactive `authorization_code` grant) ·
`oauth_client_credentials/` (machine to machine, no user at all) ·
`bearer_auth/` (the resource-server half on its own).
@@ -0,0 +1,69 @@
"""HTTP-only SEP-990: `build_auth` presents an IdP-issued ID-JAG (jwt-bearer grant); `whoami` proves the subject."""
import httpx
from mcp.client import Client
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider
from stories._harness import Target, run_client
from stories._shared.auth import MCP_URL, InMemoryTokenStorage
from .idp import issue_id_jag
from .server import DEMO_CLIENT_ID, DEMO_CLIENT_SECRET, DEMO_SCOPE, ISSUER
# The end user the stand-in IdP says is signed in.
DEMO_SUBJECT = "alice@example.com"
async def fetch_id_jag(audience: str, resource: str) -> str:
"""Step one, the part the SDK does not do: obtain a fresh ID-JAG from the enterprise IdP.
A real implementation makes an RFC 8693 token-exchange request to the IdP, presenting the
signed-in user's ID token; `audience` (the authorization server's issuer) and `resource` (the
MCP server's identifier) pass straight through into the ID-JAG's `aud` and `resource` claims.
Here the stand-in IdP signs one in-process instead.
"""
return issue_id_jag(
subject=DEMO_SUBJECT, client_id=DEMO_CLIENT_ID, audience=audience, resource=resource, scope=DEMO_SCOPE
)
def build_auth(_http: httpx.AsyncClient) -> httpx.Auth:
"""An `IdentityAssertionOAuthProvider` for the pre-registered confidential client.
`issuer` is configuration, not discovery: the provider fetches metadata from this issuer's
well-known and never asks the MCP server which authorization server to use. The string must
equal the `issuer` its metadata serves byte for byte (note the trailing slash).
`Client(url, auth=...)` doesn't exist yet, so the harness threads this onto the underlying
`httpx.AsyncClient` and hands `main` a target that is already routed through it.
"""
return IdentityAssertionOAuthProvider(
server_url=MCP_URL,
storage=InMemoryTokenStorage(),
client_id=DEMO_CLIENT_ID,
client_secret=DEMO_CLIENT_SECRET,
issuer=ISSUER,
assertion_provider=fetch_id_jag,
scope=DEMO_SCOPE,
)
async def main(target: Target, *, mode: str = "auto") -> None:
# The target is already routed through `build_auth`'s provider. The first request 401s; the
# provider fetches the authorization server's metadata from the configured issuer (never from
# the MCP server), mints a fresh ID-JAG through `fetch_id_jag`, exchanges it at `/token` under
# the jwt-bearer grant, and retries with the bearer. No `/authorize`, no `/register`, no browser.
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_SUBJECT,
"client_id": DEMO_CLIENT_ID,
"scopes": [DEMO_SCOPE],
}, result.structured_content
if __name__ == "__main__":
run_client(main)
@@ -0,0 +1,43 @@
"""A stand-in enterprise identity provider: it signs the ID-JAGs the demo authorization server trusts.
In production the IdP is a separate service (Okta, Microsoft Entra ID, ...) and the client obtains
the ID-JAG from it with an RFC 8693 token-exchange request, presenting the signed-in user's ID
token. `issue_id_jag` collapses that whole step into one in-process signing call so the story runs
unattended; the README's caveats spell out what a real deployment changes.
"""
import time
import uuid
import jwt
IDP_ISSUER = "https://idp.example.com"
# Demo only: a real IdP signs with its private key and the authorization server verifies the
# signature against the IdP's published JWKS. A shared HMAC secret keeps this story self-contained.
IDP_SIGNING_KEY = "demo-idp-signing-key"
def issue_id_jag(*, subject: str, client_id: str, audience: str, resource: str, scope: str) -> str:
"""The IdP's short-lived, signed statement that `subject`, via `client_id`, may reach `resource`.
This is where the enterprise enforces policy: an IdP that does not authorize the combination
simply never issues the ID-JAG, and there is nothing for the client to present. The `typ`
header and the claim set are fixed by the Identity Assertion JWT Authorization Grant profile.
"""
now = int(time.time())
return jwt.encode(
{
"iss": IDP_ISSUER,
"sub": subject,
"aud": audience,
"client_id": client_id,
"resource": resource,
"scope": scope,
"jti": str(uuid.uuid4()),
"iat": now,
"exp": now + 300,
},
IDP_SIGNING_KEY,
algorithm="HS256",
headers={"typ": "oauth-id-jag+jwt"},
)
@@ -0,0 +1,110 @@
"""SEP-990 authorization server + bearer-gated MCP server on one app; exports `build_app()`.
`identity_assertion_enabled=True` turns the RFC 7523 jwt-bearer grant on, and the provider's
`exchange_identity_assertion` validates the IdP-signed ID-JAG and mints an access token bound to
the user and resource the assertion names. The MCP server half is ordinary bearer auth.
"""
import jwt
from pydantic import BaseModel
from starlette.applications import Starlette
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import IdentityAssertionParams, TokenError
from mcp.server.mcpserver import MCPServer
from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken
from stories._hosting import NO_DNS_REBIND, run_app_from_args
from stories._shared.auth import MCP_URL, InMemoryAuthorizationServerProvider, auth_settings
from .idp import IDP_ISSUER, IDP_SIGNING_KEY
# DEMO ONLY: never hard-code real credentials.
DEMO_CLIENT_ID = "finance-agent"
DEMO_CLIENT_SECRET = "demo-finance-agent-secret"
DEMO_SCOPE = "mcp"
# The exact `issuer` string this authorization server's metadata serves. The client must configure
# the byte-identical string: RFC 8414 issuer comparison is character for character, and the
# settings' `AnyHttpUrl` renders the path-less loopback origin with a trailing slash.
ISSUER = str(auth_settings().issuer_url)
class Whoami(BaseModel):
subject: str
client_id: str
scopes: list[str]
class IdentityAssertionAuthorizationServer(InMemoryAuthorizationServerProvider):
"""The demo in-process AS plus the SEP-990 hook: validate an ID-JAG, mint a bound token."""
def __init__(self) -> None:
super().__init__()
self.seen_jtis: set[str] = set()
# Pre-registered out of band. Dynamic client registration refuses the jwt-bearer grant,
# so an ID-JAG client always arrives already known and already confidential.
self.clients[DEMO_CLIENT_ID] = OAuthClientInformationFull(
client_id=DEMO_CLIENT_ID,
client_secret=DEMO_CLIENT_SECRET,
redirect_uris=None,
grant_types=[JWT_BEARER_GRANT_TYPE],
token_endpoint_auth_method="client_secret_post",
)
async def exchange_identity_assertion(
self, client: OAuthClientInformationFull, params: IdentityAssertionParams
) -> OAuthToken:
"""Validate the ID-JAG per RFC 7523 §3 and the SEP-990 processing rules, then issue the token."""
try:
header = jwt.get_unverified_header(params.assertion)
claims = jwt.decode(
params.assertion,
IDP_SIGNING_KEY,
algorithms=["HS256"],
issuer=IDP_ISSUER,
audience=ISSUER,
options={"require": ["iss", "sub", "aud", "exp", "iat", "jti", "client_id", "resource", "scope"]},
)
except jwt.InvalidTokenError as error:
raise TokenError("invalid_grant", "the assertion did not verify") from error
if header.get("typ") != "oauth-id-jag+jwt":
raise TokenError("invalid_grant", "the assertion is not an ID-JAG")
if claims["client_id"] != client.client_id:
raise TokenError("invalid_grant", "the assertion was issued to a different client")
if claims["resource"] != MCP_URL:
raise TokenError("invalid_target", "the assertion is for a resource this server does not serve")
if claims["jti"] in self.seen_jtis:
raise TokenError("invalid_grant", "the assertion has already been used")
self.seen_jtis.add(claims["jti"])
# Everything on the issued token comes from the validated assertion, the audience
# restriction above all: it binds the token to the ID-JAG's `resource` claim, never to
# the client-controlled `params.resource`. No refresh token is returned either; the IdP
# owns session lifetime by deciding whether to issue the next ID-JAG.
scopes = claims["scope"].split()
access = self.mint_access_token(
client_id=claims["client_id"], scopes=scopes, resource=claims["resource"], subject=claims["sub"]
)
return OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=" ".join(scopes))
def build_app() -> Starlette:
provider = IdentityAssertionAuthorizationServer()
# `auth_server_provider=` alone is enough: MCPServer derives a token verifier from it
# (passing both trips the mutex guard).
mcp = MCPServer(
"identity-assertion-example",
auth=auth_settings(required_scopes=[DEMO_SCOPE], identity_assertion_enabled=True),
auth_server_provider=provider,
)
@mcp.tool(description="Return the end user the ID-JAG named, plus the authenticated client and scopes.")
def whoami() -> Whoami:
token = get_access_token()
assert token is not None
assert token.subject is not None
return Whoami(subject=token.subject, 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)
@@ -0,0 +1,65 @@
"""SEP-990 authorization server + bearer-gated MCP server (lowlevel API); same app shape."""
import json
from typing import Any
import mcp_types as types
from starlette.applications import Starlette
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import ProviderTokenVerifier
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from stories._hosting import NO_DNS_REBIND, run_app_from_args
from stories._shared.auth import auth_settings
from .server import DEMO_SCOPE, IdentityAssertionAuthorizationServer
WHOAMI_OUTPUT_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"subject": {"type": "string"},
"client_id": {"type": "string"},
"scopes": {"type": "array", "items": {"type": "string"}},
},
"required": ["subject", "client_id", "scopes"],
}
def build_app() -> Starlette:
provider = IdentityAssertionAuthorizationServer()
async def list_tools(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[
types.Tool(
name="whoami",
description="Return the end user the ID-JAG named, plus the authenticated client and scopes.",
input_schema={"type": "object"},
output_schema=WHOAMI_OUTPUT_SCHEMA,
),
]
)
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
assert token.subject is not None
payload = {"subject": token.subject, "client_id": token.client_id, "scopes": token.scopes}
return types.CallToolResult(content=[types.TextContent(text=json.dumps(payload))], structured_content=payload)
server = Server("identity-assertion-example", on_list_tools=list_tools, on_call_tool=call_tool)
# Unlike MCPServer (auth on the constructor), lowlevel.Server takes auth at app-build time.
return server.streamable_http_app(
auth=auth_settings(required_scopes=[DEMO_SCOPE], identity_assertion_enabled=True),
token_verifier=ProviderTokenVerifier(provider),
auth_server_provider=provider,
transport_security=NO_DNS_REBIND,
)
if __name__ == "__main__":
run_app_from_args(build_app)
+70
View File
@@ -0,0 +1,70 @@
# json-response
`streamable_http_app(json_response=True)` — one `application/json` body per
request instead of an SSE stream. Useful for serverless / edge runtimes that
can't hold a stream open. The 2026-07-28 path is stateless and JSON-only today
regardless of the flag; setting it makes the legacy (2025-era) branch on the
same endpoint behave the same way.
## Run it
```bash
# HTTP — the client self-hosts the app on a free port, runs the high-level
# Client + raw-envelope probe, then tears it down
uv run python -m stories.json_response.client --http
# same, against the lowlevel-API server variant
uv run python -m stories.json_response.client --http --server server_lowlevel
# against a server you run yourself (real uvicorn on :8000)
uv run python -m stories.json_response.server --port 8000 &
SERVER_PID=$!
uv run python -m stories.json_response.client --http http://127.0.0.1:8000/mcp
# or POST the raw envelope yourself
curl -s http://127.0.0.1:8000/mcp \
-H 'content-type: application/json' \
-H 'accept: application/json, text/event-stream' \
-H 'mcp-protocol-version: 2026-07-28' \
-H 'mcp-method: tools/list' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"0"},"io.modelcontextprotocol/clientCapabilities":{}}}}'
kill "$SERVER_PID"
```
## What to look at
- `client.py` `main``async with Client(target, mode=mode) as client:` is an
ordinary high-level client; nothing about JSON mode is visible from this side.
The same `main` also takes the raw `httpx.AsyncClient` so it can prove what
the wire looks like underneath.
- `client.py` `RAW_ENVELOPE_BODY` / `MODERN_HEADERS` — the exact 2026 wire
shape: three `io.modelcontextprotocol/*` `_meta` keys replace the initialize
handshake; `MCP-Protocol-Version` + `Mcp-Method` headers mirror the body so
gateways can route without parsing JSON. `main` posts it by hand and asserts
a single `application/json` response with no `Mcp-Session-Id`.
- `server.py` `greet` calls `ctx.report_progress(0.5)` — and `main` proves the
client's `progress_callback` is **never invoked**: JSON mode has no
back-channel for mid-call notifications (the `progress_seen == []` assertion
flips to `== [0.5]` once SSE buffering lands for the modern path).
- `server_lowlevel.py` — same ASGI app built from `lowlevel.Server`; the
`json_response=` / `transport_security=` knobs live on `streamable_http_app`,
not the server class.
## Caveats
- DNS-rebinding protection is on by default; the harness disables it via
`NO_DNS_REBIND` because the in-process httpx client sends no `Origin` header.
- The `streamable_http_app()` call shape here will move when the free-function
entry lands (see `_hosting.py`).
- `Mcp-Name` is omitted for `tools/list` because the SDK only emits it on
`tools/call` today.
## Spec
[Streamable HTTP — 2026-07-28](https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http)
· [SEP-2243 standard headers](https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#standard-request-headers)
## See also
`stateless_legacy/` (the one-liner `stateless_http=True` deploy),
`legacy_routing/` (route by era at the entry), `streaming/` (progress that *is*
delivered — over stdio/SSE).
+69
View File
@@ -0,0 +1,69 @@
"""Plain ``Client`` against a JSON-only server: mid-call progress drops. HTTP-only — ``main`` also takes ``http``.
``RAW_ENVELOPE_BODY`` / ``MODERN_HEADERS`` are the exact wire shape a 2026-era client
sends — this is the only story that shows it. ``main`` posts that body by hand and
asserts the response is a single ``application/json`` body with no session id.
"""
import httpx
from mcp_types import TextContent
from mcp_types.version import LATEST_MODERN_VERSION
from mcp.client import Client
from stories._harness import Target, run_client
# The raw 2026-07-28 POST envelope: per-request `_meta` replaces the initialize handshake.
# The key/header strings are spelled out on purpose — this is the raw-wire story. In code
# use the named constants instead: `mcp_types.PROTOCOL_VERSION_META_KEY` /
# `CLIENT_INFO_META_KEY` / `CLIENT_CAPABILITIES_META_KEY` and
# `mcp.shared.inbound.MCP_PROTOCOL_VERSION_HEADER` (`legacy_routing/` shows that form).
RAW_ENVELOPE_BODY: dict[str, object] = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": LATEST_MODERN_VERSION,
"io.modelcontextprotocol/clientInfo": {"name": "raw-probe", "version": "0.0.0"},
"io.modelcontextprotocol/clientCapabilities": {},
}
},
}
MODERN_HEADERS: dict[str, str] = {
"accept": "application/json, text/event-stream",
"content-type": "application/json",
"mcp-protocol-version": LATEST_MODERN_VERSION,
"mcp-method": "tools/list",
}
async def main(target: Target, *, mode: str = "auto", http: httpx.AsyncClient) -> None:
async with Client(target, mode=mode) as client:
assert client.protocol_version == LATEST_MODERN_VERSION
progress_seen: list[float] = []
async def on_progress(progress: float, total: float | None, message: str | None) -> None:
progress_seen.append(progress)
result = await client.call_tool("greet", {"name": "json"}, progress_callback=on_progress)
assert isinstance(result.content[0], TextContent)
assert result.content[0].text == "Hello, json!"
assert result.structured_content == {"result": "Hello, json!"}, result
# The tool called report_progress(0.5) but the modern HTTP JSON path has no
# back-channel for mid-call notifications, so the callback is never invoked.
assert progress_seen == [], f"expected progress to be dropped, got {progress_seen}"
# Hand-craft a 2026 POST and assert it comes back as a single JSON body, no session.
response = await http.post("/mcp", json=RAW_ENVELOPE_BODY, headers=MODERN_HEADERS)
assert response.status_code == 200, response.text
assert response.headers["content-type"].split(";", 1)[0] == "application/json"
assert "mcp-session-id" not in response.headers
payload = response.json()
assert payload["id"] == 1
assert [t["name"] for t in payload["result"]["tools"]] == ["greet"]
if __name__ == "__main__":
run_client(main)
+27
View File
@@ -0,0 +1,27 @@
"""Serve over Streamable HTTP with JSON responses (no SSE stream); HTTP-only, so this exports ``build_app()``.
The 2026-07-28 path is stateless and JSON-only by construction today; the
``json_response=True`` flag also forces JSON for the legacy (2025-era) branch on
the same endpoint. Mid-call notifications are dropped.
"""
from starlette.applications import Starlette
from mcp.server.mcpserver import Context, MCPServer
from stories._hosting import NO_DNS_REBIND, run_app_from_args
def build_app() -> Starlette:
mcp = MCPServer("json-response-example")
@mcp.tool()
async def greet(name: str, ctx: Context) -> str:
"""Report progress mid-call, then return a greeting."""
await ctx.report_progress(0.5, total=1.0, message="halfway")
return f"Hello, {name}!"
return mcp.streamable_http_app(json_response=True, transport_security=NO_DNS_REBIND)
if __name__ == "__main__":
run_app_from_args(build_app)
@@ -0,0 +1,44 @@
"""Serve over Streamable HTTP with JSON responses (lowlevel API)."""
from typing import Any
import mcp_types as types
from starlette.applications import Starlette
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from stories._hosting import NO_DNS_REBIND, run_app_from_args
GREET_INPUT_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
}
def build_app() -> Starlette:
async def list_tools(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[
types.Tool(
name="greet",
description="Report progress mid-call, then return a greeting.",
input_schema=GREET_INPUT_SCHEMA,
)
]
)
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
assert params.name == "greet" and params.arguments is not None
await ctx.session.report_progress(0.5, total=1.0, message="halfway")
text = f"Hello, {params.arguments['name']}!"
return types.CallToolResult(content=[types.TextContent(text=text)], structured_content={"result": text})
server = Server("json-response-example", on_list_tools=list_tools, on_call_tool=call_tool)
return server.streamable_http_app(json_response=True, transport_security=NO_DNS_REBIND)
if __name__ == "__main__":
run_app_from_args(build_app)
@@ -0,0 +1,73 @@
# legacy-elicitation
> **Legacy mechanism (2025 handshake era).** This story shows the push-style
> server→client `elicitation/create` request; the 2026-07-28 protocol carries
> elicitation as an `InputRequiredResult` round-trip instead — that path is the
> [`mrtr/`](../mrtr/) story. Elicitation itself is **not** deprecated.
> TODO(maxisbey): unify once the MRTR runtime lands
> ([#2898](https://github.com/modelcontextprotocol/python-sdk/issues/2898)).
> The TypeScript SDK ships a single dual-era `elicitation/` story; this
> directory re-merges back into `elicitation/` once MRTR lands.
A tool pauses mid-call to ask the user for structured input. On the
handshake-era protocol the server pushes an `elicitation/create` *request* to
the client and blocks until the client's `elicitation_callback` answers
`accept` / `decline` / `cancel`. Two modes: **form** (`ctx.elicit(message,
PydanticModel)` — schema derived from the model, accepted content validated
back into it) and **url** (`ctx.elicit_url(...)` — directs the user out-of-band
for OAuth / payment flows; `send_elicit_complete` notifies the client when the
flow finishes).
## Run it
```bash
# stdio (default — the client spawns the server as a subprocess)
uv run python -m stories.legacy_elicitation.client
# HTTP — the client self-hosts the server on a free port, runs, then tears it
# down (--legacy: the push request needs the handshake era)
uv run python -m stories.legacy_elicitation.client --http --legacy
# same, against the lowlevel-API server variant
uv run python -m stories.legacy_elicitation.client --http --legacy --server server_lowlevel
```
## What to look at
- `client.py` `main` — the whole client setup is one visible construction:
`Client(target, mode=mode, elicitation_callback=on_elicit)`. Supplying
`elicitation_callback` is what advertises the `elicitation: {form, url}`
capability; `on_elicit` serves *both* modes by branching on
`isinstance(params, ElicitRequestURLParams)`.
- `server.py` `register_user``await ctx.elicit("...", Registration)` derives
the form schema from the pydantic model and returns a typed
`ElicitationResult[Registration]`; narrow with `isinstance(answer,
AcceptedElicitation)` before reading `answer.data`.
- `server.py` `link_account``ctx.elicit_url(...)` for out-of-band flows;
after the user finishes, `send_elicit_complete` emits
`notifications/elicitation/complete` so the client can correlate.
- `server_lowlevel.py` — the same flow via `ctx.session.elicit_form` /
`ctx.session.elicit_url` and a hand-written `requestedSchema`.
## Caveats
- **Context paths.** `ctx.elicit` / `ctx.elicit_url` and the 2-hop
`ctx.request_context.session.send_elicit_complete` are interim; a later
release will shorten these.
- **No per-mode opt-in.** Supplying any `elicitation_callback` advertises both
form and url support; there is currently no way to advertise form-only from
`Client`.
- **Throw-style URL elicitation** (`raise UrlElicitationRequiredError([...])`
wire `-32042`) is the stateless-transport alternative to `ctx.elicit_url`;
see `tests/interaction/lowlevel/test_elicitation.py` and the `error_handling`
story.
## Spec
[Elicitation — client features](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation)
## See also
`sampling/` (same push-request shape, deprecated per SEP-2577), `mrtr/`
(the 2026-era carrier), `error_handling/`
(`UrlElicitationRequiredError`), `refund_desk/` (resolver DI rides this push
mechanism on handshake-era connections).
@@ -0,0 +1,32 @@
"""Auto-answer form and URL elicitations and assert the tool result reflects them."""
import mcp_types as types
from mcp.client import Client, ClientRequestContext
from stories._harness import Target, run_client
async def on_elicit(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult:
if isinstance(params, types.ElicitRequestURLParams):
# A real client would ask consent and open params.url in a browser, returning
# `accept` right away; the server's notifications/elicitation/complete arrives
# afterward (once the out-of-band flow finishes) for the client to correlate.
assert params.url.startswith("https://example.com/")
return types.ElicitResult(action="accept")
assert "username" in params.requested_schema["properties"]
return types.ElicitResult(action="accept", content={"username": "alice", "plan": "pro"})
async def main(target: Target, *, mode: str = "auto") -> None:
async with Client(target, mode=mode, elicitation_callback=on_elicit) as client:
registered = await client.call_tool("register_user", {})
assert isinstance(registered.content[0], types.TextContent)
assert registered.content[0].text == "registered alice (plan: pro)", registered
linked = await client.call_tool("link_account", {"provider": "github"})
assert isinstance(linked.content[0], types.TextContent)
assert linked.content[0].text == "linked github", linked
if __name__ == "__main__":
run_client(main)
@@ -0,0 +1,47 @@
"""Elicitation (handshake-era push style): a tool blocks on user input mid-call."""
from pydantic import BaseModel
from mcp.server.elicitation import AcceptedElicitation
from mcp.server.mcpserver import Context, MCPServer
from stories._hosting import run_server_from_args
class Registration(BaseModel):
username: str
plan: str | None = None
def build_server() -> MCPServer:
mcp = MCPServer("legacy-elicitation-example")
@mcp.tool(description="Register a new account by asking the user for their details.")
async def register_user(ctx: Context) -> str:
answer = await ctx.elicit("Please provide your registration details:", Registration)
if not isinstance(answer, AcceptedElicitation):
return f"registration {answer.action}"
return f"registered {answer.data.username} (plan: {answer.data.plan or 'free'})"
@mcp.tool(description="Link a third-party account by directing the user to a sign-in URL.")
async def link_account(provider: str, ctx: Context) -> str:
# elicitation_id must be unique per elicitation, not per provider — scope it to this request.
elicitation_id = f"link-{provider}-{ctx.request_context.request_id}"
answer = await ctx.elicit_url(
f"Sign in to {provider} to link your account",
url=f"https://example.com/oauth/{provider}/authorize",
elicitation_id=elicitation_id,
)
if answer.action != "accept":
return f"link {answer.action}"
# Out-of-band flow finished: tell the client which elicitation completed.
# The 2-hop `ctx.request_context.*` reach is interim; a later release shortens it.
await ctx.request_context.session.send_elicit_complete(
elicitation_id, related_request_id=ctx.request_context.request_id
)
return f"linked {provider}"
return mcp
if __name__ == "__main__":
run_server_from_args(build_server)
@@ -0,0 +1,70 @@
"""Elicitation (handshake-era push style) against the low-level Server."""
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
REGISTRATION_SCHEMA: types.ElicitRequestedSchema = {
"type": "object",
"properties": {
"username": {"type": "string"},
"plan": {"type": "string", "enum": ["free", "pro", "team"]},
},
"required": ["username"],
}
LINK_INPUT_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {"provider": {"type": "string"}},
"required": ["provider"],
}
def build_server() -> Server[Any]:
async def list_tools(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[
types.Tool(
name="register_user", description="Register a new account.", input_schema={"type": "object"}
),
types.Tool(
name="link_account", description="Link a third-party account.", input_schema=LINK_INPUT_SCHEMA
),
]
)
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
if params.name == "register_user":
answer = await ctx.session.elicit_form(
"Please provide your registration details:", REGISTRATION_SCHEMA, related_request_id=ctx.request_id
)
if answer.action != "accept" or answer.content is None:
return types.CallToolResult(content=[types.TextContent(text=f"registration {answer.action}")])
text = f"registered {answer.content['username']} (plan: {answer.content.get('plan') or 'free'})"
return types.CallToolResult(content=[types.TextContent(text=text)])
assert params.name == "link_account" and params.arguments is not None
provider = params.arguments["provider"]
# elicitation_id must be unique per elicitation, not per provider — scope it to this request.
elicitation_id = f"link-{provider}-{ctx.request_id}"
answer = await ctx.session.elicit_url(
f"Sign in to {provider} to link your account",
url=f"https://example.com/oauth/{provider}/authorize",
elicitation_id=elicitation_id,
related_request_id=ctx.request_id,
)
if answer.action != "accept":
return types.CallToolResult(content=[types.TextContent(text=f"link {answer.action}")])
await ctx.session.send_elicit_complete(elicitation_id, related_request_id=ctx.request_id)
return types.CallToolResult(content=[types.TextContent(text=f"linked {provider}")])
return Server("legacy-elicitation-example", on_list_tools=list_tools, on_call_tool=call_tool)
if __name__ == "__main__":
run_server_from_args(build_server)
+109
View File
@@ -0,0 +1,109 @@
# legacy-routing
The exported era classifier. `classify_inbound_request(body, headers=...)` from
`mcp.shared.inbound` is the body-primary test for "is this a 2026-era request?";
wrap it as `classify_era()` to route eras to different backends in your own
ASGI/ingress layer. Unlike most SDKs, the Python SDK's built-in
`streamable_http_app()` already serves **sessionful** 2025 alongside stateless
2026 on one `/mcp` route — so the predicate is for when you need *different*
arms (per-era auth, separate ports, an existing v1 deployment to keep), not to
make dual-era work at all.
Also shown: the CORS recipe (methods, request headers, and `expose_headers`)
browser-based MCP clients need.
## Run it
```bash
# HTTP only — the predicate is an HTTP-transport concern. The client
# self-hosts the app on a free port, runs, then tears it down.
uv run python -m stories.legacy_routing.client --http
# same, against the lowlevel-API server variant
uv run python -m stories.legacy_routing.client --http --server server_lowlevel
# against a server you run yourself (real uvicorn on :8000)
uv run python -m stories.legacy_routing.server --port 8000 &
SERVER_PID=$!
uv run python -m stories.legacy_routing.client --http http://127.0.0.1:8000/mcp
kill "$SERVER_PID"
```
## What to look at
- `client.py` — two visible connections to the SAME `/mcp` endpoint from one
`targets()` factory: `Client(targets(), mode=mode)` (default `"auto"`
`server/discover` → the modern arm) and `Client(targets(), mode="legacy")`
(the `initialize` handshake → the legacy arm). Each asserts `which_arm`
reports the era the built-in router actually dispatched to. The era decision
is one explicit `mode=` argument at construction.
- `client.py` — the predicate then shown directly against a modern body, a
legacy body, and a malformed-modern body. The runnable `build_app()` uses the
SDK's built-in router; the predicate itself is exercised as a pure
function — see the user-land composition recipe below for wiring it into
your own ingress.
- `server.py` `classify_era` — the tri-state wrapper. `InboundModernRoute`
`"modern"`; rung-1 `INVALID_PARAMS` (no envelope keys) → `"legacy"`; any
other `InboundLadderRejection` is a malformed-modern request to **reject**,
not route to legacy. When headers are supplied, both `Mcp-Protocol-Version`
and `Mcp-Method` must mirror the body — a disagreement (or an unsupported
version) is what produces that third arm; `client.py` shows both.
- `server.py` `build_app``streamable_http_app()` + `CORSMiddleware`. The
`which_arm` tool reads `ctx.request_context.protocol_version` to prove which
path the built-in router took.
- `server_lowlevel.py` — the CORS recipe re-used from `server.py` (the
`MCP_*` header and method constants); `build_app` wires `lowlevel.Server`
instead of `MCPServer` and reads `ctx.protocol_version` directly. The
predicate is tier-agnostic, so `classify_era` lives only in `server.py`.
## User-land composition (when you need different backends)
There is no `legacy="reject"` flag yet. To route eras to different handlers,
buffer the body, classify, replay:
```python
async def mcp_endpoint(scope, receive, send):
body, replay = await buffer_body(receive) # your ASGI helper
headers = {k.decode("ascii").lower(): v.decode("latin-1") for k, v in scope["headers"]}
match classify_era(json.loads(body or b"{}"), headers):
case "legacy":
await my_existing_v1_manager.handle_request(scope, replay, send)
case "modern":
await modern_manager.handle_request(scope, replay, send)
case rejection:
await send_jsonrpc_error(send, rejection) # map via ERROR_CODE_HTTP_STATUS
```
Non-POST verbs (`GET` standalone-SSE, `DELETE` session termination) are
sessionful-2025-only — route them straight to the legacy arm.
## Two ports instead of one
Run two `uvicorn` processes from the same `build_app()` on different ports and
put `classify_era()` (or a header check) in your ingress. Useful when the two
eras need different auth, rate limits, or scaling.
## Caveats
- The SDK's **built-in** routing is currently header-only — a 2026 client that
omits `MCP-Protocol-Version` is mis-routed to legacy.
`classify_inbound_request()` is body-primary and is what the built-in moves
to in a later release; user-land routing with the predicate is already
correct today.
- `ctx.request_context.protocol_version` is the interim 2-hop reach; a later
release will shorten it.
- DNS-rebinding protection is on by default; the harness disables it
(`NO_DNS_REBIND`) because the in-process httpx client sends no `Origin`.
Drop the kwarg for a real deployment.
- `mcp.shared.inbound` is a deep import path — a shorter re-export is planned
before beta.
## Spec
- [Versioning — backward compatibility](https://modelcontextprotocol.io/specification/draft/basic/versioning)
- [Transports — protocol version header](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports)
## See also
`dual_era/` (the simple case: one factory, built-in routing, no predicate),
`stateless_legacy/` (`stateless_http=True`), `starlette_mount/` (mount inside
FastAPI).
+62
View File
@@ -0,0 +1,62 @@
"""Connect at both eras to one app — so `main` takes `targets` — and assert the built-in router and predicate agree."""
from typing import Any
import mcp_types as types
from mcp_types import CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY
from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
from mcp.client import Client
from mcp.shared.inbound import MCP_METHOD_HEADER, MCP_PROTOCOL_VERSION_HEADER, InboundLadderRejection
from stories._harness import TargetFactory, run_client
from .server import classify_era
def _arm(result: types.CallToolResult) -> str:
first = result.content[0]
assert isinstance(first, types.TextContent)
return first.text
async def main(targets: TargetFactory, *, mode: str = "auto") -> None:
# ── modern arm: the caller's mode (the real-user "auto" default) probes
# ``server/discover`` → the stateless 2026 path.
async with Client(targets(), mode=mode) as modern:
assert modern.protocol_version == LATEST_MODERN_VERSION
assert _arm(await modern.call_tool("which_arm", {})) == "modern"
# ── legacy arm: the SAME /mcp endpoint, ``initialize`` handshake → sessionful 2025 path.
async with Client(targets(), mode="legacy") as legacy:
assert legacy.protocol_version == LATEST_HANDSHAKE_VERSION
assert _arm(await legacy.call_tool("which_arm", {})) == "legacy"
# ── the exported predicate, shown directly. A 2026 _meta envelope whose
# `Mcp-Protocol-Version`/`Mcp-Method` headers mirror it is modern; a bare
# initialize body is legacy; a header that disagrees is a rejection (NOT legacy).
modern_body: dict[str, Any] = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list",
"params": {
"_meta": {
PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION,
CLIENT_INFO_META_KEY: {"name": "demo", "version": "0"},
CLIENT_CAPABILITIES_META_KEY: {},
}
},
}
modern_headers = {MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION, MCP_METHOD_HEADER: "tools/list"}
assert classify_era(modern_body, headers=modern_headers) == "modern"
legacy_body: dict[str, Any] = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}
assert classify_era(legacy_body, headers={}) == "legacy"
# The SAME complete header set, with only the protocol version disagreeing with the body.
mismatched_headers = modern_headers | {MCP_PROTOCOL_VERSION_HEADER: LATEST_HANDSHAKE_VERSION}
mismatched = classify_era(modern_body, headers=mismatched_headers)
assert isinstance(mismatched, InboundLadderRejection), mismatched
if __name__ == "__main__":
run_client(main)
+65
View File
@@ -0,0 +1,65 @@
"""Exported era classifier: the body-primary predicate, the built-in dual-era app, and CORS — exports `build_app()`."""
from collections.abc import Mapping
from typing import Any, Literal
from mcp_types import INVALID_PARAMS
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from starlette.applications import Starlette
from starlette.middleware.cors import CORSMiddleware
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared.inbound import InboundLadderRejection, InboundModernRoute, classify_inbound_request
from stories._hosting import NO_DNS_REBIND, run_app_from_args
#: Response headers a browser-based MCP client must be able to read.
MCP_EXPOSED_HEADERS = ["Mcp-Session-Id", "WWW-Authenticate", "Last-Event-Id", "Mcp-Protocol-Version"]
#: Request headers a browser-based MCP client must be allowed to send.
MCP_ALLOWED_HEADERS = ["Authorization", "Content-Type", "Mcp-Protocol-Version", "Mcp-Session-Id", "Last-Event-Id"]
#: Streamable HTTP verbs: POST requests, the standalone GET stream, DELETE session end.
MCP_ALLOWED_METHODS = ["GET", "POST", "DELETE"]
def classify_era(
body: Mapping[str, Any], headers: Mapping[str, str]
) -> Literal["modern", "legacy"] | InboundLadderRejection:
"""Tri-state era classifier built on the exported `classify_inbound_request` predicate.
Compose this in your own ASGI/ingress layer when the two eras need different
backends. Only a rung-1 ``INVALID_PARAMS`` rejection (no envelope keys) means
"treat as legacy"; other rejections are malformed-modern and should be refused.
"""
verdict = classify_inbound_request(body, headers=headers)
if isinstance(verdict, InboundModernRoute):
return "modern"
if verdict.code == INVALID_PARAMS:
return "legacy"
return verdict
def build_app() -> Starlette:
mcp = MCPServer("legacy-routing-example")
@mcp.tool()
async def which_arm(ctx: Context) -> str:
"""Report which era the built-in router dispatched this request to."""
pv = ctx.request_context.protocol_version
return "modern" if pv in MODERN_PROTOCOL_VERSIONS else "legacy"
# One Starlette app, one /mcp route, both eras: sessionful 2025 (initialize +
# Mcp-Session-Id + GET stream) and stateless 2026 (per-request _meta envelope).
app = mcp.streamable_http_app(transport_security=NO_DNS_REBIND)
# CORS for browser-based clients. DEMO ONLY — restrict allow_origins in production.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=MCP_ALLOWED_METHODS,
allow_headers=MCP_ALLOWED_HEADERS,
expose_headers=MCP_EXPOSED_HEADERS,
)
return app
if __name__ == "__main__":
run_app_from_args(build_app)
@@ -0,0 +1,48 @@
"""Exported era classifier (lowlevel API): the same dual-era app + CORS — the predicate stays in `server.py`."""
from typing import Any
import mcp_types as types
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from starlette.applications import Starlette
from starlette.middleware.cors import CORSMiddleware
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 MCP_ALLOWED_HEADERS, MCP_ALLOWED_METHODS, MCP_EXPOSED_HEADERS
WHICH_ARM = types.Tool(
name="which_arm",
description="Report which era the built-in router dispatched this request to.",
input_schema={"type": "object", "properties": {}},
)
def build_app() -> Starlette:
async def list_tools(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[WHICH_ARM])
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
assert params.name == "which_arm"
arm = "modern" if ctx.protocol_version in MODERN_PROTOCOL_VERSIONS else "legacy"
return types.CallToolResult(content=[types.TextContent(text=arm)])
server = Server("legacy-routing-example", on_list_tools=list_tools, on_call_tool=call_tool)
app = server.streamable_http_app(transport_security=NO_DNS_REBIND)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=MCP_ALLOWED_METHODS,
allow_headers=MCP_ALLOWED_HEADERS,
expose_headers=MCP_EXPOSED_HEADERS,
)
return app
if __name__ == "__main__":
run_app_from_args(build_app)
+53
View File
@@ -0,0 +1,53 @@
# lifespan
Process-scoped dependency injection. Pass an `@asynccontextmanager` as
`lifespan=` to acquire resources (a database pool, an HTTP client) once at
startup and release them at shutdown; tool bodies read the yielded state via
the injected `Context` — no module-level globals.
## Run it
```bash
# stdio (default — the client spawns the server as a subprocess)
uv run python -m stories.lifespan.client
# HTTP — the client self-hosts the server on a free port, runs, then tears it down
uv run python -m stories.lifespan.client --http
# same, against the lowlevel-API server variant
uv run python -m stories.lifespan.client --http --server server_lowlevel
```
## What to look at
- `client.py` `main` — opens with `Client(target, mode=mode)`; the story owns
the construction, the harness only chooses the target and era. Lifespan is
invisible from here: the client speaks plain MCP, and the `lookup` results
are the only proof the yielded state was wired through.
- `app_lifespan` in `server.py` — the `try / yield / finally` shape is the
startup/shutdown contract; the `finally` block runs once on process exit, not
per request.
- `ctx.request_context.lifespan_context.db` in the `lookup` tool — the interim
3-hop access path on `MCPServer`'s `Context`.
- `server_lowlevel.py` reaches the same state via `ctx.lifespan_context.db`
one hop, because lowlevel handlers receive `ServerRequestContext` directly.
## Caveats
- `ctx.request_context.lifespan_context` is the interim path; a later release
will shorten this to `ctx.state.*`. The lowlevel `ctx.lifespan_context` path
is unaffected.
- **v1 → v2 scope change** — in v1.x, `lifespan` was entered once per
`Server.run()` call: once per *session* for stateful streamable HTTP and once
per *request* under `stateless_http=True` (stdio was already per-process). In
v2 it is entered once per process regardless of transport. See
`docs/migration.md` ("Streamable HTTP: lifespan now entered once at manager
startup").
## Spec
[Lifecycle](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle)
## See also
`stickynotes/` (lifespan-held mutable state with change notifications),
`serve_one/` (threading `lifespan_state` into the kernel by hand).
+22
View File
@@ -0,0 +1,22 @@
"""Prove the lifespan-yielded state is reachable from a tool call."""
from mcp_types import TextContent
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:
listed = await client.list_tools()
assert [t.name for t in listed.tools] == ["lookup"]
result = await client.call_tool("lookup", {"key": "alpha"})
assert isinstance(result.content[0], TextContent) and result.content[0].text == "one", result
result = await client.call_tool("lookup", {"key": "beta"})
assert isinstance(result.content[0], TextContent) and result.content[0].text == "two", result
if __name__ == "__main__":
run_client(main)
+39
View File
@@ -0,0 +1,39 @@
"""Process-scoped dependency injection via `MCPServer(lifespan=...)`."""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Any
from mcp.server.mcpserver import Context, MCPServer
from stories._hosting import run_server_from_args
@dataclass
class AppState:
db: dict[str, str]
@asynccontextmanager
async def app_lifespan(server: MCPServer[AppState]) -> AsyncIterator[AppState]:
"""Acquire process-scoped resources at startup; release them at shutdown."""
db = {"alpha": "one", "beta": "two"} # e.g. `await pool.connect()`
try:
yield AppState(db=db)
finally:
db.clear() # e.g. `await pool.disconnect()`
def build_server() -> MCPServer[AppState]:
mcp = MCPServer[AppState]("lifespan-example", lifespan=app_lifespan)
@mcp.tool(description="Look up a key in the process-scoped store.")
def lookup(key: str, ctx: Context[AppState, Any]) -> str:
# Interim 3-hop path; shortens to `ctx.state.db` in a later release.
return ctx.request_context.lifespan_context.db[key]
return mcp
if __name__ == "__main__":
run_server_from_args(build_server)
@@ -0,0 +1,66 @@
"""Process-scoped dependency injection via lowlevel `Server(lifespan=...)`."""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
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
@dataclass
class AppState:
db: dict[str, str]
@asynccontextmanager
async def app_lifespan(server: Server[AppState]) -> AsyncIterator[AppState]:
db = {"alpha": "one", "beta": "two"}
try:
yield AppState(db=db)
finally:
db.clear()
LOOKUP_INPUT_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {"key": {"type": "string"}},
"required": ["key"],
}
def build_server() -> Server[AppState]:
async def list_tools(
ctx: ServerRequestContext[AppState], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[
types.Tool(
name="lookup",
description="Look up a key in the process-scoped store.",
input_schema=LOOKUP_INPUT_SCHEMA,
)
]
)
async def call_tool(
ctx: ServerRequestContext[AppState], params: types.CallToolRequestParams
) -> types.CallToolResult:
assert params.name == "lookup" and params.arguments is not None
value = ctx.lifespan_context.db[params.arguments["key"]]
return types.CallToolResult(content=[types.TextContent(text=value)])
return Server[AppState](
"lifespan-example",
lifespan=app_lifespan,
on_list_tools=list_tools,
on_call_tool=call_tool,
)
if __name__ == "__main__":
run_server_from_args(build_server)
+177
View File
@@ -0,0 +1,177 @@
# examples/stories/manifest.toml
#
# test_manifest_matches_filesystem asserts [story.*] keys == story dirs with a client.py.
[defaults]
transports = ["in-memory", "http-asgi"] # in-memory = Client(server); http-asgi = StreamingASGITransport
era = "dual" # "dual" | "modern" | "legacy" | "dual-in-body"
status = "current" # "current" | "legacy" | "deprecated" — the feature's future, not the transport
lowlevel = true # also run main against server_lowlevel.build_server()/build_app()
server_export = "factory" # "factory" -> build_server() | "app" -> build_app()
multi_connection = false # main(target, ...) vs main(targets, ...); targets() -> fresh target per call
needs_http = false # main(..., http=) gets the raw httpx.AsyncClient (http-asgi only)
timeout_s = 30
mcp_path = "/mcp"
fixed_port = 0 # `client --http` self-host port; 0 = an OS-assigned free port
xfail = [] # ["<transport>:<era>", ...] -> strict xfail on that leg
env = {} # env vars set for the leg via monkeypatch
# ───────────────────────────── start here ─────────────────────────────
[story.tools]
[story.prompts]
[story.resources]
[story.lifespan]
[story.dual_era]
era = "dual-in-body"
multi_connection = true
[story.streaming]
[story.mrtr]
era = "modern"
[story.legacy_elicitation]
era = "legacy"
status = "legacy"
[story.refund_desk]
# Resolver elicitation picks its transport per era: input_required round-trips on
# the modern leg, push elicitation (ctx.elicit) on the legacy one.
lowlevel = false
[story.sampling]
era = "legacy"
status = "deprecated"
[story.stickynotes]
[story.custom_methods]
lowlevel = false
[story.apps]
# Extension API is MCPServer-tier (Apps decorators + extensions=[...]); no lowlevel variant.
# The extensions capability map (SEP-2133) rides server/discover, a modern-only path, so
# `main` pins "auto" (legacy initialize cannot carry it) and the leg is http-asgi.
lowlevel = false
transports = ["in-memory", "http-asgi"]
era = "dual-in-body"
[story.extensions]
# Same constraints as `apps`: MCPServer-tier extension API, capability map rides
# server/discover (modern only), client guards the capability assert by presence.
lowlevel = false
transports = ["in-memory", "http-asgi"]
era = "dual-in-body"
[story.subscriptions]
# subscriptions/listen exists only on the 2026 wire, so there is no legacy leg.
# The listen request parks for the stream's lifetime; the client ends it by
# cancelling the awaiting scope (the spec's client-side close).
era = "modern"
[story.schema_validators]
[story.middleware]
# Lowlevel-only: `Server.middleware` is the one public hook (no MCPServer accessor yet).
lowlevel = false
[story.parallel_calls]
# A per-client fresh target over a real ASGI transport is harness machinery, not user
# code; the same client body works unchanged over HTTP.
transports = ["in-memory"]
multi_connection = true
[story.roots]
era = "legacy"
status = "deprecated"
[story.pagination]
[story.error_handling]
[story.serve_one]
# Lowlevel-only: the kernel drivers take a `lowlevel.Server`; `MCPServer` has no public
# accessor for its underlying one yet, so there is no MCPServer-tier variant to show.
transports = ["in-memory"]
lowlevel = false
[story.stateless_legacy]
transports = ["http-asgi"]
server_export = "app"
era = "dual-in-body"
multi_connection = true
[story.json_response]
transports = ["http-asgi"]
server_export = "app"
era = "modern"
needs_http = true
[story.legacy_routing]
transports = ["http-asgi"]
server_export = "app"
era = "dual-in-body"
multi_connection = true
[story.starlette_mount]
transports = ["http-asgi"]
server_export = "app"
lowlevel = false
mcp_path = "/api/"
[story.sse_polling]
transports = ["http-asgi"]
server_export = "app"
era = "legacy"
status = "legacy"
timeout_s = 20
# event_store.py is local; example-grade only (sequential IDs, no eviction).
[story.standalone_get]
transports = ["http-asgi"]
era = "legacy"
status = "legacy"
[story.reconnect]
transports = ["http-asgi"]
# Both connection modes are pinned inside main itself ("auto" to populate the discover
# cache, then a hard pin + prior_discover=); the leg hands it the real-user default.
era = "dual-in-body"
multi_connection = true
[story.bearer_auth]
transports = ["http-asgi"]
server_export = "app"
fixed_port = 8000 # issuer/PRM metadata bake in :8000
[story.oauth]
transports = ["http-asgi"]
server_export = "app"
multi_connection = true
fixed_port = 8000 # issuer/PRM metadata bake in :8000
env = { OAUTH_DEMO_AUTO_CONSENT = "1" }
[story.oauth_client_credentials]
transports = ["http-asgi"]
server_export = "app"
fixed_port = 8000 # issuer/PRM metadata bake in :8000
[story.identity_assertion]
transports = ["http-asgi"]
server_export = "app"
fixed_port = 8000 # issuer/PRM metadata bake in :8000
# ───────────────────────────── deferred ─────────────────────────────
# README-only placeholders; no client.py, not expanded into legs.
# test_manifest_matches_filesystem checks these match the README-only dirs.
[deferred]
caching = "client honouring + per-result override unlanded"
tasks = "SEP-2663 — tasks extension runtime (server-decided augmentation, CreateTaskResult)"
skills = "#2896 — SEP-2640"
events = "#2901 + #2896"
+56
View File
@@ -0,0 +1,56 @@
# middleware
Register a single `async (ctx, call_next) -> result` function on
`Server.middleware` to observe or alter every request and notification the
server receives, across both protocol eras and any transport. Middleware sits
*outside* method lookup and params validation, so it sees `initialize`,
`server/discover`, `notifications/*`, and unknown methods too. The chain runs
outermost-first.
## Run it
```bash
# stdio (default — the client spawns the server as a subprocess)
uv run python -m stories.middleware.client
# HTTP — the client self-hosts the server on a free port, runs, then tears it down
uv run python -m stories.middleware.client --http
```
## What to look at
- `client.py` `main` — opens with `async with Client(target, mode=mode)`. The
story owns that construction; the harness only picks the target and era.
Middleware is invisible from this side — only the `audit_log` result proves
the wrap happened.
- `server.py``server.middleware.append(record_calls)` is the public
registration point on `mcp.server.lowlevel.Server`.
- `client.py` — the asserted log ends at `"tools/call"` without a `:done`
suffix: `audit_log` runs *inside* `call_next(ctx)`, so the `finally` hasn't
fired yet. That's the wrap.
## Caveats
- **Lowlevel-only.** `Server.middleware` on `mcp.server.lowlevel.Server` is the
one public hook; `MCPServer` has no public accessor for it yet (a
`MCPServer.middleware` accessor is planned before beta).
- The middleware signature is **provisional** (see the TODO in
`src/mcp/server/lowlevel/server.py`): it tightens to a covariant `Context[L]`
and gains an outbound seam before v2 final.
- `ServerMiddleware` / `CallNext` / `HandlerResult` are imported from
`mcp.server.context` (helper tier); not re-exported at `mcp.server.lowlevel`.
- Do **not** `await ctx.session.send_request(...)` while wrapping `initialize`
`initialize` is dispatched inline and the outbound channel isn't open yet.
- To rewrite `ctx.method` / `ctx.params` before the handler runs, pass an
adjusted context through: `await call_next(dataclasses.replace(ctx, ...))`.
`docs/migration.md` shows the full recipe.
## Spec
Middleware is SDK architecture, not an MCP spec feature.
## See also
`custom_methods/` (a vendor `acme/search` handler registered with
`add_request_handler` — middleware wraps it like any spec method),
`src/mcp/server/_otel.py` (`OpenTelemetryMiddleware`, the SDK's own consumer).
+27
View File
@@ -0,0 +1,27 @@
"""Prove the middleware wrapped both `tools/list` and the in-flight `tools/call`."""
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:
listed = await client.list_tools()
assert [t.name for t in listed.tools] == ["audit_log"]
result = await client.call_tool("audit_log", {})
assert not result.is_error
assert result.structured_content is not None, result
# Era-neutral: legacy adds initialize + notifications/initialized; modern HTTP
# adds server/discover; modern in-memory adds nothing. Filter to the methods
# this client drove.
seen = [m for m in result.structured_content["result"] if m.startswith("tools/")]
# The tail ends at tools/call with no :done — the handler ran inside the
# middleware frame. Assert the tail (not the whole list) so a re-run against
# a long-lived server, whose log accumulates across clients, still passes.
assert seen[-3:] == ["tools/list", "tools/list:done", "tools/call"], seen
if __name__ == "__main__":
run_client(main)
+54
View File
@@ -0,0 +1,54 @@
"""Dispatch-layer middleware: `Server.middleware` is the public hook.
A lowlevel-only story: `MCPServer` has no public middleware accessor yet, so the
one supported registration point is the `middleware` list on `lowlevel.Server`.
"""
import json
from typing import Any
import mcp_types as types
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
from mcp.server.lowlevel import Server
from stories._hosting import run_server_from_args
def build_server() -> Server[Any]:
log: list[str] = []
async def record_calls(ctx: ServerRequestContext[Any], call_next: CallNext) -> HandlerResult:
log.append(ctx.method)
try:
return await call_next(ctx)
finally:
log.append(f"{ctx.method}:done")
async def list_tools(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[
types.Tool(
name="audit_log",
description="Return every method the middleware has observed so far.",
input_schema={"type": "object"},
)
]
)
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
assert params.name == "audit_log"
snapshot = list(log)
return types.CallToolResult(
content=[types.TextContent(text=json.dumps(snapshot))],
structured_content={"result": snapshot},
)
server = Server("middleware-example", on_list_tools=list_tools, on_call_tool=call_tool)
server.middleware.append(record_calls)
return server
if __name__ == "__main__":
run_server_from_args(build_server)
+79
View File
@@ -0,0 +1,79 @@
# mrtr
Multi-round tool result: on the 2026-07-28 protocol a tool that needs user
input mid-call **returns** `resultType: "input_required"` with embedded
`inputRequests` and an opaque `requestState`, instead of pushing a
server-to-client request. The client fulfils the embedded requests and retries the
original `tools/call` carrying `inputResponses` and the echoed `requestState`.
The story shows both the `Client` auto-loop (one `await call_tool`, callbacks
fired transparently) and a manual `client.session` loop (the persistable
form). Because `requestState` round-trips through the client, it also shows
the security surface that protects it: `MCPServer` seals state by default
under a process-local key, handlers keep writing plaintext, and the wire only
ever carries an opaque token. The manual loop tampers with the sealed token to
show what a forged echo gets back.
## Run it
```bash
# HTTP: the client self-hosts the server on a free port, runs, then tears it
# down (the InputRequiredResult round-trip is 2026-era only)
uv run python -m stories.mrtr.client --http
# same, against the lowlevel-API server variant
uv run python -m stories.mrtr.client --http --server server_lowlevel
```
## What to look at
- `server.py` `build_server`: no security configuration at all. The default
seals under a key generated at process start, which is right for a
single-process server like this one; a fleet (multi-worker or load-balanced)
shares keys with `request_state_security=RequestStateSecurity(keys=[...])`
so any instance can verify state another minted.
- `server.py` `deploy`: handlers stay plaintext. The first round returns
`InputRequiredResult(input_requests={...},
request_state="awaiting-confirm")` and the retry asserts
`ctx.request_state == "awaiting-confirm"`. The tool never touches the
crypto; the boundary seals on the way out and unseals the echo on the way
back in.
- `client.py` `main`: the auto-loop is invisible at the call site:
`Client(target, mode=mode, elicitation_callback=on_elicit)` then
`await client.call_tool("deploy", ...)`. The same `on_elicit` callback the
legacy push path uses is dispatched for each embedded `inputRequests` entry.
- `client.py` manual block: `client.session.call_tool(...,
allow_input_required=True)` returns the raw `InputRequiredResult` so
`request_state` can be persisted between rounds. The wire value is an opaque
sealed token, **not** the string the server code wrote. The client asserts
exactly that, then retries with one character of the token flipped and gets
the single frozen error every verification failure maps to: `-32602`,
`"Invalid or expired requestState"`, `{"reason": "invalid_request_state"}`.
The specific reason (tampered tag, expiry, wrong request, wrong principal)
appears only in the server's log, never on the wire. The untampered token
then completes the round normally.
- `server_lowlevel.py`: the lowlevel tier doesn't seal by default; the same
enforcement is one appended middleware:
`server.middleware.append(RequestStateBoundary(RequestStateSecurity.ephemeral(),
default_audience=server.name))`.
## Caveats
- **Loop bound.** The auto-loop gives up after `input_required_max_rounds`
(default 10) with `InputRequiredRoundsExceededError`; raise it on the
`Client` ctor or drop to the manual loop.
- **The default key dies with the process.** It is generated at startup and
held only in memory, so a server restart (or a retry landing on a different
instance) invalidates in-flight rounds: the client gets the same frozen
rejection and must start the flow over. Use
`RequestStateSecurity(keys=[...])` when state must survive either.
## Spec
[Input required tool results (server features)](https://modelcontextprotocol.io/specification/draft/server/tools#input-required-tool-results),
[Multi-round-trip requests (security patterns)](https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr)
## See also
`legacy_elicitation/` and `sampling/`: the handshake-era push equivalents this
mechanism replaces on the 2026 protocol. `refund_desk/`: resolver DI at the
MCPServer tier: the questions a tool can declare instead of pushing by hand
(its elicited answers ride in the same sealed `requestState`).
View File
+70
View File
@@ -0,0 +1,70 @@
"""Drive the deploy tool both ways: the Client auto-loop, and a manual session-level loop."""
import mcp_types as types
from mcp import MCPError
from mcp.client import Client, ClientRequestContext
from stories._harness import Target, run_client
async def on_elicit(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult:
# The same callback serves legacy push-style elicitation/create requests AND embedded
# InputRequiredResult.input_requests entries — the driver dispatches both here.
assert isinstance(params, types.ElicitRequestFormParams)
assert "confirm" in params.requested_schema["properties"]
return types.ElicitResult(action="accept", content={"confirm": True})
async def main(target: Target, *, mode: str = "auto") -> None:
async with Client(target, mode=mode, elicitation_callback=on_elicit) as client:
# ── auto-loop: Client.call_tool dispatches input_requests to on_elicit and retries
# internally; the caller just sees the final CallToolResult.
deployed = await client.call_tool("deploy", {"env": "production"})
assert isinstance(deployed.content[0], types.TextContent)
assert deployed.content[0].text == "deployed to production", deployed
# ── manual loop: drop to client.session for the raw InputRequiredResult so the
# request_state can be persisted between rounds (e.g. across a process restart).
first = await client.session.call_tool("deploy", {"env": "staging"}, allow_input_required=True)
assert isinstance(first, types.InputRequiredResult)
assert first.input_requests is not None and "confirm" in first.input_requests
# The boundary sealed server.py's plaintext "awaiting-confirm"; the wire token is opaque.
token = first.request_state
assert token is not None and token != "awaiting-confirm", token
responses: types.InputResponses = {"confirm": types.ElicitResult(action="decline")}
# Tamper demo: flipping any one character fails verification, and every failure
# maps to one frozen wire error; the real reason appears only in the server log.
i = len(token) // 2
tampered = token[:i] + ("A" if token[i] != "A" else "B") + token[i + 1 :]
try:
await client.session.call_tool(
"deploy",
{"env": "staging"},
input_responses=responses,
request_state=tampered,
allow_input_required=True,
)
except MCPError as e:
assert e.code == types.INVALID_PARAMS
assert e.message == "Invalid or expired requestState"
assert e.data == {"reason": "invalid_request_state"}
else:
raise AssertionError("expected MCPError for a tampered requestState")
# The untampered token still completes the round; decline so this path diverges from the auto run.
second = await client.session.call_tool(
"deploy",
{"env": "staging"},
input_responses=responses,
request_state=token,
allow_input_required=True,
)
assert isinstance(second, types.CallToolResult)
assert isinstance(second.content[0], types.TextContent)
assert second.content[0].text == "deployment to staging cancelled", second
if __name__ == "__main__":
run_client(main)
+39
View File
@@ -0,0 +1,39 @@
"""Multi-round tool result (2026 era): a tool returns input_required and resumes from echoed state."""
from mcp_types import ElicitRequest, ElicitRequestedSchema, ElicitRequestFormParams, ElicitResult, InputRequiredResult
from mcp.server.mcpserver import Context, MCPServer
from stories._hosting import run_server_from_args
CONFIRM_SCHEMA: ElicitRequestedSchema = {
"type": "object",
"properties": {"confirm": {"type": "boolean", "description": "Proceed with the deployment?"}},
"required": ["confirm"],
}
def build_server() -> MCPServer:
# requestState is sealed by default under a process-local key, which suits this
# single-process server; fleets share keys=[...] so any instance can verify.
mcp = MCPServer("mrtr-example")
@mcp.tool(description="Deploy to an environment, asking the user to confirm first.")
async def deploy(env: str, ctx: Context) -> str | InputRequiredResult:
responses = ctx.input_responses
if responses is None or "confirm" not in responses:
ask = ElicitRequest(
params=ElicitRequestFormParams(message=f"Deploy to {env}?", requested_schema=CONFIRM_SCHEMA)
)
# The boundary seals this plaintext request_state on the way out and unseals the echo on retry.
return InputRequiredResult(input_requests={"confirm": ask}, request_state="awaiting-confirm")
assert ctx.request_state == "awaiting-confirm", ctx.request_state
answer = responses["confirm"]
if isinstance(answer, ElicitResult) and answer.action == "accept" and (answer.content or {}).get("confirm"):
return f"deployed to {env}"
return f"deployment to {env} cancelled"
return mcp
if __name__ == "__main__":
run_server_from_args(build_server)
+67
View File
@@ -0,0 +1,67 @@
"""Multi-round tool result (2026 era) against the low-level Server."""
from typing import Any
import mcp_types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from mcp.server.request_state import RequestStateBoundary, RequestStateSecurity
from stories._hosting import run_server_from_args
CONFIRM_SCHEMA: types.ElicitRequestedSchema = {
"type": "object",
"properties": {"confirm": {"type": "boolean", "description": "Proceed with the deployment?"}},
"required": ["confirm"],
}
DEPLOY_INPUT_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {"env": {"type": "string"}},
"required": ["env"],
}
def build_server() -> Server[Any]:
async def list_tools(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[
types.Tool(
name="deploy",
description="Deploy to an environment, asking the user to confirm first.",
input_schema=DEPLOY_INPUT_SCHEMA,
)
]
)
async def call_tool(
ctx: ServerRequestContext[Any], params: types.CallToolRequestParams
) -> types.CallToolResult | types.InputRequiredResult:
assert params.name == "deploy" and params.arguments is not None
env = params.arguments["env"]
responses = params.input_responses
if responses is None or "confirm" not in responses:
ask = types.ElicitRequest(
params=types.ElicitRequestFormParams(message=f"Deploy to {env}?", requested_schema=CONFIRM_SCHEMA)
)
return types.InputRequiredResult(input_requests={"confirm": ask}, request_state="awaiting-confirm")
assert params.request_state == "awaiting-confirm", params.request_state
answer = responses["confirm"]
if (
isinstance(answer, types.ElicitResult)
and answer.action == "accept"
and (answer.content or {}).get("confirm")
):
return types.CallToolResult(content=[types.TextContent(text=f"deployed to {env}")])
return types.CallToolResult(content=[types.TextContent(text=f"deployment to {env} cancelled")])
server = Server("mrtr-example", on_list_tools=list_tools, on_call_tool=call_tool)
# Lowlevel opt-in: append the same boundary middleware MCPServer installs by
# default; the server name becomes the token audience.
server.middleware.append(RequestStateBoundary(RequestStateSecurity.ephemeral(), default_audience=server.name))
return server
if __name__ == "__main__":
run_server_from_args(build_server)
+92
View File
@@ -0,0 +1,92 @@
# oauth
The full OAuth 2.1 authorization-code flow against an in-process Authorization
Server, over Streamable HTTP. On the **server** side: one `MCPServer(auth=...,
auth_server_provider=...)` constructor call co-hosts the RFC 9728
protected-resource metadata route, the AS routes (`/register`, `/authorize`,
`/token`, `/.well-known/oauth-authorization-server`) and the bearer-gated
`/mcp` endpoint on a single Starlette app. On the **client** side:
`OAuthClientProvider` is an `httpx.Auth` that reacts to the first `401` by
walking PRM discovery → AS metadata → DCR → PKCE authorize → token exchange →
bearer retry — all inside the first awaited request, with no user-visible
`UnauthorizedError`.
## Run it
```bash
# HTTP — the client self-hosts the co-hosted AS + bearer-gated /mcp, runs the
# authorization-code flow (headless: redirect followed in-process), then tears
# it down. Self-hosting uses this story's fixed :8000 (the AS metadata pins
# it), so :8000 must be free.
OAUTH_DEMO_AUTO_CONSENT=1 uv run python -m stories.oauth.client --http
# same, against the lowlevel-API server variant
OAUTH_DEMO_AUTO_CONSENT=1 uv run python -m stories.oauth.client --http --server server_lowlevel
# against a server you run yourself (real uvicorn on :8000)
OAUTH_DEMO_AUTO_CONSENT=1 uv run python -m stories.oauth.server --port 8000 &
SERVER_PID=$!
uv run python -m stories.oauth.client --http http://127.0.0.1:8000/mcp
kill "$SERVER_PID"
```
The port must be **8000**: the demo AS metadata (`_shared/auth.py` `BASE_URL`)
is pinned to it on both the client and server side, so on any other port the
PRM/AS discovery chain points at the wrong origin.
`OAUTH_DEMO_AUTO_CONSENT=1` makes the demo AS skip the consent screen and 302
straight back with `?code=...`; without it the authorize step returns
`error=interaction_required` so you can see where a real browser would open.
`Client(url)` has no `auth=` passthrough, so a target built from a bare URL
can't carry the flow. Both runners close that gap the same way: `run_client`
(above) and the pytest harness build an authed `httpx.AsyncClient` from
this module's `build_auth` export and hand `main` targets that are already
routed through it.
## What to look at
- **`client.py``Client(targets(), mode=mode)`, twice.** The target `main`
receives is already authed. The first construction is where the whole flow
happens: the first request `401`s and `OAuthClientProvider` runs PRM
discovery → AS metadata → DCR → PKCE authorize → token exchange → bearer
retry before `whoami`'s result reaches the body.
- **`client.py` — the second `Client(targets(), mode=mode)`.** A `Client`
cannot be re-entered after `__aexit__`; reconnecting means constructing a new
one. The provider's `TokenStorage` persisted the tokens and the DCR
registration, so this one sends `Authorization: Bearer ...` on its very first
request — no second `/authorize`, no second `/register`. The demo AS mints a
fresh `client_id` per DCR call, so `whoami` returning the *same* `client_id`
is the reuse proof.
- **`client.py``build_auth()`.** `OAuthClientProvider` is an `httpx.Auth`.
`Client(url, auth=...)` is the ergonomic the SDK is missing; until it lands
the auth has to be threaded onto the underlying `httpx.AsyncClient` by hand.
- **`server.py``MCPServer(auth=..., auth_server_provider=...)`.** The
constructor wires everything; `streamable_http_app()` reads it back. (Don't
also pass `token_verifier=``auth_server_provider` and `token_verifier` are
mutually exclusive.) The `whoami` tool reads the validated principal via
`get_access_token()` — a per-HTTP-request contextvar set by
`AuthContextMiddleware`, not per-session.
- **`server_lowlevel.py`** — same wire shape, but `lowlevel.Server` takes
`auth=`/`token_verifier=`/`auth_server_provider=` on `streamable_http_app()`
rather than the constructor. `mcp.server.auth.*` is a helper tier the lowlevel
API may import directly.
## Caveats
- `transport_security=NO_DNS_REBIND` — DNS-rebinding protection is on by default
and the in-process httpx bridge sends no `Origin` header. Drop the kwarg for a
real deployment.
- `HeadlessOAuth` only works because the demo AS auto-consents; a real
`redirect_handler` would open a browser and a real `callback_handler` would
run a loopback HTTP listener for the redirect.
- The `mcp.server.auth.*` import paths are deep (no `mcp.server` re-export yet).
## Spec
[Authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)
## See also
`bearer_auth/` (RS-only, static token, no AS) · `oauth_client_credentials/`
(M2M `client_credentials` grant — no browser, no DCR) · `reconnect/` (the other
multi-connection `targets()` consumer, no auth).
View File
+61
View File
@@ -0,0 +1,61 @@
"""HTTP-only OAuth authorization-code flow; `build_auth` supplies the provider, reconnecting needs `targets`."""
import httpx
from pydantic import AnyUrl
from mcp.client import Client
from mcp.client.auth import OAuthClientProvider
from mcp.shared.auth import OAuthClientMetadata
from stories._harness import TargetFactory, run_client
# MCP_URL pins the resource to :8000. The demo AS's own metadata (issuer, PRM `resource`)
# is built from the same constant on the server side, so the whole story is bound to that
# port — run the server on 8000 or both halves of the discovery chain point at the wrong origin.
from stories._shared.auth import MCP_URL, REDIRECT_URI, HeadlessOAuth, InMemoryTokenStorage
def build_auth(http_client: httpx.AsyncClient) -> httpx.Auth:
"""An `OAuthClientProvider` over fresh storage, completing the authorize redirect headlessly.
`Client(url, auth=...)` doesn't exist yet, so the harness threads this onto the underlying
`httpx.AsyncClient` and every target `main` receives is already routed through it.
"""
headless = HeadlessOAuth()
headless.bind(http_client)
return OAuthClientProvider(
server_url=MCP_URL,
client_metadata=OAuthClientMetadata(
client_name="oauth-story-client",
redirect_uris=[AnyUrl(REDIRECT_URI)],
grant_types=["authorization_code", "refresh_token"],
),
storage=InMemoryTokenStorage(),
redirect_handler=headless.redirect_handler,
callback_handler=headless.callback_handler,
)
async def main(targets: TargetFactory, *, mode: str = "auto") -> None:
# The target is already authed with build_auth's OAuthClientProvider. The first request to
# hit the wire 401s, and the provider walks PRM discovery → AS metadata → DCR → PKCE
# authorize → token exchange → bearer retry before any result reaches this body. No
# UnauthorizedError ever surfaces.
async with Client(targets(), mode=mode) as client:
first = await client.call_tool("whoami", {})
assert first.structured_content is not None
assert "mcp" in first.structured_content["scopes"], first
registered_id = first.structured_content["client_id"]
# A Client cannot be re-entered after __aexit__; reconnecting means constructing a new one.
# The provider's TokenStorage persisted both the issued tokens and the DCR registration, so
# this connection sends `Authorization: Bearer ...` on its very first request — no second
# /authorize, no second /register. The demo AS mints a fresh client_id per DCR call, so the
# same principal coming back IS the reuse proof.
async with Client(targets(), mode=mode) as reconnected:
again = await reconnected.call_tool("whoami", {})
assert again.structured_content is not None
assert again.structured_content["client_id"] == registered_id, again
if __name__ == "__main__":
run_client(main)
+40
View File
@@ -0,0 +1,40 @@
"""OAuth-protected MCP server: in-process AS + PRM + bearer-gated /mcp on one Starlette app — exports `build_app()`."""
from pydantic import BaseModel
from starlette.applications import Starlette
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.mcpserver import MCPServer
from stories._hosting import NO_DNS_REBIND, run_app_from_args
from stories._shared.auth import InMemoryAuthorizationServerProvider, auth_settings
class Principal(BaseModel):
client_id: str
scopes: list[str]
def build_app() -> Starlette:
# The provider is both the Authorization Server (DCR/authorize/token) and the
# token store the bearer middleware validates against — one in-memory dict.
provider = InMemoryAuthorizationServerProvider()
# ``auth_server_provider=`` alone is enough — MCPServer derives a token verifier
# from it (passing both trips the mutex guard).
mcp = MCPServer(
"oauth-example",
auth=auth_settings(required_scopes=["mcp"]),
auth_server_provider=provider,
)
@mcp.tool(description="Return the authenticated principal's client_id and granted scopes.")
def whoami() -> Principal:
token = get_access_token()
assert token is not None
return Principal(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)
+58
View File
@@ -0,0 +1,58 @@
"""OAuth-protected MCP server (lowlevel API): same app shape, hand-built result types."""
from typing import Any
import mcp_types as types
from starlette.applications import Starlette
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import ProviderTokenVerifier
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from stories._hosting import NO_DNS_REBIND, run_app_from_args
from stories._shared.auth import InMemoryAuthorizationServerProvider, auth_settings
WHOAMI_OUTPUT_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {"client_id": {"type": "string"}, "scopes": {"type": "array", "items": {"type": "string"}}},
"required": ["client_id", "scopes"],
}
def build_app() -> Starlette:
provider = InMemoryAuthorizationServerProvider()
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's client_id and granted scopes.",
input_schema={"type": "object"},
output_schema=WHOAMI_OUTPUT_SCHEMA,
),
]
)
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
payload = {"client_id": token.client_id, "scopes": token.scopes}
return types.CallToolResult(content=[types.TextContent(text=token.client_id)], structured_content=payload)
server = Server("oauth-example", on_list_tools=list_tools, on_call_tool=call_tool)
# Unlike MCPServer (auth on the constructor), lowlevel.Server takes auth as
# streamable_http_app() kwargs — same wired routes, different entry point.
return server.streamable_http_app(
auth=auth_settings(required_scopes=["mcp"]),
token_verifier=ProviderTokenVerifier(provider),
auth_server_provider=provider,
transport_security=NO_DNS_REBIND,
)
if __name__ == "__main__":
run_app_from_args(build_app)
@@ -0,0 +1,78 @@
# oauth-client-credentials
OAuth 2.0 **`client_credentials`** grant — machine-to-machine MCP auth, no
browser. A backend service authenticates *as itself* by presenting a
pre-registered `client_id`/`client_secret` directly to the AS token endpoint;
the SDK's `ClientCredentialsOAuthProvider` handles 401-challenge → PRM/AS
discovery → token POST → Bearer attachment automatically.
## Run it
```bash
# HTTP — the client self-hosts the server, runs the grant, then tears it down.
# Self-hosting uses this story's fixed :8000 (the AS metadata pins it), so
# :8000 must be free.
uv run python -m stories.oauth_client_credentials.client --http
# same, against the lowlevel-API server variant
uv run python -m stories.oauth_client_credentials.client --http --server server_lowlevel
# against a server you run yourself (real uvicorn on :8000 — auth is HTTP-only)
uv run python -m stories.oauth_client_credentials.server --port 8000 &
SERVER_PID=$!
uv run python -m stories.oauth_client_credentials.client --http http://127.0.0.1:8000/mcp
kill "$SERVER_PID"
```
OAuth is an HTTP-layer concern; stdio servers receive credentials via the
environment per the spec, so there is no stdio leg. The port must be **8000**:
the demo AS metadata (`_shared/auth.py` `BASE_URL`) is pinned to it on both
the client and server side.
## What to look at
- `client.py` `main` — opens with `async with Client(target, mode=mode) as
client:` and that's the whole program. `target` is a transport that already
carries the OAuth `httpx.Auth`; the body never touches a token.
- `client.py` `build_auth` — five lines of `ClientCredentialsOAuthProvider`
config is all the caller writes; the SDK does RFC 9728 PRM →
RFC 8414 AS-metadata discovery and token exchange on the first 401.
- `server.py` `token_endpoint` — the *entire* AS for this grant: validate
HTTP-Basic `client_id:client_secret`, mint a token, return RFC 6749 JSON.
The SDK's built-in `auth_server_provider=` only routes
`authorization_code`/`refresh_token`, so M2M servers mount their own `/token`.
- `server.py` `whoami` — `get_access_token()` is how a tool reads the
authenticated principal (`client_id`, `scopes`) from the request context.
- `server_lowlevel.py` — identical auth wiring via
`Server.streamable_http_app(auth=..., token_verifier=...,
custom_starlette_routes=[...])`; only the tool registration differs.
## Caveats
- `Client(url, auth=build_auth(http))` is the ergonomic the SDK is missing —
`Client(url)` has no `auth=` passthrough. Until it lands, the authed
`httpx.AsyncClient` → `streamable_http_client(url, http_client=hc)` chain has
to be built *outside* `main` and handed in as `target`; both `run_client`
(the standalone `--http` run) and the test harness do that from the
`build_auth` export.
- `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.
- `OAuthMetadata.authorization_endpoint` is a required field even though a
`client_credentials`-only AS has no authorize endpoint; the server sets a
dummy URL.
## `private_key_jwt`
Swap `ClientCredentialsOAuthProvider` for `PrivateKeyJWTOAuthProvider` to
authenticate the token request with a signed assertion (RFC 7523 §2.2) instead
of a shared secret. Not exercised here because the demo AS only validates
`client_secret_basic`.
## Spec
[Authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)
## See also
`oauth/` (interactive `authorization_code` + PKCE — user-facing flow) ·
`bearer_auth/` (static token, no AS — simplest gating).
@@ -0,0 +1,46 @@
"""HTTP-only: ``build_auth`` returns a ``ClientCredentialsOAuthProvider``; ``whoami`` round-trips client_id + scopes."""
import httpx
from mcp.client import Client
from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider
from stories._harness import Target, run_client
# MCP_URL pins the resource to :8000, and the server side builds its PRM/AS metadata from
# the same constant — run the server on 8000 or the discovery chain points at the wrong origin.
from stories._shared.auth import MCP_URL, InMemoryTokenStorage
from .server import DEMO_CLIENT_ID, DEMO_CLIENT_SECRET, DEMO_SCOPE
def build_auth(_http: httpx.AsyncClient) -> httpx.Auth:
"""The ``httpx.Auth`` for the ``client_credentials`` grant — five lines of provider config.
The SDK then handles 401 → RFC 9728 PRM → RFC 8414 AS-metadata discovery → token POST →
Bearer attachment automatically. ``Client(url)`` has no ``auth=`` passthrough yet, so the
harness threads this onto the transport's ``httpx.AsyncClient`` and hands ``main`` the
already-authed ``target``.
"""
return ClientCredentialsOAuthProvider(
server_url=MCP_URL,
storage=InMemoryTokenStorage(),
client_id=DEMO_CLIENT_ID,
client_secret=DEMO_CLIENT_SECRET,
scopes=DEMO_SCOPE,
)
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
assert result.structured_content is not None
assert result.structured_content["client_id"] == DEMO_CLIENT_ID, result
assert DEMO_SCOPE in result.structured_content["scopes"]
if __name__ == "__main__":
run_client(main)
@@ -0,0 +1,77 @@
"""Bearer-gated resource server + a minimal in-process ``client_credentials`` AS, one app; exports ``build_app()``."""
import base64
import secrets
from pydantic import AnyHttpUrl, BaseModel
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken
from mcp.server.mcpserver import MCPServer
from mcp.shared.auth import OAuthMetadata, OAuthToken
from stories._hosting import NO_DNS_REBIND, run_app_from_args
from stories._shared.auth import BASE_URL, auth_settings
# DEMO ONLY — never hard-code real credentials.
DEMO_CLIENT_ID = "demo-m2m-client"
DEMO_CLIENT_SECRET = "demo-m2m-secret"
DEMO_SCOPE = "mcp:tools"
class Whoami(BaseModel):
client_id: str
scopes: list[str]
def build_app() -> Starlette:
issued: dict[str, AccessToken] = {}
class _Verifier:
async def verify_token(self, token: str) -> AccessToken | None:
return issued.get(token)
mcp = MCPServer(
"oauth-client-credentials-example",
token_verifier=_Verifier(),
auth=auth_settings(required_scopes=[DEMO_SCOPE]),
)
@mcp.tool(description="Return the authenticated client_id and granted scopes.")
def whoami() -> Whoami:
token = get_access_token()
assert token is not None
return Whoami(client_id=token.client_id, scopes=token.scopes)
@mcp.custom_route("/.well-known/oauth-authorization-server", methods=["GET"])
async def as_metadata(request: Request) -> JSONResponse:
meta = OAuthMetadata(
issuer=AnyHttpUrl(BASE_URL),
authorization_endpoint=AnyHttpUrl(f"{BASE_URL}/authorize"), # unused; required
token_endpoint=AnyHttpUrl(f"{BASE_URL}/token"),
grant_types_supported=["client_credentials"],
token_endpoint_auth_methods_supported=["client_secret_basic"],
scopes_supported=[DEMO_SCOPE],
)
return JSONResponse(meta.model_dump(by_alias=True, mode="json", exclude_none=True))
@mcp.custom_route("/token", methods=["POST"])
async def token_endpoint(request: Request) -> JSONResponse:
form = await request.form()
if form.get("grant_type") != "client_credentials":
return JSONResponse({"error": "unsupported_grant_type"}, status_code=400)
creds = base64.b64decode(request.headers.get("authorization", "").removeprefix("Basic ")).decode()
if creds != f"{DEMO_CLIENT_ID}:{DEMO_CLIENT_SECRET}":
return JSONResponse({"error": "invalid_client"}, status_code=401)
access = f"access_{secrets.token_hex(16)}"
issued[access] = AccessToken(token=access, client_id=DEMO_CLIENT_ID, scopes=[DEMO_SCOPE], expires_at=None)
body = OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=DEMO_SCOPE)
return JSONResponse(body.model_dump(exclude_none=True), headers={"cache-control": "no-store"})
return mcp.streamable_http_app(transport_security=NO_DNS_REBIND)
if __name__ == "__main__":
run_app_from_args(build_app)
@@ -0,0 +1,82 @@
"""Bearer-gated MCP resource server (lowlevel API) + the same minimal ``client_credentials`` AS."""
import base64
import json
import secrets
from typing import Any
import mcp_types as types
from pydantic import AnyHttpUrl
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Route
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from mcp.shared.auth import OAuthMetadata, OAuthToken
from stories._hosting import NO_DNS_REBIND, run_app_from_args
from stories._shared.auth import BASE_URL, auth_settings
from .server import DEMO_CLIENT_ID, DEMO_CLIENT_SECRET, DEMO_SCOPE
def build_app() -> Starlette:
issued: dict[str, AccessToken] = {}
class _Verifier:
async def verify_token(self, token: str) -> AccessToken | None:
return issued.get(token)
async def list_tools(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="whoami", 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
payload = {"client_id": token.client_id, "scopes": token.scopes}
return types.CallToolResult(content=[types.TextContent(text=json.dumps(payload))], structured_content=payload)
server = Server("oauth-client-credentials-example", on_list_tools=list_tools, on_call_tool=call_tool)
async def as_metadata(request: Request) -> JSONResponse:
meta = OAuthMetadata(
issuer=AnyHttpUrl(BASE_URL),
authorization_endpoint=AnyHttpUrl(f"{BASE_URL}/authorize"), # unused; required
token_endpoint=AnyHttpUrl(f"{BASE_URL}/token"),
grant_types_supported=["client_credentials"],
token_endpoint_auth_methods_supported=["client_secret_basic"],
scopes_supported=[DEMO_SCOPE],
)
return JSONResponse(meta.model_dump(by_alias=True, mode="json", exclude_none=True))
async def token_endpoint(request: Request) -> JSONResponse:
form = await request.form()
if form.get("grant_type") != "client_credentials":
return JSONResponse({"error": "unsupported_grant_type"}, status_code=400)
creds = base64.b64decode(request.headers.get("authorization", "").removeprefix("Basic ")).decode()
if creds != f"{DEMO_CLIENT_ID}:{DEMO_CLIENT_SECRET}":
return JSONResponse({"error": "invalid_client"}, status_code=401)
access = f"access_{secrets.token_hex(16)}"
issued[access] = AccessToken(token=access, client_id=DEMO_CLIENT_ID, scopes=[DEMO_SCOPE], expires_at=None)
body = OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=DEMO_SCOPE)
return JSONResponse(body.model_dump(exclude_none=True), headers={"cache-control": "no-store"})
return server.streamable_http_app(
auth=auth_settings(required_scopes=[DEMO_SCOPE]),
token_verifier=_Verifier(),
custom_starlette_routes=[
Route("/.well-known/oauth-authorization-server", as_metadata, methods=["GET"]),
Route("/token", token_endpoint, methods=["POST"]),
],
transport_security=NO_DNS_REBIND,
)
if __name__ == "__main__":
run_app_from_args(build_app)
+52
View File
@@ -0,0 +1,52 @@
# pagination
Walk a paginated `resources/list` by hand: feed each result's `next_cursor`
back into `list_resources(cursor=...)` until it is `None`. The cursor is an
opaque server-chosen string — never parse it, and never terminate on a falsy
check (an empty string is a valid cursor under the spec).
## Run it
```bash
# stdio (default — the client spawns the server as a subprocess)
uv run python -m stories.pagination.client --server server_lowlevel
# HTTP — the client self-hosts the server on a free port, runs, then tears it down
uv run python -m stories.pagination.client --http --server server_lowlevel
```
Drop `--server server_lowlevel` (on either transport) to run against the
`MCPServer` variant (single page).
## What to look at
- `client.py` `main``async with Client(target, mode=mode) as client:` is the
whole connection. The story owns the construction; `target` is whatever
`Client()` accepts (an in-process server, a transport, or an HTTP URL) and
the entry point picks it.
- `client.py``if page.next_cursor is None: break`. Termination is
key-absent, not falsy; `while cursor:` would be a spec bug.
- `server_lowlevel.py` — the handler owns the cursor encoding (here: an
integer offset as a string) and rejects an unrecognised cursor with
`-32602 Invalid params`, the spec-recommended response.
- `server.py``MCPServer`'s decorator-registered resources are returned in
a single page; the inbound `cursor` is accepted but ignored. The same client
loop still terminates correctly after one request.
## Caveats
- **No `iter_*()` helper** — `Client` has no `iter_resources()` /
`iter_tools()` async-iterator yet; the manual `while True` loop shown here
is the supported pattern.
- **MCPServer is single-page** — `MCPServer` ignores `cursor` and never sets
`next_cursor`. Whether it grows a `page_size=` knob or stays single-page by
design is open; use the lowlevel server when you need to emit pages today.
## Spec
[Pagination — server utilities](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination)
## See also
`resources/`, `tools/`, `prompts/` — every `*/list` method paginates the same
way. Reference test: `tests/interaction/lowlevel/test_pagination.py`.
+27
View File
@@ -0,0 +1,27 @@
"""Walk every page of resources/list by hand until next_cursor is absent."""
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:
names: list[str] = []
cursor: str | None = None
pages_fetched = 0
while True:
page = await client.list_resources(cursor=cursor)
pages_fetched += 1
assert pages_fetched <= 6, "server kept returning next_cursor — runaway guard"
names.extend(r.name for r in page.resources)
if page.next_cursor is None: # terminate on absent, NOT on falsy: "" is a valid cursor
break
cursor = page.next_cursor
assert names == ["alpha", "beta", "gamma", "delta", "epsilon", "zeta"], names
# server_lowlevel.py emits 3 pages of 2; server.py (MCPServer's flat registry) emits 1.
assert pages_fetched in (1, 3), pages_fetched
if __name__ == "__main__":
run_client(main)
+24
View File
@@ -0,0 +1,24 @@
"""Six static resources on MCPServer; its built-in registry serves them as one page."""
from mcp.server.mcpserver import MCPServer
from stories._hosting import run_server_from_args
WORDS = ("alpha", "beta", "gamma", "delta", "epsilon", "zeta")
def build_server() -> MCPServer:
mcp = MCPServer("pagination-example")
def register(word: str) -> None:
@mcp.resource(f"word://{word}", name=word, mime_type="text/plain")
def read() -> str:
return word
for word in WORDS:
register(word)
return mcp
if __name__ == "__main__":
run_server_from_args(build_server)
@@ -0,0 +1,36 @@
"""Paginated resources/list (lowlevel API): pages of two via an opaque integer-offset cursor."""
from typing import Any
import mcp_types as types
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel import Server
from mcp.shared.exceptions import MCPError
from stories._hosting import run_server_from_args
WORDS = ("alpha", "beta", "gamma", "delta", "epsilon", "zeta")
PAGE_SIZE = 2
def build_server() -> Server[Any]:
async def list_resources(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListResourcesResult:
start = 0
if params is not None and params.cursor is not None:
if not params.cursor.isdigit() or int(params.cursor) >= len(WORDS):
raise MCPError(code=types.INVALID_PARAMS, message=f"Unknown cursor: {params.cursor!r}")
start = int(params.cursor)
page = WORDS[start : start + PAGE_SIZE]
next_start = start + PAGE_SIZE
return types.ListResourcesResult(
resources=[types.Resource(uri=f"word://{w}", name=w) for w in page],
next_cursor=str(next_start) if next_start < len(WORDS) else None,
)
return Server("pagination-example", on_list_resources=list_resources)
if __name__ == "__main__":
run_server_from_args(build_server)
+60
View File
@@ -0,0 +1,60 @@
# parallel-calls
Two `Client`s connected to the same server, each with a `call_tool` in flight
at once. The `meet` tool is a rendezvous: a handler signals its own arrival,
then blocks until every named peer has arrived too — so neither call can return
unless the server runs both handlers concurrently. Each caller's
`progress_callback=` sees only the notifications for *its* request — each
`Client` is a separate connection, so there's no shared wire for them to cross
on.
## Run it
The tested legs run in-memory (`Client(server)`); the identical `main` body
works unchanged over HTTP — both clients just reach the same server. Under
`--http` the client self-hosts that server on a free port, runs, then tears it
down:
```bash
# --legacy because handler-emitted progress is dropped on the modern
# streamable-HTTP path today (see Caveats).
uv run python -m stories.parallel_calls.client --http --legacy
# same, against the lowlevel-API server variant
uv run python -m stories.parallel_calls.client --http --legacy --server server_lowlevel
```
There is no stdio run for this story: the stdio default spawns a fresh server
subprocess per connection, so two clients there could never rendezvous.
## What to look at
- **`client.py` — the two visible `Client(targets(), mode=...)` blocks.** Each
connection is constructed inside `attend(...)`; `targets()` yields a fresh
target on every call and both land on the same server instance. The two
blocks run in one `anyio` task group.
- **`server.py` — the `arrivals` barrier.** Each handler sets its own
`anyio.Event` then waits for every peer's. A server that processed requests
sequentially would never set the second event, so the client would time out —
the timeout *is* the concurrency assertion. No sleeps.
- **`client.py``progress_callback=` per call.** Each call passes its own
callback; `received == {"a": ["a"], "b": ["b"]}` shows each connection
delivered its own progress, and — combined with the rendezvous — that both
calls were genuinely in flight at once.
- **`server_lowlevel.py`** — same wire contract on the lowlevel `Server`,
reporting via `ctx.session.report_progress(...)`.
## Caveats
- Over Streamable HTTP in the modern (2026-07-28) era, handler-emitted progress
is currently dropped (the single-exchange dispatch context no-ops `notify()`).
In-memory (both eras) and legacy-era HTTP deliver progress correctly — hence
the `--legacy` above.
## Spec
[Progress flow](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress)
## See also
`streaming/` (progress + cancellation on one call), `reconnect/` (the other
multi-connection client), `tools/` (basics).
+40
View File
@@ -0,0 +1,40 @@
"""Two concurrent `Client`s, so `main` takes `targets`; their rendezvous in one tool proves concurrent dispatch."""
import anyio
from mcp_types import TextContent
from mcp.client import Client
from stories._harness import TargetFactory, run_client
async def main(targets: TargetFactory, *, mode: str = "auto") -> None:
party = ["a", "b"]
results: dict[str, str] = {}
received: dict[str, list[str | None]] = {tag: [] for tag in party}
async def attend(tag: str) -> None:
async def on_progress(progress: float, total: float | None, message: str | None) -> None:
received[tag].append(message)
# targets() yields a fresh connection target on every call; both land on the SAME
# server instance, so the two `meet` handlers can observe each other's arrival.
async with Client(targets(), mode=mode) as client:
result = await client.call_tool("meet", {"tag": tag, "party": party}, progress_callback=on_progress)
assert not result.is_error, result
assert isinstance(result.content[0], TextContent)
results[tag] = result.content[0].text
# Neither call can return until both handlers are running at once; a server that processed
# requests one-at-a-time would never set the second event and we'd time out here.
with anyio.fail_after(5):
async with anyio.create_task_group() as tg:
tg.start_soon(attend, "a")
tg.start_soon(attend, "b")
assert results == {"a": "a", "b": "b"}, results
# Progress is routed by progress token: each callback saw only its own tag, never the sibling's.
assert received == {"a": ["a"], "b": ["b"]}, received
if __name__ == "__main__":
run_client(main)
+31
View File
@@ -0,0 +1,31 @@
"""One tool that rendezvouses with named peers, proving the server dispatches calls concurrently."""
from collections import defaultdict
import anyio
from mcp.server.mcpserver import Context, MCPServer
from stories._hosting import run_server_from_args
def build_server() -> MCPServer:
mcp = MCPServer("parallel-calls-example")
# One Event per tag, shared across every call to this server instance. A handler sets its
# own tag's event, then waits for every peer's — so no call can return until all named
# peers are concurrently in-flight. A sequential dispatcher would deadlock here.
arrivals: dict[str, anyio.Event] = defaultdict(anyio.Event)
@mcp.tool()
async def meet(tag: str, party: list[str], ctx: Context) -> str:
"""Signal arrival as `tag`, block until every tag in `party` has also arrived, then return."""
arrivals[tag].set()
for peer in party:
await arrivals[peer].wait()
await ctx.report_progress(1.0, total=1.0, message=tag)
return tag
return mcp
if __name__ == "__main__":
run_server_from_args(build_server)
@@ -0,0 +1,48 @@
"""Rendezvous tool on the lowlevel `Server`, proving concurrent dispatch without `MCPServer`."""
from collections import defaultdict
from typing import Any
import anyio
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
MEET_INPUT_SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"tag": {"type": "string"},
"party": {"type": "array", "items": {"type": "string"}},
},
"required": ["tag", "party"],
}
def build_server() -> Server[Any]:
arrivals: dict[str, anyio.Event] = defaultdict(anyio.Event)
async def list_tools(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="meet", description="Rendezvous with peers.", input_schema=MEET_INPUT_SCHEMA)]
)
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
assert params.name == "meet"
assert params.arguments is not None
tag = params.arguments["tag"]
assert isinstance(tag, str)
arrivals[tag].set()
for peer in params.arguments["party"]:
await arrivals[peer].wait()
await ctx.session.report_progress(1.0, total=1.0, message=tag)
return types.CallToolResult(content=[types.TextContent(text=tag)])
return Server("parallel-calls-example", on_list_tools=list_tools, on_call_tool=call_tool)
if __name__ == "__main__":
run_server_from_args(build_server)
+50
View File
@@ -0,0 +1,50 @@
# prompts
Expose prompt templates with `@mcp.prompt()` and let clients autocomplete their
arguments with `@mcp.completion()`. `MCPServer` derives each prompt's
`arguments` (name + required) from the function signature. The client lists
prompts, completes the `language` argument of `code_review`, then renders both
prompts.
## Run it
```bash
# stdio (default — the client spawns the server as a subprocess)
uv run python -m stories.prompts.client
# HTTP — the client self-hosts the server on a free port, runs, then tears it down
uv run python -m stories.prompts.client --http
# same, against the lowlevel-API server variant
uv run python -m stories.prompts.client --http --server server_lowlevel
```
## What to look at
- `client.py` `main` — the body opens with `async with Client(target,
mode=mode) as client:`; `target` is anything `Client(...)` accepts (an
in-process server, a `Transport`, or an HTTP URL).
- `server.py` `greet` vs `code_review` — return a bare `str` (wrapped as one
user message) or a `list[Message]` for a multi-turn seed conversation.
- `server.py` `complete()` — one global handler dispatches on `ref` +
`argument.name`; returning `None` becomes an empty completion. There is no
per-argument `completer=` sugar yet.
- `server_lowlevel.py` — the same `Prompt` / `PromptArgument` descriptors and
`GetPromptResult` built by hand; this is what `MCPServer` generates for you.
- `client.py` `complete(...)` — `argument` is a `{"name": ..., "value": ...}`
dict, the only `Client` request method that takes a raw dict for a typed
wire field.
## Caveats
`@mcp.prompt()` and `@mcp.completion()` need the parentheses — `@mcp.prompt`
without `()` raises a confusing `TypeError` at registration time.
## Spec
[Prompts](https://modelcontextprotocol.io/specification/2025-11-25/server/prompts)
· [Completion](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion)
## See also
`tools/` (start here), `resources/` (the other `ref` kind completion accepts),
`pagination/` (`list_prompts` cursor loop).
+39
View File
@@ -0,0 +1,39 @@
"""List prompts, autocomplete an argument, then render both prompts."""
from mcp_types import PromptReference, TextContent
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:
listed = await client.list_prompts()
by_name = {p.name: p for p in listed.prompts}
assert set(by_name) == {"greet", "code_review"}
assert by_name["greet"].arguments is not None
assert [a.name for a in by_name["greet"].arguments] == ["name"]
assert by_name["greet"].arguments[0].required is True
assert by_name["code_review"].title == "Code Review"
completion = await client.complete(
PromptReference(name="code_review"),
argument={"name": "language", "value": "py"},
)
assert completion.completion.values == ["python", "pytorch"], completion
greeted = await client.get_prompt("greet", {"name": "Ada"})
assert len(greeted.messages) == 1
assert greeted.messages[0].role == "user"
assert isinstance(greeted.messages[0].content, TextContent)
assert "Ada" in greeted.messages[0].content.text
reviewed = await client.get_prompt("code_review", {"language": "rust", "code": "fn main() {}"})
assert [m.role for m in reviewed.messages] == ["user", "assistant"]
first = reviewed.messages[0].content
assert isinstance(first, TextContent)
assert "rust" in first.text and "fn main() {}" in first.text
if __name__ == "__main__":
run_client(main)
+43
View File
@@ -0,0 +1,43 @@
"""Prompts primitive: register templates, list, render, complete an argument."""
from mcp_types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, UserMessage
from stories._hosting import run_server_from_args
LANGUAGES = ["python", "pytorch", "rust", "go", "typescript"]
def build_server() -> MCPServer:
mcp = MCPServer("prompts-example")
@mcp.prompt(title="Greeting")
def greet(name: str) -> str:
"""Ask the model to greet someone by name."""
return f"Write a one-line greeting for {name}."
@mcp.prompt(title="Code Review")
def code_review(language: str, code: str) -> list[Message]:
"""Ask the model to review a code snippet."""
return [
UserMessage(f"Review this {language} code for bugs and idioms:\n\n{code}"),
AssistantMessage("I'll review it. Let me read through the code first."),
]
@mcp.completion()
async def complete(
ref: PromptReference | ResourceTemplateReference,
argument: CompletionArgument,
context: CompletionContext | None,
) -> Completion | None:
if isinstance(ref, PromptReference) and ref.name == "code_review" and argument.name == "language":
matches = [lang for lang in LANGUAGES if lang.startswith(argument.value)]
return Completion(values=matches, total=len(matches), has_more=False)
return None
return mcp
if __name__ == "__main__":
run_server_from_args(build_server)
@@ -0,0 +1,87 @@
"""Prompts primitive (lowlevel API): hand-built Prompt descriptors, GetPromptResult, completion."""
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
LANGUAGES = ["python", "pytorch", "rust", "go", "typescript"]
PROMPTS = [
types.Prompt(
name="greet",
title="Greeting",
description="Ask the model to greet someone by name.",
arguments=[types.PromptArgument(name="name", required=True)],
),
types.Prompt(
name="code_review",
title="Code Review",
description="Ask the model to review a code snippet.",
arguments=[
types.PromptArgument(name="language", required=True),
types.PromptArgument(name="code", required=True),
],
),
]
def build_server() -> Server[Any]:
async def list_prompts(
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
) -> types.ListPromptsResult:
return types.ListPromptsResult(prompts=PROMPTS)
async def get_prompt(ctx: ServerRequestContext[Any], params: types.GetPromptRequestParams) -> types.GetPromptResult:
args = params.arguments or {}
if params.name == "greet":
return types.GetPromptResult(
description="Ask the model to greet someone by name.",
messages=[
types.PromptMessage(
role="user",
content=types.TextContent(text=f"Write a one-line greeting for {args['name']}."),
)
],
)
if params.name == "code_review":
return types.GetPromptResult(
description="Ask the model to review a code snippet.",
messages=[
types.PromptMessage(
role="user",
content=types.TextContent(
text=f"Review this {args['language']} code for bugs and idioms:\n\n{args['code']}"
),
),
types.PromptMessage(
role="assistant",
content=types.TextContent(text="I'll review it. Let me read through the code first."),
),
],
)
raise NotImplementedError
async def completion(ctx: ServerRequestContext[Any], params: types.CompleteRequestParams) -> types.CompleteResult:
if (
isinstance(params.ref, types.PromptReference)
and params.ref.name == "code_review"
and params.argument.name == "language"
):
matches = [lang for lang in LANGUAGES if lang.startswith(params.argument.value)]
return types.CompleteResult(completion=types.Completion(values=matches, total=len(matches), has_more=False))
return types.CompleteResult(completion=types.Completion(values=[]))
return Server(
"prompts-example",
on_list_prompts=list_prompts,
on_get_prompt=get_prompt,
on_completion=completion,
)
if __name__ == "__main__":
run_server_from_args(build_server)
+56
View File
@@ -0,0 +1,56 @@
# reconnect
Probe `server/discover` once, persist the `DiscoverResult`, and reconnect with
**zero round-trips**. The first client connects at `mode="auto"` (one
`server/discover` request inside `__aenter__`); a second client at
`mode=LATEST_MODERN_VERSION, prior_discover=<cached>` enters with no wire
traffic and has `server_info` / `server_capabilities` available immediately.
## Run it
```bash
# over HTTP — Streamable HTTP only; in-memory has no "round-trip" to skip.
# The client self-hosts the server on a free port, runs, then tears it down.
uv run python -m stories.reconnect.client --http
# same, against the lowlevel-API server variant
uv run python -m stories.reconnect.client --http --server server_lowlevel
```
## What to look at
- `client.py` — the first `Client(targets(), mode="auto")`. The `mode="auto"`
connect ladder runs `server/discover` inside `__aenter__`;
`client.session.discover_result` is the cached result. Round-trip it through
`model_dump_json()` / `DiscoverResult.model_validate_json()` to model an
on-disk cache.
- `client.py` — `Client(targets(), mode=LATEST_MODERN_VERSION,
prior_discover=rehydrated)`. A version pin plus a prior `DiscoverResult`
installs the cached state via `ClientSession.adopt()` with no `initialize`
and no `server/discover` on the wire — the era-neutral `client.server_info` /
`.server_capabilities` accessors are populated before the first request.
- `client.py` — `targets()`. A `Client` cannot be re-entered after exit; each
call yields a fresh target against the same server, so the reconnect is a
genuinely new connection.
## Caveats
- `mode=<version-pin>` *without* `prior_discover=` synthesizes a placeholder
whose `server_info` is `Implementation(name="", version="")`. Pass the cached
result to get real identity on reconnect. Whether `Client` should expose a
public synthesizer (or refuse the bare pin) is open.
- `client.session.discover_result` is a one-hop reach into the mechanics layer;
`Client` does not yet surface the cached result directly.
- The wire-level proof that the second entry sends zero requests lives in the
interaction suite (`test_prior_discover_populates_state_with_zero_connect_time_traffic`);
this story asserts only what's observable through the public `Client`
surface.
## Spec
- [`server/discover`](https://modelcontextprotocol.io/specification/draft/server/discover)
- [Versioning — backward compatibility](https://modelcontextprotocol.io/specification/draft/basic/versioning)
## See also
`dual_era/` (auto-discover + era-neutral accessors), `parallel_calls/` (the
other multi-connection client).
+44
View File
@@ -0,0 +1,44 @@
"""Probe server/discover once, persist the result, reconnect with zero round-trips — a fresh `Client` via `targets`."""
from mcp_types import DiscoverResult
from mcp_types.version import LATEST_MODERN_VERSION
from mcp.client import Client
from stories._harness import TargetFactory, run_client
async def main(targets: TargetFactory, *, mode: str = "auto") -> None:
# The caller's mode (the real-user "auto" default) probes server/discover inside
# __aenter__ and caches the result; a hard version pin would skip the probe and
# never see the server's real DiscoverResult.
async with Client(targets(), mode=mode) as client:
discovered = client.session.discover_result
assert discovered is not None, "mode='auto' against a modern server populates discover_result"
assert client.protocol_version == LATEST_MODERN_VERSION
assert client.server_info.name == "reconnect-example"
assert LATEST_MODERN_VERSION in discovered.supported_versions
result = await client.call_tool("add", {"a": 2, "b": 3})
assert result.structured_content == {"result": 5}, result
# Round-trip through JSON to model loading the result from an on-disk cache.
saved = discovered.model_dump_json(by_alias=True)
rehydrated = DiscoverResult.model_validate_json(saved)
assert rehydrated == discovered
# Reconnect: a version pin plus the cached DiscoverResult adopts the prior state with
# zero round-trips on entry. A Client cannot be re-entered after exit, so targets()
# yields a fresh one. Without prior_discover= a bare pin would synthesize a blank
# server_info — the cache is what makes the era-neutral accessors useful here.
async with Client(targets(), mode=LATEST_MODERN_VERSION, prior_discover=rehydrated) as second:
assert second.protocol_version == LATEST_MODERN_VERSION
assert second.server_info.name == "reconnect-example"
assert second.server_capabilities.tools is not None
assert second.session.discover_result == rehydrated
result = await second.call_tool("add", {"a": 1, "b": 1})
assert result.structured_content == {"result": 2}, result
if __name__ == "__main__":
run_client(main)
+23
View File
@@ -0,0 +1,23 @@
"""A small modern server whose DiscoverResult a client persists for zero-RTT reconnect."""
from mcp.server.mcpserver import MCPServer
from stories._hosting import run_server_from_args
def build_server() -> MCPServer:
mcp = MCPServer(
"reconnect-example",
version="1.0.0",
instructions="Call add(a, b) to sum two integers.",
)
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two integers."""
return a + b
return mcp
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