Skip to content

Commit 3893947

Browse files
committed
Show Client construction in every story; cut/rename stories per review
- Invert the harness contract: each client.py now defines main(target, *, mode) and constructs Client(target, mode=...) itself, so the construction users came to see is in every example. The Connect factory, client_kw export, and needs_connect plumbing are gone. - Remove custom_version (the SDK has no supported-protocol-versions knob yet, so it could not show one) and client_session (an escape hatch we do not want a headline example for); dual_era keeps the negotiated-version callout. - Rename elicitation -> legacy_elicitation and add status (current / legacy / deprecated) to the manifest, surfaced in the story index, with README banners and migration notes on the legacy and deprecated stories. - dual_era servers note that one factory serves both eras with no configuration.
1 parent f864663 commit 3893947

80 files changed

Lines changed: 1248 additions & 1516 deletions

Some content is hidden

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

examples/stories/README.md

Lines changed: 53 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,20 @@
22

33
One feature per folder. Each story is a small, self-verifying program: a
44
`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.
5+
`server_lowlevel.py`) and a `client.py` whose `main()` makes assertions and
6+
exits non-zero on failure. The code you read here is the same code CI runs —
7+
there is no separate test double.
8+
9+
## How to read a story
10+
11+
Start with the story's README, then `server.py`, then `client.py`. Every
12+
`client.py` exports `async def main(target, *, mode="auto")` — or
13+
`main(targets, ...)` for the stories that open more than one connection — and
14+
constructs the `Client` itself, so the body opens with the one line a client
15+
example exists to teach: `async with Client(target, mode=mode) as client:`.
16+
The `run_client(main)` call in the `__main__` block is only argv plumbing
17+
(stdio vs `--http`, which `mode` to pass); it never hides how the client
18+
connects.
819

920
## Running a story
1021

@@ -27,54 +38,59 @@ uv run --frozen pytest tests/examples/ # everything
2738
uv run --frozen pytest tests/examples/ -k tools # one story
2839
```
2940

30-
[`manifest.toml`](manifest.toml) declares each story's transports, era, and
31-
variants; `tests/examples/` expands it.
41+
[`manifest.toml`](manifest.toml) declares each story's transports, era, status,
42+
and variants; `tests/examples/` expands it.
3243

3344
## Layout
3445

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
46+
`_hosting.py` adapts a story's `build_server()` / `build_app()` to argv (stdio
47+
vs `--http` serving); `_harness.py` is the client-side mirror — it picks the
48+
`target` that `main()` connects to (a stdio subprocess by default, a URL under
49+
`--http`). They isolate the parts of the SDK's hosting surface
3850
that are still moving — **don't copy them into your own project**; copy the
3951
`server.py` / `client.py` bodies instead. `_shared/` holds an in-process OAuth
4052
authorization server reused by the auth stories.
4153

4254
## Stories
4355

