diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index cdf203733..21a70f46e 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -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 diff --git a/examples/README.md b/examples/README.md index 5ed4dd55f..0a283e135 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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). diff --git a/examples/pyproject.toml b/examples/pyproject.toml new file mode 100644 index 000000000..7b0109591 --- /dev/null +++ b/examples/pyproject.toml @@ -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"] diff --git a/examples/stories/README.md b/examples/stories/README.md new file mode 100644 index 000000000..93f04a014 --- /dev/null +++ b/examples/stories/README.md @@ -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 +` 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 ` swaps in a sibling server module on stdio and on the +self-hosted `--http` run; with `--http ` 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 `). 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/`. diff --git a/examples/stories/__init__.py b/examples/stories/__init__.py new file mode 100644 index 000000000..6f4d6055a --- /dev/null +++ b/examples/stories/__init__.py @@ -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. +""" diff --git a/examples/stories/_harness.py b/examples/stories/_harness.py new file mode 100644 index 000000000..6af90a85c --- /dev/null +++ b/examples/stories/_harness.py @@ -0,0 +1,201 @@ +"""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 ``.py`` over stdio per call, ```` 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 " + ) + 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 `` 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__", "")) + 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"] == "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) diff --git a/examples/stories/_hosting.py b/examples/stories/_hosting.py new file mode 100644 index 000000000..041778677 --- /dev/null +++ b/examples/stories/_hosting.py @@ -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) diff --git a/examples/stories/_shared/__init__.py b/examples/stories/_shared/__init__.py new file mode 100644 index 000000000..bf9e14872 --- /dev/null +++ b/examples/stories/_shared/__init__.py @@ -0,0 +1 @@ +"""Shared scaffolding the auth/hosting stories import (not teaching surface).""" diff --git a/examples/stories/_shared/auth.py b/examples/stories/_shared/auth.py new file mode 100644 index 000000000..63079ad6f --- /dev/null +++ b/examples/stories/_shared/auth.py @@ -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), + ) diff --git a/examples/stories/apps/README.md b/examples/stories/apps/README.md new file mode 100644 index 000000000..b802525fa --- /dev/null +++ b/examples/stories/apps/README.md @@ -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) diff --git a/examples/stories/bearer_auth/README.md b/examples/stories/bearer_auth/README.md new file mode 100644 index 000000000..d1d556f94 --- /dev/null +++ b/examples/stories/bearer_auth/README.md @@ -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). diff --git a/examples/stories/bearer_auth/__init__.py b/examples/stories/bearer_auth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/bearer_auth/client.py b/examples/stories/bearer_auth/client.py new file mode 100644 index 000000000..5c419a071 --- /dev/null +++ b/examples/stories/bearer_auth/client.py @@ -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 `` 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) diff --git a/examples/stories/bearer_auth/server.py b/examples/stories/bearer_auth/server.py new file mode 100644 index 000000000..45c9872c3 --- /dev/null +++ b/examples/stories/bearer_auth/server.py @@ -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) diff --git a/examples/stories/bearer_auth/server_lowlevel.py b/examples/stories/bearer_auth/server_lowlevel.py new file mode 100644 index 000000000..f5abfc08c --- /dev/null +++ b/examples/stories/bearer_auth/server_lowlevel.py @@ -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) diff --git a/examples/stories/caching/README.md b/examples/stories/caching/README.md new file mode 100644 index 000000000..be0bb4820 --- /dev/null +++ b/examples/stories/caching/README.md @@ -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). diff --git a/examples/stories/custom_methods/README.md b/examples/stories/custom_methods/README.md new file mode 100644 index 000000000..924ea0298 --- /dev/null +++ b/examples/stories/custom_methods/README.md @@ -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). diff --git a/examples/stories/custom_methods/__init__.py b/examples/stories/custom_methods/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/custom_methods/client.py b/examples/stories/custom_methods/client.py new file mode 100644 index 000000000..4003885fa --- /dev/null +++ b/examples/stories/custom_methods/client.py @@ -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) diff --git a/examples/stories/custom_methods/server.py b/examples/stories/custom_methods/server.py new file mode 100644 index 000000000..260aff787 --- /dev/null +++ b/examples/stories/custom_methods/server.py @@ -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) diff --git a/examples/stories/dual_era/README.md b/examples/stories/dual_era/README.md new file mode 100644 index 000000000..f14f16402 --- /dev/null +++ b/examples/stories/dual_era/README.md @@ -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). diff --git a/examples/stories/dual_era/__init__.py b/examples/stories/dual_era/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/dual_era/client.py b/examples/stories/dual_era/client.py new file mode 100644 index 000000000..ba9acf5d9 --- /dev/null +++ b/examples/stories/dual_era/client.py @@ -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) diff --git a/examples/stories/dual_era/server.py b/examples/stories/dual_era/server.py new file mode 100644 index 000000000..3f70ee63c --- /dev/null +++ b/examples/stories/dual_era/server.py @@ -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) diff --git a/examples/stories/dual_era/server_lowlevel.py b/examples/stories/dual_era/server_lowlevel.py new file mode 100644 index 000000000..b209135e6 --- /dev/null +++ b/examples/stories/dual_era/server_lowlevel.py @@ -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) diff --git a/examples/stories/error_handling/README.md b/examples/stories/error_handling/README.md new file mode 100644 index 000000000..f55083738 --- /dev/null +++ b/examples/stories/error_handling/README.md @@ -0,0 +1,54 @@ +# 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. +- `client.py` reads `e.error.data` rather than `e.data`; the convenience + property carries a `no cover` pragma that `strict-no-cover` would trip. + +## 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). diff --git a/examples/stories/error_handling/__init__.py b/examples/stories/error_handling/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/error_handling/client.py b/examples/stories/error_handling/client.py new file mode 100644 index 000000000..bd63c4fb1 --- /dev/null +++ b/examples/stories/error_handling/client.py @@ -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.error.data == {"reason": "demo"} + else: + raise AssertionError("expected MCPError for a protocol-level rejection") + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/error_handling/server.py b/examples/stories/error_handling/server.py new file mode 100644 index 000000000..e4f355443 --- /dev/null +++ b/examples/stories/error_handling/server.py @@ -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) diff --git a/examples/stories/error_handling/server_lowlevel.py b/examples/stories/error_handling/server_lowlevel.py new file mode 100644 index 000000000..9bb9aef86 --- /dev/null +++ b/examples/stories/error_handling/server_lowlevel.py @@ -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) diff --git a/examples/stories/events/README.md b/examples/stories/events/README.md new file mode 100644 index 000000000..0fe7dc8e9 --- /dev/null +++ b/examples/stories/events/README.md @@ -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). diff --git a/examples/stories/json_response/README.md b/examples/stories/json_response/README.md new file mode 100644 index 000000000..36ad7300f --- /dev/null +++ b/examples/stories/json_response/README.md @@ -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/2026-07-28/basic/transports/streamable-http) +· [SEP-2243 standard headers](https://modelcontextprotocol.io/specification/2026-07-28/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). diff --git a/examples/stories/json_response/__init__.py b/examples/stories/json_response/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/json_response/client.py b/examples/stories/json_response/client.py new file mode 100644 index 000000000..08af5ef91 --- /dev/null +++ b/examples/stories/json_response/client.py @@ -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) diff --git a/examples/stories/json_response/server.py b/examples/stories/json_response/server.py new file mode 100644 index 000000000..c09aca78f --- /dev/null +++ b/examples/stories/json_response/server.py @@ -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) diff --git a/examples/stories/json_response/server_lowlevel.py b/examples/stories/json_response/server_lowlevel.py new file mode 100644 index 000000000..bcb14eb9a --- /dev/null +++ b/examples/stories/json_response/server_lowlevel.py @@ -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) diff --git a/examples/stories/legacy_elicitation/README.md b/examples/stories/legacy_elicitation/README.md new file mode 100644 index 000000000..62f4379c3 --- /dev/null +++ b/examples/stories/legacy_elicitation/README.md @@ -0,0 +1,72 @@ +# 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/` +(planned — the 2026-era carrier), `error_handling/` +(`UrlElicitationRequiredError`). diff --git a/examples/stories/legacy_elicitation/__init__.py b/examples/stories/legacy_elicitation/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/legacy_elicitation/client.py b/examples/stories/legacy_elicitation/client.py new file mode 100644 index 000000000..52bb95e51 --- /dev/null +++ b/examples/stories/legacy_elicitation/client.py @@ -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) diff --git a/examples/stories/legacy_elicitation/server.py b/examples/stories/legacy_elicitation/server.py new file mode 100644 index 000000000..d2a6e95a5 --- /dev/null +++ b/examples/stories/legacy_elicitation/server.py @@ -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) diff --git a/examples/stories/legacy_elicitation/server_lowlevel.py b/examples/stories/legacy_elicitation/server_lowlevel.py new file mode 100644 index 000000000..08c7c3a76 --- /dev/null +++ b/examples/stories/legacy_elicitation/server_lowlevel.py @@ -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) diff --git a/examples/stories/legacy_routing/README.md b/examples/stories/legacy_routing/README.md new file mode 100644 index 000000000..2617f1470 --- /dev/null +++ b/examples/stories/legacy_routing/README.md @@ -0,0 +1,107 @@ +# 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` (header mismatch, unsupported version) is a + malformed-modern request to **reject**, not route to legacy. +- `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). diff --git a/examples/stories/legacy_routing/__init__.py b/examples/stories/legacy_routing/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/legacy_routing/client.py b/examples/stories/legacy_routing/client.py new file mode 100644 index 000000000..d31d7b18f --- /dev/null +++ b/examples/stories/legacy_routing/client.py @@ -0,0 +1,59 @@ +"""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_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 body carrying the 2026 _meta + # envelope classifies as modern; a bare initialize body classifies as legacy; + # a 2026 envelope whose header 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: {}, + } + }, + } + assert classify_era(modern_body, headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}) == "modern" + + legacy_body: dict[str, Any] = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}} + assert classify_era(legacy_body, headers={}) == "legacy" + + mismatched = classify_era(modern_body, headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_HANDSHAKE_VERSION}) + assert isinstance(mismatched, InboundLadderRejection), mismatched + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/legacy_routing/server.py b/examples/stories/legacy_routing/server.py new file mode 100644 index 000000000..79cc2afa6 --- /dev/null +++ b/examples/stories/legacy_routing/server.py @@ -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) diff --git a/examples/stories/legacy_routing/server_lowlevel.py b/examples/stories/legacy_routing/server_lowlevel.py new file mode 100644 index 000000000..d2f763c8e --- /dev/null +++ b/examples/stories/legacy_routing/server_lowlevel.py @@ -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) diff --git a/examples/stories/lifespan/README.md b/examples/stories/lifespan/README.md new file mode 100644 index 000000000..f2cb6c9d3 --- /dev/null +++ b/examples/stories/lifespan/README.md @@ -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). diff --git a/examples/stories/lifespan/__init__.py b/examples/stories/lifespan/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/lifespan/client.py b/examples/stories/lifespan/client.py new file mode 100644 index 000000000..51633177f --- /dev/null +++ b/examples/stories/lifespan/client.py @@ -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) diff --git a/examples/stories/lifespan/server.py b/examples/stories/lifespan/server.py new file mode 100644 index 000000000..a66e2154a --- /dev/null +++ b/examples/stories/lifespan/server.py @@ -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) diff --git a/examples/stories/lifespan/server_lowlevel.py b/examples/stories/lifespan/server_lowlevel.py new file mode 100644 index 000000000..09945c12c --- /dev/null +++ b/examples/stories/lifespan/server_lowlevel.py @@ -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) diff --git a/examples/stories/manifest.toml b/examples/stories/manifest.toml new file mode 100644 index 000000000..7a1f079e8 --- /dev/null +++ b/examples/stories/manifest.toml @@ -0,0 +1,148 @@ +# 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 = [] # [":", ...] -> 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] +# progress + log notifications dropped on the modern streamable-HTTP path pending SSE wiring +xfail = ["http-asgi:modern"] + +[story.legacy_elicitation] +era = "legacy" +status = "legacy" + +[story.sampling] +era = "legacy" +status = "deprecated" + +[story.stickynotes] + +[story.custom_methods] +lowlevel = false + +[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 + +# ───────────────────────────── 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" +mrtr = "#2898 — InputRequiredResult runtime" +subscriptions = "#2901 — Client.listen / ServerEventBus" +tasks = "extensions capability map + tasks runtime" +apps = "#2896 — extensions capability map" +skills = "#2896 — SEP-2640" +events = "#2901 + #2896" diff --git a/examples/stories/middleware/README.md b/examples/stories/middleware/README.md new file mode 100644 index 000000000..599f890f8 --- /dev/null +++ b/examples/stories/middleware/README.md @@ -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). diff --git a/examples/stories/middleware/__init__.py b/examples/stories/middleware/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/middleware/client.py b/examples/stories/middleware/client.py new file mode 100644 index 000000000..60ebbbc30 --- /dev/null +++ b/examples/stories/middleware/client.py @@ -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) diff --git a/examples/stories/middleware/server.py b/examples/stories/middleware/server.py new file mode 100644 index 000000000..076120dcc --- /dev/null +++ b/examples/stories/middleware/server.py @@ -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) diff --git a/examples/stories/mrtr/README.md b/examples/stories/mrtr/README.md new file mode 100644 index 000000000..6058e3a84 --- /dev/null +++ b/examples/stories/mrtr/README.md @@ -0,0 +1,30 @@ +# mrtr + +Multi-round tool results: a 2026-era tool call returns +`resultType: "input_required"` with a `requestState` HMAC instead of pushing an +`elicitation/create` request. The client fulfils the input and resubmits, and +the server resumes from the carried state. The story will show both the +auto-fulfil helper and a manual resubmit loop. + +**Status: not yet implemented** ([#2898](https://github.com/modelcontextprotocol/python-sdk/issues/2898)). +The lowlevel registration surface is in this base — +[#2967](https://github.com/modelcontextprotocol/python-sdk/pull/2967) +(`ae13ede`) widened the tool/prompt/resource handler return types to include +`InputRequiredResult`. The runnable story is deliberately a follow-up PR to +keep this one reviewable. + +## Spec + +[Multi-round tool results — server features](https://modelcontextprotocol.io/specification/draft/server/tools#multi-round-results) + +## Working example elsewhere + +The TypeScript SDK ships a runnable `mrtr` story: +[typescript-sdk/examples/mrtr](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/mrtr). + +## See also + +`legacy_elicitation/` and `sampling/` — the handshake-era push equivalents that +this mechanism replaces on the 2026 protocol. The TypeScript SDK ships a single +dual-era `elicitation/` story covering both eras in one place; we re-merge +`legacy_elicitation/` back into `elicitation/` once MRTR lands. diff --git a/examples/stories/oauth/README.md b/examples/stories/oauth/README.md new file mode 100644 index 000000000..a773a7851 --- /dev/null +++ b/examples/stories/oauth/README.md @@ -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). diff --git a/examples/stories/oauth/__init__.py b/examples/stories/oauth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/oauth/client.py b/examples/stories/oauth/client.py new file mode 100644 index 000000000..c55307f63 --- /dev/null +++ b/examples/stories/oauth/client.py @@ -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) diff --git a/examples/stories/oauth/server.py b/examples/stories/oauth/server.py new file mode 100644 index 000000000..6d4c706b0 --- /dev/null +++ b/examples/stories/oauth/server.py @@ -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) diff --git a/examples/stories/oauth/server_lowlevel.py b/examples/stories/oauth/server_lowlevel.py new file mode 100644 index 000000000..0bc7799c1 --- /dev/null +++ b/examples/stories/oauth/server_lowlevel.py @@ -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) diff --git a/examples/stories/oauth_client_credentials/README.md b/examples/stories/oauth_client_credentials/README.md new file mode 100644 index 000000000..8cd5a5b82 --- /dev/null +++ b/examples/stories/oauth_client_credentials/README.md @@ -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). diff --git a/examples/stories/oauth_client_credentials/__init__.py b/examples/stories/oauth_client_credentials/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/oauth_client_credentials/client.py b/examples/stories/oauth_client_credentials/client.py new file mode 100644 index 000000000..318523ee7 --- /dev/null +++ b/examples/stories/oauth_client_credentials/client.py @@ -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) diff --git a/examples/stories/oauth_client_credentials/server.py b/examples/stories/oauth_client_credentials/server.py new file mode 100644 index 000000000..7e3d910e8 --- /dev/null +++ b/examples/stories/oauth_client_credentials/server.py @@ -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) diff --git a/examples/stories/oauth_client_credentials/server_lowlevel.py b/examples/stories/oauth_client_credentials/server_lowlevel.py new file mode 100644 index 000000000..ba2003ded --- /dev/null +++ b/examples/stories/oauth_client_credentials/server_lowlevel.py @@ -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) diff --git a/examples/stories/pagination/README.md b/examples/stories/pagination/README.md new file mode 100644 index 000000000..f7113d4cc --- /dev/null +++ b/examples/stories/pagination/README.md @@ -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`. diff --git a/examples/stories/pagination/__init__.py b/examples/stories/pagination/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/pagination/client.py b/examples/stories/pagination/client.py new file mode 100644 index 000000000..a952a3208 --- /dev/null +++ b/examples/stories/pagination/client.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) diff --git a/examples/stories/pagination/server.py b/examples/stories/pagination/server.py new file mode 100644 index 000000000..81a4f04fc --- /dev/null +++ b/examples/stories/pagination/server.py @@ -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) diff --git a/examples/stories/pagination/server_lowlevel.py b/examples/stories/pagination/server_lowlevel.py new file mode 100644 index 000000000..55958a962 --- /dev/null +++ b/examples/stories/pagination/server_lowlevel.py @@ -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) diff --git a/examples/stories/parallel_calls/README.md b/examples/stories/parallel_calls/README.md new file mode 100644 index 000000000..678a3642e --- /dev/null +++ b/examples/stories/parallel_calls/README.md @@ -0,0 +1,58 @@ +# 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 — the SDK +demultiplexes by progress token, not by arrival order. + +## 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"]}` proves the SDK routes + in-flight progress per request. +- **`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). diff --git a/examples/stories/parallel_calls/__init__.py b/examples/stories/parallel_calls/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/parallel_calls/client.py b/examples/stories/parallel_calls/client.py new file mode 100644 index 000000000..945e5410a --- /dev/null +++ b/examples/stories/parallel_calls/client.py @@ -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) diff --git a/examples/stories/parallel_calls/server.py b/examples/stories/parallel_calls/server.py new file mode 100644 index 000000000..dc6d805e4 --- /dev/null +++ b/examples/stories/parallel_calls/server.py @@ -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) diff --git a/examples/stories/parallel_calls/server_lowlevel.py b/examples/stories/parallel_calls/server_lowlevel.py new file mode 100644 index 000000000..32807e170 --- /dev/null +++ b/examples/stories/parallel_calls/server_lowlevel.py @@ -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) diff --git a/examples/stories/prompts/README.md b/examples/stories/prompts/README.md new file mode 100644 index 000000000..3bce94b99 --- /dev/null +++ b/examples/stories/prompts/README.md @@ -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). diff --git a/examples/stories/prompts/__init__.py b/examples/stories/prompts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/prompts/client.py b/examples/stories/prompts/client.py new file mode 100644 index 000000000..22aae4af4 --- /dev/null +++ b/examples/stories/prompts/client.py @@ -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) diff --git a/examples/stories/prompts/server.py b/examples/stories/prompts/server.py new file mode 100644 index 000000000..2ef3fc3d8 --- /dev/null +++ b/examples/stories/prompts/server.py @@ -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) diff --git a/examples/stories/prompts/server_lowlevel.py b/examples/stories/prompts/server_lowlevel.py new file mode 100644 index 000000000..2fb41de8b --- /dev/null +++ b/examples/stories/prompts/server_lowlevel.py @@ -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) diff --git a/examples/stories/reconnect/README.md b/examples/stories/reconnect/README.md new file mode 100644 index 000000000..78d281e7a --- /dev/null +++ b/examples/stories/reconnect/README.md @@ -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=` 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=` *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). diff --git a/examples/stories/reconnect/__init__.py b/examples/stories/reconnect/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/reconnect/client.py b/examples/stories/reconnect/client.py new file mode 100644 index 000000000..aab2312dc --- /dev/null +++ b/examples/stories/reconnect/client.py @@ -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) diff --git a/examples/stories/reconnect/server.py b/examples/stories/reconnect/server.py new file mode 100644 index 000000000..bda460a29 --- /dev/null +++ b/examples/stories/reconnect/server.py @@ -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) diff --git a/examples/stories/reconnect/server_lowlevel.py b/examples/stories/reconnect/server_lowlevel.py new file mode 100644 index 000000000..5c6a057d6 --- /dev/null +++ b/examples/stories/reconnect/server_lowlevel.py @@ -0,0 +1,48 @@ +"""A small modern server whose DiscoverResult a client persists for zero-RTT reconnect (lowlevel API).""" + +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 + +ADD = types.Tool( + name="add", + description="Add two integers.", + input_schema={ + "type": "object", + "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}}, + "required": ["a", "b"], + }, +) + + +def build_server() -> Server[Any]: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[ADD]) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.arguments is not None + if params.name == "add": + total = int(params.arguments["a"]) + int(params.arguments["b"]) + return types.CallToolResult( + content=[types.TextContent(text=str(total))], + structured_content={"result": total}, + ) + raise NotImplementedError + + return Server( + "reconnect-example", + version="1.0.0", + instructions="Call add(a, b) to sum two integers.", + on_list_tools=list_tools, + on_call_tool=call_tool, + ) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/resources/README.md b/examples/stories/resources/README.md new file mode 100644 index 000000000..10b210fe9 --- /dev/null +++ b/examples/stories/resources/README.md @@ -0,0 +1,50 @@ +# resources + +Expose data by URI: a static resource (`config://app`) and an RFC-6570 +template (`greeting://{name}`). One `@mcp.resource()` decorator handles both — +the SDK infers static-vs-template from whether the URI contains `{...}`. The +client lists resources, lists templates, then reads each. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.resources.client + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.resources.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.resources.client --http --server server_lowlevel +``` + +## What to look at + +- `client.py` `async with Client(target, mode=mode) as client:` — the one line + every client example exists to teach. `target` is anything `Client()` + accepts (an in-process server, a transport, or an HTTP URL) and `mode=` is + always explicit; the rest of the story is the body of that `async with`. +- `server.py` `app_config` vs `greeting` — a URI with no `{}` registers a + static resource (appears in `resources/list`); a URI with `{name}` registers + a template (appears only in `resources/templates/list`) and the placeholder + must match the function parameter name. +- `server_lowlevel.py` `read_resource` — without `MCPServer` you own the URI + dispatch yourself, including raising `MCPError(code=INVALID_PARAMS, ...)` for + unknown URIs (matches what `MCPServer` sends). +- `client.py` `isinstance(entry, TextResourceContents)` — `contents` is a list + of `TextResourceContents | BlobResourceContents`; narrow before reading + `.text`. + +## Not shown here + +Subscriptions. Per-URI `resources/subscribe` is a 2025-era RPC being replaced +by `subscriptions/listen` in 2026-07-28; neither is shown in this story. See +`stickynotes/` for `list_changed` notifications. + +## Spec + +[Resources — server features](https://modelcontextprotocol.io/specification/2025-11-25/server/resources) + +## See also + +`stickynotes/` (list-changed notifications), `pagination/` (cursor over a long +resource list). diff --git a/examples/stories/resources/__init__.py b/examples/stories/resources/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/resources/client.py b/examples/stories/resources/client.py new file mode 100644 index 000000000..29f88d529 --- /dev/null +++ b/examples/stories/resources/client.py @@ -0,0 +1,30 @@ +"""List resources and templates, then read both the static and templated URIs.""" + +from mcp_types import TextResourceContents + +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_resources() + assert [r.uri for r in listed.resources] == ["config://app"] + + templates = await client.list_resource_templates() + assert [t.uri_template for t in templates.resource_templates] == ["greeting://{name}"] + + config = await client.read_resource("config://app") + entry = config.contents[0] + assert isinstance(entry, TextResourceContents) + assert entry.text == '{"feature": true}' + assert entry.mime_type == "application/json" + + hello = await client.read_resource("greeting://world") + entry = hello.contents[0] + assert isinstance(entry, TextResourceContents) + assert entry.text == "Hello, world!" + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/resources/server.py b/examples/stories/resources/server.py new file mode 100644 index 000000000..0879455cb --- /dev/null +++ b/examples/stories/resources/server.py @@ -0,0 +1,24 @@ +"""Resources primitive: a static URI and an RFC-6570 template via @mcp.resource().""" + +from mcp.server.mcpserver import MCPServer +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer("resources-example") + + @mcp.resource("config://app", mime_type="application/json") + def app_config() -> str: + """Static application config.""" + return '{"feature": true}' + + @mcp.resource("greeting://{name}") + def greeting(name: str) -> str: + """A greeting for the named subject.""" + return f"Hello, {name}!" + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/resources/server_lowlevel.py b/examples/stories/resources/server_lowlevel.py new file mode 100644 index 000000000..2161fecc9 --- /dev/null +++ b/examples/stories/resources/server_lowlevel.py @@ -0,0 +1,65 @@ +"""Resources primitive (lowlevel API): hand-built list/templates/read handlers.""" + +from typing import Any + +import mcp_types as types +from mcp_types.jsonrpc import INVALID_PARAMS + +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 + + +def build_server() -> Server[Any]: + async def list_resources( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListResourcesResult: + return types.ListResourcesResult( + resources=[ + types.Resource( + uri="config://app", + name="app_config", + description="Static application config.", + mime_type="application/json", + ) + ] + ) + + async def list_resource_templates( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListResourceTemplatesResult: + return types.ListResourceTemplatesResult( + resource_templates=[ + types.ResourceTemplate( + uri_template="greeting://{name}", + name="greeting", + description="A greeting for the named subject.", + mime_type="text/plain", + ) + ] + ) + + async def read_resource( + ctx: ServerRequestContext[Any], params: types.ReadResourceRequestParams + ) -> types.ReadResourceResult: + if params.uri == "config://app": + text, mime = '{"feature": true}', "application/json" + elif params.uri.startswith("greeting://"): + text, mime = f"Hello, {params.uri.removeprefix('greeting://')}!", "text/plain" + else: + raise MCPError(code=INVALID_PARAMS, message=f"Resource not found: {params.uri}") + return types.ReadResourceResult( + contents=[types.TextResourceContents(uri=params.uri, mime_type=mime, text=text)] + ) + + return Server( + "resources-example", + on_list_resources=list_resources, + on_list_resource_templates=list_resource_templates, + on_read_resource=read_resource, + ) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/roots/README.md b/examples/stories/roots/README.md new file mode 100644 index 000000000..9714616b6 --- /dev/null +++ b/examples/stories/roots/README.md @@ -0,0 +1,58 @@ +# roots + +> **Deprecated** in the 2026-07-28 protocol (SEP-2577); functional through the +> deprecation window. Migration: accept directory paths as ordinary tool +> parameters or resource URIs instead of relying on `roots/list`. +> TODO(maxisbey): revisit before beta. + +The client passes a `list_roots_callback` returning the filesystem locations it +is willing to expose; a server tool calls `ctx.session.list_roots()` mid-request +and the client's callback answers it. Passing the callback is what makes the +client advertise the `roots` capability — there is no separate flag. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.roots.client + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.roots.client --http --legacy +# same, against the lowlevel-API server variant +uv run python -m stories.roots.client --http --legacy --server server_lowlevel +``` + +## What to look at + +- `client.py` `main` — the + `Client(target, mode=mode, list_roots_callback=list_roots)` construction is + the whole client-side story: the callback is wired in as a constructor + argument, and that alone advertises the capability. +- `client.py` `list_roots` — the callback takes a `ClientRequestContext` and + returns `ListRootsResult`. +- `server.py` — `await ctx.session.list_roots()` inside the tool body: a + server→client request that blocks until the callback answers. +- `server_lowlevel.py` — the same call from `ServerRequestContext.session`, + with the `CallToolResult` built by hand. + +## Caveats + +- **Legacy-era only.** `roots/list` is a server-initiated request with no + 2026-07-28 wire carrier until the multi-round-trip runtime lands + ([#2898](https://github.com/modelcontextprotocol/python-sdk/issues/2898)), so + this story runs with `era = "legacy"` and the harness pins the handshake path. +- `ctx.session.list_roots()` is `@deprecated`; the + `# pyright: ignore[reportDeprecated]` is deliberate. There is no + non-deprecated server-side path until the multi-round-trip runtime lands. +- `ctx.session.*` is the interim 2-hop path; a later release will shorten it. +- `notifications/roots/list_changed` is intentionally not shown — removed in + 2026-07-28 (SEP-2575) and deprecated on the legacy path. + +## Spec + +[Roots — client features](https://modelcontextprotocol.io/specification/2025-11-25/client/roots) + +## See also + +`legacy_elicitation/`, `sampling/` — sibling server→client requests on the same +MRTR migration path. diff --git a/examples/stories/roots/__init__.py b/examples/stories/roots/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/roots/client.py b/examples/stories/roots/client.py new file mode 100644 index 000000000..9d8252991 --- /dev/null +++ b/examples/stories/roots/client.py @@ -0,0 +1,31 @@ +"""Expose two filesystem roots and verify the server's tool can read them back.""" + +from mcp_types import ListRootsResult, Root, TextContent +from pydantic import FileUrl + +from mcp.client import Client, ClientRequestContext +from stories._harness import Target, run_client + + +async def list_roots(context: ClientRequestContext) -> ListRootsResult: + return ListRootsResult( + roots=[ + Root(uri=FileUrl("file:///workspace/project"), name="project"), + Root(uri=FileUrl("file:///workspace/scratch")), + ] + ) + + +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode, list_roots_callback=list_roots) as client: + result = await client.call_tool("show_roots", {}) + + assert not result.is_error, result + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == ("file:///workspace/project (project)\nfile:///workspace/scratch (unnamed)"), ( + result.content[0].text + ) + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/roots/server.py b/examples/stories/roots/server.py new file mode 100644 index 000000000..79e95f16c --- /dev/null +++ b/examples/stories/roots/server.py @@ -0,0 +1,19 @@ +"""Roots primitive: a tool asks the client which filesystem roots it may use.""" + +from mcp.server.mcpserver import Context, MCPServer +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer("roots-example") + + @mcp.tool(description="Return the filesystem roots the client has exposed.") + async def show_roots(ctx: Context) -> str: + result = await ctx.session.list_roots() # pyright: ignore[reportDeprecated] + return "\n".join(f"{root.uri} ({root.name or 'unnamed'})" for root in result.roots) + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/roots/server_lowlevel.py b/examples/stories/roots/server_lowlevel.py new file mode 100644 index 000000000..2696c946c --- /dev/null +++ b/examples/stories/roots/server_lowlevel.py @@ -0,0 +1,36 @@ +"""Roots primitive (lowlevel API): the same server→client round-trip, hand-built.""" + +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 + + +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="show_roots", + description="Return the filesystem roots the client has exposed.", + input_schema={"type": "object"}, + ), + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "show_roots" + result = await ctx.session.list_roots() # pyright: ignore[reportDeprecated] + lines = [f"{root.uri} ({root.name or 'unnamed'})" for root in result.roots] + return types.CallToolResult(content=[types.TextContent(text="\n".join(lines))]) + + return Server("roots-example", on_list_tools=list_tools, on_call_tool=call_tool) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/sampling/README.md b/examples/stories/sampling/README.md new file mode 100644 index 000000000..73d021b09 --- /dev/null +++ b/examples/stories/sampling/README.md @@ -0,0 +1,62 @@ +# sampling + +> **Deprecated** in the 2026-07-28 protocol (SEP-2577); functional through the +> deprecation window. Migration: call your LLM provider directly from the +> server instead of requesting completions through the client. +> TODO(maxisbey): revisit before beta. + +A tool that asks the **client's** LLM for a completion mid-call — the inverted +MCP direction. The server holds no model API key; it awaits +`ctx.session.create_message(...)` and the client's `sampling_callback` answers. +Registering the callback is what makes the client advertise the `sampling` +capability — there is no separate flag. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.sampling.client + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.sampling.client --http --legacy +# same, against the lowlevel-API server variant +uv run python -m stories.sampling.client --http --legacy --server server_lowlevel +``` + +## What to look at + +- `client.py` `main` — `async with Client(target, mode=mode, + sampling_callback=on_sample) as client:`. The callback is an ordinary + constructor kwarg; registering it is the whole opt-in. +- `client.py` `on_sample` — takes `(ClientRequestContext, + CreateMessageRequestParams)` and returns a `CreateMessageResult`. A real + host calls its LLM provider here; the example returns a canned answer so the + round-trip is assertable. +- `server.py` — `await ctx.session.create_message(...)` inside the tool body: a + server→client request that blocks until the callback answers. There is no + `Context.sample()` sugar; reaching `ctx.session` is the public path. +- `server_lowlevel.py` — the same call from `ServerRequestContext.session`, + with the `CallToolResult` built by hand. + +## Caveats + +- **Legacy-era only.** `sampling/createMessage` is a server-initiated request + with no 2026-07-28 wire carrier until the multi-round-trip runtime lands + ([#2898](https://github.com/modelcontextprotocol/python-sdk/issues/2898)), so + this story runs with `era = "legacy"` and the harness pins the handshake path. +- `ctx.session.create_message()` is `@deprecated`; the + `# pyright: ignore[reportDeprecated]` is deliberate. There is no + non-deprecated server-side path until the multi-round-trip runtime lands. +- `ctx.session.*` is the interim 2-hop path; a later release will shorten it. +- `Client` has no `sampling_capabilities=` kwarg, so the `sampling.tools` + sub-capability (tools-in-sampling) is unreachable from the high-level client. + Drop to `ClientSession` if you need it. + +## Spec + +[Sampling — client features](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) + +## See also + +`legacy_elicitation/`, `roots/` — sibling server→client requests on the same +MRTR migration path. diff --git a/examples/stories/sampling/__init__.py b/examples/stories/sampling/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/sampling/client.py b/examples/stories/sampling/client.py new file mode 100644 index 000000000..0ca88db99 --- /dev/null +++ b/examples/stories/sampling/client.py @@ -0,0 +1,30 @@ +"""Supply a canned sampling_callback and assert its text round-trips through the tool.""" + +from mcp_types import CreateMessageRequestParams, CreateMessageResult, TextContent + +from mcp.client import Client, ClientRequestContext +from stories._harness import Target, run_client + + +async def on_sample(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult: + # A real host would call its LLM provider here; the example returns a deterministic + # canned answer so the round-trip is assertable. + return CreateMessageResult( + role="assistant", + content=TextContent(text="[canned summary]"), + model="stub-model", + stop_reason="endTurn", + ) + + +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode, sampling_callback=on_sample) as client: + result = await client.call_tool("summarize", {"text": "hello world"}) + + assert not result.is_error, result + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "[canned summary]", result.content[0].text + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/sampling/server.py b/examples/stories/sampling/server.py new file mode 100644 index 000000000..c97d8ab24 --- /dev/null +++ b/examples/stories/sampling/server.py @@ -0,0 +1,25 @@ +"""Sampling primitive: a tool asks the client's LLM for a completion mid-call.""" + +from mcp_types import SamplingMessage, TextContent + +from mcp.server.mcpserver import Context, MCPServer +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer("sampling-example") + + @mcp.tool(description="Summarize text by asking the host's LLM via sampling/createMessage.") + async def summarize(text: str, ctx: Context) -> str: + result = await ctx.session.create_message( # pyright: ignore[reportDeprecated] + messages=[SamplingMessage(role="user", content=TextContent(text=f"Summarize in one sentence:\n\n{text}"))], + max_tokens=200, + ) + assert isinstance(result.content, TextContent) + return result.content.text + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/sampling/server_lowlevel.py b/examples/stories/sampling/server_lowlevel.py new file mode 100644 index 000000000..5bc2a1943 --- /dev/null +++ b/examples/stories/sampling/server_lowlevel.py @@ -0,0 +1,45 @@ +"""Sampling primitive (lowlevel API): the same server→client round-trip, hand-built.""" + +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 + + +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="summarize", + description="Summarize text by asking the host's LLM via sampling/createMessage.", + input_schema={ + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], + }, + ), + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "summarize" + assert params.arguments is not None + prompt = f"Summarize in one sentence:\n\n{params.arguments['text']}" + result = await ctx.session.create_message( # pyright: ignore[reportDeprecated] + messages=[types.SamplingMessage(role="user", content=types.TextContent(text=prompt))], + max_tokens=200, + ) + assert isinstance(result.content, types.TextContent) + return types.CallToolResult(content=[types.TextContent(text=result.content.text)]) + + return Server("sampling-example", on_list_tools=list_tools, on_call_tool=call_tool) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/schema_validators/README.md b/examples/stories/schema_validators/README.md new file mode 100644 index 000000000..984f1595b --- /dev/null +++ b/examples/stories/schema_validators/README.md @@ -0,0 +1,52 @@ +# schema-validators + +Four ways to type a tool parameter so `MCPServer` derives the JSON-Schema +`inputSchema` and validates arguments before your handler runs: a pydantic +`BaseModel`, a `TypedDict`, a `@dataclass`, and a bare `dict[str, Any]`. The +client lists the tools, resolves each `who` schema, and round-trips a call. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.schema_validators.client + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.schema_validators.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.schema_validators.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); the entry point picks it, the story constructs it. +- `server.py` — `who.name` vs `who["name"]`: pydantic and dataclass parameters + arrive as **instances** (attribute access); TypedDict and `dict[str, Any]` + arrive as plain dicts. +- `client.py` — the listed `inputSchema` for the three typed variants nests a + `$defs`/`$ref` object with a `name` property; `greet_dict` publishes only + `{"type": "object", "additionalProperties": true}` — no field validation. +- `server_lowlevel.py` — the same schemas written by hand. There is no + reflection layer at this tier; you author JSON Schema and unpack + `params.arguments` yourself. + +## Caveats + +- Pydantic emits local `#/$defs/` references for nested models. The SDK does + not dereference network `$ref`s (SEP-2106 MUST NOT); only same-document refs + are resolved during validation. +- `PersonTD` is `total=True`, so its nested schema requires both `name` and + `title`; the `BaseModel` and `@dataclass` variants default `title="friend"`, + so only `name` is required there. Use `typing.NotRequired[...]` to mark + optional TypedDict fields. + +## Spec + +[Tools — input schema](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#input-schema) + +## See also + +`tools/` (output schema → `structuredContent`), `error_handling/` (what +happens when validation fails). diff --git a/examples/stories/schema_validators/__init__.py b/examples/stories/schema_validators/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/schema_validators/client.py b/examples/stories/schema_validators/client.py new file mode 100644 index 000000000..8f6794edd --- /dev/null +++ b/examples/stories/schema_validators/client.py @@ -0,0 +1,38 @@ +"""Asserts each variant publishes a `who` object schema and the call round-trips.""" + +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() + by_name = {t.name: t for t in listed.tools} + assert set(by_name) == {"greet_pydantic", "greet_typeddict", "greet_dataclass", "greet_dict"} + + for name in ("greet_pydantic", "greet_typeddict", "greet_dataclass"): + schema = by_name[name].input_schema + assert schema["required"] == ["who"], schema + # MCPServer emits a $defs/$ref pair; lowlevel inlines. Resolve either. + who = schema["properties"]["who"] + if "$ref" in who: + who = schema["$defs"][who["$ref"].rsplit("/", 1)[-1]] + assert "name" in who["properties"], who + + result = await client.call_tool(name, {"who": {"name": "Ada", "title": "colleague"}}) + assert not result.is_error, result + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Hello Ada, my colleague" + + # dict[str, Any] → free-form object schema, no nested `properties` required. + dict_who = by_name["greet_dict"].input_schema["properties"]["who"] + assert dict_who["type"] == "object" and "$ref" not in dict_who + result = await client.call_tool("greet_dict", {"who": {"name": "Ada"}}) + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Hello Ada, my friend" + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/schema_validators/server.py b/examples/stories/schema_validators/server.py new file mode 100644 index 000000000..8648e211d --- /dev/null +++ b/examples/stories/schema_validators/server.py @@ -0,0 +1,59 @@ +"""Four ways to type a tool parameter so MCPServer derives and enforces inputSchema.""" + +from dataclasses import dataclass +from typing import Any + +from pydantic import BaseModel + +# pydantic requires typing_extensions.TypedDict (not typing.TypedDict) on Python < 3.12 +# when a TypedDict is used as a field/parameter type. +from typing_extensions import TypedDict + +from mcp.server.mcpserver import MCPServer +from stories._hosting import run_server_from_args + + +class PersonModel(BaseModel): + name: str + title: str = "friend" + + +class PersonTD(TypedDict): + name: str + title: str + + +@dataclass +class PersonDC: + name: str + title: str = "friend" + + +def build_server() -> MCPServer: + mcp = MCPServer("schema-validators-example") + + @mcp.tool() + def greet_pydantic(who: PersonModel) -> str: + """`who` arrives as a validated PersonModel instance.""" + return f"Hello {who.name}, my {who.title}" + + @mcp.tool() + def greet_typeddict(who: PersonTD) -> str: + """`who` arrives as a plain dict; TypedDict drives the schema and editor hints.""" + return f"Hello {who['name']}, my {who['title']}" + + @mcp.tool() + def greet_dataclass(who: PersonDC) -> str: + """`who` arrives as a PersonDC instance (pydantic coerces the wire dict).""" + return f"Hello {who.name}, my {who.title}" + + @mcp.tool() + def greet_dict(who: dict[str, Any]) -> str: + """`who` is a free-form object — any dict passes; the handler must check it.""" + return f"Hello {who['name']}, my {who.get('title', 'friend')}" + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/schema_validators/server_lowlevel.py b/examples/stories/schema_validators/server_lowlevel.py new file mode 100644 index 000000000..02dca8d16 --- /dev/null +++ b/examples/stories/schema_validators/server_lowlevel.py @@ -0,0 +1,55 @@ +"""Same four tools via lowlevel.Server — inputSchema is hand-written JSON Schema.""" + +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 + +# With lowlevel.Server there is no reflection layer: you author the JSON Schema +# yourself and validate/unpack `params.arguments` in the handler. +PERSON_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"name": {"type": "string"}, "title": {"type": "string"}}, + "required": ["name"], +} +TOOLS = [ + types.Tool( + name=f"greet_{variant}", + description=f"Greet ({variant} input shape)", + input_schema={"type": "object", "properties": {"who": PERSON_SCHEMA}, "required": ["who"]}, + ) + for variant in ("pydantic", "typeddict", "dataclass") +] +TOOLS.append( + types.Tool( + name="greet_dict", + description="Greet (free-form dict input)", + input_schema={ + "type": "object", + "properties": {"who": {"type": "object", "additionalProperties": True}}, + "required": ["who"], + }, + ) +) + + +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: + assert params.arguments is not None + who = params.arguments["who"] + text = f"Hello {who['name']}, my {who.get('title', 'friend')}" + return types.CallToolResult(content=[types.TextContent(text=text)]) + + return Server("schema-validators-example", on_list_tools=list_tools, on_call_tool=call_tool) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/serve_one/README.md b/examples/stories/serve_one/README.md new file mode 100644 index 000000000..67f163e18 --- /dev/null +++ b/examples/stories/serve_one/README.md @@ -0,0 +1,60 @@ +# serve-one + +The kernel layer beneath `MCPServer.run()` / `run_server_from_args`. Every +transport entry composes the same three pieces: a `lowlevel.Server` (the +handler registry), a `Connection` (per-peer state), and a driver — `serve_one` +for one request → result dict, or `serve_connection` for a dispatcher loop. +This is what you write to bring up MCP over a custom transport. Uniquely, the +server file here builds the stdio entry by hand instead of importing +`stories._hosting`. + +## Run it + +```bash +# stdio (default — the client spawns server.py as a subprocess; its __main__ +# is the hand-built serve_connection loop) +uv run python -m stories.serve_one.client +``` + +## What to look at + +- `server.py::handle_one` — `Connection.from_envelope(...)` + `serve_one(...)` + returns the raw result dict for one request. No handshake, no streams; the + entry owns wire encoding and exception→error mapping. +- `server.py::main` — `JSONRPCDispatcher` + `Connection.for_loop(...)` + + `serve_connection(...)`: exactly what `Server.run()` does internally for + stdio. +- `server.py::SingleExchangeContext` — the per-request `DispatchContext` a + custom entry must supply. The SDK ships no public concrete class for this + yet. +- `client.py` — drives `handle_one` directly and asserts the raw result-dict + shape (`structuredContent` / `content`), then proves the loop-mode driver + works over the wire. + +## Caveats + +- **Deep imports** — `serve_one`, `serve_connection`, and `Connection` are only + reachable at `mcp.server.runner` / `mcp.server.connection` today; a shorter + `mcp.server.*` re-export is tracked for beta. +- **Lowlevel-only.** The drivers take a `lowlevel.Server` and `MCPServer` has + no public accessor for its underlying one (`_lowlevel_server` is private), so + there is no `MCPServer`-tier variant of this story. Build the lowlevel + `Server` directly until that accessor lands. +- **No public `DispatchContext`** — `SingleExchangeContext` is hand-rolled + boilerplate; a public helper (or a `serve_one` overload that builds one) is + tracked for beta. +- **Lifespan** — the transport entry enters `server.lifespan(server)` **once** + and threads `lifespan_state` to every `handle_one()` call; never enter it + per-request. +- `ServerRunner` is kernel-internal; never construct it directly. The + free-function drivers are the supported surface. + +## Spec + +[Architecture — lifecycle](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle) +· [2026 versioning — discover](https://modelcontextprotocol.io/specification/2026-07-28/server/discover) + +## See also + +`legacy_routing/` (composing `serve_one` behind `classify_inbound_request`), +`dual_era/` (`Connection.protocol_version` in handlers). diff --git a/examples/stories/serve_one/__init__.py b/examples/stories/serve_one/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/serve_one/client.py b/examples/stories/serve_one/client.py new file mode 100644 index 000000000..73bd457e1 --- /dev/null +++ b/examples/stories/serve_one/client.py @@ -0,0 +1,39 @@ +"""Drive `handle_one` directly to assert the raw result-dict shape, then over the wire.""" + +import mcp_types as types +from mcp_types.version import LATEST_MODERN_VERSION + +from mcp.client import Client +from stories._harness import Target, run_client +from stories.serve_one.server import build_server, handle_one + + +async def main(target: Target, *, mode: str = "auto") -> None: + # ── direct: the namesake recipe — Connection.from_envelope + serve_one → raw result dict. + # The entry enters lifespan once and threads it to every per-request handle_one(). + server = build_server() + params = { + "name": "add", + "arguments": {"a": 2, "b": 3}, + "_meta": { + types.PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION, + types.CLIENT_INFO_META_KEY: {"name": "serve-one-probe", "version": "0.0.0"}, + types.CLIENT_CAPABILITIES_META_KEY: {}, + }, + } + async with server.lifespan(server) as lifespan_state: + raw = await handle_one(server, "tools/call", params, lifespan_state=lifespan_state) + assert raw["structuredContent"] == {"result": 5}, raw + assert raw["content"][0] == {"type": "text", "text": "5"}, raw + + # ── over the wire: the loop-mode driver behind the connected client. + async with Client(target, mode=mode) as client: + listed = await client.list_tools() + assert [t.name for t in listed.tools] == ["add"] + + result = await client.call_tool("add", {"a": 2, "b": 3}) + assert result.structured_content == {"result": 5}, result + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/serve_one/server.py b/examples/stories/serve_one/server.py new file mode 100644 index 000000000..232ebf2be --- /dev/null +++ b/examples/stories/serve_one/server.py @@ -0,0 +1,109 @@ +"""serve_one / serve_connection mechanics: the kernel drivers a transport entry composes. + +`handle_one()` is the modern single-exchange recipe (`Connection.from_envelope` ++ `serve_one` → raw result dict). `main()` is the loop recipe +(`JSONRPCDispatcher` + `Connection.for_loop` + `serve_connection`) — what +`Server.run()` does for stdio. Both drivers take a `lowlevel.Server`, so this is +a lowlevel-only story: `MCPServer` has no public accessor for its underlying +`Server` yet. +""" + +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any + +import anyio +import mcp_types as types +from mcp_types.version import LATEST_MODERN_VERSION + +from mcp.server.connection import Connection # deep-path import; shorter re-export planned +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from mcp.server.runner import serve_connection, serve_one # deep-path import; shorter re-export planned +from mcp.server.stdio import stdio_server +from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher +from mcp.shared.transport_context import TransportContext + +__all__ = ["SingleExchangeContext", "build_server", "handle_one"] + + +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="add", description="Add two integers.", input_schema={"type": "object"})] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "add" and params.arguments is not None + total = params.arguments["a"] + params.arguments["b"] + return types.CallToolResult(content=[types.TextContent(text=str(total))], structured_content={"result": total}) + + return Server("serve-one-example", on_list_tools=list_tools, on_call_tool=call_tool) + + +@dataclass +class SingleExchangeContext: + """Minimal `DispatchContext` for one inbound request with no back-channel. + + A custom transport entry hand-builds one of these per request. The SDK + ships no public concrete class for this yet; this is the structural minimum. + """ + + request_id: int | str | None + transport: TransportContext = field(default_factory=lambda: TransportContext(kind="custom", can_send_request=False)) + message_metadata: None = None + can_send_request: bool = False + cancel_requested: anyio.Event = field(default_factory=anyio.Event) + + async def send_raw_request(self, method: str, params: Mapping[str, Any] | None, opts: Any = None) -> dict[str, Any]: + raise NotImplementedError # no back-channel on the single-exchange path + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: Any = None) -> None: + return None + + async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: + return None + + +async def handle_one( + server: Server[Any], method: str, params: Mapping[str, Any], *, lifespan_state: Any +) -> dict[str, Any]: + """Serve exactly one modern-era request and return its raw result dict. + + Reads the envelope from `params._meta` (the 2026 wire shape), builds a + born-ready `Connection.from_envelope`, and drives `serve_one`. The transport + entry enters `server.lifespan(server)` once and threads `lifespan_state` to + every call — never enter the lifespan per-request. + """ + meta = params.get("_meta", {}) + connection = Connection.from_envelope( + meta.get(types.PROTOCOL_VERSION_META_KEY, LATEST_MODERN_VERSION), + meta.get(types.CLIENT_INFO_META_KEY), + meta.get(types.CLIENT_CAPABILITIES_META_KEY), + ) + return await serve_one( + server, + SingleExchangeContext(request_id=1), + method, + params, + connection=connection, + lifespan_state=lifespan_state, + ) + + +async def main() -> None: + """Serve over stdio by building the dispatcher + Connection by hand (loop mode).""" + server = build_server() + async with server.lifespan(server) as lifespan_state: + async with stdio_server() as (read_stream, write_stream): + dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher( + read_stream, write_stream, inline_methods=frozenset({"initialize"}) + ) + connection = Connection.for_loop(dispatcher) + await serve_connection(server, dispatcher, connection=connection, lifespan_state=lifespan_state) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/examples/stories/skills/README.md b/examples/stories/skills/README.md new file mode 100644 index 000000000..d984fe5b6 --- /dev/null +++ b/examples/stories/skills/README.md @@ -0,0 +1,14 @@ +# skills + +SEP-2640 skills: a server exposes a `skill://index.json` directory resource and +`@skill` / `@skillDir` registrations that a host can read to bootstrap +agent-level instructions. The story will list skills and read one. + +**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 the skills extension. + +## Spec + +[SEP-2640 — skills](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2640) +· [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133) diff --git a/examples/stories/sse_polling/README.md b/examples/stories/sse_polling/README.md new file mode 100644 index 000000000..ddd1b6188 --- /dev/null +++ b/examples/stories/sse_polling/README.md @@ -0,0 +1,76 @@ +# sse-polling + +> **Legacy mechanism (2025 handshake era).** `Last-Event-ID` resumability and +> the sessionful transport are removed in the 2026-07-28 protocol (SEP-2575) +> with no modern-era equivalent; the closest 2026-era pattern is client-side +> reconnection over a persisted `DiscoverResult` — +> [`reconnect/`](../reconnect/). TODO(maxisbey): revisit before beta. + +SEP-1699 server-initiated SSE disconnection with `Last-Event-ID` replay. The +server's `EventStore` stamps every SSE event with an ID and opens each response +stream with a priming event; mid-handler the tool calls +`ctx.close_sse_stream()` to release the open HTTP response (freeing a +connection slot), keeps emitting progress into the event store, and returns. +The client transport sees the stream end, reconnects with `Last-Event-ID`, and +the event store replays everything it missed — `await client.call_tool(...)` +resolves as if the disconnect never happened. + +## Run it + +```bash +# HTTP — the client self-hosts the app on a free port, runs, then tears it down +uv run python -m stories.sse_polling.client --http --legacy +# same, against the lowlevel-API server variant +uv run python -m stories.sse_polling.client --http --legacy --server server_lowlevel + +# against a server you run yourself (real uvicorn on :8000) +uv run python -m stories.sse_polling.server --port 8000 & +SERVER_PID=$! +uv run python -m stories.sse_polling.client --http http://127.0.0.1:8000/mcp --legacy +kill "$SERVER_PID" +``` + +## What to look at + +- **`client.py` `main` — opens with `async with Client(target, mode=mode)`.** + There is no client-side resumability configuration: the `Client` and the + `streamable_http_client` transport handle the priming event, the SSE `retry:` + hint, and the `Last-Event-ID` reconnect automatically. The assertion that the + `"after-close"` progress message arrived is the proof — it was emitted while + no SSE stream was open. +- **`server.py` — `streamable_http_app(event_store=..., retry_interval=0)`.** + Passing an `EventStore` is what enables resumability: every SSE event gets an + ID and the response opens with a priming event so the client always has a + `Last-Event-ID` to reconnect with. `retry_interval=0` makes the client's + reconnect wait a no-op (the SSE `retry:` hint). +- **`server.py` — `await ctx.close_sse_stream()`.** Ends the current request's + SSE response without cancelling the handler. Everything emitted afterwards + goes to the event store and is replayed on reconnect. A no-op when no + `event_store` is configured. +- **`server_lowlevel.py` — `ctx.close_sse_stream`.** On the lowlevel API the + callback is an optional field on `ServerRequestContext`; it is `None` unless + an event store is wired and the negotiated version is in the 2025 era. + +## Caveats + +- `streamable_http_app(...)` is a hosting entry that reshapes in a later + release; this story calls it directly because the event-store and + retry-interval kwargs are the point. +- DNS-rebinding protection is disabled (`transport_security=NO_DNS_REBIND`) + because the in-process httpx client sends no `Origin` header. Drop the kwarg + for a real deployment. +- `event_store.py` here is example-grade only (sequential IDs, no eviction). A + production server would back the `EventStore` interface with persistent + storage. + +## Spec + +[Resumability and Redelivery](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery) +· SEP-1699 (server-initiated SSE close) + +## See also + +`standalone_get/` (the standalone-stream sibling of `close_sse_stream()`), +`reconnect/` (the modern-era reconnection story — persisted `DiscoverResult`, +no event store), `streaming/` (in-flight progress + cancellation without the +disconnect). diff --git a/examples/stories/sse_polling/__init__.py b/examples/stories/sse_polling/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/sse_polling/client.py b/examples/stories/sse_polling/client.py new file mode 100644 index 000000000..d2f391895 --- /dev/null +++ b/examples/stories/sse_polling/client.py @@ -0,0 +1,32 @@ +"""Call a tool whose SSE stream the server closes mid-flight; the call still completes. HTTP-only — no SSE on stdio.""" + +import anyio +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: + messages: list[str | None] = [] + + async def on_progress(progress: float, total: float | None, message: str | None) -> None: + messages.append(message) + + with anyio.fail_after(10): + result = await client.call_tool("long_operation", {}, progress_callback=on_progress) + + # The result arrived — the client transport survived the server-initiated close, + # reconnected with Last-Event-ID, and received the replayed response. + assert not result.is_error, result + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "resumed" + + # "after-close" was emitted while no SSE stream was open; receiving it proves the + # event store buffered it and the reconnect replayed it. + assert messages == ["before-close", "after-close"], messages + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/sse_polling/event_store.py b/examples/stories/sse_polling/event_store.py new file mode 100644 index 000000000..95d2b8acc --- /dev/null +++ b/examples/stories/sse_polling/event_store.py @@ -0,0 +1,34 @@ +"""Minimal in-memory `EventStore` for the SSE-resumability example. + +Sequential integer IDs so the wire is readable; a production server would back +this interface with persistent storage so replay survives a process restart. +""" + +from mcp_types import JSONRPCMessage + +from mcp.server.streamable_http import EventCallback, EventId, EventMessage, EventStore, StreamId + + +class InMemoryEventStore(EventStore): + """Stores every event in arrival order and replays the same-stream tail after a given ID.""" + + def __init__(self) -> None: + self._events: list[tuple[StreamId, JSONRPCMessage | None]] = [] + + async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId: + self._events.append((stream_id, message)) + return str(len(self._events)) + + async def replay_events_after(self, last_event_id: EventId, send_callback: EventCallback) -> StreamId | None: + try: + cursor = int(last_event_id) + except ValueError: + return None + if not 0 < cursor <= len(self._events): + return None + stream_id, _ = self._events[cursor - 1] + for index in range(cursor, len(self._events)): + event_stream_id, message = self._events[index] + if event_stream_id == stream_id and message is not None: + await send_callback(EventMessage(message, str(index + 1))) + return stream_id diff --git a/examples/stories/sse_polling/server.py b/examples/stories/sse_polling/server.py new file mode 100644 index 000000000..1098ca6d5 --- /dev/null +++ b/examples/stories/sse_polling/server.py @@ -0,0 +1,35 @@ +"""SEP-1699: a tool closes its own SSE stream mid-call; the event store buffers the rest. Exports `build_app()`.""" + +from starlette.applications import Starlette + +from mcp.server.mcpserver import Context, MCPServer +from stories._hosting import NO_DNS_REBIND, run_app_from_args +from stories.sse_polling.event_store import InMemoryEventStore + + +def build_app() -> Starlette: + mcp = MCPServer("sse-polling-example") + + @mcp.tool() + async def long_operation(ctx: Context) -> str: + """Emit progress, close this call's SSE stream, emit more progress, then return. + + Everything sent after `close_sse_stream()` lands in the event store and is + replayed when the client reconnects with `Last-Event-ID`. + """ + await ctx.report_progress(0.5, total=1.0, message="before-close") + await ctx.close_sse_stream() + await ctx.report_progress(1.0, total=1.0, message="after-close") + return "resumed" + + # event_store enables Last-Event-ID replay; retry_interval=0 makes the client's + # reconnect wait a no-op so the example is deterministic without real time. + return mcp.streamable_http_app( + event_store=InMemoryEventStore(), + retry_interval=0, + transport_security=NO_DNS_REBIND, + ) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/sse_polling/server_lowlevel.py b/examples/stories/sse_polling/server_lowlevel.py new file mode 100644 index 000000000..fcf319986 --- /dev/null +++ b/examples/stories/sse_polling/server_lowlevel.py @@ -0,0 +1,45 @@ +"""SEP-1699 polling on the lowlevel `Server`: close the request's SSE stream mid-handler.""" + +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 +from stories.sse_polling.event_store import InMemoryEventStore + +_TOOL = types.Tool( + name="long_operation", + description="Emit progress, close the SSE stream, emit more, return.", + 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=[_TOOL]) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "long_operation" + await ctx.session.report_progress(0.5, total=1.0, message="before-close") + # The transport only wires this callback when an event_store is configured and the + # negotiated version is in the 2025 era; it is None otherwise. + if ctx.close_sse_stream is not None: + await ctx.close_sse_stream() + await ctx.session.report_progress(1.0, total=1.0, message="after-close") + return types.CallToolResult(content=[types.TextContent(text="resumed")]) + + server = Server("sse-polling-example", on_list_tools=list_tools, on_call_tool=call_tool) + return server.streamable_http_app( + event_store=InMemoryEventStore(), + retry_interval=0, + transport_security=NO_DNS_REBIND, + ) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/standalone_get/README.md b/examples/stories/standalone_get/README.md new file mode 100644 index 000000000..c460e1491 --- /dev/null +++ b/examples/stories/standalone_get/README.md @@ -0,0 +1,67 @@ +# standalone-get + +> **Legacy mechanism (2025 handshake era).** The 2026-07-28 protocol delivers +> server-initiated notifications over a `subscriptions/listen` stream instead +> of the standalone GET stream. TODO(maxisbey): unify once +> `subscriptions/listen` lands +> ([#2901](https://github.com/modelcontextprotocol/python-sdk/issues/2901)). + +Server-initiated `notifications/resources/list_changed` delivered over the +**standalone GET SSE stream** of a sessionful Streamable-HTTP connection. The +`add_note` tool mutates the resource list and emits the notification with no +related request; the client's `message_handler` receives it on the GET stream, +awaits it on an `anyio.Event`, then re-lists to observe the change. + +## Run it + +```bash +# HTTP only — the standalone GET stream is a Streamable-HTTP feature. The +# client self-hosts the server on a free port, runs, then tears it down. +uv run python -m stories.standalone_get.client --http --legacy +# same, against the lowlevel-API server variant +uv run python -m stories.standalone_get.client --http --legacy --server server_lowlevel + +# against a server you run yourself +uv run python -m stories.standalone_get.server --http --port 8000 & +SERVER_PID=$! +uv run python -m stories.standalone_get.client --http http://127.0.0.1:8000/mcp --legacy +kill "$SERVER_PID" +``` + +## What to look at + +- **`client.py` — `Client(target, mode=mode, message_handler=on_message)`.** + Unsolicited notifications have no typed callback, so the catch-all + `message_handler` is wired at construction — it (and the `anyio.Event` it + sets) must exist *before* the connection does. The notification is not + guaranteed to arrive before the tool result (different streams), so the body + `await`s the event, bounded by `anyio.fail_after(5)`. +- **`server.py` — `await ctx.session.send_resource_list_changed()`.** + `MCPServer.add_resource` does **not** auto-emit (unlike the TypeScript SDK's + `registerResource`); the explicit call is the teaching point. Because + `send_*_list_changed()` carries no `related_request_id`, the only route to the + client is the standalone GET stream. + +## Caveats + +- DNS-rebinding protection is disabled via `transport_security=NO_DNS_REBIND` + because the in-process httpx client sends no `Origin` header. Drop the kwarg + for a real deployment. +- Neither `MCPServer` nor lowlevel `Server` auto-advertises + `resources.listChanged: true` in capabilities, and `MCPServer` exposes no knob + to set it. A spec-conformant client that gates on the capability flag would + skip the handler. +- `ctx.session.*` is the interim path; a later release will shorten it. +- Tool-triggered, not timer-driven, for harness determinism. "Server pushes on + its own schedule" is not demonstrated. + +## Spec + +[List Changed Notification](https://modelcontextprotocol.io/specification/2025-11-25/server/resources#list-changed-notification), +[Streamable HTTP — Listening for Messages](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server) + +## See also + +`stickynotes/` (list_changed inside a feature capstone), `sse_polling/` (the +other GET-stream story — resumability), `json_response/` (what happens when the +server can't stream). diff --git a/examples/stories/standalone_get/__init__.py b/examples/stories/standalone_get/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/standalone_get/client.py b/examples/stories/standalone_get/client.py new file mode 100644 index 000000000..aaf870f0e --- /dev/null +++ b/examples/stories/standalone_get/client.py @@ -0,0 +1,40 @@ +"""Receive `notifications/resources/list_changed` over the standalone GET stream, then re-list.""" + +import anyio +import mcp_types as types + +from mcp.client import Client +from stories._harness import Target, run_client + + +async def main(target: Target, *, mode: str = "auto") -> None: + # `message_handler` is constructor-only on `Client`, so the event it sets + # has to exist before the connection does. + received: list[types.ResourceListChangedNotification] = [] + seen = anyio.Event() + + async def on_message(message: object) -> None: + if isinstance(message, types.ResourceListChangedNotification): + received.append(message) + seen.set() + + async with Client(target, mode=mode, message_handler=on_message) as client: + before = await client.list_resources() + assert len(before.resources) >= 1, before + + result = await client.call_tool("add_note", {"content": "hello"}) + assert not result.is_error, result + + # The notification rides the standalone GET stream, not the call's POST stream — + # delivery order vs the tool result is not guaranteed, so wait. + with anyio.fail_after(5): + await seen.wait() + assert len(received) == 1, received + + after = await client.list_resources() + assert len(after.resources) == len(before.resources) + 1, after + assert {r.name for r in after.resources} >= {"initial", "note-1"} + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/standalone_get/server.py b/examples/stories/standalone_get/server.py new file mode 100644 index 000000000..4b0c95684 --- /dev/null +++ b/examples/stories/standalone_get/server.py @@ -0,0 +1,30 @@ +"""Sessionful Streamable HTTP: a tool mutates resources and emits `list_changed` over the standalone GET stream.""" + +import itertools + +from mcp.server.mcpserver import Context, MCPServer +from mcp.server.mcpserver.resources import TextResource +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer("standalone-get-example") + counter = itertools.count(1) + + mcp.add_resource(TextResource(uri="note://initial", name="initial", text="initial content")) + + @mcp.tool() + async def add_note(content: str, ctx: Context) -> str: + """Register a new resource and announce it via `notifications/resources/list_changed`.""" + name = f"note-{next(counter)}" + mcp.add_resource(TextResource(uri=f"note://{name}", name=name, text=content)) + # MCPServer does not auto-emit on add_resource; send explicitly. With no + # related_request_id this routes to the standalone GET stream. + await ctx.session.send_resource_list_changed() + return f"registered {name}" + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/standalone_get/server_lowlevel.py b/examples/stories/standalone_get/server_lowlevel.py new file mode 100644 index 000000000..21ee8c1f1 --- /dev/null +++ b/examples/stories/standalone_get/server_lowlevel.py @@ -0,0 +1,49 @@ +"""Sessionful Streamable HTTP (lowlevel `Server`): tool-triggered `list_changed` over the standalone GET stream.""" + +import itertools +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 + +ADD_NOTE_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"content": {"type": "string"}}, + "required": ["content"], +} + + +def build_server() -> Server[Any]: + counter = itertools.count(1) + resources: list[types.Resource] = [types.Resource(uri="note://initial", name="initial", mime_type="text/plain")] + + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="add_note", input_schema=ADD_NOTE_INPUT_SCHEMA)]) + + async def list_resources( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListResourcesResult: + return types.ListResourcesResult(resources=list(resources)) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "add_note" and params.arguments is not None + name = f"note-{next(counter)}" + resources.append(types.Resource(uri=f"note://{name}", name=name, mime_type="text/plain")) + await ctx.session.send_resource_list_changed() + return types.CallToolResult(content=[types.TextContent(text=f"registered {name}")]) + + return Server( + "standalone-get-example", + on_list_tools=list_tools, + on_list_resources=list_resources, + on_call_tool=call_tool, + ) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/starlette_mount/README.md b/examples/stories/starlette_mount/README.md new file mode 100644 index 000000000..4098a5fde --- /dev/null +++ b/examples/stories/starlette_mount/README.md @@ -0,0 +1,57 @@ +# starlette-mount + +Embed an MCP server inside an existing Starlette (or FastAPI) app at a +sub-path, next to your own routes. `mcp.streamable_http_app()` returns a +mountable ASGI app; the two things to get right are the **path** (the default +`streamable_http_path="/mcp"` stacks under your mount prefix) and the +**lifespan** (Starlette does not run a mounted sub-app's lifespan, so the +parent must enter `mcp.session_manager.run()`). + +## Run it + +```bash +# HTTP — the client self-hosts the mounted app on a free port at /api/, runs, +# then tears it down +uv run python -m stories.starlette_mount.client --http + +# against a server you run yourself (real uvicorn on :8000) +uv run python -m stories.starlette_mount.server --port 8000 & +SERVER_PID=$! +curl http://127.0.0.1:8000/health # → {"status":"ok"} +uv run python -m stories.starlette_mount.client --http http://127.0.0.1:8000/api/ +kill "$SERVER_PID" +``` + +## What to look at + +- `client.py` `main` — opens with `async with Client(target, mode=mode) as + client:`. Nothing on the client side knows about the mount: the `/api/` URL + handed in as `target` is just another streamable-HTTP endpoint. +- `server.py` `streamable_http_path="/"` — without this the endpoint would be + `/api/mcp`; with it, `Mount("/api", ...)` serves MCP at `/api/` (trailing + slash required — Starlette's `Mount` forwards `/api` as an empty path that + the inner `/` route won't match). +- `server.py` `lifespan` — `mcp.session_manager.run()` **must** be entered by + the parent app. Forget it and every MCP request hangs (the sub-app's own + lifespan never fires under `Mount`). +- `server.py` `Route("/health", ...)` — non-MCP routes live alongside the + mount; FastAPI users do the same with `app.mount("/api", mcp_app)`. + +## Caveats + +- DNS-rebinding protection is on by default; the example passes + `transport_security=NO_DNS_REBIND` because the in-process test client sends + no `Origin` header. Remove it (or configure allowed hosts) for a real + deployment. +- The parent-lifespan dance is a known SDK ergonomics gap (other SDKs mount + with no extra ceremony); tracked for the beta reshape. The recipe shown here + is what works today. + +## Spec + +[Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) + +## See also + +`stateless_legacy/` (the one-liner `mcp.streamable_http_app()` without a parent +app), `json_response/`, `legacy_routing/`. TS-SDK equivalent: `examples/hono/`. diff --git a/examples/stories/starlette_mount/__init__.py b/examples/stories/starlette_mount/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/starlette_mount/client.py b/examples/stories/starlette_mount/client.py new file mode 100644 index 000000000..dcfc3495b --- /dev/null +++ b/examples/stories/starlette_mount/client.py @@ -0,0 +1,23 @@ +"""Connect to the sub-mounted MCP endpoint at /api/, list tools and call greet. HTTP-only: the mount is the story.""" + +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] == ["greet"] + + result = await client.call_tool("greet", {"name": "Starlette"}) + assert not result.is_error + first = result.content[0] + assert isinstance(first, TextContent) + assert "Hello, Starlette!" in first.text, result + assert result.structured_content == {"result": "Hello, Starlette! (served from a Starlette sub-mount)"} + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/starlette_mount/server.py b/examples/stories/starlette_mount/server.py new file mode 100644 index 000000000..858abc920 --- /dev/null +++ b/examples/stories/starlette_mount/server.py @@ -0,0 +1,47 @@ +"""Mount an MCPServer in an existing Starlette app at a sub-path, alongside non-MCP routes; exports `build_app()`.""" + +import contextlib +from collections.abc import AsyncIterator + +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Mount, Route + +from mcp.server.mcpserver import MCPServer +from stories._hosting import NO_DNS_REBIND, run_app_from_args + + +def build_app() -> Starlette: + mcp = MCPServer("starlette-mount-example") + + @mcp.tool() + def greet(name: str) -> str: + """Return a greeting.""" + return f"Hello, {name}! (served from a Starlette sub-mount)" + + # streamable_http_path="/" so Mount("/api", ...) serves the MCP endpoint at + # /api itself, not /api/mcp. The returned sub-app has its own lifespan, but + # Starlette does not run nested lifespans under Mount — the parent app below + # must enter mcp.session_manager.run() itself. + mcp_app = mcp.streamable_http_app(streamable_http_path="/", transport_security=NO_DNS_REBIND) + + async def health(_request: Request) -> JSONResponse: + return JSONResponse({"status": "ok"}) + + @contextlib.asynccontextmanager + async def lifespan(_app: Starlette) -> AsyncIterator[None]: + async with mcp.session_manager.run(): + yield + + return Starlette( + routes=[ + Route("/health", health), + Mount("/api", app=mcp_app), + ], + lifespan=lifespan, + ) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/stateless_legacy/README.md b/examples/stories/stateless_legacy/README.md new file mode 100644 index 000000000..7fc1630ce --- /dev/null +++ b/examples/stories/stateless_legacy/README.md @@ -0,0 +1,59 @@ +# stateless-legacy + +The one-liner HTTP deploy. `MCPServer.streamable_http_app(stateless_http=True)` +returns a complete ASGI app that serves **both** protocol eras on `/mcp`: 2025 +clients get the `initialize` handshake answered statelessly (no `Mcp-Session-Id`, +fresh transport per request, horizontally scalable), 2026 clients get the +per-request envelope path. Hand it straight to uvicorn — no session-manager +wiring, no era flag. The client connects once per era and asserts the same +`greet` tool answers identically either way. + +## Run it + +```bash +# HTTP — the client self-hosts the app on a free port, connects once as a +# modern client and once as a legacy client, then tears it down +uv run python -m stories.stateless_legacy.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.stateless_legacy.client --http --server server_lowlevel + +# against a server you run yourself (real uvicorn on :8000) +uv run python -m stories.stateless_legacy.server --port 8000 & +SERVER_PID=$! +uv run python -m stories.stateless_legacy.client --http http://127.0.0.1:8000/mcp +kill "$SERVER_PID" +``` + +## What to look at + +- `client.py` — two visible `Client(targets(), mode=...)` constructions against + the same URL. The first connects at the caller's `mode` (the real-user + `"auto"` default routes to the 2026 envelope path); the second pins + `mode="legacy"` and runs the `initialize` handshake. `client.protocol_version` + is the era-neutral accessor: two negotiated versions, identical tool result. +- `server.py` — `stateless_http=True` is the only knob; era routing is automatic + inside `StreamableHTTPSessionManager.handle_request`. The returned `Starlette` + already wires `lifespan=session_manager.run()`, so `uvicorn.run(app, ...)` + works with no parent-lifespan ceremony. +- `server_lowlevel.py` — `lowlevel.Server.streamable_http_app()` is the same + call; `MCPServer` delegates to it. + +## 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. +- `streamable_http_app()` reshapes in a later release; the call is isolated in + `build_app()` so the change touches one line per server file. + +## Spec + +[Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) +· [Versioning — backward compatibility](https://modelcontextprotocol.io/specification/draft/basic/versioning) + +## See also + +`dual_era/` (era branching inside a tool handler) · `legacy_routing/` +(`classify_inbound_request()` for sessionful-2025 + modern on one mount) · +`starlette_mount/` (mounting under FastAPI/Starlette with parent lifespan) · +`json_response/` (`json_response=True` and what it drops). diff --git a/examples/stories/stateless_legacy/__init__.py b/examples/stories/stateless_legacy/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/stateless_legacy/client.py b/examples/stories/stateless_legacy/client.py new file mode 100644 index 000000000..d21ff850c --- /dev/null +++ b/examples/stories/stateless_legacy/client.py @@ -0,0 +1,37 @@ +"""Connect at each era — two connections, so `main` takes `targets`; the same stateless app answers both.""" + +from mcp_types import TextContent +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 era: the caller's mode (the real-user "auto" default) routes this connection + # through the 2026 envelope path. No initialize handshake, no session id. + async with Client(targets(), mode=mode) as client: + assert client.protocol_version == LATEST_MODERN_VERSION + + listed = await client.list_tools() + assert [t.name for t in listed.tools] == ["greet"] + + result = await client.call_tool("greet", {"name": "world"}) + assert not result.is_error + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Hello, world!", result + + # ── legacy era: a fresh mode="legacy" client runs the initialize handshake against the + # SAME stateless app. It is answered statelessly (no Mcp-Session-Id) and the same tool + # gives the same answer — the era is invisible to the server body. + async with Client(targets(), mode="legacy") as legacy: + assert legacy.protocol_version == LATEST_HANDSHAKE_VERSION + + result = await legacy.call_tool("greet", {"name": "world"}) + assert not result.is_error + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Hello, world!", result + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/stateless_legacy/server.py b/examples/stories/stateless_legacy/server.py new file mode 100644 index 000000000..40c82ad34 --- /dev/null +++ b/examples/stories/stateless_legacy/server.py @@ -0,0 +1,22 @@ +"""The one-liner HTTP deploy: one stateless ASGI app serves both protocol eras, so it exports `build_app()`.""" + +from starlette.applications import Starlette + +from mcp.server.mcpserver import MCPServer +from stories._hosting import NO_DNS_REBIND, run_app_from_args + + +def build_app() -> Starlette: + mcp = MCPServer("stateless-legacy-example") + + @mcp.tool(description="A simple greeting tool.") + def greet(name: str) -> str: + return f"Hello, {name}!" + + # stateless_http=True: no Mcp-Session-Id, fresh transport per POST — horizontally + # scalable. The same app also answers 2026-era envelope requests with no extra config. + return mcp.streamable_http_app(stateless_http=True, transport_security=NO_DNS_REBIND) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/stateless_legacy/server_lowlevel.py b/examples/stories/stateless_legacy/server_lowlevel.py new file mode 100644 index 000000000..44943abd3 --- /dev/null +++ b/examples/stories/stateless_legacy/server_lowlevel.py @@ -0,0 +1,38 @@ +"""The one-liner HTTP deploy (lowlevel API): Server.streamable_http_app(stateless_http=True).""" + +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="A simple greeting tool.", 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 + return types.CallToolResult(content=[types.TextContent(text=f"Hello, {params.arguments['name']}!")]) + + server = Server("stateless-legacy-example", on_list_tools=list_tools, on_call_tool=call_tool) + return server.streamable_http_app(stateless_http=True, transport_security=NO_DNS_REBIND) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/stickynotes/README.md b/examples/stories/stickynotes/README.md new file mode 100644 index 000000000..b1d454314 --- /dev/null +++ b/examples/stories/stickynotes/README.md @@ -0,0 +1,62 @@ +# stickynotes + +The "real app" capstone: tools mutate a sticky-notes board held in the +server's lifespan context, each note is a `note:///{id}` resource, +`notifications/resources/list_changed` fires on add/remove, and `remove_all` +blocks on a form-mode elicitation so the user must explicitly confirm a +destructive clear. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.stickynotes.client + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.stickynotes.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.stickynotes.client --http --server server_lowlevel +``` + +## What to look at + +- **`client.py` `main` → `Client(target, mode=mode, elicitation_callback=..., + message_handler=...)`** — the construction is the example: callbacks are + plain constructor kwargs, and `mode=` is explicit. The scripted elicitation + answer and the `list_changed` event are locals of `main`, so every + connection starts clean. +- **`server.py` `lifespan` → `Board`** — long-lived mutable state belongs in + the lifespan context, never a module global. Tools reach it via + `ctx.request_context.lifespan_context`; this 2-hop path is interim and will + shorten to `ctx.state.*` in a later release. +- **`add_note` / `remove_note`** — `mcp.add_resource(FunctionResource(...))` + registers a concrete resource at runtime; `ctx.session.send_resource_list_changed()` + tells connected clients to re-list. **Gap:** `MCPServer` has no public + `remove_resource()` yet, so `remove_note` reaches a private attribute — do + not copy that line. `server_lowlevel.py` shows the clean equivalent: + `on_list_resources` reads the board and builds the list fresh per call, so + removal is just `board.notes.pop(...)` with no registry mutation. +- **`remove_all` → `ctx.elicit(...)`** — push-style server→client elicitation + needs a back-channel and an advertised client capability, so it only runs on + the legacy-era legs. On a modern connection there is no server→client + request channel; the modern equivalent is the multi-round-trip + `InputRequiredResult` flow (see `mrtr/`, not yet implemented). The client + branches on `client.protocol_version`. + +## Caveats + +- `list_changed` and `ctx.elicit()` are skipped on modern legs: the + notification needs a standalone stream and `ctx.elicit()` would raise + `NoBackChannelError`. `main` branches on + `client.protocol_version in HANDSHAKE_PROTOCOL_VERSIONS`. + +## Spec + +- [Tools](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) +- [Resources](https://modelcontextprotocol.io/specification/2025-11-25/server/resources) +- [Elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) + +## See also + +`tools/`, `resources/`, `legacy_elicitation/`, `lifespan/`, `standalone_get/` +(`list_changed` over the GET stream). diff --git a/examples/stories/stickynotes/__init__.py b/examples/stories/stickynotes/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/stickynotes/client.py b/examples/stories/stickynotes/client.py new file mode 100644 index 000000000..56ca10f55 --- /dev/null +++ b/examples/stories/stickynotes/client.py @@ -0,0 +1,81 @@ +"""Drive the sticky-notes board end to end and prove `remove_all` clears only on a confirmed elicitation.""" + +import anyio +import mcp_types as types +from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS + +from mcp.client import Client, ClientRequestContext +from stories._harness import Target, run_client + + +async def main(target: Target, *, mode: str = "auto") -> None: + # Scripted reply for the server's `remove_all` elicitation; rebound between calls below. + answer = "cancel" + list_changed = anyio.Event() + + async def on_elicit(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult: + if answer == "cancel": + return types.ElicitResult(action="cancel") + return types.ElicitResult(action="accept", content={"confirm": answer == "confirm"}) + + async def on_message(message: object) -> None: + if isinstance(message, types.ResourceListChangedNotification): + list_changed.set() + + async with Client(target, mode=mode, elicitation_callback=on_elicit, message_handler=on_message) as client: + legacy = client.protocol_version in HANDSHAKE_PROTOCOL_VERSIONS + + # Add two notes. + first = await client.call_tool("add_note", {"text": "Buy milk"}) + assert first.structured_content is not None + first_id, first_uri = first.structured_content["id"], first.structured_content["uri"] + assert first_uri.startswith("note:///") + second = await client.call_tool("add_note", {"text": "Walk the dog"}) + assert second.structured_content is not None + second_id, second_uri = second.structured_content["id"], second.structured_content["uri"] + assert first_id != second_id + + # List + read — both notes appear as resources; first reads back its text. + listed = await client.list_resources() + uris = {str(r.uri) for r in listed.resources} + assert first_uri in uris and second_uri in uris, uris + read = await client.read_resource(first_uri) + assert isinstance(read.contents[0], types.TextResourceContents) + assert read.contents[0].text == "Buy milk" + + # list_changed rides the standalone stream — only deliverable on a legacy-era connection. + if legacy: + with anyio.fail_after(5): + await list_changed.wait() + + # Remove one. + removed = await client.call_tool("remove_note", {"note_id": first_id}) + assert removed.structured_content == {"result": True} + after = await client.list_resources() + assert first_uri not in {str(r.uri) for r in after.resources} + + # remove_all uses push-style elicitation: legacy-era only (modern equivalent lands with the mrtr/ story). + if not legacy: + gone = await client.call_tool("remove_note", {"note_id": second_id}) + assert gone.structured_content == {"result": True} + return + + cancelled = await client.call_tool("remove_all", {}) + assert cancelled.structured_content == {"status": "cancelled", "removed": 0} + + answer = "unchecked" + declined = await client.call_tool("remove_all", {}) + assert declined.structured_content == {"status": "declined", "removed": 0} + + answer = "confirm" + cleared = await client.call_tool("remove_all", {}) + assert cleared.structured_content == {"status": "cleared", "removed": 1} + final = await client.list_resources() + assert not [r for r in final.resources if str(r.uri).startswith("note:///")] + + empty = await client.call_tool("remove_all", {}) + assert empty.structured_content == {"status": "empty", "removed": 0} + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/stickynotes/server.py b/examples/stories/stickynotes/server.py new file mode 100644 index 000000000..4c6c9d0a7 --- /dev/null +++ b/examples/stories/stickynotes/server.py @@ -0,0 +1,99 @@ +"""Capstone sticky-notes board: tools mutate lifespan state, one resource per note, +`resources/list_changed` on add/remove, elicitation-guarded clear.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass, field + +from pydantic import BaseModel + +from mcp.server.mcpserver import Context, MCPServer +from mcp.server.mcpserver.resources import FunctionResource +from stories._hosting import run_server_from_args + + +@dataclass +class Board: + notes: dict[str, str] = field(default_factory=dict[str, str]) + _next: int = 1 + + def claim_id(self) -> str: + nid, self._next = str(self._next), self._next + 1 + return nid + + +class AddResult(BaseModel): + id: str + uri: str + + +class ClearResult(BaseModel): + status: str + removed: int + + +class ConfirmClear(BaseModel): + confirm: bool + + +def build_server() -> MCPServer: + @asynccontextmanager + async def lifespan(_: MCPServer) -> AsyncIterator[Board]: + yield Board() + + mcp = MCPServer("stickynotes-example", lifespan=lifespan) + + def unregister_note(note_id: str) -> None: + # DO NOT copy this line into your own server. `MCPServer` has no public + # `remove_resource()` yet (only `add_resource`), so unregistering a runtime-added + # resource has to reach a private attribute. `server_lowlevel.py` shows the clean + # shape: `on_list_resources` rebuilds the list from the board on every call, so + # removal never touches a registry at all. + mcp._resource_manager._resources.pop(f"note:///{note_id}", None) # pyright: ignore[reportPrivateUsage] + + @mcp.tool() + async def add_note(text: str, ctx: Context[Board]) -> AddResult: + """Add a sticky note and register a `note:///{id}` resource for it.""" + board = ctx.request_context.lifespan_context + note_id = board.claim_id() + uri = f"note:///{note_id}" + board.notes[note_id] = text + mcp.add_resource( + FunctionResource(uri=uri, name=f"note-{note_id}", mime_type="text/plain", fn=lambda: board.notes[note_id]) + ) + await ctx.session.send_resource_list_changed() + return AddResult(id=note_id, uri=uri) + + @mcp.tool() + async def remove_note(note_id: str, ctx: Context[Board]) -> bool: + """Remove one sticky note and unregister its resource.""" + board = ctx.request_context.lifespan_context + removed = board.notes.pop(note_id, None) is not None + if removed: + unregister_note(note_id) + await ctx.session.send_resource_list_changed() + return removed + + @mcp.tool() + async def remove_all(ctx: Context[Board]) -> ClearResult: + """Remove every note after a confirmed form-mode elicitation (handshake-era only).""" + board = ctx.request_context.lifespan_context + if not board.notes: + return ClearResult(status="empty", removed=0) + answer = await ctx.elicit(f"Remove all {len(board.notes)} note(s)? This cannot be undone.", ConfirmClear) + if answer.action == "cancel": + return ClearResult(status="cancelled", removed=0) + if answer.action != "accept" or not answer.data.confirm: + return ClearResult(status="declined", removed=0) + count = len(board.notes) + for nid in list(board.notes): + unregister_note(nid) + board.notes.clear() + await ctx.session.send_resource_list_changed() + return ClearResult(status="cleared", removed=count) + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/stickynotes/server_lowlevel.py b/examples/stories/stickynotes/server_lowlevel.py new file mode 100644 index 000000000..15a20a797 --- /dev/null +++ b/examples/stories/stickynotes/server_lowlevel.py @@ -0,0 +1,119 @@ +"""Capstone sticky-notes board on the lowlevel `Server`: handlers read lifespan state directly.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +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 Board: + notes: dict[str, str] = field(default_factory=dict[str, str]) + _next: int = 1 + + def claim_id(self) -> str: + nid, self._next = str(self._next), self._next + 1 + return nid + + +CONFIRM_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"confirm": {"type": "boolean", "title": "Yes, permanently delete every sticky note"}}, + "required": ["confirm"], +} + +TOOLS = [ + types.Tool( + name="add_note", + description="Add a sticky note.", + input_schema={"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}, + ), + types.Tool( + name="remove_note", + description="Remove one sticky note.", + input_schema={"type": "object", "properties": {"note_id": {"type": "string"}}, "required": ["note_id"]}, + ), + types.Tool(name="remove_all", description="Remove every note after confirmation.", input_schema={"type": "object"}), +] + + +def _result(text: str, structured: dict[str, Any]) -> types.CallToolResult: + return types.CallToolResult(content=[types.TextContent(text=text)], structured_content=structured) + + +def build_server() -> Server[Board]: + @asynccontextmanager + async def lifespan(_: Server[Board]) -> AsyncIterator[Board]: + yield Board() + + async def list_tools( + ctx: ServerRequestContext[Board], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=TOOLS) + + async def list_resources( + ctx: ServerRequestContext[Board], params: types.PaginatedRequestParams | None + ) -> types.ListResourcesResult: + board = ctx.lifespan_context + return types.ListResourcesResult( + resources=[ + types.Resource(uri=f"note:///{nid}", name=f"note-{nid}", mime_type="text/plain") for nid in board.notes + ] + ) + + async def read_resource( + ctx: ServerRequestContext[Board], params: types.ReadResourceRequestParams + ) -> types.ReadResourceResult: + board = ctx.lifespan_context + nid = str(params.uri).removeprefix("note:///") + return types.ReadResourceResult( + contents=[types.TextResourceContents(uri=params.uri, mime_type="text/plain", text=board.notes[nid])] + ) + + async def call_tool(ctx: ServerRequestContext[Board], params: types.CallToolRequestParams) -> types.CallToolResult: + board = ctx.lifespan_context + args = params.arguments or {} + if params.name == "add_note": + nid = board.claim_id() + board.notes[nid] = args["text"] + await ctx.session.send_resource_list_changed() + return _result(f"added #{nid}", {"id": nid, "uri": f"note:///{nid}"}) + if params.name == "remove_note": + removed = board.notes.pop(args["note_id"], None) is not None + if removed: + await ctx.session.send_resource_list_changed() + return _result("removed" if removed else "not found", {"result": removed}) + if params.name == "remove_all": + if not board.notes: + return _result("empty", {"status": "empty", "removed": 0}) + answer = await ctx.session.elicit_form( + f"Remove all {len(board.notes)} note(s)? This cannot be undone.", CONFIRM_SCHEMA, ctx.request_id + ) + if answer.action == "cancel": + return _result("cancelled", {"status": "cancelled", "removed": 0}) + if answer.action != "accept" or not (answer.content or {}).get("confirm"): + return _result("declined", {"status": "declined", "removed": 0}) + count = len(board.notes) + board.notes.clear() + await ctx.session.send_resource_list_changed() + return _result(f"cleared {count}", {"status": "cleared", "removed": count}) + raise NotImplementedError + + return Server( + "stickynotes-example", + lifespan=lifespan, + on_list_tools=list_tools, + on_call_tool=call_tool, + on_list_resources=list_resources, + on_read_resource=read_resource, + ) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/streaming/README.md b/examples/stories/streaming/README.md new file mode 100644 index 000000000..86e2e7478 --- /dev/null +++ b/examples/stories/streaming/README.md @@ -0,0 +1,80 @@ +# streaming + +The three in-flight server→client channels during a tool call: **progress** +(`ctx.report_progress` → the caller's `progress_callback=`), **logging** +(`notifications/message` → the client's `logging_callback=`), and +**cancellation** (abandoning the client's awaiting scope interrupts the server +handler). One `countdown(steps)` tool emits a progress notification and a log +line per step; the client asserts both streams arrive in order, then cancels a +long call mid-flight by cancelling the enclosing `anyio.CancelScope` from +inside the progress callback (event-driven, no `sleep`). + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.streaming.client +uv run python -m stories.streaming.client --server server_lowlevel + +# HTTP — the client self-hosts the server on a free port, runs, then tears it +# down (--legacy: see the note below) +uv run python -m stories.streaming.client --http --legacy +# same, against the lowlevel-API server variant +uv run python -m stories.streaming.client --http --legacy --server server_lowlevel +``` + +The modern HTTP leg (drop `--legacy`) is `xfail` until the SSE wiring lands — +mid-call progress and log notifications are currently dropped there (see +Caveats). + +## What to look at + +- `client.py` `main` — opens with `async with Client(target, mode=mode, + logging_callback=on_log)`. The story owns that construction; the harness only + picks the target and era. `logging_callback` is constructor-only on `Client` + (no setter after connect), so the callback and the `logs` list it fills are + closed over right above the `Client(...)` call. +- `server.py` — `ctx.report_progress(i, steps, msg)` is a silent no-op when the + caller passed no `progress_callback`; the SDK reads the token from the + request's `_meta` for you. The log notification is sent via the raw + `session.send_notification(...)` because the `ctx.log()` / `ctx.info()` + shorthands are deprecated (SEP-2577) with no non-deprecated replacement yet. + `related_request_id=` keeps the log on this request's response stream — over + streamable HTTP an unrelated notification would ride the standalone GET + stream instead. +- `server.py` — `ctx.request_context.session` / `ctx.request_context.request_id` + is the interim 2-hop path; a later release will shorten these. +- `server.py` — the `except anyio.get_cancelled_exc_class(): raise` block is + where a real handler would release resources before re-raising. **Never + swallow** the cancellation exception. +- `client.py` — cancellation is just cancelling the `anyio` scope around + `await client.call_tool(...)`; the SDK sends `notifications/cancelled` for + you on stateful transports. There is no `client.cancel(request_id)` API. +- `server_lowlevel.py` — the same wire contract built by hand against + `ServerRequestContext.session` directly. + +## Caveats + +- **Logging is deprecated** in the 2026-07-28 protocol (SEP-2577); functional + through the deprecation window. Migration: write to stderr or emit + OpenTelemetry instead of `notifications/message`. It is shown here because + servers still need to support 2025-era clients during that window. Progress + and cancellation are **not** deprecated. TODO(maxisbey): revisit before beta. +- On the modern (2026-07-28) streamable-HTTP path, mid-call progress and log + notifications are currently dropped pending the SSE wiring; the + `http-asgi:modern` leg of this story is `xfail` until that lands. +- When a request is cancelled the server currently replies with + `ErrorData(code=0, message="Request cancelled")`; the spec says it should not + reply at all. The client never observes it (its awaiting task is already + cancelled), so this story does not assert on the reply. + +## Spec + +[Progress](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress), +[cancellation](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation), +[logging](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging) + +## See also + +`parallel_calls/` (concurrent in-flight calls), `error_handling/` (the +cancellation error path), `tools/` (the basics this builds on). diff --git a/examples/stories/streaming/__init__.py b/examples/stories/streaming/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/streaming/client.py b/examples/stories/streaming/client.py new file mode 100644 index 000000000..e584b4c1e --- /dev/null +++ b/examples/stories/streaming/client.py @@ -0,0 +1,54 @@ +"""Asserts progress + log notifications arrive in order, then cancels a call mid-flight.""" + +import anyio +from mcp_types import LoggingMessageNotificationParams + +from mcp.client import Client +from stories._harness import Target, run_client + + +async def main(target: Target, *, mode: str = "auto") -> None: + # `logging_callback` is constructor-only on `Client`, so the list it fills + # has to exist before the connection does. + logs: list[LoggingMessageNotificationParams] = [] + + async def on_log(params: LoggingMessageNotificationParams) -> None: + logs.append(params) + + async with Client(target, mode=mode, logging_callback=on_log) as client: + # ── progress + logging: a short countdown delivers exactly `steps` of each, in order ── + updates: list[tuple[float, float | None, str | None]] = [] + + async def collect(progress: float, total: float | None, message: str | None) -> None: + updates.append((progress, total, message)) + + result = await client.call_tool("countdown", {"steps": 3}, progress_callback=collect) + assert result.structured_content == {"completed": 3, "total": 3}, result + assert updates == [(1.0, 3.0, "step 1/3"), (2.0, 3.0, "step 2/3"), (3.0, 3.0, "step 3/3")] + assert [(m.level, m.logger, m.data) for m in logs] == [ + ("info", "countdown", "step 1/3"), + ("info", "countdown", "step 2/3"), + ("info", "countdown", "step 3/3"), + ] + + # ── cancellation: abandon the awaiting scope once the call is provably in flight ── + in_flight = anyio.Event() + with anyio.fail_after(5): + with anyio.CancelScope() as scope: + + async def cancel_once_in_flight(progress: float, total: float | None, message: str | None) -> None: + in_flight.set() + scope.cancel() + + await client.call_tool("countdown", {"steps": 1_000}, progress_callback=cancel_once_in_flight) + + assert in_flight.is_set(), "the call must have started before it was cancelled" + assert scope.cancelled_caught, "abandoning the scope should have cancelled the in-flight call" + + # The session survives cancellation: a follow-up call still works. + after = await client.call_tool("countdown", {"steps": 1}, progress_callback=collect) + assert after.structured_content == {"completed": 1, "total": 1} + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/streaming/server.py b/examples/stories/streaming/server.py new file mode 100644 index 000000000..ced59878d --- /dev/null +++ b/examples/stories/streaming/server.py @@ -0,0 +1,40 @@ +"""Progress, in-flight logging, and cancellation from a single long-running tool.""" + +import anyio +import mcp_types as types + +from mcp.server.mcpserver import Context, MCPServer +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer("streaming-example") + + @mcp.tool() + async def countdown(steps: int, ctx: Context) -> dict[str, int]: + """Emit one progress + one log notification per step; observes cancellation.""" + try: + for i in range(1, steps + 1): + await ctx.report_progress(float(i), float(steps), f"step {i}/{steps}") + # No non-deprecated logging helper on Context yet, so send the raw + # notification. `related_request_id` keeps it on this request's response + # stream (matters over streamable HTTP). + await ctx.request_context.session.send_notification( + types.LoggingMessageNotification( + params=types.LoggingMessageNotificationParams( + level="info", logger="countdown", data=f"step {i}/{steps}" + ) + ), + related_request_id=ctx.request_context.request_id, + ) + except anyio.get_cancelled_exc_class(): + # The client abandoned the call. Release resources here, then re-raise so + # the dispatcher unwinds the request — never swallow cancellation. + raise + return {"completed": steps, "total": steps} + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/streaming/server_lowlevel.py b/examples/stories/streaming/server_lowlevel.py new file mode 100644 index 000000000..07daf641b --- /dev/null +++ b/examples/stories/streaming/server_lowlevel.py @@ -0,0 +1,69 @@ +"""Progress, in-flight logging, and cancellation against the low-level Server.""" + +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 + +COUNTDOWN_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"steps": {"type": "integer"}}, + "required": ["steps"], +} + + +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="countdown", + description="Emit one progress + one log notification per step; observes cancellation.", + input_schema=COUNTDOWN_INPUT_SCHEMA, + ) + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "countdown" and params.arguments is not None + steps = int(params.arguments["steps"]) + try: + for i in range(1, steps + 1): + await ctx.session.report_progress(float(i), float(steps), f"step {i}/{steps}") + await ctx.session.send_notification( + types.LoggingMessageNotification( + params=types.LoggingMessageNotificationParams( + level="info", logger="countdown", data=f"step {i}/{steps}" + ) + ), + related_request_id=ctx.request_id, + ) + except anyio.get_cancelled_exc_class(): + raise + return types.CallToolResult( + content=[types.TextContent(text=f"completed {steps}/{steps}")], + structured_content={"completed": steps, "total": steps}, + ) + + async def set_logging_level( + ctx: ServerRequestContext[Any], params: types.SetLevelRequestParams + ) -> types.EmptyResult: + """Registered so the server advertises the `logging` capability; never called.""" + raise NotImplementedError + + return Server( + "streaming-example", + on_list_tools=list_tools, + on_call_tool=call_tool, + on_set_logging_level=set_logging_level, + ) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/subscriptions/README.md b/examples/stories/subscriptions/README.md new file mode 100644 index 000000000..d41d0f82b --- /dev/null +++ b/examples/stories/subscriptions/README.md @@ -0,0 +1,27 @@ +# subscriptions + +The 2026-era `subscriptions/listen` channel: the server publishes change events +through a `ServerEventBus`, and `Client.listen()` opens an async iterator over +them. Replaces the handshake-era `resources/subscribe` + standalone-GET +notification path. + +**Status: not yet implemented** ([#2901](https://github.com/modelcontextprotocol/python-sdk/issues/2901)). +The lowlevel registration surface is in this base — +[#2967](https://github.com/modelcontextprotocol/python-sdk/pull/2967) +(`ae13ede`) added the lowlevel `on_subscriptions_listen` handler slot — but +there is no `Client.listen()` or `ServerEventBus` yet. The runnable story is +deliberately a follow-up PR to keep this one reviewable. + +## Spec + +[Subscriptions — basic utilities](https://modelcontextprotocol.io/specification/draft/basic/utilities/subscriptions) + +## Working example elsewhere + +The TypeScript SDK ships a runnable `subscriptions` story: +[typescript-sdk/examples/subscriptions](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/subscriptions). + +## See also + +`standalone_get/` (handshake-era server-initiated notifications), `resources/` +(legacy `subscribe` deliberately omitted). diff --git a/examples/stories/tasks/README.md b/examples/stories/tasks/README.md new file mode 100644 index 000000000..ef15ae63f --- /dev/null +++ b/examples/stories/tasks/README.md @@ -0,0 +1,16 @@ +# tasks + +The `io.modelcontextprotocol/tasks` extension: long-running work registered +with `@task`, polled via `tasks/get`, updated mid-flight, and cancelled with +`tasks/cancel`. The story will show a task that outlives the request that +started it. + +**Status: not yet implemented.** The extension types exist but the `extensions` +capability map is not yet surfaced on `MCPServer`, and the runtime trails the +release. The TypeScript SDK deliberately removed its tasks example pending the +same work. + +## Spec + +[Tasks — basic utilities](https://modelcontextprotocol.io/specification/draft/basic/utilities/tasks) +· [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133) diff --git a/examples/stories/tools/README.md b/examples/stories/tools/README.md new file mode 100644 index 000000000..996fa0c2b --- /dev/null +++ b/examples/stories/tools/README.md @@ -0,0 +1,39 @@ +# tools + +**Start here.** Register tools with `@mcp.tool()`; the SDK infers the JSON +input schema from type hints, the output schema from the return annotation, and +returns `structuredContent` alongside text. `ToolAnnotations` carries +behavioural hints (`readOnlyHint`, `idempotentHint`) the host can show to +users. The client lists tools, inspects schemas + annotations, calls both, and +asserts structured output. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.tools.client + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.tools.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.tools.client --http --server server_lowlevel +``` + +## What to look at + +- `server.py` `calc` — `Literal[...]` and `BaseModel` in the signature become + the tool's `inputSchema` / `outputSchema` with zero hand-written JSON. +- `server.py` `echo` — `structured_output=False` opts out of schema inference + for a plain text-only tool. +- `server_lowlevel.py` — the same wire contract built by hand: this is what + `MCPServer` generates for you. + +## Spec + +[Tools — server features](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) + +## See also + +`schema_validators/` (every input-schema source: pydantic / TypedDict / +dataclass / dict), `error_handling/` (`is_error` vs protocol error), +`streaming/` (progress mid-call). diff --git a/examples/stories/tools/__init__.py b/examples/stories/tools/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/stories/tools/client.py b/examples/stories/tools/client.py new file mode 100644 index 000000000..74e1ab4c0 --- /dev/null +++ b/examples/stories/tools/client.py @@ -0,0 +1,32 @@ +"""List tools, inspect schemas + annotations, call both tools, assert structured output.""" + +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() + by_name = {t.name: t for t in listed.tools} + assert set(by_name) == {"calc", "echo"} + + calc = by_name["calc"] + assert calc.annotations is not None and calc.annotations.read_only_hint is True + assert calc.annotations.idempotent_hint is True + assert calc.output_schema is not None + assert set(calc.input_schema.get("required", ())) >= {"op", "a", "b"} + assert by_name["echo"].output_schema is None + + result = await client.call_tool("calc", {"op": "add", "a": 2, "b": 3}) + assert not result.is_error + assert result.structured_content == {"op": "add", "result": 5.0}, result + + echoed = await client.call_tool("echo", {"text": "hi"}) + assert echoed.structured_content is None + assert isinstance(echoed.content[0], TextContent) and echoed.content[0].text == "hi" + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/tools/server.py b/examples/stories/tools/server.py new file mode 100644 index 000000000..a1f035c26 --- /dev/null +++ b/examples/stories/tools/server.py @@ -0,0 +1,37 @@ +"""Tools primitive: register, list, call, structured output, annotations.""" + +from typing import Literal + +from mcp_types import ToolAnnotations +from pydantic import BaseModel + +from mcp.server.mcpserver import MCPServer +from stories._hosting import run_server_from_args + + +class CalcResult(BaseModel): + op: str + result: float + + +def build_server() -> MCPServer: + mcp = MCPServer("tools-example") + + @mcp.tool( + title="Calculator", + description="Apply an arithmetic operation to two numbers.", + annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True), + ) + def calc(op: Literal["add", "sub", "mul"], a: float, b: float) -> CalcResult: + result = a + b if op == "add" else a - b if op == "sub" else a * b + return CalcResult(op=op, result=result) + + @mcp.tool(description="Echo the input back as plain text.", structured_output=False) + def echo(text: str) -> str: + return text + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/tools/server_lowlevel.py b/examples/stories/tools/server_lowlevel.py new file mode 100644 index 000000000..e6c4c05ef --- /dev/null +++ b/examples/stories/tools/server_lowlevel.py @@ -0,0 +1,72 @@ +"""Tools primitive (lowlevel API): hand-built Tool descriptors and CallToolResult.""" + +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 + +CALC_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "op": {"type": "string", "enum": ["add", "sub", "mul"]}, + "a": {"type": "number"}, + "b": {"type": "number"}, + }, + "required": ["op", "a", "b"], +} +CALC_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"op": {"type": "string"}, "result": {"type": "number"}}, + "required": ["op", "result"], +} +ECHO_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], +} + + +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="calc", + title="Calculator", + description="Apply an arithmetic operation to two numbers.", + input_schema=CALC_INPUT_SCHEMA, + output_schema=CALC_OUTPUT_SCHEMA, + annotations=types.ToolAnnotations(read_only_hint=True, idempotent_hint=True), + ), + types.Tool( + name="echo", + description="Echo the input back as plain text.", + input_schema=ECHO_INPUT_SCHEMA, + ), + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.arguments is not None + if params.name == "calc": + op, a, b = params.arguments["op"], float(params.arguments["a"]), float(params.arguments["b"]) + result = a + b if op == "add" else a - b if op == "sub" else a * b + payload = {"op": op, "result": result} + return types.CallToolResult( + content=[types.TextContent(text=f"{a} {op} {b} = {result}")], + structured_content=payload, + ) + if params.name == "echo": + return types.CallToolResult(content=[types.TextContent(text=str(params.arguments["text"]))]) + raise NotImplementedError + + return Server("tools-example", on_list_tools=list_tools, on_call_tool=call_tool) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/pyproject.toml b/pyproject.toml index 830fc9963..6922a9cf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,8 @@ build-constraint-dependencies = [ dev = [ # We add mcp[cli] so `uv sync` considers the extras. "mcp[cli]", + "mcp-example-stories", + "tomli>=2.0; python_version < '3.11'", "pyright>=1.1.400", "pytest>=8.4.0", "ruff>=0.8.5", @@ -135,12 +137,16 @@ include = [ "src/mcp", "src/mcp-types/mcp_types", "tests", + "examples/stories", "examples/servers", "examples/snippets", "examples/clients", ] venvPath = "." venv = ".venv" +# `stories` is a workspace package rooted at examples/; the IDE language server +# does not always pick up the editable-install .pth, so resolve it statically. +extraPaths = ["examples"] # The FastAPI style of using decorators in tests gives a `reportUnusedFunction` error. # See https://github.com/microsoft/pyright/issues/7771 for more details. # TODO(Marcelo): We should remove `reportPrivateUsage = false`. The idea is that we should test the workflow that uses @@ -149,8 +155,17 @@ venv = ".venv" executionEnvironments = [ { root = "tests", extraPaths = [ ".", + "examples", ], reportUnusedFunction = false, reportPrivateUsage = false }, - { root = "examples/servers", reportUnusedFunction = false }, + { root = "examples/stories", extraPaths = [ + "examples", + ], reportUnusedFunction = false }, + # The `mcp-example-stories` editable install puts `examples/` on sys.path, + # which defeats pyright's auto-detection of `simple-auth/` as a package + # root (it's the one server example that imports itself by absolute name). + { root = "examples/servers", extraPaths = [ + "examples/servers/simple-auth", + ], reportUnusedFunction = false }, ] [tool.ruff] @@ -194,10 +209,11 @@ max-returns = 13 # Default is 6 max-statements = 102 # Default is 50 [tool.uv.workspace] -members = ["src/mcp-types", "examples/clients/*", "examples/servers/*", "examples/snippets"] +members = ["src/mcp-types", "examples", "examples/clients/*", "examples/servers/*", "examples/snippets"] [tool.uv.sources] mcp = { workspace = true } +mcp-example-stories = { workspace = true } mcp-types = { workspace = true } strict-no-cover = { git = "https://github.com/pydantic/strict-no-cover" } diff --git a/src/mcp/server/elicitation.py b/src/mcp/server/elicitation.py index 066d33a1b..dc0e669c8 100644 --- a/src/mcp/server/elicitation.py +++ b/src/mcp/server/elicitation.py @@ -113,7 +113,7 @@ async def elicit_with_validation( return AcceptedElicitation(data=validated_data) elif result.action == "decline": return DeclinedElicitation() - elif result.action == "cancel": # pragma: no cover + elif result.action == "cancel": return CancelledElicitation() else: # pragma: no cover # This should never happen, but handle it just in case diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 8ad945b6a..a4fbc1005 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -561,7 +561,7 @@ def streamable_http_app( ) ) - if custom_starlette_routes: # pragma: no cover + if custom_starlette_routes: routes.extend(custom_starlette_routes) return Starlette( diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 15308eefd..fb64adae7 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -199,7 +199,7 @@ def __init__( self._token_verifier = token_verifier # Create token verifier from provider if needed (backwards compatibility) - if auth_server_provider and not token_verifier: # pragma: no cover + if auth_server_provider and not token_verifier: self._token_verifier = ProviderTokenVerifier(auth_server_provider) self._custom_starlette_routes: list[Route] = [] @@ -821,7 +821,7 @@ async def health_check(request: Request) -> Response: ``` """ - def decorator( # pragma: no cover + def decorator( func: Callable[[Request], Awaitable[Response]], ) -> Callable[[Request], Awaitable[Response]]: self._custom_starlette_routes.append( @@ -829,7 +829,7 @@ def decorator( # pragma: no cover ) return func - return decorator # pragma: no cover + return decorator async def run_stdio_async(self) -> None: """Run the server using stdio transport.""" diff --git a/tests/examples/__init__.py b/tests/examples/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/examples/conftest.py b/tests/examples/conftest.py new file mode 100644 index 000000000..ffe22caad --- /dev/null +++ b/tests/examples/conftest.py @@ -0,0 +1,170 @@ +"""Discovery + parametrization for the example-stories matrix. + +Reads ``examples/stories/manifest.toml`` and expands each story across +(server_variant × transport × era). The story modules are imported as +real packages (the ``mcp-example-stories`` workspace member installs ``stories`` +editable), so pyright sees them and a signature change red-lines every story. + +The HTTP-ASGI leg reuses the interaction suite's in-process bridge directly +from ``tests.interaction.transports._bridge`` (both live under ``tests/``); the +move to ``stories._shared.bridge`` is a later batch. +""" + +from __future__ import annotations + +import importlib +import sys +from collections.abc import AsyncIterator +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import httpx +import pytest +import stories +from mcp_types.version import LATEST_MODERN_VERSION +from starlette.applications import Starlette +from stories._harness import AuthBuilder, TargetFactory +from stories._hosting import asgi_from + +from mcp.client.streamable_http import streamable_http_client +from tests.interaction.transports._bridge import StreamingASGITransport + +if sys.version_info >= (3, 11): # pragma: lax no cover + import tomllib +else: # pragma: lax no cover + import tomli as tomllib + +STORIES_DIR = Path(stories.__file__).parent +BASE_URL = "http://127.0.0.1:8000" + +MANIFEST = tomllib.loads((STORIES_DIR / "manifest.toml").read_text()) +DEFAULTS: dict[str, Any] = MANIFEST["defaults"] +STORIES: dict[str, dict[str, Any]] = MANIFEST["story"] + +_ERA_TO_MODE = {"modern": LATEST_MODERN_VERSION, "legacy": "legacy", "in-body": "auto"} +"""``Client`` rejects handshake-era version strings, so ``legacy`` resolves to +``mode='legacy'`` rather than ``LATEST_HANDSHAKE_VERSION``. ``in-body`` legs pin +their connection modes inside ``main`` themselves, so they get ``"auto"`` — the +``Client`` default; the era axis still passes every ``mode=`` explicitly.""" + + +def story_cfg(name: str) -> dict[str, Any]: + return DEFAULTS | STORIES.get(name, {}) + + +def _expand_era(era: str) -> tuple[str, ...]: + if era == "dual": + return ("modern", "legacy") + if era == "dual-in-body": + return ("in-body",) + return (era,) + + +@dataclass(frozen=True) +class Leg: + story: str + server_variant: str + transport: str + era: str + + @property + def id(self) -> str: + return "-".join((self.story, self.server_variant, self.transport, self.era)) + + @property + def mode(self) -> str: + """The explicit ``mode=`` this leg passes to the story's ``main``.""" + return _ERA_TO_MODE[self.era] + + +def _legs() -> list[tuple[Leg, dict[str, Any]]]: + out: list[tuple[Leg, dict[str, Any]]] = [] + for name in STORIES: + cfg = story_cfg(name) + variants = ["server"] + (["server_lowlevel"] if cfg["lowlevel"] else []) + out.extend( + (Leg(name, variant, transport, era), cfg) + for variant in variants + for transport in cfg["transports"] + for era in _expand_era(cfg["era"]) + ) + return out + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + if "leg" not in metafunc.fixturenames: + return + params: list[Any] = [] + for leg, cfg in _legs(): + marks: list[pytest.MarkDecorator] = [] + if f"{leg.transport}:{leg.era}" in cfg["xfail"]: + marks.append(pytest.mark.xfail(strict=True, reason="manifest xfail")) + params.append(pytest.param(leg, marks=marks, id=leg.id)) + metafunc.parametrize("leg", params) + + +@pytest.fixture +def cfg(leg: Leg) -> dict[str, Any]: + return story_cfg(leg.story) + + +@pytest.fixture +def server_module(leg: Leg) -> Any: + return importlib.import_module(f"stories.{leg.story}.{leg.server_variant}") + + +@pytest.fixture +def client_module(leg: Leg) -> Any: + return importlib.import_module(f"stories.{leg.story}.client") + + +@dataclass +class Hosted: + """One server/app instance hosted for the leg's whole duration. + + ``targets`` yields a fresh connection target against that single instance on + every call, so state observed by one connection is visible to the next. + ``http`` is the shared raw ``httpx.AsyncClient`` bound to the same ASGI app, + or ``None`` on the in-memory leg. + """ + + targets: TargetFactory + http: httpx.AsyncClient | None + + +@pytest.fixture +async def hosted( + leg: Leg, cfg: dict[str, Any], server_module: Any, client_module: Any, monkeypatch: pytest.MonkeyPatch +) -> AsyncIterator[Hosted]: + """Build the leg's server/app once and keep it running for the test. + + The story's ``main`` owns the ``Client(target, mode=...)`` construction; this + fixture only decides what ``target`` is. Auth stories thread an ``httpx.Auth`` + onto the bridge client via a module-level ``build_auth(http)`` export. + """ + for key, value in cfg["env"].items(): + monkeypatch.setenv(key, value) + path = cfg["mcp_path"] + + if leg.transport == "in-memory": + server = server_module.build_server() + yield Hosted(lambda: server, None) + return + + # http-asgi: one Starlette app per leg. ``server_export="app"`` stories hand us the + # app directly; ``"factory"`` stories are wrapped via ``asgi_from``. Either way the + # app's own lifespan is what brings the session manager up, and the in-process + # bridge never fires ASGI lifespan events itself, so enter it explicitly. + if cfg["server_export"] == "app": + app: Starlette = server_module.build_app() + else: + app = asgi_from(server_module.build_server(), path=path) + build_auth: AuthBuilder | None = getattr(client_module, "build_auth", None) + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http_client, + ): + if build_auth is not None: + http_client.auth = build_auth(http_client) + yield Hosted(lambda: streamable_http_client(f"{BASE_URL}{path}", http_client=http_client), http_client) diff --git a/tests/examples/test_stories.py b/tests/examples/test_stories.py new file mode 100644 index 000000000..f56106a79 --- /dev/null +++ b/tests/examples/test_stories.py @@ -0,0 +1,74 @@ +"""Run every story's ``main`` over the in-process (transport × era × variant) matrix.""" + +from __future__ import annotations + +import importlib +import inspect +from typing import Any + +import anyio +import pytest + +from tests.examples.conftest import MANIFEST, STORIES, STORIES_DIR, Hosted, Leg, story_cfg + +pytestmark = pytest.mark.anyio + + +async def test_story(leg: Leg, cfg: dict[str, Any], hosted: Hosted, client_module: Any) -> None: + kwargs: dict[str, Any] = {"mode": leg.mode} + if cfg["needs_http"]: + kwargs["http"] = hosted.http + with anyio.fail_after(cfg["timeout_s"]): + if cfg["multi_connection"]: + await client_module.main(hosted.targets, **kwargs) + else: + await client_module.main(hosted.targets(), **kwargs) + + +def test_manifest_matches_filesystem() -> None: + """Manifest [story.*] / [deferred] keys and on-disk story directories agree exactly.""" + dirs = {d.name for d in STORIES_DIR.iterdir() if d.is_dir() and not d.name.startswith(("_", "."))} + runnable = {d for d in dirs if (STORIES_DIR / d / "client.py").exists()} + in_manifest = set(STORIES) + assert runnable == in_manifest, {"only_on_disk": runnable - in_manifest, "only_in_manifest": in_manifest - runnable} + # README-only stub dirs must be exactly the [deferred] table. + deferred_manifest = set(MANIFEST.get("deferred", {})) + assert dirs - runnable == deferred_manifest, { + "stub_dirs_missing_from_manifest": (dirs - runnable) - deferred_manifest, + "deferred_entries_missing_dir": deferred_manifest - (dirs - runnable), + } + assert runnable.isdisjoint(deferred_manifest), "deferred stories must not have a client.py" + + +_ERAS = {"dual", "modern", "legacy", "dual-in-body"} +_TRANSPORTS = {"in-memory", "http-asgi"} +_SERVER_EXPORTS = {"factory", "app"} + + +def test_manifest_schema_valid() -> None: + """Declared manifest values are mutually consistent with the story files.""" + for name in STORIES: + cfg = story_cfg(name) + assert "-" not in name, f"{name!r}: story directories must be underscored" + assert cfg["era"] in _ERAS, f"{name!r}: era={cfg['era']!r} not in {_ERAS}" + assert cfg["server_export"] in _SERVER_EXPORTS, f"{name!r}: server_export={cfg['server_export']!r}" + assert set(cfg["transports"]) <= _TRANSPORTS, f"{name!r}: transports={cfg['transports']!r}" + assert (STORIES_DIR / name / "__init__.py").exists(), f"{name!r}: missing __init__.py" + if cfg["server_export"] == "factory": + assert (STORIES_DIR / name / "server.py").exists(), f"{name!r}: missing server.py" + else: + assert "in-memory" not in cfg["transports"], f"{name!r}: server_export='app' cannot run in-memory" + if cfg["needs_http"]: + assert cfg["transports"] == ["http-asgi"], f"{name!r}: needs_http requires transports=['http-asgi']" + ll = STORIES_DIR / name / "server_lowlevel.py" + assert cfg["lowlevel"] == ll.exists(), f"{name!r}: lowlevel={cfg['lowlevel']} vs server_lowlevel.py on disk" + + +@pytest.mark.parametrize("name", sorted(STORIES)) +def test_main_signature_matches_manifest(name: str) -> None: + """``main``'s first parameter is ``target``/``targets`` per ``multi_connection``; ``http`` iff ``needs_http``.""" + cfg = story_cfg(name) + params = list(inspect.signature(importlib.import_module(f"stories.{name}.client").main).parameters) + first = "targets" if cfg["multi_connection"] else "target" + assert params[0] == first, f"{name}: first param is {params[0]!r}, expected {first!r}" + assert ("http" in params) == cfg["needs_http"], f"{name}: 'http' param vs needs_http={cfg['needs_http']}" diff --git a/tests/examples/test_stories_smoke.py b/tests/examples/test_stories_smoke.py new file mode 100644 index 000000000..ed8a26ea4 --- /dev/null +++ b/tests/examples/test_stories_smoke.py @@ -0,0 +1,57 @@ +"""Subprocess smoke for the story ``__main__`` paths. + +The in-process matrix in ``test_stories.py`` never executes a story's +``if __name__ == "__main__"`` block, so ``run_client`` / ``run_server_from_args`` / +``run_app_from_args`` and the real stdio + uvicorn entries are unverified by +construction. This file proves that plumbing by running the literal commands the +story READMEs print: stdio (``run_client`` spawns the server over stdio) and bare +``--http`` (``run_client`` self-hosts the server on a real uvicorn socket on a +port it owns, then terminates it). + +lax no cover: gated on ``MCP_EXAMPLES_SMOKE=1``, which CI sets on exactly one +matrix cell (ubuntu / 3.12 / locked — see ``shared.yml``). Every other cell +skips at collection, so the test body is uncovered there and the per-job 100% +gate would otherwise fail. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import anyio +import pytest + +pytestmark = [ + pytest.mark.anyio, + pytest.mark.skipif( + os.environ.get("MCP_EXAMPLES_SMOKE") != "1", + reason="subprocess smoke runs on one CI cell only; set MCP_EXAMPLES_SMOKE=1", + ), +] + +_REPO_ROOT = Path(__file__).parents[2] +# httpx in the spawned client honours these and tries to mount a SOCKS transport even for +# 127.0.0.1; strip them so the smoke run is hermetic regardless of the caller's shell. +_PROXY_VARS = {v for base in ("all_proxy", "http_proxy", "https_proxy", "ftp_proxy") for v in (base, base.upper())} +_ENV = {k: v for k, v in os.environ.items() if k not in _PROXY_VARS} + + +@pytest.mark.parametrize( + "argv", + [ + ("stories.tools.client",), + ("stories.tools.client", "--http"), + ("stories.bearer_auth.client", "--http"), + ], + ids=["tools-stdio", "tools-http", "bearer_auth-http"], +) +async def test_story_main_runs_end_to_end(argv: tuple[str, ...]) -> None: # pragma: lax no cover + """``python -m .client [--http]`` (the README command) exits 0 over a real subprocess.""" + with anyio.fail_after(60): + async with await anyio.open_process( + [sys.executable, "-m", *argv], cwd=_REPO_ROOT, env=_ENV, stdout=None, stderr=None + ) as proc: + await proc.wait() + assert proc.returncode == 0 diff --git a/tests/examples/test_story_shape.py b/tests/examples/test_story_shape.py new file mode 100644 index 000000000..d5510923c --- /dev/null +++ b/tests/examples/test_story_shape.py @@ -0,0 +1,122 @@ +"""AST shape-check: stories keep the SDK construction visible and the harness contained. + +The python analogue of typescript-sdk's eslint import-allowlist over its examples, +strictly stronger: it also asserts each ``main`` constructs ``Client(...)`` itself — +the regression the harness inversion exists to prevent. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +from tests.examples.conftest import STORIES, STORIES_DIR, story_cfg + +_HARNESS_ALLOWLIST = frozenset({"run_client", "target_from_args", "Target", "TargetFactory"}) +"""The only ``stories._harness`` names a ``client.py`` may use. ``AuthBuilder`` is +additionally allowed in a ``client.py`` that defines ``build_auth`` (the auth seam +``run_client`` and the conftest both look up by name).""" + +_MCPSERVER_TIER = ("mcp.server.mcpserver", "mcp.server.MCPServer") +"""Both spellings of the high-level tier: the ``mcpserver`` module and its ``mcp.server`` re-export.""" + +_LOWLEVEL_STORIES = [name for name in sorted(STORIES) if story_cfg(name)["lowlevel"]] + + +def _parse(path: Path) -> ast.Module: + """Parse ``path`` into an AST module.""" + return ast.parse(path.read_text(), filename=str(path)) + + +def _resolve(node: ast.ImportFrom, package: str) -> str: + """The absolute module path ``node`` imports from, resolving a relative import against ``package``.""" + parents = package.split(".")[: -(node.level - 1) or None] if node.level else [] + return ".".join([*parents, *([node.module] if node.module else [])]) + + +def _module_paths(tree: ast.Module, package: str) -> set[str]: + """Every dotted module path the file (a module in ``package``) references — imports, with relative + ones resolved to absolute, plus attribute chains rooted at an import-bound name (``import mcp.shared`` + + ``mcp.shared._memory.f()``), so a reach-in is caught however it is spelled.""" + paths: set[str] = set() + bound: dict[str, str] = {} + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + paths.add(alias.name) + local = alias.asname or alias.name.partition(".")[0] + bound[local] = alias.name if alias.asname else local + elif isinstance(node, ast.ImportFrom): + module = _resolve(node, package) + for alias in node.names: + paths.add(f"{module}.{alias.name}") + bound[alias.asname or alias.name] = f"{module}.{alias.name}" + for node in ast.walk(tree): + attrs: list[str] = [] + expr: ast.AST = node + while isinstance(expr, ast.Attribute): + attrs.append(expr.attr) + expr = expr.value + if attrs and isinstance(expr, ast.Name) and expr.id in bound: + paths.add(".".join([bound[expr.id], *reversed(attrs)])) + return paths + + +def _is_private_mcp(path: str) -> bool: + """True when ``path`` crosses a ``_``-private segment inside the ``mcp`` package.""" + head, *rest = path.split(".") + return head == "mcp" and any(part.startswith("_") for part in rest) + + +def _is_story_module(path: str) -> bool: + """True for ``stories....`` — a story package, not a ``stories._*`` scaffold.""" + head, _, rest = path.partition(".") + return head == "stories" and bool(rest) and not rest.startswith("_") + + +@pytest.mark.parametrize("name", sorted(STORIES)) +def test_main_constructs_client_inline(name: str) -> None: + """``main``'s body contains a literal ``Client(...)`` call; the construction is never hidden in a helper.""" + tree = _parse(STORIES_DIR / name / "client.py") + mains = [n for n in tree.body if isinstance(n, ast.AsyncFunctionDef) and n.name == "main"] + assert mains, f"{name}/client.py defines no top-level async `main`" + calls = {n.func.id for n in ast.walk(mains[0]) if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)} + assert "Client" in calls, f"{name}/client.py: main() never calls Client(...) itself" + + +@pytest.mark.parametrize("name", sorted(STORIES)) +def test_client_harness_imports_within_allowlist(name: str) -> None: + """``client.py`` takes nothing from ``stories._harness`` beyond the allowlist, bounding the harness surface.""" + tree = _parse(STORIES_DIR / name / "client.py") + defines_build_auth = any(isinstance(n, ast.FunctionDef) and n.name == "build_auth" for n in tree.body) + allowed = _HARNESS_ALLOWLIST | {"AuthBuilder"} if defines_build_auth else _HARNESS_ALLOWLIST + paths = _module_paths(tree, package=f"stories.{name}") + used = {p.removeprefix("stories._harness.").partition(".")[0] for p in paths if p.startswith("stories._harness.")} + assert used <= allowed, f"{name}/client.py uses {sorted(used - allowed)} from stories._harness" + + +@pytest.mark.parametrize("name", sorted(STORIES)) +def test_story_files_import_no_private_mcp_module(name: str) -> None: + """No file in a story directory references a ``_``-private ``mcp.*`` module.""" + for path in sorted((STORIES_DIR / name).glob("*.py")): + private = sorted(p for p in _module_paths(_parse(path), package=f"stories.{name}") if _is_private_mcp(p)) + assert not private, f"{path.relative_to(STORIES_DIR)} reaches into private mcp module(s): {private}" + + +@pytest.mark.parametrize("name", _LOWLEVEL_STORIES) +def test_server_lowlevel_imports_no_mcpserver_tier(name: str) -> None: + """``server_lowlevel.py`` stays on the lowlevel tier; it never references ``MCPServer`` or its module.""" + paths = _module_paths(_parse(STORIES_DIR / name / "server_lowlevel.py"), package=f"stories.{name}") + high = sorted(p for p in paths if any(f"{p}.".startswith(f"{tier}.") for tier in _MCPSERVER_TIER)) + assert not high, f"{name}/server_lowlevel.py references the MCPServer tier: {high}" + + +@pytest.mark.parametrize("scaffold", ["_harness.py", "_hosting.py"]) +def test_scaffold_imports_no_story_module(scaffold: str) -> None: + """The dependency is one-way: ``_harness.py`` / ``_hosting.py`` import no ``stories.`` module.""" + story_refs = sorted( + p for p in _module_paths(_parse(STORIES_DIR / scaffold), package="stories") if _is_story_module(p) + ) + assert not story_refs, f"{scaffold} imports a story module: {story_refs}" diff --git a/uv.lock b/uv.lock index b6dbdd9e0..a1e8a7e35 100644 --- a/uv.lock +++ b/uv.lock @@ -10,6 +10,7 @@ resolution-markers = [ members = [ "mcp", "mcp-everything-server", + "mcp-example-stories", "mcp-simple-auth", "mcp-simple-auth-client", "mcp-simple-chatbot", @@ -943,6 +944,7 @@ dev = [ { name = "inline-snapshot" }, { name = "logfire" }, { name = "mcp", extra = ["cli"] }, + { name = "mcp-example-stories" }, { name = "opentelemetry-sdk" }, { name = "pillow" }, { name = "pyright" }, @@ -953,6 +955,7 @@ dev = [ { name = "pytest-xdist" }, { name = "ruff" }, { name = "strict-no-cover" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "trio" }, ] docs = [ @@ -998,6 +1001,7 @@ dev = [ { name = "inline-snapshot", specifier = ">=0.23.0" }, { name = "logfire", specifier = ">=3.0.0" }, { name = "mcp", extras = ["cli"], editable = "." }, + { name = "mcp-example-stories", editable = "examples" }, { name = "opentelemetry-sdk", specifier = ">=1.39.1" }, { name = "pillow", specifier = ">=12.0" }, { name = "pyright", specifier = ">=1.1.400" }, @@ -1008,6 +1012,7 @@ dev = [ { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "ruff", specifier = ">=0.8.5" }, { name = "strict-no-cover", git = "https://github.com/pydantic/strict-no-cover" }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" }, { name = "trio", specifier = ">=0.26.2" }, ] docs = [ @@ -1044,7 +1049,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, + { name = "mcp" }, { name = "starlette" }, { name = "uvicorn" }, ] @@ -1056,6 +1061,21 @@ dev = [ { name = "ruff", specifier = ">=0.6.9" }, ] +[[package]] +name = "mcp-example-stories" +version = "0.0.0" +source = { editable = "examples" } +dependencies = [ + { name = "mcp" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] + +[package.metadata] +requires-dist = [ + { name = "mcp", editable = "." }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" }, +] + [[package]] name = "mcp-simple-auth" version = "0.1.0" @@ -1083,7 +1103,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, + { name = "mcp" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pydantic-settings", specifier = ">=2.5.2" }, { name = "sse-starlette", specifier = ">=1.6.1" }, @@ -1116,7 +1136,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.2.0" }, - { name = "mcp", editable = "." }, + { name = "mcp" }, ] [package.metadata.requires-dev] @@ -1145,7 +1165,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "mcp", editable = "." }, + { name = "mcp" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "uvicorn", specifier = ">=0.32.1" }, ] @@ -1180,7 +1200,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, + { name = "mcp" }, ] [package.metadata.requires-dev] @@ -1213,7 +1233,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, + { name = "mcp" }, ] [package.metadata.requires-dev] @@ -1246,7 +1266,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, + { name = "mcp" }, ] [package.metadata.requires-dev] @@ -1281,7 +1301,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, + { name = "mcp" }, { name = "starlette" }, { name = "uvicorn" }, ] @@ -1318,7 +1338,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, + { name = "mcp" }, { name = "starlette" }, { name = "uvicorn" }, ] @@ -1353,7 +1373,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, + { name = "mcp" }, ] [package.metadata.requires-dev] @@ -1372,7 +1392,7 @@ dependencies = [ ] [package.metadata] -requires-dist = [{ name = "mcp", editable = "." }] +requires-dist = [{ name = "mcp" }] [[package]] name = "mcp-sse-polling-client" @@ -1393,7 +1413,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.2.0" }, - { name = "mcp", editable = "." }, + { name = "mcp" }, ] [package.metadata.requires-dev] @@ -1428,7 +1448,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, + { name = "mcp" }, { name = "starlette" }, { name = "uvicorn" }, ] @@ -1449,7 +1469,7 @@ dependencies = [ ] [package.metadata] -requires-dist = [{ name = "mcp", editable = "." }] +requires-dist = [{ name = "mcp" }] [[package]] name = "mcp-types"