Skip to content

Commit c439aeb

Browse files
committed
Add a story shape-check; document the canonical example shape
- tests/examples/test_story_shape.py: an AST check that every client.py constructs Client(...) inline in main's body, imports only the small harness allowlist, reaches no private mcp attributes, and that server_lowlevel.py never imports the high-level server module. - stories/README.md gains a "Canonical shape" section with the pasteable skeleton and the import rules; non-canonical stories state why in their module docstring; server names normalised to "<story>-example". - mrtr/ and subscriptions/ stubs note the lowlevel registration surface now exists upstream and they graduate once this branch's base has it.
1 parent 3893947 commit c439aeb

27 files changed

Lines changed: 199 additions & 28 deletions

File tree

examples/stories/README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,45 @@ One feature per folder. Each story is a small, self-verifying program: a
66
exits non-zero on failure. The code you read here is the same code CI runs —
77
there is no separate test double.
88

9+
## Canonical shape
10+
11+
Every `client.py` starts from this skeleton — copy it, then replace the body
12+
with the story's assertions:
13+
14+
```python
15+
"""One line: what this client proves."""
16+
17+
from mcp.client import Client
18+
from stories._harness import Target, run_client
19+
20+
21+
async def main(target: Target, *, mode: str = "auto") -> None:
22+
async with Client(target, mode=mode) as client:
23+
... # the story's assertions
24+
25+
26+
if __name__ == "__main__":
27+
run_client(main)
28+
```
29+
30+
There are exactly two `main` shapes. A story that opens **one** connection
31+
takes `main(target: Target, ...)`. A story that opens **more than one** sets
32+
`multi_connection = true` in [`manifest.toml`](manifest.toml), takes
33+
`main(targets: TargetFactory, ...)`, and calls `targets()` once per fresh
34+
connection — a `Client` cannot be re-entered after exit. Nothing else changes
35+
shape.
36+
37+
Story files import from `stories._harness` only these names: `run_client`,
38+
`target_from_args`, `Target`, `TargetFactory` — plus `AuthBuilder` for the
39+
auth stories. Everything else a story uses comes from public `mcp.*` modules.
40+
41+
The repetition this produces across stories is deliberate, not a refactor
42+
waiting to happen: each `client.py` is a standalone, compiled doc page, so
43+
when a public API changes, N red example files flag N doc pages. Don't pull
44+
the `Client(target, mode=mode)` line (or anything around it) into a shared
45+
helper. A story that can't be the canonical shape says why in its module
46+
docstring's first line.
47+
948
## How to read a story
1049

1150
Start with the story's README, then `server.py`, then `client.py`. Every

examples/stories/bearer_auth/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Call the bearer-gated server through an already-authed transport; assert the ``whoami`` principal."""
1+
"""Call the bearer-gated server through an already-authed (``build_auth``, HTTP-only) transport; assert ``whoami``."""
22

33
from collections.abc import Generator
44

examples/stories/bearer_auth/server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Resource-server-only bearer auth: ``TokenVerifier`` + ``AuthSettings`` → 401/PRM/principal."""
1+
"""Resource-server-only bearer auth: ``TokenVerifier``/``AuthSettings`` → 401/PRM/principal. Exports ``build_app()``."""
22

33
import time
44

examples/stories/custom_methods/server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ class SearchResult(types.Result):
2424

2525

2626
def build_server() -> Server[Any]:
27-
server = Server("acme-search")
27+
server = Server("custom-methods-example")
2828

2929
async def search(ctx: ServerRequestContext[Any], params: SearchParams) -> SearchResult:
3030
items = [f"{params.query}-{i}" for i in range(params.limit)]

examples/stories/dual_era/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Connect to the same server factory twice — once per era — and assert both are served."""
1+
"""Connect to the same server factory twice — once per era, so `main` takes `targets` — and assert both are served."""
22

33
from mcp import types
44
from mcp.client import Client

examples/stories/json_response/client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Regular ``Client`` against a JSON-only server; assert mid-call progress is dropped.
1+
"""Plain ``Client`` against a JSON-only server: mid-call progress drops. HTTP-only — ``main`` also takes ``http``.
22
33
``RAW_ENVELOPE_BODY`` / ``MODERN_HEADERS`` are the exact wire shape a 2026-era client
44
sends — this is the only story that shows it. ``main`` posts that body by hand and

examples/stories/json_response/server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Serve over Streamable HTTP with JSON responses (no SSE stream).
1+
"""Serve over Streamable HTTP with JSON responses (no SSE stream); HTTP-only, so this exports ``build_app()``.
22
33
The 2026-07-28 path is stateless and JSON-only by construction today; the
44
``json_response=True`` flag also forces JSON for the legacy (2025-era) branch on

examples/stories/legacy_elicitation/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
> [`mrtr/`](../mrtr/) story. Elicitation itself is **not** deprecated.
77
> TODO(maxisbey): unify once the MRTR runtime lands
88
> ([#2898](https://github.com/modelcontextprotocol/python-sdk/issues/2898)).
9+
> The TypeScript SDK ships a single dual-era `elicitation/` story; this
10+
> directory re-merges back into `elicitation/` once MRTR lands.
911
1012
A tool pauses mid-call to ask the user for structured input. On the
1113
handshake-era protocol the server pushes an `elicitation/create` *request* to

examples/stories/legacy_elicitation/server.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ class Registration(BaseModel):
1313

1414

1515
def build_server() -> MCPServer:
16-
mcp = MCPServer("elicitation-example")
16+
mcp = MCPServer("legacy-elicitation-example")
1717

1818
@mcp.tool(description="Register a new account by asking the user for their details.")
1919
async def register_user(ctx: Context) -> str:

examples/stories/legacy_elicitation/server_lowlevel.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolReques
5858
await ctx.session.send_elicit_complete(elicitation_id, related_request_id=ctx.request_id)
5959
return types.CallToolResult(content=[types.TextContent(text=f"linked {provider}")])
6060

61-
return Server("elicitation-example", on_list_tools=list_tools, on_call_tool=call_tool)
61+
return Server("legacy-elicitation-example", on_list_tools=list_tools, on_call_tool=call_tool)
6262

6363

6464
if __name__ == "__main__":

0 commit comments

Comments
 (0)