56+
The **status** column is the feature's standing in the protocol, from
57+
[`manifest.toml`](manifest.toml): `current`, `legacy` (a 2025 handshake-era
58+
mechanism with a 2026-era replacement), or `deprecated` (deprecated by
59+
SEP-2577; functional through the deprecation window). Each non-`current` story's README
60+
opens with a banner saying what replaces it.
61+
4462
| story | what it shows | status |
4563
|---|---|---|
4664
| **— 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 |
65+
| [`tools`](tools/) | `@mcp.tool()`, schema inference, structured output, annotations | current |
66+
| [`prompts`](prompts/) | `@mcp.prompt()`, list/get, argument completion | current |
67+
| [`resources`](resources/) | `@mcp.resource()`, list/read, URI templates | current |
68+
| [`lifespan`](lifespan/) | startup/shutdown lifespan, per-request state injection | current |
69+
| [`dual_era`](dual_era/) | one server factory serving both protocol eras; era-neutral accessors | current |
5370
| **— 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 |
71+
| [`streaming`](streaming/) | progress notifications, in-flight logging, cancellation | current |
72+
| [`legacy_elicitation`](legacy_elicitation/) | server pauses a tool to ask the user (form + url) via a push request | legacy |
73+
| [`sampling`](sampling/) | server asks the client's LLM mid-tool (push request) | deprecated |
74+
| [`stickynotes`](stickynotes/) | capstone: tools mutate state → resources + `list_changed` + elicit guard | current |
75+
| [`custom_methods`](custom_methods/) | vendor-prefixed JSON-RPC via `add_request_handler` / `send_request` | current |
76+
| [`schema_validators`](schema_validators/) | tool input schema from pydantic / TypedDict / dataclass / dict | current |
77+
| [`middleware`](middleware/) | server-side request/response middleware | current |
78+
| [`parallel_calls`](parallel_calls/) | two clients rendezvous in one tool; per-call progress attribution | current |
79+
| [`roots`](roots/) | client-declared roots, server reads them via `ctx` | deprecated |
80+
| [`pagination`](pagination/) | manual cursor loop over list endpoints | current |
81+
| [`error_handling`](error_handling/) | `is_error` results vs `MCPError`; `ToolError` | current |
82+
| [`serve_one`](serve_one/) | building a `Connection` by hand and calling `serve_one` directly | current |
6783
| **— 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 |
84+
| [`stateless_legacy`](stateless_legacy/) | `streamable_http_app()` default posture; the one-liner deploy | current |
85+
| [`json_response`](json_response/) | `json_response=True` mode; raw 2026 POST envelope on the wire | current |
86+
| [`legacy_routing`](legacy_routing/) | `classify_inbound_request()` era routing in front of a sessionful 1.x deploy | current |
87+
| [`starlette_mount`](starlette_mount/) | mounting `streamable_http_app()` under a Starlette/FastAPI sub-path | current |
88+
| [`sse_polling`](sse_polling/) | SEP-1699 `closeSSE()` + `Last-Event-ID` resume via `EventStore` | legacy |
89+
| [`standalone_get`](standalone_get/) | server-initiated `list_changed` over the sessionful GET stream | legacy |
90+
| [`reconnect`](reconnect/) | explicit `discover()`, persist `DiscoverResult`, zero-RTT reconnect | current |
91+
| [`bearer_auth`](bearer_auth/) | `TokenVerifier` + `AuthSettings` bearer gate, PRM metadata, `get_access_token()` | current |
92+
| [`oauth`](oauth/) | full `authorization_code` grant against an in-process AS | current |
93+
| [`oauth_client_credentials`](oauth_client_credentials/) | `client_credentials` grant; minimal in-process token endpoint | current |
7894
| **— deferred (README only) —** | | |
7995
| [`caching`](caching/) | `CacheableResult` ttl/scope hints; client honouring | not yet implemented |
8096
| [`mrtr`](mrtr/) | `InputRequiredResult` round-trip with `requestState` HMAC | not yet implemented — [#2898](https://github.com/modelcontextprotocol/python-sdk/issues/2898) |

examples/stories/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Self-verifying example suite for the MCP Python SDK.
22
33
Each story directory holds a ``server.py`` (and usually ``server_lowlevel.py``)
4-
plus a ``client.py`` whose ``scenario(client)`` runs against both.
4+
plus a ``client.py`` whose ``main(target, *, mode)`` runs against both.
55
``tests/examples/`` drives every story over an in-process matrix.
66
"""

examples/stories/_harness.py

Lines changed: 87 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,43 @@
11
"""Client-side scaffold for story examples.
22
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).
3+
A story's ``client.py`` imports ``Target`` (or ``TargetFactory``) for its ``main``
4+
signature and calls ``run_client(main)`` from ``__main__``. The story owns the
5+
``Client(target, mode=...)`` construction; this module only decides WHICH target
6+
``__main__`` hands it.
67
"""
78

89
from __future__ import annotations
910

1011
import sys
1112
import traceback
12-
from collections.abc import AsyncIterator, Awaitable, Callable
13-
from contextlib import AbstractAsyncContextManager, asynccontextmanager
13+
from collections.abc import Awaitable, Callable
1414
from pathlib import Path
15-
from typing import Any, Protocol
15+
from typing import Any, TypeAlias
16+
from urllib.parse import urlsplit
1617

1718
import anyio
1819
import httpx
1920

2021
from mcp import StdioServerParameters, stdio_client
21-
from mcp.client import Client
22+
from mcp.client import Transport
23+
from mcp.client.streamable_http import streamable_http_client
24+
from mcp.server import Server
25+
from mcp.server.mcpserver import MCPServer
2226
from mcp.shared.version import LATEST_MODERN_VERSION
2327

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+
if sys.version_info >= (3, 11):
29+
import tomllib
30+
else:
31+
import tomli as tomllib
2832

33+
Target: TypeAlias = "Server[Any] | MCPServer | Transport | str"
34+
"""Anything ``Client(...)`` accepts: an in-process server, a ``Transport``, or an HTTP URL."""
2935

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-
"""
36+
TargetFactory = Callable[[], Target]
37+
"""Yields a FRESH target against the same server/app on every call (``multi_connection`` stories)."""
3738

38-
def __call__(self, *, auth: AuthBuilder | None = None, **client_kw: Any) -> AbstractAsyncContextManager[Client]: ...
39+
AuthBuilder = Callable[[httpx.AsyncClient], httpx.Auth]
40+
"""Builds an ``httpx.Auth`` bound to the in-process HTTP client (auth-story harness seam)."""
3941

4042

4143
def argv_after(flag: str, *, default: str | None = None) -> str:
@@ -48,67 +50,84 @@ def argv_after(flag: str, *, default: str | None = None) -> str:
4850
return default
4951

5052

