Skip to content

Commit d1810cf

Browse files
committed
Add story-style examples suite (29 stories + harness + CI)
- examples/stories/: 29 narrative example scripts, each a self-contained client+server scenario that can be read top-to-bottom and run directly - examples/pyproject.toml: workspace member so stories resolve against the in-tree SDK - tests/examples/: pytest harness that imports and runs every story under the existing coverage gate (in-memory, no subprocesses) - .github/workflows/shared.yml: wire the stories harness into the test job - pyproject.toml / uv.lock: register examples workspace member
1 parent 1b1abf6 commit d1810cf

166 files changed

Lines changed: 6686 additions & 385 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/shared.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,10 @@ jobs:
7979

8080
- name: Run pytest with coverage
8181
shell: bash
82+
env:
83+
# tests/examples/test_stories_smoke.py is gated on this var; it spawns real
84+
# stdio + uvicorn subprocesses, so run it on exactly one matrix cell.
85+
MCP_EXAMPLES_SMOKE: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' && matrix.dep-resolution.name == 'locked' && '1' || '' }}
8286
run: |
8387
uv run --frozen --no-sync coverage erase
8488
uv run --frozen --no-sync coverage run -m pytest -n auto

examples/README.md

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
1-
# Python SDK Examples
1+
# Python SDK examples
22

