Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/shared.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 17 additions & 4 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# 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.

For real-world servers see the
[servers repository](https://github.com/modelcontextprotocol/servers).
13 changes: 13 additions & 0 deletions examples/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[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"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["stories"]
105 changes: 105 additions & 0 deletions examples/stories/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# 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.

## 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

# against a running HTTP server
uv run python -m stories.tools.server --http --port 8000 &
uv run python -m stories.tools.client --http http://127.0.0.1:8000/mcp
```

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 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()` default posture; the one-liner deploy | current |
| [`json_response`](json_response/) | `json_response=True` mode; raw 2026 POST envelope on the wire | current |
| [`legacy_routing`](legacy_routing/) | `classify_inbound_request()` era routing in front of a sessionful 1.x deploy | current |
| [`starlette_mount`](starlette_mount/) | mounting `streamable_http_app()` under a Starlette/FastAPI sub-path | current |
| [`sse_polling`](sse_polling/) | SEP-1699 `closeSSE()` + `Last-Event-ID` resume via `EventStore` | legacy |
| [`standalone_get`](standalone_get/) | server-initiated `list_changed` over the sessionful GET stream | legacy |
| [`reconnect`](reconnect/) | explicit `discover()`, persist `DiscoverResult`, zero-RTT reconnect | current |
| [`bearer_auth`](bearer_auth/) | `TokenVerifier` + `AuthSettings` bearer gate, PRM metadata, `get_access_token()` | current |
| [`oauth`](oauth/) | full `authorization_code` grant against an in-process AS | current |
| [`oauth_client_credentials`](oauth_client_credentials/) | `client_credentials` grant; minimal in-process token endpoint | current |
| **— deferred (README only) —** | | |
| [`caching`](caching/) | `CacheableResult` ttl/scope hints; client honouring | not yet implemented |
| [`mrtr`](mrtr/) | `InputRequiredResult` round-trip with `requestState` HMAC | not yet implemented — [#2898](https://github.com/modelcontextprotocol/python-sdk/issues/2898) |
| [`subscriptions`](subscriptions/) | `subscriptions/listen`, `ServerEventBus`, `Client.listen()` | not yet implemented — [#2901](https://github.com/modelcontextprotocol/python-sdk/issues/2901) |
| [`tasks`](tasks/) | `io.modelcontextprotocol/tasks` extension | not yet implemented |
| [`apps`](apps/) | MCP Apps: `ui://` resource + `_meta.ui` | not yet implemented — [#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896) |
| [`skills`](skills/) | SEP-2640 skills extension | not yet implemented — [#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896) |
| [`events`](events/) | `io.modelcontextprotocol/events` extension | not yet implemented |

The TypeScript SDK's `repl`, `client-quickstart`, and `server-quickstart`
examples are intentionally not ported (interactive / external network deps);
its `hono` example maps to `starlette_mount/`.
6 changes: 6 additions & 0 deletions examples/stories/__init__.py
Original file line number Diff line number Diff line change
@@ -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.
"""
136 changes: 136 additions & 0 deletions examples/stories/_harness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""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 sys
import traceback
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any, TypeAlias
from urllib.parse import urlsplit

import anyio
import httpx

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
from mcp.shared.version import LATEST_MODERN_VERSION

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) -> TargetFactory:
"""Build a ``TargetFactory`` for the sibling server over the argv-selected transport.

``--http <url>`` targets that streamable-HTTP URL; ``--stdio`` (the default) spawns
the sibling ``server.py`` as a fresh subprocess on each call. ``--server <stem>``
selects ``<stem>.py`` (e.g. ``server_lowlevel``). ``file`` is the caller's ``__file__``.
"""
if "--http" in sys.argv:
url = argv_after("--http")
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 _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``.

Builds the argv-selected target(s) for the story that defines ``main``, picks the
era from argv, and calls ``main`` with an explicit ``mode=``. If the story module
exports ``build_auth``, the ``--http`` target is routed through an ``httpx.AsyncClient``
that carries the returned ``httpx.Auth``. Prints ``OK:``/``FAIL:`` to stderr, exits 0/1.
"""
globals_ = getattr(main, "__globals__", {})
file = str(globals_.get("__file__", "<unknown>"))
name = Path(file).parent.name
cfg = _story_cfg(name)
targets = target_from_args(file)
build_auth: AuthBuilder | None = globals_.get("build_auth")
transport = "http" if "--http" in sys.argv else "stdio"
# Never rely on the SDK's mode= default — be explicit. 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 the
# real-user "auto" 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"]):
if not cfg["needs_http"] and (build_auth is None or transport != "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.
if transport != "http":
raise SystemExit(f"{name} asserts on raw HTTP responses; run it with --http <url>")
url = argv_after("--http")
parts = urlsplit(url)
async with httpx.AsyncClient(base_url=f"{parts.scheme}://{parts.netloc}") as http:
make = targets
if build_auth is not None:
http.auth = build_auth(http)
make = _authed_targets(url, http)
target: Any = make if cfg["multi_connection"] else make()
if cfg["needs_http"]:
await main(target, mode=mode, http=http)
else:
await main(target, mode=mode)

try:
anyio.run(_run)
except Exception:
print(f"FAIL: {name} ({transport}/{era})", file=sys.stderr)
traceback.print_exc()
raise SystemExit(1) from None
print(f"OK: {name} ({transport}/{era})", file=sys.stderr)
raise SystemExit(0)
87 changes: 87 additions & 0 deletions examples/stories/_hosting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Server-side hosting scaffold for story examples.

A story's ``server.py`` / ``server_lowlevel.py`` imports only from here. The
marked lines touch entry-point APIs that a later release reshapes into
free-function entries; isolating them here keeps story bodies stable.
"""

from __future__ import annotations

import sys
from collections.abc import Callable
from typing import Any, TypeAlias

import anyio
import uvicorn
from starlette.applications import Starlette

from mcp.server.lowlevel import Server
from mcp.server.mcpserver import MCPServer
from mcp.server.stdio import stdio_server
from mcp.server.transport_security import TransportSecuritySettings

AnyServer: TypeAlias = "MCPServer | Server[Any]"
ServerFactory = Callable[[], AnyServer]
AppFactory = Callable[[], Starlette]

NO_DNS_REBIND = TransportSecuritySettings(enable_dns_rebinding_protection=False)
"""Harness servers bind 127.0.0.1 and the in-process httpx client sends no Origin header."""


def argv_after(flag: str, *, default: str | None = None) -> str:
"""Return the argv token following ``flag``, or ``default`` when the flag is absent."""
try:
return sys.argv[sys.argv.index(flag) + 1]
except ValueError:
if default is None:
raise SystemExit(f"missing required {flag}") from None
return default


def asgi_from(server: AnyServer, *, path: str = "/mcp") -> Starlette:
"""Wrap a server instance in its streamable-HTTP ASGI app for in-process driving."""
return server.streamable_http_app( # becomes free fn streamable_http(server, legacy=...)
streamable_http_path=path,
stateless_http=False, # bool folds into a legacy= enum in a later release
transport_security=NO_DNS_REBIND,
)


def run_server_from_args(build_server: ServerFactory) -> None:
"""Entry point for ``if __name__ == "__main__"`` in every ``server*.py``.

Bare argv serves over stdio; ``--http --port N [--path /mcp]`` serves over
uvicorn on 127.0.0.1:N.
"""
server = build_server()
if "--http" in sys.argv:
port = int(argv_after("--port", default="8000"))
path = argv_after("--path", default="/mcp")
anyio.run(_serve_http, server, port, path)
else:
anyio.run(_serve_stdio, server)


async def _serve_stdio(server: AnyServer) -> None:
if isinstance(server, MCPServer):
await server.run_stdio_async() # becomes await serve_stdio(server)
else:
async with stdio_server() as (read, write): # becomes await serve_stdio(server)
await server.run(read, write, server.create_initialization_options())


async def _serve_http(server: AnyServer, port: int, path: str) -> None:
app = asgi_from(server, path=path)
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
await uvicorn.Server(config).serve()


def run_app_from_args(build_app: AppFactory) -> None:
"""Entry point for ``if __name__ == "__main__"`` in app-exporting ``server*.py``.

App-exporting stories are HTTP-only; ``--port N`` serves the Starlette app over
uvicorn on 127.0.0.1:N (uvicorn drives the app's own lifespan). No stdio leg.
"""
port = int(argv_after("--port", default="8000"))
config = uvicorn.Config(build_app(), host="127.0.0.1", port=port, log_level="error")
anyio.run(uvicorn.Server(config).serve)
1 change: 1 addition & 0 deletions examples/stories/_shared/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Shared scaffolding the auth/hosting stories import (not teaching surface)."""
Loading
Loading