51-
def connect_from_args(file: str) -> Connect:
52-
"""Build a ``Connect`` targeting the sibling server over the argv-selected transport.
53+
def target_from_args(file: str) -> TargetFactory:
54+
"""Build a ``TargetFactory`` for the sibling server over the argv-selected transport.
5355
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__``.
56+
``--http <url>`` targets that streamable-HTTP URL; ``--stdio`` (the default) spawns
57+
the sibling ``server.py`` as a fresh subprocess on each call. ``--server <stem>``
58+
selects ``<stem>.py`` (e.g. ``server_lowlevel``). ``file`` is the caller's ``__file__``.
5859
"""
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.
6360
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:
61+
url = argv_after("--http")
62+
return lambda: url
63+
# stdio is legacy-only until serve_stdio() lands; the modern arm is --http only for now.
64+
server = Path(file).parent / f"{argv_after('--server', default='server')}.py"
65+
params = StdioServerParameters(command=sys.executable, args=[str(server)])
66+
return lambda: stdio_client(params) # becomes Client(params) once that overload lands
67+
68+
69+
def _story_cfg(name: str) -> dict[str, Any]:
70+
"""The manifest entry for the story ``name`` with ``[defaults]`` applied."""
71+
manifest: dict[str, Any] = tomllib.loads((Path(__file__).parent / "manifest.toml").read_text())
72+
return manifest["defaults"] | manifest["story"].get(name, {})
73+
74+
75+
def _authed_targets(url: str, http: httpx.AsyncClient) -> TargetFactory:
76+
"""Fresh streamable-HTTP transports over an already-authed ``httpx`` client."""
77+
return lambda: streamable_http_client(url, http_client=http)
78+
79+
80+
def run_client(main: Callable[..., Awaitable[None]]) -> None:
9181
"""Entry point for ``if __name__ == "__main__"`` in every ``client.py``.
9282
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.
83+
Builds the argv-selected target(s) for the story that defines ``main``, picks the
84+
era from argv, and calls ``main`` with an explicit ``mode=``. If the story module
85+
exports ``build_auth``, the ``--http`` target is routed through an ``httpx.AsyncClient``
86+
that carries the returned ``httpx.Auth``. Prints ``OK:``/``FAIL:`` to stderr, exits 0/1.
9687
"""
97-
file = getattr(scenario, "__globals__", {}).get("__file__", "<unknown>")
88+
globals_ = getattr(main, "__globals__", {})
89+
file = str(globals_.get("__file__", "<unknown>"))
9890
name = Path(file).parent.name
91+
cfg = _story_cfg(name)
92+
targets = target_from_args(file)
93+
build_auth: AuthBuilder | None = globals_.get("build_auth")
9994
transport = "http" if "--http" in sys.argv else "stdio"
95+
# Never rely on the SDK's mode= default — be explicit. stdio is legacy-only until
96+
# the SDK's stdio entry can negotiate the era, so only --http gets a modern arm.
10097
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]
98+
if cfg["era"] == "dual-in-body":
99+
# The story pins its connection modes inside ``main`` itself, so hand it the
100+
# real-user "auto" default and let those in-body pins decide. A hard version pin
101+
# here would skip the discover probe and leave ``server_info`` blank.
102+
era = "in-body"
103+
mode = {"modern": LATEST_MODERN_VERSION, "legacy": "legacy", "in-body": "auto"}[era]
104+
105+
async def _run() -> None:
106+
with anyio.fail_after(cfg["timeout_s"]):
107+
if not cfg["needs_http"] and (build_auth is None or transport != "http"):
108+
await main(targets if cfg["multi_connection"] else targets(), mode=mode)
109+
return
110+
# Auth and needs_http stories want the raw httpx client underneath the transport:
111+
# build_auth threads an httpx.Auth onto it (Client(url, auth=...) doesn't exist
112+
# yet), and needs_http stories assert on raw responses, so root the client at the
113+
# server origin and relative paths like "/mcp" resolve.
114+
if transport != "http":
115+
raise SystemExit(f"{name} asserts on raw HTTP responses; run it with --http <url>")
116+
url = argv_after("--http")
117+
parts = urlsplit(url)
118+
async with httpx.AsyncClient(base_url=f"{parts.scheme}://{parts.netloc}") as http:
119+
make = targets
120+
if build_auth is not None:
121+
http.auth = build_auth(http)
122+
make = _authed_targets(url, http)
123+
target: Any = make if cfg["multi_connection"] else make()
124+
if cfg["needs_http"]:
125+
await main(target, mode=mode, http=http)
107126
else:
108-
await scenario(client) # type: ignore[call-arg]
127+
await main(target, mode=mode)
109128

110129
try:
111-
anyio.run(_main)
130+
anyio.run(_run)
112131
except Exception:
113132
print(f"FAIL: {name} ({transport}/{era})", file=sys.stderr)
114133
traceback.print_exc()

0 commit comments

Comments
 (0)