Add story-style examples suite (27 stories + harness + CI) (#2957)
This commit is contained in:
parent
9dc8c5f02d
commit
4caa41f6d5
160 changed files with 6801 additions and 26 deletions
4
.github/workflows/shared.yml
vendored
4
.github/workflows/shared.yml
vendored
|
|
@ -79,6 +79,10 @@ jobs:
|
|||
|
||||
- name: Run pytest with coverage
|
||||
shell: bash
|
||||
env:
|
||||
# tests/examples/test_stories_smoke.py is gated on this var; it spawns real
|
||||
# stdio + uvicorn subprocesses, so run it on exactly one matrix cell.
|
||||
MCP_EXAMPLES_SMOKE: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' && matrix.dep-resolution.name == 'locked' && '1' || '' }}
|
||||
run: |
|
||||
uv run --frozen --no-sync coverage erase
|
||||
uv run --frozen --no-sync coverage run -m pytest -n auto
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
# Python SDK Examples
|
||||
# Python SDK examples
|
||||
|
||||
This folders aims to provide simple examples of using the Python SDK. Please refer to the
|
||||
[servers repository](https://github.com/modelcontextprotocol/servers)
|
||||
for real-world servers.
|
||||
- [`stories/`](stories/) — **the canonical reference.** One self-verifying
|
||||
example per protocol feature, each with its own README. Start with
|
||||
[`stories/tools/`](stories/tools/); the [stories README](stories/README.md)
|
||||
has the full table and how to run them.
|
||||
- [`snippets/`](snippets/) — short extracts embedded into `README.v2.md`. Kept
|
||||
minimal and in sync with the top-level README; not intended to be run
|
||||
standalone.
|
||||
- [`servers/everything-server/`](servers/everything-server/) — the conformance
|
||||
target for the cross-SDK
|
||||
[conformance suite](https://github.com/modelcontextprotocol/conformance).
|
||||
Exercises every server capability in one process.
|
||||
- [`mcpserver/`](mcpserver/) — single-file v1-era examples retained for the
|
||||
migration guide; superseded by `stories/` and slated for removal.
|
||||
- [`clients/`](clients/) and the remaining [`servers/`](servers/) directories
|
||||
(`simple-*`, `sse-polling-demo`, `structured-output-lowlevel`) — standalone
|
||||
v1-era projects still linked from `README.v2.md`; retained pending
|
||||
consolidation into `stories/`.
|
||||
|
||||
For real-world servers see the
|
||||
[servers repository](https://github.com/modelcontextprotocol/servers).
|
||||
|
|
|
|||
16
examples/pyproject.toml
Normal file
16
examples/pyproject.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
[project]
|
||||
name = "mcp-example-stories"
|
||||
version = "0.0.0"
|
||||
description = "Self-verifying example suite for the MCP Python SDK (dev-only, not published)"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"mcp",
|
||||
"tomli>=2.0; python_version < '3.11'",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["stories"]
|
||||
164
examples/stories/README.md
Normal file
164
examples/stories/README.md
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
# Story examples
|
||||
|
||||
One feature per folder. Each story is a small, self-verifying program: a
|
||||
`server.py` (plus, where the wire contract is worth seeing by hand, a
|
||||
`server_lowlevel.py`) and a `client.py` whose `main()` makes assertions and
|
||||
exits non-zero on failure. The code you read here is the same code CI runs —
|
||||
there is no separate test double.
|
||||
|
||||
## Canonical shape
|
||||
|
||||
Every `client.py` starts from this skeleton — copy it, then replace the body
|
||||
with the story's assertions:
|
||||
|
||||
```python
|
||||
"""One line: what this client proves."""
|
||||
|
||||
from mcp.client import Client
|
||||
from stories._harness import Target, run_client
|
||||
|
||||
|
||||
async def main(target: Target, *, mode: str = "auto") -> None:
|
||||
async with Client(target, mode=mode) as client:
|
||||
... # the story's assertions
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_client(main)
|
||||
```
|
||||
|
||||
There are exactly two `main` shapes. A story that opens **one** connection
|
||||
takes `main(target: Target, ...)`. A story that opens **more than one** sets
|
||||
`multi_connection = true` in [`manifest.toml`](manifest.toml), takes
|
||||
`main(targets: TargetFactory, ...)`, and calls `targets()` once per fresh
|
||||
connection — a `Client` cannot be re-entered after exit. Nothing else changes
|
||||
shape.
|
||||
|
||||
Story files import from `stories._harness` only these names: `run_client`,
|
||||
`target_from_args`, `Target`, `TargetFactory` — plus `AuthBuilder` for the
|
||||
auth stories. Everything else a story uses comes from public `mcp.*` modules.
|
||||
|
||||
The repetition this produces across stories is deliberate, not a refactor
|
||||
waiting to happen: each `client.py` is a standalone, compiled doc page, so
|
||||
when a public API changes, N red example files flag N doc pages. Don't pull
|
||||
the `Client(target, mode=mode)` line (or anything around it) into a shared
|
||||
helper. A story that can't be the canonical shape says why in its module
|
||||
docstring's first line.
|
||||
|
||||
## How to read a story
|
||||
|
||||
Start with the story's README, then `server.py`, then `client.py`. Every
|
||||
`client.py` exports `async def main(target, *, mode="auto")` — or
|
||||
`main(targets, ...)` for the stories that open more than one connection — and
|
||||
constructs the `Client` itself, so the body opens with the one line a client
|
||||
example exists to teach: `async with Client(target, mode=mode) as client:`.
|
||||
The `run_client(main)` call in the `__main__` block is only argv plumbing
|
||||
(stdio vs `--http`, which `mode` to pass); it never hides how the client
|
||||
connects.
|
||||
|
||||
## Running a story
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
# stdio (default — the client spawns the server as a subprocess)
|
||||
uv run python -m stories.tools.client
|
||||
|
||||
# HTTP, self-hosted — the client spawns the server on a real uvicorn socket on a
|
||||
# port it owns, waits for it, runs, then terminates it. Nothing to background or kill.
|
||||
uv run python -m stories.tools.client --http
|
||||
|
||||
# the same self-hosted run against the story's lowlevel-API server variant
|
||||
uv run python -m stories.tools.client --http --server server_lowlevel
|
||||
|
||||
# HTTP against a server you run yourself
|
||||
uv run python -m stories.tools.server --http --port 8000 # separate terminal
|
||||
uv run python -m stories.tools.client --http http://127.0.0.1:8000/mcp
|
||||
```
|
||||
|
||||
`--http` takes two forms. Bare `--http` is the canonical HTTP run — it is
|
||||
complete on its own, and it is what every per-story README shows. `--http
|
||||
<url>` connects to a server you started yourself; the per-story READMEs spell
|
||||
that out only where hosting is the lesson (the HTTP-hosting and auth stories).
|
||||
`--server <stem>` swaps in a sibling server module on stdio and on the
|
||||
self-hosted `--http` run; with `--http <url>` you already picked the server
|
||||
when you started it. The auth stories (`bearer_auth/`, `oauth/`,
|
||||
`oauth_client_credentials/`) self-host on their fixed `:8000` instead of a
|
||||
free port because their issuer/PRM metadata bake it in — `:8000` must be
|
||||
free, and the run refuses to start (rather than silently testing whatever is
|
||||
there) if it is not.
|
||||
|
||||
The full matrix (every story × transport × era × server-variant) runs under
|
||||
pytest:
|
||||
|
||||
```bash
|
||||
uv run --frozen pytest tests/examples/ # everything
|
||||
uv run --frozen pytest tests/examples/ -k tools # one story
|
||||
```
|
||||
|
||||
[`manifest.toml`](manifest.toml) declares each story's transports, era, status,
|
||||
and variants; `tests/examples/` expands it.
|
||||
|
||||
## Layout
|
||||
|
||||
`_hosting.py` adapts a story's `build_server()` / `build_app()` to argv (stdio
|
||||
vs `--http` serving); `_harness.py` is the client-side mirror — it picks the
|
||||
`target` that `main()` connects to (a stdio subprocess by default, a self-hosted
|
||||
HTTP subprocess under bare `--http`, your URL under `--http <url>`). They
|
||||
isolate the parts of the SDK's hosting surface
|
||||
that are still moving — **don't copy them into your own project**; copy the
|
||||
`server.py` / `client.py` bodies instead. `_shared/` holds an in-process OAuth
|
||||
authorization server reused by the auth stories.
|
||||
|
||||
## Stories
|
||||
|
||||
The **status** column is the feature's standing in the protocol, from
|
||||
[`manifest.toml`](manifest.toml): `current`, `legacy` (a 2025 handshake-era
|
||||
mechanism with a 2026-era replacement), or `deprecated` (deprecated by
|
||||
SEP-2577; functional through the deprecation window). Each non-`current` story's README
|
||||
opens with a banner saying what replaces it.
|
||||
|
||||
| story | what it shows | status |
|
||||
|---|---|---|
|
||||
| **— start here —** | | |
|
||||
| [`tools`](tools/) | `@mcp.tool()`, schema inference, structured output, annotations | current |
|
||||
| [`prompts`](prompts/) | `@mcp.prompt()`, list/get, argument completion | current |
|
||||
| [`resources`](resources/) | `@mcp.resource()`, list/read, URI templates | current |
|
||||
| [`lifespan`](lifespan/) | startup/shutdown lifespan, per-request state injection | current |
|
||||
| [`dual_era`](dual_era/) | one server factory serving both protocol eras; era-neutral accessors | current |
|
||||
| **— feature stories —** | | |
|
||||
| [`streaming`](streaming/) | progress notifications, in-flight logging, cancellation | current |
|
||||
| [`legacy_elicitation`](legacy_elicitation/) | server pauses a tool to ask the user (form + url) via a push request | legacy |
|
||||
| [`sampling`](sampling/) | server asks the client's LLM mid-tool (push request) | deprecated |
|
||||
| [`stickynotes`](stickynotes/) | capstone: tools mutate state → resources + `list_changed` + elicit guard | current |
|
||||
| [`custom_methods`](custom_methods/) | vendor-prefixed JSON-RPC via `add_request_handler` / `send_request` | current |
|
||||
| [`schema_validators`](schema_validators/) | tool input schema from pydantic / TypedDict / dataclass / dict | current |
|
||||
| [`middleware`](middleware/) | server-side request/response middleware | current |
|
||||
| [`parallel_calls`](parallel_calls/) | two clients rendezvous in one tool; per-call progress attribution | current |
|
||||
| [`roots`](roots/) | client-declared roots, server reads them via `ctx` | deprecated |
|
||||
| [`pagination`](pagination/) | manual cursor loop over list endpoints | current |
|
||||
| [`error_handling`](error_handling/) | `is_error` results vs `MCPError`; `ToolError` | current |
|
||||
| [`serve_one`](serve_one/) | building a `Connection` by hand and calling `serve_one` directly | current |
|
||||
| **— HTTP hosting —** | | |
|
||||
| [`stateless_legacy`](stateless_legacy/) | `streamable_http_app(stateless_http=True)`; the one-liner deploy | current |
|
||||
| [`json_response`](json_response/) | `json_response=True` mode; raw 2026 POST envelope on the wire | current |
|
||||
| [`legacy_routing`](legacy_routing/) | `classify_inbound_request()` era routing in front of a sessionful 1.x deploy | current |
|
||||
| [`starlette_mount`](starlette_mount/) | mounting `streamable_http_app()` under a Starlette/FastAPI sub-path | current |
|
||||
| [`sse_polling`](sse_polling/) | SEP-1699 `closeSSE()` + `Last-Event-ID` resume via `EventStore` | legacy |
|
||||
| [`standalone_get`](standalone_get/) | server-initiated `list_changed` over the sessionful GET stream | legacy |
|
||||
| [`reconnect`](reconnect/) | explicit `discover()`, persist `DiscoverResult`, zero-RTT reconnect | current |
|
||||
| [`bearer_auth`](bearer_auth/) | `TokenVerifier` + `AuthSettings` bearer gate, PRM metadata, `get_access_token()` | current |
|
||||
| [`oauth`](oauth/) | full `authorization_code` grant against an in-process AS | current |
|
||||
| [`oauth_client_credentials`](oauth_client_credentials/) | `client_credentials` grant; minimal in-process token endpoint | current |
|
||||
| **— deferred (README only) —** | | |
|
||||
| [`caching`](caching/) | `CacheableResult` ttl/scope hints; client honouring | not yet implemented |
|
||||
| [`mrtr`](mrtr/) | `InputRequiredResult` round-trip with `requestState` HMAC | not yet implemented — [#2898](https://github.com/modelcontextprotocol/python-sdk/issues/2898) |
|
||||
| [`subscriptions`](subscriptions/) | `subscriptions/listen`, `ServerEventBus`, `Client.listen()` | not yet implemented — [#2901](https://github.com/modelcontextprotocol/python-sdk/issues/2901) |
|
||||
| [`tasks`](tasks/) | `io.modelcontextprotocol/tasks` extension | not yet implemented |
|
||||
| [`apps`](apps/) | MCP Apps: `ui://` resource + `_meta.ui` | not yet implemented — [#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896) |
|
||||
| [`skills`](skills/) | SEP-2640 skills extension | not yet implemented — [#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896) |
|
||||
| [`events`](events/) | `io.modelcontextprotocol/events` extension | not yet implemented |
|
||||
|
||||
The TypeScript SDK's `repl`, `client-quickstart`, and `server-quickstart`
|
||||
examples are intentionally not ported (interactive / external network deps);
|
||||
its `hono` example maps to `starlette_mount/`.
|
||||
6
examples/stories/__init__.py
Normal file
6
examples/stories/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""Self-verifying example suite for the MCP Python SDK.
|
||||
|
||||
Each story directory holds a ``server.py`` (and usually ``server_lowlevel.py``)
|
||||
plus a ``client.py`` whose ``main(target, *, mode)`` runs against both.
|
||||
``tests/examples/`` drives every story over an in-process matrix.
|
||||
"""
|
||||
203
examples/stories/_harness.py
Normal file
203
examples/stories/_harness.py
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
"""Client-side scaffold for story examples.
|
||||
|
||||
A story's ``client.py`` imports ``Target`` (or ``TargetFactory``) for its ``main``
|
||||
signature and calls ``run_client(main)`` from ``__main__``. The story owns the
|
||||
``Client(target, mode=...)`` construction; this module only decides WHICH target
|
||||
``__main__`` hands it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import sys
|
||||
import traceback
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeAlias
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
from mcp_types.version import LATEST_MODERN_VERSION
|
||||
|
||||
from mcp import StdioServerParameters, stdio_client
|
||||
from mcp.client import Transport
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.server import Server
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
import tomllib
|
||||
else:
|
||||
import tomli as tomllib
|
||||
|
||||
Target: TypeAlias = "Server[Any] | MCPServer | Transport | str"
|
||||
"""Anything ``Client(...)`` accepts: an in-process server, a ``Transport``, or an HTTP URL."""
|
||||
|
||||
TargetFactory = Callable[[], Target]
|
||||
"""Yields a FRESH target against the same server/app on every call (``multi_connection`` stories)."""
|
||||
|
||||
AuthBuilder = Callable[[httpx.AsyncClient], httpx.Auth]
|
||||
"""Builds an ``httpx.Auth`` bound to the in-process HTTP client (auth-story harness seam)."""
|
||||
|
||||
|
||||
def argv_after(flag: str, *, default: str | None = None) -> str:
|
||||
"""Return the argv token following ``flag``, or ``default`` when the flag is absent."""
|
||||
try:
|
||||
return sys.argv[sys.argv.index(flag) + 1]
|
||||
except ValueError:
|
||||
if default is None:
|
||||
raise SystemExit(f"missing required {flag}") from None
|
||||
return default
|
||||
|
||||
|
||||
def target_from_args(file: str, url: str | None) -> TargetFactory:
|
||||
"""Build a ``TargetFactory`` for the sibling server of the ``client.py`` at ``file``.
|
||||
|
||||
``url`` (already resolved by ``run_client``) targets that streamable-HTTP endpoint; ``None``
|
||||
spawns ``<stem>.py`` over stdio per call, ``<stem>`` from ``--server`` (default ``server``).
|
||||
"""
|
||||
if url is not None:
|
||||
return lambda: url
|
||||
# stdio is legacy-only until serve_stdio() lands; the modern arm is --http only for now.
|
||||
server = Path(file).parent / f"{argv_after('--server', default='server')}.py"
|
||||
params = StdioServerParameters(command=sys.executable, args=[str(server)])
|
||||
return lambda: stdio_client(params) # becomes Client(params) once that overload lands
|
||||
|
||||
|
||||
def _explicit_http_url() -> str | None:
|
||||
"""The URL token after ``--http``, or ``None`` when the flag stands alone (self-host)."""
|
||||
rest = sys.argv[sys.argv.index("--http") + 1 :]
|
||||
return rest[0] if rest and not rest[0].startswith("-") else None
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
"""An OS-assigned free TCP port, released for the server subprocess to re-bind."""
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return sock.getsockname()[1]
|
||||
|
||||
|
||||
async def _accepting(port: int) -> bool:
|
||||
"""Whether something accepts a TCP connect on ``127.0.0.1:port`` right now."""
|
||||
try:
|
||||
stream = await anyio.connect_tcp("127.0.0.1", port)
|
||||
except OSError:
|
||||
return False
|
||||
await stream.aclose()
|
||||
return True
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _self_hosted(name: str, cfg: dict[str, Any]) -> AsyncIterator[str]:
|
||||
"""Serve the story's sibling server from a subprocess on a port this process owns; yield its URL.
|
||||
|
||||
Readiness is the first accepted TCP connect (bounded by ``run_client``'s
|
||||
``anyio.fail_after``); exiting terminates the subprocess. Nothing to background or kill.
|
||||
A subprocess that dies before serving, or a ``fixed_port`` someone else already holds,
|
||||
is a loud ``SystemExit`` rather than a hang or a run against the wrong server.
|
||||
"""
|
||||
port: int = cfg["fixed_port"] or _free_port()
|
||||
if cfg["fixed_port"] and await _accepting(port):
|
||||
# The readiness probe below can't tell our child from a server already on the
|
||||
# story's pinned port, so a foreign listener would be tested in its place.
|
||||
raise SystemExit(
|
||||
f"{name} self-hosts on :{port} but something is already serving there; "
|
||||
f"stop it, or connect to it with --http <url>"
|
||||
)
|
||||
module = f"stories.{name}.{argv_after('--server', default='server')}"
|
||||
serve = ["--http"] if cfg["server_export"] == "factory" else []
|
||||
argv = [sys.executable, "-m", module, *serve, "--port", str(port)]
|
||||
async with await anyio.open_process(argv, stdout=None, stderr=None) as server:
|
||||
try:
|
||||
while server.returncode is None and not await _accepting(port):
|
||||
await anyio.sleep(0.05)
|
||||
if server.returncode is not None:
|
||||
raise SystemExit(f"{module} exited {server.returncode} before serving on :{port}")
|
||||
yield f"http://127.0.0.1:{port}{cfg['mcp_path']}"
|
||||
finally:
|
||||
if server.returncode is None:
|
||||
server.terminate()
|
||||
|
||||
|
||||
def _story_cfg(name: str) -> dict[str, Any]:
|
||||
"""The manifest entry for the story ``name`` with ``[defaults]`` applied."""
|
||||
manifest: dict[str, Any] = tomllib.loads((Path(__file__).parent / "manifest.toml").read_text())
|
||||
return manifest["defaults"] | manifest["story"].get(name, {})
|
||||
|
||||
|
||||
def _authed_targets(url: str, http: httpx.AsyncClient) -> TargetFactory:
|
||||
"""Fresh streamable-HTTP transports over an already-authed ``httpx`` client."""
|
||||
return lambda: streamable_http_client(url, http_client=http)
|
||||
|
||||
|
||||
def run_client(main: Callable[..., Awaitable[None]]) -> None:
|
||||
"""Entry point for ``if __name__ == "__main__"`` in every ``client.py``.
|
||||
|
||||
Resolves the argv target — stdio (the default), ``--http <url>`` for a server you run, or
|
||||
bare ``--http`` to self-host the sibling server in a subprocess it owns — and calls ``main``
|
||||
with an explicit ``mode=``. A ``build_auth`` export auths the HTTP target. ``OK``/``FAIL``, exit 0/1.
|
||||
"""
|
||||
globals_ = getattr(main, "__globals__", {})
|
||||
file = str(globals_.get("__file__", "<unknown>"))
|
||||
name = Path(file).parent.name
|
||||
cfg = _story_cfg(name)
|
||||
build_auth: AuthBuilder | None = globals_.get("build_auth")
|
||||
transport = "http" if "--http" in sys.argv else "stdio"
|
||||
if cfg["server_export"] == "app" and transport != "http":
|
||||
raise SystemExit(
|
||||
f"{name} exports an ASGI app (no stdio entry point); self-host it over HTTP:\n"
|
||||
f" python -m stories.{name}.client --http"
|
||||
)
|
||||
if cfg["needs_http"] and transport != "http":
|
||||
raise SystemExit(f"{name} asserts on raw HTTP responses; run it with --http")
|
||||
explicit_url = _explicit_http_url() if transport == "http" else None
|
||||
# The era is an axis of the story matrix, so ``mode=`` is always passed explicitly
|
||||
# even though it often matches the ``Client`` default of "auto". stdio is legacy-only
|
||||
# until the SDK's stdio entry can negotiate the era, so only --http gets a modern arm.
|
||||
era = "modern" if transport == "http" and "--legacy" not in sys.argv else "legacy"
|
||||
if cfg["era"] in ("legacy", "modern"):
|
||||
era = cfg["era"]
|
||||
if cfg["era"] == "dual-in-body":
|
||||
# The story pins its connection modes inside ``main`` itself, so hand it "auto"
|
||||
# (the ``Client`` default) and let those in-body pins decide. A hard version pin
|
||||
# here would skip the discover probe and leave ``server_info`` blank.
|
||||
era = "in-body"
|
||||
mode = {"modern": LATEST_MODERN_VERSION, "legacy": "legacy", "in-body": "auto"}[era]
|
||||
|
||||
async def _run() -> None:
|
||||
with anyio.fail_after(cfg["timeout_s"]):
|
||||
async with AsyncExitStack() as stack:
|
||||
url = explicit_url
|
||||
if transport == "http" and url is None:
|
||||
url = await stack.enter_async_context(_self_hosted(name, cfg))
|
||||
targets = target_from_args(file, url)
|
||||
if url is None or (build_auth is None and not cfg["needs_http"]):
|
||||
await main(targets if cfg["multi_connection"] else targets(), mode=mode)
|
||||
return
|
||||
# Auth and needs_http stories want the raw httpx client underneath the transport:
|
||||
# build_auth threads an httpx.Auth onto it (Client(url, auth=...) doesn't exist
|
||||
# yet), and needs_http stories assert on raw responses, so root the client at the
|
||||
# server origin and relative paths like "/mcp" resolve.
|
||||
parts = urlsplit(url)
|
||||
base = f"{parts.scheme}://{parts.netloc}"
|
||||
http = await stack.enter_async_context(httpx.AsyncClient(base_url=base))
|
||||
make = targets
|
||||
if build_auth is not None:
|
||||
http.auth = build_auth(http)
|
||||
make = _authed_targets(url, http)
|
||||
target: Any = make if cfg["multi_connection"] else make()
|
||||
if cfg["needs_http"]:
|
||||
await main(target, mode=mode, http=http)
|
||||
else:
|
||||
await main(target, mode=mode)
|
||||
|
||||
try:
|
||||
anyio.run(_run)
|
||||
except Exception:
|
||||
print(f"FAIL: {name} ({transport}/{era})", file=sys.stderr)
|
||||
traceback.print_exc()
|
||||
raise SystemExit(1) from None
|
||||
print(f"OK: {name} ({transport}/{era})", file=sys.stderr)
|
||||
raise SystemExit(0)
|
||||
87
examples/stories/_hosting.py
Normal file
87
examples/stories/_hosting.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Server-side hosting scaffold for story examples.
|
||||
|
||||
A story's ``server.py`` / ``server_lowlevel.py`` imports only from here. The
|
||||
marked lines touch entry-point APIs that a later release reshapes into
|
||||
free-function entries; isolating them here keeps story bodies stable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from typing import Any, TypeAlias
|
||||
|
||||
import anyio
|
||||
import uvicorn
|
||||
from starlette.applications import Starlette
|
||||
|
||||
from mcp.server.lowlevel import Server
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.server.stdio import stdio_server
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
|
||||
AnyServer: TypeAlias = "MCPServer | Server[Any]"
|
||||
ServerFactory = Callable[[], AnyServer]
|
||||
AppFactory = Callable[[], Starlette]
|
||||
|
||||
NO_DNS_REBIND = TransportSecuritySettings(enable_dns_rebinding_protection=False)
|
||||
"""Harness servers bind 127.0.0.1 and the in-process httpx client sends no Origin header."""
|
||||
|
||||
|
||||
def argv_after(flag: str, *, default: str | None = None) -> str:
|
||||
"""Return the argv token following ``flag``, or ``default`` when the flag is absent."""
|
||||
try:
|
||||
return sys.argv[sys.argv.index(flag) + 1]
|
||||
except ValueError:
|
||||
if default is None:
|
||||
raise SystemExit(f"missing required {flag}") from None
|
||||
return default
|
||||
|
||||
|
||||
def asgi_from(server: AnyServer, *, path: str = "/mcp") -> Starlette:
|
||||
"""Wrap a server instance in its streamable-HTTP ASGI app for in-process driving."""
|
||||
return server.streamable_http_app( # becomes free fn streamable_http(server, legacy=...)
|
||||
streamable_http_path=path,
|
||||
stateless_http=False, # bool folds into a legacy= enum in a later release
|
||||
transport_security=NO_DNS_REBIND,
|
||||
)
|
||||
|
||||
|
||||
def run_server_from_args(build_server: ServerFactory) -> None:
|
||||
"""Entry point for ``if __name__ == "__main__"`` in every ``server*.py``.
|
||||
|
||||
Bare argv serves over stdio; ``--http --port N [--path /mcp]`` serves over
|
||||
uvicorn on 127.0.0.1:N.
|
||||
"""
|
||||
server = build_server()
|
||||
if "--http" in sys.argv:
|
||||
port = int(argv_after("--port", default="8000"))
|
||||
path = argv_after("--path", default="/mcp")
|
||||
anyio.run(_serve_http, server, port, path)
|
||||
else:
|
||||
anyio.run(_serve_stdio, server)
|
||||
|
||||
|
||||
async def _serve_stdio(server: AnyServer) -> None:
|
||||
if isinstance(server, MCPServer):
|
||||
await server.run_stdio_async() # becomes await serve_stdio(server)
|
||||
else:
|
||||
async with stdio_server() as (read, write): # becomes await serve_stdio(server)
|
||||
await server.run(read, write, server.create_initialization_options())
|
||||
|
||||
|
||||
async def _serve_http(server: AnyServer, port: int, path: str) -> None:
|
||||
app = asgi_from(server, path=path)
|
||||
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
|
||||
await uvicorn.Server(config).serve()
|
||||
|
||||
|
||||
def run_app_from_args(build_app: AppFactory) -> None:
|
||||
"""Entry point for ``if __name__ == "__main__"`` in app-exporting ``server*.py``.
|
||||
|
||||
App-exporting stories are HTTP-only; ``--port N`` serves the Starlette app over
|
||||
uvicorn on 127.0.0.1:N (uvicorn drives the app's own lifespan). No stdio leg.
|
||||
"""
|
||||
port = int(argv_after("--port", default="8000"))
|
||||
config = uvicorn.Config(build_app(), host="127.0.0.1", port=port, log_level="error")
|
||||
anyio.run(uvicorn.Server(config).serve)
|
||||
1
examples/stories/_shared/__init__.py
Normal file
1
examples/stories/_shared/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Shared scaffolding the auth/hosting stories import (not teaching surface)."""
|
||||
159
examples/stories/_shared/auth.py
Normal file
159
examples/stories/_shared/auth.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"""Minimal in-process OAuth pieces for the auth stories.
|
||||
|
||||
A story-shaped subset; ``tests/interaction/auth`` keeps its own (richer) provider.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import httpx
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from mcp.server.auth.provider import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
AuthorizationParams,
|
||||
OAuthAuthorizationServerProvider,
|
||||
RefreshToken,
|
||||
construct_redirect_uri,
|
||||
)
|
||||
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions
|
||||
from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthToken
|
||||
|
||||
BASE_URL = "http://127.0.0.1:8000"
|
||||
MCP_URL = f"{BASE_URL}/mcp"
|
||||
REDIRECT_URI = f"{BASE_URL}/oauth/callback"
|
||||
|
||||
|
||||
class InMemoryTokenStorage:
|
||||
"""A ``TokenStorage`` that keeps tokens and DCR client info on instance attributes."""
|
||||
|
||||
tokens: OAuthToken | None = None
|
||||
client_info: OAuthClientInformationFull | None = None
|
||||
|
||||
async def get_tokens(self) -> OAuthToken | None:
|
||||
return self.tokens
|
||||
|
||||
async def set_tokens(self, tokens: OAuthToken) -> None:
|
||||
self.tokens = tokens
|
||||
|
||||
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
||||
return self.client_info
|
||||
|
||||
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
||||
self.client_info = client_info
|
||||
|
||||
|
||||
class HeadlessOAuth:
|
||||
"""Completes the authorize redirect in-process via the bound ``httpx`` client."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.authorize_url: str | None = None
|
||||
self._http: httpx.AsyncClient | None = None
|
||||
self._result = AuthorizationCodeResult(code="", state=None)
|
||||
|
||||
def bind(self, http_client: httpx.AsyncClient) -> None:
|
||||
self._http = http_client
|
||||
|
||||
async def redirect_handler(self, authorization_url: str) -> None:
|
||||
assert self._http is not None
|
||||
self.authorize_url = authorization_url
|
||||
# ``auth=None`` is load-bearing: re-entering the locked auth flow would deadlock.
|
||||
response = await self._http.get(authorization_url, follow_redirects=False, auth=None)
|
||||
assert response.status_code == 302, f"authorize returned {response.status_code}: {response.text}"
|
||||
params = parse_qs(urlsplit(response.headers["location"]).query)
|
||||
self._result = AuthorizationCodeResult(code=params.get("code", [""])[0], state=params.get("state", [None])[0])
|
||||
|
||||
async def callback_handler(self) -> AuthorizationCodeResult:
|
||||
return self._result
|
||||
|
||||
|
||||
class InMemoryAuthorizationServerProvider(
|
||||
OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]
|
||||
):
|
||||
"""Minimal demo AS: DCR + authorize + auth-code exchange held in instance dicts.
|
||||
|
||||
``authorize`` auto-consents only when ``OAUTH_DEMO_AUTO_CONSENT=1``; otherwise it redirects
|
||||
with ``error=interaction_required`` so a manual run shows where a real browser would open.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.clients: dict[str, OAuthClientInformationFull] = {}
|
||||
self.codes: dict[str, AuthorizationCode] = {}
|
||||
self.access_tokens: dict[str, AccessToken] = {}
|
||||
|
||||
def mint_access_token(self, *, client_id: str, scopes: list[str], resource: str | None = None) -> str:
|
||||
access = f"access_{secrets.token_hex(16)}"
|
||||
self.access_tokens[access] = AccessToken(
|
||||
token=access, client_id=client_id, scopes=scopes, expires_at=int(time.time()) + 3600, resource=resource
|
||||
)
|
||||
return access
|
||||
|
||||
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
|
||||
return self.clients.get(client_id)
|
||||
|
||||
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
|
||||
assert client_info.client_id is not None
|
||||
self.clients[client_info.client_id] = client_info
|
||||
|
||||
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
|
||||
target = str(params.redirect_uri)
|
||||
if os.environ.get("OAUTH_DEMO_AUTO_CONSENT") != "1":
|
||||
return construct_redirect_uri(target, error="interaction_required", state=params.state)
|
||||
assert client.client_id is not None
|
||||
code = AuthorizationCode(
|
||||
code=f"code_{secrets.token_hex(16)}",
|
||||
client_id=client.client_id,
|
||||
scopes=params.scopes or ["mcp"],
|
||||
expires_at=time.time() + 300,
|
||||
code_challenge=params.code_challenge,
|
||||
redirect_uri=params.redirect_uri,
|
||||
redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
|
||||
resource=params.resource,
|
||||
)
|
||||
self.codes[code.code] = code
|
||||
return construct_redirect_uri(target, code=code.code, state=params.state)
|
||||
|
||||
async def load_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: str
|
||||
) -> AuthorizationCode | None:
|
||||
return self.codes.get(authorization_code)
|
||||
|
||||
async def exchange_authorization_code(
|
||||
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
|
||||
) -> OAuthToken:
|
||||
scopes = authorization_code.scopes
|
||||
access = self.mint_access_token(
|
||||
client_id=authorization_code.client_id, scopes=scopes, resource=authorization_code.resource
|
||||
)
|
||||
del self.codes[authorization_code.code]
|
||||
return OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=" ".join(scopes))
|
||||
|
||||
async def load_access_token(self, token: str) -> AccessToken | None:
|
||||
return self.access_tokens.get(token)
|
||||
|
||||
async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def exchange_refresh_token(
|
||||
self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]
|
||||
) -> OAuthToken:
|
||||
raise NotImplementedError
|
||||
|
||||
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def auth_settings(*, required_scopes: list[str] | None = None) -> AuthSettings:
|
||||
"""``AuthSettings`` for the co-hosted demo AS+RS on the loopback origin, DCR enabled."""
|
||||
scopes = required_scopes or ["mcp"]
|
||||
return AuthSettings(
|
||||
issuer_url=AnyHttpUrl(BASE_URL),
|
||||
resource_server_url=AnyHttpUrl(MCP_URL),
|
||||
required_scopes=scopes,
|
||||
client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=scopes, default_scopes=scopes),
|
||||
)
|
||||
14
examples/stories/apps/README.md
Normal file
14
examples/stories/apps/README.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# apps
|
||||
|
||||
MCP Apps: a tool result carries a `_meta.ui` reference to a `ui://` resource
|
||||
that the host renders as an interactive surface. The story will register a
|
||||
`@ui` resource and return it from a tool.
|
||||
|
||||
**Status: not yet implemented** ([#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896)).
|
||||
The `extensions` capability map is not yet surfaced on `MCPServer`, so a server
|
||||
cannot advertise Apps support and a client cannot negotiate it.
|
||||
|
||||
## Spec
|
||||
|
||||
[MCP Apps — extensions](https://modelcontextprotocol.io/specification/draft/extensions/apps)
|
||||
· [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133)
|
||||
96
examples/stories/bearer_auth/README.md
Normal file
96
examples/stories/bearer_auth/README.md
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
# bearer-auth
|
||||
|
||||
Resource-server-only bearer auth. Pass a `TokenVerifier` + `AuthSettings`
|
||||
(issuer, resource URL, required scopes) when building the streamable-HTTP app
|
||||
and the SDK wires three things automatically: a bearer gate that answers 401 +
|
||||
`WWW-Authenticate: Bearer ... resource_metadata=...` (or 403 `insufficient_scope`),
|
||||
the RFC 9728 protected-resource-metadata document at
|
||||
`/.well-known/oauth-protected-resource/mcp`, and the verified `AccessToken`
|
||||
inside tool handlers via `get_access_token()`. The verifier here accepts one
|
||||
static token — replace it with JWT verification or RFC 7662 introspection. No
|
||||
authorization server; see `../oauth/` for the full grant flow.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
# HTTP — the client self-hosts the bearer-gated app, connects with the demo
|
||||
# bearer token, then tears it down. Self-hosting uses this story's fixed :8000
|
||||
# (the issuer/PRM metadata pin it), so :8000 must be free.
|
||||
uv run python -m stories.bearer_auth.client --http
|
||||
# same, against the lowlevel-API server variant
|
||||
uv run python -m stories.bearer_auth.client --http --server server_lowlevel
|
||||
|
||||
# against a server you run yourself (real uvicorn on :8000). The next section's
|
||||
# curl probes use it too and `kill` it when done. While it is up it owns :8000,
|
||||
# so the two self-host lines above refuse to run rather than test it by mistake.
|
||||
uv run python -m stories.bearer_auth.server --port 8000 &
|
||||
SERVER_PID=$!
|
||||
uv run python -m stories.bearer_auth.client --http http://127.0.0.1:8000/mcp
|
||||
```
|
||||
|
||||
`Client(url)` has no `auth=` passthrough, so a target built from a bare URL
|
||||
can't carry the token. Both runners close that gap the same way: `run_client`
|
||||
(above) and the pytest harness thread the module's `build_auth` export onto the
|
||||
`httpx.AsyncClient` underneath the transport and hand `main` a target that is
|
||||
already routed through it.
|
||||
|
||||
## Try it without the SDK client
|
||||
|
||||
```bash
|
||||
# no token → 401 + WWW-Authenticate pointing at the PRM document
|
||||
curl -i -X POST http://127.0.0.1:8000/mcp \
|
||||
-H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"ping"}'
|
||||
|
||||
# the RFC 9728 protected-resource-metadata document
|
||||
curl -s http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp | jq
|
||||
|
||||
# done with the server you started in "Run it"
|
||||
kill "$SERVER_PID"
|
||||
```
|
||||
|
||||
## What to look at
|
||||
|
||||
- `client.py` `main` — opens with `async with Client(target, mode=mode) as
|
||||
client:` and that is the whole program. The `target` it receives is a
|
||||
transport that already carries the bearer token; nothing in the body knows
|
||||
auth exists.
|
||||
- `client.py` `build_auth` / `StaticBearerAuth` — bearer auth client-side is
|
||||
five lines of `httpx.Auth`. `Client(url, auth=...)` is the ergonomic the SDK
|
||||
is missing; until it lands, the auth has to be threaded onto the
|
||||
`httpx.AsyncClient` underneath the transport, outside `main`.
|
||||
- `server.py` — `MCPServer(token_verifier=..., auth=AuthSettings(...))` is the
|
||||
whole recipe; `streamable_http_app()` reads those constructor kwargs and
|
||||
mounts the bearer gate + PRM route.
|
||||
- `server_lowlevel.py` — same gate, but `lowlevel.Server` takes
|
||||
`auth=` / `token_verifier=` at **`streamable_http_app(...)` time**, not in the
|
||||
constructor. `mcp.server.auth.*` imports are allowed in lowlevel files
|
||||
(helper-tier).
|
||||
- `whoami()` — `get_access_token()` returns the per-HTTP-request `AccessToken`.
|
||||
It is **not** on `Context` (unlike other SDKs' `ctx.authInfo`); a later
|
||||
release will namespace it as `ctx.transport.auth`.
|
||||
|
||||
## Caveats
|
||||
|
||||
- `transport_security=NO_DNS_REBIND` — DNS-rebinding protection is on by default
|
||||
for localhost binds; the harness disables it because the in-process httpx
|
||||
client sends no `Origin` header. Drop the kwarg for a real deployment.
|
||||
- `RESOURCE_URL` is hard-coded to port 8000 (the harness's in-process origin).
|
||||
If you change `--port`, edit `RESOURCE_URL` to match or the PRM document's
|
||||
`resource` field will be wrong.
|
||||
- Auth is HTTP-only; over stdio or the in-memory transport `get_access_token()`
|
||||
returns `None` and there is no gate.
|
||||
- The 401/403 status codes and `WWW-Authenticate` header are HTTP-level and
|
||||
`Client` cannot observe them; they are pinned by
|
||||
`tests/interaction/auth/test_bearer.py` and shown via `curl` above.
|
||||
|
||||
## Spec
|
||||
|
||||
[Authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization)
|
||||
· RFC 9728 (Protected Resource Metadata) · RFC 6750 (`WWW-Authenticate: Bearer`)
|
||||
|
||||
## See also
|
||||
|
||||
`oauth/` (full authorization-code grant with an in-process AS) ·
|
||||
`oauth_client_credentials/` (M2M `client_credentials` grant) ·
|
||||
`stateless_legacy/` (the un-gated hosting baseline).
|
||||
0
examples/stories/bearer_auth/__init__.py
Normal file
0
examples/stories/bearer_auth/__init__.py
Normal file
48
examples/stories/bearer_auth/client.py
Normal file
48
examples/stories/bearer_auth/client.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""Call the bearer-gated server through an already-authed (``build_auth``, HTTP-only) transport; assert ``whoami``."""
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
import httpx
|
||||
|
||||
from mcp.client import Client
|
||||
from stories._harness import Target, run_client
|
||||
|
||||
from .server import DEMO_TOKEN, REQUIRED_SCOPE
|
||||
|
||||
|
||||
class StaticBearerAuth(httpx.Auth):
|
||||
"""``httpx.Auth`` that attaches a fixed ``Authorization: Bearer <token>`` to every request."""
|
||||
|
||||
def __init__(self, token: str) -> None:
|
||||
self.token = token
|
||||
|
||||
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
|
||||
request.headers["Authorization"] = f"Bearer {self.token}"
|
||||
yield request
|
||||
|
||||
|
||||
def build_auth(_http: httpx.AsyncClient) -> httpx.Auth:
|
||||
"""The demo bearer token as an ``httpx.Auth``.
|
||||
|
||||
``Client(url, auth=...)`` doesn't exist yet, so the harness threads this onto the underlying
|
||||
``httpx.AsyncClient`` and the target ``main`` receives is already routed through it.
|
||||
"""
|
||||
return StaticBearerAuth(DEMO_TOKEN)
|
||||
|
||||
|
||||
async def main(target: Target, *, mode: str = "auto") -> None:
|
||||
async with Client(target, mode=mode) as client:
|
||||
listed = await client.list_tools()
|
||||
assert [t.name for t in listed.tools] == ["whoami"]
|
||||
|
||||
result = await client.call_tool("whoami", {})
|
||||
assert not result.is_error, result
|
||||
assert result.structured_content == {
|
||||
"subject": "demo-user",
|
||||
"client_id": "demo-client",
|
||||
"scopes": [REQUIRED_SCOPE],
|
||||
}, result.structured_content
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_client(main)
|
||||
56
examples/stories/bearer_auth/server.py
Normal file
56
examples/stories/bearer_auth/server.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""Resource-server-only bearer auth: ``TokenVerifier``/``AuthSettings`` → 401/PRM/principal. Exports ``build_app()``."""
|
||||
|
||||
import time
|
||||
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.applications import Starlette
|
||||
|
||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||
from mcp.server.auth.provider import AccessToken, TokenVerifier
|
||||
from mcp.server.auth.settings import AuthSettings
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from stories._hosting import NO_DNS_REBIND, run_app_from_args
|
||||
|
||||
ISSUER = "https://auth.example.com"
|
||||
RESOURCE_URL = "http://127.0.0.1:8000/mcp"
|
||||
REQUIRED_SCOPE = "mcp:read"
|
||||
DEMO_TOKEN = "demo-token"
|
||||
|
||||
|
||||
class StaticTokenVerifier(TokenVerifier):
|
||||
"""Accepts one hard-coded token. Replace with JWT verification or RFC 7662 introspection."""
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
if token != DEMO_TOKEN:
|
||||
return None
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id="demo-client",
|
||||
scopes=[REQUIRED_SCOPE],
|
||||
expires_at=int(time.time()) + 3600,
|
||||
subject="demo-user",
|
||||
)
|
||||
|
||||
|
||||
def build_app() -> Starlette:
|
||||
mcp = MCPServer(
|
||||
"bearer-auth-example",
|
||||
token_verifier=StaticTokenVerifier(),
|
||||
auth=AuthSettings(
|
||||
issuer_url=AnyHttpUrl(ISSUER),
|
||||
resource_server_url=AnyHttpUrl(RESOURCE_URL),
|
||||
required_scopes=[REQUIRED_SCOPE],
|
||||
),
|
||||
)
|
||||
|
||||
@mcp.tool(description="Return the authenticated principal.")
|
||||
def whoami() -> dict[str, str | list[str]]:
|
||||
token = get_access_token()
|
||||
assert token is not None # the bearer gate guarantees this on the HTTP path
|
||||
return {"subject": token.subject or "", "client_id": token.client_id, "scopes": token.scopes}
|
||||
|
||||
return mcp.streamable_http_app(transport_security=NO_DNS_REBIND)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_app_from_args(build_app)
|
||||
56
examples/stories/bearer_auth/server_lowlevel.py
Normal file
56
examples/stories/bearer_auth/server_lowlevel.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""Resource-server-only bearer auth (lowlevel API): same gate, hand-built ``CallToolResult``."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import mcp_types as types
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.applications import Starlette
|
||||
|
||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||
from mcp.server.auth.settings import AuthSettings
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.lowlevel import Server
|
||||
from stories._hosting import NO_DNS_REBIND, run_app_from_args
|
||||
|
||||
from .server import ISSUER, REQUIRED_SCOPE, RESOURCE_URL, StaticTokenVerifier
|
||||
|
||||
|
||||
def build_app() -> Starlette:
|
||||
async def list_tools(
|
||||
ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None
|
||||
) -> types.ListToolsResult:
|
||||
return types.ListToolsResult(
|
||||
tools=[
|
||||
types.Tool(
|
||||
name="whoami",
|
||||
description="Return the authenticated principal.",
|
||||
input_schema={"type": "object"},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
|
||||
assert params.name == "whoami"
|
||||
token = get_access_token()
|
||||
assert token is not None # the bearer gate guarantees this on the HTTP path
|
||||
payload = {"subject": token.subject or "", "client_id": token.client_id, "scopes": token.scopes}
|
||||
return types.CallToolResult(
|
||||
content=[types.TextContent(text=f"{token.subject} via {token.client_id}")],
|
||||
structured_content=payload,
|
||||
)
|
||||
|
||||
server = Server("bearer-auth-example", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
# lowlevel.Server takes auth at app-build time, not in the constructor (cf. MCPServer).
|
||||
return server.streamable_http_app(
|
||||
auth=AuthSettings(
|
||||
issuer_url=AnyHttpUrl(ISSUER),
|
||||
resource_server_url=AnyHttpUrl(RESOURCE_URL),
|
||||
required_scopes=[REQUIRED_SCOPE],
|
||||
),
|
||||
token_verifier=StaticTokenVerifier(),
|
||||
transport_security=NO_DNS_REBIND,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_app_from_args(build_app)
|
||||
20
examples/stories/caching/README.md
Normal file
20
examples/stories/caching/README.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# caching
|
||||
|
||||
A server stamps `CacheableResult` hints (`ttl_ms`, `cache_scope`) onto list and
|
||||
read responses; a client honours them to skip redundant round-trips. The story
|
||||
will show per-result overrides on `@mcp.resource()` / `@mcp.tool()` and the
|
||||
client-side cache hit/miss path.
|
||||
|
||||
**Status: not yet implemented.** Server-side stamping landed (defaults
|
||||
`ttl_ms=0`, `cache_scope="private"`), but the per-result override hook and the
|
||||
client honouring path are not implemented yet. An example today could only show
|
||||
the defaults being emitted, not acted on.
|
||||
|
||||
## Spec
|
||||
|
||||
[Caching — basic utilities](https://modelcontextprotocol.io/specification/draft/basic/utilities/caching)
|
||||
|
||||
## Working example elsewhere
|
||||
|
||||
The TypeScript SDK ships a runnable `caching` story:
|
||||
[typescript-sdk/examples/caching](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/caching).
|
||||
53
examples/stories/custom_methods/README.md
Normal file
53
examples/stories/custom_methods/README.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# custom-methods
|
||||
|
||||
Register and call a vendor-prefixed JSON-RPC method that is not part of the
|
||||
MCP spec. The server uses the low-level `Server.add_request_handler` (there is
|
||||
no `MCPServer` surface for this, so `server.py` is lowlevel-native and there is
|
||||
no `server_lowlevel.py` sibling); the client drops to `client.session` to send
|
||||
it.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
# stdio (default — the client spawns the server as a subprocess)
|
||||
uv run python -m stories.custom_methods.client
|
||||
|
||||
# HTTP — the client self-hosts the server on a free port, runs, then tears it down
|
||||
uv run python -m stories.custom_methods.client --http
|
||||
```
|
||||
|
||||
## What to look at
|
||||
|
||||
- `client.py` `main` — the body opens with `Client(target, mode=mode)`. The
|
||||
vendor request rides whichever protocol era `mode` selects; nothing else in
|
||||
the story changes between eras.
|
||||
- `server.py` `SearchParams` — subclasses `types.RequestParams` so `_meta`
|
||||
(and on a 2026-07-28 connection, the reserved `io.modelcontextprotocol/*`
|
||||
envelope keys) parse uniformly without extra code.
|
||||
- `server.py` `add_request_handler("acme/search", SearchParams, search)` — the
|
||||
method string is the wire `method`; use a vendor prefix so it can never
|
||||
collide with a future spec method.
|
||||
- `client.py` `client.session.send_request(...)` — `Client` only exposes spec
|
||||
verbs, so vendor methods go through the underlying `ClientSession`. The
|
||||
`cast("types.ClientRequest", ...)` is needed because `send_request`'s
|
||||
`request` parameter is currently typed as the closed spec union; widening it
|
||||
(or adding `Client.send_request`) is tracked for beta.
|
||||
|
||||
## Caveats
|
||||
|
||||
- The TypeScript SDK's equivalent example also shows a custom server→client
|
||||
**notification** (`acme/searchProgress`). The Python client currently drops
|
||||
any notification whose method is not in the spec registry
|
||||
(`ClientSession._on_notify` → `KeyError` → silent drop), and there is no
|
||||
`set_notification_handler` analogue. That half is omitted here.
|
||||
|
||||
## Spec
|
||||
|
||||
[Requests — basic protocol](https://modelcontextprotocol.io/specification/2025-11-25/basic#requests)
|
||||
(JSON-RPC request shape; vendor method names live outside the spec's reserved
|
||||
set).
|
||||
|
||||
## See also
|
||||
|
||||
`serve_one/` (the per-exchange driver that runs registered handlers),
|
||||
`middleware/` (wrapping every registered handler, including vendor methods).
|
||||
0
examples/stories/custom_methods/__init__.py
Normal file
0
examples/stories/custom_methods/__init__.py
Normal file
39
examples/stories/custom_methods/client.py
Normal file
39
examples/stories/custom_methods/client.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"""Send a vendor-prefixed request via the `client.session` escape hatch."""
|
||||
|
||||
from typing import Literal, cast
|
||||
|
||||
import mcp_types as types
|
||||
|
||||
from mcp.client import Client
|
||||
from stories._harness import Target, run_client
|
||||
|
||||
|
||||
class SearchParams(types.RequestParams):
|
||||
query: str
|
||||
limit: int = 10
|
||||
|
||||
|
||||
class SearchRequest(types.Request[SearchParams, Literal["acme/search"]]):
|
||||
method: Literal["acme/search"] = "acme/search"
|
||||
params: SearchParams
|
||||
|
||||
|
||||
class SearchResult(types.Result):
|
||||
items: list[str]
|
||||
|
||||
|
||||
async def main(target: Target, *, mode: str = "auto") -> None:
|
||||
async with Client(target, mode=mode) as client:
|
||||
# `Client` only exposes spec-defined verbs, so vendor methods have to drop one
|
||||
# layer to `client.session` today — there is no `Client`-level API for them
|
||||
# yet, and whether `.session` stays public is undecided. `send_request` is
|
||||
# typed against the closed `ClientRequest` union, hence the cast; at runtime
|
||||
# the body only calls `.model_dump()` and the unknown method skips the
|
||||
# per-spec result-validation registry.
|
||||
request = SearchRequest(params=SearchParams(query="mcp", limit=3))
|
||||
result = await client.session.send_request(cast("types.ClientRequest", request), SearchResult)
|
||||
assert result.items == ["mcp-0", "mcp-1", "mcp-2"], result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_client(main)
|
||||
39
examples/stories/custom_methods/server.py
Normal file
39
examples/stories/custom_methods/server.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
"""Register a vendor-prefixed JSON-RPC method on the low-level Server.
|
||||
|
||||
`MCPServer` has no public surface for arbitrary method registration, so this
|
||||
story's `server.py` is lowlevel-native (no `server_lowlevel.py` sibling).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import mcp_types as types
|
||||
|
||||
from mcp.server.context import ServerRequestContext
|
||||
from mcp.server.lowlevel import Server
|
||||
from stories._hosting import run_server_from_args
|
||||
|
||||
|
||||
class SearchParams(types.RequestParams):
|
||||
"""Subclass `RequestParams` so `_meta` (and the 2026 envelope keys) parse uniformly."""
|
||||
|
||||
query: str
|
||||
limit: int = 10
|
||||
|
||||
|
||||
class SearchResult(types.Result):
|
||||
items: list[str]
|
||||
|
||||
|
||||
def build_server() -> Server[Any]:
|
||||
server = Server("custom-methods-example")
|
||||
|
||||
async def search(ctx: ServerRequestContext[Any], params: SearchParams) -> SearchResult:
|
||||
items = [f"{params.query}-{i}" for i in range(params.limit)]
|
||||
return SearchResult(items=items)
|
||||
|
||||
server.add_request_handler("acme/search", SearchParams, search)
|
||||
return server
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_server_from_args(build_server)
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue