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
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:
@@ -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/`.
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Shared scaffolding the auth/hosting stories import (not teaching surface)."""
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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`).
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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`.
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
@@ -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
Reference in New Issue
Block a user