Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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 numberDiff line numberDiff 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
25 changes: 21 additions & 4 deletions examples/README.md
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,22 @@
# Python SDK Examples
# Python SDK examples

This folders aims to provide simple examples of using the Python SDK. Please refer to the
[servers repository](https://github.com/modelcontextprotocol/servers)
for real-world servers.
- [`stories/`](stories/) — **the canonical reference.** One self-verifying
example per protocol feature, each with its own README. Start with
[`stories/tools/`](stories/tools/); the [stories README](stories/README.md)
has the full table and how to run them.
- [`snippets/`](snippets/) — short extracts embedded into `README.v2.md`. Kept
minimal and in sync with the top-level README; not intended to be run
standalone.
- [`servers/everything-server/`](servers/everything-server/) — the conformance
target for the cross-SDK
[conformance suite](https://github.com/modelcontextprotocol/conformance).
Exercises every server capability in one process.
- [`mcpserver/`](mcpserver/) — single-file v1-era examples retained for the
migration guide; superseded by `stories/` and slated for removal.
- [`clients/`](clients/) and the remaining [`servers/`](servers/) directories
(`simple-*`, `sse-polling-demo`, `structured-output-lowlevel`) — standalone
v1-era projects still linked from `README.v2.md`; retained pending
consolidation into `stories/`.

For real-world servers see the
[servers repository](https://github.com/modelcontextprotocol/servers).
16 changes: 16 additions & 0 deletions examples/pyproject.toml
Original file line numberDiff line numberDiff line change
@@ -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"]
Comment thread
claude[bot] marked this conversation as resolved.
164 changes: 164 additions & 0 deletions examples/stories/README.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
# Story examples

One feature per folder. Each story is a small, self-verifying program: a
`server.py` (plus, where the wire contract is worth seeing by hand, a
`server_lowlevel.py`) and a `client.py` whose `main()` makes assertions and
exits non-zero on failure. The code you read here is the same code CI runs —
there is no separate test double.

## Canonical shape

Every `client.py` starts from this skeleton — copy it, then replace the body
with the story's assertions:

```python
"""One line: what this client proves."""

from mcp.client import Client
from stories._harness import Target, run_client


async def main(target: Target, *, mode: str = "auto") -> None:
async with Client(target, mode=mode) as client:
... # the story's assertions


if __name__ == "__main__":
run_client(main)
```

There are exactly two `main` shapes. A story that opens **one** connection
takes `main(target: Target, ...)`. A story that opens **more than one** sets
`multi_connection = true` in [`manifest.toml`](manifest.toml), takes
`main(targets: TargetFactory, ...)`, and calls `targets()` once per fresh
connection — a `Client` cannot be re-entered after exit. Nothing else changes
shape.

Story files import from `stories._harness` only these names: `run_client`,
`target_from_args`, `Target`, `TargetFactory` — plus `AuthBuilder` for the
auth stories. Everything else a story uses comes from public `mcp.*` modules.

The repetition this produces across stories is deliberate, not a refactor
waiting to happen: each `client.py` is a standalone, compiled doc page, so
when a public API changes, N red example files flag N doc pages. Don't pull
the `Client(target, mode=mode)` line (or anything around it) into a shared
helper. A story that can't be the canonical shape says why in its module
docstring's first line.

## How to read a story

Start with the story's README, then `server.py`, then `client.py`. Every
`client.py` exports `async def main(target, *, mode="auto")` — or
`main(targets, ...)` for the stories that open more than one connection — and
constructs the `Client` itself, so the body opens with the one line a client
example exists to teach: `async with Client(target, mode=mode) as client:`.
The `run_client(main)` call in the `__main__` block is only argv plumbing
(stdio vs `--http`, which `mode` to pass); it never hides how the client
connects.

## Running a story

From the repository root:

```bash
# stdio (default — the client spawns the server as a subprocess)
uv run python -m stories.tools.client

# HTTP, self-hosted — the client spawns the server on a real uvicorn socket on a
# port it owns, waits for it, runs, then terminates it. Nothing to background or kill.
uv run python -m stories.tools.client --http

# the same self-hosted run against the story's lowlevel-API server variant
uv run python -m stories.tools.client --http --server server_lowlevel

# HTTP against a server you run yourself
uv run python -m stories.tools.server --http --port 8000 # separate terminal
uv run python -m stories.tools.client --http http://127.0.0.1:8000/mcp
```

`--http` takes two forms. Bare `--http` is the canonical HTTP run — it is
complete on its own, and it is what every per-story README shows. `--http
<url>` connects to a server you started yourself; the per-story READMEs spell
that out only where hosting is the lesson (the HTTP-hosting and auth stories).
`--server <stem>` swaps in a sibling server module on stdio and on the
self-hosted `--http` run; with `--http <url>` you already picked the server
when you started it. The auth stories (`bearer_auth/`, `oauth/`,
`oauth_client_credentials/`) self-host on their fixed `:8000` instead of a
free port because their issuer/PRM metadata bake it in — `:8000` must be
free, and the run refuses to start (rather than silently testing whatever is
there) if it is not.

The full matrix (every story × transport × era × server-variant) runs under
pytest:

```bash
uv run --frozen pytest tests/examples/ # everything
uv run --frozen pytest tests/examples/ -k tools # one story
```

[`manifest.toml`](manifest.toml) declares each story's transports, era, status,
and variants; `tests/examples/` expands it.

## Layout

`_hosting.py` adapts a story's `build_server()` / `build_app()` to argv (stdio
vs `--http` serving); `_harness.py` is the client-side mirror — it picks the
`target` that `main()` connects to (a stdio subprocess by default, a self-hosted
HTTP subprocess under bare `--http`, your URL under `--http <url>`). They
isolate the parts of the SDK's hosting surface
that are still moving — **don't copy them into your own project**; copy the
`server.py` / `client.py` bodies instead. `_shared/` holds an in-process OAuth
authorization server reused by the auth stories.

## Stories

The **status** column is the feature's standing in the protocol, from
[`manifest.toml`](manifest.toml): `current`, `legacy` (a 2025 handshake-era
mechanism with a 2026-era replacement), or `deprecated` (deprecated by
SEP-2577; functional through the deprecation window). Each non-`current` story's README
opens with a banner saying what replaces it.

| story | what it shows | status |
|---|---|---|
| **— start here —** | | |
| [`tools`](tools/) | `@mcp.tool()`, schema inference, structured output, annotations | current |
| [`prompts`](prompts/) | `@mcp.prompt()`, list/get, argument completion | current |
| [`resources`](resources/) | `@mcp.resource()`, list/read, URI templates | current |
| [`lifespan`](lifespan/) | startup/shutdown lifespan, per-request state injection | current |
| [`dual_era`](dual_era/) | one server factory serving both protocol eras; era-neutral accessors | current |
| **— feature stories —** | | |
| [`streaming`](streaming/) | progress notifications, in-flight logging, cancellation | current |
| [`legacy_elicitation`](legacy_elicitation/) | server pauses a tool to ask the user (form + url) via a push request | legacy |
| [`sampling`](sampling/) | server asks the client's LLM mid-tool (push request) | deprecated |
| [`stickynotes`](stickynotes/) | capstone: tools mutate state → resources + `list_changed` + elicit guard | current |
| [`custom_methods`](custom_methods/) | vendor-prefixed JSON-RPC via `add_request_handler` / `send_request` | current |
| [`schema_validators`](schema_validators/) | tool input schema from pydantic / TypedDict / dataclass / dict | current |
| [`middleware`](middleware/) | server-side request/response middleware | current |
| [`parallel_calls`](parallel_calls/) | two clients rendezvous in one tool; per-call progress attribution | current |
| [`roots`](roots/) | client-declared roots, server reads them via `ctx` | deprecated |
| [`pagination`](pagination/) | manual cursor loop over list endpoints | current |
| [`error_handling`](error_handling/) | `is_error` results vs `MCPError`; `ToolError` | current |
| [`serve_one`](serve_one/) | building a `Connection` by hand and calling `serve_one` directly | current |
| **— HTTP hosting —** | | |
| [`stateless_legacy`](stateless_legacy/) | `streamable_http_app(stateless_http=True)`; the one-liner deploy | current |
| [`json_response`](json_response/) | `json_response=True` mode; raw 2026 POST envelope on the wire | current |
| [`legacy_routing`](legacy_routing/) | `classify_inbound_request()` era routing in front of a sessionful 1.x deploy | current |
| [`starlette_mount`](starlette_mount/) | mounting `streamable_http_app()` under a Starlette/FastAPI sub-path | current |
| [`sse_polling`](sse_polling/) | SEP-1699 `closeSSE()` + `Last-Event-ID` resume via `EventStore` | legacy |

Check warning on line 147 in examples/stories/README.md

View check run for this annotation

Claude/ Claude Code Review

Stories index references closeSSE(), a function name that does not exist in the Python SDK

The story index table describes `sse_polling` as "SEP-1699 `closeSSE()` + `Last-Event-ID` resume via `EventStore`", but `closeSSE()` is the TypeScript-SDK spelling and doesn't exist anywhere in this repository — the Python API the story actually teaches is `ctx.close_sse_stream()`. Update the table entry to `close_sse_stream()` (or describe the SEP feature without code formatting) so readers grepping the SDK find the real name.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The story index table describes sse_polling as "SEP-1699 closeSSE() + Last-Event-ID resume via EventStore", but closeSSE() is the TypeScript-SDK spelling and doesn't exist anywhere in this repository — the Python API the story actually teaches is ctx.close_sse_stream(). Update the table entry to close_sse_stream() (or describe the SEP feature without code formatting) so readers grepping the SDK find the real name.

Extended reasoning...

The issue. Line 147 of examples/stories/README.md (the story index table) describes the sse_polling story as "SEP-1699 closeSSE() + Last-Event-ID resume via EventStore". A repo-wide grep shows closeSSE appears exactly once in the entire repository — on this line. It is the TypeScript-SDK / SEP-1699 camelCase spelling; no such function is defined or exported anywhere in src/mcp or in the example code.

What the correct name is. The Python API the sse_polling story teaches is close_sse_stream(): the close_sse_stream callback on ServerRequestContext (src/mcp/server/context.py:41), wired by the streamable-HTTP transport (src/mcp/server/streamable_http.py), and called as await ctx.close_sse_stream() in examples/stories/sse_polling/server.py and server_lowlevel.py. The sse_polling story's own README consistently spells it close_sse_stream() as well — only this index entry uses the foreign spelling.

How a reader hits it. The index table otherwise uses real Python SDK names in code formatting (classify_inbound_request(), streamable_http_app(stateless_http=True), get_access_token(), add_request_handler, ...), so a code-formatted closeSSE() reads as a Python API. Step-by-step: (1) read the sse_polling row in the index; (2) grep the SDK for closeSSE — zero hits outside this README line; (3) open sse_polling/server.py — the call there is ctx.close_sse_stream(). The cross-reference points at a name that does not exist in this SDK.

Why nothing catches it. The story suite's checks (tests/examples/test_story_shape.py, the manifest consistency tests) validate code shape and manifest/filesystem agreement, not prose in READMEs, so a stale function name in the index table passes CI silently.

Why this is intentional-looking but still worth fixing. One could argue closeSSE() is deliberately the SEP-1699 spelling, but nothing in the table marks it as a TypeScript-SDK or spec-level name, and every neighboring entry uses the Python spelling. It is also the same class of issue as the is_legacy_request() stale-name reference in stateless_legacy/README.md that was already accepted and fixed — this is the remaining occurrence of a code-formatted function name that doesn't exist in the SDK.

Impact and fix. Doc-only, no runtime effect. One-word change in the table entry, e.g.: | sse_polling | SEP-1699 close_sse_stream() + Last-Event-ID resume via EventStore | legacy | — or drop the code formatting and refer to "SEP-1699 server-initiated SSE close".

| [`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 numberDiff line numberDiff 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.
"""
Loading
Loading