Skip to content

Commit 707cbaa

Browse files
committed
Address example-suite review findings
- legacy_elicitation (lowlevel): thread related_request_id through elicit_form/elicit_url so the request rides the originating POST's stream, and make elicitation ids unique per request. - legacy_routing: complete the CORS recipe (allow_methods/allow_headers) in both server variants. - Harness: HTTP-only stories exit with a friendly message instead of hanging when run without --http; drop the unused manifest smoke key; declare the tomli marker dependency on the examples package. - Docs: one two-variant run recipe everywhere, index rows describe the real Python surface, and assorted README corrections.
1 parent 6dd50e7 commit 707cbaa

21 files changed

Lines changed: 92 additions & 41 deletions

File tree

examples/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
Exercises every server capability in one process.
1414
- [`mcpserver/`](mcpserver/) — single-file v1-era examples retained for the
1515
migration guide; superseded by `stories/` and slated for removal.
16+
- [`clients/`](clients/) and the remaining [`servers/`](servers/) directories
17+
(`simple-*`, `sse-polling-demo`, `structured-output-lowlevel`) — standalone
18+
v1-era projects still linked from `README.v2.md`; retained pending
19+
consolidation into `stories/`.
1620

1721
For real-world servers see the
1822
[servers repository](https://github.com/modelcontextprotocol/servers).

examples/pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ name = "mcp-example-stories"
33
version = "0.0.0"
44
description = "Self-verifying example suite for the MCP Python SDK (dev-only, not published)"
55
requires-python = ">=3.10"
6-
dependencies = ["mcp"]
6+
dependencies = [
7+
"mcp",
8+
"tomli>=2.0; python_version < '3.11'",
9+
]
710

811
[build-system]
912
requires = ["hatchling"]

examples/stories/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ opens with a banner saying what replaces it.
120120
| [`error_handling`](error_handling/) | `is_error` results vs `MCPError`; `ToolError` | current |
121121
| [`serve_one`](serve_one/) | building a `Connection` by hand and calling `serve_one` directly | current |
122122
| **— HTTP hosting —** | | |
123-
| [`stateless_legacy`](stateless_legacy/) | `streamable_http_app()` default posture; the one-liner deploy | current |
123+
| [`stateless_legacy`](stateless_legacy/) | `streamable_http_app(stateless_http=True)`; the one-liner deploy | current |
124124
| [`json_response`](json_response/) | `json_response=True` mode; raw 2026 POST envelope on the wire | current |
125125
| [`legacy_routing`](legacy_routing/) | `classify_inbound_request()` era routing in front of a sessionful 1.x deploy | current |
126126
| [`starlette_mount`](starlette_mount/) | mounting `streamable_http_app()` under a Starlette/FastAPI sub-path | current |

examples/stories/_harness.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@ def run_client(main: Callable[..., Awaitable[None]]) -> None:
9292
targets = target_from_args(file)
9393
build_auth: AuthBuilder | None = globals_.get("build_auth")
9494
transport = "http" if "--http" in sys.argv else "stdio"
95+
if cfg["server_export"] == "app" and transport != "http":
96+
raise SystemExit(
97+
f"{name} exports an ASGI app (no stdio entry point); start its server, then run:\n"
98+
f" python -m stories.{name}.client --http http://127.0.0.1:8000{cfg['mcp_path']}"
99+
)
95100
# The era is an axis of the story matrix, so ``mode=`` is always passed explicitly
96101
# even though it often matches the ``Client`` default of "auto". stdio is legacy-only
97102
# until the SDK's stdio entry can negotiate the era, so only --http gets a modern arm.

examples/stories/bearer_auth/README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ uv run python -m stories.bearer_auth.server --port 8000 &
1919
# connect with the demo bearer token
2020
uv run python -m stories.bearer_auth.client --http http://127.0.0.1:8000/mcp
2121

22-
# lowlevel-API variant of the same app
23-
uv run python -m stories.bearer_auth.server_lowlevel --port 8001 &
24-
uv run python -m stories.bearer_auth.client --http http://127.0.0.1:8001/mcp
22+
# lowlevel server variant — same port, so stop the first server
23+
kill %1
24+
uv run python -m stories.bearer_auth.server_lowlevel --port 8000 &
25+
uv run python -m stories.bearer_auth.client --http http://127.0.0.1:8000/mcp
2526
```
2627

2728
`Client(url)` has no `auth=` passthrough, so a target built from a bare URL

examples/stories/dual_era/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ stays era-agnostic.
1414
uv run python -m stories.dual_era.server --http --port 8000 &
1515
uv run python -m stories.dual_era.client --http http://127.0.0.1:8000/mcp
1616

17-
# lowlevel server variant
17+
# lowlevel server variant — same port, so stop the first server
18+
kill %1
1819
uv run python -m stories.dual_era.server_lowlevel --http --port 8000 &
1920
uv run python -m stories.dual_era.client --http http://127.0.0.1:8000/mcp
2021
```

examples/stories/json_response/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,5 +59,6 @@ curl -s http://127.0.0.1:8000/mcp \
5959

6060
## See also
6161

62-
`stateless_legacy/` (the default posture), `legacy_routing/` (route by era at
63-
the entry), `streaming/` (progress that *is* delivered — over stdio/SSE).
62+
`stateless_legacy/` (the one-liner `stateless_http=True` deploy),
63+
`legacy_routing/` (route by era at the entry), `streaming/` (progress that *is*
64+
delivered — over stdio/SSE).

examples/stories/legacy_elicitation/client.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77

88
async def on_elicit(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult:
99
if isinstance(params, types.ElicitRequestURLParams):
10-
# A real client would open params.url in a browser, then wait for the matching
11-
# notifications/elicitation/complete before resolving.
10+
# A real client would ask consent and open params.url in a browser, returning
11+
# `accept` right away; the server's notifications/elicitation/complete arrives
12+
# afterward (once the out-of-band flow finishes) for the client to correlate.
1213
assert params.url.startswith("https://example.com/")
1314
return types.ElicitResult(action="accept")
1415
assert "username" in params.requested_schema["properties"]

examples/stories/legacy_elicitation/server.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ async def register_user(ctx: Context) -> str:
2424

2525
@mcp.tool(description="Link a third-party account by directing the user to a sign-in URL.")
2626
async def link_account(provider: str, ctx: Context) -> str:
27-
elicitation_id = f"link-{provider}"
27+
# elicitation_id must be unique per elicitation, not per provider — scope it to this request.
28+
elicitation_id = f"link-{provider}-{ctx.request_context.request_id}"
2829
answer = await ctx.elicit_url(
2930
f"Sign in to {provider} to link your account",
3031
url=f"https://example.com/oauth/{provider}/authorize",

examples/stories/legacy_elicitation/server_lowlevel.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,19 +39,23 @@ async def list_tools(
3939

4040
async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult:
4141
if params.name == "register_user":
42-
answer = await ctx.session.elicit_form("Please provide your registration details:", REGISTRATION_SCHEMA)
42+
answer = await ctx.session.elicit_form(
43+
"Please provide your registration details:", REGISTRATION_SCHEMA, related_request_id=ctx.request_id
44+
)
4345
if answer.action != "accept" or answer.content is None:
4446
return types.CallToolResult(content=[types.TextContent(text=f"registration {answer.action}")])
4547
text = f"registered {answer.content['username']} (plan: {answer.content.get('plan') or 'free'})"
4648
return types.CallToolResult(content=[types.TextContent(text=text)])
4749

4850
assert params.name == "link_account" and params.arguments is not None
4951
provider = params.arguments["provider"]
50-
elicitation_id = f"link-{provider}"
52+
# elicitation_id must be unique per elicitation, not per provider — scope it to this request.
53+
elicitation_id = f"link-{provider}-{ctx.request_id}"
5154
answer = await ctx.session.elicit_url(
5255
f"Sign in to {provider} to link your account",
5356
url=f"https://example.com/oauth/{provider}/authorize",
5457
elicitation_id=elicitation_id,
58+
related_request_id=ctx.request_id,
5559
)
5660
if answer.action != "accept":
5761
return types.CallToolResult(content=[types.TextContent(text=f"link {answer.action}")])

0 commit comments

Comments
 (0)