3-
This folders aims to provide simple examples of using the Python SDK. Please refer to the
4-
[servers repository](https://github.com/modelcontextprotocol/servers)
5-
for real-world servers.
3+
- [`stories/`](stories/)**the canonical reference.** One self-verifying
4+
example per protocol feature, each with its own README. Start with
5+
[`stories/tools/`](stories/tools/); the [stories README](stories/README.md)
6+
has the full table and how to run them.
7+
- [`snippets/`](snippets/) — short extracts embedded into `README.v2.md`. Kept
8+
minimal and in sync with the top-level README; not intended to be run
9+
standalone.
10+
- [`servers/everything-server/`](servers/everything-server/) — the conformance
11+
target for the cross-SDK
12+
[conformance suite](https://github.com/modelcontextprotocol/conformance).
13+
Exercises every server capability in one process.
14+
- [`mcpserver/`](mcpserver/) — single-file v1-era examples retained for the
15+
migration guide; superseded by `stories/` and slated for removal.
16+
17+
For real-world servers see the
18+
[servers repository](https://github.com/modelcontextprotocol/servers).

examples/pyproject.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[project]
2+
name = "mcp-example-stories"
3+
version = "0.0.0"
4+
description = "Self-verifying example suite for the MCP Python SDK (dev-only, not published)"
5+
requires-python = ">=3.10"
6+
dependencies = ["mcp"]
7+
8+
[build-system]
9+
requires = ["hatchling"]
10+
build-backend = "hatchling.build"
11+
12+
[tool.hatch.build.targets.wheel]
13+
packages = ["stories"]

examples/stories/README.md

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Story examples
2+
3+
One feature per folder. Each story is a small, self-verifying program: a
4+
`server.py` (plus, where the wire contract is worth seeing by hand, a
5+
`server_lowlevel.py`) and a `client.py` whose `scenario(client)` makes
6+
assertions and exits non-zero on failure. The code you read here is the same
7+
code CI runs — there is no separate test double.
8+
9+
## Running a story
10+
11+
From the repository root:
12+
13+
```bash
14+
# stdio (default — the client spawns the server as a subprocess)
15+
uv run python -m stories.tools.client
16+
17+
# against a running HTTP server
18+
uv run python -m stories.tools.server --http --port 8000 &
19+
uv run python -m stories.tools.client --http http://127.0.0.1:8000/mcp
20+
```
21+
22+
The full matrix (every story × transport × era × server-variant) runs under
23+
pytest:
24+
25+
```bash
26+
uv run --frozen pytest tests/examples/ # everything
27+
uv run --frozen pytest tests/examples/ -k tools # one story
28+
```
29+
30+
[`manifest.toml`](manifest.toml) declares each story's transports, era, and
31+
variants; `tests/examples/` expands it.
32+
33+
## Layout
34+
35+
`_harness.py` and `_hosting.py` are scaffolding that adapts a story's
36+
`build_server()` / `build_app()` to argv (stdio vs `--http`) and to the
37+
in-process test bridge. They isolate the parts of the SDK's hosting surface
38+
that are still moving — **don't copy them into your own project**; copy the
39+
`server.py` / `client.py` bodies instead. `_shared/` holds an in-process OAuth
40+
authorization server reused by the auth stories.
41+
42+
## Stories
43+
44+
| story | what it shows | status |
45+
|---|---|---|
46+
| **— start here —** | | |
47+
| [`tools`](tools/) | `@mcp.tool()`, schema inference, structured output, annotations | ready |
48+
| [`prompts`](prompts/) | `@mcp.prompt()`, list/get, argument completion | ready |
49+
| [`resources`](resources/) | `@mcp.resource()`, list/read, URI templates | ready |
50+
| [`lifespan`](lifespan/) | startup/shutdown lifespan, per-request state injection | ready |
51+
| [`dual_era`](dual_era/) | one server factory serving both protocol eras; era-neutral accessors | ready |
52+
| [`custom_version`](custom_version/) | restricting `supported_protocol_versions` | ready |
53+
| **— feature stories —** | | |
54+
| [`streaming`](streaming/) | progress notifications, in-flight logging, cancellation | ready |
55+
| [`elicitation`](elicitation/) | server pauses a tool to ask the user (form + url) | ready (legacy-era) |
56+
| [`sampling`](sampling/) | server asks the client's LLM mid-tool (push request) | ready (legacy-era) |
57+
| [`stickynotes`](stickynotes/) | capstone: tools mutate state → resources + `list_changed` + elicit guard | ready |
58+
| [`custom_methods`](custom_methods/) | vendor-prefixed JSON-RPC via `add_request_handler` / `send_request` | ready |
59+
| [`schema_validators`](schema_validators/) | tool input schema from pydantic / TypedDict / dataclass / dict | ready |
60+
| [`middleware`](middleware/) | server-side request/response middleware | ready |
61+
| [`parallel_calls`](parallel_calls/) | N×M concurrent calls; per-call notification attribution | ready |
62+
| [`roots`](roots/) | client-declared roots, server reads them via `ctx` | ready (legacy-era) |
63+
| [`pagination`](pagination/) | manual cursor loop over list endpoints | ready |
64+
| [`error_handling`](error_handling/) | `is_error` results vs `MCPError`; `ToolError` | ready |
65+
| [`client_session`](client_session/) | dropping to `client.session` / `ClientSession` mechanics | ready |
66+
| [`serve_one`](serve_one/) | building a `Connection` by hand and calling `serve_one` directly | ready |
67+
| **— HTTP hosting —** | | |
68+
| [`stateless_legacy`](stateless_legacy/) | `streamable_http_app()` default posture; the one-liner deploy | ready |
69+
| [`json_response`](json_response/) | `json_response=True` mode; raw 2026 POST envelope on the wire | ready |
70+
| [`legacy_routing`](legacy_routing/) | `is_legacy_request()` classifier in front of a sessionful 1.x deploy | ready |
71+
| [`starlette_mount`](starlette_mount/) | mounting `streamable_http_app()` under a Starlette/FastAPI sub-path | ready |
72+
| [`sse_polling`](sse_polling/) | SEP-1699 `closeSSE()` + `Last-Event-ID` resume via `EventStore` | ready |
73+
| [`standalone_get`](standalone_get/) | server-initiated `list_changed` over the sessionful GET stream | ready |
74+
| [`reconnect`](reconnect/) | explicit `discover()`, persist `DiscoverResult`, zero-RTT reconnect | ready |
75+
| [`bearer_auth`](bearer_auth/) | `requireBearerAuth`, PRM metadata, static-token verifier, `ctx.authInfo` | ready |
76+
| [`oauth`](oauth/) | full `authorization_code` grant against an in-process AS | ready |
77+
| [`oauth_client_credentials`](oauth_client_credentials/) | `client_credentials` grant; minimal in-process token endpoint | ready |
78+
| **— deferred (README only) —** | | |
79+
| [`caching`](caching/) | `CacheableResult` ttl/scope hints; client honouring | not yet implemented |
80+
| [`mrtr`](mrtr/) | `InputRequiredResult` round-trip with `requestState` HMAC | not yet implemented — [#2898](https://github.com/modelcontextprotocol/python-sdk/issues/2898) |
81+
| [`subscriptions`](subscriptions/) | `subscriptions/listen`, `ServerEventBus`, `Client.listen()` | not yet implemented — [#2901](https://github.com/modelcontextprotocol/python-sdk/issues/2901) |
82+
| [`tasks`](tasks/) | `io.modelcontextprotocol/tasks` extension | not yet implemented |
83+
| [`apps`](apps/) | MCP Apps: `ui://` resource + `_meta.ui` | not yet implemented — [#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896) |
84+
| [`skills`](skills/) | SEP-2640 skills extension | not yet implemented — [#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896) |
85+
| [`events`](events/) | `io.modelcontextprotocol/events` extension | not yet implemented |
86+
87+
The TypeScript SDK's `repl`, `client-quickstart`, and `server-quickstart`
88+
examples are intentionally not ported (interactive / external network deps);
89+
its `hono` example maps to `starlette_mount/`.

examples/stories/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Self-verifying example suite for the MCP Python SDK.
2+
3+
Each story directory holds a ``server.py`` (and usually ``server_lowlevel.py``)
4+
plus a ``client.py`` whose ``scenario(client)`` runs against both.
5+
``tests/examples/`` drives every story over an in-process matrix.
6+
"""

examples/stories/_harness.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Client-side scaffold for story examples.
2+
3+
A story's ``client.py`` imports only from here. The ``Connect`` factory and
4+
``run_client`` ride the locked ``Client(transport, mode=...)`` surface; the one
5+
volatile line is the stdio wrap (marked inline).
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import sys
11+
import traceback
12+
from collections.abc import AsyncIterator, Awaitable, Callable
13+
from contextlib import AbstractAsyncContextManager, asynccontextmanager
14+
from pathlib import Path
15+
from typing import Any, Protocol
16+
17+
import anyio
18+
import httpx
19+
20+
from mcp import StdioServerParameters, stdio_client
21+
from mcp.client import Client
22+
from mcp.shared.version import LATEST_MODERN_VERSION
23+
24+
Scenario = Callable[[Client], Awaitable[None]]
25+
ScenarioWithConnect = Callable[[Client, "Connect"], Awaitable[None]]
26+
AuthBuilder = Callable[[httpx.AsyncClient], httpx.Auth]
27+
"""Builds an ``httpx.Auth`` bound to the in-process HTTP client (auth-story harness seam)."""
28+
29+
30+
class Connect(Protocol):
31+
"""A factory yielding a connected ``Client``; accepts the same kwargs as ``Client``.
32+
33+
``auth`` is the HTTP-only escape hatch for auth stories: when given, the factory
34+
builds a fresh ``httpx.AsyncClient`` against the same app, applies ``auth(http)``
35+
to it, and wraps the result in ``streamable_http_client`` before entering ``Client``.
36+
"""
37+
38+
def __call__(self, *, auth: AuthBuilder | None = None, **client_kw: Any) -> AbstractAsyncContextManager[Client]: ...
39+
40+
41+
def argv_after(flag: str, *, default: str | None = None) -> str:
42+
"""Return the argv token following ``flag``, or ``default`` when the flag is absent."""
43+
try:
44+
return sys.argv[sys.argv.index(flag) + 1]
45+
except ValueError:
46+
if default is None:
47+
raise SystemExit(f"missing required {flag}") from None
48+
return default
49+
50+
51+
def connect_from_args(file: str) -> Connect:
52+
"""Build a ``Connect`` targeting the sibling server over the argv-selected transport.
53+
54+
``--http <url>`` connects over streamable HTTP; ``--stdio`` (the default) spawns the
55+
sibling ``server.py`` as a subprocess. ``--server <stem>`` selects ``<stem>.py``
56+
(e.g. ``server_lowlevel``). ``--legacy`` pins the handshake era; otherwise the
57+
modern era is used. ``file`` is the caller's ``__file__``.
58+
"""
59+
here = Path(file).parent
60+
server_stem = argv_after("--server", default="server")
61+
# Never rely on the SDK's mode= default — be explicit. stdio is legacy-only until
62+
# the SDK's stdio entry can negotiate the era; the modern arm is --http only for now.
63+
if "--http" in sys.argv:
64+
mode = "legacy" if "--legacy" in sys.argv else LATEST_MODERN_VERSION
65+
else:
66+
mode = "legacy" # stdio gains a modern arm once serve_stdio() lands
67+
68+
@asynccontextmanager
69+
async def _connect(*, auth: AuthBuilder | None = None, **client_kw: Any) -> AsyncIterator[Client]:
70+
assert auth is None, "auth= via connect_from_args is not wired; auth stories own their __main__"
71+
client_kw.setdefault("mode", mode)
72+
target: Any
73+
if "--http" in sys.argv:
74+
target = argv_after("--http")
75+
else:
76+
params = StdioServerParameters(command=sys.executable, args=[str(here / f"{server_stem}.py")])
77+
target = stdio_client(params) # becomes Client(params) once that overload lands
78+
async with Client(target, **client_kw) as client:
79+
yield client
80+
81+
return _connect
82+
83+
84+
def run_client(
85+
scenario: Scenario | ScenarioWithConnect,
86+
*,
87+
connect: Connect,
88+
needs_connect: bool = False,
89+
**client_kw: Any,
90+
) -> None:
91+
"""Entry point for ``if __name__ == "__main__"`` in every ``client.py``.
92+
93+
Runs ``scenario`` inside a connected client; prints ``OK:``/``FAIL:`` to stderr and
94+
exits 0/1. ``needs_connect=True`` passes ``connect`` as the second argument so the
95+
scenario can open additional clients.
96+
"""
97+
file = getattr(scenario, "__globals__", {}).get("__file__", "<unknown>")
98+
name = Path(file).parent.name
99+
transport = "http" if "--http" in sys.argv else "stdio"
100+
era = "modern" if transport == "http" and "--legacy" not in sys.argv else "legacy"
101+
102+
async def _main() -> None:
103+
with anyio.fail_after(30):
104+
async with connect(**client_kw) as client:
105+
if needs_connect:
106+
await scenario(client, connect) # type: ignore[call-arg]
107+
else:
108+
await scenario(client) # type: ignore[call-arg]
109+
110+
try:
111+
anyio.run(_main)
112+
except Exception:
113+
print(f"FAIL: {name} ({transport}/{era})", file=sys.stderr)
114+
traceback.print_exc()
115+
raise SystemExit(1) from None
116+
print(f"OK: {name} ({transport}/{era})", file=sys.stderr)
117+
raise SystemExit(0)

examples/stories/_hosting.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Server-side hosting scaffold for story examples.
2+
3+
A story's ``server.py`` / ``server_lowlevel.py`` imports only from here. The
4+
marked lines touch entry-point APIs that a later release reshapes into
5+
free-function entries; isolating them here keeps story bodies stable.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import sys
11+
from collections.abc import Callable
12+
from typing import Any, TypeAlias
13+
14+
import anyio
15+
import uvicorn
16+
from starlette.applications import Starlette
17+
18+
from mcp.server.lowlevel import Server
19+
from mcp.server.mcpserver import MCPServer
20+
from mcp.server.stdio import stdio_server
21+
from mcp.server.transport_security import TransportSecuritySettings
22+
23+
AnyServer: TypeAlias = "MCPServer | Server[Any]"
24+
ServerFactory = Callable[[], AnyServer]
25+
AppFactory = Callable[[], Starlette]
26+
27+
NO_DNS_REBIND = TransportSecuritySettings(enable_dns_rebinding_protection=False)
28+
"""Harness servers bind 127.0.0.1 and the in-process httpx client sends no Origin header."""
29+
30+
31+
def argv_after(flag: str, *, default: str | None = None) -> str:
32+
"""Return the argv token following ``flag``, or ``default`` when the flag is absent."""
33+
try:
34+
return sys.argv[sys.argv.index(flag) + 1]
35+
except ValueError:
36+
if default is None:
37+
raise SystemExit(f"missing required {flag}") from None
38+
return default
39+
40+
41+
def asgi_from(server: AnyServer, *, path: str = "/mcp") -> Starlette:
42+
"""Wrap a server instance in its streamable-HTTP ASGI app for in-process driving."""
43+
return server.streamable_http_app( # becomes free fn streamable_http(server, legacy=...)
44+
streamable_http_path=path,
45+
stateless_http=False, # bool folds into a legacy= enum in a later release
46+
transport_security=NO_DNS_REBIND,
47+
)
48+
49+
50+
def run_server_from_args(build_server: ServerFactory) -> None:
51+
"""Entry point for ``if __name__ == "__main__"`` in every ``server*.py``.
52+
53+
Bare argv serves over stdio; ``--http --port N [--path /mcp]`` serves over
54+
uvicorn on 127.0.0.1:N.
55+
"""
56+
server = build_server()
57+
if "--http" in sys.argv:
58+
port = int(argv_after("--port", default="8000"))
59+
path = argv_after("--path", default="/mcp")
60+
anyio.run(_serve_http, server, port, path)
61+
else:
62+
anyio.run(_serve_stdio, server)
63+
64+
65+
async def _serve_stdio(server: AnyServer) -> None:
66+
if isinstance(server, MCPServer):
67+
await server.run_stdio_async() # becomes await serve_stdio(server)
68+
else:
69+
async with stdio_server() as (read, write): # becomes await serve_stdio(server)
70+
await server.run(read, write, server.create_initialization_options())
71+
72+
73+
async def _serve_http(server: AnyServer, port: int, path: str) -> None:
74+
app = asgi_from(server, path=path)
75+
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error")
76+
await uvicorn.Server(config).serve()
77+
78+
79+
def run_app_from_args(build_app: AppFactory) -> None:
80+
"""Entry point for ``if __name__ == "__main__"`` in app-exporting ``server*.py``.
81+
82+
App-exporting stories are HTTP-only; ``--port N`` serves the Starlette app over
83+
uvicorn on 127.0.0.1:N (uvicorn drives the app's own lifespan). No stdio leg.
84+
"""
85+
port = int(argv_after("--port", default="8000"))
86+
config = uvicorn.Config(build_app(), host="127.0.0.1", port=port, log_level="error")
87+
anyio.run(uvicorn.Server(config).serve)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Shared scaffolding the auth/hosting stories import (not teaching surface)."""

0 commit comments

Comments
 (0)