From 84d9520dceee31b57a2a94bc2de400a83407a1ca Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:12:07 +0000 Subject: [PATCH 01/14] 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 --- .github/workflows/shared.yml | 4 + examples/README.md | 21 +- examples/pyproject.toml | 13 + examples/stories/README.md | 89 ++++ examples/stories/__init__.py | 6 + examples/stories/_harness.py | 117 ++++++ examples/stories/_hosting.py | 87 ++++ examples/stories/_shared/__init__.py | 1 + examples/stories/_shared/auth.py | 159 +++++++ examples/stories/apps/README.md | 14 + examples/stories/bearer_auth/README.md | 80 ++++ examples/stories/bearer_auth/__init__.py | 0 examples/stories/bearer_auth/client.py | 57 +++ examples/stories/bearer_auth/server.py | 56 +++ .../stories/bearer_auth/server_lowlevel.py | 56 +++ examples/stories/caching/README.md | 20 + examples/stories/client_session/README.md | 66 +++ examples/stories/client_session/__init__.py | 0 examples/stories/client_session/client.py | 33 ++ examples/stories/client_session/server.py | 19 + examples/stories/custom_methods/README.md | 51 +++ examples/stories/custom_methods/__init__.py | 0 examples/stories/custom_methods/client.py | 36 ++ examples/stories/custom_methods/server.py | 38 ++ examples/stories/custom_version/README.md | 47 +++ examples/stories/custom_version/__init__.py | 0 examples/stories/custom_version/client.py | 22 + examples/stories/custom_version/server.py | 19 + .../stories/custom_version/server_lowlevel.py | 33 ++ examples/stories/dual_era/README.md | 53 +++ examples/stories/dual_era/__init__.py | 0 examples/stories/dual_era/client.py | 38 ++ examples/stories/dual_era/server.py | 22 + examples/stories/dual_era/server_lowlevel.py | 47 +++ examples/stories/elicitation/README.md | 66 +++ examples/stories/elicitation/__init__.py | 0 examples/stories/elicitation/client.py | 34 ++ examples/stories/elicitation/server.py | 46 +++ .../stories/elicitation/server_lowlevel.py | 65 +++ examples/stories/error_handling/README.md | 51 +++ examples/stories/error_handling/__init__.py | 0 examples/stories/error_handling/client.py | 36 ++ examples/stories/error_handling/server.py | 34 ++ .../stories/error_handling/server_lowlevel.py | 44 ++ examples/stories/events/README.md | 21 + examples/stories/json_response/README.md | 58 +++ examples/stories/json_response/__init__.py | 0 examples/stories/json_response/client.py | 87 ++++ examples/stories/json_response/server.py | 27 ++ .../stories/json_response/server_lowlevel.py | 44 ++ examples/stories/legacy_routing/README.md | 95 +++++ examples/stories/legacy_routing/__init__.py | 0 examples/stories/legacy_routing/client.py | 56 +++ examples/stories/legacy_routing/server.py | 55 +++ .../stories/legacy_routing/server_lowlevel.py | 42 ++ examples/stories/lifespan/README.md | 45 ++ examples/stories/lifespan/__init__.py | 0 examples/stories/lifespan/client.py | 20 + examples/stories/lifespan/server.py | 39 ++ examples/stories/lifespan/server_lowlevel.py | 65 +++ examples/stories/manifest.toml | 139 +++++++ examples/stories/middleware/README.md | 50 +++ examples/stories/middleware/__init__.py | 0 examples/stories/middleware/client.py | 24 ++ examples/stories/middleware/server.py | 35 ++ .../stories/middleware/server_lowlevel.py | 49 +++ examples/stories/mrtr/README.md | 26 ++ examples/stories/oauth/README.md | 70 ++++ examples/stories/oauth/__init__.py | 0 examples/stories/oauth/client.py | 89 ++++ examples/stories/oauth/server.py | 40 ++ examples/stories/oauth/server_lowlevel.py | 58 +++ .../oauth_client_credentials/README.md | 65 +++ .../oauth_client_credentials/__init__.py | 0 .../oauth_client_credentials/client.py | 57 +++ .../oauth_client_credentials/server.py | 77 ++++ .../server_lowlevel.py | 82 ++++ examples/stories/pagination/README.md | 49 +++ examples/stories/pagination/__init__.py | 0 examples/stories/pagination/client.py | 26 ++ examples/stories/pagination/server.py | 24 ++ .../stories/pagination/server_lowlevel.py | 35 ++ examples/stories/parallel_calls/README.md | 48 +++ examples/stories/parallel_calls/__init__.py | 0 examples/stories/parallel_calls/client.py | 40 ++ examples/stories/parallel_calls/server.py | 31 ++ .../stories/parallel_calls/server_lowlevel.py | 48 +++ examples/stories/prompts/README.md | 46 +++ examples/stories/prompts/__init__.py | 0 examples/stories/prompts/client.py | 37 ++ examples/stories/prompts/server.py | 42 ++ examples/stories/prompts/server_lowlevel.py | 86 ++++ examples/stories/reconnect/README.md | 58 +++ examples/stories/reconnect/__init__.py | 0 examples/stories/reconnect/client.py | 49 +++ examples/stories/reconnect/server.py | 23 ++ examples/stories/reconnect/server_lowlevel.py | 47 +++ examples/stories/resources/README.md | 48 +++ examples/stories/resources/__init__.py | 0 examples/stories/resources/client.py | 28 ++ examples/stories/resources/server.py | 24 ++ examples/stories/resources/server_lowlevel.py | 64 +++ examples/stories/roots/README.md | 54 +++ examples/stories/roots/__init__.py | 0 examples/stories/roots/client.py | 36 ++ examples/stories/roots/server.py | 19 + examples/stories/roots/server_lowlevel.py | 35 ++ examples/stories/sampling/README.md | 56 +++ examples/stories/sampling/__init__.py | 0 examples/stories/sampling/client.py | 34 ++ examples/stories/sampling/server.py | 24 ++ examples/stories/sampling/server_lowlevel.py | 44 ++ examples/stories/schema_validators/README.md | 48 +++ .../stories/schema_validators/__init__.py | 0 examples/stories/schema_validators/client.py | 36 ++ examples/stories/schema_validators/server.py | 55 +++ .../schema_validators/server_lowlevel.py | 54 +++ examples/stories/serve_one/README.md | 66 +++ examples/stories/serve_one/__init__.py | 0 examples/stories/serve_one/client.py | 38 ++ examples/stories/serve_one/server.py | 35 ++ examples/stories/serve_one/server_lowlevel.py | 107 +++++ examples/stories/skills/README.md | 14 + examples/stories/sse_polling/README.md | 66 +++ examples/stories/sse_polling/__init__.py | 0 examples/stories/sse_polling/client.py | 31 ++ examples/stories/sse_polling/event_store.py | 33 ++ examples/stories/sse_polling/server.py | 35 ++ .../stories/sse_polling/server_lowlevel.py | 45 ++ examples/stories/standalone_get/README.md | 57 +++ examples/stories/standalone_get/__init__.py | 0 examples/stories/standalone_get/client.py | 48 +++ examples/stories/standalone_get/server.py | 30 ++ .../stories/standalone_get/server_lowlevel.py | 48 +++ examples/stories/starlette_mount/README.md | 47 +++ examples/stories/starlette_mount/__init__.py | 0 examples/stories/starlette_mount/client.py | 21 + examples/stories/starlette_mount/server.py | 47 +++ examples/stories/stateless_legacy/README.md | 54 +++ examples/stories/stateless_legacy/__init__.py | 0 examples/stories/stateless_legacy/client.py | 35 ++ examples/stories/stateless_legacy/server.py | 22 + .../stateless_legacy/server_lowlevel.py | 38 ++ examples/stories/stickynotes/README.md | 58 +++ examples/stories/stickynotes/__init__.py | 0 examples/stories/stickynotes/client.py | 92 +++++ examples/stories/stickynotes/server.py | 93 +++++ .../stories/stickynotes/server_lowlevel.py | 118 ++++++ examples/stories/streaming/README.md | 69 ++++ examples/stories/streaming/__init__.py | 0 examples/stories/streaming/client.py | 61 +++ examples/stories/streaming/server.py | 40 ++ examples/stories/streaming/server_lowlevel.py | 69 ++++ examples/stories/subscriptions/README.md | 24 ++ examples/stories/tasks/README.md | 16 + examples/stories/tools/README.md | 38 ++ examples/stories/tools/__init__.py | 0 examples/stories/tools/client.py | 30 ++ examples/stories/tools/server.py | 37 ++ examples/stories/tools/server_lowlevel.py | 71 ++++ pyproject.toml | 14 +- tests/examples/__init__.py | 0 tests/examples/conftest.py | 208 ++++++++++ tests/examples/test_stories.py | 82 ++++ tests/examples/test_stories_smoke.py | 101 +++++ uv.lock | 390 +----------------- 166 files changed, 6686 insertions(+), 385 deletions(-) create mode 100644 examples/pyproject.toml create mode 100644 examples/stories/README.md create mode 100644 examples/stories/__init__.py create mode 100644 examples/stories/_harness.py create mode 100644 examples/stories/_hosting.py create mode 100644 examples/stories/_shared/__init__.py create mode 100644 examples/stories/_shared/auth.py create mode 100644 examples/stories/apps/README.md create mode 100644 examples/stories/bearer_auth/README.md create mode 100644 examples/stories/bearer_auth/__init__.py create mode 100644 examples/stories/bearer_auth/client.py create mode 100644 examples/stories/bearer_auth/server.py create mode 100644 examples/stories/bearer_auth/server_lowlevel.py create mode 100644 examples/stories/caching/README.md create mode 100644 examples/stories/client_session/README.md create mode 100644 examples/stories/client_session/__init__.py create mode 100644 examples/stories/client_session/client.py create mode 100644 examples/stories/client_session/server.py create mode 100644 examples/stories/custom_methods/README.md create mode 100644 examples/stories/custom_methods/__init__.py create mode 100644 examples/stories/custom_methods/client.py create mode 100644 examples/stories/custom_methods/server.py create mode 100644 examples/stories/custom_version/README.md create mode 100644 examples/stories/custom_version/__init__.py create mode 100644 examples/stories/custom_version/client.py create mode 100644 examples/stories/custom_version/server.py create mode 100644 examples/stories/custom_version/server_lowlevel.py create mode 100644 examples/stories/dual_era/README.md create mode 100644 examples/stories/dual_era/__init__.py create mode 100644 examples/stories/dual_era/client.py create mode 100644 examples/stories/dual_era/server.py create mode 100644 examples/stories/dual_era/server_lowlevel.py create mode 100644 examples/stories/elicitation/README.md create mode 100644 examples/stories/elicitation/__init__.py create mode 100644 examples/stories/elicitation/client.py create mode 100644 examples/stories/elicitation/server.py create mode 100644 examples/stories/elicitation/server_lowlevel.py create mode 100644 examples/stories/error_handling/README.md create mode 100644 examples/stories/error_handling/__init__.py create mode 100644 examples/stories/error_handling/client.py create mode 100644 examples/stories/error_handling/server.py create mode 100644 examples/stories/error_handling/server_lowlevel.py create mode 100644 examples/stories/events/README.md create mode 100644 examples/stories/json_response/README.md create mode 100644 examples/stories/json_response/__init__.py create mode 100644 examples/stories/json_response/client.py create mode 100644 examples/stories/json_response/server.py create mode 100644 examples/stories/json_response/server_lowlevel.py create mode 100644 examples/stories/legacy_routing/README.md create mode 100644 examples/stories/legacy_routing/__init__.py create mode 100644 examples/stories/legacy_routing/client.py create mode 100644 examples/stories/legacy_routing/server.py create mode 100644 examples/stories/legacy_routing/server_lowlevel.py create mode 100644 examples/stories/lifespan/README.md create mode 100644 examples/stories/lifespan/__init__.py create mode 100644 examples/stories/lifespan/client.py create mode 100644 examples/stories/lifespan/server.py create mode 100644 examples/stories/lifespan/server_lowlevel.py create mode 100644 examples/stories/manifest.toml create mode 100644 examples/stories/middleware/README.md create mode 100644 examples/stories/middleware/__init__.py create mode 100644 examples/stories/middleware/client.py create mode 100644 examples/stories/middleware/server.py create mode 100644 examples/stories/middleware/server_lowlevel.py create mode 100644 examples/stories/mrtr/README.md create mode 100644 examples/stories/oauth/README.md create mode 100644 examples/stories/oauth/__init__.py create mode 100644 examples/stories/oauth/client.py create mode 100644 examples/stories/oauth/server.py create mode 100644 examples/stories/oauth/server_lowlevel.py create mode 100644 examples/stories/oauth_client_credentials/README.md create mode 100644 examples/stories/oauth_client_credentials/__init__.py create mode 100644 examples/stories/oauth_client_credentials/client.py create mode 100644 examples/stories/oauth_client_credentials/server.py create mode 100644 examples/stories/oauth_client_credentials/server_lowlevel.py create mode 100644 examples/stories/pagination/README.md create mode 100644 examples/stories/pagination/__init__.py create mode 100644 examples/stories/pagination/client.py create mode 100644 examples/stories/pagination/server.py create mode 100644 examples/stories/pagination/server_lowlevel.py create mode 100644 examples/stories/parallel_calls/README.md create mode 100644 examples/stories/parallel_calls/__init__.py create mode 100644 examples/stories/parallel_calls/client.py create mode 100644 examples/stories/parallel_calls/server.py create mode 100644 examples/stories/parallel_calls/server_lowlevel.py create mode 100644 examples/stories/prompts/README.md create mode 100644 examples/stories/prompts/__init__.py create mode 100644 examples/stories/prompts/client.py create mode 100644 examples/stories/prompts/server.py create mode 100644 examples/stories/prompts/server_lowlevel.py create mode 100644 examples/stories/reconnect/README.md create mode 100644 examples/stories/reconnect/__init__.py create mode 100644 examples/stories/reconnect/client.py create mode 100644 examples/stories/reconnect/server.py create mode 100644 examples/stories/reconnect/server_lowlevel.py create mode 100644 examples/stories/resources/README.md create mode 100644 examples/stories/resources/__init__.py create mode 100644 examples/stories/resources/client.py create mode 100644 examples/stories/resources/server.py create mode 100644 examples/stories/resources/server_lowlevel.py create mode 100644 examples/stories/roots/README.md create mode 100644 examples/stories/roots/__init__.py create mode 100644 examples/stories/roots/client.py create mode 100644 examples/stories/roots/server.py create mode 100644 examples/stories/roots/server_lowlevel.py create mode 100644 examples/stories/sampling/README.md create mode 100644 examples/stories/sampling/__init__.py create mode 100644 examples/stories/sampling/client.py create mode 100644 examples/stories/sampling/server.py create mode 100644 examples/stories/sampling/server_lowlevel.py create mode 100644 examples/stories/schema_validators/README.md create mode 100644 examples/stories/schema_validators/__init__.py create mode 100644 examples/stories/schema_validators/client.py create mode 100644 examples/stories/schema_validators/server.py create mode 100644 examples/stories/schema_validators/server_lowlevel.py create mode 100644 examples/stories/serve_one/README.md create mode 100644 examples/stories/serve_one/__init__.py create mode 100644 examples/stories/serve_one/client.py create mode 100644 examples/stories/serve_one/server.py create mode 100644 examples/stories/serve_one/server_lowlevel.py create mode 100644 examples/stories/skills/README.md create mode 100644 examples/stories/sse_polling/README.md create mode 100644 examples/stories/sse_polling/__init__.py create mode 100644 examples/stories/sse_polling/client.py create mode 100644 examples/stories/sse_polling/event_store.py create mode 100644 examples/stories/sse_polling/server.py create mode 100644 examples/stories/sse_polling/server_lowlevel.py create mode 100644 examples/stories/standalone_get/README.md create mode 100644 examples/stories/standalone_get/__init__.py create mode 100644 examples/stories/standalone_get/client.py create mode 100644 examples/stories/standalone_get/server.py create mode 100644 examples/stories/standalone_get/server_lowlevel.py create mode 100644 examples/stories/starlette_mount/README.md create mode 100644 examples/stories/starlette_mount/__init__.py create mode 100644 examples/stories/starlette_mount/client.py create mode 100644 examples/stories/starlette_mount/server.py create mode 100644 examples/stories/stateless_legacy/README.md create mode 100644 examples/stories/stateless_legacy/__init__.py create mode 100644 examples/stories/stateless_legacy/client.py create mode 100644 examples/stories/stateless_legacy/server.py create mode 100644 examples/stories/stateless_legacy/server_lowlevel.py create mode 100644 examples/stories/stickynotes/README.md create mode 100644 examples/stories/stickynotes/__init__.py create mode 100644 examples/stories/stickynotes/client.py create mode 100644 examples/stories/stickynotes/server.py create mode 100644 examples/stories/stickynotes/server_lowlevel.py create mode 100644 examples/stories/streaming/README.md create mode 100644 examples/stories/streaming/__init__.py create mode 100644 examples/stories/streaming/client.py create mode 100644 examples/stories/streaming/server.py create mode 100644 examples/stories/streaming/server_lowlevel.py create mode 100644 examples/stories/subscriptions/README.md create mode 100644 examples/stories/tasks/README.md create mode 100644 examples/stories/tools/README.md create mode 100644 examples/stories/tools/__init__.py create mode 100644 examples/stories/tools/client.py create mode 100644 examples/stories/tools/server.py create mode 100644 examples/stories/tools/server_lowlevel.py create mode 100644 tests/examples/__init__.py create mode 100644 tests/examples/conftest.py create mode 100644 tests/examples/test_stories.py create mode 100644 tests/examples/test_stories_smoke.py diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index cdf2037332..21a70f46ef 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -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 diff --git a/examples/README.md b/examples/README.md index 5ed4dd55f5..17be7cdbb0 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,5 +1,18 @@ -# 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. + +For real-world servers see the +[servers repository](https://github.com/modelcontextprotocol/servers). diff --git a/examples/pyproject.toml b/examples/pyproject.toml new file mode 100644 index 0000000000..237103cc8a --- /dev/null +++ b/examples/pyproject.toml @@ -0,0 +1,13 @@ +[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"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["stories"] diff --git a/examples/stories/README.md b/examples/stories/README.md new file mode 100644 index 0000000000..bab972fc95 --- /dev/null +++ b/examples/stories/README.md @@ -0,0 +1,89 @@ +# 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 `scenario(client)` 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. + +## 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 + +# against a running HTTP server +uv run python -m stories.tools.server --http --port 8000 & +uv run python -m stories.tools.client --http http://127.0.0.1:8000/mcp +``` + +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, and +variants; `tests/examples/` expands it. + +## Layout + +`_harness.py` and `_hosting.py` are scaffolding that adapts a story's +`build_server()` / `build_app()` to argv (stdio vs `--http`) and to the +in-process test bridge. 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 + +| story | what it shows | status | +|---|---|---| +| **— start here —** | | | +| [`tools`](tools/) | `@mcp.tool()`, schema inference, structured output, annotations | ready | +| [`prompts`](prompts/) | `@mcp.prompt()`, list/get, argument completion | ready | +| [`resources`](resources/) | `@mcp.resource()`, list/read, URI templates | ready | +| [`lifespan`](lifespan/) | startup/shutdown lifespan, per-request state injection | ready | +| [`dual_era`](dual_era/) | one server factory serving both protocol eras; era-neutral accessors | ready | +| [`custom_version`](custom_version/) | restricting `supported_protocol_versions` | ready | +| **— feature stories —** | | | +| [`streaming`](streaming/) | progress notifications, in-flight logging, cancellation | ready | +| [`elicitation`](elicitation/) | server pauses a tool to ask the user (form + url) | ready (legacy-era) | +| [`sampling`](sampling/) | server asks the client's LLM mid-tool (push request) | ready (legacy-era) | +| [`stickynotes`](stickynotes/) | capstone: tools mutate state → resources + `list_changed` + elicit guard | ready | +| [`custom_methods`](custom_methods/) | vendor-prefixed JSON-RPC via `add_request_handler` / `send_request` | ready | +| [`schema_validators`](schema_validators/) | tool input schema from pydantic / TypedDict / dataclass / dict | ready | +| [`middleware`](middleware/) | server-side request/response middleware | ready | +| [`parallel_calls`](parallel_calls/) | N×M concurrent calls; per-call notification attribution | ready | +| [`roots`](roots/) | client-declared roots, server reads them via `ctx` | ready (legacy-era) | +| [`pagination`](pagination/) | manual cursor loop over list endpoints | ready | +| [`error_handling`](error_handling/) | `is_error` results vs `MCPError`; `ToolError` | ready | +| [`client_session`](client_session/) | dropping to `client.session` / `ClientSession` mechanics | ready | +| [`serve_one`](serve_one/) | building a `Connection` by hand and calling `serve_one` directly | ready | +| **— HTTP hosting —** | | | +| [`stateless_legacy`](stateless_legacy/) | `streamable_http_app()` default posture; the one-liner deploy | ready | +| [`json_response`](json_response/) | `json_response=True` mode; raw 2026 POST envelope on the wire | ready | +| [`legacy_routing`](legacy_routing/) | `is_legacy_request()` classifier in front of a sessionful 1.x deploy | ready | +| [`starlette_mount`](starlette_mount/) | mounting `streamable_http_app()` under a Starlette/FastAPI sub-path | ready | +| [`sse_polling`](sse_polling/) | SEP-1699 `closeSSE()` + `Last-Event-ID` resume via `EventStore` | ready | +| [`standalone_get`](standalone_get/) | server-initiated `list_changed` over the sessionful GET stream | ready | +| [`reconnect`](reconnect/) | explicit `discover()`, persist `DiscoverResult`, zero-RTT reconnect | ready | +| [`bearer_auth`](bearer_auth/) | `requireBearerAuth`, PRM metadata, static-token verifier, `ctx.authInfo` | ready | +| [`oauth`](oauth/) | full `authorization_code` grant against an in-process AS | ready | +| [`oauth_client_credentials`](oauth_client_credentials/) | `client_credentials` grant; minimal in-process token endpoint | ready | +| **— 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/`. diff --git a/examples/stories/__init__.py b/examples/stories/__init__.py new file mode 100644 index 0000000000..a3db033cda --- /dev/null +++ b/examples/stories/__init__.py @@ -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 ``scenario(client)`` runs against both. +``tests/examples/`` drives every story over an in-process matrix. +""" diff --git a/examples/stories/_harness.py b/examples/stories/_harness.py new file mode 100644 index 0000000000..baf3081fee --- /dev/null +++ b/examples/stories/_harness.py @@ -0,0 +1,117 @@ +"""Client-side scaffold for story examples. + +A story's ``client.py`` imports only from here. The ``Connect`` factory and +``run_client`` ride the locked ``Client(transport, mode=...)`` surface; the one +volatile line is the stdio wrap (marked inline). +""" + +from __future__ import annotations + +import sys +import traceback +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from pathlib import Path +from typing import Any, Protocol + +import anyio +import httpx + +from mcp import StdioServerParameters, stdio_client +from mcp.client import Client +from mcp.shared.version import LATEST_MODERN_VERSION + +Scenario = Callable[[Client], Awaitable[None]] +ScenarioWithConnect = Callable[[Client, "Connect"], Awaitable[None]] +AuthBuilder = Callable[[httpx.AsyncClient], httpx.Auth] +"""Builds an ``httpx.Auth`` bound to the in-process HTTP client (auth-story harness seam).""" + + +class Connect(Protocol): + """A factory yielding a connected ``Client``; accepts the same kwargs as ``Client``. + + ``auth`` is the HTTP-only escape hatch for auth stories: when given, the factory + builds a fresh ``httpx.AsyncClient`` against the same app, applies ``auth(http)`` + to it, and wraps the result in ``streamable_http_client`` before entering ``Client``. + """ + + def __call__(self, *, auth: AuthBuilder | None = None, **client_kw: Any) -> AbstractAsyncContextManager[Client]: ... + + +def argv_after(flag: str, *, default: str | None = None) -> str: + """Return the argv token following ``flag``, or ``default`` when the flag is absent.""" + try: + return sys.argv[sys.argv.index(flag) + 1] + except ValueError: + if default is None: + raise SystemExit(f"missing required {flag}") from None + return default + + +def connect_from_args(file: str) -> Connect: + """Build a ``Connect`` targeting the sibling server over the argv-selected transport. + + ``--http `` connects over streamable HTTP; ``--stdio`` (the default) spawns the + sibling ``server.py`` as a subprocess. ``--server `` selects ``.py`` + (e.g. ``server_lowlevel``). ``--legacy`` pins the handshake era; otherwise the + modern era is used. ``file`` is the caller's ``__file__``. + """ + here = Path(file).parent + server_stem = argv_after("--server", default="server") + # Never rely on the SDK's mode= default — be explicit. stdio is legacy-only until + # the SDK's stdio entry can negotiate the era; the modern arm is --http only for now. + if "--http" in sys.argv: + mode = "legacy" if "--legacy" in sys.argv else LATEST_MODERN_VERSION + else: + mode = "legacy" # stdio gains a modern arm once serve_stdio() lands + + @asynccontextmanager + async def _connect(*, auth: AuthBuilder | None = None, **client_kw: Any) -> AsyncIterator[Client]: + assert auth is None, "auth= via connect_from_args is not wired; auth stories own their __main__" + client_kw.setdefault("mode", mode) + target: Any + if "--http" in sys.argv: + target = argv_after("--http") + else: + params = StdioServerParameters(command=sys.executable, args=[str(here / f"{server_stem}.py")]) + target = stdio_client(params) # becomes Client(params) once that overload lands + async with Client(target, **client_kw) as client: + yield client + + return _connect + + +def run_client( + scenario: Scenario | ScenarioWithConnect, + *, + connect: Connect, + needs_connect: bool = False, + **client_kw: Any, +) -> None: + """Entry point for ``if __name__ == "__main__"`` in every ``client.py``. + + Runs ``scenario`` inside a connected client; prints ``OK:``/``FAIL:`` to stderr and + exits 0/1. ``needs_connect=True`` passes ``connect`` as the second argument so the + scenario can open additional clients. + """ + file = getattr(scenario, "__globals__", {}).get("__file__", "") + name = Path(file).parent.name + transport = "http" if "--http" in sys.argv else "stdio" + era = "modern" if transport == "http" and "--legacy" not in sys.argv else "legacy" + + async def _main() -> None: + with anyio.fail_after(30): + async with connect(**client_kw) as client: + if needs_connect: + await scenario(client, connect) # type: ignore[call-arg] + else: + await scenario(client) # type: ignore[call-arg] + + try: + anyio.run(_main) + except Exception: + print(f"FAIL: {name} ({transport}/{era})", file=sys.stderr) + traceback.print_exc() + raise SystemExit(1) from None + print(f"OK: {name} ({transport}/{era})", file=sys.stderr) + raise SystemExit(0) diff --git a/examples/stories/_hosting.py b/examples/stories/_hosting.py new file mode 100644 index 0000000000..041778677d --- /dev/null +++ b/examples/stories/_hosting.py @@ -0,0 +1,87 @@ +"""Server-side hosting scaffold for story examples. + +A story's ``server.py`` / ``server_lowlevel.py`` imports only from here. The +marked lines touch entry-point APIs that a later release reshapes into +free-function entries; isolating them here keeps story bodies stable. +""" + +from __future__ import annotations + +import sys +from collections.abc import Callable +from typing import Any, TypeAlias + +import anyio +import uvicorn +from starlette.applications import Starlette + +from mcp.server.lowlevel import Server +from mcp.server.mcpserver import MCPServer +from mcp.server.stdio import stdio_server +from mcp.server.transport_security import TransportSecuritySettings + +AnyServer: TypeAlias = "MCPServer | Server[Any]" +ServerFactory = Callable[[], AnyServer] +AppFactory = Callable[[], Starlette] + +NO_DNS_REBIND = TransportSecuritySettings(enable_dns_rebinding_protection=False) +"""Harness servers bind 127.0.0.1 and the in-process httpx client sends no Origin header.""" + + +def argv_after(flag: str, *, default: str | None = None) -> str: + """Return the argv token following ``flag``, or ``default`` when the flag is absent.""" + try: + return sys.argv[sys.argv.index(flag) + 1] + except ValueError: + if default is None: + raise SystemExit(f"missing required {flag}") from None + return default + + +def asgi_from(server: AnyServer, *, path: str = "/mcp") -> Starlette: + """Wrap a server instance in its streamable-HTTP ASGI app for in-process driving.""" + return server.streamable_http_app( # becomes free fn streamable_http(server, legacy=...) + streamable_http_path=path, + stateless_http=False, # bool folds into a legacy= enum in a later release + transport_security=NO_DNS_REBIND, + ) + + +def run_server_from_args(build_server: ServerFactory) -> None: + """Entry point for ``if __name__ == "__main__"`` in every ``server*.py``. + + Bare argv serves over stdio; ``--http --port N [--path /mcp]`` serves over + uvicorn on 127.0.0.1:N. + """ + server = build_server() + if "--http" in sys.argv: + port = int(argv_after("--port", default="8000")) + path = argv_after("--path", default="/mcp") + anyio.run(_serve_http, server, port, path) + else: + anyio.run(_serve_stdio, server) + + +async def _serve_stdio(server: AnyServer) -> None: + if isinstance(server, MCPServer): + await server.run_stdio_async() # becomes await serve_stdio(server) + else: + async with stdio_server() as (read, write): # becomes await serve_stdio(server) + await server.run(read, write, server.create_initialization_options()) + + +async def _serve_http(server: AnyServer, port: int, path: str) -> None: + app = asgi_from(server, path=path) + config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error") + await uvicorn.Server(config).serve() + + +def run_app_from_args(build_app: AppFactory) -> None: + """Entry point for ``if __name__ == "__main__"`` in app-exporting ``server*.py``. + + App-exporting stories are HTTP-only; ``--port N`` serves the Starlette app over + uvicorn on 127.0.0.1:N (uvicorn drives the app's own lifespan). No stdio leg. + """ + port = int(argv_after("--port", default="8000")) + config = uvicorn.Config(build_app(), host="127.0.0.1", port=port, log_level="error") + anyio.run(uvicorn.Server(config).serve) diff --git a/examples/stories/_shared/__init__.py b/examples/stories/_shared/__init__.py new file mode 100644 index 0000000000..bf9e14872e --- /dev/null +++ b/examples/stories/_shared/__init__.py @@ -0,0 +1 @@ +"""Shared scaffolding the auth/hosting stories import (not teaching surface).""" diff --git a/examples/stories/_shared/auth.py b/examples/stories/_shared/auth.py new file mode 100644 index 0000000000..63079ad6fc --- /dev/null +++ b/examples/stories/_shared/auth.py @@ -0,0 +1,159 @@ +"""Minimal in-process OAuth pieces for the auth stories. + +A story-shaped subset; ``tests/interaction/auth`` keeps its own (richer) provider. +""" + +from __future__ import annotations + +import os +import secrets +import time +from urllib.parse import parse_qs, urlsplit + +import httpx +from pydantic import AnyHttpUrl + +from mcp.server.auth.provider import ( + AccessToken, + AuthorizationCode, + AuthorizationParams, + OAuthAuthorizationServerProvider, + RefreshToken, + construct_redirect_uri, +) +from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions +from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthToken + +BASE_URL = "http://127.0.0.1:8000" +MCP_URL = f"{BASE_URL}/mcp" +REDIRECT_URI = f"{BASE_URL}/oauth/callback" + + +class InMemoryTokenStorage: + """A ``TokenStorage`` that keeps tokens and DCR client info on instance attributes.""" + + tokens: OAuthToken | None = None + client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self.tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + self.tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self.client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self.client_info = client_info + + +class HeadlessOAuth: + """Completes the authorize redirect in-process via the bound ``httpx`` client.""" + + def __init__(self) -> None: + self.authorize_url: str | None = None + self._http: httpx.AsyncClient | None = None + self._result = AuthorizationCodeResult(code="", state=None) + + def bind(self, http_client: httpx.AsyncClient) -> None: + self._http = http_client + + async def redirect_handler(self, authorization_url: str) -> None: + assert self._http is not None + self.authorize_url = authorization_url + # ``auth=None`` is load-bearing: re-entering the locked auth flow would deadlock. + response = await self._http.get(authorization_url, follow_redirects=False, auth=None) + assert response.status_code == 302, f"authorize returned {response.status_code}: {response.text}" + params = parse_qs(urlsplit(response.headers["location"]).query) + self._result = AuthorizationCodeResult(code=params.get("code", [""])[0], state=params.get("state", [None])[0]) + + async def callback_handler(self) -> AuthorizationCodeResult: + return self._result + + +class InMemoryAuthorizationServerProvider( + OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken] +): + """Minimal demo AS: DCR + authorize + auth-code exchange held in instance dicts. + + ``authorize`` auto-consents only when ``OAUTH_DEMO_AUTO_CONSENT=1``; otherwise it redirects + with ``error=interaction_required`` so a manual run shows where a real browser would open. + """ + + def __init__(self) -> None: + self.clients: dict[str, OAuthClientInformationFull] = {} + self.codes: dict[str, AuthorizationCode] = {} + self.access_tokens: dict[str, AccessToken] = {} + + def mint_access_token(self, *, client_id: str, scopes: list[str], resource: str | None = None) -> str: + access = f"access_{secrets.token_hex(16)}" + self.access_tokens[access] = AccessToken( + token=access, client_id=client_id, scopes=scopes, expires_at=int(time.time()) + 3600, resource=resource + ) + return access + + async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: + return self.clients.get(client_id) + + async def register_client(self, client_info: OAuthClientInformationFull) -> None: + assert client_info.client_id is not None + self.clients[client_info.client_id] = client_info + + async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: + target = str(params.redirect_uri) + if os.environ.get("OAUTH_DEMO_AUTO_CONSENT") != "1": + return construct_redirect_uri(target, error="interaction_required", state=params.state) + assert client.client_id is not None + code = AuthorizationCode( + code=f"code_{secrets.token_hex(16)}", + client_id=client.client_id, + scopes=params.scopes or ["mcp"], + expires_at=time.time() + 300, + code_challenge=params.code_challenge, + redirect_uri=params.redirect_uri, + redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly, + resource=params.resource, + ) + self.codes[code.code] = code + return construct_redirect_uri(target, code=code.code, state=params.state) + + async def load_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: str + ) -> AuthorizationCode | None: + return self.codes.get(authorization_code) + + async def exchange_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode + ) -> OAuthToken: + scopes = authorization_code.scopes + access = self.mint_access_token( + client_id=authorization_code.client_id, scopes=scopes, resource=authorization_code.resource + ) + del self.codes[authorization_code.code] + return OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=" ".join(scopes)) + + async def load_access_token(self, token: str) -> AccessToken | None: + return self.access_tokens.get(token) + + async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None: + raise NotImplementedError + + async def exchange_refresh_token( + self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str] + ) -> OAuthToken: + raise NotImplementedError + + async def revoke_token(self, token: AccessToken | RefreshToken) -> None: + raise NotImplementedError + + +def auth_settings(*, required_scopes: list[str] | None = None) -> AuthSettings: + """``AuthSettings`` for the co-hosted demo AS+RS on the loopback origin, DCR enabled.""" + scopes = required_scopes or ["mcp"] + return AuthSettings( + issuer_url=AnyHttpUrl(BASE_URL), + resource_server_url=AnyHttpUrl(MCP_URL), + required_scopes=scopes, + client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=scopes, default_scopes=scopes), + ) diff --git a/examples/stories/apps/README.md b/examples/stories/apps/README.md new file mode 100644 index 0000000000..b802525fa0 --- /dev/null +++ b/examples/stories/apps/README.md @@ -0,0 +1,14 @@ +# apps + +MCP Apps: a tool result carries a `_meta.ui` reference to a `ui://` resource +that the host renders as an interactive surface. The story will register a +`@ui` resource and return it from a tool. + +**Status: not yet implemented** ([#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896)). +The `extensions` capability map is not yet surfaced on `MCPServer`, so a server +cannot advertise Apps support and a client cannot negotiate it. + +## Spec + +[MCP Apps — extensions](https://modelcontextprotocol.io/specification/draft/extensions/apps) +· [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133) diff --git a/examples/stories/bearer_auth/README.md b/examples/stories/bearer_auth/README.md new file mode 100644 index 0000000000..1ca7f3f717 --- /dev/null +++ b/examples/stories/bearer_auth/README.md @@ -0,0 +1,80 @@ +# bearer-auth + +Resource-server-only bearer auth. Pass a `TokenVerifier` + `AuthSettings` +(issuer, resource URL, required scopes) when building the streamable-HTTP app +and the SDK wires three things automatically: a bearer gate that answers 401 + +`WWW-Authenticate: Bearer ... resource_metadata=...` (or 403 `insufficient_scope`), +the RFC 9728 protected-resource-metadata document at +`/.well-known/oauth-protected-resource/mcp`, and the verified `AccessToken` +inside tool handlers via `get_access_token()`. The verifier here accepts one +static token — replace it with JWT verification or RFC 7662 introspection. No +authorization server; see `../oauth/` for the full grant flow. + +## Run it + +```bash +# start the bearer-gated server (real uvicorn on :8000) +uv run python -m stories.bearer_auth.server --port 8000 & + +# connect with the demo bearer token +uv run python -m stories.bearer_auth.client --http http://127.0.0.1:8000/mcp + +# lowlevel-API variant of the same app +uv run python -m stories.bearer_auth.server_lowlevel --port 8001 & +uv run python -m stories.bearer_auth.client --http http://127.0.0.1:8001/mcp +``` + +## Try it without the SDK client + +```bash +# no token → 401 + WWW-Authenticate pointing at the PRM document +curl -i -X POST http://127.0.0.1:8000/mcp \ + -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"ping"}' + +# the RFC 9728 protected-resource-metadata document +curl -s http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp | jq +``` + +## What to look at + +- `server.py` — `MCPServer(token_verifier=..., auth=AuthSettings(...))` is the + whole recipe; `streamable_http_app()` reads those constructor kwargs and + mounts the bearer gate + PRM route. +- `server_lowlevel.py` — same gate, but `lowlevel.Server` takes + `auth=` / `token_verifier=` at **`streamable_http_app(...)` time**, not in the + constructor. `mcp.server.auth.*` imports are allowed in lowlevel files + (helper-tier). +- `whoami()` — `get_access_token()` returns the per-HTTP-request `AccessToken`. + It is **not** on `Context` (unlike other SDKs' `ctx.authInfo`); a later + release will namespace it as `ctx.transport.auth`. +- `client.py` — `http_client_kw` carries the `Authorization` header at the + `httpx.AsyncClient` layer because `Client(url)` has no `auth=` passthrough + yet. The `__main__` block shows the hand-built + `httpx.AsyncClient → streamable_http_client → Client` chain a real caller + would write today. + +## Caveats + +- `transport_security=NO_DNS_REBIND` — DNS-rebinding protection is on by default + for localhost binds; the harness disables it because the in-process httpx + client sends no `Origin` header. Drop the kwarg for a real deployment. +- `RESOURCE_URL` is hard-coded to port 8000 (the harness's in-process origin). + If you change `--port`, edit `RESOURCE_URL` to match or the PRM document's + `resource` field will be wrong. +- Auth is HTTP-only; over stdio or the in-memory transport `get_access_token()` + returns `None` and there is no gate. +- The 401/403 status codes and `WWW-Authenticate` header are HTTP-level and + `Client` cannot observe them; they are pinned by + `tests/interaction/auth/test_bearer.py` and shown via `curl` above. + +## Spec + +[Authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) +· RFC 9728 (Protected Resource Metadata) · RFC 6750 (`WWW-Authenticate: Bearer`) + +## See also + +`oauth/` (full authorization-code grant with an in-process AS) · +`oauth_client_credentials/` (M2M `client_credentials` grant) · +`stateless_legacy/` (the un-gated hosting baseline). diff --git a/examples/stories/bearer_auth/__init__.py b/examples/stories/bearer_auth/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/bearer_auth/client.py b/examples/stories/bearer_auth/client.py new file mode 100644 index 0000000000..96d030f842 --- /dev/null +++ b/examples/stories/bearer_auth/client.py @@ -0,0 +1,57 @@ +"""Call the bearer-gated server with a static ``Authorization`` header; assert the principal.""" + +import sys +import traceback +from typing import Any + +import anyio +import httpx + +from mcp.client import Client +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.version import LATEST_MODERN_VERSION +from stories._harness import argv_after + +from .server import DEMO_TOKEN, REQUIRED_SCOPE + +# ``Client(url)`` has no ``auth=`` / ``http_client=`` passthrough yet, so the bearer +# header is threaded at the ``httpx.AsyncClient`` layer. The harness reads this +# module-level dict and splats it into the in-process bridge client. +http_client_kw: dict[str, Any] = {"headers": {"authorization": f"Bearer {DEMO_TOKEN}"}} + + +async def scenario(client: Client) -> None: + listed = await client.list_tools() + assert [t.name for t in listed.tools] == ["whoami"] + + result = await client.call_tool("whoami", {}) + assert not result.is_error, result + assert result.structured_content == { + "subject": "demo-user", + "client_id": "demo-client", + "scopes": [REQUIRED_SCOPE], + }, result.structured_content + + +if __name__ == "__main__": + # HTTP-only auth story; --http takes the MCP endpoint URL. Hand-rolled because + # ``connect_from_args`` cannot thread the bearer header; this IS the recipe. + url = argv_after("--http", default="http://127.0.0.1:8000/mcp") + mode = "legacy" if "--legacy" in sys.argv else LATEST_MODERN_VERSION + + async def _main() -> None: + with anyio.fail_after(30): + async with ( + httpx.AsyncClient(**http_client_kw) as http_client, + Client(streamable_http_client(url, http_client=http_client), mode=mode) as client, + ): + await scenario(client) + + try: + anyio.run(_main) + except Exception: + print("FAIL: bearer_auth (http)", file=sys.stderr) + traceback.print_exc() + raise SystemExit(1) from None + print("OK: bearer_auth (http)", file=sys.stderr) + raise SystemExit(0) diff --git a/examples/stories/bearer_auth/server.py b/examples/stories/bearer_auth/server.py new file mode 100644 index 0000000000..daeb37b97e --- /dev/null +++ b/examples/stories/bearer_auth/server.py @@ -0,0 +1,56 @@ +"""Resource-server-only bearer auth: ``TokenVerifier`` + ``AuthSettings`` → 401/PRM/principal.""" + +import time + +from pydantic import AnyHttpUrl +from starlette.applications import Starlette + +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.server.auth.settings import AuthSettings +from mcp.server.mcpserver import MCPServer +from stories._hosting import NO_DNS_REBIND, run_app_from_args + +ISSUER = "https://auth.example.com" +RESOURCE_URL = "http://127.0.0.1:8000/mcp" +REQUIRED_SCOPE = "mcp:read" +DEMO_TOKEN = "demo-token" + + +class StaticTokenVerifier(TokenVerifier): + """Accepts one hard-coded token. Replace with JWT verification or RFC 7662 introspection.""" + + async def verify_token(self, token: str) -> AccessToken | None: + if token != DEMO_TOKEN: + return None + return AccessToken( + token=token, + client_id="demo-client", + scopes=[REQUIRED_SCOPE], + expires_at=int(time.time()) + 3600, + subject="demo-user", + ) + + +def build_app() -> Starlette: + mcp = MCPServer( + "bearer-auth-example", + token_verifier=StaticTokenVerifier(), + auth=AuthSettings( + issuer_url=AnyHttpUrl(ISSUER), + resource_server_url=AnyHttpUrl(RESOURCE_URL), + required_scopes=[REQUIRED_SCOPE], + ), + ) + + @mcp.tool(description="Return the authenticated principal.") + def whoami() -> dict[str, str | list[str]]: + token = get_access_token() + assert token is not None # the bearer gate guarantees this on the HTTP path + return {"subject": token.subject or "", "client_id": token.client_id, "scopes": token.scopes} + + return mcp.streamable_http_app(transport_security=NO_DNS_REBIND) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/bearer_auth/server_lowlevel.py b/examples/stories/bearer_auth/server_lowlevel.py new file mode 100644 index 0000000000..d01a63acea --- /dev/null +++ b/examples/stories/bearer_auth/server_lowlevel.py @@ -0,0 +1,56 @@ +"""Resource-server-only bearer auth (lowlevel API): same gate, hand-built ``CallToolResult``.""" + +from typing import Any + +from pydantic import AnyHttpUrl +from starlette.applications import Starlette + +from mcp import types +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.settings import AuthSettings +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import NO_DNS_REBIND, run_app_from_args + +from .server import ISSUER, REQUIRED_SCOPE, RESOURCE_URL, StaticTokenVerifier + + +def build_app() -> Starlette: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="whoami", + description="Return the authenticated principal.", + input_schema={"type": "object"}, + ), + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "whoami" + token = get_access_token() + assert token is not None # the bearer gate guarantees this on the HTTP path + payload = {"subject": token.subject or "", "client_id": token.client_id, "scopes": token.scopes} + return types.CallToolResult( + content=[types.TextContent(text=f"{token.subject} via {token.client_id}")], + structured_content=payload, + ) + + server = Server("bearer-auth-example", on_list_tools=list_tools, on_call_tool=call_tool) + # lowlevel.Server takes auth at app-build time, not in the constructor (cf. MCPServer). + return server.streamable_http_app( + auth=AuthSettings( + issuer_url=AnyHttpUrl(ISSUER), + resource_server_url=AnyHttpUrl(RESOURCE_URL), + required_scopes=[REQUIRED_SCOPE], + ), + token_verifier=StaticTokenVerifier(), + transport_security=NO_DNS_REBIND, + ) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/caching/README.md b/examples/stories/caching/README.md new file mode 100644 index 0000000000..be0bb48209 --- /dev/null +++ b/examples/stories/caching/README.md @@ -0,0 +1,20 @@ +# caching + +A server stamps `CacheableResult` hints (`ttl_ms`, `cache_scope`) onto list and +read responses; a client honours them to skip redundant round-trips. The story +will show per-result overrides on `@mcp.resource()` / `@mcp.tool()` and the +client-side cache hit/miss path. + +**Status: not yet implemented.** Server-side stamping landed (defaults +`ttl_ms=0`, `cache_scope="private"`), but the per-result override hook and the +client honouring path are not implemented yet. An example today could only show +the defaults being emitted, not acted on. + +## Spec + +[Caching — basic utilities](https://modelcontextprotocol.io/specification/draft/basic/utilities/caching) + +## Working example elsewhere + +The TypeScript SDK ships a runnable `caching` story: +[typescript-sdk/examples/caching](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/caching). diff --git a/examples/stories/client_session/README.md b/examples/stories/client_session/README.md new file mode 100644 index 0000000000..b6fd47f85f --- /dev/null +++ b/examples/stories/client_session/README.md @@ -0,0 +1,66 @@ +# client-session + +`Client` is a thin shell over `ClientSession`. This story shows the +`client.session` escape hatch: the era-specific result slots +(`initialize_result` / `discover_result`), the Optional-vs-narrowed accessors, +and the generic `send_request()` that every typed `client.*()` method wraps. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.client_session.client + +# against a running HTTP server +uv run python -m stories.client_session.server --http --port 8000 & +uv run python -m stories.client_session.client --http http://127.0.0.1:8000/mcp +``` + +## What to look at + +- **`client.session`** returns the live `ClientSession`. This is the documented + escape hatch when `Client` doesn't expose what you need (custom JSON-RPC, + era-specific result objects, the connect primitives). +- **Exactly one of `initialize_result` / `discover_result`** is ever non-None. + `Client.__aenter__` ran the connect ladder for you (`mode="legacy"` → + `initialize()`; `mode="auto"` → `discover()` with fallback; `mode=` + → `adopt()`); which slot is filled tells you which path it took. +- **`ClientSession.protocol_version` is `str | None`; `Client.protocol_version` + is `str`.** Same value, different Optional-ness — `Client` guarantees it's + set inside the `async with` block. Same for `server_info` / + `server_capabilities`. +- **`send_request(request_model, result_type)`** is the layer beneath + `client.list_tools()`. See `custom_methods/` for using it to call vendor + methods `Client` doesn't model. + +> When `mode=` is set without `prior_discover=`, the SDK synthesizes +> a placeholder `DiscoverResult` (empty `server_info` / `capabilities`); only +> `protocol_version` is meaningful on that path. + +## Building a ClientSession directly + +`Client` builds the `ClientSession` for you. To own the connect step yourself +(e.g. to call `discover()` and cache the result, or to drive raw streams from +a custom transport), construct `ClientSession` over a stream pair: + +```python +from mcp import ClientSession, StdioServerParameters, stdio_client + +async with stdio_client(StdioServerParameters(command="./server")) as (read, write): + async with ClientSession(read, write) as session: + result = await session.initialize() # or: await session.discover() + tools = await session.list_tools() +``` + +This is the v1-lineage shape; `Client` exists so you usually don't write it. + +## Spec + +- [Lifecycle — 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle) +- [Lifecycle — 2026-07-28 discover](https://modelcontextprotocol.io/specification/2026-07-28/basic/lifecycle#discover) + +## See also + +`serve_one/` (server-side mechanics counterpart), `custom_methods/` +(`send_request` for vendor JSON-RPC), `dual_era/` (the connect ladder as the +teaching point). diff --git a/examples/stories/client_session/__init__.py b/examples/stories/client_session/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/client_session/client.py b/examples/stories/client_session/client.py new file mode 100644 index 0000000000..bb23aec74c --- /dev/null +++ b/examples/stories/client_session/client.py @@ -0,0 +1,33 @@ +"""Drop from `Client` to `client.session`: the `ClientSession` mechanics layer beneath.""" + +from mcp import types +from mcp.client import Client, ClientSession +from stories._harness import connect_from_args, run_client + + +async def scenario(client: Client) -> None: + # client.session is the ClientSession that Client.__aenter__ connected for you. + session: ClientSession = client.session + + # __aenter__ ran exactly one of initialize() / discover() / adopt(), so exactly one + # era-specific result slot is populated — whichever era was negotiated. + assert (session.initialize_result is None) != (session.discover_result is None) + + # ClientSession's accessors are Optional (None until a result is adopted); Client's + # same-named properties narrow them to non-Optional inside the `async with` block. + assert session.protocol_version is not None + assert session.protocol_version == client.protocol_version + assert session.server_info == client.server_info + assert session.server_capabilities == client.server_capabilities + + # send_request() is the generic primitive every typed client.*() method wraps: + # any ClientRequest model + the expected result type. + listed = await session.send_request(types.ListToolsRequest(), types.ListToolsResult) + assert [t.name for t in listed.tools] == ["add"] + + # The typed wrapper produces the same result. + assert await client.list_tools() == listed + + +if __name__ == "__main__": + run_client(scenario, connect=connect_from_args(__file__)) diff --git a/examples/stories/client_session/server.py b/examples/stories/client_session/server.py new file mode 100644 index 0000000000..746d39a55b --- /dev/null +++ b/examples/stories/client_session/server.py @@ -0,0 +1,19 @@ +"""Minimal server for the client-session story (the teaching point is client-side).""" + +from mcp.server.mcpserver import MCPServer +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer("client-session-example") + + @mcp.tool() + def add(a: int, b: int) -> int: + """Add two integers.""" + return a + b + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/custom_methods/README.md b/examples/stories/custom_methods/README.md new file mode 100644 index 0000000000..ce7416109e --- /dev/null +++ b/examples/stories/custom_methods/README.md @@ -0,0 +1,51 @@ +# custom-methods + +Register and call a vendor-prefixed JSON-RPC method that is not part of the +MCP spec. The server uses the low-level `Server.add_request_handler` (there is +no `MCPServer` surface for this, so `server.py` is lowlevel-native and there is +no `server_lowlevel.py` sibling); the client drops to `client.session` to send +it. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.custom_methods.client + +# against a running HTTP server +uv run python -m stories.custom_methods.server --http --port 8000 & +uv run python -m stories.custom_methods.client --http http://127.0.0.1:8000/mcp +``` + +## What to look at + +- `server.py` `SearchParams` — subclasses `types.RequestParams` so `_meta` + (and on a 2026-07-28 connection, the reserved `io.modelcontextprotocol/*` + envelope keys) parse uniformly without extra code. +- `server.py` `add_request_handler("acme/search", SearchParams, search)` — the + method string is the wire `method`; use a vendor prefix so it can never + collide with a future spec method. +- `client.py` `client.session.send_request(...)` — `Client` only exposes spec + verbs, so vendor methods go through the underlying `ClientSession`. The + `cast("types.ClientRequest", ...)` is needed because `send_request`'s + `request` parameter is currently typed as the closed spec union; widening it + (or adding `Client.send_request`) is tracked for beta. + +## Caveats + +- The TypeScript SDK's equivalent example also shows a custom server→client + **notification** (`acme/searchProgress`). The Python client currently drops + any notification whose method is not in the spec registry + (`ClientSession._on_notify` → `KeyError` → silent drop), and there is no + `set_notification_handler` analogue. That half is omitted here. + +## Spec + +[Requests — basic protocol](https://modelcontextprotocol.io/specification/2025-11-25/basic#requests) +(JSON-RPC request shape; vendor method names live outside the spec's reserved +set). + +## See also + +`serve_one/` (the per-exchange driver that runs registered handlers), +`middleware/` (wrapping every registered handler, including vendor methods). diff --git a/examples/stories/custom_methods/__init__.py b/examples/stories/custom_methods/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/custom_methods/client.py b/examples/stories/custom_methods/client.py new file mode 100644 index 0000000000..700179f5ca --- /dev/null +++ b/examples/stories/custom_methods/client.py @@ -0,0 +1,36 @@ +"""Send a vendor-prefixed request via the `client.session` escape hatch.""" + +from typing import Literal, cast + +from mcp import types +from mcp.client import Client +from stories._harness import connect_from_args, run_client + + +class SearchParams(types.RequestParams): + query: str + limit: int = 10 + + +class SearchRequest(types.Request[SearchParams, Literal["acme/search"]]): + method: Literal["acme/search"] = "acme/search" + params: SearchParams + + +class SearchResult(types.Result): + items: list[str] + + +async def scenario(client: Client) -> None: + # `Client` only exposes spec-defined verbs. For vendor methods, drop one + # layer to `client.session` — the sanctioned escape hatch. `send_request` + # is typed against the closed `ClientRequest` union, hence the cast; at + # runtime the body only calls `.model_dump()` and the unknown method skips + # the per-spec result-validation registry. + request = SearchRequest(params=SearchParams(query="mcp", limit=3)) + result = await client.session.send_request(cast("types.ClientRequest", request), SearchResult) + assert result.items == ["mcp-0", "mcp-1", "mcp-2"], result + + +if __name__ == "__main__": + run_client(scenario, connect=connect_from_args(__file__)) diff --git a/examples/stories/custom_methods/server.py b/examples/stories/custom_methods/server.py new file mode 100644 index 0000000000..3184725027 --- /dev/null +++ b/examples/stories/custom_methods/server.py @@ -0,0 +1,38 @@ +"""Register a vendor-prefixed JSON-RPC method on the low-level Server. + +`MCPServer` has no public surface for arbitrary method registration, so this +story's `server.py` is lowlevel-native (no `server_lowlevel.py` sibling). +""" + +from typing import Any + +from mcp import types +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + + +class SearchParams(types.RequestParams): + """Subclass `RequestParams` so `_meta` (and the 2026 envelope keys) parse uniformly.""" + + query: str + limit: int = 10 + + +class SearchResult(types.Result): + items: list[str] + + +def build_server() -> Server[Any]: + server = Server("acme-search") + + async def search(ctx: ServerRequestContext[Any], params: SearchParams) -> SearchResult: + items = [f"{params.query}-{i}" for i in range(params.limit)] + return SearchResult(items=items) + + server.add_request_handler("acme/search", SearchParams, search) + return server + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/custom_version/README.md b/examples/stories/custom_version/README.md new file mode 100644 index 0000000000..a4c9dd682f --- /dev/null +++ b/examples/stories/custom_version/README.md @@ -0,0 +1,47 @@ +# custom-version + +Where the negotiated protocol version lives after the 2025-era `initialize` +handshake: `client.protocol_version` on the client, and +`ctx.request_context.protocol_version` (or `ctx.protocol_version` directly on +`ServerRequestContext` in the lowlevel API) inside a handler. The scenario +proves both sides agree by round-tripping the server's view through a tool +call and comparing it to the client's accessor. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.custom_version.client + +# against a running HTTP server +uv run python -m stories.custom_version.server --http --port 8000 & +uv run python -m stories.custom_version.client --http http://127.0.0.1:8000/mcp --legacy +``` + +## What to look at + +- `server.py` — `ctx.request_context.protocol_version`: the version the + `initialize` handshake settled on, available to every handler. +- `server_lowlevel.py` — `ctx.protocol_version`: the same field directly on + `ServerRequestContext`. +- `client.py` — `client.protocol_version`: the era-neutral accessor (populated + whether the connection used `initialize` or `server/discover`). + +## Not yet: overriding the supported-version set + +The TypeScript SDK lets a server declare `supportedProtocolVersions: [...]` to +accept a version string the SDK doesn't yet ship (the first entry is the +counter-offer when the client requests something unknown). The python-sdk +doesn't expose this knob yet — server-side negotiation is fixed to +`mcp.shared.version.HANDSHAKE_PROTOCOL_VERSIONS`. When that kwarg lands, +`build_server()` grows one argument and `scenario()` asserts a custom version +round-trips. Tracked for pre-beta. + +## Spec + +[Lifecycle — version negotiation](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle) + +## See also + +`dual_era/` (one server serving both eras), `legacy_routing/` (HTTP-layer era +classifier). diff --git a/examples/stories/custom_version/__init__.py b/examples/stories/custom_version/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/custom_version/client.py b/examples/stories/custom_version/client.py new file mode 100644 index 0000000000..4e808089cf --- /dev/null +++ b/examples/stories/custom_version/client.py @@ -0,0 +1,22 @@ +"""Assert the client and server agree on the negotiated protocol version.""" + +from mcp import types +from mcp.client import Client +from mcp.shared.version import HANDSHAKE_PROTOCOL_VERSIONS +from stories._harness import connect_from_args, run_client + + +async def scenario(client: Client) -> None: + # Era-neutral accessor: populated from InitializeResult under mode="legacy". + assert client.protocol_version in HANDSHAKE_PROTOCOL_VERSIONS, client.protocol_version + + listed = await client.list_tools() + assert [t.name for t in listed.tools] == ["protocol_info"] + + result = await client.call_tool("protocol_info", {}) + assert isinstance(result.content[0], types.TextContent) + assert result.content[0].text == client.protocol_version, result + + +if __name__ == "__main__": + run_client(scenario, connect=connect_from_args(__file__)) diff --git a/examples/stories/custom_version/server.py b/examples/stories/custom_version/server.py new file mode 100644 index 0000000000..8333fe2ef8 --- /dev/null +++ b/examples/stories/custom_version/server.py @@ -0,0 +1,19 @@ +"""Read the negotiated protocol version inside a tool handler (initialize handshake).""" + +from mcp.server.mcpserver import Context, MCPServer +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer("custom-version-example") + + @mcp.tool() + def protocol_info(ctx: Context) -> str: + """Return the protocol version this connection negotiated.""" + return ctx.request_context.protocol_version + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/custom_version/server_lowlevel.py b/examples/stories/custom_version/server_lowlevel.py new file mode 100644 index 0000000000..f1458098e5 --- /dev/null +++ b/examples/stories/custom_version/server_lowlevel.py @@ -0,0 +1,33 @@ +"""Read the negotiated protocol version inside a lowlevel handler (initialize handshake).""" + +from typing import Any + +from mcp import types +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + + +def build_server() -> Server[Any]: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="protocol_info", + description="Return the protocol version this connection negotiated.", + input_schema={"type": "object"}, + ) + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "protocol_info" + return types.CallToolResult(content=[types.TextContent(text=ctx.protocol_version)]) + + return Server("custom-version-example", on_list_tools=list_tools, on_call_tool=call_tool) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/dual_era/README.md b/examples/stories/dual_era/README.md new file mode 100644 index 0000000000..83d399aef3 --- /dev/null +++ b/examples/stories/dual_era/README.md @@ -0,0 +1,53 @@ +# dual-era + +One server factory, both protocol eras. A `mode="legacy"` client runs the +`initialize` handshake; a `mode="auto"` client probes `server/discover` and +adopts the 2026 stateless era — the same `greet` tool answers both and reports +which era served it via `ctx.request_context.protocol_version`. **Start here** +when migrating a v1 server: the entry owns the era decision, the server body +stays era-agnostic. + +## Run it + +```bash +# over HTTP — the same /mcp endpoint serves both eras +uv run python -m stories.dual_era.server --http --port 8000 & +uv run python -m stories.dual_era.client --http http://127.0.0.1:8000/mcp + +# lowlevel server variant +uv run python -m stories.dual_era.server_lowlevel --http --port 8000 & +uv run python -m stories.dual_era.client --http http://127.0.0.1:8000/mcp --server server_lowlevel +``` + +The bare stdio invocation (`uv run python -m stories.dual_era.client`) is +legacy-only until the SDK's stdio entry can negotiate the era, so the modern +leg fails there today — run over `--http`. + +## What to look at + +- `server.py` — `ctx.request_context.protocol_version` is the era branch key + (lowlevel: `ctx.protocol_version` directly). Compare against + `MODERN_PROTOCOL_VERSIONS`, never a date literal. +- `client.py` — `client.protocol_version` / `client.server_info` / + `client.server_capabilities` are era-neutral: populated by `initialize` *or* + `server/discover`, whichever ran. +- `client.py` — `mode="auto"` is the discover-then-fallback ladder; + `mode="legacy"` forces the handshake. No date strings appear in the body. + +## Caveats + +- `ctx.request_context.protocol_version` is the current way to read the + negotiated version; a later release will shorten it to `ctx.transport.*`. +- Over HTTP the built-in era branch is currently header-only — a 2026 client + that omits the `MCP-Protocol-Version` header is mis-routed to the legacy + path. The body-primary classifier lands in a later release. + +## Spec + +- [Versioning — backward compatibility](https://modelcontextprotocol.io/specification/draft/basic/versioning) +- [`server/discover`](https://modelcontextprotocol.io/specification/draft/server/discover) + +## See also + +`custom_version/` (pin the server to one era), `legacy_routing/` (route eras +yourself), `reconnect/` (persist `DiscoverResult` for zero-RTT reconnect). diff --git a/examples/stories/dual_era/__init__.py b/examples/stories/dual_era/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/dual_era/client.py b/examples/stories/dual_era/client.py new file mode 100644 index 0000000000..29225f3120 --- /dev/null +++ b/examples/stories/dual_era/client.py @@ -0,0 +1,38 @@ +"""Connect to the same server factory twice — once per era — and assert both are served.""" + +from mcp import types +from mcp.client import Client +from mcp.shared.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION +from stories._harness import Connect, connect_from_args, run_client + + +async def scenario(client: Client, connect: Connect) -> None: + # ── modern leg: the harness-supplied client connected at mode="auto", so __aenter__ + # sent server/discover and adopted the result — no initialize handshake ran. + assert client.protocol_version == LATEST_MODERN_VERSION + assert client.server_info.name == "dual-era-example" + assert client.server_capabilities.tools is not None + + listed = await client.list_tools() + assert [t.name for t in listed.tools] == ["greet"] + + result = await client.call_tool("greet", {"name": "2026 client"}) + first = result.content[0] + assert isinstance(first, types.TextContent) + assert first.text == f"Hello, 2026 client! (served on the modern era at {LATEST_MODERN_VERSION})" + + # ── legacy leg: a fresh client at mode="legacy" runs the initialize handshake against + # the SAME server factory. The era-neutral accessors are populated identically. + async with connect(mode="legacy") as legacy: + assert legacy.protocol_version == LATEST_HANDSHAKE_VERSION + assert legacy.server_info.name == "dual-era-example" + assert legacy.server_capabilities.tools is not None + + result = await legacy.call_tool("greet", {"name": "2025 client"}) + first = result.content[0] + assert isinstance(first, types.TextContent) + assert first.text == f"Hello, 2025 client! (served on the legacy era at {LATEST_HANDSHAKE_VERSION})" + + +if __name__ == "__main__": + run_client(scenario, connect=connect_from_args(__file__), needs_connect=True, mode="auto") diff --git a/examples/stories/dual_era/server.py b/examples/stories/dual_era/server.py new file mode 100644 index 0000000000..5135c239c5 --- /dev/null +++ b/examples/stories/dual_era/server.py @@ -0,0 +1,22 @@ +"""One MCPServer factory that serves both the 2025 handshake era and the 2026 stateless era.""" + +from mcp.server.mcpserver import Context, MCPServer +from mcp.shared.version import MODERN_PROTOCOL_VERSIONS +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer("dual-era-example", instructions="A small dual-era demo server.") + + @mcp.tool() + async def greet(name: str, ctx: Context) -> str: + """Greet the caller and report which protocol era served the request.""" + pv = ctx.request_context.protocol_version + era = "modern" if pv in MODERN_PROTOCOL_VERSIONS else "legacy" + return f"Hello, {name}! (served on the {era} era at {pv})" + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/dual_era/server_lowlevel.py b/examples/stories/dual_era/server_lowlevel.py new file mode 100644 index 0000000000..a891a01efd --- /dev/null +++ b/examples/stories/dual_era/server_lowlevel.py @@ -0,0 +1,47 @@ +"""One lowlevel Server factory that serves both the 2025 handshake era and the 2026 stateless era.""" + +from typing import Any + +from mcp import types +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from mcp.shared.version import MODERN_PROTOCOL_VERSIONS +from stories._hosting import run_server_from_args + +GREET_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], +} + + +def build_server() -> Server[Any]: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="greet", + description="Greet the caller and report which protocol era served the request.", + input_schema=GREET_INPUT_SCHEMA, + ), + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "greet" and params.arguments is not None + era = "modern" if ctx.protocol_version in MODERN_PROTOCOL_VERSIONS else "legacy" + text = f"Hello, {params.arguments['name']}! (served on the {era} era at {ctx.protocol_version})" + return types.CallToolResult(content=[types.TextContent(text=text)]) + + return Server( + "dual-era-example", + instructions="A small dual-era demo server.", + on_list_tools=list_tools, + on_call_tool=call_tool, + ) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/elicitation/README.md b/examples/stories/elicitation/README.md new file mode 100644 index 0000000000..daad6c4c73 --- /dev/null +++ b/examples/stories/elicitation/README.md @@ -0,0 +1,66 @@ +# elicitation + +A tool pauses mid-call to ask the user for structured input. On the +handshake-era protocol the server pushes an `elicitation/create` *request* to +the client and blocks until the client's `elicitation_callback` answers +`accept` / `decline` / `cancel`. Two modes: **form** (`ctx.elicit(message, +PydanticModel)` — schema derived from the model, accepted content validated +back into it) and **url** (`ctx.elicit_url(...)` — directs the user out-of-band +for OAuth / payment flows; `send_elicit_complete` notifies the client when the +flow finishes). + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.elicitation.client + +# against a running HTTP server +uv run python -m stories.elicitation.server --http --port 8000 & +uv run python -m stories.elicitation.client --http http://127.0.0.1:8000/mcp +``` + +## What to look at + +- `server.py` `register_user` — `await ctx.elicit("...", Registration)` derives + the form schema from the pydantic model and returns a typed + `ElicitationResult[Registration]`; narrow with `isinstance(answer, + AcceptedElicitation)` before reading `answer.data`. +- `server.py` `link_account` — `ctx.elicit_url(...)` for out-of-band flows; + after the user finishes, `send_elicit_complete` emits + `notifications/elicitation/complete` so the client can correlate. +- `client.py` `on_elicit` — one callback serves *both* modes by branching on + `isinstance(params, ElicitRequestURLParams)`. Supplying + `elicitation_callback` auto-advertises the `elicitation: {form, url}` + capability. +- `server_lowlevel.py` — the same flow via `ctx.session.elicit_form` / + `ctx.session.elicit_url` and a hand-written `requestedSchema`. + +## Caveats + +- **Handshake-era only.** The push-style `ctx.elicit()` requires a + server→client request channel. The 2026-07-28 protocol carries elicitation as + an `InputRequiredResult` round-trip instead — that path lands with the `mrtr` + story ([#2898](https://github.com/modelcontextprotocol/python-sdk/issues/2898)), + at which point this example becomes dual-era. Elicitation itself is **not** + deprecated by SEP-2577 (unlike roots/sampling/logging). +- **Context paths.** `ctx.elicit` / `ctx.elicit_url` and the 2-hop + `ctx.request_context.session.send_elicit_complete` are interim; a later + release will shorten these. +- **No per-mode opt-in.** Supplying any `elicitation_callback` advertises both + form and url support; there is currently no way to advertise form-only from + `Client`. +- **Throw-style URL elicitation** (`raise UrlElicitationRequiredError([...])` → + wire `-32042`) is the stateless-transport alternative to `ctx.elicit_url`; + see `tests/interaction/lowlevel/test_elicitation.py` and the `error_handling` + story. + +## Spec + +[Elicitation — client features](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) + +## See also + +`sampling/` (same push-request shape, deprecated per SEP-2577), `mrtr/` +(planned — the 2026-era carrier), `error_handling/` +(`UrlElicitationRequiredError`). diff --git a/examples/stories/elicitation/__init__.py b/examples/stories/elicitation/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/elicitation/client.py b/examples/stories/elicitation/client.py new file mode 100644 index 0000000000..a9da6a001c --- /dev/null +++ b/examples/stories/elicitation/client.py @@ -0,0 +1,34 @@ +"""Auto-answer form and URL elicitations and assert the tool result reflects them.""" + +from typing import Any + +from mcp import types +from mcp.client import Client, ClientRequestContext +from stories._harness import connect_from_args, run_client + + +async def on_elicit(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult: + if isinstance(params, types.ElicitRequestURLParams): + # A real client would open params.url in a browser, then wait for the matching + # notifications/elicitation/complete before resolving. + assert params.url.startswith("https://example.com/") + return types.ElicitResult(action="accept") + assert "username" in params.requested_schema["properties"] + return types.ElicitResult(action="accept", content={"username": "alice", "plan": "pro"}) + + +client_kw: dict[str, Any] = {"elicitation_callback": on_elicit} + + +async def scenario(client: Client) -> None: + registered = await client.call_tool("register_user", {}) + assert isinstance(registered.content[0], types.TextContent) + assert registered.content[0].text == "registered alice (plan: pro)", registered + + linked = await client.call_tool("link_account", {"provider": "github"}) + assert isinstance(linked.content[0], types.TextContent) + assert linked.content[0].text == "linked github", linked + + +if __name__ == "__main__": + run_client(scenario, connect=connect_from_args(__file__), **client_kw) diff --git a/examples/stories/elicitation/server.py b/examples/stories/elicitation/server.py new file mode 100644 index 0000000000..a5be613bed --- /dev/null +++ b/examples/stories/elicitation/server.py @@ -0,0 +1,46 @@ +"""Elicitation (handshake-era push style): a tool blocks on user input mid-call.""" + +from pydantic import BaseModel + +from mcp.server.elicitation import AcceptedElicitation +from mcp.server.mcpserver import Context, MCPServer +from stories._hosting import run_server_from_args + + +class Registration(BaseModel): + username: str + plan: str | None = None + + +def build_server() -> MCPServer: + mcp = MCPServer("elicitation-example") + + @mcp.tool(description="Register a new account by asking the user for their details.") + async def register_user(ctx: Context) -> str: + answer = await ctx.elicit("Please provide your registration details:", Registration) + if not isinstance(answer, AcceptedElicitation): + return f"registration {answer.action}" + return f"registered {answer.data.username} (plan: {answer.data.plan or 'free'})" + + @mcp.tool(description="Link a third-party account by directing the user to a sign-in URL.") + async def link_account(provider: str, ctx: Context) -> str: + elicitation_id = f"link-{provider}" + answer = await ctx.elicit_url( + f"Sign in to {provider} to link your account", + url=f"https://example.com/oauth/{provider}/authorize", + elicitation_id=elicitation_id, + ) + if answer.action != "accept": + return f"link {answer.action}" + # Out-of-band flow finished: tell the client which elicitation completed. + # The 2-hop `ctx.request_context.*` reach is interim; a later release shortens it. + await ctx.request_context.session.send_elicit_complete( + elicitation_id, related_request_id=ctx.request_context.request_id + ) + return f"linked {provider}" + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/elicitation/server_lowlevel.py b/examples/stories/elicitation/server_lowlevel.py new file mode 100644 index 0000000000..82722a2b17 --- /dev/null +++ b/examples/stories/elicitation/server_lowlevel.py @@ -0,0 +1,65 @@ +"""Elicitation (handshake-era push style) against the low-level Server.""" + +from typing import Any + +from mcp import types +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + +REGISTRATION_SCHEMA: types.ElicitRequestedSchema = { + "type": "object", + "properties": { + "username": {"type": "string"}, + "plan": {"type": "string", "enum": ["free", "pro", "team"]}, + }, + "required": ["username"], +} +LINK_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"provider": {"type": "string"}}, + "required": ["provider"], +} + + +def build_server() -> Server[Any]: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="register_user", description="Register a new account.", input_schema={"type": "object"} + ), + types.Tool( + name="link_account", description="Link a third-party account.", input_schema=LINK_INPUT_SCHEMA + ), + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + if params.name == "register_user": + answer = await ctx.session.elicit_form("Please provide your registration details:", REGISTRATION_SCHEMA) + if answer.action != "accept" or answer.content is None: + return types.CallToolResult(content=[types.TextContent(text=f"registration {answer.action}")]) + text = f"registered {answer.content['username']} (plan: {answer.content.get('plan') or 'free'})" + return types.CallToolResult(content=[types.TextContent(text=text)]) + + assert params.name == "link_account" and params.arguments is not None + provider = params.arguments["provider"] + elicitation_id = f"link-{provider}" + answer = await ctx.session.elicit_url( + f"Sign in to {provider} to link your account", + url=f"https://example.com/oauth/{provider}/authorize", + elicitation_id=elicitation_id, + ) + if answer.action != "accept": + return types.CallToolResult(content=[types.TextContent(text=f"link {answer.action}")]) + await ctx.session.send_elicit_complete(elicitation_id, related_request_id=ctx.request_id) + return types.CallToolResult(content=[types.TextContent(text=f"linked {provider}")]) + + return Server("elicitation-example", on_list_tools=list_tools, on_call_tool=call_tool) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/error_handling/README.md b/examples/stories/error_handling/README.md new file mode 100644 index 0000000000..7f9d8f6605 --- /dev/null +++ b/examples/stories/error_handling/README.md @@ -0,0 +1,51 @@ +# error-handling + +Tool *execution* failures travel as a successful `CallToolResult` with +`is_error=True` so the LLM can read the message and self-correct. +*Protocol* failures travel as a JSON-RPC error that the client catches as +`MCPError`. This story shows how to produce each from a tool body — `raise +ToolError(...)` vs `raise MCPError(...)` on `MCPServer`; an explicit +`is_error=True` return vs `raise MCPError` on `lowlevel.Server` — and how a +client tells them apart. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.error_handling.client + +# against a running HTTP server +uv run python -m stories.error_handling.server --http --port 8000 & +uv run python -m stories.error_handling.client --http http://127.0.0.1:8000/mcp +``` + +## What to look at + +- `server.py` — `raise ToolError(...)` vs `raise MCPError(...)`: same `raise` + keyword, opposite wire channel. The tool wrapper re-raises `MCPError` + verbatim and wraps everything else as an `is_error` result. +- `server_lowlevel.py` — no wrapper: you build `CallToolResult(is_error=True)` + yourself, and `MCPError` is the only way to pick a JSON-RPC error code. +- `client.py` — `await` returns for `is_error` results; `except MCPError` + catches protocol errors. The client never auto-raises on `is_error`. + +## Caveats + +- The "any other exception → `is_error` result" contract on `MCPServer` and the + "uncaught exception → `code=0`" behaviour on `lowlevel.Server` are **not + shown** — the contract is under design and the legacy code is a known spec + divergence. This story will grow those cases once the contract lands. +- `MCPServer` prefixes the execution-error message with + `"Error executing tool {name}: "`; build a `CallToolResult` directly from a + lowlevel handler if you need verbatim control. +- `client.py` reads `e.error.data` rather than `e.data`; the convenience + property carries a `no cover` pragma that `strict-no-cover` would trip. + +## Spec + +[Tools — error handling](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#error-handling) + +## See also + +`tools/` (the happy path), `streaming/` (cancellation as a third error-adjacent +surface). diff --git a/examples/stories/error_handling/__init__.py b/examples/stories/error_handling/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/error_handling/client.py b/examples/stories/error_handling/client.py new file mode 100644 index 0000000000..1cdea3d760 --- /dev/null +++ b/examples/stories/error_handling/client.py @@ -0,0 +1,36 @@ +"""Prove the two error channels: is_error results return; MCPError raises.""" + +from mcp import MCPError +from mcp.client import Client +from mcp.types import INVALID_PARAMS, TextContent +from stories._harness import connect_from_args, run_client + + +async def scenario(client: Client) -> None: + # Success: is_error defaults to False. + ok = await client.call_tool("divide", {"a": 6, "b": 2}) + assert ok.is_error is False, ok + assert isinstance(ok.content[0], TextContent) + assert ok.content[0].text == "3.0" + + # Execution error: arrives as a *result* — await returns, no exception. + failed = await client.call_tool("divide", {"a": 1, "b": 0}) + assert failed.is_error is True, "execution errors ride CallToolResult, not an exception" + assert isinstance(failed.content[0], TextContent) + # MCPServer prefixes "Error executing tool divide: ..."; lowlevel returns + # the message verbatim. Assert the substring both produce. + assert "cannot divide by zero" in failed.content[0].text + + # Protocol error: arrives as a raised MCPError. + try: + await client.call_tool("restricted", {}) + except MCPError as e: + assert e.code == INVALID_PARAMS + assert e.message == "this tool is gated" + assert e.error.data == {"reason": "demo"} + else: + raise AssertionError("expected MCPError for a protocol-level rejection") + + +if __name__ == "__main__": + run_client(scenario, connect=connect_from_args(__file__)) diff --git a/examples/stories/error_handling/server.py b/examples/stories/error_handling/server.py new file mode 100644 index 0000000000..96667a5d0c --- /dev/null +++ b/examples/stories/error_handling/server.py @@ -0,0 +1,34 @@ +"""Two error channels: ToolError -> is_error result; MCPError -> JSON-RPC protocol error.""" + +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.exceptions import ToolError +from mcp.shared.exceptions import MCPError +from mcp.types import INVALID_PARAMS +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer("error-handling-example") + + @mcp.tool() + def divide(a: float, b: float) -> float: + """Divide a by b. Division by zero is an execution error the LLM should see.""" + if b == 0: + # ToolError is caught by the tool wrapper and returned as + # CallToolResult(is_error=True) — the LLM reads the message and can + # self-correct. + raise ToolError("cannot divide by zero") + return a / b + + @mcp.tool() + def restricted() -> str: + """A tool that always rejects the caller at the protocol level.""" + # MCPError escapes the tool wrapper and becomes a JSON-RPC error + # response — the *host* sees code/message/data, not the LLM. + raise MCPError(code=INVALID_PARAMS, message="this tool is gated", data={"reason": "demo"}) + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/error_handling/server_lowlevel.py b/examples/stories/error_handling/server_lowlevel.py new file mode 100644 index 0000000000..217762d85d --- /dev/null +++ b/examples/stories/error_handling/server_lowlevel.py @@ -0,0 +1,44 @@ +"""Two error channels on lowlevel.Server: return is_error=True yourself, or raise MCPError.""" + +from typing import Any + +from mcp import types +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from mcp.shared.exceptions import MCPError +from stories._hosting import run_server_from_args + +_TOOLS = [ + types.Tool(name="divide", description="Divide a by b.", input_schema={"type": "object"}), + types.Tool(name="restricted", description="Always rejects.", input_schema={"type": "object"}), +] + + +def build_server() -> Server[Any]: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=_TOOLS) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + args = params.arguments or {} + if params.name == "divide": + a, b = float(args["a"]), float(args["b"]) + if b == 0: + # Execution error: build the is_error result yourself. + return types.CallToolResult( + content=[types.TextContent(text="cannot divide by zero")], + is_error=True, + ) + return types.CallToolResult(content=[types.TextContent(text=str(a / b))]) + if params.name == "restricted": + # Protocol error: raise MCPError; the dispatcher serialises it as a + # JSON-RPC error response with this code/message/data. + raise MCPError(code=types.INVALID_PARAMS, message="this tool is gated", data={"reason": "demo"}) + raise MCPError(code=types.INVALID_PARAMS, message=f"Unknown tool: {params.name}") + + return Server("error-handling-example", on_list_tools=list_tools, on_call_tool=call_tool) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/events/README.md b/examples/stories/events/README.md new file mode 100644 index 0000000000..0fe7dc8e97 --- /dev/null +++ b/examples/stories/events/README.md @@ -0,0 +1,21 @@ +# events + +The `io.modelcontextprotocol/events` extension: poll, push, and webhook +delivery of server-originated events on top of the `subscriptions/listen` +channel. The story will show a server emitting events and a client consuming +them over each delivery mode. + +**Status: not yet implemented.** Depends on both the `subscriptions/listen` +runtime ([#2901](https://github.com/modelcontextprotocol/python-sdk/issues/2901)) +and the `extensions` capability map +([#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896)) — +neither has landed. + +## Spec + +[Events — extensions](https://modelcontextprotocol.io/specification/draft/extensions/events) +· [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133) + +## See also + +`subscriptions/` (the listen channel this builds on). diff --git a/examples/stories/json_response/README.md b/examples/stories/json_response/README.md new file mode 100644 index 0000000000..4649bd45af --- /dev/null +++ b/examples/stories/json_response/README.md @@ -0,0 +1,58 @@ +# json-response + +`streamable_http_app(json_response=True)` — one `application/json` body per +request instead of an SSE stream. Useful for serverless / edge runtimes that +can't hold a stream open. The 2026-07-28 path is stateless and JSON-only today +regardless of the flag; setting it makes the legacy (2025-era) branch on the +same endpoint behave the same way. + +## Run it + +```bash +# start the server (real uvicorn) +uv run python -m stories.json_response.server --port 8000 & + +# high-level Client + raw-envelope probe against it +uv run python -m stories.json_response.client --http http://127.0.0.1:8000 + +# or POST the raw envelope yourself +curl -s http://127.0.0.1:8000/mcp \ + -H 'content-type: application/json' \ + -H 'accept: application/json, text/event-stream' \ + -H 'mcp-protocol-version: 2026-07-28' \ + -H 'mcp-method: tools/list' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"0"},"io.modelcontextprotocol/clientCapabilities":{}}}}' +``` + +## What to look at + +- `client.py` `RAW_ENVELOPE_BODY` / `MODERN_HEADERS` — the exact 2026 wire + shape: three `io.modelcontextprotocol/*` `_meta` keys replace the initialize + handshake; `MCP-Protocol-Version` + `Mcp-Method` headers mirror the body so + gateways can route without parsing JSON. +- `server.py` `greet` calls `ctx.report_progress(0.5)` — and `scenario` proves + the client's `progress_callback` is **never invoked**: JSON mode has no + back-channel for mid-call notifications (the `progress_seen == []` assertion + flips to `== [0.5]` once SSE buffering lands for the modern path). +- `server_lowlevel.py` — same ASGI app built from `lowlevel.Server`; the + `json_response=` / `transport_security=` knobs live on `streamable_http_app`, + not the server class. + +## Caveats + +- DNS-rebinding protection is on by default; the harness disables it via + `NO_DNS_REBIND` because the in-process httpx client sends no `Origin` header. +- The `streamable_http_app()` call shape here will move when the free-function + entry lands (see `_hosting.py`). +- `Mcp-Name` is omitted for `tools/list` because the SDK only emits it on + `tools/call` today. + +## Spec + +[Streamable HTTP — 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http) +· [SEP-2243 standard headers](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#standard-request-headers) + +## See also + +`stateless_legacy/` (the default posture), `legacy_routing/` (route by era at +the entry), `streaming/` (progress that *is* delivered — over stdio/SSE). diff --git a/examples/stories/json_response/__init__.py b/examples/stories/json_response/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/json_response/client.py b/examples/stories/json_response/client.py new file mode 100644 index 0000000000..380a7a72ab --- /dev/null +++ b/examples/stories/json_response/client.py @@ -0,0 +1,87 @@ +"""Regular ``Client`` against a JSON-only server; assert mid-call progress is dropped. + +``RAW_ENVELOPE_BODY`` / ``MODERN_HEADERS`` are the exact wire shape a 2026-era client +sends — this is the only story that shows it. ``scenario`` posts that body by hand +and asserts the response is a single ``application/json`` body with no session id. +""" + +import sys +import traceback + +import anyio +import httpx + +from mcp.client import Client +from mcp.shared.version import LATEST_MODERN_VERSION +from mcp.types import TextContent +from stories._harness import argv_after + +# The raw 2026-07-28 POST envelope: per-request `_meta` replaces the initialize handshake. +RAW_ENVELOPE_BODY: dict[str, object] = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": { + "_meta": { + "io.modelcontextprotocol/protocolVersion": LATEST_MODERN_VERSION, + "io.modelcontextprotocol/clientInfo": {"name": "raw-probe", "version": "0.0.0"}, + "io.modelcontextprotocol/clientCapabilities": {}, + } + }, +} +MODERN_HEADERS: dict[str, str] = { + "accept": "application/json, text/event-stream", + "content-type": "application/json", + "mcp-protocol-version": LATEST_MODERN_VERSION, + "mcp-method": "tools/list", +} + + +async def scenario(client: Client, http: httpx.AsyncClient) -> None: + assert client.protocol_version == LATEST_MODERN_VERSION + + progress_seen: list[float] = [] + + async def _on_progress(progress: float, total: float | None, message: str | None) -> None: + progress_seen.append(progress) + + result = await client.call_tool("greet", {"name": "json"}, progress_callback=_on_progress) + assert isinstance(result.content[0], TextContent) and result.content[0].text == "Hello, json!" + assert result.structured_content == {"result": "Hello, json!"}, result + + # The tool called report_progress(0.5) but the modern HTTP JSON path has no + # back-channel for mid-call notifications, so the callback is never invoked. + assert progress_seen == [], f"expected progress to be dropped, got {progress_seen}" + + # Hand-craft a 2026 POST and assert it comes back as a single JSON body, no session. + response = await http.post("/mcp", json=RAW_ENVELOPE_BODY, headers=MODERN_HEADERS) + assert response.status_code == 200, response.text + assert response.headers["content-type"].split(";", 1)[0] == "application/json" + assert "mcp-session-id" not in response.headers + payload = response.json() + assert payload["id"] == 1 + assert [t["name"] for t in payload["result"]["tools"]] == ["greet"] + + +if __name__ == "__main__": + # HTTP-only story; --http takes the server origin (without /mcp). + # Hand-rolled because `run_client` has no needs_http arm; matches its + # fail_after(30) + FAIL/OK + exit-code semantics inline. + origin = argv_after("--http") + + async def _main() -> None: + with anyio.fail_after(30): + async with ( + httpx.AsyncClient(base_url=origin) as http_client, + Client(f"{origin}/mcp", mode=LATEST_MODERN_VERSION) as client, + ): + await scenario(client, http_client) + + try: + anyio.run(_main) + except Exception: + print("FAIL: json_response (http/modern)", file=sys.stderr) + traceback.print_exc() + raise SystemExit(1) from None + print("OK: json_response (http/modern)", file=sys.stderr) + raise SystemExit(0) diff --git a/examples/stories/json_response/server.py b/examples/stories/json_response/server.py new file mode 100644 index 0000000000..9ff767462f --- /dev/null +++ b/examples/stories/json_response/server.py @@ -0,0 +1,27 @@ +"""Serve over Streamable HTTP with JSON responses (no SSE stream). + +The 2026-07-28 path is stateless and JSON-only by construction today; the +``json_response=True`` flag also forces JSON for the legacy (2025-era) branch on +the same endpoint. Mid-call notifications are dropped. +""" + +from starlette.applications import Starlette + +from mcp.server.mcpserver import Context, MCPServer +from stories._hosting import NO_DNS_REBIND, run_app_from_args + + +def build_app() -> Starlette: + mcp = MCPServer("json-response-example") + + @mcp.tool() + async def greet(name: str, ctx: Context) -> str: + """Report progress mid-call, then return a greeting.""" + await ctx.report_progress(0.5, total=1.0, message="halfway") + return f"Hello, {name}!" + + return mcp.streamable_http_app(json_response=True, transport_security=NO_DNS_REBIND) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/json_response/server_lowlevel.py b/examples/stories/json_response/server_lowlevel.py new file mode 100644 index 0000000000..65ff815611 --- /dev/null +++ b/examples/stories/json_response/server_lowlevel.py @@ -0,0 +1,44 @@ +"""Serve over Streamable HTTP with JSON responses (lowlevel API).""" + +from typing import Any + +from starlette.applications import Starlette + +from mcp import types +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import NO_DNS_REBIND, run_app_from_args + +GREET_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], +} + + +def build_app() -> Starlette: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="greet", + description="Report progress mid-call, then return a greeting.", + input_schema=GREET_INPUT_SCHEMA, + ) + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "greet" and params.arguments is not None + await ctx.session.report_progress(0.5, total=1.0, message="halfway") + text = f"Hello, {params.arguments['name']}!" + return types.CallToolResult(content=[types.TextContent(text=text)], structured_content={"result": text}) + + server = Server("json-response-example", on_list_tools=list_tools, on_call_tool=call_tool) + return server.streamable_http_app(json_response=True, transport_security=NO_DNS_REBIND) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/legacy_routing/README.md b/examples/stories/legacy_routing/README.md new file mode 100644 index 0000000000..e912e543ad --- /dev/null +++ b/examples/stories/legacy_routing/README.md @@ -0,0 +1,95 @@ +# legacy-routing + +The exported era classifier. `classify_inbound_request(body, headers=...)` from +`mcp.shared.inbound` is the body-primary test for "is this a 2026-era request?"; +wrap it as `classify_era()` to route eras to different backends in your own +ASGI/ingress layer. Unlike most SDKs, the Python SDK's built-in +`streamable_http_app()` already serves **sessionful** 2025 alongside stateless +2026 on one `/mcp` route — so the predicate is for when you need *different* +arms (per-era auth, separate ports, an existing v1 deployment to keep), not to +make dual-era work at all. + +Also shown: the CORS `expose_headers` recipe browser-based MCP clients need. + +## Run it + +```bash +# HTTP only — the predicate is an HTTP-transport concern +uv run python -m stories.legacy_routing.server --port 8000 & +uv run python -m stories.legacy_routing.client --http http://127.0.0.1:8000/mcp + +# lowlevel server variant +uv run python -m stories.legacy_routing.server_lowlevel --port 8000 & +uv run python -m stories.legacy_routing.client --http http://127.0.0.1:8000/mcp --server server_lowlevel +``` + +## What to look at + +- `server.py` `classify_era` — the tri-state wrapper. `InboundModernRoute` → + `"modern"`; rung-1 `INVALID_PARAMS` (no envelope keys) → `"legacy"`; any + other `InboundLadderRejection` (header mismatch, unsupported version) is a + malformed-modern request to **reject**, not route to legacy. +- `server.py` `build_app` — `streamable_http_app()` + `CORSMiddleware`. The + `which_arm` tool reads `ctx.request_context.protocol_version` to prove which + path the built-in router took. +- `client.py` — same endpoint, two `mode=` values, two arms; then the predicate + shown directly against a modern body, a legacy body, and a malformed-modern + body. The runnable `build_app()` uses the SDK's built-in router; the + predicate itself is exercised as a pure function — see the user-land + composition recipe below for wiring it into your own ingress. +- `server_lowlevel.py` — same `classify_era` and CORS recipe (re-used from + `server.py`); `build_app` wires `lowlevel.Server` instead of `MCPServer` and + reads `ctx.protocol_version` directly. + +## User-land composition (when you need different backends) + +There is no `legacy="reject"` flag yet. To route eras to different handlers, +buffer the body, classify, replay: + +```python +async def mcp_endpoint(scope, receive, send): + body, replay = await buffer_body(receive) # your ASGI helper + headers = {k.decode("ascii").lower(): v.decode("latin-1") for k, v in scope["headers"]} + match classify_era(json.loads(body or b"{}"), headers): + case "legacy": + await my_existing_v1_manager.handle_request(scope, replay, send) + case "modern": + await modern_manager.handle_request(scope, replay, send) + case rejection: + await send_jsonrpc_error(send, rejection) # map via ERROR_CODE_HTTP_STATUS +``` + +Non-POST verbs (`GET` standalone-SSE, `DELETE` session termination) are +sessionful-2025-only — route them straight to the legacy arm. + +## Two ports instead of one + +Run two `uvicorn` processes from the same `build_app()` on different ports and +put `classify_era()` (or a header check) in your ingress. Useful when the two +eras need different auth, rate limits, or scaling. + +## Caveats + +- The SDK's **built-in** routing is currently header-only — a 2026 client that + omits `MCP-Protocol-Version` is mis-routed to legacy. + `classify_inbound_request()` is body-primary and is what the built-in moves + to in a later release; user-land routing with the predicate is already + correct today. +- `ctx.request_context.protocol_version` is the interim 2-hop reach; a later + release will shorten it. +- DNS-rebinding protection is on by default; the harness disables it + (`NO_DNS_REBIND`) because the in-process httpx client sends no `Origin`. + Drop the kwarg for a real deployment. +- `mcp.shared.inbound` is a deep import path — a shorter re-export is planned + before beta. + +## Spec + +- [Versioning — backward compatibility](https://modelcontextprotocol.io/specification/draft/basic/versioning) +- [Transports — protocol version header](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports) + +## See also + +`dual_era/` (the simple case: one factory, built-in routing, no predicate), +`stateless_legacy/` (`stateless_http=True`), `starlette_mount/` (mount inside +FastAPI). diff --git a/examples/stories/legacy_routing/__init__.py b/examples/stories/legacy_routing/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/legacy_routing/client.py b/examples/stories/legacy_routing/client.py new file mode 100644 index 0000000000..f11f93d13c --- /dev/null +++ b/examples/stories/legacy_routing/client.py @@ -0,0 +1,56 @@ +"""Connect at both eras to one app; assert the built-in router and the predicate agree.""" + +from typing import Any + +from mcp import types +from mcp.client import Client +from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER, InboundLadderRejection +from mcp.shared.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION +from mcp.types import CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY +from stories._harness import Connect, connect_from_args, run_client + +from .server import classify_era + + +def _arm(result: types.CallToolResult) -> str: + first = result.content[0] + assert isinstance(first, types.TextContent) + return first.text + + +async def scenario(client: Client, connect: Connect) -> None: + # ── modern leg: harness-supplied client at mode="auto" probed server/discover. + assert client.protocol_version == LATEST_MODERN_VERSION + assert _arm(await client.call_tool("which_arm", {})) == "modern" + + # ── legacy leg: same /mcp endpoint, initialize handshake → sessionful 2025 path. + async with connect(mode="legacy") as legacy: + assert legacy.protocol_version == LATEST_HANDSHAKE_VERSION + assert _arm(await legacy.call_tool("which_arm", {})) == "legacy" + + # ── the exported predicate, shown directly. A body carrying the 2026 _meta + # envelope classifies as modern; a bare initialize body classifies as legacy; + # a 2026 envelope whose header disagrees is a rejection (NOT legacy). + modern_body: dict[str, Any] = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/list", + "params": { + "_meta": { + PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION, + CLIENT_INFO_META_KEY: {"name": "demo", "version": "0"}, + CLIENT_CAPABILITIES_META_KEY: {}, + } + }, + } + assert classify_era(modern_body, headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}) == "modern" + + legacy_body: dict[str, Any] = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}} + assert classify_era(legacy_body, headers={}) == "legacy" + + mismatched = classify_era(modern_body, headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_HANDSHAKE_VERSION}) + assert isinstance(mismatched, InboundLadderRejection), mismatched + + +if __name__ == "__main__": + run_client(scenario, connect=connect_from_args(__file__), needs_connect=True, mode="auto") diff --git a/examples/stories/legacy_routing/server.py b/examples/stories/legacy_routing/server.py new file mode 100644 index 0000000000..d7bf3bb555 --- /dev/null +++ b/examples/stories/legacy_routing/server.py @@ -0,0 +1,55 @@ +"""Exported era classifier: the body-primary predicate, the built-in dual-era app, and CORS.""" + +from collections.abc import Mapping +from typing import Any, Literal + +from starlette.applications import Starlette +from starlette.middleware.cors import CORSMiddleware + +from mcp.server.mcpserver import Context, MCPServer +from mcp.shared.inbound import InboundLadderRejection, InboundModernRoute, classify_inbound_request +from mcp.shared.version import MODERN_PROTOCOL_VERSIONS +from mcp.types import INVALID_PARAMS +from stories._hosting import NO_DNS_REBIND, run_app_from_args + +#: Response headers a browser-based MCP client must be able to read. +MCP_EXPOSED_HEADERS = ["Mcp-Session-Id", "WWW-Authenticate", "Last-Event-Id", "Mcp-Protocol-Version"] + + +def classify_era( + body: Mapping[str, Any], headers: Mapping[str, str] +) -> Literal["modern", "legacy"] | InboundLadderRejection: + """Tri-state era classifier built on the exported `classify_inbound_request` predicate. + + Compose this in your own ASGI/ingress layer when the two eras need different + backends. Only a rung-1 ``INVALID_PARAMS`` rejection (no envelope keys) means + "treat as legacy"; other rejections are malformed-modern and should be refused. + """ + verdict = classify_inbound_request(body, headers=headers) + if isinstance(verdict, InboundModernRoute): + return "modern" + if verdict.code == INVALID_PARAMS: + return "legacy" + return verdict + + +def build_app() -> Starlette: + mcp = MCPServer("legacy-routing-example") + + @mcp.tool() + async def which_arm(ctx: Context) -> str: + """Report which era the built-in router dispatched this request to.""" + pv = ctx.request_context.protocol_version + return "modern" if pv in MODERN_PROTOCOL_VERSIONS else "legacy" + + # One Starlette app, one /mcp route, both eras: sessionful 2025 (initialize + + # Mcp-Session-Id + GET stream) and stateless 2026 (per-request _meta envelope). + app = mcp.streamable_http_app(transport_security=NO_DNS_REBIND) + + # CORS for browser-based clients. DEMO ONLY — restrict allow_origins in production. + app.add_middleware(CORSMiddleware, allow_origins=["*"], expose_headers=MCP_EXPOSED_HEADERS) + return app + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/legacy_routing/server_lowlevel.py b/examples/stories/legacy_routing/server_lowlevel.py new file mode 100644 index 0000000000..02cfc741a9 --- /dev/null +++ b/examples/stories/legacy_routing/server_lowlevel.py @@ -0,0 +1,42 @@ +"""Exported era classifier (lowlevel API): predicate + built-in dual-era app + CORS.""" + +from typing import Any + +from starlette.applications import Starlette +from starlette.middleware.cors import CORSMiddleware + +from mcp import types +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from mcp.shared.version import MODERN_PROTOCOL_VERSIONS +from stories._hosting import NO_DNS_REBIND, run_app_from_args + +from .server import MCP_EXPOSED_HEADERS + +WHICH_ARM = types.Tool( + name="which_arm", + description="Report which era the built-in router dispatched this request to.", + input_schema={"type": "object", "properties": {}}, +) + + +def build_app() -> Starlette: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[WHICH_ARM]) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "which_arm" + arm = "modern" if ctx.protocol_version in MODERN_PROTOCOL_VERSIONS else "legacy" + return types.CallToolResult(content=[types.TextContent(text=arm)]) + + server = Server("legacy-routing-example", on_list_tools=list_tools, on_call_tool=call_tool) + + app = server.streamable_http_app(transport_security=NO_DNS_REBIND) + app.add_middleware(CORSMiddleware, allow_origins=["*"], expose_headers=MCP_EXPOSED_HEADERS) + return app + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/lifespan/README.md b/examples/stories/lifespan/README.md new file mode 100644 index 0000000000..e8b75c2637 --- /dev/null +++ b/examples/stories/lifespan/README.md @@ -0,0 +1,45 @@ +# lifespan + +Process-scoped dependency injection. Pass an `@asynccontextmanager` as +`lifespan=` to acquire resources (a database pool, an HTTP client) once at +startup and release them at shutdown; tool bodies read the yielded state via +the injected `Context` — no module-level globals. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.lifespan.client + +# against a running HTTP server +uv run python -m stories.lifespan.server --http --port 8000 & +uv run python -m stories.lifespan.client --http http://127.0.0.1:8000/mcp +``` + +## What to look at + +- `app_lifespan` in `server.py` — the `try / yield / finally` shape is the + startup/shutdown contract; the `finally` block runs once on process exit, not + per request. +- `ctx.request_context.lifespan_context.db` in the `lookup` tool — the interim + 3-hop access path on `MCPServer`'s `Context`. +- `server_lowlevel.py` reaches the same state via `ctx.lifespan_context.db` — + one hop, because lowlevel handlers receive `ServerRequestContext` directly. + +## Caveats + +- `ctx.request_context.lifespan_context` is the interim path; a later release + will shorten this to `ctx.state.*`. The lowlevel `ctx.lifespan_context` path + is unaffected. +- **v1 → v2 scope change** — in v1.x, `lifespan` was entered once *per + connection*; in v2 it is entered once *per process*. See `docs/migration.md` + ("lifespan now per-process"). + +## Spec + +[Lifecycle](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle) + +## See also + +`stickynotes/` (lifespan-held mutable state with change notifications), +`serve_one/` (threading `lifespan_state` into the kernel by hand). diff --git a/examples/stories/lifespan/__init__.py b/examples/stories/lifespan/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/lifespan/client.py b/examples/stories/lifespan/client.py new file mode 100644 index 0000000000..d348fdcaa7 --- /dev/null +++ b/examples/stories/lifespan/client.py @@ -0,0 +1,20 @@ +"""Prove the lifespan-yielded state is reachable from a tool call.""" + +from mcp.client import Client +from mcp.types import TextContent +from stories._harness import connect_from_args, run_client + + +async def scenario(client: Client) -> None: + listed = await client.list_tools() + assert [t.name for t in listed.tools] == ["lookup"] + + result = await client.call_tool("lookup", {"key": "alpha"}) + assert isinstance(result.content[0], TextContent) and result.content[0].text == "one", result + + result = await client.call_tool("lookup", {"key": "beta"}) + assert isinstance(result.content[0], TextContent) and result.content[0].text == "two", result + + +if __name__ == "__main__": + run_client(scenario, connect=connect_from_args(__file__)) diff --git a/examples/stories/lifespan/server.py b/examples/stories/lifespan/server.py new file mode 100644 index 0000000000..a66e2154ad --- /dev/null +++ b/examples/stories/lifespan/server.py @@ -0,0 +1,39 @@ +"""Process-scoped dependency injection via `MCPServer(lifespan=...)`.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass +from typing import Any + +from mcp.server.mcpserver import Context, MCPServer +from stories._hosting import run_server_from_args + + +@dataclass +class AppState: + db: dict[str, str] + + +@asynccontextmanager +async def app_lifespan(server: MCPServer[AppState]) -> AsyncIterator[AppState]: + """Acquire process-scoped resources at startup; release them at shutdown.""" + db = {"alpha": "one", "beta": "two"} # e.g. `await pool.connect()` + try: + yield AppState(db=db) + finally: + db.clear() # e.g. `await pool.disconnect()` + + +def build_server() -> MCPServer[AppState]: + mcp = MCPServer[AppState]("lifespan-example", lifespan=app_lifespan) + + @mcp.tool(description="Look up a key in the process-scoped store.") + def lookup(key: str, ctx: Context[AppState, Any]) -> str: + # Interim 3-hop path; shortens to `ctx.state.db` in a later release. + return ctx.request_context.lifespan_context.db[key] + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/lifespan/server_lowlevel.py b/examples/stories/lifespan/server_lowlevel.py new file mode 100644 index 0000000000..36d835c4cb --- /dev/null +++ b/examples/stories/lifespan/server_lowlevel.py @@ -0,0 +1,65 @@ +"""Process-scoped dependency injection via lowlevel `Server(lifespan=...)`.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass +from typing import Any + +from mcp import types +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + + +@dataclass +class AppState: + db: dict[str, str] + + +@asynccontextmanager +async def app_lifespan(server: Server[AppState]) -> AsyncIterator[AppState]: + db = {"alpha": "one", "beta": "two"} + try: + yield AppState(db=db) + finally: + db.clear() + + +LOOKUP_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"key": {"type": "string"}}, + "required": ["key"], +} + + +def build_server() -> Server[AppState]: + async def list_tools( + ctx: ServerRequestContext[AppState], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="lookup", + description="Look up a key in the process-scoped store.", + input_schema=LOOKUP_INPUT_SCHEMA, + ) + ] + ) + + async def call_tool( + ctx: ServerRequestContext[AppState], params: types.CallToolRequestParams + ) -> types.CallToolResult: + assert params.name == "lookup" and params.arguments is not None + value = ctx.lifespan_context.db[params.arguments["key"]] + return types.CallToolResult(content=[types.TextContent(text=value)]) + + return Server[AppState]( + "lifespan-example", + lifespan=app_lifespan, + on_list_tools=list_tools, + on_call_tool=call_tool, + ) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/manifest.toml b/examples/stories/manifest.toml new file mode 100644 index 0000000000..820cba10f3 --- /dev/null +++ b/examples/stories/manifest.toml @@ -0,0 +1,139 @@ +# examples/stories/manifest.toml +# +# Drives tests/examples/ axis expansion. test_manifest_matches_filesystem +# asserts [story.*] keys == story dirs with a client.py. + +[defaults] +transports = ["in-memory", "http-asgi"] # in-memory = Client(server); http-asgi = StreamingASGITransport +era = "dual" # "dual" | "modern" | "legacy" | "dual-in-body" +lowlevel = true # also run scenario against server_lowlevel.build_server()/build_app() +server_export = "factory" # "factory" -> build_server() | "app" -> build_app() +needs_connect = false # scenario(client) vs scenario(client, connect) +needs_http = false # scenario(..., http) gets the raw httpx.AsyncClient (http-asgi only) +timeout_s = 30 +smoke = false +mcp_path = "/mcp" +xfail = [] # [":", ...] -> strict xfail on that leg +env = {} # env vars set for the leg via monkeypatch + +# ───────────────────────────── start here ───────────────────────────── + +[story.tools] +smoke = true + +[story.prompts] + +[story.resources] + +[story.lifespan] + +[story.dual_era] +era = "dual-in-body" +needs_connect = true + +[story.custom_version] +era = "legacy" + +[story.streaming] +# progress + log notifications dropped on the modern streamable-HTTP path pending SSE wiring +xfail = ["http-asgi:modern"] + +[story.elicitation] +era = "legacy" + +[story.sampling] +era = "legacy" + +[story.stickynotes] + +[story.custom_methods] +lowlevel = false + +[story.schema_validators] + +[story.middleware] + +[story.parallel_calls] +# modern single-exchange dispatch context no-ops notify() over streamable-http +xfail = ["http-asgi:modern"] + +[story.roots] +era = "legacy" + +[story.pagination] + +[story.error_handling] + +[story.client_session] +lowlevel = false + +[story.serve_one] +transports = ["in-memory"] + +[story.stateless_legacy] +transports = ["http-asgi"] +server_export = "app" +era = "dual-in-body" +needs_connect = true +smoke = true + +[story.json_response] +transports = ["http-asgi"] +server_export = "app" +era = "modern" +needs_http = true + +[story.legacy_routing] +transports = ["http-asgi"] +server_export = "app" +era = "dual-in-body" +needs_connect = true + +[story.starlette_mount] +transports = ["http-asgi"] +server_export = "app" +lowlevel = false +mcp_path = "/api/" + +[story.sse_polling] +transports = ["http-asgi"] +server_export = "app" +era = "legacy" +timeout_s = 20 +# event_store.py is local; example-grade only (sequential IDs, no eviction). + +[story.standalone_get] +transports = ["http-asgi"] +era = "legacy" + +[story.reconnect] +transports = ["http-asgi"] +era = "modern" +needs_connect = true + +[story.bearer_auth] +transports = ["http-asgi"] +server_export = "app" + +[story.oauth] +transports = ["http-asgi"] +server_export = "app" +needs_connect = true +env = { OAUTH_DEMO_AUTO_CONSENT = "1" } + +[story.oauth_client_credentials] +transports = ["http-asgi"] +server_export = "app" + +# ───────────────────────────── deferred ───────────────────────────── +# README-only placeholders; no client.py, not expanded into legs. +# test_manifest_matches_filesystem checks these match the README-only dirs. + +[deferred] +caching = "client honouring + per-result override unlanded" +mrtr = "#2898 — InputRequiredResult runtime" +subscriptions = "#2901 — Client.listen / ServerEventBus" +tasks = "extensions capability map + tasks runtime" +apps = "#2896 — extensions capability map" +skills = "#2896 — SEP-2640" +events = "#2901 + #2896" diff --git a/examples/stories/middleware/README.md b/examples/stories/middleware/README.md new file mode 100644 index 0000000000..f270418181 --- /dev/null +++ b/examples/stories/middleware/README.md @@ -0,0 +1,50 @@ +# middleware + +Register a single `async (ctx, call_next) -> result` function on +`Server.middleware` to observe or alter every request and notification the +server receives, across both protocol eras and any transport. Middleware sits +*outside* method lookup and params validation, so it sees `initialize`, +`server/discover`, `notifications/*`, and unknown methods too. The chain runs +outermost-first. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.middleware.client + +# against a running HTTP server +uv run python -m stories.middleware.server --http --port 8000 & +uv run python -m stories.middleware.client --http http://127.0.0.1:8000/mcp +``` + +## What to look at + +- `server_lowlevel.py` — `server.middleware.append(record_calls)` is the public + registration point on `mcp.server.lowlevel.Server`. +- `server.py` — `MCPServer` has no public hook yet, so the example reaches + `mcp._lowlevel_server.middleware` (a public `MCPServer.middleware` accessor + is planned before beta — prefer the lowlevel variant until then). +- `client.py` — the asserted log ends at `"tools/call"` without a `:done` + suffix: `audit_log` runs *inside* `call_next(ctx)`, so the `finally` hasn't + fired yet. That's the wrap. + +## Caveats + +- The middleware signature is **provisional** (see the TODO in + `src/mcp/server/lowlevel/server.py`): it tightens to a covariant `Context[L]` + and gains an outbound seam before v2 final. +- `ServerMiddleware` / `CallNext` / `HandlerResult` are imported from + `mcp.server.context` (helper tier); not re-exported at `mcp.server.lowlevel`. +- Do **not** `await ctx.session.send_request(...)` while wrapping `initialize` + — `initialize` is dispatched inline and the outbound channel isn't open yet. + +## Spec + +Middleware is SDK architecture, not an MCP spec feature. + +## See also + +`custom_methods/` (rewrite `ctx.method` / `ctx.params` via +`dataclasses.replace(ctx, ...)` before `call_next`), +`src/mcp/server/_otel.py` (`OpenTelemetryMiddleware`, the SDK's own consumer). diff --git a/examples/stories/middleware/__init__.py b/examples/stories/middleware/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/middleware/client.py b/examples/stories/middleware/client.py new file mode 100644 index 0000000000..ba3c05f56b --- /dev/null +++ b/examples/stories/middleware/client.py @@ -0,0 +1,24 @@ +"""Prove the middleware wrapped both `tools/list` and the in-flight `tools/call`.""" + +from mcp.client import Client +from stories._harness import connect_from_args, run_client + + +async def scenario(client: Client) -> None: + listed = await client.list_tools() + assert [t.name for t in listed.tools] == ["audit_log"] + + result = await client.call_tool("audit_log", {}) + assert not result.is_error + assert result.structured_content is not None, result + + # Era-neutral: legacy adds initialize + notifications/initialized; modern HTTP + # adds server/discover; modern in-memory adds nothing. Filter to the methods + # this scenario drove. + seen = [m for m in result.structured_content["result"] if m.startswith("tools/")] + # tools/call:done is absent — the handler ran inside the middleware frame. + assert seen == ["tools/list", "tools/list:done", "tools/call"], seen + + +if __name__ == "__main__": + run_client(scenario, connect=connect_from_args(__file__)) diff --git a/examples/stories/middleware/server.py b/examples/stories/middleware/server.py new file mode 100644 index 0000000000..64b3bce78b --- /dev/null +++ b/examples/stories/middleware/server.py @@ -0,0 +1,35 @@ +"""Dispatch-layer middleware: one function wraps every inbound MCP message.""" + +from typing import Any + +from mcp.server.context import CallNext, HandlerResult, ServerRequestContext +from mcp.server.mcpserver import MCPServer +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer("middleware-example") + log: list[str] = [] + + async def record_calls(ctx: ServerRequestContext[Any], call_next: CallNext) -> HandlerResult: + log.append(ctx.method) + try: + return await call_next(ctx) + finally: + log.append(f"{ctx.method}:done") + + # MCPServer exposes no public middleware hook yet; the list lives on the wrapped + # lowlevel Server. DO NOT copy this private reach — see server_lowlevel.py for the + # public `server.middleware.append(...)` registration. + mcp._lowlevel_server.middleware.append(record_calls) # pyright: ignore[reportPrivateUsage] + + @mcp.tool() + def audit_log() -> list[str]: + """Return every method the middleware has observed so far.""" + return list(log) + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/middleware/server_lowlevel.py b/examples/stories/middleware/server_lowlevel.py new file mode 100644 index 0000000000..2fc4ff0398 --- /dev/null +++ b/examples/stories/middleware/server_lowlevel.py @@ -0,0 +1,49 @@ +"""Dispatch-layer middleware (lowlevel API): `Server.middleware` is the public hook.""" + +import json +from typing import Any + +from mcp import types +from mcp.server.context import CallNext, HandlerResult, ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + + +def build_server() -> Server[Any]: + log: list[str] = [] + + async def record_calls(ctx: ServerRequestContext[Any], call_next: CallNext) -> HandlerResult: + log.append(ctx.method) + try: + return await call_next(ctx) + finally: + log.append(f"{ctx.method}:done") + + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="audit_log", + description="Return every method the middleware has observed so far.", + input_schema={"type": "object"}, + ) + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "audit_log" + snapshot = list(log) + return types.CallToolResult( + content=[types.TextContent(text=json.dumps(snapshot))], + structured_content={"result": snapshot}, + ) + + server = Server("middleware-example", on_list_tools=list_tools, on_call_tool=call_tool) + server.middleware.append(record_calls) + return server + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/mrtr/README.md b/examples/stories/mrtr/README.md new file mode 100644 index 0000000000..226c44f83f --- /dev/null +++ b/examples/stories/mrtr/README.md @@ -0,0 +1,26 @@ +# mrtr + +Multi-round tool results: a 2026-era tool call returns +`resultType: "input_required"` with a `requestState` HMAC instead of pushing an +`elicitation/create` request. The client fulfils the input and resubmits, and +the server resumes from the carried state. The story will show both the +auto-fulfil helper and a manual resubmit loop. + +**Status: not yet implemented** ([#2898](https://github.com/modelcontextprotocol/python-sdk/issues/2898)). +The `InputRequiredResult` types exist, but `Client.call_tool` still validates +the response as a plain `CallToolResult` and rejects `input_required`. There is +no runnable round-trip until the runtime lands. + +## Spec + +[Multi-round tool results — server features](https://modelcontextprotocol.io/specification/draft/server/tools#multi-round-results) + +## Working example elsewhere + +The TypeScript SDK ships a runnable `mrtr` story: +[typescript-sdk/examples/mrtr](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/mrtr). + +## See also + +`elicitation/` and `sampling/` — the handshake-era push equivalents that this +mechanism replaces on the 2026 protocol. diff --git a/examples/stories/oauth/README.md b/examples/stories/oauth/README.md new file mode 100644 index 0000000000..c34ba5a2f0 --- /dev/null +++ b/examples/stories/oauth/README.md @@ -0,0 +1,70 @@ +# oauth + +The full OAuth 2.1 authorization-code flow against an in-process Authorization +Server, over Streamable HTTP. On the **server** side: one `MCPServer(auth=..., +auth_server_provider=...)` constructor call co-hosts the RFC 9728 +protected-resource metadata route, the AS routes (`/register`, `/authorize`, +`/token`, `/.well-known/oauth-authorization-server`) and the bearer-gated +`/mcp` endpoint on a single Starlette app. On the **client** side: +`OAuthClientProvider` is an `httpx.Auth` that reacts to the first `401` by +walking PRM discovery → AS metadata → DCR → PKCE authorize → token exchange → +bearer retry — all inside `Client.__aenter__`, with no user-visible +`UnauthorizedError`. + +## Run it + +```bash +# terminal 1 — co-hosted AS + bearer-gated /mcp on :8000 +OAUTH_DEMO_AUTO_CONSENT=1 uv run python -m stories.oauth.server --port 8000 + +# terminal 2 — authorization-code flow (headless: redirect followed in-process) +uv run python -m stories.oauth.client --http http://127.0.0.1:8000/mcp + +# lowlevel-API variant of the same app +OAUTH_DEMO_AUTO_CONSENT=1 uv run python -m stories.oauth.server_lowlevel --port 8000 +``` + +`OAUTH_DEMO_AUTO_CONSENT=1` makes the demo AS skip the consent screen and 302 +straight back with `?code=...`; without it the authorize step returns +`error=interaction_required` so you can see where a real browser would open. + +## What to look at + +- **`server.py` — `MCPServer(auth=..., auth_server_provider=...)`.** The + constructor wires everything; `streamable_http_app()` reads it back. (Don't + also pass `token_verifier=` — `auth_server_provider` and `token_verifier` are + mutually exclusive.) The `whoami` tool reads the validated principal via + `get_access_token()` — a per-HTTP-request contextvar set by + `AuthContextMiddleware`, not per-session. +- **`server_lowlevel.py`** — same wire shape, but `lowlevel.Server` takes + `auth=`/`token_verifier=`/`auth_server_provider=` on `streamable_http_app()` + rather than the constructor. `mcp.server.auth.*` is a helper tier the lowlevel + API may import directly. +- **`client.py` — `_auth_with()` / `build_auth()`.** `OAuthClientProvider` is + threaded onto `httpx.AsyncClient.auth`; `Client(url)` has no `auth=` kwarg + yet, so the transport is built by hand: + `Client(streamable_http_client(url, http_client=http))`. +- **`client.py` — token reuse.** A `Client` cannot be re-entered after + `__aexit__`. The third connection reuses the same `TokenStorage`, so it sends + `Authorization: Bearer ...` on the very first request — no `/authorize`, no + `/register` — and `whoami` returns the DCR-persisted `client_id`. + +## Caveats + +- `transport_security=NO_DNS_REBIND` — DNS-rebinding protection is on by default + and the in-process httpx bridge sends no `Origin` header. Drop the kwarg for a + real deployment. +- `HeadlessOAuth` only works because the demo AS auto-consents; a real + `redirect_handler` would open a browser and a real `callback_handler` would + run a loopback HTTP listener for the redirect. +- The `mcp.server.auth.*` import paths are deep (no `mcp.server` re-export yet). + +## Spec + +[Authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) + +## See also + +`bearer_auth/` (RS-only, static token, no AS) · `oauth_client_credentials/` +(M2M `client_credentials` grant — no browser, no DCR) · `reconnect/` (the other +`connect: Connect` consumer). diff --git a/examples/stories/oauth/__init__.py b/examples/stories/oauth/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/oauth/client.py b/examples/stories/oauth/client.py new file mode 100644 index 0000000000..1ee67111be --- /dev/null +++ b/examples/stories/oauth/client.py @@ -0,0 +1,89 @@ +"""OAuth authorization-code flow: 401 → PRM → AS metadata → DCR → PKCE authorize → token → retry.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any + +import anyio +import httpx +from pydantic import AnyUrl + +from mcp.client import Client +from mcp.client.auth import OAuthClientProvider +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import OAuthClientMetadata +from mcp.shared.version import LATEST_MODERN_VERSION +from stories._harness import AuthBuilder, Connect, argv_after, run_client +from stories._shared.auth import MCP_URL, REDIRECT_URI, HeadlessOAuth, InMemoryTokenStorage + + +def _auth_with(storage: InMemoryTokenStorage) -> AuthBuilder: + """Build an ``OAuthClientProvider`` over ``storage``, completing the redirect headlessly.""" + + def builder(http_client: httpx.AsyncClient) -> httpx.Auth: + headless = HeadlessOAuth() + headless.bind(http_client) + return OAuthClientProvider( + server_url=MCP_URL, + client_metadata=OAuthClientMetadata( + client_name="oauth-story-client", + redirect_uris=[AnyUrl(REDIRECT_URI)], + grant_types=["authorization_code", "refresh_token"], + ), + storage=storage, + redirect_handler=headless.redirect_handler, + callback_handler=headless.callback_handler, + ) + + return builder + + +def build_auth(http_client: httpx.AsyncClient) -> httpx.Auth: + """Harness hook: fresh storage so each leg's first connection runs the full flow.""" + return _auth_with(InMemoryTokenStorage())(http_client) + + +async def scenario(client: Client, connect: Connect) -> None: + # The harness entered ``client`` with auth=build_auth(...); the first /mcp request + # 401'd and OAuthClientProvider walked PRM discovery → AS metadata → DCR → PKCE + # authorize → token exchange → bearer retry — all inside __aenter__. Prove it landed: + result = await client.call_tool("whoami", {}) + assert result.structured_content is not None + assert "mcp" in result.structured_content["scopes"], result + + # TokenStorage contract: a fresh provider over fresh storage runs the full flow and + # persists both the issued tokens and the DCR-registered client info. + storage = InMemoryTokenStorage() + with anyio.fail_after(5): + async with connect(auth=_auth_with(storage)) as second: + await second.call_tool("whoami", {}) + assert storage.tokens is not None + assert storage.client_info is not None and storage.client_info.client_id is not None + registered_id = storage.client_info.client_id + + # Token reuse: a fresh Client over the SAME storage sends Bearer on the very first + # request — no /authorize, no /register. The principal is the one DCR persisted. + with anyio.fail_after(5): + async with connect(auth=_auth_with(storage)) as third: + again = await third.call_tool("whoami", {}) + assert again.structured_content is not None + assert again.structured_content["client_id"] == registered_id, again + + +@asynccontextmanager +async def _connect_real(*, auth: AuthBuilder | None = None, **kw: Any) -> AsyncIterator[Client]: + """Real-socket ``Connect`` for ``__main__``. + + ``Client(url)`` has no ``auth=`` passthrough yet, so build ``httpx.AsyncClient`` → + ``streamable_http_client`` → ``Client`` by hand and thread the auth onto httpx. + """ + url = argv_after("--http", default=MCP_URL) + kw.setdefault("mode", LATEST_MODERN_VERSION) + async with httpx.AsyncClient() as http: + http.auth = (auth or build_auth)(http) + async with Client(streamable_http_client(url, http_client=http), **kw) as c: + yield c + + +if __name__ == "__main__": + run_client(scenario, connect=_connect_real, needs_connect=True) diff --git a/examples/stories/oauth/server.py b/examples/stories/oauth/server.py new file mode 100644 index 0000000000..855cb5f951 --- /dev/null +++ b/examples/stories/oauth/server.py @@ -0,0 +1,40 @@ +"""OAuth-protected MCP server: in-process AS + PRM + bearer-gated /mcp on one Starlette app.""" + +from pydantic import BaseModel +from starlette.applications import Starlette + +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.mcpserver import MCPServer +from stories._hosting import NO_DNS_REBIND, run_app_from_args +from stories._shared.auth import InMemoryAuthorizationServerProvider, auth_settings + + +class Principal(BaseModel): + client_id: str + scopes: list[str] + + +def build_app() -> Starlette: + # The provider is both the Authorization Server (DCR/authorize/token) and the + # token store the bearer middleware validates against — one in-memory dict. + provider = InMemoryAuthorizationServerProvider() + + # ``auth_server_provider=`` alone is enough — MCPServer derives a token verifier + # from it (passing both trips the mutex guard). + mcp = MCPServer( + "oauth-example", + auth=auth_settings(required_scopes=["mcp"]), + auth_server_provider=provider, + ) + + @mcp.tool(description="Return the authenticated principal's client_id and granted scopes.") + def whoami() -> Principal: + token = get_access_token() + assert token is not None + return Principal(client_id=token.client_id, scopes=token.scopes) + + return mcp.streamable_http_app(transport_security=NO_DNS_REBIND) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/oauth/server_lowlevel.py b/examples/stories/oauth/server_lowlevel.py new file mode 100644 index 0000000000..93d5afb209 --- /dev/null +++ b/examples/stories/oauth/server_lowlevel.py @@ -0,0 +1,58 @@ +"""OAuth-protected MCP server (lowlevel API): same app shape, hand-built result types.""" + +from typing import Any + +from starlette.applications import Starlette + +from mcp import types +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.provider import ProviderTokenVerifier +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import NO_DNS_REBIND, run_app_from_args +from stories._shared.auth import InMemoryAuthorizationServerProvider, auth_settings + +WHOAMI_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"client_id": {"type": "string"}, "scopes": {"type": "array", "items": {"type": "string"}}}, + "required": ["client_id", "scopes"], +} + + +def build_app() -> Starlette: + provider = InMemoryAuthorizationServerProvider() + + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="whoami", + description="Return the authenticated principal's client_id and granted scopes.", + input_schema={"type": "object"}, + output_schema=WHOAMI_OUTPUT_SCHEMA, + ), + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "whoami" + token = get_access_token() + assert token is not None + payload = {"client_id": token.client_id, "scopes": token.scopes} + return types.CallToolResult(content=[types.TextContent(text=token.client_id)], structured_content=payload) + + server = Server("oauth-example", on_list_tools=list_tools, on_call_tool=call_tool) + # Unlike MCPServer (auth on the constructor), lowlevel.Server takes auth as + # streamable_http_app() kwargs — same wired routes, different entry point. + return server.streamable_http_app( + auth=auth_settings(required_scopes=["mcp"]), + token_verifier=ProviderTokenVerifier(provider), + auth_server_provider=provider, + transport_security=NO_DNS_REBIND, + ) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/oauth_client_credentials/README.md b/examples/stories/oauth_client_credentials/README.md new file mode 100644 index 0000000000..1037927014 --- /dev/null +++ b/examples/stories/oauth_client_credentials/README.md @@ -0,0 +1,65 @@ +# oauth-client-credentials + +OAuth 2.0 **`client_credentials`** grant — machine-to-machine MCP auth, no +browser. A backend service authenticates *as itself* by presenting a +pre-registered `client_id`/`client_secret` directly to the AS token endpoint; +the SDK's `ClientCredentialsOAuthProvider` handles 401-challenge → PRM/AS +discovery → token POST → Bearer attachment automatically. + +## Run it + +```bash +# start the server (real uvicorn on :8000 — auth is HTTP-only) +uv run python -m stories.oauth_client_credentials.server --port 8000 & +uv run python -m stories.oauth_client_credentials.client --http http://127.0.0.1:8000/mcp + +# lowlevel-API variant of the same app +uv run python -m stories.oauth_client_credentials.server_lowlevel --port 8000 & +uv run python -m stories.oauth_client_credentials.client --http http://127.0.0.1:8000/mcp +``` + +OAuth is an HTTP-layer concern; stdio servers receive credentials via the +environment per the spec, so there is no stdio leg. + +## What to look at + +- `client.py` `build_auth` — five lines of `ClientCredentialsOAuthProvider` + config is all the caller writes; the SDK does RFC 9728 PRM → + RFC 8414 AS-metadata discovery and token exchange on the first 401. +- `server.py` `token_endpoint` — the *entire* AS for this grant: validate + HTTP-Basic `client_id:client_secret`, mint a token, return RFC 6749 JSON. + The SDK's built-in `auth_server_provider=` only routes + `authorization_code`/`refresh_token`, so M2M servers mount their own `/token`. +- `server.py` `whoami` — `get_access_token()` is how a tool reads the + authenticated principal (`client_id`, `scopes`) from the request context. +- `server_lowlevel.py` — identical auth wiring via + `Server.streamable_http_app(auth=..., token_verifier=..., + custom_starlette_routes=[...])`; only the tool registration differs. + +## Caveats + +- `Client(url)` has no `auth=` passthrough — you build `httpx.AsyncClient` → + `streamable_http_client(url, http_client=hc)` → `Client(transport)` yourself. + The `__main__` block shows the chain. +- `transport_security=NO_DNS_REBIND` — DNS-rebinding protection is on by + default for localhost binds; the harness disables it because the in-process + httpx client sends no `Origin` header. Drop the kwarg for a real deployment. +- `OAuthMetadata.authorization_endpoint` is a required field even though a + `client_credentials`-only AS has no authorize endpoint; the server sets a + dummy URL. + +## `private_key_jwt` + +Swap `ClientCredentialsOAuthProvider` for `PrivateKeyJWTOAuthProvider` to +authenticate the token request with a signed assertion (RFC 7523 §2.2) instead +of a shared secret. Not exercised here because the demo AS only validates +`client_secret_basic`. + +## Spec + +[Authorization](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) + +## See also + +`oauth/` (interactive `authorization_code` + PKCE — user-facing flow) · +`bearer_auth/` (static token, no AS — simplest gating). diff --git a/examples/stories/oauth_client_credentials/__init__.py b/examples/stories/oauth_client_credentials/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/oauth_client_credentials/client.py b/examples/stories/oauth_client_credentials/client.py new file mode 100644 index 0000000000..afb290eedf --- /dev/null +++ b/examples/stories/oauth_client_credentials/client.py @@ -0,0 +1,57 @@ +"""Connect with ``ClientCredentialsOAuthProvider``; assert ``whoami`` round-trips client_id + scopes.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any + +import httpx + +from mcp.client import Client +from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.version import LATEST_MODERN_VERSION +from stories._harness import argv_after, run_client +from stories._shared.auth import MCP_URL, InMemoryTokenStorage + +from .server import DEMO_CLIENT_ID, DEMO_CLIENT_SECRET, DEMO_SCOPE + + +def build_auth(_http: httpx.AsyncClient) -> httpx.Auth: + """The ``httpx.Auth`` for the ``client_credentials`` grant — five lines of provider config. + + The SDK then handles 401 → RFC 9728 PRM → RFC 8414 AS-metadata discovery → token POST → + Bearer attachment automatically. Signature satisfies the harness ``AuthBuilder`` hook. + """ + return ClientCredentialsOAuthProvider( + server_url=MCP_URL, + storage=InMemoryTokenStorage(), + client_id=DEMO_CLIENT_ID, + client_secret=DEMO_CLIENT_SECRET, + scopes=DEMO_SCOPE, + ) + + +async def scenario(client: Client) -> None: + listed = await client.list_tools() + assert [t.name for t in listed.tools] == ["whoami"] + + result = await client.call_tool("whoami", {}) + assert not result.is_error + assert result.structured_content is not None + assert result.structured_content["client_id"] == DEMO_CLIENT_ID, result + assert DEMO_SCOPE in result.structured_content["scopes"] + + +if __name__ == "__main__": + url = argv_after("--http", default=MCP_URL) + + # Client(url) has no auth= passthrough yet, so build the httpx → streamable_http_client + # → Client chain by hand and thread the auth onto httpx. + @asynccontextmanager + async def _connect(**kw: Any) -> AsyncIterator[Client]: + async with httpx.AsyncClient() as http: + http.auth = build_auth(http) + async with Client(streamable_http_client(url, http_client=http), **kw) as client: + yield client + + run_client(scenario, connect=_connect, mode=LATEST_MODERN_VERSION) diff --git a/examples/stories/oauth_client_credentials/server.py b/examples/stories/oauth_client_credentials/server.py new file mode 100644 index 0000000000..96be7e09a8 --- /dev/null +++ b/examples/stories/oauth_client_credentials/server.py @@ -0,0 +1,77 @@ +"""Bearer-gated MCP resource server + a minimal in-process ``client_credentials`` AS, one app.""" + +import base64 +import secrets + +from pydantic import AnyHttpUrl, BaseModel +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse + +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.provider import AccessToken +from mcp.server.mcpserver import MCPServer +from mcp.shared.auth import OAuthMetadata, OAuthToken +from stories._hosting import NO_DNS_REBIND, run_app_from_args +from stories._shared.auth import BASE_URL, auth_settings + +# DEMO ONLY — never hard-code real credentials. +DEMO_CLIENT_ID = "demo-m2m-client" +DEMO_CLIENT_SECRET = "demo-m2m-secret" +DEMO_SCOPE = "mcp:tools" + + +class Whoami(BaseModel): + client_id: str + scopes: list[str] + + +def build_app() -> Starlette: + issued: dict[str, AccessToken] = {} + + class _Verifier: + async def verify_token(self, token: str) -> AccessToken | None: + return issued.get(token) + + mcp = MCPServer( + "oauth-client-credentials-example", + token_verifier=_Verifier(), + auth=auth_settings(required_scopes=[DEMO_SCOPE]), + ) + + @mcp.tool(description="Return the authenticated client_id and granted scopes.") + def whoami() -> Whoami: + token = get_access_token() + assert token is not None + return Whoami(client_id=token.client_id, scopes=token.scopes) + + @mcp.custom_route("/.well-known/oauth-authorization-server", methods=["GET"]) + async def as_metadata(request: Request) -> JSONResponse: + meta = OAuthMetadata( + issuer=AnyHttpUrl(BASE_URL), + authorization_endpoint=AnyHttpUrl(f"{BASE_URL}/authorize"), # unused; required + token_endpoint=AnyHttpUrl(f"{BASE_URL}/token"), + grant_types_supported=["client_credentials"], + token_endpoint_auth_methods_supported=["client_secret_basic"], + scopes_supported=[DEMO_SCOPE], + ) + return JSONResponse(meta.model_dump(by_alias=True, mode="json", exclude_none=True)) + + @mcp.custom_route("/token", methods=["POST"]) + async def token_endpoint(request: Request) -> JSONResponse: + form = await request.form() + if form.get("grant_type") != "client_credentials": + return JSONResponse({"error": "unsupported_grant_type"}, status_code=400) + creds = base64.b64decode(request.headers.get("authorization", "").removeprefix("Basic ")).decode() + if creds != f"{DEMO_CLIENT_ID}:{DEMO_CLIENT_SECRET}": + return JSONResponse({"error": "invalid_client"}, status_code=401) + access = f"access_{secrets.token_hex(16)}" + issued[access] = AccessToken(token=access, client_id=DEMO_CLIENT_ID, scopes=[DEMO_SCOPE], expires_at=None) + body = OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=DEMO_SCOPE) + return JSONResponse(body.model_dump(exclude_none=True), headers={"cache-control": "no-store"}) + + return mcp.streamable_http_app(transport_security=NO_DNS_REBIND) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/oauth_client_credentials/server_lowlevel.py b/examples/stories/oauth_client_credentials/server_lowlevel.py new file mode 100644 index 0000000000..147ab45948 --- /dev/null +++ b/examples/stories/oauth_client_credentials/server_lowlevel.py @@ -0,0 +1,82 @@ +"""Bearer-gated MCP resource server (lowlevel API) + the same minimal ``client_credentials`` AS.""" + +import base64 +import json +import secrets +from typing import Any + +from pydantic import AnyHttpUrl +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Route + +from mcp import types +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.provider import AccessToken +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from mcp.shared.auth import OAuthMetadata, OAuthToken +from stories._hosting import NO_DNS_REBIND, run_app_from_args +from stories._shared.auth import BASE_URL, auth_settings + +from .server import DEMO_CLIENT_ID, DEMO_CLIENT_SECRET, DEMO_SCOPE + + +def build_app() -> Starlette: + issued: dict[str, AccessToken] = {} + + class _Verifier: + async def verify_token(self, token: str) -> AccessToken | None: + return issued.get(token) + + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="whoami", input_schema={"type": "object"})]) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "whoami" + token = get_access_token() + assert token is not None + payload = {"client_id": token.client_id, "scopes": token.scopes} + return types.CallToolResult(content=[types.TextContent(text=json.dumps(payload))], structured_content=payload) + + server = Server("oauth-client-credentials-example", on_list_tools=list_tools, on_call_tool=call_tool) + + async def as_metadata(request: Request) -> JSONResponse: + meta = OAuthMetadata( + issuer=AnyHttpUrl(BASE_URL), + authorization_endpoint=AnyHttpUrl(f"{BASE_URL}/authorize"), # unused; required + token_endpoint=AnyHttpUrl(f"{BASE_URL}/token"), + grant_types_supported=["client_credentials"], + token_endpoint_auth_methods_supported=["client_secret_basic"], + scopes_supported=[DEMO_SCOPE], + ) + return JSONResponse(meta.model_dump(by_alias=True, mode="json", exclude_none=True)) + + async def token_endpoint(request: Request) -> JSONResponse: + form = await request.form() + if form.get("grant_type") != "client_credentials": + return JSONResponse({"error": "unsupported_grant_type"}, status_code=400) + creds = base64.b64decode(request.headers.get("authorization", "").removeprefix("Basic ")).decode() + if creds != f"{DEMO_CLIENT_ID}:{DEMO_CLIENT_SECRET}": + return JSONResponse({"error": "invalid_client"}, status_code=401) + access = f"access_{secrets.token_hex(16)}" + issued[access] = AccessToken(token=access, client_id=DEMO_CLIENT_ID, scopes=[DEMO_SCOPE], expires_at=None) + body = OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=DEMO_SCOPE) + return JSONResponse(body.model_dump(exclude_none=True), headers={"cache-control": "no-store"}) + + return server.streamable_http_app( + auth=auth_settings(required_scopes=[DEMO_SCOPE]), + token_verifier=_Verifier(), + custom_starlette_routes=[ + Route("/.well-known/oauth-authorization-server", as_metadata, methods=["GET"]), + Route("/token", token_endpoint, methods=["POST"]), + ], + transport_security=NO_DNS_REBIND, + ) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/pagination/README.md b/examples/stories/pagination/README.md new file mode 100644 index 0000000000..6fa188a6e7 --- /dev/null +++ b/examples/stories/pagination/README.md @@ -0,0 +1,49 @@ +# pagination + +Walk a paginated `resources/list` by hand: feed each result's `next_cursor` +back into `list_resources(cursor=...)` until it is `None`. The cursor is an +opaque server-chosen string — never parse it, and never terminate on a falsy +check (an empty string is a valid cursor under the spec). + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.pagination.client --server server_lowlevel + +# against a running HTTP server +uv run python -m stories.pagination.server_lowlevel --http --port 8000 & +uv run python -m stories.pagination.client --http http://127.0.0.1:8000/mcp +``` + +Swap `server_lowlevel` → `server` to run against the `MCPServer` variant +(single page). + +## What to look at + +- `client.py` — `if page.next_cursor is None: break`. Termination is + key-absent, not falsy; `while cursor:` would be a spec bug. +- `server_lowlevel.py` — the handler owns the cursor encoding (here: an + integer offset as a string) and rejects an unrecognised cursor with + `-32602 Invalid params`, the spec-recommended response. +- `server.py` — `MCPServer`'s decorator-registered resources are returned in + a single page; the inbound `cursor` is accepted but ignored. The same client + loop still terminates correctly after one request. + +## Caveats + +- **No `iter_*()` helper** — `Client` has no `iter_resources()` / + `iter_tools()` async-iterator yet; the manual `while True` loop shown here + is the supported pattern. +- **MCPServer is single-page** — `MCPServer` ignores `cursor` and never sets + `next_cursor`. Whether it grows a `page_size=` knob or stays single-page by + design is open; use the lowlevel server when you need to emit pages today. + +## Spec + +[Pagination — server utilities](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination) + +## See also + +`resources/`, `tools/`, `prompts/` — every `*/list` method paginates the same +way. Reference test: `tests/interaction/lowlevel/test_pagination.py`. diff --git a/examples/stories/pagination/__init__.py b/examples/stories/pagination/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/pagination/client.py b/examples/stories/pagination/client.py new file mode 100644 index 0000000000..ca22408b25 --- /dev/null +++ b/examples/stories/pagination/client.py @@ -0,0 +1,26 @@ +"""Walk every page of resources/list by hand until next_cursor is absent.""" + +from mcp.client import Client +from stories._harness import connect_from_args, run_client + + +async def scenario(client: Client) -> None: + names: list[str] = [] + cursor: str | None = None + pages_fetched = 0 + while True: + page = await client.list_resources(cursor=cursor) + pages_fetched += 1 + assert pages_fetched <= 6, "server kept returning next_cursor — runaway guard" + names.extend(r.name for r in page.resources) + if page.next_cursor is None: # terminate on absent, NOT on falsy: "" is a valid cursor + break + cursor = page.next_cursor + + assert names == ["alpha", "beta", "gamma", "delta", "epsilon", "zeta"], names + # server_lowlevel.py emits 3 pages of 2; server.py (MCPServer's flat registry) emits 1. + assert pages_fetched in (1, 3), pages_fetched + + +if __name__ == "__main__": + run_client(scenario, connect=connect_from_args(__file__)) diff --git a/examples/stories/pagination/server.py b/examples/stories/pagination/server.py new file mode 100644 index 0000000000..81a4f04fc5 --- /dev/null +++ b/examples/stories/pagination/server.py @@ -0,0 +1,24 @@ +"""Six static resources on MCPServer; its built-in registry serves them as one page.""" + +from mcp.server.mcpserver import MCPServer +from stories._hosting import run_server_from_args + +WORDS = ("alpha", "beta", "gamma", "delta", "epsilon", "zeta") + + +def build_server() -> MCPServer: + mcp = MCPServer("pagination-example") + + def register(word: str) -> None: + @mcp.resource(f"word://{word}", name=word, mime_type="text/plain") + def read() -> str: + return word + + for word in WORDS: + register(word) + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/pagination/server_lowlevel.py b/examples/stories/pagination/server_lowlevel.py new file mode 100644 index 0000000000..5be90b8c74 --- /dev/null +++ b/examples/stories/pagination/server_lowlevel.py @@ -0,0 +1,35 @@ +"""Paginated resources/list (lowlevel API): pages of two via an opaque integer-offset cursor.""" + +from typing import Any + +from mcp import types +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from mcp.shared.exceptions import MCPError +from stories._hosting import run_server_from_args + +WORDS = ("alpha", "beta", "gamma", "delta", "epsilon", "zeta") +PAGE_SIZE = 2 + + +def build_server() -> Server[Any]: + async def list_resources( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListResourcesResult: + start = 0 + if params is not None and params.cursor is not None: + if not params.cursor.isdigit() or int(params.cursor) >= len(WORDS): + raise MCPError(code=types.INVALID_PARAMS, message=f"Unknown cursor: {params.cursor!r}") + start = int(params.cursor) + page = WORDS[start : start + PAGE_SIZE] + next_start = start + PAGE_SIZE + return types.ListResourcesResult( + resources=[types.Resource(uri=f"word://{w}", name=w) for w in page], + next_cursor=str(next_start) if next_start < len(WORDS) else None, + ) + + return Server("pagination-example", on_list_resources=list_resources) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/parallel_calls/README.md b/examples/stories/parallel_calls/README.md new file mode 100644 index 0000000000..95836e4925 --- /dev/null +++ b/examples/stories/parallel_calls/README.md @@ -0,0 +1,48 @@ +# parallel-calls + +One `Client`, two `call_tool` requests in flight at once. Each caller gets its +own answer, and the per-call `progress_callback=` sees only the progress +notifications for *that* request — the SDK demultiplexes by progress token, not +by arrival order. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.parallel_calls.client + +# against a running HTTP server +uv run python -m stories.parallel_calls.server --http --port 8000 & +uv run python -m stories.parallel_calls.client --http http://127.0.0.1:8000/mcp +``` + +## What to look at + +- **`server.py` — the `arrivals` barrier.** Each handler sets its own + `anyio.Event` then waits for every peer's. A server that processed requests + sequentially would never set the second event, so the client would time out — + the timeout *is* the concurrency assertion. No sleeps. +- **`client.py` — `progress_callback=` per call.** Two concurrent calls each + pass a separate callback; `received == {"a": ["a"], "b": ["b"]}` proves the + SDK routes in-flight progress per request. +- **`server_lowlevel.py`** — same wire contract on the lowlevel `Server`, + reporting via `ctx.session.report_progress(...)`. + +## Caveats + +- Over Streamable HTTP in the modern (2026-07-28) era, handler-emitted progress + is currently dropped (the single-exchange dispatch context no-ops `notify()`). + That cell is `xfail`; in-memory and legacy-era HTTP both deliver progress + correctly. +- The N-clients × 1-server variant is omitted: the harness `connect()` factory + rebuilds the server per call, so a cross-client rendezvous would deadlock. + Over a long-running HTTP server it works exactly as the single-client case — + open a second `Client` against the same URL. + +## Spec + +[Progress flow](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress) + +## See also + +`streaming/` (progress + cancellation on one call), `tools/` (basics). diff --git a/examples/stories/parallel_calls/__init__.py b/examples/stories/parallel_calls/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/parallel_calls/client.py b/examples/stories/parallel_calls/client.py new file mode 100644 index 0000000000..e28b1eb285 --- /dev/null +++ b/examples/stories/parallel_calls/client.py @@ -0,0 +1,40 @@ +"""Issue concurrent `call_tool` requests on one `Client`; assert per-call progress demux.""" + +import anyio + +from mcp.client import Client +from mcp.types import TextContent +from stories._harness import connect_from_args, run_client + + +async def scenario(client: Client) -> None: + party = ["a", "b"] + results: dict[str, str] = {} + received: dict[str, list[str | None]] = {tag: [] for tag in party} + + def collector(tag: str): + async def on_progress(progress: float, total: float | None, message: str | None) -> None: + received[tag].append(message) + + return on_progress + + async def call(tag: str) -> None: + result = await client.call_tool("meet", {"tag": tag, "party": party}, progress_callback=collector(tag)) + assert not result.is_error, result + assert isinstance(result.content[0], TextContent) + results[tag] = result.content[0].text + + # Neither call can return until both handlers are running concurrently; a server that + # processed requests one-at-a-time would never set the second event and we'd time out here. + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: + tg.start_soon(call, "a") + tg.start_soon(call, "b") + + assert results == {"a": "a", "b": "b"}, results + # Progress is token-keyed per request: each callback saw only its own tag, never the sibling's. + assert received == {"a": ["a"], "b": ["b"]}, received + + +if __name__ == "__main__": + run_client(scenario, connect=connect_from_args(__file__)) diff --git a/examples/stories/parallel_calls/server.py b/examples/stories/parallel_calls/server.py new file mode 100644 index 0000000000..dc6d805e4a --- /dev/null +++ b/examples/stories/parallel_calls/server.py @@ -0,0 +1,31 @@ +"""One tool that rendezvouses with named peers, proving the server dispatches calls concurrently.""" + +from collections import defaultdict + +import anyio + +from mcp.server.mcpserver import Context, MCPServer +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer("parallel-calls-example") + # One Event per tag, shared across every call to this server instance. A handler sets its + # own tag's event, then waits for every peer's — so no call can return until all named + # peers are concurrently in-flight. A sequential dispatcher would deadlock here. + arrivals: dict[str, anyio.Event] = defaultdict(anyio.Event) + + @mcp.tool() + async def meet(tag: str, party: list[str], ctx: Context) -> str: + """Signal arrival as `tag`, block until every tag in `party` has also arrived, then return.""" + arrivals[tag].set() + for peer in party: + await arrivals[peer].wait() + await ctx.report_progress(1.0, total=1.0, message=tag) + return tag + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/parallel_calls/server_lowlevel.py b/examples/stories/parallel_calls/server_lowlevel.py new file mode 100644 index 0000000000..fa0cf812cc --- /dev/null +++ b/examples/stories/parallel_calls/server_lowlevel.py @@ -0,0 +1,48 @@ +"""Rendezvous tool on the lowlevel `Server`, proving concurrent dispatch without `MCPServer`.""" + +from collections import defaultdict +from typing import Any + +import anyio + +from mcp import types +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + +MEET_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "tag": {"type": "string"}, + "party": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["tag", "party"], +} + + +def build_server() -> Server[Any]: + arrivals: dict[str, anyio.Event] = defaultdict(anyio.Event) + + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[types.Tool(name="meet", description="Rendezvous with peers.", input_schema=MEET_INPUT_SCHEMA)] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "meet" + assert params.arguments is not None + tag = params.arguments["tag"] + assert isinstance(tag, str) + arrivals[tag].set() + for peer in params.arguments["party"]: + await arrivals[peer].wait() + await ctx.session.report_progress(1.0, total=1.0, message=tag) + return types.CallToolResult(content=[types.TextContent(text=tag)]) + + return Server("parallel-calls-example", on_list_tools=list_tools, on_call_tool=call_tool) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/prompts/README.md b/examples/stories/prompts/README.md new file mode 100644 index 0000000000..e9732d991d --- /dev/null +++ b/examples/stories/prompts/README.md @@ -0,0 +1,46 @@ +# prompts + +Expose prompt templates with `@mcp.prompt()` and let clients autocomplete their +arguments with `@mcp.completion()`. `MCPServer` derives each prompt's +`arguments` (name + required) from the function signature. The client lists +prompts, completes the `language` argument of `code_review`, then renders both +prompts. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.prompts.client + +# against a running HTTP server +uv run python -m stories.prompts.server --http --port 8000 & +uv run python -m stories.prompts.client --http http://127.0.0.1:8000/mcp +``` + +## What to look at + +- `server.py` `greet` vs `code_review` — return a bare `str` (wrapped as one + user message) or a `list[Message]` for a multi-turn seed conversation. +- `server.py` `complete()` — one global handler dispatches on `ref` + + `argument.name`; returning `None` becomes an empty completion. There is no + per-argument `completer=` sugar yet. +- `server_lowlevel.py` — the same `Prompt` / `PromptArgument` descriptors and + `GetPromptResult` built by hand; this is what `MCPServer` generates for you. +- `client.py` `complete(...)` — `argument` is a `{"name": ..., "value": ...}` + dict, the only `Client` request method that takes a raw dict for a typed + wire field. + +## Caveats + +`@mcp.prompt()` and `@mcp.completion()` need the parentheses — `@mcp.prompt` +without `()` raises a confusing `TypeError` at registration time. + +## Spec + +[Prompts](https://modelcontextprotocol.io/specification/2025-11-25/server/prompts) +· [Completion](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion) + +## See also + +`tools/` (start here), `resources/` (the other `ref` kind completion accepts), +`pagination/` (`list_prompts` cursor loop). diff --git a/examples/stories/prompts/__init__.py b/examples/stories/prompts/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/prompts/client.py b/examples/stories/prompts/client.py new file mode 100644 index 0000000000..932cdef073 --- /dev/null +++ b/examples/stories/prompts/client.py @@ -0,0 +1,37 @@ +"""List prompts, autocomplete an argument, then render both prompts.""" + +from mcp.client import Client +from mcp.types import PromptReference, TextContent +from stories._harness import connect_from_args, run_client + + +async def scenario(client: Client) -> None: + listed = await client.list_prompts() + by_name = {p.name: p for p in listed.prompts} + assert set(by_name) == {"greet", "code_review"} + assert by_name["greet"].arguments is not None + assert [a.name for a in by_name["greet"].arguments] == ["name"] + assert by_name["greet"].arguments[0].required is True + assert by_name["code_review"].title == "Code Review" + + completion = await client.complete( + PromptReference(name="code_review"), + argument={"name": "language", "value": "py"}, + ) + assert completion.completion.values == ["python", "pytorch"], completion + + greeted = await client.get_prompt("greet", {"name": "Ada"}) + assert len(greeted.messages) == 1 + assert greeted.messages[0].role == "user" + assert isinstance(greeted.messages[0].content, TextContent) + assert "Ada" in greeted.messages[0].content.text + + reviewed = await client.get_prompt("code_review", {"language": "rust", "code": "fn main() {}"}) + assert [m.role for m in reviewed.messages] == ["user", "assistant"] + first = reviewed.messages[0].content + assert isinstance(first, TextContent) + assert "rust" in first.text and "fn main() {}" in first.text + + +if __name__ == "__main__": + run_client(scenario, connect=connect_from_args(__file__)) diff --git a/examples/stories/prompts/server.py b/examples/stories/prompts/server.py new file mode 100644 index 0000000000..9fe9788d22 --- /dev/null +++ b/examples/stories/prompts/server.py @@ -0,0 +1,42 @@ +"""Prompts primitive: register templates, list, render, complete an argument.""" + +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, UserMessage +from mcp.types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference +from stories._hosting import run_server_from_args + +LANGUAGES = ["python", "pytorch", "rust", "go", "typescript"] + + +def build_server() -> MCPServer: + mcp = MCPServer("prompts-example") + + @mcp.prompt(title="Greeting") + def greet(name: str) -> str: + """Ask the model to greet someone by name.""" + return f"Write a one-line greeting for {name}." + + @mcp.prompt(title="Code Review") + def code_review(language: str, code: str) -> list[Message]: + """Ask the model to review a code snippet.""" + return [ + UserMessage(f"Review this {language} code for bugs and idioms:\n\n{code}"), + AssistantMessage("I'll review it. Let me read through the code first."), + ] + + @mcp.completion() + async def complete( + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, + ) -> Completion | None: + if isinstance(ref, PromptReference) and ref.name == "code_review" and argument.name == "language": + matches = [lang for lang in LANGUAGES if lang.startswith(argument.value)] + return Completion(values=matches, total=len(matches), has_more=False) + return None + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/prompts/server_lowlevel.py b/examples/stories/prompts/server_lowlevel.py new file mode 100644 index 0000000000..e2dff3aea6 --- /dev/null +++ b/examples/stories/prompts/server_lowlevel.py @@ -0,0 +1,86 @@ +"""Prompts primitive (lowlevel API): hand-built Prompt descriptors, GetPromptResult, completion.""" + +from typing import Any + +from mcp import types +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + +LANGUAGES = ["python", "pytorch", "rust", "go", "typescript"] + +PROMPTS = [ + types.Prompt( + name="greet", + title="Greeting", + description="Ask the model to greet someone by name.", + arguments=[types.PromptArgument(name="name", required=True)], + ), + types.Prompt( + name="code_review", + title="Code Review", + description="Ask the model to review a code snippet.", + arguments=[ + types.PromptArgument(name="language", required=True), + types.PromptArgument(name="code", required=True), + ], + ), +] + + +def build_server() -> Server[Any]: + async def list_prompts( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListPromptsResult: + return types.ListPromptsResult(prompts=PROMPTS) + + async def get_prompt(ctx: ServerRequestContext[Any], params: types.GetPromptRequestParams) -> types.GetPromptResult: + args = params.arguments or {} + if params.name == "greet": + return types.GetPromptResult( + description="Ask the model to greet someone by name.", + messages=[ + types.PromptMessage( + role="user", + content=types.TextContent(text=f"Write a one-line greeting for {args['name']}."), + ) + ], + ) + if params.name == "code_review": + return types.GetPromptResult( + description="Ask the model to review a code snippet.", + messages=[ + types.PromptMessage( + role="user", + content=types.TextContent( + text=f"Review this {args['language']} code for bugs and idioms:\n\n{args['code']}" + ), + ), + types.PromptMessage( + role="assistant", + content=types.TextContent(text="I'll review it. Let me read through the code first."), + ), + ], + ) + raise NotImplementedError + + async def completion(ctx: ServerRequestContext[Any], params: types.CompleteRequestParams) -> types.CompleteResult: + if ( + isinstance(params.ref, types.PromptReference) + and params.ref.name == "code_review" + and params.argument.name == "language" + ): + matches = [lang for lang in LANGUAGES if lang.startswith(params.argument.value)] + return types.CompleteResult(completion=types.Completion(values=matches, total=len(matches), has_more=False)) + return types.CompleteResult(completion=types.Completion(values=[])) + + return Server( + "prompts-example", + on_list_prompts=list_prompts, + on_get_prompt=get_prompt, + on_completion=completion, + ) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/reconnect/README.md b/examples/stories/reconnect/README.md new file mode 100644 index 0000000000..d7204b0461 --- /dev/null +++ b/examples/stories/reconnect/README.md @@ -0,0 +1,58 @@ +# reconnect + +Probe `server/discover` once, persist the `DiscoverResult`, and reconnect with +**zero round-trips**. The first client connects at `mode="auto"` (one +`server/discover` request inside `__aenter__`); a second client at +`mode=LATEST_MODERN_VERSION, prior_discover=` enters with no wire +traffic and has `server_info` / `server_capabilities` available immediately. + +## Run it + +```bash +# over HTTP — Streamable HTTP only; in-memory has no "round-trip" to skip +uv run python -m stories.reconnect.server --http --port 8000 & +uv run python -m stories.reconnect.client --http http://127.0.0.1:8000/mcp + +# lowlevel server variant +uv run python -m stories.reconnect.server_lowlevel --http --port 8000 & +uv run python -m stories.reconnect.client --http http://127.0.0.1:8000/mcp --server server_lowlevel +``` + +## What to look at + +- `client.py` — `client.session.discover_result`. The `mode="auto"` connect + ladder ran `server/discover` inside `__aenter__`; this property is the cached + result. Round-trip it through `model_dump_json()` / + `DiscoverResult.model_validate_json()` to model an on-disk cache. +- `client.py` — `connect(mode=LATEST_MODERN_VERSION, prior_discover=...)`. A + version pin plus a prior `DiscoverResult` installs the cached state via + `ClientSession.adopt()` with no `initialize` and no `server/discover` on the + wire — the era-neutral `client.server_info` / `.server_capabilities` + accessors are populated before the first request. +- `client.py` — the `connect: Connect` factory. A `Client` cannot be re-entered + after exit; build a fresh one via `connect()` for each attempt (see + `docs/migration.md`). + +## Caveats + +- `mode=` *without* `prior_discover=` synthesizes a placeholder + whose `server_info` is `Implementation(name="", version="")`. Pass the cached + result to get real identity on reconnect. Whether `Client` should expose a + public synthesizer (or refuse the bare pin) is open. +- `client.session.discover_result` is a one-hop reach into the mechanics layer; + `Client` does not yet surface the cached result directly. +- The wire-level proof that the second entry sends zero requests lives in the + interaction suite (`test_prior_discover_populates_state_with_zero_connect_time_traffic`); + this story asserts only what's observable through the public `Client` + surface. + +## Spec + +- [`server/discover`](https://modelcontextprotocol.io/specification/draft/server/discover) +- [Versioning — backward compatibility](https://modelcontextprotocol.io/specification/draft/basic/versioning) + +## See also + +`dual_era/` (auto-discover + era-neutral accessors), `client_session/` (the +`initialize`/`discover`/`adopt` mechanics layer), `parallel_calls/` (the other +`connect: Connect` consumer). diff --git a/examples/stories/reconnect/__init__.py b/examples/stories/reconnect/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/reconnect/client.py b/examples/stories/reconnect/client.py new file mode 100644 index 0000000000..f2d4fc9c59 --- /dev/null +++ b/examples/stories/reconnect/client.py @@ -0,0 +1,49 @@ +"""Probe server/discover once, persist the DiscoverResult, then reconnect with zero round-trips.""" + +from typing import Any + +import anyio + +from mcp.client import Client +from mcp.shared.version import LATEST_MODERN_VERSION +from mcp.types import DiscoverResult +from stories._harness import Connect, connect_from_args, run_client + +# The harness pins era="modern" → mode=LATEST_MODERN_VERSION (R8); override to "auto" so the +# first connection actually probes server/discover and caches the real DiscoverResult. +client_kw: dict[str, Any] = {"mode": "auto"} + + +async def scenario(client: Client, connect: Connect) -> None: + # ── first connection: mode="auto" probed server/discover inside __aenter__ ── + discovered = client.session.discover_result + assert discovered is not None, "mode='auto' against a modern server populates discover_result" + assert client.protocol_version == LATEST_MODERN_VERSION + assert client.server_info.name == "reconnect-example" + assert LATEST_MODERN_VERSION in discovered.supported_versions + + result = await client.call_tool("add", {"a": 2, "b": 3}) + assert result.structured_content == {"result": 5}, result + + # ── persist: round-trip through JSON to model loading from a cache on disk ── + saved = discovered.model_dump_json(by_alias=True) + rehydrated = DiscoverResult.model_validate_json(saved) + assert rehydrated == discovered + + # ── second connection: zero-RTT — mode= + prior_discover= sends nothing on entry. + # A Client cannot be re-entered after exit; build a fresh one via connect(). Without + # prior_discover= a bare pin would synthesize a blank server_info — passing the cached + # result is what makes the era-neutral accessors useful on reconnect. + with anyio.fail_after(5): + async with connect(mode=LATEST_MODERN_VERSION, prior_discover=rehydrated) as second: + assert second.protocol_version == LATEST_MODERN_VERSION + assert second.server_info.name == "reconnect-example" + assert second.server_capabilities.tools is not None + assert second.session.discover_result == rehydrated + + result = await second.call_tool("add", {"a": 1, "b": 1}) + assert result.structured_content == {"result": 2}, result + + +if __name__ == "__main__": + run_client(scenario, connect=connect_from_args(__file__), needs_connect=True, **client_kw) diff --git a/examples/stories/reconnect/server.py b/examples/stories/reconnect/server.py new file mode 100644 index 0000000000..bda460a295 --- /dev/null +++ b/examples/stories/reconnect/server.py @@ -0,0 +1,23 @@ +"""A small modern server whose DiscoverResult a client persists for zero-RTT reconnect.""" + +from mcp.server.mcpserver import MCPServer +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer( + "reconnect-example", + version="1.0.0", + instructions="Call add(a, b) to sum two integers.", + ) + + @mcp.tool() + def add(a: int, b: int) -> int: + """Add two integers.""" + return a + b + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/reconnect/server_lowlevel.py b/examples/stories/reconnect/server_lowlevel.py new file mode 100644 index 0000000000..926b7f3604 --- /dev/null +++ b/examples/stories/reconnect/server_lowlevel.py @@ -0,0 +1,47 @@ +"""A small modern server whose DiscoverResult a client persists for zero-RTT reconnect (lowlevel API).""" + +from typing import Any + +from mcp import types +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + +ADD = types.Tool( + name="add", + description="Add two integers.", + input_schema={ + "type": "object", + "properties": {"a": {"type": "integer"}, "b": {"type": "integer"}}, + "required": ["a", "b"], + }, +) + + +def build_server() -> Server[Any]: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[ADD]) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.arguments is not None + if params.name == "add": + total = int(params.arguments["a"]) + int(params.arguments["b"]) + return types.CallToolResult( + content=[types.TextContent(text=str(total))], + structured_content={"result": total}, + ) + raise NotImplementedError + + return Server( + "reconnect-example", + version="1.0.0", + instructions="Call add(a, b) to sum two integers.", + on_list_tools=list_tools, + on_call_tool=call_tool, + ) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/resources/README.md b/examples/stories/resources/README.md new file mode 100644 index 0000000000..2a29116cac --- /dev/null +++ b/examples/stories/resources/README.md @@ -0,0 +1,48 @@ +# resources + +Expose data by URI: a static resource (`config://app`) and an RFC-6570 +template (`greeting://{name}`). One `@mcp.resource()` decorator handles both — +the SDK infers static-vs-template from whether the URI contains `{...}`. The +client lists resources, lists templates, then reads each. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.resources.client + +# against a running HTTP server +uv run python -m stories.resources.server --http --port 8000 & +uv run python -m stories.resources.client --http http://127.0.0.1:8000/mcp + +# swap in the lowlevel server +uv run python -m stories.resources.client --server server_lowlevel +``` + +## What to look at + +- `server.py` `app_config` vs `greeting` — a URI with no `{}` registers a + static resource (appears in `resources/list`); a URI with `{name}` registers + a template (appears only in `resources/templates/list`) and the placeholder + must match the function parameter name. +- `server_lowlevel.py` `read_resource` — without `MCPServer` you own the URI + dispatch yourself, including raising `MCPError(code=INVALID_PARAMS, ...)` for + unknown URIs (matches what `MCPServer` sends). +- `client.py` `isinstance(entry, TextResourceContents)` — `contents` is a list + of `TextResourceContents | BlobResourceContents`; narrow before reading + `.text`. + +## Not shown here + +Subscriptions. Per-URI `resources/subscribe` is a 2025-era RPC being replaced +by `subscriptions/listen` in 2026-07-28; neither is shown in this story. See +`stickynotes/` for `list_changed` notifications. + +## Spec + +[Resources — server features](https://modelcontextprotocol.io/specification/2025-11-25/server/resources) + +## See also + +`stickynotes/` (list-changed notifications), `pagination/` (cursor over a long +resource list). diff --git a/examples/stories/resources/__init__.py b/examples/stories/resources/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/resources/client.py b/examples/stories/resources/client.py new file mode 100644 index 0000000000..69ec056056 --- /dev/null +++ b/examples/stories/resources/client.py @@ -0,0 +1,28 @@ +"""List resources and templates, then read both the static and templated URIs.""" + +from mcp.client import Client +from mcp.types import TextResourceContents +from stories._harness import connect_from_args, run_client + + +async def scenario(client: Client) -> None: + listed = await client.list_resources() + assert [r.uri for r in listed.resources] == ["config://app"] + + templates = await client.list_resource_templates() + assert [t.uri_template for t in templates.resource_templates] == ["greeting://{name}"] + + config = await client.read_resource("config://app") + entry = config.contents[0] + assert isinstance(entry, TextResourceContents) + assert entry.text == '{"feature": true}' + assert entry.mime_type == "application/json" + + hello = await client.read_resource("greeting://world") + entry = hello.contents[0] + assert isinstance(entry, TextResourceContents) + assert entry.text == "Hello, world!" + + +if __name__ == "__main__": + run_client(scenario, connect=connect_from_args(__file__)) diff --git a/examples/stories/resources/server.py b/examples/stories/resources/server.py new file mode 100644 index 0000000000..0879455cb1 --- /dev/null +++ b/examples/stories/resources/server.py @@ -0,0 +1,24 @@ +"""Resources primitive: a static URI and an RFC-6570 template via @mcp.resource().""" + +from mcp.server.mcpserver import MCPServer +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer("resources-example") + + @mcp.resource("config://app", mime_type="application/json") + def app_config() -> str: + """Static application config.""" + return '{"feature": true}' + + @mcp.resource("greeting://{name}") + def greeting(name: str) -> str: + """A greeting for the named subject.""" + return f"Hello, {name}!" + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/resources/server_lowlevel.py b/examples/stories/resources/server_lowlevel.py new file mode 100644 index 0000000000..eb935d9b9a --- /dev/null +++ b/examples/stories/resources/server_lowlevel.py @@ -0,0 +1,64 @@ +"""Resources primitive (lowlevel API): hand-built list/templates/read handlers.""" + +from typing import Any + +from mcp import types +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from mcp.shared.exceptions import MCPError +from mcp.types.jsonrpc import INVALID_PARAMS +from stories._hosting import run_server_from_args + + +def build_server() -> Server[Any]: + async def list_resources( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListResourcesResult: + return types.ListResourcesResult( + resources=[ + types.Resource( + uri="config://app", + name="app_config", + description="Static application config.", + mime_type="application/json", + ) + ] + ) + + async def list_resource_templates( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListResourceTemplatesResult: + return types.ListResourceTemplatesResult( + resource_templates=[ + types.ResourceTemplate( + uri_template="greeting://{name}", + name="greeting", + description="A greeting for the named subject.", + mime_type="text/plain", + ) + ] + ) + + async def read_resource( + ctx: ServerRequestContext[Any], params: types.ReadResourceRequestParams + ) -> types.ReadResourceResult: + if params.uri == "config://app": + text, mime = '{"feature": true}', "application/json" + elif params.uri.startswith("greeting://"): + text, mime = f"Hello, {params.uri.removeprefix('greeting://')}!", "text/plain" + else: + raise MCPError(code=INVALID_PARAMS, message=f"Resource not found: {params.uri}") + return types.ReadResourceResult( + contents=[types.TextResourceContents(uri=params.uri, mime_type=mime, text=text)] + ) + + return Server( + "resources-example", + on_list_resources=list_resources, + on_list_resource_templates=list_resource_templates, + on_read_resource=read_resource, + ) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/roots/README.md b/examples/stories/roots/README.md new file mode 100644 index 0000000000..9acaa7d63f --- /dev/null +++ b/examples/stories/roots/README.md @@ -0,0 +1,54 @@ +# roots + +The client registers a `list_roots_callback` returning the filesystem locations +it is willing to expose; a server tool calls `ctx.session.list_roots()` +mid-request and the client's callback answers it. Registering the callback is +what makes the client advertise the `roots` capability — there is no separate +flag. + +> **Deprecated.** The roots capability is deprecated as of 2026-07-28 +> (SEP-2577). New servers should accept directory paths as ordinary tool +> parameters or resource URIs instead. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.roots.client + +# against a running HTTP server +uv run python -m stories.roots.server --http --port 8000 & +uv run python -m stories.roots.client --http http://127.0.0.1:8000/mcp --legacy +``` + +## What to look at + +- `client.py` `list_roots` — the callback takes a `ClientRequestContext` and + returns `ListRootsResult`; passing it as `list_roots_callback=` is what + advertises the capability. +- `server.py` — `await ctx.session.list_roots()` inside the tool body: a + server→client request that blocks until the callback answers. +- `server_lowlevel.py` — the same call from `ServerRequestContext.session`, + with the `CallToolResult` built by hand. + +## Caveats + +- **Legacy-era only.** `roots/list` is a server-initiated request with no + 2026-07-28 wire carrier until the multi-round-trip runtime lands + ([#2898](https://github.com/modelcontextprotocol/python-sdk/issues/2898)), so + this story runs with `era = "legacy"` and the harness pins the handshake path. +- `ctx.session.list_roots()` is `@deprecated`; the + `# pyright: ignore[reportDeprecated]` is deliberate. There is no + non-deprecated server-side path until the multi-round-trip runtime lands. +- `ctx.session.*` is the interim 2-hop path; a later release will shorten it. +- `notifications/roots/list_changed` is intentionally not shown — removed in + 2026-07-28 (SEP-2575) and deprecated on the legacy path. + +## Spec + +[Roots — client features](https://modelcontextprotocol.io/specification/2025-11-25/client/roots) + +## See also + +`elicitation/`, `sampling/` — sibling server→client requests on the same MRTR +migration path. diff --git a/examples/stories/roots/__init__.py b/examples/stories/roots/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/roots/client.py b/examples/stories/roots/client.py new file mode 100644 index 0000000000..e85b420e62 --- /dev/null +++ b/examples/stories/roots/client.py @@ -0,0 +1,36 @@ +"""Expose two filesystem roots and verify the server's tool can read them back.""" + +from typing import Any + +from pydantic import FileUrl + +from mcp.client import Client, ClientRequestContext +from mcp.types import ListRootsResult, Root, TextContent +from stories._harness import connect_from_args, run_client + + +async def list_roots(context: ClientRequestContext) -> ListRootsResult: + return ListRootsResult( + roots=[ + Root(uri=FileUrl("file:///workspace/project"), name="project"), + Root(uri=FileUrl("file:///workspace/scratch")), + ] + ) + + +def client_kw() -> dict[str, Any]: + return {"list_roots_callback": list_roots} + + +async def scenario(client: Client) -> None: + result = await client.call_tool("show_roots", {}) + + assert not result.is_error, result + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == ("file:///workspace/project (project)\nfile:///workspace/scratch (unnamed)"), ( + result.content[0].text + ) + + +if __name__ == "__main__": + run_client(scenario, connect=connect_from_args(__file__), **client_kw()) diff --git a/examples/stories/roots/server.py b/examples/stories/roots/server.py new file mode 100644 index 0000000000..79e95f16c0 --- /dev/null +++ b/examples/stories/roots/server.py @@ -0,0 +1,19 @@ +"""Roots primitive: a tool asks the client which filesystem roots it may use.""" + +from mcp.server.mcpserver import Context, MCPServer +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer("roots-example") + + @mcp.tool(description="Return the filesystem roots the client has exposed.") + async def show_roots(ctx: Context) -> str: + result = await ctx.session.list_roots() # pyright: ignore[reportDeprecated] + return "\n".join(f"{root.uri} ({root.name or 'unnamed'})" for root in result.roots) + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/roots/server_lowlevel.py b/examples/stories/roots/server_lowlevel.py new file mode 100644 index 0000000000..866e8c3e09 --- /dev/null +++ b/examples/stories/roots/server_lowlevel.py @@ -0,0 +1,35 @@ +"""Roots primitive (lowlevel API): the same server→client round-trip, hand-built.""" + +from typing import Any + +from mcp import types +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + + +def build_server() -> Server[Any]: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="show_roots", + description="Return the filesystem roots the client has exposed.", + input_schema={"type": "object"}, + ), + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "show_roots" + result = await ctx.session.list_roots() # pyright: ignore[reportDeprecated] + lines = [f"{root.uri} ({root.name or 'unnamed'})" for root in result.roots] + return types.CallToolResult(content=[types.TextContent(text="\n".join(lines))]) + + return Server("roots-example", on_list_tools=list_tools, on_call_tool=call_tool) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/sampling/README.md b/examples/stories/sampling/README.md new file mode 100644 index 0000000000..f6a3238e64 --- /dev/null +++ b/examples/stories/sampling/README.md @@ -0,0 +1,56 @@ +# sampling + +A tool that asks the **client's** LLM for a completion mid-call — the inverted +MCP direction. The server holds no model API key; it awaits +`ctx.session.create_message(...)` and the client's `sampling_callback` answers. +Registering the callback is what makes the client advertise the `sampling` +capability — there is no separate flag. + +> **Deprecated.** The sampling capability is deprecated as of 2026-07-28 +> (SEP-2577). New servers should call an LLM provider directly instead of +> requesting completions through the client. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.sampling.client + +# against a running HTTP server +uv run python -m stories.sampling.server --http --port 8000 & +uv run python -m stories.sampling.client --http http://127.0.0.1:8000/mcp --legacy +``` + +## What to look at + +- `client.py` `sampling_callback` — takes `(ClientRequestContext, + CreateMessageRequestParams)` and returns `CreateMessageResult`; passing it as + `sampling_callback=` is what advertises the capability. +- `server.py` — `await ctx.session.create_message(...)` inside the tool body: a + server→client request that blocks until the callback answers. There is no + `Context.sample()` sugar; reaching `ctx.session` is the public path. +- `server_lowlevel.py` — the same call from `ServerRequestContext.session`, + with the `CallToolResult` built by hand. + +## Caveats + +- **Legacy-era only.** `sampling/createMessage` is a server-initiated request + with no 2026-07-28 wire carrier until the multi-round-trip runtime lands + ([#2898](https://github.com/modelcontextprotocol/python-sdk/issues/2898)), so + this story runs with `era = "legacy"` and the harness pins the handshake path. +- `ctx.session.create_message()` is `@deprecated`; the + `# pyright: ignore[reportDeprecated]` is deliberate. There is no + non-deprecated server-side path until the multi-round-trip runtime lands. +- `ctx.session.*` is the interim 2-hop path; a later release will shorten it. +- `Client` has no `sampling_capabilities=` kwarg, so the `sampling.tools` + sub-capability (tools-in-sampling) is unreachable from the high-level client. + Drop to `ClientSession` if you need it. + +## Spec + +[Sampling — client features](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) + +## See also + +`elicitation/`, `roots/` — sibling server→client requests on the same MRTR +migration path. diff --git a/examples/stories/sampling/__init__.py b/examples/stories/sampling/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/sampling/client.py b/examples/stories/sampling/client.py new file mode 100644 index 0000000000..31cd61451e --- /dev/null +++ b/examples/stories/sampling/client.py @@ -0,0 +1,34 @@ +"""Supply a canned sampling_callback and assert its text round-trips through the tool.""" + +from typing import Any + +from mcp.client import Client, ClientRequestContext +from mcp.types import CreateMessageRequestParams, CreateMessageResult, TextContent +from stories._harness import connect_from_args, run_client + + +async def sampling_callback(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult: + # A real host would call its LLM provider here; the example returns a deterministic + # canned answer so the round-trip is assertable. + return CreateMessageResult( + role="assistant", + content=TextContent(text="[canned summary]"), + model="stub-model", + stop_reason="endTurn", + ) + + +def client_kw() -> dict[str, Any]: + return {"sampling_callback": sampling_callback} + + +async def scenario(client: Client) -> None: + result = await client.call_tool("summarize", {"text": "hello world"}) + + assert not result.is_error, result + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "[canned summary]", result.content[0].text + + +if __name__ == "__main__": + run_client(scenario, connect=connect_from_args(__file__), **client_kw()) diff --git a/examples/stories/sampling/server.py b/examples/stories/sampling/server.py new file mode 100644 index 0000000000..7481f2e36b --- /dev/null +++ b/examples/stories/sampling/server.py @@ -0,0 +1,24 @@ +"""Sampling primitive: a tool asks the client's LLM for a completion mid-call.""" + +from mcp.server.mcpserver import Context, MCPServer +from mcp.types import SamplingMessage, TextContent +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer("sampling-example") + + @mcp.tool(description="Summarize text by asking the host's LLM via sampling/createMessage.") + async def summarize(text: str, ctx: Context) -> str: + result = await ctx.session.create_message( # pyright: ignore[reportDeprecated] + messages=[SamplingMessage(role="user", content=TextContent(text=f"Summarize in one sentence:\n\n{text}"))], + max_tokens=200, + ) + assert isinstance(result.content, TextContent) + return result.content.text + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/sampling/server_lowlevel.py b/examples/stories/sampling/server_lowlevel.py new file mode 100644 index 0000000000..0aa2368843 --- /dev/null +++ b/examples/stories/sampling/server_lowlevel.py @@ -0,0 +1,44 @@ +"""Sampling primitive (lowlevel API): the same server→client round-trip, hand-built.""" + +from typing import Any + +from mcp import types +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + + +def build_server() -> Server[Any]: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="summarize", + description="Summarize text by asking the host's LLM via sampling/createMessage.", + input_schema={ + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], + }, + ), + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "summarize" + assert params.arguments is not None + prompt = f"Summarize in one sentence:\n\n{params.arguments['text']}" + result = await ctx.session.create_message( # pyright: ignore[reportDeprecated] + messages=[types.SamplingMessage(role="user", content=types.TextContent(text=prompt))], + max_tokens=200, + ) + assert isinstance(result.content, types.TextContent) + return types.CallToolResult(content=[types.TextContent(text=result.content.text)]) + + return Server("sampling-example", on_list_tools=list_tools, on_call_tool=call_tool) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/schema_validators/README.md b/examples/stories/schema_validators/README.md new file mode 100644 index 0000000000..c594a01cf1 --- /dev/null +++ b/examples/stories/schema_validators/README.md @@ -0,0 +1,48 @@ +# schema-validators + +Four ways to type a tool parameter so `MCPServer` derives the JSON-Schema +`inputSchema` and validates arguments before your handler runs: a pydantic +`BaseModel`, a `TypedDict`, a `@dataclass`, and a bare `dict[str, Any]`. The +client lists the tools, resolves each `who` schema, and round-trips a call. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.schema_validators.client + +# against a running HTTP server +uv run python -m stories.schema_validators.server --http --port 8000 & +uv run python -m stories.schema_validators.client --http http://127.0.0.1:8000/mcp +``` + +## What to look at + +- `server.py` — `who.name` vs `who["name"]`: pydantic and dataclass parameters + arrive as **instances** (attribute access); TypedDict and `dict[str, Any]` + arrive as plain dicts. +- `client.py` — the listed `inputSchema` for the three typed variants nests a + `$defs`/`$ref` object with a `name` property; `greet_dict` publishes only + `{"type": "object", "additionalProperties": true}` — no field validation. +- `server_lowlevel.py` — the same schemas written by hand. There is no + reflection layer at this tier; you author JSON Schema and unpack + `params.arguments` yourself. + +## Caveats + +- Pydantic emits local `#/$defs/` references for nested models. The SDK does + not dereference network `$ref`s (SEP-2106 MUST NOT); only same-document refs + are resolved during validation. +- `PersonTD` is `total=True`, so its nested schema requires both `name` and + `title`; the `BaseModel` and `@dataclass` variants default `title="friend"`, + so only `name` is required there. Use `typing.NotRequired[...]` to mark + optional TypedDict fields. + +## Spec + +[Tools — input schema](https://modelcontextprotocol.io/specification/2025-11-25/server/tools#input-schema) + +## See also + +`tools/` (output schema → `structuredContent`), `error_handling/` (what +happens when validation fails). diff --git a/examples/stories/schema_validators/__init__.py b/examples/stories/schema_validators/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/schema_validators/client.py b/examples/stories/schema_validators/client.py new file mode 100644 index 0000000000..9c156816f6 --- /dev/null +++ b/examples/stories/schema_validators/client.py @@ -0,0 +1,36 @@ +"""Asserts each variant publishes a `who` object schema and the call round-trips.""" + +from mcp.client import Client +from mcp.types import TextContent +from stories._harness import connect_from_args, run_client + + +async def scenario(client: Client) -> None: + listed = await client.list_tools() + by_name = {t.name: t for t in listed.tools} + assert set(by_name) == {"greet_pydantic", "greet_typeddict", "greet_dataclass", "greet_dict"} + + for name in ("greet_pydantic", "greet_typeddict", "greet_dataclass"): + schema = by_name[name].input_schema + assert schema["required"] == ["who"], schema + # MCPServer emits a $defs/$ref pair; lowlevel inlines. Resolve either. + who = schema["properties"]["who"] + if "$ref" in who: + who = schema["$defs"][who["$ref"].rsplit("/", 1)[-1]] + assert "name" in who["properties"], who + + result = await client.call_tool(name, {"who": {"name": "Ada", "title": "colleague"}}) + assert not result.is_error, result + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Hello Ada, my colleague" + + # dict[str, Any] → free-form object schema, no nested `properties` required. + dict_who = by_name["greet_dict"].input_schema["properties"]["who"] + assert dict_who["type"] == "object" and "$ref" not in dict_who + result = await client.call_tool("greet_dict", {"who": {"name": "Ada"}}) + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Hello Ada, my friend" + + +if __name__ == "__main__": + run_client(scenario, connect=connect_from_args(__file__)) diff --git a/examples/stories/schema_validators/server.py b/examples/stories/schema_validators/server.py new file mode 100644 index 0000000000..527c177e07 --- /dev/null +++ b/examples/stories/schema_validators/server.py @@ -0,0 +1,55 @@ +"""Four ways to type a tool parameter so MCPServer derives and enforces inputSchema.""" + +from dataclasses import dataclass +from typing import Any, TypedDict + +from pydantic import BaseModel + +from mcp.server.mcpserver import MCPServer +from stories._hosting import run_server_from_args + + +class PersonModel(BaseModel): + name: str + title: str = "friend" + + +class PersonTD(TypedDict): + name: str + title: str + + +@dataclass +class PersonDC: + name: str + title: str = "friend" + + +def build_server() -> MCPServer: + mcp = MCPServer("schema-validators-example") + + @mcp.tool() + def greet_pydantic(who: PersonModel) -> str: + """`who` arrives as a validated PersonModel instance.""" + return f"Hello {who.name}, my {who.title}" + + @mcp.tool() + def greet_typeddict(who: PersonTD) -> str: + """`who` arrives as a plain dict; TypedDict drives the schema and editor hints.""" + return f"Hello {who['name']}, my {who['title']}" + + @mcp.tool() + def greet_dataclass(who: PersonDC) -> str: + """`who` arrives as a PersonDC instance (pydantic coerces the wire dict).""" + return f"Hello {who.name}, my {who.title}" + + @mcp.tool() + def greet_dict(who: dict[str, Any]) -> str: + """`who` is a free-form object — any dict passes; the handler must check it.""" + return f"Hello {who['name']}, my {who.get('title', 'friend')}" + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/schema_validators/server_lowlevel.py b/examples/stories/schema_validators/server_lowlevel.py new file mode 100644 index 0000000000..313f19ea99 --- /dev/null +++ b/examples/stories/schema_validators/server_lowlevel.py @@ -0,0 +1,54 @@ +"""Same four tools via lowlevel.Server — inputSchema is hand-written JSON Schema.""" + +from typing import Any + +from mcp import types +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + +# With lowlevel.Server there is no reflection layer: you author the JSON Schema +# yourself and validate/unpack `params.arguments` in the handler. +PERSON_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"name": {"type": "string"}, "title": {"type": "string"}}, + "required": ["name"], +} +TOOLS = [ + types.Tool( + name=f"greet_{variant}", + description=f"Greet ({variant} input shape)", + input_schema={"type": "object", "properties": {"who": PERSON_SCHEMA}, "required": ["who"]}, + ) + for variant in ("pydantic", "typeddict", "dataclass") +] +TOOLS.append( + types.Tool( + name="greet_dict", + description="Greet (free-form dict input)", + input_schema={ + "type": "object", + "properties": {"who": {"type": "object", "additionalProperties": True}}, + "required": ["who"], + }, + ) +) + + +def build_server() -> Server[Any]: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=TOOLS) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.arguments is not None + who = params.arguments["who"] + text = f"Hello {who['name']}, my {who.get('title', 'friend')}" + return types.CallToolResult(content=[types.TextContent(text=text)]) + + return Server("schema-validators-example", on_list_tools=list_tools, on_call_tool=call_tool) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/serve_one/README.md b/examples/stories/serve_one/README.md new file mode 100644 index 0000000000..d59f909d27 --- /dev/null +++ b/examples/stories/serve_one/README.md @@ -0,0 +1,66 @@ +# serve-one + +The kernel layer beneath `MCPServer.run()` / `run_server_from_args`. Every +transport entry composes the same three pieces: a `lowlevel.Server` (the +handler registry), a `Connection` (per-peer state), and a driver — `serve_one` +for one request → result dict, or `serve_connection` for a dispatcher loop. +This is what you write to bring up MCP over a custom transport. Uniquely, the +server files here build the stdio entry by hand instead of importing +`stories._hosting`. + +## Run it + +```bash +# stdio (default — the client spawns server.py as a subprocess; its __main__ +# is the hand-built serve_loop recipe) +uv run python -m stories.serve_one.client + +# drive the lowlevel hand-built loop instead +uv run python -m stories.serve_one.client --server server_lowlevel +``` + +## What to look at + +- `server_lowlevel.py::handle_one` — `Connection.from_envelope(...)` + + `serve_one(...)` returns the raw result dict for one request. No handshake, + no streams; the entry owns wire encoding and exception→error mapping. +- `server_lowlevel.py::main` — `JSONRPCDispatcher` + `Connection.for_loop(...)` + + `serve_connection(...)`: exactly what `Server.run()` does internally for + stdio. +- `server_lowlevel.py::SingleExchangeContext` — the per-request + `DispatchContext` a custom entry must supply. The SDK ships no public + concrete class for this yet. +- `server.py::main` — `serve_loop(...)` over an `MCPServer`'s underlying + `lowlevel.Server`; surfaces the missing public accessor. +- `client.py` — drives `handle_one` directly and asserts the raw result-dict + shape (`structuredContent` / `content`), then proves the loop-mode driver + works over the wire. + +## Caveats + +- **Deep imports** — `serve_one`, `serve_connection`, `serve_loop`, + `Connection` are only reachable at `mcp.server.runner` / + `mcp.server.connection` today; a shorter `mcp.server.*` re-export is tracked + for beta. +- **`MCPServer` accessor** — `server.py` reaches `mcp._lowlevel_server` because + there's no public way to hand an `MCPServer` to the drivers. Prefer the + lowlevel variant until that lands. +- **No public `DispatchContext`** — `SingleExchangeContext` is hand-rolled + boilerplate; a public helper (or a `serve_one` overload that builds one) is + tracked for beta. +- **Lifespan** — the transport entry enters `server.lifespan(server)` **once** + and threads `lifespan_state` to every `handle_one()` call; never enter it + per-request. +- `ServerRunner` is kernel-internal; never construct it directly. The + free-function drivers are the supported surface. + +## Spec + +[Architecture — lifecycle](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle) +· [2026 versioning — discover](https://modelcontextprotocol.io/specification/2026-07-28/server/discover) + +## See also + +`client_session/` (the client-side mechanics counterpart), `legacy_routing/` +(composing `serve_one` behind `classify_inbound_request`), `dual_era/` +(`Connection.protocol_version` in handlers). diff --git a/examples/stories/serve_one/__init__.py b/examples/stories/serve_one/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/serve_one/client.py b/examples/stories/serve_one/client.py new file mode 100644 index 0000000000..42342dd2c4 --- /dev/null +++ b/examples/stories/serve_one/client.py @@ -0,0 +1,38 @@ +"""Drive `handle_one` directly to assert the raw result-dict shape, then over the wire.""" + +from mcp import types +from mcp.client import Client +from mcp.shared.version import LATEST_MODERN_VERSION +from stories._harness import connect_from_args, run_client +from stories.serve_one.server_lowlevel import build_server as build_lowlevel +from stories.serve_one.server_lowlevel import handle_one + + +async def scenario(client: Client) -> None: + # ── direct: the namesake recipe — Connection.from_envelope + serve_one → raw result dict. + # The entry enters lifespan once and threads it to every per-request handle_one(). + server = build_lowlevel() + params = { + "name": "add", + "arguments": {"a": 2, "b": 3}, + "_meta": { + types.PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION, + types.CLIENT_INFO_META_KEY: {"name": "serve-one-probe", "version": "0.0.0"}, + types.CLIENT_CAPABILITIES_META_KEY: {}, + }, + } + async with server.lifespan(server) as lifespan_state: + raw = await handle_one(server, "tools/call", params, lifespan_state=lifespan_state) + assert raw["structuredContent"] == {"result": 5}, raw + assert raw["content"][0] == {"type": "text", "text": "5"}, raw + + # ── over the wire: the loop-mode driver behind the connected client. + listed = await client.list_tools() + assert [t.name for t in listed.tools] == ["add"] + + result = await client.call_tool("add", {"a": 2, "b": 3}) + assert result.structured_content == {"result": 5}, result + + +if __name__ == "__main__": + run_client(scenario, connect=connect_from_args(__file__)) diff --git a/examples/stories/serve_one/server.py b/examples/stories/serve_one/server.py new file mode 100644 index 0000000000..98174765c3 --- /dev/null +++ b/examples/stories/serve_one/server.py @@ -0,0 +1,35 @@ +"""Kernel drivers: drive an `MCPServer` via `serve_loop` directly. + +The drivers (`serve_loop` / `serve_one`) take a `lowlevel.Server`; `MCPServer` +has no public accessor for its underlying one yet, so this file reaches +`_lowlevel_server`. See `server_lowlevel.py` for the clean shape. +""" + +import anyio + +from mcp.server.mcpserver import MCPServer +from mcp.server.runner import serve_loop # deep-path import; shorter re-export planned +from mcp.server.stdio import stdio_server + + +def build_server() -> MCPServer: + mcp = MCPServer("serve-one-example") + + @mcp.tool() + def add(a: int, b: int) -> int: + """Add two integers.""" + return a + b + + return mcp + + +async def main() -> None: + mcp = build_server() + server = mcp._lowlevel_server # pyright: ignore[reportPrivateUsage] # no public accessor yet + async with server.lifespan(server) as lifespan_state: + async with stdio_server() as (read_stream, write_stream): + await serve_loop(server, read_stream, write_stream, lifespan_state=lifespan_state) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/examples/stories/serve_one/server_lowlevel.py b/examples/stories/serve_one/server_lowlevel.py new file mode 100644 index 0000000000..7ce3962ee7 --- /dev/null +++ b/examples/stories/serve_one/server_lowlevel.py @@ -0,0 +1,107 @@ +"""serve_one / serve_connection mechanics: the kernel drivers a transport entry composes. + +`handle_one()` is the modern single-exchange recipe (`Connection.from_envelope` ++ `serve_one` → raw result dict). `main()` is the loop recipe +(`JSONRPCDispatcher` + `Connection.for_loop` + `serve_connection`) — what +`Server.run()` does for stdio. +""" + +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any + +import anyio + +from mcp import types +from mcp.server.connection import Connection # deep-path import; shorter re-export planned +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from mcp.server.runner import serve_connection, serve_one # deep-path import; shorter re-export planned +from mcp.server.stdio import stdio_server +from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher +from mcp.shared.transport_context import TransportContext +from mcp.shared.version import LATEST_MODERN_VERSION + +__all__ = ["SingleExchangeContext", "build_server", "handle_one"] + + +def build_server() -> Server[Any]: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[types.Tool(name="add", description="Add two integers.", input_schema={"type": "object"})] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "add" and params.arguments is not None + total = params.arguments["a"] + params.arguments["b"] + return types.CallToolResult(content=[types.TextContent(text=str(total))], structured_content={"result": total}) + + return Server("serve-one-example", on_list_tools=list_tools, on_call_tool=call_tool) + + +@dataclass +class SingleExchangeContext: + """Minimal `DispatchContext` for one inbound request with no back-channel. + + A custom transport entry hand-builds one of these per request. The SDK + ships no public concrete class for this yet; this is the structural minimum. + """ + + request_id: int | str | None + transport: TransportContext = field(default_factory=lambda: TransportContext(kind="custom", can_send_request=False)) + message_metadata: None = None + can_send_request: bool = False + cancel_requested: anyio.Event = field(default_factory=anyio.Event) + + async def send_raw_request(self, method: str, params: Mapping[str, Any] | None, opts: Any = None) -> dict[str, Any]: + raise NotImplementedError # no back-channel on the single-exchange path + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: Any = None) -> None: + return None + + async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: + return None + + +async def handle_one( + server: Server[Any], method: str, params: Mapping[str, Any], *, lifespan_state: Any +) -> dict[str, Any]: + """Serve exactly one modern-era request and return its raw result dict. + + Reads the envelope from `params._meta` (the 2026 wire shape), builds a + born-ready `Connection.from_envelope`, and drives `serve_one`. The transport + entry enters `server.lifespan(server)` once and threads `lifespan_state` to + every call — never enter the lifespan per-request. + """ + meta = params.get("_meta", {}) + connection = Connection.from_envelope( + meta.get(types.PROTOCOL_VERSION_META_KEY, LATEST_MODERN_VERSION), + meta.get(types.CLIENT_INFO_META_KEY), + meta.get(types.CLIENT_CAPABILITIES_META_KEY), + ) + return await serve_one( + server, + SingleExchangeContext(request_id=1), + method, + params, + connection=connection, + lifespan_state=lifespan_state, + ) + + +async def main() -> None: + """Serve over stdio by building the dispatcher + Connection by hand (loop mode).""" + server = build_server() + async with server.lifespan(server) as lifespan_state: + async with stdio_server() as (read_stream, write_stream): + dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher( + read_stream, write_stream, inline_methods=frozenset({"initialize"}) + ) + connection = Connection.for_loop(dispatcher) + await serve_connection(server, dispatcher, connection=connection, lifespan_state=lifespan_state) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/examples/stories/skills/README.md b/examples/stories/skills/README.md new file mode 100644 index 0000000000..d984fe5b61 --- /dev/null +++ b/examples/stories/skills/README.md @@ -0,0 +1,14 @@ +# skills + +SEP-2640 skills: a server exposes a `skill://index.json` directory resource and +`@skill` / `@skillDir` registrations that a host can read to bootstrap +agent-level instructions. The story will list skills and read one. + +**Status: not yet implemented** ([#2896](https://github.com/modelcontextprotocol/python-sdk/issues/2896)). +The `extensions` capability map is not yet surfaced on `MCPServer`, so a server +cannot advertise the skills extension. + +## Spec + +[SEP-2640 — skills](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2640) +· [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133) diff --git a/examples/stories/sse_polling/README.md b/examples/stories/sse_polling/README.md new file mode 100644 index 0000000000..ce3ff2e1ff --- /dev/null +++ b/examples/stories/sse_polling/README.md @@ -0,0 +1,66 @@ +# sse-polling + +SEP-1699 server-initiated SSE disconnection with `Last-Event-ID` replay. The +server's `EventStore` stamps every SSE event with an ID and opens each response +stream with a priming event; mid-handler the tool calls +`ctx.close_sse_stream()` to release the open HTTP response (freeing a +connection slot), keeps emitting progress into the event store, and returns. +The client transport sees the stream end, reconnects with `Last-Event-ID`, and +the event store replays everything it missed — `await client.call_tool(...)` +resolves as if the disconnect never happened. + +**2025-era only.** `Last-Event-ID` resumability and the sessionful transport +are removed in the 2026-07-28 spec (SEP-2575); there is no modern-era +equivalent. + +## Run it + +```bash +# in one terminal +uv run python -m stories.sse_polling.server --port 8000 +# in another +uv run python -m stories.sse_polling.client --http http://127.0.0.1:8000/mcp --legacy +``` + +## What to look at + +- **`server.py` — `streamable_http_app(event_store=..., retry_interval=0)`.** + Passing an `EventStore` is what enables resumability: every SSE event gets an + ID and the response opens with a priming event so the client always has a + `Last-Event-ID` to reconnect with. `retry_interval=0` makes the client's + reconnect wait a no-op (the SSE `retry:` hint). +- **`server.py` — `await ctx.close_sse_stream()`.** Ends the current request's + SSE response without cancelling the handler. Everything emitted afterwards + goes to the event store and is replayed on reconnect. A no-op when no + `event_store` is configured. +- **`server_lowlevel.py` — `ctx.close_sse_stream`.** On the lowlevel API the + callback is an optional field on `ServerRequestContext`; it is `None` unless + an event store is wired and the negotiated version is in the 2025 era. +- **`client.py` — nothing special.** The `Client` and `streamable_http_client` + transport handle the priming event, the `retry:` hint, and the + `Last-Event-ID` reconnect automatically. The assertion that `"after-close"` + arrived is the proof. + +## Caveats + +- `streamable_http_app(...)` is a hosting entry that reshapes in a later + release; this story calls it directly because the event-store and + retry-interval kwargs are the point. +- DNS-rebinding protection is disabled (`transport_security=NO_DNS_REBIND`) + because the in-process httpx client sends no `Origin` header. Drop the kwarg + for a real deployment. +- `event_store.py` here is example-grade only (sequential IDs, no eviction). A + production server would back the `EventStore` interface with persistent + storage. + +## Spec + +[Resumability and Redelivery](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery) +· SEP-1699 (server-initiated SSE close) + +## See also + +`standalone_get/` (the standalone-stream sibling of `close_sse_stream()`), +`reconnect/` (the modern-era reconnection story — persisted `DiscoverResult`, +no event store), `streaming/` (in-flight progress + cancellation without the +disconnect). diff --git a/examples/stories/sse_polling/__init__.py b/examples/stories/sse_polling/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/sse_polling/client.py b/examples/stories/sse_polling/client.py new file mode 100644 index 0000000000..d154f1844f --- /dev/null +++ b/examples/stories/sse_polling/client.py @@ -0,0 +1,31 @@ +"""Call a tool whose SSE stream the server closes mid-flight; assert the call still completes.""" + +import anyio + +from mcp.client import Client +from mcp.types import TextContent +from stories._harness import connect_from_args, run_client + + +async def scenario(client: Client) -> None: + messages: list[str | None] = [] + + async def on_progress(progress: float, total: float | None, message: str | None) -> None: + messages.append(message) + + with anyio.fail_after(10): + result = await client.call_tool("long_operation", {}, progress_callback=on_progress) + + # The result arrived — the client transport survived the server-initiated close, + # reconnected with Last-Event-ID, and received the replayed response. + assert not result.is_error, result + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "resumed" + + # "after-close" was emitted while no SSE stream was open; receiving it proves the + # event store buffered it and the reconnect replayed it. + assert messages == ["before-close", "after-close"], messages + + +if __name__ == "__main__": + run_client(scenario, connect=connect_from_args(__file__)) diff --git a/examples/stories/sse_polling/event_store.py b/examples/stories/sse_polling/event_store.py new file mode 100644 index 0000000000..1cd24827a7 --- /dev/null +++ b/examples/stories/sse_polling/event_store.py @@ -0,0 +1,33 @@ +"""Minimal in-memory `EventStore` for the SSE-resumability example. + +Sequential integer IDs so the wire is readable; a production server would back +this interface with persistent storage so replay survives a process restart. +""" + +from mcp.server.streamable_http import EventCallback, EventId, EventMessage, EventStore, StreamId +from mcp.types import JSONRPCMessage + + +class InMemoryEventStore(EventStore): + """Stores every event in arrival order and replays the same-stream tail after a given ID.""" + + def __init__(self) -> None: + self._events: list[tuple[StreamId, JSONRPCMessage | None]] = [] + + async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId: + self._events.append((stream_id, message)) + return str(len(self._events)) + + async def replay_events_after(self, last_event_id: EventId, send_callback: EventCallback) -> StreamId | None: + try: + cursor = int(last_event_id) + except ValueError: + return None + if not 0 < cursor <= len(self._events): + return None + stream_id, _ = self._events[cursor - 1] + for index in range(cursor, len(self._events)): + event_stream_id, message = self._events[index] + if event_stream_id == stream_id and message is not None: + await send_callback(EventMessage(message, str(index + 1))) + return stream_id diff --git a/examples/stories/sse_polling/server.py b/examples/stories/sse_polling/server.py new file mode 100644 index 0000000000..ff186f0e5a --- /dev/null +++ b/examples/stories/sse_polling/server.py @@ -0,0 +1,35 @@ +"""SEP-1699: a tool that closes its own SSE stream mid-call; the event store buffers the rest.""" + +from starlette.applications import Starlette + +from mcp.server.mcpserver import Context, MCPServer +from stories._hosting import NO_DNS_REBIND, run_app_from_args +from stories.sse_polling.event_store import InMemoryEventStore + + +def build_app() -> Starlette: + mcp = MCPServer("sse-polling-example") + + @mcp.tool() + async def long_operation(ctx: Context) -> str: + """Emit progress, close this call's SSE stream, emit more progress, then return. + + Everything sent after `close_sse_stream()` lands in the event store and is + replayed when the client reconnects with `Last-Event-ID`. + """ + await ctx.report_progress(0.5, total=1.0, message="before-close") + await ctx.close_sse_stream() + await ctx.report_progress(1.0, total=1.0, message="after-close") + return "resumed" + + # event_store enables Last-Event-ID replay; retry_interval=0 makes the client's + # reconnect wait a no-op so the example is deterministic without real time. + return mcp.streamable_http_app( + event_store=InMemoryEventStore(), + retry_interval=0, + transport_security=NO_DNS_REBIND, + ) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/sse_polling/server_lowlevel.py b/examples/stories/sse_polling/server_lowlevel.py new file mode 100644 index 0000000000..9d9ce85bd3 --- /dev/null +++ b/examples/stories/sse_polling/server_lowlevel.py @@ -0,0 +1,45 @@ +"""SEP-1699 polling on the lowlevel `Server`: close the request's SSE stream mid-handler.""" + +from typing import Any + +from starlette.applications import Starlette + +from mcp import types +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import NO_DNS_REBIND, run_app_from_args +from stories.sse_polling.event_store import InMemoryEventStore + +_TOOL = types.Tool( + name="long_operation", + description="Emit progress, close the SSE stream, emit more, return.", + input_schema={"type": "object", "properties": {}}, +) + + +def build_app() -> Starlette: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[_TOOL]) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "long_operation" + await ctx.session.report_progress(0.5, total=1.0, message="before-close") + # The transport only wires this callback when an event_store is configured and the + # negotiated version is in the 2025 era; it is None otherwise. + if ctx.close_sse_stream is not None: + await ctx.close_sse_stream() + await ctx.session.report_progress(1.0, total=1.0, message="after-close") + return types.CallToolResult(content=[types.TextContent(text="resumed")]) + + server = Server("sse-polling-example", on_list_tools=list_tools, on_call_tool=call_tool) + return server.streamable_http_app( + event_store=InMemoryEventStore(), + retry_interval=0, + transport_security=NO_DNS_REBIND, + ) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/standalone_get/README.md b/examples/stories/standalone_get/README.md new file mode 100644 index 0000000000..9b56b51f27 --- /dev/null +++ b/examples/stories/standalone_get/README.md @@ -0,0 +1,57 @@ +# standalone-get + +Server-initiated `notifications/resources/list_changed` delivered over the +**standalone GET SSE stream** of a sessionful Streamable-HTTP connection. The +`add_note` tool mutates the resource list and emits the notification with no +related request; the client's `message_handler` receives it on the GET stream, +awaits it on an `anyio.Event`, then re-lists to observe the change. + +## Run it + +```bash +# server (HTTP-only — the standalone GET stream is a Streamable-HTTP feature) +uv run python -m stories.standalone_get.server --http --port 8000 & +# client +uv run python -m stories.standalone_get.client --http http://127.0.0.1:8000/mcp --legacy +``` + +## What to look at + +- **`server.py` — `await ctx.session.send_resource_list_changed()`.** + `MCPServer.add_resource` does **not** auto-emit (unlike the TypeScript SDK's + `registerResource`); the explicit call is the teaching point. Because + `send_*_list_changed()` carries no `related_request_id`, the only route to the + client is the standalone GET stream. +- **`client.py` — `message_handler=` + `anyio.Event`.** The notification is not + guaranteed to arrive before the tool result (different streams), so the + scenario `await`s an event the handler sets, bounded by `anyio.fail_after(5)`. + `client_kw()` is a callable so each run wires a fresh `anyio.Event` into + `message_handler`. + +## Caveats + +- **Legacy-era only.** The standalone GET stream is a sessionful 2025-era + transport feature; in 2026-07-28 these notifications travel on a + `subscriptions/listen` stream instead — not yet wired in this SDK + ([#2901](https://github.com/modelcontextprotocol/python-sdk/issues/2901)). +- DNS-rebinding protection is disabled via `transport_security=NO_DNS_REBIND` + because the in-process httpx client sends no `Origin` header. Drop the kwarg + for a real deployment. +- Neither `MCPServer` nor lowlevel `Server` auto-advertises + `resources.listChanged: true` in capabilities, and `MCPServer` exposes no knob + to set it. A spec-conformant client that gates on the capability flag would + skip the handler. +- `ctx.session.*` is the interim path; a later release will shorten it. +- Tool-triggered, not timer-driven, for harness determinism. "Server pushes on + its own schedule" is not demonstrated. + +## Spec + +[List Changed Notification](https://modelcontextprotocol.io/specification/2025-11-25/server/resources#list-changed-notification), +[Streamable HTTP — Listening for Messages](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server) + +## See also + +`stickynotes/` (list_changed inside a feature capstone), `sse_polling/` (the +other GET-stream story — resumability), `json_response/` (what happens when the +server can't stream). diff --git a/examples/stories/standalone_get/__init__.py b/examples/stories/standalone_get/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/standalone_get/client.py b/examples/stories/standalone_get/client.py new file mode 100644 index 0000000000..ced7348bbc --- /dev/null +++ b/examples/stories/standalone_get/client.py @@ -0,0 +1,48 @@ +"""Receive `notifications/resources/list_changed` over the standalone GET stream, then re-list.""" + +from typing import Any + +import anyio + +from mcp import types +from mcp.client import Client +from stories._harness import connect_from_args, run_client + +# Shared between the `message_handler` (wired at connect time) and `scenario()`. +# Reset per leg by `client_kw()` so each (variant × era) starts clean. +_received: list[types.ResourceListChangedNotification] = [] +_seen: list[anyio.Event] = [] + + +async def _on_message(message: object) -> None: + if isinstance(message, types.ResourceListChangedNotification): + _received.append(message) + _seen[0].set() + + +def client_kw() -> dict[str, Any]: + _received[:] = [] + _seen[:] = [anyio.Event()] + return {"message_handler": _on_message} + + +async def scenario(client: Client) -> None: + before = await client.list_resources() + assert len(before.resources) >= 1, before + + result = await client.call_tool("add_note", {"content": "hello"}) + assert not result.is_error, result + + # The notification rides the standalone GET stream, not the call's POST stream — + # delivery order vs the tool result is not guaranteed, so wait. + with anyio.fail_after(5): + await _seen[0].wait() + assert len(_received) == 1, _received + + after = await client.list_resources() + assert len(after.resources) == len(before.resources) + 1, after + assert {r.name for r in after.resources} >= {"initial", "note-1"} + + +if __name__ == "__main__": + run_client(scenario, connect=connect_from_args(__file__), **client_kw()) diff --git a/examples/stories/standalone_get/server.py b/examples/stories/standalone_get/server.py new file mode 100644 index 0000000000..4b0c956841 --- /dev/null +++ b/examples/stories/standalone_get/server.py @@ -0,0 +1,30 @@ +"""Sessionful Streamable HTTP: a tool mutates resources and emits `list_changed` over the standalone GET stream.""" + +import itertools + +from mcp.server.mcpserver import Context, MCPServer +from mcp.server.mcpserver.resources import TextResource +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer("standalone-get-example") + counter = itertools.count(1) + + mcp.add_resource(TextResource(uri="note://initial", name="initial", text="initial content")) + + @mcp.tool() + async def add_note(content: str, ctx: Context) -> str: + """Register a new resource and announce it via `notifications/resources/list_changed`.""" + name = f"note-{next(counter)}" + mcp.add_resource(TextResource(uri=f"note://{name}", name=name, text=content)) + # MCPServer does not auto-emit on add_resource; send explicitly. With no + # related_request_id this routes to the standalone GET stream. + await ctx.session.send_resource_list_changed() + return f"registered {name}" + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/standalone_get/server_lowlevel.py b/examples/stories/standalone_get/server_lowlevel.py new file mode 100644 index 0000000000..09c8cbd84b --- /dev/null +++ b/examples/stories/standalone_get/server_lowlevel.py @@ -0,0 +1,48 @@ +"""Sessionful Streamable HTTP (lowlevel `Server`): tool-triggered `list_changed` over the standalone GET stream.""" + +import itertools +from typing import Any + +from mcp import types +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + +ADD_NOTE_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"content": {"type": "string"}}, + "required": ["content"], +} + + +def build_server() -> Server[Any]: + counter = itertools.count(1) + resources: list[types.Resource] = [types.Resource(uri="note://initial", name="initial", mime_type="text/plain")] + + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="add_note", input_schema=ADD_NOTE_INPUT_SCHEMA)]) + + async def list_resources( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListResourcesResult: + return types.ListResourcesResult(resources=list(resources)) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "add_note" and params.arguments is not None + name = f"note-{next(counter)}" + resources.append(types.Resource(uri=f"note://{name}", name=name, mime_type="text/plain")) + await ctx.session.send_resource_list_changed() + return types.CallToolResult(content=[types.TextContent(text=f"registered {name}")]) + + return Server( + "standalone-get-example", + on_list_tools=list_tools, + on_list_resources=list_resources, + on_call_tool=call_tool, + ) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/starlette_mount/README.md b/examples/stories/starlette_mount/README.md new file mode 100644 index 0000000000..da4231433f --- /dev/null +++ b/examples/stories/starlette_mount/README.md @@ -0,0 +1,47 @@ +# starlette-mount + +Embed an MCP server inside an existing Starlette (or FastAPI) app at a +sub-path, next to your own routes. `mcp.streamable_http_app()` returns a +mountable ASGI app; the two things to get right are the **path** (the default +`streamable_http_path="/mcp"` stacks under your mount prefix) and the +**lifespan** (Starlette does not run a mounted sub-app's lifespan, so the +parent must enter `mcp.session_manager.run()`). + +## Run it + +```bash +uv run python -m stories.starlette_mount.server --port 8000 & +curl http://127.0.0.1:8000/health # → {"status":"ok"} +uv run python -m stories.starlette_mount.client --http http://127.0.0.1:8000/api/ +``` + +## What to look at + +- `server.py` `streamable_http_path="/"` — without this the endpoint would be + `/api/mcp`; with it, `Mount("/api", ...)` serves MCP at `/api/` (trailing + slash required — Starlette's `Mount` forwards `/api` as an empty path that + the inner `/` route won't match). +- `server.py` `lifespan` — `mcp.session_manager.run()` **must** be entered by + the parent app. Forget it and every MCP request hangs (the sub-app's own + lifespan never fires under `Mount`). +- `server.py` `Route("/health", ...)` — non-MCP routes live alongside the + mount; FastAPI users do the same with `app.mount("/api", mcp_app)`. + +## Caveats + +- DNS-rebinding protection is on by default; the example passes + `transport_security=NO_DNS_REBIND` because the in-process test client sends + no `Origin` header. Remove it (or configure allowed hosts) for a real + deployment. +- The parent-lifespan dance is a known SDK ergonomics gap (other SDKs mount + with no extra ceremony); tracked for the beta reshape. The recipe shown here + is what works today. + +## Spec + +[Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) + +## See also + +`stateless_legacy/` (the one-liner `mcp.streamable_http_app()` without a parent +app), `json_response/`, `legacy_routing/`. TS-SDK equivalent: `examples/hono/`. diff --git a/examples/stories/starlette_mount/__init__.py b/examples/stories/starlette_mount/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/starlette_mount/client.py b/examples/stories/starlette_mount/client.py new file mode 100644 index 0000000000..5f8065f8a6 --- /dev/null +++ b/examples/stories/starlette_mount/client.py @@ -0,0 +1,21 @@ +"""Connect to the sub-mounted MCP endpoint at /api, list tools and call greet.""" + +from mcp.client import Client +from mcp.types import TextContent +from stories._harness import connect_from_args, run_client + + +async def scenario(client: Client) -> None: + listed = await client.list_tools() + assert [t.name for t in listed.tools] == ["greet"] + + result = await client.call_tool("greet", {"name": "Starlette"}) + assert not result.is_error + first = result.content[0] + assert isinstance(first, TextContent) + assert "Hello, Starlette!" in first.text, result + assert result.structured_content == {"result": "Hello, Starlette! (served from a Starlette sub-mount)"} + + +if __name__ == "__main__": + run_client(scenario, connect=connect_from_args(__file__)) diff --git a/examples/stories/starlette_mount/server.py b/examples/stories/starlette_mount/server.py new file mode 100644 index 0000000000..482de2c22b --- /dev/null +++ b/examples/stories/starlette_mount/server.py @@ -0,0 +1,47 @@ +"""Mount an MCPServer inside an existing Starlette app at a sub-path, alongside non-MCP routes.""" + +import contextlib +from collections.abc import AsyncIterator + +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Mount, Route + +from mcp.server.mcpserver import MCPServer +from stories._hosting import NO_DNS_REBIND, run_app_from_args + + +def build_app() -> Starlette: + mcp = MCPServer("starlette-mount-example") + + @mcp.tool() + def greet(name: str) -> str: + """Return a greeting.""" + return f"Hello, {name}! (served from a Starlette sub-mount)" + + # streamable_http_path="/" so Mount("/api", ...) serves the MCP endpoint at + # /api itself, not /api/mcp. The returned sub-app has its own lifespan, but + # Starlette does not run nested lifespans under Mount — the parent app below + # must enter mcp.session_manager.run() itself. + mcp_app = mcp.streamable_http_app(streamable_http_path="/", transport_security=NO_DNS_REBIND) + + async def health(_request: Request) -> JSONResponse: + return JSONResponse({"status": "ok"}) + + @contextlib.asynccontextmanager + async def lifespan(_app: Starlette) -> AsyncIterator[None]: + async with mcp.session_manager.run(): + yield + + return Starlette( + routes=[ + Route("/health", health), + Mount("/api", app=mcp_app), + ], + lifespan=lifespan, + ) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/stateless_legacy/README.md b/examples/stories/stateless_legacy/README.md new file mode 100644 index 0000000000..00169dfa3b --- /dev/null +++ b/examples/stories/stateless_legacy/README.md @@ -0,0 +1,54 @@ +# stateless-legacy + +The one-liner HTTP deploy. `MCPServer.streamable_http_app(stateless_http=True)` +returns a complete ASGI app that serves **both** protocol eras on `/mcp`: 2025 +clients get the `initialize` handshake answered statelessly (no `Mcp-Session-Id`, +fresh transport per request, horizontally scalable), 2026 clients get the +per-request envelope path. Hand it straight to uvicorn — no session-manager +wiring, no era flag. The client connects once per era and asserts the same +`greet` tool answers identically either way. + +## Run it + +```bash +# start the server (real uvicorn on :8000) +uv run python -m stories.stateless_legacy.server --port 8000 & + +# connect once as a modern client and once as a legacy client +uv run python -m stories.stateless_legacy.client --http http://127.0.0.1:8000/mcp + +# lowlevel-API variant of the same app +uv run python -m stories.stateless_legacy.server_lowlevel --port 8001 & +uv run python -m stories.stateless_legacy.client --http http://127.0.0.1:8001/mcp +``` + +## What to look at + +- `server.py` — `stateless_http=True` is the only knob; era routing is automatic + inside `StreamableHTTPSessionManager.handle_request`. The returned `Starlette` + already wires `lifespan=session_manager.run()`, so `uvicorn.run(app, ...)` + works with no parent-lifespan ceremony. +- `server_lowlevel.py` — `lowlevel.Server.streamable_http_app()` is the same + call; `MCPServer` delegates to it. +- `client.py` — `client.protocol_version` is the era-neutral accessor; same + scenario body, two different negotiated versions, identical tool result. + +## Caveats + +- `transport_security=NO_DNS_REBIND` — DNS-rebinding protection is on by default + for localhost binds; the harness disables it because the in-process httpx + client sends no `Origin` header. Drop the kwarg for a real deployment. +- `streamable_http_app()` reshapes in a later release; the call is isolated in + `build_app()` so the change touches one line per server file. + +## Spec + +[Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#streamable-http) +· [Versioning — backward compatibility](https://modelcontextprotocol.io/specification/draft/basic/versioning) + +## See also + +`dual_era/` (era branching inside a tool handler) · `legacy_routing/` +(`is_legacy_request()` for sessionful-2025 + modern on one mount) · +`starlette_mount/` (mounting under FastAPI/Starlette with parent lifespan) · +`json_response/` (`json_response=True` and what it drops). diff --git a/examples/stories/stateless_legacy/__init__.py b/examples/stories/stateless_legacy/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/stateless_legacy/client.py b/examples/stories/stateless_legacy/client.py new file mode 100644 index 0000000000..4af06891c9 --- /dev/null +++ b/examples/stories/stateless_legacy/client.py @@ -0,0 +1,35 @@ +"""Connect at each era; the same stateless app answers both with the same result.""" + +from mcp.client import Client +from mcp.shared.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION +from mcp.types import TextContent +from stories._harness import Connect, connect_from_args, run_client + + +async def scenario(client: Client, connect: Connect) -> None: + # ── modern leg: the harness-supplied client connected at mode="auto"; the entry routed + # this request through the 2026 envelope path. No initialize handshake, no session id. + assert client.protocol_version == LATEST_MODERN_VERSION + + listed = await client.list_tools() + assert [t.name for t in listed.tools] == ["greet"] + + result = await client.call_tool("greet", {"name": "world"}) + assert not result.is_error + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Hello, world!", result + + # ── legacy leg: a fresh mode="legacy" client runs the initialize handshake against the + # SAME stateless app. It is answered statelessly (no Mcp-Session-Id) and the same tool + # gives the same answer — the era is invisible to the server body. + async with connect(mode="legacy") as legacy: + assert legacy.protocol_version == LATEST_HANDSHAKE_VERSION + + result = await legacy.call_tool("greet", {"name": "world"}) + assert not result.is_error + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Hello, world!", result + + +if __name__ == "__main__": + run_client(scenario, connect=connect_from_args(__file__), needs_connect=True, mode="auto") diff --git a/examples/stories/stateless_legacy/server.py b/examples/stories/stateless_legacy/server.py new file mode 100644 index 0000000000..d12a183d64 --- /dev/null +++ b/examples/stories/stateless_legacy/server.py @@ -0,0 +1,22 @@ +"""The one-liner HTTP deploy: one stateless ASGI app serves both protocol eras.""" + +from starlette.applications import Starlette + +from mcp.server.mcpserver import MCPServer +from stories._hosting import NO_DNS_REBIND, run_app_from_args + + +def build_app() -> Starlette: + mcp = MCPServer("stateless-legacy-example") + + @mcp.tool(description="A simple greeting tool.") + def greet(name: str) -> str: + return f"Hello, {name}!" + + # stateless_http=True: no Mcp-Session-Id, fresh transport per POST — horizontally + # scalable. The same app also answers 2026-era envelope requests with no extra config. + return mcp.streamable_http_app(stateless_http=True, transport_security=NO_DNS_REBIND) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/stateless_legacy/server_lowlevel.py b/examples/stories/stateless_legacy/server_lowlevel.py new file mode 100644 index 0000000000..4a4433696b --- /dev/null +++ b/examples/stories/stateless_legacy/server_lowlevel.py @@ -0,0 +1,38 @@ +"""The one-liner HTTP deploy (lowlevel API): Server.streamable_http_app(stateless_http=True).""" + +from typing import Any + +from starlette.applications import Starlette + +from mcp import types +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import NO_DNS_REBIND, run_app_from_args + +GREET_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], +} + + +def build_app() -> Starlette: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool(name="greet", description="A simple greeting tool.", input_schema=GREET_INPUT_SCHEMA), + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "greet" and params.arguments is not None + return types.CallToolResult(content=[types.TextContent(text=f"Hello, {params.arguments['name']}!")]) + + server = Server("stateless-legacy-example", on_list_tools=list_tools, on_call_tool=call_tool) + return server.streamable_http_app(stateless_http=True, transport_security=NO_DNS_REBIND) + + +if __name__ == "__main__": + run_app_from_args(build_app) diff --git a/examples/stories/stickynotes/README.md b/examples/stories/stickynotes/README.md new file mode 100644 index 0000000000..0835a4b16d --- /dev/null +++ b/examples/stories/stickynotes/README.md @@ -0,0 +1,58 @@ +# stickynotes + +The "real app" capstone: tools mutate a sticky-notes board held in the +server's lifespan context, each note is a `note:///{id}` resource, +`notifications/resources/list_changed` fires on add/remove, and `remove_all` +blocks on a form-mode elicitation so the user must explicitly confirm a +destructive clear. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.stickynotes.client + +# against a running HTTP server +uv run python -m stories.stickynotes.server --http --port 8000 & +uv run python -m stories.stickynotes.client --http http://127.0.0.1:8000/mcp +``` + +## What to look at + +- **`server.py` `lifespan` → `Board`** — long-lived mutable state belongs in + the lifespan context, never a module global. Tools reach it via + `ctx.request_context.lifespan_context`; this 2-hop path is interim and will + shorten to `ctx.state.*` in a later release. +- **`add_note` / `remove_note`** — `mcp.add_resource(FunctionResource(...))` + registers a concrete resource at runtime; `ctx.session.send_resource_list_changed()` + tells connected clients to re-list. **Gap:** `MCPServer` has no public + `remove_resource()` yet, so `remove_note` reaches a private attribute — do + not copy that line. `server_lowlevel.py` shows the clean equivalent: + `on_list_resources` reads the board and builds the list fresh per call, so + removal is just `board.notes.pop(...)` with no registry mutation. +- **`remove_all` → `ctx.elicit(...)`** — push-style server→client elicitation + needs a back-channel and an advertised client capability, so it only runs on + the legacy-era legs. On a modern connection there is no server→client + request channel; the modern equivalent is the multi-round-trip + `InputRequiredResult` flow (see `mrtr/`, not yet implemented). The client + branches on `client.protocol_version`. + +## Caveats + +- `list_changed` and `ctx.elicit()` are skipped on modern legs: the + notification needs a standalone stream and `ctx.elicit()` would raise + `NoBackChannelError`. The scenario branches on + `client.protocol_version in HANDSHAKE_PROTOCOL_VERSIONS`. +- `client_kw()` is callable so each test leg gets fresh callback state (the + scripted elicit answer and the `list_changed` event). + +## Spec + +- [Tools](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) +- [Resources](https://modelcontextprotocol.io/specification/2025-11-25/server/resources) +- [Elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) + +## See also + +`tools/`, `resources/`, `elicitation/`, `lifespan/`, `standalone_get/` +(`list_changed` over the GET stream). diff --git a/examples/stories/stickynotes/__init__.py b/examples/stories/stickynotes/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/stickynotes/client.py b/examples/stories/stickynotes/client.py new file mode 100644 index 0000000000..b1210b31f2 --- /dev/null +++ b/examples/stories/stickynotes/client.py @@ -0,0 +1,92 @@ +"""Drive the sticky-notes board end to end and prove `remove_all` clears only on a confirmed elicitation.""" + +from typing import Any + +import anyio + +from mcp import types +from mcp.client import Client, ClientRequestContext +from mcp.shared.version import HANDSHAKE_PROTOCOL_VERSIONS +from stories._harness import connect_from_args, run_client + +# Shared state between the client callbacks (wired at connect time) and `scenario()`. +# Reset per leg by `client_kw()` so each (transport × era × variant) starts clean. +_elicit_answer: list[str] = ["cancel"] +_list_changed: list[anyio.Event] = [] + + +async def _on_elicit(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult: + if _elicit_answer[0] == "cancel": + return types.ElicitResult(action="cancel") + return types.ElicitResult(action="accept", content={"confirm": _elicit_answer[0] == "confirm"}) + + +async def _on_message(message: object) -> None: + if isinstance(message, types.ResourceListChangedNotification): + _list_changed[0].set() + + +def client_kw() -> dict[str, Any]: + _elicit_answer[:] = ["cancel"] + _list_changed[:] = [anyio.Event()] + return {"elicitation_callback": _on_elicit, "message_handler": _on_message} + + +async def scenario(client: Client) -> None: + legacy = client.protocol_version in HANDSHAKE_PROTOCOL_VERSIONS + + # Add two notes. + first = await client.call_tool("add_note", {"text": "Buy milk"}) + assert first.structured_content is not None + first_id, first_uri = first.structured_content["id"], first.structured_content["uri"] + assert first_uri.startswith("note:///") + second = await client.call_tool("add_note", {"text": "Walk the dog"}) + assert second.structured_content is not None + second_id, second_uri = second.structured_content["id"], second.structured_content["uri"] + assert first_id != second_id + + # List + read — both notes appear as resources; first reads back its text. + listed = await client.list_resources() + uris = {str(r.uri) for r in listed.resources} + assert first_uri in uris and second_uri in uris, uris + read = await client.read_resource(first_uri) + assert isinstance(read.contents[0], types.TextResourceContents) + assert read.contents[0].text == "Buy milk" + + # list_changed rides the standalone stream — only deliverable on a legacy-era connection. + if legacy: + with anyio.fail_after(5): + await _list_changed[0].wait() + + # Remove one. + removed = await client.call_tool("remove_note", {"note_id": first_id}) + assert removed.structured_content == {"result": True} + after = await client.list_resources() + assert first_uri not in {str(r.uri) for r in after.resources} + + # remove_all uses push-style elicitation: legacy-era only (modern equivalent lands with the mrtr/ story). + if not legacy: + gone = await client.call_tool("remove_note", {"note_id": second_id}) + assert gone.structured_content == {"result": True} + return + + _elicit_answer[0] = "cancel" + cancelled = await client.call_tool("remove_all", {}) + assert cancelled.structured_content == {"status": "cancelled", "removed": 0} + + _elicit_answer[0] = "unchecked" + declined = await client.call_tool("remove_all", {}) + assert declined.structured_content == {"status": "declined", "removed": 0} + + _elicit_answer[0] = "confirm" + cleared = await client.call_tool("remove_all", {}) + assert cleared.structured_content == {"status": "cleared", "removed": 1} + final = await client.list_resources() + assert not [r for r in final.resources if str(r.uri).startswith("note:///")] + + empty = await client.call_tool("remove_all", {}) + assert empty.structured_content == {"status": "empty", "removed": 0} + + +if __name__ == "__main__": + run_client(scenario, connect=connect_from_args(__file__), **client_kw()) diff --git a/examples/stories/stickynotes/server.py b/examples/stories/stickynotes/server.py new file mode 100644 index 0000000000..05002f7a5d --- /dev/null +++ b/examples/stories/stickynotes/server.py @@ -0,0 +1,93 @@ +"""Capstone sticky-notes board: tools mutate lifespan state, one resource per note, +`resources/list_changed` on add/remove, elicitation-guarded clear.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass, field + +from pydantic import BaseModel + +from mcp.server.mcpserver import Context, MCPServer +from mcp.server.mcpserver.resources import FunctionResource +from stories._hosting import run_server_from_args + + +@dataclass +class Board: + notes: dict[str, str] = field(default_factory=dict[str, str]) + _next: int = 1 + + def claim_id(self) -> str: + nid, self._next = str(self._next), self._next + 1 + return nid + + +class AddResult(BaseModel): + id: str + uri: str + + +class ClearResult(BaseModel): + status: str + removed: int + + +class ConfirmClear(BaseModel): + confirm: bool + + +def build_server() -> MCPServer: + @asynccontextmanager + async def lifespan(_: MCPServer) -> AsyncIterator[Board]: + yield Board() + + mcp = MCPServer("stickynotes-example", lifespan=lifespan) + + @mcp.tool() + async def add_note(text: str, ctx: Context[Board]) -> AddResult: + """Add a sticky note and register a `note:///{id}` resource for it.""" + board = ctx.request_context.lifespan_context + note_id = board.claim_id() + uri = f"note:///{note_id}" + board.notes[note_id] = text + mcp.add_resource( + FunctionResource(uri=uri, name=f"note-{note_id}", mime_type="text/plain", fn=lambda: board.notes[note_id]) + ) + await ctx.session.send_resource_list_changed() + return AddResult(id=note_id, uri=uri) + + @mcp.tool() + async def remove_note(note_id: str, ctx: Context[Board]) -> bool: + """Remove one sticky note and unregister its resource.""" + board = ctx.request_context.lifespan_context + removed = board.notes.pop(note_id, None) is not None + if removed: + # MCPServer has no public remove_resource() yet — DO NOT copy this private + # reach; see server_lowlevel.py for the clean pattern (rebuild the list per call). + mcp._resource_manager._resources.pop(f"note:///{note_id}", None) # pyright: ignore[reportPrivateUsage] + await ctx.session.send_resource_list_changed() + return removed + + @mcp.tool() + async def remove_all(ctx: Context[Board]) -> ClearResult: + """Remove every note after a confirmed form-mode elicitation (handshake-era only).""" + board = ctx.request_context.lifespan_context + if not board.notes: + return ClearResult(status="empty", removed=0) + answer = await ctx.elicit(f"Remove all {len(board.notes)} note(s)? This cannot be undone.", ConfirmClear) + if answer.action == "cancel": + return ClearResult(status="cancelled", removed=0) + if answer.action != "accept" or not answer.data.confirm: + return ClearResult(status="declined", removed=0) + count = len(board.notes) + for nid in list(board.notes): + mcp._resource_manager._resources.pop(f"note:///{nid}", None) # pyright: ignore[reportPrivateUsage] + board.notes.clear() + await ctx.session.send_resource_list_changed() + return ClearResult(status="cleared", removed=count) + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/stickynotes/server_lowlevel.py b/examples/stories/stickynotes/server_lowlevel.py new file mode 100644 index 0000000000..c4665cccd8 --- /dev/null +++ b/examples/stories/stickynotes/server_lowlevel.py @@ -0,0 +1,118 @@ +"""Capstone sticky-notes board on the lowlevel `Server`: handlers read lifespan state directly.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from typing import Any + +from mcp import types +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + + +@dataclass +class Board: + notes: dict[str, str] = field(default_factory=dict[str, str]) + _next: int = 1 + + def claim_id(self) -> str: + nid, self._next = str(self._next), self._next + 1 + return nid + + +CONFIRM_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"confirm": {"type": "boolean", "title": "Yes, permanently delete every sticky note"}}, + "required": ["confirm"], +} + +TOOLS = [ + types.Tool( + name="add_note", + description="Add a sticky note.", + input_schema={"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}, + ), + types.Tool( + name="remove_note", + description="Remove one sticky note.", + input_schema={"type": "object", "properties": {"note_id": {"type": "string"}}, "required": ["note_id"]}, + ), + types.Tool(name="remove_all", description="Remove every note after confirmation.", input_schema={"type": "object"}), +] + + +def _result(text: str, structured: dict[str, Any]) -> types.CallToolResult: + return types.CallToolResult(content=[types.TextContent(text=text)], structured_content=structured) + + +def build_server() -> Server[Board]: + @asynccontextmanager + async def lifespan(_: Server[Board]) -> AsyncIterator[Board]: + yield Board() + + async def list_tools( + ctx: ServerRequestContext[Board], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=TOOLS) + + async def list_resources( + ctx: ServerRequestContext[Board], params: types.PaginatedRequestParams | None + ) -> types.ListResourcesResult: + board = ctx.lifespan_context + return types.ListResourcesResult( + resources=[ + types.Resource(uri=f"note:///{nid}", name=f"note-{nid}", mime_type="text/plain") for nid in board.notes + ] + ) + + async def read_resource( + ctx: ServerRequestContext[Board], params: types.ReadResourceRequestParams + ) -> types.ReadResourceResult: + board = ctx.lifespan_context + nid = str(params.uri).removeprefix("note:///") + return types.ReadResourceResult( + contents=[types.TextResourceContents(uri=params.uri, mime_type="text/plain", text=board.notes[nid])] + ) + + async def call_tool(ctx: ServerRequestContext[Board], params: types.CallToolRequestParams) -> types.CallToolResult: + board = ctx.lifespan_context + args = params.arguments or {} + if params.name == "add_note": + nid = board.claim_id() + board.notes[nid] = args["text"] + await ctx.session.send_resource_list_changed() + return _result(f"added #{nid}", {"id": nid, "uri": f"note:///{nid}"}) + if params.name == "remove_note": + removed = board.notes.pop(args["note_id"], None) is not None + if removed: + await ctx.session.send_resource_list_changed() + return _result("removed" if removed else "not found", {"result": removed}) + if params.name == "remove_all": + if not board.notes: + return _result("empty", {"status": "empty", "removed": 0}) + answer = await ctx.session.elicit_form( + f"Remove all {len(board.notes)} note(s)? This cannot be undone.", CONFIRM_SCHEMA, ctx.request_id + ) + if answer.action == "cancel": + return _result("cancelled", {"status": "cancelled", "removed": 0}) + if answer.action != "accept" or not (answer.content or {}).get("confirm"): + return _result("declined", {"status": "declined", "removed": 0}) + count = len(board.notes) + board.notes.clear() + await ctx.session.send_resource_list_changed() + return _result(f"cleared {count}", {"status": "cleared", "removed": count}) + raise NotImplementedError + + return Server( + "stickynotes-example", + lifespan=lifespan, + on_list_tools=list_tools, + on_call_tool=call_tool, + on_list_resources=list_resources, + on_read_resource=read_resource, + ) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/streaming/README.md b/examples/stories/streaming/README.md new file mode 100644 index 0000000000..f5b5193ac0 --- /dev/null +++ b/examples/stories/streaming/README.md @@ -0,0 +1,69 @@ +# streaming + +The three in-flight server→client channels during a tool call: **progress** +(`ctx.report_progress` → the caller's `progress_callback=`), **logging** +(`notifications/message` → the client's `logging_callback=`), and +**cancellation** (abandoning the client's awaiting scope interrupts the server +handler). One `countdown(steps)` tool emits a progress notification and a log +line per step; the client asserts both streams arrive in order, then cancels a +long call mid-flight by cancelling the enclosing `anyio.CancelScope` from +inside the progress callback (event-driven, no `sleep`). + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.streaming.client +uv run python -m stories.streaming.client --server server_lowlevel + +# against a running HTTP server +uv run python -m stories.streaming.server --http --port 8000 & +uv run python -m stories.streaming.client --http http://127.0.0.1:8000/mcp +``` + +## What to look at + +- `server.py` — `ctx.report_progress(i, steps, msg)` is a silent no-op when the + caller passed no `progress_callback`; the SDK reads the token from the + request's `_meta` for you. The log notification is sent via the raw + `session.send_notification(...)` because the `ctx.log()` / `ctx.info()` + shorthands are deprecated (SEP-2577) with no non-deprecated replacement yet. + `related_request_id=` keeps the log on this request's response stream — over + streamable HTTP an unrelated notification would ride the standalone GET + stream instead. +- `server.py` — `ctx.request_context.session` / `ctx.request_context.request_id` + is the interim 2-hop path; a later release will shorten these. +- `server.py` — the `except anyio.get_cancelled_exc_class(): raise` block is + where a real handler would release resources before re-raising. **Never + swallow** the cancellation exception. +- `client.py` — cancellation is just cancelling the `anyio` scope around + `await client.call_tool(...)`; the SDK sends `notifications/cancelled` for + you on stateful transports. There is no `client.cancel(request_id)` API. +- `server_lowlevel.py` — the same wire contract built by hand against + `ServerRequestContext.session` directly. + +## Caveats + +- **Logging is deprecated** as of 2026-07-28 (SEP-2577); migrate to stderr / + OpenTelemetry. It is shown here because servers still need to support + 2025-era clients during the deprecation window. +- On the modern (2026-07-28) streamable-HTTP path, mid-call progress and log + notifications are currently dropped pending the SSE wiring; the + `http-asgi:modern` leg of this story is `xfail` until that lands. +- When a request is cancelled the server currently replies with + `ErrorData(code=0, message="Request cancelled")`; the spec says it should not + reply at all. The client never observes it (its awaiting task is already + cancelled), so this story does not assert on the reply. +- `Client.logging_callback` is constructor-only (no setter), so the callback + and the list it fills are module-level; `scenario()` clears the list at start. + +## Spec + +[Progress](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress), +[cancellation](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation), +[logging](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging) + +## See also + +`parallel_calls/` (concurrent in-flight calls), `error_handling/` (the +cancellation error path), `tools/` (the basics this builds on). diff --git a/examples/stories/streaming/__init__.py b/examples/stories/streaming/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/streaming/client.py b/examples/stories/streaming/client.py new file mode 100644 index 0000000000..1e41ebf2e4 --- /dev/null +++ b/examples/stories/streaming/client.py @@ -0,0 +1,61 @@ +"""Asserts progress + log notifications arrive in order, then cancels a call mid-flight.""" + +from typing import Any + +import anyio + +from mcp.client import Client +from mcp.types import LoggingMessageNotificationParams +from stories._harness import connect_from_args, run_client + +# `logging_callback` is constructor-only on `Client`, so the callback and the +# list it fills must be module-level for `scenario()` to read them. Cleared per run. +_logs: list[LoggingMessageNotificationParams] = [] + + +async def _on_log(params: LoggingMessageNotificationParams) -> None: + _logs.append(params) + + +client_kw: dict[str, Any] = {"logging_callback": _on_log} + + +async def scenario(client: Client) -> None: + _logs.clear() + + # ── progress + logging: a short countdown delivers exactly `steps` of each, in order ── + updates: list[tuple[float, float | None, str | None]] = [] + + async def collect(progress: float, total: float | None, message: str | None) -> None: + updates.append((progress, total, message)) + + result = await client.call_tool("countdown", {"steps": 3}, progress_callback=collect) + assert result.structured_content == {"completed": 3, "total": 3}, result + assert updates == [(1.0, 3.0, "step 1/3"), (2.0, 3.0, "step 2/3"), (3.0, 3.0, "step 3/3")] + assert [(m.level, m.logger, m.data) for m in _logs] == [ + ("info", "countdown", "step 1/3"), + ("info", "countdown", "step 2/3"), + ("info", "countdown", "step 3/3"), + ] + + # ── cancellation: abandon the awaiting scope once the call is provably in flight ── + in_flight = anyio.Event() + with anyio.fail_after(5): + with anyio.CancelScope() as scope: + + async def cancel_once_in_flight(progress: float, total: float | None, message: str | None) -> None: + in_flight.set() + scope.cancel() + + await client.call_tool("countdown", {"steps": 1_000}, progress_callback=cancel_once_in_flight) + + assert in_flight.is_set(), "the call must have started before it was cancelled" + assert scope.cancelled_caught, "abandoning the scope should have cancelled the in-flight call" + + # The session survives cancellation: a follow-up call still works. + after = await client.call_tool("countdown", {"steps": 1}, progress_callback=collect) + assert after.structured_content == {"completed": 1, "total": 1} + + +if __name__ == "__main__": + run_client(scenario, connect=connect_from_args(__file__), **client_kw) diff --git a/examples/stories/streaming/server.py b/examples/stories/streaming/server.py new file mode 100644 index 0000000000..0d917def60 --- /dev/null +++ b/examples/stories/streaming/server.py @@ -0,0 +1,40 @@ +"""Progress, in-flight logging, and cancellation from a single long-running tool.""" + +import anyio + +from mcp import types +from mcp.server.mcpserver import Context, MCPServer +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer("streaming-example") + + @mcp.tool() + async def countdown(steps: int, ctx: Context) -> dict[str, int]: + """Emit one progress + one log notification per step; observes cancellation.""" + try: + for i in range(1, steps + 1): + await ctx.report_progress(float(i), float(steps), f"step {i}/{steps}") + # No non-deprecated logging helper on Context yet, so send the raw + # notification. `related_request_id` keeps it on this request's response + # stream (matters over streamable HTTP). + await ctx.request_context.session.send_notification( + types.LoggingMessageNotification( + params=types.LoggingMessageNotificationParams( + level="info", logger="countdown", data=f"step {i}/{steps}" + ) + ), + related_request_id=ctx.request_context.request_id, + ) + except anyio.get_cancelled_exc_class(): + # The client abandoned the call. Release resources here, then re-raise so + # the dispatcher unwinds the request — never swallow cancellation. + raise + return {"completed": steps, "total": steps} + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/streaming/server_lowlevel.py b/examples/stories/streaming/server_lowlevel.py new file mode 100644 index 0000000000..17ee17c15e --- /dev/null +++ b/examples/stories/streaming/server_lowlevel.py @@ -0,0 +1,69 @@ +"""Progress, in-flight logging, and cancellation against the low-level Server.""" + +from typing import Any + +import anyio + +from mcp import types +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + +COUNTDOWN_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"steps": {"type": "integer"}}, + "required": ["steps"], +} + + +def build_server() -> Server[Any]: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="countdown", + description="Emit one progress + one log notification per step; observes cancellation.", + input_schema=COUNTDOWN_INPUT_SCHEMA, + ) + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "countdown" and params.arguments is not None + steps = int(params.arguments["steps"]) + try: + for i in range(1, steps + 1): + await ctx.session.report_progress(float(i), float(steps), f"step {i}/{steps}") + await ctx.session.send_notification( + types.LoggingMessageNotification( + params=types.LoggingMessageNotificationParams( + level="info", logger="countdown", data=f"step {i}/{steps}" + ) + ), + related_request_id=ctx.request_id, + ) + except anyio.get_cancelled_exc_class(): + raise + return types.CallToolResult( + content=[types.TextContent(text=f"completed {steps}/{steps}")], + structured_content={"completed": steps, "total": steps}, + ) + + async def set_logging_level( + ctx: ServerRequestContext[Any], params: types.SetLevelRequestParams + ) -> types.EmptyResult: + """Registered so the server advertises the `logging` capability; never called.""" + raise NotImplementedError + + return Server( + "streaming-example", + on_list_tools=list_tools, + on_call_tool=call_tool, + on_set_logging_level=set_logging_level, + ) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/subscriptions/README.md b/examples/stories/subscriptions/README.md new file mode 100644 index 0000000000..8ea974bcec --- /dev/null +++ b/examples/stories/subscriptions/README.md @@ -0,0 +1,24 @@ +# subscriptions + +The 2026-era `subscriptions/listen` channel: the server publishes change events +through a `ServerEventBus`, and `Client.listen()` opens an async iterator over +them. Replaces the handshake-era `resources/subscribe` + standalone-GET +notification path. + +**Status: not yet implemented** ([#2901](https://github.com/modelcontextprotocol/python-sdk/issues/2901)). +Types exist; there is no `Client.listen()`, no `ServerEventBus`, and no +entry-handled `subscriptions/listen` route yet. + +## Spec + +[Subscriptions — basic utilities](https://modelcontextprotocol.io/specification/draft/basic/utilities/subscriptions) + +## Working example elsewhere + +The TypeScript SDK ships a runnable `subscriptions` story: +[typescript-sdk/examples/subscriptions](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/subscriptions). + +## See also + +`standalone_get/` (handshake-era server-initiated notifications), `resources/` +(legacy `subscribe` deliberately omitted). diff --git a/examples/stories/tasks/README.md b/examples/stories/tasks/README.md new file mode 100644 index 0000000000..ef15ae63fc --- /dev/null +++ b/examples/stories/tasks/README.md @@ -0,0 +1,16 @@ +# tasks + +The `io.modelcontextprotocol/tasks` extension: long-running work registered +with `@task`, polled via `tasks/get`, updated mid-flight, and cancelled with +`tasks/cancel`. The story will show a task that outlives the request that +started it. + +**Status: not yet implemented.** The extension types exist but the `extensions` +capability map is not yet surfaced on `MCPServer`, and the runtime trails the +release. The TypeScript SDK deliberately removed its tasks example pending the +same work. + +## Spec + +[Tasks — basic utilities](https://modelcontextprotocol.io/specification/draft/basic/utilities/tasks) +· [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133) diff --git a/examples/stories/tools/README.md b/examples/stories/tools/README.md new file mode 100644 index 0000000000..caa86e3916 --- /dev/null +++ b/examples/stories/tools/README.md @@ -0,0 +1,38 @@ +# tools + +**Start here.** Register tools with `@mcp.tool()`; the SDK infers the JSON +input schema from type hints, the output schema from the return annotation, and +returns `structuredContent` alongside text. `ToolAnnotations` carries +behavioural hints (`readOnlyHint`, `idempotentHint`) the host can show to +users. The client lists tools, inspects schemas + annotations, calls both, and +asserts structured output. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.tools.client + +# against a running HTTP server +uv run python -m stories.tools.server --http --port 8000 & +uv run python -m stories.tools.client --http http://127.0.0.1:8000/mcp +``` + +## What to look at + +- `server.py` `calc` — `Literal[...]` and `BaseModel` in the signature become + the tool's `inputSchema` / `outputSchema` with zero hand-written JSON. +- `server.py` `echo` — `structured_output=False` opts out of schema inference + for a plain text-only tool. +- `server_lowlevel.py` — the same wire contract built by hand: this is what + `MCPServer` generates for you. + +## Spec + +[Tools — server features](https://modelcontextprotocol.io/specification/2025-11-25/server/tools) + +## See also + +`schema_validators/` (every input-schema source: pydantic / TypedDict / +dataclass / dict), `error_handling/` (`is_error` vs protocol error), +`streaming/` (progress mid-call). diff --git a/examples/stories/tools/__init__.py b/examples/stories/tools/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/tools/client.py b/examples/stories/tools/client.py new file mode 100644 index 0000000000..0ebfde1800 --- /dev/null +++ b/examples/stories/tools/client.py @@ -0,0 +1,30 @@ +"""List tools, inspect schemas + annotations, call both tools, assert structured output.""" + +from mcp.client import Client +from mcp.types import TextContent +from stories._harness import connect_from_args, run_client + + +async def scenario(client: Client) -> None: + listed = await client.list_tools() + by_name = {t.name: t for t in listed.tools} + assert set(by_name) == {"calc", "echo"} + + calc = by_name["calc"] + assert calc.annotations is not None and calc.annotations.read_only_hint is True + assert calc.annotations.idempotent_hint is True + assert calc.output_schema is not None + assert set(calc.input_schema.get("required", ())) >= {"op", "a", "b"} + assert by_name["echo"].output_schema is None + + result = await client.call_tool("calc", {"op": "add", "a": 2, "b": 3}) + assert not result.is_error + assert result.structured_content == {"op": "add", "result": 5.0}, result + + echoed = await client.call_tool("echo", {"text": "hi"}) + assert echoed.structured_content is None + assert isinstance(echoed.content[0], TextContent) and echoed.content[0].text == "hi" + + +if __name__ == "__main__": + run_client(scenario, connect=connect_from_args(__file__)) diff --git a/examples/stories/tools/server.py b/examples/stories/tools/server.py new file mode 100644 index 0000000000..93e4398092 --- /dev/null +++ b/examples/stories/tools/server.py @@ -0,0 +1,37 @@ +"""Tools primitive: register, list, call, structured output, annotations.""" + +from typing import Literal + +from pydantic import BaseModel + +from mcp.server.mcpserver import MCPServer +from mcp.types import ToolAnnotations +from stories._hosting import run_server_from_args + + +class CalcResult(BaseModel): + op: str + result: float + + +def build_server() -> MCPServer: + mcp = MCPServer("tools-example") + + @mcp.tool( + title="Calculator", + description="Apply an arithmetic operation to two numbers.", + annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True), + ) + def calc(op: Literal["add", "sub", "mul"], a: float, b: float) -> CalcResult: + result = a + b if op == "add" else a - b if op == "sub" else a * b + return CalcResult(op=op, result=result) + + @mcp.tool(description="Echo the input back as plain text.", structured_output=False) + def echo(text: str) -> str: + return text + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/tools/server_lowlevel.py b/examples/stories/tools/server_lowlevel.py new file mode 100644 index 0000000000..8d670914d6 --- /dev/null +++ b/examples/stories/tools/server_lowlevel.py @@ -0,0 +1,71 @@ +"""Tools primitive (lowlevel API): hand-built Tool descriptors and CallToolResult.""" + +from typing import Any + +from mcp import types +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + +CALC_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "op": {"type": "string", "enum": ["add", "sub", "mul"]}, + "a": {"type": "number"}, + "b": {"type": "number"}, + }, + "required": ["op", "a", "b"], +} +CALC_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"op": {"type": "string"}, "result": {"type": "number"}}, + "required": ["op", "result"], +} +ECHO_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"text": {"type": "string"}}, + "required": ["text"], +} + + +def build_server() -> Server[Any]: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="calc", + title="Calculator", + description="Apply an arithmetic operation to two numbers.", + input_schema=CALC_INPUT_SCHEMA, + output_schema=CALC_OUTPUT_SCHEMA, + annotations=types.ToolAnnotations(read_only_hint=True, idempotent_hint=True), + ), + types.Tool( + name="echo", + description="Echo the input back as plain text.", + input_schema=ECHO_INPUT_SCHEMA, + ), + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.arguments is not None + if params.name == "calc": + op, a, b = params.arguments["op"], float(params.arguments["a"]), float(params.arguments["b"]) + result = a + b if op == "add" else a - b if op == "sub" else a * b + payload = {"op": op, "result": result} + return types.CallToolResult( + content=[types.TextContent(text=f"{a} {op} {b} = {result}")], + structured_content=payload, + ) + if params.name == "echo": + return types.CallToolResult(content=[types.TextContent(text=str(params.arguments["text"]))]) + raise NotImplementedError + + return Server("tools-example", on_list_tools=list_tools, on_call_tool=call_tool) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/pyproject.toml b/pyproject.toml index 830fc9963c..051f7199cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,8 @@ build-constraint-dependencies = [ dev = [ # We add mcp[cli] so `uv sync` considers the extras. "mcp[cli]", + "mcp-example-stories", + "tomli>=2.0; python_version < '3.11'", "pyright>=1.1.400", "pytest>=8.4.0", "ruff>=0.8.5", @@ -135,12 +137,15 @@ include = [ "src/mcp", "src/mcp-types/mcp_types", "tests", + "examples/stories", "examples/servers", "examples/snippets", - "examples/clients", ] venvPath = "." venv = ".venv" +# `stories` is a workspace package rooted at examples/; the IDE language server +# does not always pick up the editable-install .pth, so resolve it statically. +extraPaths = ["examples"] # The FastAPI style of using decorators in tests gives a `reportUnusedFunction` error. # See https://github.com/microsoft/pyright/issues/7771 for more details. # TODO(Marcelo): We should remove `reportPrivateUsage = false`. The idea is that we should test the workflow that uses @@ -149,7 +154,11 @@ venv = ".venv" executionEnvironments = [ { root = "tests", extraPaths = [ ".", + "examples", ], reportUnusedFunction = false, reportPrivateUsage = false }, + { root = "examples/stories", extraPaths = [ + "examples", + ], reportUnusedFunction = false }, { root = "examples/servers", reportUnusedFunction = false }, ] @@ -194,10 +203,11 @@ max-returns = 13 # Default is 6 max-statements = 102 # Default is 50 [tool.uv.workspace] -members = ["src/mcp-types", "examples/clients/*", "examples/servers/*", "examples/snippets"] +members = ["src/mcp-types", "examples", "examples/servers/*", "examples/snippets"] [tool.uv.sources] mcp = { workspace = true } +mcp-example-stories = { workspace = true } mcp-types = { workspace = true } strict-no-cover = { git = "https://github.com/pydantic/strict-no-cover" } diff --git a/tests/examples/__init__.py b/tests/examples/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/examples/conftest.py b/tests/examples/conftest.py new file mode 100644 index 0000000000..a63f867571 --- /dev/null +++ b/tests/examples/conftest.py @@ -0,0 +1,208 @@ +"""Discovery + parametrization for the example-stories matrix. + +Reads ``examples/stories/manifest.toml`` and expands each story across +(server_variant × transport × era). The story modules are imported as +real packages (the ``mcp-example-stories`` workspace member installs ``stories`` +editable), so pyright sees them and a signature change red-lines every story. + +The HTTP-ASGI leg reuses the interaction suite's in-process bridge directly +from ``tests.interaction.transports._bridge`` (both live under ``tests/``); the +move to ``stories._shared.bridge`` is a later batch. +""" + +from __future__ import annotations + +import importlib +import sys +from collections.abc import AsyncIterator, Callable +from contextlib import asynccontextmanager +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import httpx +import pytest +import stories +from starlette.applications import Starlette +from stories._harness import AuthBuilder, Connect +from stories._hosting import asgi_from + +from mcp.client import Client +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.version import LATEST_MODERN_VERSION +from tests.interaction.transports._bridge import StreamingASGITransport + +if sys.version_info >= (3, 11): + import tomllib +else: # pragma: lax no cover + import tomli as tomllib + +STORIES_DIR = Path(stories.__file__).parent +BASE_URL = "http://127.0.0.1:8000" + +MANIFEST = tomllib.loads((STORIES_DIR / "manifest.toml").read_text()) +DEFAULTS: dict[str, Any] = MANIFEST["defaults"] +STORIES: dict[str, dict[str, Any]] = MANIFEST["story"] + +_ERA_TO_MODE = {"modern": LATEST_MODERN_VERSION, "legacy": "legacy"} +"""R8 maps era→mode. ``Client`` rejects handshake-era version strings, so +``legacy`` resolves to ``mode='legacy'`` rather than ``LATEST_HANDSHAKE_VERSION``.""" + + +def story_cfg(name: str) -> dict[str, Any]: + return DEFAULTS | STORIES.get(name, {}) + + +def _expand_era(era: str) -> tuple[str, ...]: + if era == "dual": + return ("modern", "legacy") + if era == "dual-in-body": + return ("in-body",) + return (era,) + + +@dataclass(frozen=True) +class Leg: + story: str + server_variant: str + transport: str + era: str + + @property + def id(self) -> str: + return "-".join((self.story, self.server_variant, self.transport, self.era)) + + +def _legs() -> list[tuple[Leg, dict[str, Any]]]: + out: list[tuple[Leg, dict[str, Any]]] = [] + for name in STORIES: + cfg = story_cfg(name) + variants = ["server"] + (["server_lowlevel"] if cfg["lowlevel"] else []) + out.extend( + (Leg(name, variant, transport, era), cfg) + for variant in variants + for transport in cfg["transports"] + for era in _expand_era(cfg["era"]) + ) + return out + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + if "leg" not in metafunc.fixturenames: + return + params: list[Any] = [] + for leg, cfg in _legs(): + marks: list[pytest.MarkDecorator] = [] + if f"{leg.transport}:{leg.era}" in cfg["xfail"]: + marks.append(pytest.mark.xfail(strict=True, reason="manifest xfail")) + params.append(pytest.param(leg, marks=marks, id=leg.id)) + metafunc.parametrize("leg", params) + + +@pytest.fixture +def cfg(leg: Leg) -> dict[str, Any]: + return story_cfg(leg.story) + + +@pytest.fixture +def server_module(leg: Leg) -> Any: + return importlib.import_module(f"stories.{leg.story}.{leg.server_variant}") + + +@pytest.fixture +def client_module(leg: Leg) -> Any: + return importlib.import_module(f"stories.{leg.story}.client") + + +def _client_kw(client_module: Any) -> dict[str, Any]: + raw: dict[str, Any] | Callable[[], dict[str, Any]] | None = getattr(client_module, "client_kw", None) + if raw is None: + return {} + return raw() if callable(raw) else dict(raw) + + +def _http_client_kw(client_module: Any) -> dict[str, Any]: + """``httpx.AsyncClient(...)`` kwargs the story's client module wants threaded (G-http-kw).""" + return dict(getattr(client_module, "http_client_kw", None) or {}) + + +@dataclass +class Hosted: + """One server/app instance hosted for the leg's whole duration. + + ``connect`` opens a fresh ``Client`` against that single instance on every + call (G-connect-shared-server / G-event-store-per-app: state observed by one + client is visible to the next). ``http`` is the shared raw ``httpx.AsyncClient`` + bound to the same ASGI app, or ``None`` on the in-memory leg. + """ + + connect: Connect + http: httpx.AsyncClient | None + + +@pytest.fixture +async def hosted( + leg: Leg, cfg: dict[str, Any], server_module: Any, client_module: Any, monkeypatch: pytest.MonkeyPatch +) -> AsyncIterator[Hosted]: + """Build the leg's server/app once and keep it running for the test. + + The leg's era pre-seeds ``mode=``; a scenario may override it per-call (R6 + ``dual-in-body`` opens both eras from the same body). Auth stories thread an + ``httpx.Auth`` onto the bridge client via a module-level ``build_auth(http)`` + export and/or extra ``httpx.AsyncClient`` kwargs via ``http_client_kw`` (G3). + """ + for key, value in cfg["env"].items(): + monkeypatch.setenv(key, value) + mode = _ERA_TO_MODE.get(leg.era, "auto") + path = cfg["mcp_path"] + + if leg.transport == "in-memory": + server = server_module.build_server() + + @asynccontextmanager + async def _connect(*, auth: AuthBuilder | None = None, **kw: Any) -> AsyncIterator[Client]: + assert auth is None, "auth= is HTTP-only; restrict the story to transports=['http-asgi']" + kw.setdefault("mode", mode) + async with Client(server, **kw) as client: + yield client + + yield Hosted(_connect, None) + return + + # http-asgi: one Starlette app per leg. ``server_export="app"`` stories hand us the + # app directly; ``"factory"`` stories are wrapped via ``asgi_from``. Either way the + # app's own lifespan is what brings the session manager up (G-app-lifespan), and the + # in-process bridge never fires ASGI lifespan events itself, so enter it explicitly. + if cfg["server_export"] == "app": + app: Starlette = server_module.build_app() + else: + app = asgi_from(server_module.build_server(), path=path) + http_kw = _http_client_kw(client_module) + build_auth: AuthBuilder | None = getattr(client_module, "build_auth", None) + async with ( + app.router.lifespan_context(app), + httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL, **http_kw) as http_client, + ): + if build_auth is not None: + http_client.auth = build_auth(http_client) + + @asynccontextmanager + async def _connect(*, auth: AuthBuilder | None = None, **kw: Any) -> AsyncIterator[Client]: + kw.setdefault("mode", mode) + if auth is None: + async with Client(streamable_http_client(f"{BASE_URL}{path}", http_client=http_client), **kw) as client: + yield client + return + # A scenario-supplied ``auth`` gets a fresh httpx client against the same app so + # the second connection's auth flow doesn't share request-level state with the first. + async with httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL, **http_kw) as fresh: + fresh.auth = auth(fresh) + async with Client(streamable_http_client(f"{BASE_URL}{path}", http_client=fresh), **kw) as client: + yield client + + yield Hosted(_connect, http_client) + + +@pytest.fixture +def scenario_kw(client_module: Any) -> dict[str, Any]: + return _client_kw(client_module) diff --git a/tests/examples/test_stories.py b/tests/examples/test_stories.py new file mode 100644 index 0000000000..de1c95dfc5 --- /dev/null +++ b/tests/examples/test_stories.py @@ -0,0 +1,82 @@ +"""Run every story's ``scenario`` over the in-process (transport × era × variant) matrix.""" + +from __future__ import annotations + +import importlib +import inspect +from typing import Any + +import anyio +import pytest + +from tests.examples.conftest import MANIFEST, STORIES, STORIES_DIR, Hosted, Leg, story_cfg + +pytestmark = pytest.mark.anyio + + +async def test_story( + leg: Leg, + cfg: dict[str, Any], + hosted: Hosted, + client_module: Any, + scenario_kw: dict[str, Any], +) -> None: + scenario = client_module.scenario + with anyio.fail_after(cfg["timeout_s"]): + async with hosted.connect(**scenario_kw) as client: + args: list[Any] = [client] + if cfg["needs_connect"] or leg.era == "in-body": + args.append(hosted.connect) + if cfg["needs_http"]: + args.append(hosted.http) + await scenario(*args) + + +def test_manifest_matches_filesystem() -> None: + """Manifest [story.*] / [deferred] keys and on-disk story directories agree exactly.""" + dirs = {d.name for d in STORIES_DIR.iterdir() if d.is_dir() and not d.name.startswith(("_", "."))} + runnable = {d for d in dirs if (STORIES_DIR / d / "client.py").exists()} + in_manifest = set(STORIES) + assert runnable == in_manifest, {"only_on_disk": runnable - in_manifest, "only_in_manifest": in_manifest - runnable} + # README-only stub dirs must be exactly the [deferred] table. + deferred_manifest = set(MANIFEST.get("deferred", {})) + assert dirs - runnable == deferred_manifest, { + "stub_dirs_missing_from_manifest": (dirs - runnable) - deferred_manifest, + "deferred_entries_missing_dir": deferred_manifest - (dirs - runnable), + } + assert runnable.isdisjoint(deferred_manifest), "deferred stories must not have a client.py" + + +_ERAS = {"dual", "modern", "legacy", "dual-in-body"} +_TRANSPORTS = {"in-memory", "http-asgi"} +_SERVER_EXPORTS = {"factory", "app"} + + +def test_manifest_schema_valid() -> None: + """Declared manifest values are mutually consistent with the story files.""" + for name in STORIES: + cfg = story_cfg(name) + assert "-" not in name, f"{name!r}: story directories must be underscored (R3)" + assert cfg["era"] in _ERAS, f"{name!r}: era={cfg['era']!r} not in {_ERAS}" + assert cfg["server_export"] in _SERVER_EXPORTS, f"{name!r}: server_export={cfg['server_export']!r}" + assert set(cfg["transports"]) <= _TRANSPORTS, f"{name!r}: transports={cfg['transports']!r}" + assert (STORIES_DIR / name / "__init__.py").exists(), f"{name!r}: missing __init__.py" + if cfg["server_export"] == "factory": + assert (STORIES_DIR / name / "server.py").exists(), f"{name!r}: missing server.py" + else: + assert "in-memory" not in cfg["transports"], f"{name!r}: server_export='app' cannot run in-memory" + if cfg["needs_http"]: + assert cfg["transports"] == ["http-asgi"], f"{name!r}: needs_http requires transports=['http-asgi']" + ll = STORIES_DIR / name / "server_lowlevel.py" + assert cfg["lowlevel"] == ll.exists(), f"{name!r}: lowlevel={cfg['lowlevel']} vs server_lowlevel.py on disk" + + +def test_scenario_arity_matches_manifest() -> None: + """``scenario`` parameter count agrees with ``needs_connect``/``dual-in-body``/``needs_http``.""" + for name in STORIES: + cfg = story_cfg(name) + wants_connect = cfg["needs_connect"] or cfg["era"] == "dual-in-body" + expected = 1 + int(wants_connect) + int(cfg["needs_http"]) + mod = importlib.import_module(f"stories.{name}.client") + arity = len(inspect.signature(mod.scenario).parameters) + assert arity == expected, f"{name}: arity={arity} expected={expected}" diff --git a/tests/examples/test_stories_smoke.py b/tests/examples/test_stories_smoke.py new file mode 100644 index 0000000000..aa06cdc1d9 --- /dev/null +++ b/tests/examples/test_stories_smoke.py @@ -0,0 +1,101 @@ +"""Subprocess smoke for the story ``__main__`` paths. + +The in-process matrix in ``test_stories.py`` never executes a story's +``if __name__ == "__main__"`` block, so ``run_client`` / ``run_server_from_args`` / +``run_app_from_args`` and the real stdio + uvicorn entries are unverified by +construction. This file proves that plumbing once over real subprocesses for two +stories (``tools`` over stdio, ``tools`` + ``bearer_auth`` over a real uvicorn +socket). + +lax no cover: gated on ``MCP_EXAMPLES_SMOKE=1``, which CI sets on exactly one +matrix cell (ubuntu / 3.12 / locked — see ``shared.yml``). Every other cell +skips at collection, so the test bodies and the helpers they call are uncovered +there and the per-job 100% gate would otherwise fail. +""" + +from __future__ import annotations + +import os +import socket +import sys +from pathlib import Path + +import anyio +import pytest + +pytestmark = [ + pytest.mark.anyio, + pytest.mark.skipif( + os.environ.get("MCP_EXAMPLES_SMOKE") != "1", + reason="subprocess smoke runs on one CI cell only; set MCP_EXAMPLES_SMOKE=1", + ), +] + +_REPO_ROOT = Path(__file__).parents[2] +# httpx in the spawned client honours these and tries to mount a SOCKS transport even for +# 127.0.0.1; strip them so the smoke run is hermetic regardless of the caller's shell. +_PROXY_VARS = {v for base in ("all_proxy", "http_proxy", "https_proxy", "ftp_proxy") for v in (base, base.upper())} +_ENV = {k: v for k, v in os.environ.items() if k not in _PROXY_VARS} + + +def _free_port() -> int: # pragma: lax no cover + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +async def _wait_listening(port: int) -> None: # pragma: lax no cover + """Poll ``127.0.0.1:port`` until it accepts; condition-based, not a fixed-duration wait.""" + while True: + try: + stream = await anyio.connect_tcp("127.0.0.1", port) + except OSError: + await anyio.sleep(0.05) + else: + await stream.aclose() + return + + +async def _run_module(*argv: str) -> int: # pragma: lax no cover + async with await anyio.open_process( + [sys.executable, "-m", *argv], cwd=_REPO_ROOT, env=_ENV, stdout=None, stderr=None + ) as proc: + await proc.wait() + assert proc.returncode is not None + return proc.returncode + + +async def test_tools_stdio_main_runs_end_to_end() -> None: # pragma: lax no cover + """``python -m stories.tools.client`` spawns the sibling server over real stdio and exits 0.""" + with anyio.fail_after(30): + assert await _run_module("stories.tools.client") == 0 + + +@pytest.mark.parametrize( + ("story", "server_argv"), + [ + ("tools", ("stories.tools.server", "--http")), + ("bearer_auth", ("stories.bearer_auth.server",)), + ], + ids=["tools", "bearer_auth"], +) +async def test_http_main_runs_end_to_end(story: str, server_argv: tuple[str, ...]) -> None: # pragma: lax no cover + """Spawn the story's server on a real uvicorn socket, drive its client at it, assert exit 0.""" + port = _free_port() + with anyio.fail_after(30): + async with await anyio.open_process( + [sys.executable, "-m", *server_argv, "--port", str(port)], + cwd=_REPO_ROOT, + env=_ENV, + stdout=None, + stderr=None, + ) as server: + try: + await _wait_listening(port) + assert await _run_module(f"stories.{story}.client", "--http", f"http://127.0.0.1:{port}/mcp") == 0 + finally: + server.terminate() + with anyio.move_on_after(5): + await server.wait() + if server.returncode is None: + server.kill() diff --git a/uv.lock b/uv.lock index b6dbdd9e0a..b9ffd968cc 100644 --- a/uv.lock +++ b/uv.lock @@ -10,18 +10,8 @@ resolution-markers = [ members = [ "mcp", "mcp-everything-server", - "mcp-simple-auth", - "mcp-simple-auth-client", - "mcp-simple-chatbot", - "mcp-simple-pagination", - "mcp-simple-prompt", - "mcp-simple-resource", - "mcp-simple-streamablehttp", - "mcp-simple-streamablehttp-stateless", - "mcp-simple-tool", + "mcp-example-stories", "mcp-snippets", - "mcp-sse-polling-client", - "mcp-sse-polling-demo", "mcp-structured-output-lowlevel", "mcp-types", ] @@ -943,6 +933,7 @@ dev = [ { name = "inline-snapshot" }, { name = "logfire" }, { name = "mcp", extra = ["cli"] }, + { name = "mcp-example-stories" }, { name = "opentelemetry-sdk" }, { name = "pillow" }, { name = "pyright" }, @@ -953,6 +944,7 @@ dev = [ { name = "pytest-xdist" }, { name = "ruff" }, { name = "strict-no-cover" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "trio" }, ] docs = [ @@ -998,6 +990,7 @@ dev = [ { name = "inline-snapshot", specifier = ">=0.23.0" }, { name = "logfire", specifier = ">=3.0.0" }, { name = "mcp", extras = ["cli"], editable = "." }, + { name = "mcp-example-stories", editable = "examples" }, { name = "opentelemetry-sdk", specifier = ">=1.39.1" }, { name = "pillow", specifier = ">=12.0" }, { name = "pyright", specifier = ">=1.1.400" }, @@ -1008,6 +1001,7 @@ dev = [ { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "ruff", specifier = ">=0.8.5" }, { name = "strict-no-cover", git = "https://github.com/pydantic/strict-no-cover" }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" }, { name = "trio", specifier = ">=0.26.2" }, ] docs = [ @@ -1044,244 +1038,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, - { name = "starlette" }, - { name = "uvicorn" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.378" }, - { name = "pytest", specifier = ">=8.3.3" }, - { name = "ruff", specifier = ">=0.6.9" }, -] - -[[package]] -name = "mcp-simple-auth" -version = "0.1.0" -source = { editable = "examples/servers/simple-auth" } -dependencies = [ - { name = "anyio" }, - { name = "click" }, - { name = "httpx" }, - { name = "mcp" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "sse-starlette" }, - { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "anyio", specifier = ">=4.5" }, - { name = "click", specifier = ">=8.2.0" }, - { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, - { name = "pydantic", specifier = ">=2.0" }, - { name = "pydantic-settings", specifier = ">=2.5.2" }, - { name = "sse-starlette", specifier = ">=1.6.1" }, - { name = "uvicorn", marker = "sys_platform != 'emscripten'", specifier = ">=0.23.1" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.391" }, - { name = "pytest", specifier = ">=8.3.4" }, - { name = "ruff", specifier = ">=0.8.5" }, -] - -[[package]] -name = "mcp-simple-auth-client" -version = "0.1.0" -source = { editable = "examples/clients/simple-auth-client" } -dependencies = [ - { name = "click" }, - { name = "mcp" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "click", specifier = ">=8.2.0" }, - { name = "mcp", editable = "." }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.379" }, - { name = "pytest", specifier = ">=8.3.3" }, - { name = "ruff", specifier = ">=0.6.9" }, -] - -[[package]] -name = "mcp-simple-chatbot" -version = "0.1.0" -source = { editable = "examples/clients/simple-chatbot" } -dependencies = [ - { name = "mcp" }, - { name = "python-dotenv" }, - { name = "uvicorn" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "mcp", editable = "." }, - { name = "python-dotenv", specifier = ">=1.0.0" }, - { name = "uvicorn", specifier = ">=0.32.1" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.379" }, - { name = "pytest", specifier = ">=8.3.3" }, - { name = "ruff", specifier = ">=0.6.9" }, -] - -[[package]] -name = "mcp-simple-pagination" -version = "0.1.0" -source = { editable = "examples/servers/simple-pagination" } -dependencies = [ - { name = "anyio" }, - { name = "click" }, - { name = "httpx" }, - { name = "mcp" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "anyio", specifier = ">=4.5" }, - { name = "click", specifier = ">=8.2.0" }, - { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.378" }, - { name = "pytest", specifier = ">=8.3.3" }, - { name = "ruff", specifier = ">=0.6.9" }, -] - -[[package]] -name = "mcp-simple-prompt" -version = "0.1.0" -source = { editable = "examples/servers/simple-prompt" } -dependencies = [ - { name = "anyio" }, - { name = "click" }, - { name = "httpx" }, - { name = "mcp" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "anyio", specifier = ">=4.5" }, - { name = "click", specifier = ">=8.2.0" }, - { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.378" }, - { name = "pytest", specifier = ">=8.3.3" }, - { name = "ruff", specifier = ">=0.6.9" }, -] - -[[package]] -name = "mcp-simple-resource" -version = "0.1.0" -source = { editable = "examples/servers/simple-resource" } -dependencies = [ - { name = "anyio" }, - { name = "click" }, - { name = "httpx" }, { name = "mcp" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "anyio", specifier = ">=4.5" }, - { name = "click", specifier = ">=8.2.0" }, - { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.378" }, - { name = "pytest", specifier = ">=8.3.3" }, - { name = "ruff", specifier = ">=0.6.9" }, -] - -[[package]] -name = "mcp-simple-streamablehttp" -version = "0.1.0" -source = { editable = "examples/servers/simple-streamablehttp" } -dependencies = [ - { name = "anyio" }, - { name = "click" }, - { name = "httpx" }, - { name = "mcp" }, - { name = "starlette" }, - { name = "uvicorn" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "anyio", specifier = ">=4.5" }, - { name = "click", specifier = ">=8.2.0" }, - { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, { name = "starlette" }, { name = "uvicorn" }, ] @@ -1294,74 +1051,15 @@ dev = [ ] [[package]] -name = "mcp-simple-streamablehttp-stateless" -version = "0.1.0" -source = { editable = "examples/servers/simple-streamablehttp-stateless" } +name = "mcp-example-stories" +version = "0.0.0" +source = { editable = "examples" } dependencies = [ - { name = "anyio" }, - { name = "click" }, - { name = "httpx" }, { name = "mcp" }, - { name = "starlette" }, - { name = "uvicorn" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, ] [package.metadata] -requires-dist = [ - { name = "anyio", specifier = ">=4.5" }, - { name = "click", specifier = ">=8.2.0" }, - { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, - { name = "starlette" }, - { name = "uvicorn" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.378" }, - { name = "pytest", specifier = ">=8.3.3" }, - { name = "ruff", specifier = ">=0.6.9" }, -] - -[[package]] -name = "mcp-simple-tool" -version = "0.1.0" -source = { editable = "examples/servers/simple-tool" } -dependencies = [ - { name = "anyio" }, - { name = "click" }, - { name = "httpx" }, - { name = "mcp" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "anyio", specifier = ">=4.5" }, - { name = "click", specifier = ">=8.2.0" }, - { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.378" }, - { name = "pytest", specifier = ">=8.3.3" }, - { name = "ruff", specifier = ">=0.6.9" }, -] +requires-dist = [{ name = "mcp", editable = "." }] [[package]] name = "mcp-snippets" @@ -1372,73 +1070,7 @@ dependencies = [ ] [package.metadata] -requires-dist = [{ name = "mcp", editable = "." }] - -[[package]] -name = "mcp-sse-polling-client" -version = "0.1.0" -source = { editable = "examples/clients/sse-polling-client" } -dependencies = [ - { name = "click" }, - { name = "mcp" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "click", specifier = ">=8.2.0" }, - { name = "mcp", editable = "." }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.378" }, - { name = "pytest", specifier = ">=8.3.3" }, - { name = "ruff", specifier = ">=0.6.9" }, -] - -[[package]] -name = "mcp-sse-polling-demo" -version = "0.1.0" -source = { editable = "examples/servers/sse-polling-demo" } -dependencies = [ - { name = "anyio" }, - { name = "click" }, - { name = "httpx" }, - { name = "mcp" }, - { name = "starlette" }, - { name = "uvicorn" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pyright" }, - { name = "pytest" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "anyio", specifier = ">=4.5" }, - { name = "click", specifier = ">=8.2.0" }, - { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, - { name = "starlette" }, - { name = "uvicorn" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pyright", specifier = ">=1.1.378" }, - { name = "pytest", specifier = ">=8.3.3" }, - { name = "ruff", specifier = ">=0.6.9" }, -] +requires-dist = [{ name = "mcp" }] [[package]] name = "mcp-structured-output-lowlevel" @@ -1449,7 +1081,7 @@ dependencies = [ ] [package.metadata] -requires-dist = [{ name = "mcp", editable = "." }] +requires-dist = [{ name = "mcp" }] [[package]] name = "mcp-types" From 376fa1184b0868c20a87888b8c89fcdf17378954 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:12:31 +0000 Subject: [PATCH 02/14] Remove examples superseded by stories/; update dangling references - Delete examples/clients/* and examples/servers/simple-*, sse-polling-demo (each has an equivalent under examples/stories/) - examples/mcpserver/*.py: add TODO pointers to the corresponding story - README.v2.md, examples/snippets/clients/oauth_client.py: update paths that pointed at the removed examples - src/mcp/server/auth/middleware/bearer_auth.py: drop pragma now that the bearer_auth story exercises this path --- README.v2.md | 13 +- examples/clients/simple-auth-client/README.md | 98 ---- .../mcp_simple_auth_client/__init__.py | 1 - .../mcp_simple_auth_client/main.py | 390 ---------------- .../clients/simple-auth-client/pyproject.toml | 43 -- .../clients/simple-chatbot/.python-version | 1 - examples/clients/simple-chatbot/README.MD | 113 ----- .../mcp_simple_chatbot/.env.example | 1 - .../simple-chatbot/mcp_simple_chatbot/main.py | 421 ------------------ .../mcp_simple_chatbot/requirements.txt | 4 - .../mcp_simple_chatbot/servers_config.json | 12 - .../simple-chatbot/mcp_simple_chatbot/test.db | Bin 8192 -> 0 bytes .../clients/simple-chatbot/pyproject.toml | 47 -- examples/clients/sse-polling-client/README.md | 30 -- .../mcp_sse_polling_client/__init__.py | 1 - .../mcp_sse_polling_client/main.py | 102 ----- .../clients/sse-polling-client/pyproject.toml | 36 -- examples/mcpserver/complex_inputs.py | 1 + examples/mcpserver/desktop.py | 1 + .../direct_call_tool_result_return.py | 1 + examples/mcpserver/simple_echo.py | 1 + examples/servers/simple-auth/README.md | 135 ------ .../simple-auth/mcp_simple_auth/__init__.py | 1 - .../simple-auth/mcp_simple_auth/__main__.py | 7 - .../mcp_simple_auth/auth_server.py | 185 -------- .../mcp_simple_auth/legacy_as_server.py | 137 ------ .../simple-auth/mcp_simple_auth/py.typed | 0 .../simple-auth/mcp_simple_auth/server.py | 161 ------- .../mcp_simple_auth/simple_auth_provider.py | 272 ----------- .../mcp_simple_auth/token_verifier.py | 108 ----- examples/servers/simple-auth/pyproject.toml | 33 -- examples/servers/simple-pagination/README.md | 77 ---- .../mcp_simple_pagination/__init__.py | 0 .../mcp_simple_pagination/__main__.py | 5 - .../mcp_simple_pagination/server.py | 176 -------- .../servers/simple-pagination/pyproject.toml | 43 -- .../servers/simple-prompt/.python-version | 1 - examples/servers/simple-prompt/README.md | 55 --- .../mcp_simple_prompt/__init__.py | 0 .../mcp_simple_prompt/__main__.py | 5 - .../simple-prompt/mcp_simple_prompt/server.py | 98 ---- examples/servers/simple-prompt/pyproject.toml | 43 -- .../servers/simple-resource/.python-version | 1 - examples/servers/simple-resource/README.md | 48 -- .../mcp_simple_resource/__init__.py | 0 .../mcp_simple_resource/__main__.py | 5 - .../mcp_simple_resource/server.py | 91 ---- .../servers/simple-resource/pyproject.toml | 43 -- .../simple-streamablehttp-stateless/README.md | 38 -- .../__init__.py | 0 .../__main__.py | 7 - .../server.py | 116 ----- .../pyproject.toml | 36 -- .../servers/simple-streamablehttp/README.md | 51 --- .../mcp_simple_streamablehttp/__init__.py | 0 .../mcp_simple_streamablehttp/__main__.py | 4 - .../mcp_simple_streamablehttp/event_store.py | 93 ---- .../mcp_simple_streamablehttp/server.py | 142 ------ .../simple-streamablehttp/pyproject.toml | 36 -- examples/servers/simple-tool/.python-version | 1 - examples/servers/simple-tool/README.md | 48 -- .../simple-tool/mcp_simple_tool/__init__.py | 0 .../simple-tool/mcp_simple_tool/__main__.py | 5 - .../simple-tool/mcp_simple_tool/server.py | 80 ---- examples/servers/simple-tool/pyproject.toml | 43 -- examples/servers/sse-polling-demo/README.md | 36 -- .../mcp_sse_polling_demo/__init__.py | 1 - .../mcp_sse_polling_demo/__main__.py | 6 - .../mcp_sse_polling_demo/event_store.py | 98 ---- .../mcp_sse_polling_demo/server.py | 160 ------- .../servers/sse-polling-demo/pyproject.toml | 36 -- examples/snippets/clients/oauth_client.py | 2 +- src/mcp/server/auth/middleware/bearer_auth.py | 5 +- 73 files changed, 12 insertions(+), 4079 deletions(-) delete mode 100644 examples/clients/simple-auth-client/README.md delete mode 100644 examples/clients/simple-auth-client/mcp_simple_auth_client/__init__.py delete mode 100644 examples/clients/simple-auth-client/mcp_simple_auth_client/main.py delete mode 100644 examples/clients/simple-auth-client/pyproject.toml delete mode 100644 examples/clients/simple-chatbot/.python-version delete mode 100644 examples/clients/simple-chatbot/README.MD delete mode 100644 examples/clients/simple-chatbot/mcp_simple_chatbot/.env.example delete mode 100644 examples/clients/simple-chatbot/mcp_simple_chatbot/main.py delete mode 100644 examples/clients/simple-chatbot/mcp_simple_chatbot/requirements.txt delete mode 100644 examples/clients/simple-chatbot/mcp_simple_chatbot/servers_config.json delete mode 100644 examples/clients/simple-chatbot/mcp_simple_chatbot/test.db delete mode 100644 examples/clients/simple-chatbot/pyproject.toml delete mode 100644 examples/clients/sse-polling-client/README.md delete mode 100644 examples/clients/sse-polling-client/mcp_sse_polling_client/__init__.py delete mode 100644 examples/clients/sse-polling-client/mcp_sse_polling_client/main.py delete mode 100644 examples/clients/sse-polling-client/pyproject.toml delete mode 100644 examples/servers/simple-auth/README.md delete mode 100644 examples/servers/simple-auth/mcp_simple_auth/__init__.py delete mode 100644 examples/servers/simple-auth/mcp_simple_auth/__main__.py delete mode 100644 examples/servers/simple-auth/mcp_simple_auth/auth_server.py delete mode 100644 examples/servers/simple-auth/mcp_simple_auth/legacy_as_server.py delete mode 100644 examples/servers/simple-auth/mcp_simple_auth/py.typed delete mode 100644 examples/servers/simple-auth/mcp_simple_auth/server.py delete mode 100644 examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py delete mode 100644 examples/servers/simple-auth/mcp_simple_auth/token_verifier.py delete mode 100644 examples/servers/simple-auth/pyproject.toml delete mode 100644 examples/servers/simple-pagination/README.md delete mode 100644 examples/servers/simple-pagination/mcp_simple_pagination/__init__.py delete mode 100644 examples/servers/simple-pagination/mcp_simple_pagination/__main__.py delete mode 100644 examples/servers/simple-pagination/mcp_simple_pagination/server.py delete mode 100644 examples/servers/simple-pagination/pyproject.toml delete mode 100644 examples/servers/simple-prompt/.python-version delete mode 100644 examples/servers/simple-prompt/README.md delete mode 100644 examples/servers/simple-prompt/mcp_simple_prompt/__init__.py delete mode 100644 examples/servers/simple-prompt/mcp_simple_prompt/__main__.py delete mode 100644 examples/servers/simple-prompt/mcp_simple_prompt/server.py delete mode 100644 examples/servers/simple-prompt/pyproject.toml delete mode 100644 examples/servers/simple-resource/.python-version delete mode 100644 examples/servers/simple-resource/README.md delete mode 100644 examples/servers/simple-resource/mcp_simple_resource/__init__.py delete mode 100644 examples/servers/simple-resource/mcp_simple_resource/__main__.py delete mode 100644 examples/servers/simple-resource/mcp_simple_resource/server.py delete mode 100644 examples/servers/simple-resource/pyproject.toml delete mode 100644 examples/servers/simple-streamablehttp-stateless/README.md delete mode 100644 examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__init__.py delete mode 100644 examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__main__.py delete mode 100644 examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py delete mode 100644 examples/servers/simple-streamablehttp-stateless/pyproject.toml delete mode 100644 examples/servers/simple-streamablehttp/README.md delete mode 100644 examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__init__.py delete mode 100644 examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__main__.py delete mode 100644 examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py delete mode 100644 examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py delete mode 100644 examples/servers/simple-streamablehttp/pyproject.toml delete mode 100644 examples/servers/simple-tool/.python-version delete mode 100644 examples/servers/simple-tool/README.md delete mode 100644 examples/servers/simple-tool/mcp_simple_tool/__init__.py delete mode 100644 examples/servers/simple-tool/mcp_simple_tool/__main__.py delete mode 100644 examples/servers/simple-tool/mcp_simple_tool/server.py delete mode 100644 examples/servers/simple-tool/pyproject.toml delete mode 100644 examples/servers/sse-polling-demo/README.md delete mode 100644 examples/servers/sse-polling-demo/mcp_sse_polling_demo/__init__.py delete mode 100644 examples/servers/sse-polling-demo/mcp_sse_polling_demo/__main__.py delete mode 100644 examples/servers/sse-polling-demo/mcp_sse_polling_demo/event_store.py delete mode 100644 examples/servers/sse-polling-demo/mcp_sse_polling_demo/server.py delete mode 100644 examples/servers/sse-polling-demo/pyproject.toml diff --git a/README.v2.md b/README.v2.md index b9896d9412..8590f64126 100644 --- a/README.v2.md +++ b/README.v2.md @@ -1058,7 +1058,7 @@ if __name__ == "__main__": _Full example: [examples/snippets/servers/oauth_server.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/oauth_server.py)_ -For a complete example with separate Authorization Server and Resource Server implementations, see [`examples/servers/simple-auth/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/servers/simple-auth/). +For a complete example with separate Authorization Server and Resource Server implementations, see [`examples/stories/oauth/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/stories/oauth/) (and [`examples/stories/bearer_auth/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/stories/bearer_auth/) for static-token auth). **Architecture:** @@ -1343,10 +1343,7 @@ app = Starlette( _Full example: [examples/snippets/servers/streamable_starlette_mount.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_starlette_mount.py)_ -For low level server with Streamable HTTP implementations, see: - -- Stateful server: [`examples/servers/simple-streamablehttp/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/servers/simple-streamablehttp/) -- Stateless server: [`examples/servers/simple-streamablehttp-stateless/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/servers/simple-streamablehttp-stateless/) +For low level server with Streamable HTTP implementations, see [`examples/stories/stateless_legacy/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/stories/stateless_legacy/). The streamable HTTP transport supports: @@ -2100,7 +2097,7 @@ _Full example: [examples/snippets/clients/pagination_client.py](https://github.c - **Backward compatible** - clients that don't support pagination will still work (they'll just get the first page) - **Flexible page sizes** - Each endpoint can define its own page size based on data characteristics -See the [simple-pagination example](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/servers/simple-pagination) for a complete implementation. +See the [pagination example](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/stories/pagination) for a complete implementation. ### Writing MCP Clients @@ -2320,7 +2317,7 @@ The SDK includes [authorization support](https://modelcontextprotocol.io/specifi ```python """Before running, specify running MCP RS server URL. To spin up RS server locally, see - examples/servers/simple-auth/README.md + examples/stories/oauth/README.md cd to the `examples/snippets` directory and run: uv run oauth-client @@ -2415,7 +2412,7 @@ if __name__ == "__main__": _Full example: [examples/snippets/clients/oauth_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/oauth_client.py)_ -For a complete working example, see [`examples/clients/simple-auth-client/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/clients/simple-auth-client/). +For a complete working example, see [`examples/stories/oauth/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/stories/oauth/). ### Parsing Tool Results diff --git a/examples/clients/simple-auth-client/README.md b/examples/clients/simple-auth-client/README.md deleted file mode 100644 index 708c0371b8..0000000000 --- a/examples/clients/simple-auth-client/README.md +++ /dev/null @@ -1,98 +0,0 @@ -# Simple Auth Client Example - -A demonstration of how to use the MCP Python SDK with OAuth authentication over streamable HTTP or SSE transport. - -## Features - -- OAuth 2.0 authentication with PKCE -- Support for both StreamableHTTP and SSE transports -- Interactive command-line interface - -## Installation - -```bash -cd examples/clients/simple-auth-client -uv sync --reinstall -``` - -## Usage - -### 1. Start an MCP server with OAuth support - -The simple-auth server example provides three server configurations. See [examples/servers/simple-auth/README.md](../../servers/simple-auth/README.md) for full details. - -#### Option A: New Architecture (Recommended) - -Separate Authorization Server and Resource Server: - -```bash -# Terminal 1: Start Authorization Server on port 9000 -cd examples/servers/simple-auth -uv run mcp-simple-auth-as --port=9000 - -# Terminal 2: Start Resource Server on port 8001 -cd examples/servers/simple-auth -uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http -``` - -#### Option B: Legacy Server (Backwards Compatibility) - -```bash -# Single server that acts as both AS and RS (port 8000) -cd examples/servers/simple-auth -uv run mcp-simple-auth-legacy --port=8000 --transport=streamable-http -``` - -### 2. Run the client - -```bash -# Connect to Resource Server (new architecture, default port 8001) -MCP_SERVER_PORT=8001 uv run mcp-simple-auth-client - -# Connect to Legacy Server (port 8000) -uv run mcp-simple-auth-client - -# Use SSE transport -MCP_SERVER_PORT=8001 MCP_TRANSPORT_TYPE=sse uv run mcp-simple-auth-client -``` - -### 3. Complete OAuth flow - -The client will open your browser for authentication. After completing OAuth, you can use commands: - -- `list` - List available tools -- `call [args]` - Call a tool with optional JSON arguments -- `quit` - Exit - -## Example - -```markdown -🚀 Simple MCP Auth Client -Connecting to: http://localhost:8001/mcp -Transport type: streamable-http - -🔗 Attempting to connect to http://localhost:8001/mcp... -📡 Opening StreamableHTTP transport connection with auth... -Opening browser for authorization: http://localhost:9000/authorize?... - -✅ Connected to MCP server at http://localhost:8001/mcp - -mcp> list -📋 Available tools: -1. get_time - Description: Get the current server time. - -mcp> call get_time -🔧 Tool 'get_time' result: -{"current_time": "2024-01-15T10:30:00", "timezone": "UTC", ...} - -mcp> quit -``` - -## Configuration - -| Environment Variable | Description | Default | -|---------------------|-------------|---------| -| `MCP_SERVER_PORT` | Port number of the MCP server | `8000` | -| `MCP_TRANSPORT_TYPE` | Transport type: `streamable-http` or `sse` | `streamable-http` | -| `MCP_CLIENT_METADATA_URL` | Optional URL for client metadata (CIMD) | None | diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/__init__.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/__init__.py deleted file mode 100644 index 06eb1f29d1..0000000000 --- a/examples/clients/simple-auth-client/mcp_simple_auth_client/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Simple OAuth client for MCP simple-auth server.""" diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py deleted file mode 100644 index 0d461d5d11..0000000000 --- a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py +++ /dev/null @@ -1,390 +0,0 @@ -#!/usr/bin/env python3 -"""Simple MCP client example with OAuth authentication support. - -This client connects to an MCP server using streamable HTTP transport with OAuth. - -""" - -from __future__ import annotations as _annotations - -import asyncio -import os -import socketserver -import threading -import time -import webbrowser -from http.server import BaseHTTPRequestHandler, HTTPServer -from typing import Any -from urllib.parse import parse_qs, urlparse - -import httpx -from mcp.client._transport import ReadStream, WriteStream -from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider, TokenStorage -from mcp.client.session import ClientSession -from mcp.client.sse import sse_client -from mcp.client.streamable_http import streamable_http_client -from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken -from mcp.shared.message import SessionMessage - - -class InMemoryTokenStorage(TokenStorage): - """Simple in-memory token storage implementation.""" - - def __init__(self): - self._tokens: OAuthToken | None = None - self._client_info: OAuthClientInformationFull | None = None - - async def get_tokens(self) -> OAuthToken | None: - return self._tokens - - async def set_tokens(self, tokens: OAuthToken) -> None: - self._tokens = tokens - - async def get_client_info(self) -> OAuthClientInformationFull | None: - return self._client_info - - async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: - self._client_info = client_info - - -class CallbackHandler(BaseHTTPRequestHandler): - """Simple HTTP handler to capture OAuth callback.""" - - def __init__( - self, - request: Any, - client_address: tuple[str, int], - server: socketserver.BaseServer, - callback_data: dict[str, Any], - ): - """Initialize with callback data storage.""" - self.callback_data = callback_data - super().__init__(request, client_address, server) - - def do_GET(self): - """Handle GET request from OAuth redirect.""" - parsed = urlparse(self.path) - query_params = parse_qs(parsed.query) - - if "code" in query_params: - self.callback_data["authorization_code"] = query_params["code"][0] - self.callback_data["state"] = query_params.get("state", [None])[0] - self.callback_data["iss"] = query_params.get("iss", [None])[0] - self.send_response(200) - self.send_header("Content-type", "text/html") - self.end_headers() - self.wfile.write(b""" - - -

Authorization Successful!

-

You can close this window and return to the terminal.

- - - - """) - elif "error" in query_params: - self.callback_data["error"] = query_params["error"][0] - self.send_response(400) - self.send_header("Content-type", "text/html") - self.end_headers() - self.wfile.write( - f""" - - -

Authorization Failed

-

Error: {query_params["error"][0]}

-

You can close this window and return to the terminal.

- - - """.encode() - ) - else: - self.send_response(404) - self.end_headers() - - def log_message(self, format: str, *args: Any): - """Suppress default logging.""" - - -class CallbackServer: - """Simple server to handle OAuth callbacks.""" - - def __init__(self, port: int = 3000): - self.port = port - self.server = None - self.thread = None - self.callback_data = {"authorization_code": None, "state": None, "iss": None, "error": None} - - def _create_handler_with_data(self): - """Create a handler class with access to callback data.""" - callback_data = self.callback_data - - class DataCallbackHandler(CallbackHandler): - def __init__( - self, - request: BaseHTTPRequestHandler, - client_address: tuple[str, int], - server: socketserver.BaseServer, - ): - super().__init__(request, client_address, server, callback_data) - - return DataCallbackHandler - - def start(self): - """Start the callback server in a background thread.""" - handler_class = self._create_handler_with_data() - self.server = HTTPServer(("localhost", self.port), handler_class) - self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) - self.thread.start() - print(f"🖥️ Started callback server on http://localhost:{self.port}") - - def stop(self): - """Stop the callback server.""" - if self.server: - self.server.shutdown() - self.server.server_close() - if self.thread: - self.thread.join(timeout=1) - - def wait_for_callback(self, timeout: int = 300): - """Wait for OAuth callback with timeout.""" - start_time = time.time() - while time.time() - start_time < timeout: - if self.callback_data["authorization_code"]: - return self.callback_data["authorization_code"] - elif self.callback_data["error"]: - raise Exception(f"OAuth error: {self.callback_data['error']}") - time.sleep(0.1) - raise Exception("Timeout waiting for OAuth callback") - - @property - def state(self): - """The received state parameter.""" - return self.callback_data["state"] - - @property - def iss(self): - """The received iss parameter.""" - return self.callback_data["iss"] - - -class SimpleAuthClient: - """Simple MCP client with auth support.""" - - def __init__( - self, - server_url: str, - transport_type: str = "streamable-http", - client_metadata_url: str | None = None, - ): - self.server_url = server_url - self.transport_type = transport_type - self.client_metadata_url = client_metadata_url - self.session: ClientSession | None = None - - async def connect(self): - """Connect to the MCP server.""" - print(f"🔗 Attempting to connect to {self.server_url}...") - - try: - callback_server = CallbackServer(port=3030) - callback_server.start() - - async def callback_handler() -> AuthorizationCodeResult: - """Wait for OAuth callback and return auth code, state, and iss.""" - print("⏳ Waiting for authorization callback...") - try: - auth_code = callback_server.wait_for_callback(timeout=300) - return AuthorizationCodeResult(code=auth_code, state=callback_server.state, iss=callback_server.iss) - finally: - callback_server.stop() - - client_metadata_dict = { - "client_name": "Simple Auth Client", - "redirect_uris": ["http://localhost:3030/callback"], - "grant_types": ["authorization_code", "refresh_token"], - "response_types": ["code"], - } - - async def _default_redirect_handler(authorization_url: str) -> None: - """Default redirect handler that opens the URL in a browser.""" - print(f"Opening browser for authorization: {authorization_url}") - webbrowser.open(authorization_url) - - # Create OAuth authentication handler using the new interface - # Use client_metadata_url to enable CIMD when the server supports it - oauth_auth = OAuthClientProvider( - server_url=self.server_url.replace("/mcp", ""), - client_metadata=OAuthClientMetadata.model_validate(client_metadata_dict), - storage=InMemoryTokenStorage(), - redirect_handler=_default_redirect_handler, - callback_handler=callback_handler, - client_metadata_url=self.client_metadata_url, - ) - - # Create transport with auth handler based on transport type - if self.transport_type == "sse": - print("📡 Opening SSE transport connection with auth...") - async with sse_client( - url=self.server_url, - auth=oauth_auth, - timeout=60.0, - ) as (read_stream, write_stream): - await self._run_session(read_stream, write_stream) - else: - print("📡 Opening StreamableHTTP transport connection with auth...") - async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: - async with streamable_http_client(url=self.server_url, http_client=custom_client) as ( - read_stream, - write_stream, - ): - await self._run_session(read_stream, write_stream) - - except Exception as e: - print(f"❌ Failed to connect: {e}") - import traceback - - traceback.print_exc() - - async def _run_session( - self, - read_stream: ReadStream[SessionMessage | Exception], - write_stream: WriteStream[SessionMessage], - ): - """Run the MCP session with the given streams.""" - print("🤝 Initializing MCP session...") - async with ClientSession(read_stream, write_stream) as session: - self.session = session - print("⚡ Starting session initialization...") - await session.initialize() - print("✨ Session initialization complete!") - - print(f"\n✅ Connected to MCP server at {self.server_url}") - - # Run interactive loop - await self.interactive_loop() - - async def list_tools(self): - """List available tools from the server.""" - if not self.session: - print("❌ Not connected to server") - return - - try: - result = await self.session.list_tools() - if hasattr(result, "tools") and result.tools: - print("\n📋 Available tools:") - for i, tool in enumerate(result.tools, 1): - print(f"{i}. {tool.name}") - if tool.description: - print(f" Description: {tool.description}") - print() - else: - print("No tools available") - except Exception as e: - print(f"❌ Failed to list tools: {e}") - - async def call_tool(self, tool_name: str, arguments: dict[str, Any] | None = None): - """Call a specific tool.""" - if not self.session: - print("❌ Not connected to server") - return - - try: - result = await self.session.call_tool(tool_name, arguments or {}) - print(f"\n🔧 Tool '{tool_name}' result:") - if hasattr(result, "content"): - for content in result.content: - if content.type == "text": - print(content.text) - else: - print(content) - else: - print(result) - except Exception as e: - print(f"❌ Failed to call tool '{tool_name}': {e}") - - async def interactive_loop(self): - """Run interactive command loop.""" - print("\n🎯 Interactive MCP Client") - print("Commands:") - print(" list - List available tools") - print(" call [args] - Call a tool") - print(" quit - Exit the client") - print() - - while True: - try: - command = input("mcp> ").strip() - - if not command: - continue - - if command == "quit": - break - - elif command == "list": - await self.list_tools() - - elif command.startswith("call "): - parts = command.split(maxsplit=2) - tool_name = parts[1] if len(parts) > 1 else "" - - if not tool_name: - print("❌ Please specify a tool name") - continue - - # Parse arguments (simple JSON-like format) - arguments: dict[str, Any] = {} - if len(parts) > 2: - import json - - try: - arguments = json.loads(parts[2]) - except json.JSONDecodeError: - print("❌ Invalid arguments format (expected JSON)") - continue - - await self.call_tool(tool_name, arguments) - - else: - print("❌ Unknown command. Try 'list', 'call ', or 'quit'") - - except KeyboardInterrupt: - print("\n\n👋 Goodbye!") - break - except EOFError: - break - - -async def main(): - """Main entry point.""" - # Default server URL - can be overridden with environment variable - # Most MCP streamable HTTP servers use /mcp as the endpoint - server_url = os.getenv("MCP_SERVER_PORT", 8000) - transport_type = os.getenv("MCP_TRANSPORT_TYPE", "streamable-http") - client_metadata_url = os.getenv("MCP_CLIENT_METADATA_URL") - server_url = ( - f"http://localhost:{server_url}/mcp" - if transport_type == "streamable-http" - else f"http://localhost:{server_url}/sse" - ) - - print("🚀 Simple MCP Auth Client") - print(f"Connecting to: {server_url}") - print(f"Transport type: {transport_type}") - if client_metadata_url: - print(f"Client metadata URL: {client_metadata_url}") - - # Start connection flow - OAuth will be handled automatically - client = SimpleAuthClient(server_url, transport_type, client_metadata_url) - await client.connect() - - -def cli(): - """CLI entry point for uv script.""" - asyncio.run(main()) - - -if __name__ == "__main__": - cli() diff --git a/examples/clients/simple-auth-client/pyproject.toml b/examples/clients/simple-auth-client/pyproject.toml deleted file mode 100644 index f84d1430fe..0000000000 --- a/examples/clients/simple-auth-client/pyproject.toml +++ /dev/null @@ -1,43 +0,0 @@ -[project] -name = "mcp-simple-auth-client" -version = "0.1.0" -description = "A simple OAuth client for the MCP simple-auth server" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] -keywords = ["mcp", "oauth", "client", "auth"] -license = { text = "MIT" } -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", -] -dependencies = ["click>=8.2.0", "mcp"] - -[project.scripts] -mcp-simple-auth-client = "mcp_simple_auth_client.main:cli" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_simple_auth_client"] - -[tool.pyright] -include = ["mcp_simple_auth_client"] -venvPath = "." -venv = ".venv" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = [] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[dependency-groups] -dev = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/clients/simple-chatbot/.python-version b/examples/clients/simple-chatbot/.python-version deleted file mode 100644 index c8cfe39591..0000000000 --- a/examples/clients/simple-chatbot/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.10 diff --git a/examples/clients/simple-chatbot/README.MD b/examples/clients/simple-chatbot/README.MD deleted file mode 100644 index 482109f97b..0000000000 --- a/examples/clients/simple-chatbot/README.MD +++ /dev/null @@ -1,113 +0,0 @@ -# MCP Simple Chatbot - -This example demonstrates how to integrate the Model Context Protocol (MCP) into a simple CLI chatbot. The implementation showcases MCP's flexibility by supporting multiple tools through MCP servers and is compatible with any LLM provider that follows OpenAI API standards. - -## Requirements - -- Python 3.10 -- `python-dotenv` -- `requests` -- `mcp` -- `uvicorn` - -## Installation - -1. **Install the dependencies:** - - ```bash - pip install -r requirements.txt - ``` - -2. **Set up environment variables:** - - Create a `.env` file in the root directory and add your API key: - - ```plaintext - LLM_API_KEY=your_api_key_here - ``` - - **Note:** The current implementation is configured to use the Groq API endpoint (`https://api.groq.com/openai/v1/chat/completions`) with the `llama-3.2-90b-vision-preview` model. If you plan to use a different LLM provider, you'll need to modify the `LLMClient` class in `main.py` to use the appropriate endpoint URL and model parameters. - -3. **Configure servers:** - - The `servers_config.json` follows the same structure as Claude Desktop, allowing for easy integration of multiple servers. - Here's an example: - - ```json - { - "mcpServers": { - "sqlite": { - "command": "uvx", - "args": ["mcp-server-sqlite", "--db-path", "./test.db"] - }, - "puppeteer": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-puppeteer"] - } - } - } - ``` - - Environment variables are supported as well. Pass them as you would with the Claude Desktop App. - - Example: - - ```json - { - "mcpServers": { - "server_name": { - "command": "uvx", - "args": ["mcp-server-name", "--additional-args"], - "env": { - "API_KEY": "your_api_key_here" - } - } - } - } - ``` - -## Usage - -1. **Run the client:** - - ```bash - python main.py - ``` - -2. **Interact with the assistant:** - - The assistant will automatically detect available tools and can respond to queries based on the tools provided by the configured servers. - -3. **Exit the session:** - - Type `quit` or `exit` to end the session. - -## Architecture - -- **Tool Discovery**: Tools are automatically discovered from configured servers. -- **System Prompt**: Tools are dynamically included in the system prompt, allowing the LLM to understand available capabilities. -- **Server Integration**: Supports any MCP-compatible server, tested with various server implementations including Uvicorn and Node.js. - -### Class Structure - -- **Configuration**: Manages environment variables and server configurations -- **Server**: Handles MCP server initialization, tool discovery, and execution -- **Tool**: Represents individual tools with their properties and formatting -- **LLMClient**: Manages communication with the LLM provider -- **ChatSession**: Orchestrates the interaction between user, LLM, and tools - -### Logic Flow - -1. **Tool Integration**: - - Tools are dynamically discovered from MCP servers - - Tool descriptions are automatically included in system prompt - - Tool execution is handled through standardized MCP protocol - -2. **Runtime Flow**: - - User input is received - - Input is sent to LLM with context of available tools - - LLM response is parsed: - - If it's a tool call → execute tool and return result - - If it's a direct response → return to user - - Tool results are sent back to LLM for interpretation - - Final response is presented to user diff --git a/examples/clients/simple-chatbot/mcp_simple_chatbot/.env.example b/examples/clients/simple-chatbot/mcp_simple_chatbot/.env.example deleted file mode 100644 index 39be363c20..0000000000 --- a/examples/clients/simple-chatbot/mcp_simple_chatbot/.env.example +++ /dev/null @@ -1 +0,0 @@ -LLM_API_KEY=gsk_1234567890 diff --git a/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py b/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py deleted file mode 100644 index 72b1a6f204..0000000000 --- a/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py +++ /dev/null @@ -1,421 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -import logging -import os -import shutil -from contextlib import AsyncExitStack -from typing import Any - -import httpx -from dotenv import load_dotenv -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client - -# Configure logging -logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") - - -class Configuration: - """Manages configuration and environment variables for the MCP client.""" - - def __init__(self) -> None: - """Initialize configuration with environment variables.""" - self.load_env() - self.api_key = os.getenv("LLM_API_KEY") - - @staticmethod - def load_env() -> None: - """Load environment variables from .env file.""" - load_dotenv() - - @staticmethod - def load_config(file_path: str) -> dict[str, Any]: - """Load server configuration from JSON file. - - Args: - file_path: Path to the JSON configuration file. - - Returns: - Dict containing server configuration. - - Raises: - FileNotFoundError: If configuration file doesn't exist. - JSONDecodeError: If configuration file is invalid JSON. - """ - with open(file_path, "r") as f: - return json.load(f) - - @property - def llm_api_key(self) -> str: - """Get the LLM API key. - - Returns: - The API key as a string. - - Raises: - ValueError: If the API key is not found in environment variables. - """ - if not self.api_key: - raise ValueError("LLM_API_KEY not found in environment variables") - return self.api_key - - -class Server: - """Manages MCP server connections and tool execution.""" - - def __init__(self, name: str, config: dict[str, Any]) -> None: - self.name: str = name - self.config: dict[str, Any] = config - self.stdio_context: Any | None = None - self.session: ClientSession | None = None - self._cleanup_lock: asyncio.Lock = asyncio.Lock() - self.exit_stack: AsyncExitStack = AsyncExitStack() - - async def initialize(self) -> None: - """Initialize the server connection.""" - command = shutil.which("npx") if self.config["command"] == "npx" else self.config["command"] - if command is None: - raise ValueError("The command must be a valid string and cannot be None.") - - server_params = StdioServerParameters( - command=command, - args=self.config["args"], - env={**os.environ, **self.config["env"]} if self.config.get("env") else None, - ) - try: - stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params)) - read, write = stdio_transport - session = await self.exit_stack.enter_async_context(ClientSession(read, write)) - await session.initialize() - self.session = session - except Exception as e: - logging.error(f"Error initializing server {self.name}: {e}") - await self.cleanup() - raise - - async def list_tools(self) -> list[Tool]: - """List available tools from the server. - - Returns: - A list of available tools. - - Raises: - RuntimeError: If the server is not initialized. - """ - if not self.session: - raise RuntimeError(f"Server {self.name} not initialized") - - tools_response = await self.session.list_tools() - tools: list[Tool] = [] - - for item in tools_response: - if item[0] == "tools": - tools.extend(Tool(tool.name, tool.description, tool.input_schema, tool.title) for tool in item[1]) - - return tools - - async def execute_tool( - self, - tool_name: str, - arguments: dict[str, Any], - retries: int = 2, - delay: float = 1.0, - ) -> Any: - """Execute a tool with retry mechanism. - - Args: - tool_name: Name of the tool to execute. - arguments: Tool arguments. - retries: Number of retry attempts. - delay: Delay between retries in seconds. - - Returns: - Tool execution result. - - Raises: - RuntimeError: If server is not initialized. - Exception: If tool execution fails after all retries. - """ - if not self.session: - raise RuntimeError(f"Server {self.name} not initialized") - - attempt = 0 - while attempt < retries: - try: - logging.info(f"Executing {tool_name}...") - result = await self.session.call_tool(tool_name, arguments) - - return result - - except Exception as e: - attempt += 1 - logging.warning(f"Error executing tool: {e}. Attempt {attempt} of {retries}.") - if attempt < retries: - logging.info(f"Retrying in {delay} seconds...") - await asyncio.sleep(delay) - else: - logging.error("Max retries reached. Failing.") - raise - - async def cleanup(self) -> None: - """Clean up server resources.""" - async with self._cleanup_lock: - try: - await self.exit_stack.aclose() - self.session = None - self.stdio_context = None - except Exception as e: - logging.error(f"Error during cleanup of server {self.name}: {e}") - - -class Tool: - """Represents a tool with its properties and formatting.""" - - def __init__( - self, - name: str, - description: str, - input_schema: dict[str, Any], - title: str | None = None, - ) -> None: - self.name: str = name - self.title: str | None = title - self.description: str = description - self.input_schema: dict[str, Any] = input_schema - - def format_for_llm(self) -> str: - """Format tool information for LLM. - - Returns: - A formatted string describing the tool. - """ - args_desc: list[str] = [] - if "properties" in self.input_schema: - for param_name, param_info in self.input_schema["properties"].items(): - arg_desc = f"- {param_name}: {param_info.get('description', 'No description')}" - if param_name in self.input_schema.get("required", []): - arg_desc += " (required)" - args_desc.append(arg_desc) - - # Build the formatted output with title as a separate field - output = f"Tool: {self.name}\n" - - # Add human-readable title if available - if self.title: - output += f"User-readable title: {self.title}\n" - - output += f"""Description: {self.description} -Arguments: -{chr(10).join(args_desc)} -""" - - return output - - -class LLMClient: - """Manages communication with the LLM provider.""" - - def __init__(self, api_key: str) -> None: - self.api_key: str = api_key - - def get_response(self, messages: list[dict[str, str]]) -> str: - """Get a response from the LLM. - - Args: - messages: A list of message dictionaries. - - Returns: - The LLM's response as a string. - - Raises: - httpx.RequestError: If the request to the LLM fails. - """ - url = "https://api.groq.com/openai/v1/chat/completions" - - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {self.api_key}", - } - payload = { - "messages": messages, - "model": "meta-llama/llama-4-scout-17b-16e-instruct", - "temperature": 0.7, - "max_tokens": 4096, - "top_p": 1, - "stream": False, - "stop": None, - } - - try: - with httpx.Client() as client: - response = client.post(url, headers=headers, json=payload) - response.raise_for_status() - data = response.json() - return data["choices"][0]["message"]["content"] - - except httpx.RequestError as e: - error_message = f"Error getting LLM response: {str(e)}" - logging.error(error_message) - - if isinstance(e, httpx.HTTPStatusError): - status_code = e.response.status_code - logging.error(f"Status code: {status_code}") - logging.error(f"Response details: {e.response.text}") - - return f"I encountered an error: {error_message}. Please try again or rephrase your request." - - -class ChatSession: - """Orchestrates the interaction between user, LLM, and tools.""" - - def __init__(self, servers: list[Server], llm_client: LLMClient) -> None: - self.servers: list[Server] = servers - self.llm_client: LLMClient = llm_client - - async def cleanup_servers(self) -> None: - """Clean up all servers properly.""" - for server in reversed(self.servers): - try: - await server.cleanup() - except Exception as e: - logging.warning(f"Warning during final cleanup: {e}") - - async def process_llm_response(self, llm_response: str) -> str: - """Process the LLM response and execute tools if needed. - - Args: - llm_response: The response from the LLM. - - Returns: - The result of tool execution or the original response. - """ - import json - - def _clean_json_string(json_string: str) -> str: - """Remove ```json ... ``` or ``` ... ``` wrappers if the LLM response is fenced.""" - import re - - pattern = r"^```(?:\s*json)?\s*(.*?)\s*```$" - return re.sub(pattern, r"\1", json_string, flags=re.DOTALL | re.IGNORECASE).strip() - - try: - tool_call = json.loads(_clean_json_string(llm_response)) - if "tool" in tool_call and "arguments" in tool_call: - logging.info(f"Executing tool: {tool_call['tool']}") - logging.info(f"With arguments: {tool_call['arguments']}") - - for server in self.servers: - tools = await server.list_tools() - if any(tool.name == tool_call["tool"] for tool in tools): - try: - result = await server.execute_tool(tool_call["tool"], tool_call["arguments"]) - - if isinstance(result, dict) and "progress" in result: - progress = result["progress"] # type: ignore - total = result["total"] # type: ignore - percentage = (progress / total) * 100 # type: ignore - logging.info(f"Progress: {progress}/{total} ({percentage:.1f}%)") - - return f"Tool execution result: {result}" - except Exception as e: - error_msg = f"Error executing tool: {str(e)}" - logging.error(error_msg) - return error_msg - - return f"No server found with tool: {tool_call['tool']}" - return llm_response - except json.JSONDecodeError: - return llm_response - - async def start(self) -> None: - """Main chat session handler.""" - try: - for server in self.servers: - try: - await server.initialize() - except Exception as e: - logging.error(f"Failed to initialize server: {e}") - await self.cleanup_servers() - return - - all_tools: list[Tool] = [] - for server in self.servers: - tools = await server.list_tools() - all_tools.extend(tools) - - tools_description = "\n".join([tool.format_for_llm() for tool in all_tools]) - - system_message = ( - "You are a helpful assistant with access to these tools:\n\n" - f"{tools_description}\n" - "Choose the appropriate tool based on the user's question. " - "If no tool is needed, reply directly.\n\n" - "IMPORTANT: When you need to use a tool, you must ONLY respond with " - "the exact JSON object format below, nothing else:\n" - "{\n" - ' "tool": "tool-name",\n' - ' "arguments": {\n' - ' "argument-name": "value"\n' - " }\n" - "}\n\n" - "After receiving a tool's response:\n" - "1. Transform the raw data into a natural, conversational response\n" - "2. Keep responses concise but informative\n" - "3. Focus on the most relevant information\n" - "4. Use appropriate context from the user's question\n" - "5. Avoid simply repeating the raw data\n\n" - "Please use only the tools that are explicitly defined above." - ) - - messages = [{"role": "system", "content": system_message}] - - while True: - try: - user_input = input("You: ").strip().lower() - if user_input in ["quit", "exit"]: - logging.info("\nExiting...") - break - - messages.append({"role": "user", "content": user_input}) - - llm_response = self.llm_client.get_response(messages) - logging.info("\nAssistant: %s", llm_response) - - result = await self.process_llm_response(llm_response) - - if result != llm_response: - messages.append({"role": "assistant", "content": llm_response}) - messages.append({"role": "system", "content": result}) - - final_response = self.llm_client.get_response(messages) - logging.info("\nFinal response: %s", final_response) - messages.append({"role": "assistant", "content": final_response}) - else: - messages.append({"role": "assistant", "content": llm_response}) - - except KeyboardInterrupt: - logging.info("\nExiting...") - break - - finally: - await self.cleanup_servers() - - -async def run() -> None: - """Initialize and run the chat session.""" - config = Configuration() - server_config = config.load_config("servers_config.json") - servers = [Server(name, srv_config) for name, srv_config in server_config["mcpServers"].items()] - llm_client = LLMClient(config.llm_api_key) - chat_session = ChatSession(servers, llm_client) - await chat_session.start() - - -def main() -> None: - asyncio.run(run()) - - -if __name__ == "__main__": - main() diff --git a/examples/clients/simple-chatbot/mcp_simple_chatbot/requirements.txt b/examples/clients/simple-chatbot/mcp_simple_chatbot/requirements.txt deleted file mode 100644 index 2292072ffa..0000000000 --- a/examples/clients/simple-chatbot/mcp_simple_chatbot/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -python-dotenv>=1.0.0 -requests>=2.31.0 -mcp>=1.0.0 -uvicorn>=0.32.1 diff --git a/examples/clients/simple-chatbot/mcp_simple_chatbot/servers_config.json b/examples/clients/simple-chatbot/mcp_simple_chatbot/servers_config.json deleted file mode 100644 index 3a92d05d1e..0000000000 --- a/examples/clients/simple-chatbot/mcp_simple_chatbot/servers_config.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "mcpServers": { - "sqlite": { - "command": "uvx", - "args": ["mcp-server-sqlite", "--db-path", "./test.db"] - }, - "puppeteer": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-puppeteer"] - } - } -} diff --git a/examples/clients/simple-chatbot/mcp_simple_chatbot/test.db b/examples/clients/simple-chatbot/mcp_simple_chatbot/test.db deleted file mode 100644 index d08dabc936040dd0098f4586d03152f7c6210c6f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8192 zcmeI#OK;OK8~|{qco+&xwyse2sOD)x+r(psT_&M{yESX4b*OC$V+bVN#9PCfhn%z$ zLt^5<2?;Ly25{hjcIJYRkoX2jaNQNSazPryNjk2ak!&S#;=liX{Bp8UtJopKn;v&) zi08lnP!vEg1^}dbx2WQxh$rm5M^*IwuaW{+yU+VYFGbq};*v!QNC7Dz1*Cu!kOERb z3P=GdAO)m=6zB;&R8TITSDsFVwAp4p_pFW?2FG=3sje3qI&KseD>^=I#nWkwZ40m5 zZ0IGuj;r;RwL*OZuj(6@MZ~2J!wvmTBl_^UZ89uIsibG3vw2oUK>MM6)jnx&wHKPN z)wFpnul1?l)X(aB^^LlxcGbJ;b#+2?ltl_i0VyB_q<|EV0#ZNN^BPTYDBH7&C&+Z(QM~5J|gqH2lWkG-&oSNH=llw>arhk5XIg){329>B|yIZ)- zs1>k~m`8Eq$Y2x`30>C41&jKj$h&svzxtA17#o1#3NlR2n2W323z->uoJ{^YDD7t- zXEX?g(K>6I)FHLqc%~s0f>CtKSj6Q{laznIt9^eqey$&aakS=j0*0&9A~Q*T_ACT> zw8|beJ<2We;6}Wj;WH3Sp^9F_MHX!13Uz$4oQ!im4Z#Fjvt1h(x#u#%KZr&8uZK^^ zDPrlKkBv5CT}BE|;;D0|AecnemgtR*kh&I`O(xGH2xie@yTd}y3tQOm8I8n@L~QJ2 zwDZ5vOD)Q`M0CFA(icV7ZO+<}woZAoV+G`PVvvK;oQ6Yrh}UUown!_X9y|d-4wdX3 e$0JM0mNKcxutcpb@kbVt(KAZyYR7GJN!uSOx7u9* diff --git a/examples/clients/simple-chatbot/pyproject.toml b/examples/clients/simple-chatbot/pyproject.toml deleted file mode 100644 index 2d7205735a..0000000000 --- a/examples/clients/simple-chatbot/pyproject.toml +++ /dev/null @@ -1,47 +0,0 @@ -[project] -name = "mcp-simple-chatbot" -version = "0.1.0" -description = "A simple CLI chatbot using the Model Context Protocol (MCP)" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] -keywords = ["mcp", "llm", "chatbot", "cli"] -license = { text = "MIT" } -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", -] -dependencies = [ - "python-dotenv>=1.0.0", - "mcp", - "uvicorn>=0.32.1", -] - -[project.scripts] -mcp-simple-chatbot = "mcp_simple_chatbot.main:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_simple_chatbot"] - -[tool.pyright] -include = ["mcp_simple_chatbot"] -venvPath = "." -venv = ".venv" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = [] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[dependency-groups] -dev = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/clients/sse-polling-client/README.md b/examples/clients/sse-polling-client/README.md deleted file mode 100644 index 78449aa832..0000000000 --- a/examples/clients/sse-polling-client/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# MCP SSE Polling Demo Client - -Demonstrates client-side auto-reconnect for the SSE polling pattern (SEP-1699). - -## Features - -- Connects to SSE polling demo server -- Automatically reconnects when server closes SSE stream -- Resumes from Last-Event-ID to avoid missing messages -- Respects server-provided retry interval - -## Usage - -```bash -# First start the server: -uv run mcp-sse-polling-demo --port 3000 - -# Then run this client: -uv run mcp-sse-polling-client --url http://localhost:3000/mcp - -# Custom options: -uv run mcp-sse-polling-client --url http://localhost:3000/mcp --items 20 --checkpoint-every 5 -``` - -## Options - -- `--url`: Server URL (default: ) -- `--items`: Number of items to process (default: 10) -- `--checkpoint-every`: Checkpoint interval (default: 3) -- `--log-level`: Logging level (default: DEBUG) diff --git a/examples/clients/sse-polling-client/mcp_sse_polling_client/__init__.py b/examples/clients/sse-polling-client/mcp_sse_polling_client/__init__.py deleted file mode 100644 index ee69b32c96..0000000000 --- a/examples/clients/sse-polling-client/mcp_sse_polling_client/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""SSE Polling Demo Client - demonstrates auto-reconnect for long-running tasks.""" diff --git a/examples/clients/sse-polling-client/mcp_sse_polling_client/main.py b/examples/clients/sse-polling-client/mcp_sse_polling_client/main.py deleted file mode 100644 index e91ed9d527..0000000000 --- a/examples/clients/sse-polling-client/mcp_sse_polling_client/main.py +++ /dev/null @@ -1,102 +0,0 @@ -"""SSE Polling Demo Client - -Demonstrates the client-side auto-reconnect for SSE polling pattern. - -This client connects to the SSE Polling Demo server and calls process_batch, -which triggers periodic server-side stream closes. The client automatically -reconnects using Last-Event-ID and resumes receiving messages. - -Run with: - # First start the server: - uv run mcp-sse-polling-demo --port 3000 - - # Then run this client: - uv run mcp-sse-polling-client --url http://localhost:3000/mcp -""" - -import asyncio -import logging - -import click -from mcp import ClientSession -from mcp.client.streamable_http import streamable_http_client - - -async def run_demo(url: str, items: int, checkpoint_every: int) -> None: - """Run the SSE polling demo.""" - print(f"\n{'=' * 60}") - print("SSE Polling Demo Client") - print(f"{'=' * 60}") - print(f"Server URL: {url}") - print(f"Processing {items} items with checkpoints every {checkpoint_every}") - print(f"{'=' * 60}\n") - - async with streamable_http_client(url) as (read_stream, write_stream): - async with ClientSession(read_stream, write_stream) as session: - # Initialize the connection - print("Initializing connection...") - await session.initialize() - print("Connected!\n") - - # List available tools - tools = await session.list_tools() - print(f"Available tools: {[t.name for t in tools.tools]}\n") - - # Call the process_batch tool - print(f"Calling process_batch(items={items}, checkpoint_every={checkpoint_every})...\n") - print("-" * 40) - - result = await session.call_tool( - "process_batch", - { - "items": items, - "checkpoint_every": checkpoint_every, - }, - ) - - print("-" * 40) - if result.content: - content = result.content[0] - text = getattr(content, "text", str(content)) - print(f"\nResult: {text}") - else: - print("\nResult: No content") - print(f"{'=' * 60}\n") - - -@click.command() -@click.option( - "--url", - default="http://localhost:3000/mcp", - help="Server URL", -) -@click.option( - "--items", - default=10, - help="Number of items to process", -) -@click.option( - "--checkpoint-every", - default=3, - help="Checkpoint interval", -) -@click.option( - "--log-level", - default="INFO", - help="Logging level", -) -def main(url: str, items: int, checkpoint_every: int, log_level: str) -> None: - """Run the SSE Polling Demo client.""" - logging.basicConfig( - level=getattr(logging, log_level.upper()), - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - ) - # Suppress noisy HTTP client logging - logging.getLogger("httpx").setLevel(logging.WARNING) - logging.getLogger("httpcore").setLevel(logging.WARNING) - - asyncio.run(run_demo(url, items, checkpoint_every)) - - -if __name__ == "__main__": - main() diff --git a/examples/clients/sse-polling-client/pyproject.toml b/examples/clients/sse-polling-client/pyproject.toml deleted file mode 100644 index 4db29857fd..0000000000 --- a/examples/clients/sse-polling-client/pyproject.toml +++ /dev/null @@ -1,36 +0,0 @@ -[project] -name = "mcp-sse-polling-client" -version = "0.1.0" -description = "Demo client for SSE polling with auto-reconnect" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] -keywords = ["mcp", "sse", "polling", "client"] -license = { text = "MIT" } -dependencies = ["click>=8.2.0", "mcp"] - -[project.scripts] -mcp-sse-polling-client = "mcp_sse_polling_client.main:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_sse_polling_client"] - -[tool.pyright] -include = ["mcp_sse_polling_client"] -venvPath = "." -venv = ".venv" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = [] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[dependency-groups] -dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/mcpserver/complex_inputs.py b/examples/mcpserver/complex_inputs.py index 93a42d1c89..704ba052a0 100644 --- a/examples/mcpserver/complex_inputs.py +++ b/examples/mcpserver/complex_inputs.py @@ -1,3 +1,4 @@ +# TODO: superseded by examples/stories/schema_validators/; remove once tests/test_examples.py is migrated. """MCPServer Complex inputs Example Demonstrates validation via pydantic with complex models. diff --git a/examples/mcpserver/desktop.py b/examples/mcpserver/desktop.py index 804184516d..c2999714f4 100644 --- a/examples/mcpserver/desktop.py +++ b/examples/mcpserver/desktop.py @@ -1,3 +1,4 @@ +# TODO: superseded by examples/stories/resources/; remove once tests/test_examples.py is migrated. """MCPServer Desktop Example A simple example that exposes the desktop directory as a resource. diff --git a/examples/mcpserver/direct_call_tool_result_return.py b/examples/mcpserver/direct_call_tool_result_return.py index c73e6164f5..2f9e63a044 100644 --- a/examples/mcpserver/direct_call_tool_result_return.py +++ b/examples/mcpserver/direct_call_tool_result_return.py @@ -1,3 +1,4 @@ +# TODO: superseded by examples/stories/tools/; remove once tests/test_examples.py is migrated. """MCPServer Echo Server with direct CallToolResult return""" from typing import Annotated diff --git a/examples/mcpserver/simple_echo.py b/examples/mcpserver/simple_echo.py index 3d8142a665..f2184d4af5 100644 --- a/examples/mcpserver/simple_echo.py +++ b/examples/mcpserver/simple_echo.py @@ -1,3 +1,4 @@ +# TODO: superseded by examples/stories/tools/; remove once tests/test_examples.py is migrated. """MCPServer Echo Server""" from mcp.server.mcpserver import MCPServer diff --git a/examples/servers/simple-auth/README.md b/examples/servers/simple-auth/README.md deleted file mode 100644 index d4a10c43b0..0000000000 --- a/examples/servers/simple-auth/README.md +++ /dev/null @@ -1,135 +0,0 @@ -# MCP OAuth Authentication Demo - -This example demonstrates OAuth 2.0 authentication with the Model Context Protocol using **separate Authorization Server (AS) and Resource Server (RS)** to comply with the new RFC 9728 specification. - ---- - -## Running the Servers - -### Step 1: Start Authorization Server - -```bash -# Navigate to the simple-auth directory -cd examples/servers/simple-auth - -# Start Authorization Server on port 9000 -uv run mcp-simple-auth-as --port=9000 -``` - -**What it provides:** - -- OAuth 2.0 flows (registration, authorization, token exchange) -- Simple credential-based authentication (no external provider needed) -- Token introspection endpoint for Resource Servers (`/introspect`) - ---- - -### Step 2: Start Resource Server (MCP Server) - -```bash -# In another terminal, navigate to the simple-auth directory -cd examples/servers/simple-auth - -# Start Resource Server on port 8001, connected to Authorization Server -uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http - -# With RFC 8707 strict resource validation (recommended for production) -uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http --oauth-strict - -``` - -### Step 3: Test with Client - -```bash -cd examples/clients/simple-auth-client -# Start client with streamable HTTP -MCP_SERVER_PORT=8001 MCP_TRANSPORT_TYPE=streamable-http uv run mcp-simple-auth-client -``` - -## How It Works - -### RFC 9728 Discovery - -**Client → Resource Server:** - -```bash -curl http://localhost:8001/.well-known/oauth-protected-resource -``` - -```json -{ - "resource": "http://localhost:8001", - "authorization_servers": ["http://localhost:9000"] -} -``` - -**Client → Authorization Server:** - -```bash -curl http://localhost:9000/.well-known/oauth-authorization-server -``` - -```json -{ - "issuer": "http://localhost:9000", - "authorization_endpoint": "http://localhost:9000/authorize", - "token_endpoint": "http://localhost:9000/token" -} -``` - -## Legacy MCP Server as Authorization Server (Backwards Compatibility) - -For backwards compatibility with older MCP implementations, a legacy server is provided that acts as an Authorization Server (following the old spec where MCP servers could optionally provide OAuth): - -### Running the Legacy Server - -```bash -# Start legacy server on port 8000 (the default) -cd examples/servers/simple-auth -uv run mcp-simple-auth-legacy --port=8000 --transport=streamable-http -``` - -**Differences from the new architecture:** - -- **MCP server acts as AS:** The MCP server itself provides OAuth endpoints (old spec behavior) -- **No separate RS:** The server handles both authentication and MCP tools -- **Local token validation:** Tokens are validated internally without introspection -- **No RFC 9728 support:** Does not provide `/.well-known/oauth-protected-resource` -- **Direct OAuth discovery:** OAuth metadata is at the MCP server's URL - -### Testing with Legacy Server - -```bash -# Test with client (will automatically fall back to legacy discovery) -cd examples/clients/simple-auth-client -MCP_SERVER_PORT=8000 MCP_TRANSPORT_TYPE=streamable-http uv run mcp-simple-auth-client -``` - -The client will: - -1. Try RFC 9728 discovery at `/.well-known/oauth-protected-resource` (404 on legacy server) -2. Fall back to direct OAuth discovery at `/.well-known/oauth-authorization-server` -3. Complete authentication with the MCP server acting as its own AS - -This ensures existing MCP servers (which could optionally act as Authorization Servers under the old spec) continue to work while the ecosystem transitions to the new architecture where MCP servers are Resource Servers only. - -## Manual Testing - -### Test Discovery - -```bash -# Test Resource Server discovery endpoint (new architecture) -curl -v http://localhost:8001/.well-known/oauth-protected-resource - -# Test Authorization Server metadata -curl -v http://localhost:9000/.well-known/oauth-authorization-server -``` - -### Test Token Introspection - -```bash -# After getting a token through OAuth flow: -curl -X POST http://localhost:9000/introspect \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "token=your_access_token" -``` diff --git a/examples/servers/simple-auth/mcp_simple_auth/__init__.py b/examples/servers/simple-auth/mcp_simple_auth/__init__.py deleted file mode 100644 index 3e12b31832..0000000000 --- a/examples/servers/simple-auth/mcp_simple_auth/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Simple MCP server with GitHub OAuth authentication.""" diff --git a/examples/servers/simple-auth/mcp_simple_auth/__main__.py b/examples/servers/simple-auth/mcp_simple_auth/__main__.py deleted file mode 100644 index 2365ff5a1b..0000000000 --- a/examples/servers/simple-auth/mcp_simple_auth/__main__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Main entry point for simple MCP server with GitHub OAuth authentication.""" - -import sys - -from mcp_simple_auth.server import main - -sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-auth/mcp_simple_auth/auth_server.py b/examples/servers/simple-auth/mcp_simple_auth/auth_server.py deleted file mode 100644 index 26c87c5ef2..0000000000 --- a/examples/servers/simple-auth/mcp_simple_auth/auth_server.py +++ /dev/null @@ -1,185 +0,0 @@ -"""Authorization Server for MCP Split Demo. - -This server handles OAuth flows, client registration, and token issuance. -Can be replaced with enterprise authorization servers like Auth0, Entra ID, etc. - -NOTE: this is a simplified example for demonstration purposes. -This is not a production-ready implementation. - -""" - -import asyncio -import logging -import time - -import click -from pydantic import AnyHttpUrl, BaseModel -from starlette.applications import Starlette -from starlette.exceptions import HTTPException -from starlette.requests import Request -from starlette.responses import JSONResponse, Response -from starlette.routing import Route -from uvicorn import Config, Server - -from mcp.server.auth.routes import cors_middleware, create_auth_routes -from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions - -from .simple_auth_provider import SimpleAuthSettings, SimpleOAuthProvider - -logger = logging.getLogger(__name__) - - -class AuthServerSettings(BaseModel): - """Settings for the Authorization Server.""" - - # Server settings - host: str = "localhost" - port: int = 9000 - server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") - auth_callback_path: str = "http://localhost:9000/login/callback" - - -class SimpleAuthProvider(SimpleOAuthProvider): - """Authorization Server provider with simple demo authentication. - - This provider: - 1. Issues MCP tokens after simple credential authentication - 2. Stores token state for introspection by Resource Servers - """ - - def __init__(self, auth_settings: SimpleAuthSettings, auth_callback_path: str, server_url: str): - super().__init__(auth_settings, auth_callback_path, server_url) - - -def create_authorization_server(server_settings: AuthServerSettings, auth_settings: SimpleAuthSettings) -> Starlette: - """Create the Authorization Server application.""" - oauth_provider = SimpleAuthProvider( - auth_settings, server_settings.auth_callback_path, str(server_settings.server_url) - ) - - mcp_auth_settings = AuthSettings( - issuer_url=server_settings.server_url, - client_registration_options=ClientRegistrationOptions( - enabled=True, - valid_scopes=[auth_settings.mcp_scope], - default_scopes=[auth_settings.mcp_scope], - ), - required_scopes=[auth_settings.mcp_scope], - resource_server_url=None, - ) - - # Create OAuth routes - routes = create_auth_routes( - provider=oauth_provider, - issuer_url=mcp_auth_settings.issuer_url, - service_documentation_url=mcp_auth_settings.service_documentation_url, - client_registration_options=mcp_auth_settings.client_registration_options, - revocation_options=mcp_auth_settings.revocation_options, - ) - - # Add login page route (GET) - async def login_page_handler(request: Request) -> Response: - """Show login form.""" - state = request.query_params.get("state") - if not state: - raise HTTPException(400, "Missing state parameter") - return await oauth_provider.get_login_page(state) - - routes.append(Route("/login", endpoint=login_page_handler, methods=["GET"])) - - # Add login callback route (POST) - async def login_callback_handler(request: Request) -> Response: - """Handle simple authentication callback.""" - return await oauth_provider.handle_login_callback(request) - - routes.append(Route("/login/callback", endpoint=login_callback_handler, methods=["POST"])) - - # Add token introspection endpoint (RFC 7662) for Resource Servers - async def introspect_handler(request: Request) -> Response: - """Token introspection endpoint for Resource Servers. - - Resource Servers call this endpoint to validate tokens without - needing direct access to token storage. - """ - form = await request.form() - token = form.get("token") - if not token or not isinstance(token, str): - return JSONResponse({"active": False}, status_code=400) - - # Look up token in provider - access_token = await oauth_provider.load_access_token(token) - if not access_token: - return JSONResponse({"active": False}) - - return JSONResponse( - { - "active": True, - "client_id": access_token.client_id, - "scope": " ".join(access_token.scopes), - "exp": access_token.expires_at, - "iat": int(time.time()), - "token_type": "Bearer", - "aud": access_token.resource, # RFC 8707 audience claim - "sub": access_token.subject, # RFC 7662 subject - "iss": str(server_settings.server_url), - } - ) - - routes.append( - Route( - "/introspect", - endpoint=cors_middleware(introspect_handler, ["POST", "OPTIONS"]), - methods=["POST", "OPTIONS"], - ) - ) - - return Starlette(routes=routes) - - -async def run_server(server_settings: AuthServerSettings, auth_settings: SimpleAuthSettings): - """Run the Authorization Server.""" - auth_server = create_authorization_server(server_settings, auth_settings) - - config = Config( - auth_server, - host=server_settings.host, - port=server_settings.port, - log_level="info", - ) - server = Server(config) - - logger.info(f"🚀 MCP Authorization Server running on {server_settings.server_url}") - - await server.serve() - - -@click.command() -@click.option("--port", default=9000, help="Port to listen on") -def main(port: int) -> int: - """Run the MCP Authorization Server. - - This server handles OAuth flows and can be used by multiple Resource Servers. - - Uses simple hardcoded credentials for demo purposes. - """ - logging.basicConfig(level=logging.INFO) - - # Load simple auth settings - auth_settings = SimpleAuthSettings() - - # Create server settings - host = "localhost" - server_url = f"http://{host}:{port}" - server_settings = AuthServerSettings( - host=host, - port=port, - server_url=AnyHttpUrl(server_url), - auth_callback_path=f"{server_url}/login", - ) - - asyncio.run(run_server(server_settings, auth_settings)) - return 0 - - -if __name__ == "__main__": - main() # type: ignore[call-arg] diff --git a/examples/servers/simple-auth/mcp_simple_auth/legacy_as_server.py b/examples/servers/simple-auth/mcp_simple_auth/legacy_as_server.py deleted file mode 100644 index ab7773b5bb..0000000000 --- a/examples/servers/simple-auth/mcp_simple_auth/legacy_as_server.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Legacy Combined Authorization Server + Resource Server for MCP. - -This server implements the old spec where MCP servers could act as both AS and RS. -Used for backwards compatibility testing with the new split AS/RS architecture. - -NOTE: this is a simplified example for demonstration purposes. -This is not a production-ready implementation. - -""" - -import datetime -import logging -from typing import Any, Literal - -import click -from pydantic import AnyHttpUrl, BaseModel -from starlette.exceptions import HTTPException -from starlette.requests import Request -from starlette.responses import Response - -from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions -from mcp.server.mcpserver.server import MCPServer - -from .simple_auth_provider import SimpleAuthSettings, SimpleOAuthProvider - -logger = logging.getLogger(__name__) - - -class ServerSettings(BaseModel): - """Settings for the simple auth MCP server.""" - - # Server settings - host: str = "localhost" - port: int = 8000 - server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8000") - auth_callback_path: str = "http://localhost:8000/login/callback" - - -class LegacySimpleOAuthProvider(SimpleOAuthProvider): - """Simple OAuth provider for legacy MCP server.""" - - def __init__(self, auth_settings: SimpleAuthSettings, auth_callback_path: str, server_url: str): - super().__init__(auth_settings, auth_callback_path, server_url) - - -def create_simple_mcp_server(server_settings: ServerSettings, auth_settings: SimpleAuthSettings) -> MCPServer: - """Create a simple MCPServer server with simple authentication.""" - oauth_provider = LegacySimpleOAuthProvider( - auth_settings, server_settings.auth_callback_path, str(server_settings.server_url) - ) - - mcp_auth_settings = AuthSettings( - issuer_url=server_settings.server_url, - client_registration_options=ClientRegistrationOptions( - enabled=True, - valid_scopes=[auth_settings.mcp_scope], - default_scopes=[auth_settings.mcp_scope], - ), - required_scopes=[auth_settings.mcp_scope], - # No resource_server_url parameter in legacy mode - resource_server_url=None, - ) - - app = MCPServer( - name="Simple Auth MCP Server", - instructions="A simple MCP server with simple credential authentication", - auth_server_provider=oauth_provider, - debug=True, - auth=mcp_auth_settings, - ) - # Store server settings for later use in run() - app._server_settings = server_settings # type: ignore[attr-defined] - - @app.custom_route("/login", methods=["GET"]) - async def login_page_handler(request: Request) -> Response: - """Show login form.""" - state = request.query_params.get("state") - if not state: - raise HTTPException(400, "Missing state parameter") - return await oauth_provider.get_login_page(state) - - @app.custom_route("/login/callback", methods=["POST"]) - async def login_callback_handler(request: Request) -> Response: - """Handle simple authentication callback.""" - return await oauth_provider.handle_login_callback(request) - - @app.tool() - async def get_time() -> dict[str, Any]: - """Get the current server time. - - This tool demonstrates that system information can be protected - by OAuth authentication. User must be authenticated to access it. - """ - - now = datetime.datetime.now() - - return { - "current_time": now.isoformat(), - "timezone": "UTC", # Simplified for demo - "timestamp": now.timestamp(), - "formatted": now.strftime("%Y-%m-%d %H:%M:%S"), - } - - return app - - -@click.command() -@click.option("--port", default=8000, help="Port to listen on") -@click.option( - "--transport", - default="streamable-http", - type=click.Choice(["sse", "streamable-http"]), - help="Transport protocol to use ('sse' or 'streamable-http')", -) -def main(port: int, transport: Literal["sse", "streamable-http"]) -> int: - """Run the simple auth MCP server.""" - logging.basicConfig(level=logging.INFO) - - auth_settings = SimpleAuthSettings() - # Create server settings - host = "localhost" - server_url = f"http://{host}:{port}" - server_settings = ServerSettings( - host=host, - port=port, - server_url=AnyHttpUrl(server_url), - auth_callback_path=f"{server_url}/login", - ) - - mcp_server = create_simple_mcp_server(server_settings, auth_settings) - logger.info(f"🚀 MCP Legacy Server running on {server_url}") - mcp_server.run(transport=transport, host=host, port=port) - return 0 - - -if __name__ == "__main__": - main() # type: ignore[call-arg] diff --git a/examples/servers/simple-auth/mcp_simple_auth/py.typed b/examples/servers/simple-auth/mcp_simple_auth/py.typed deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/examples/servers/simple-auth/mcp_simple_auth/server.py b/examples/servers/simple-auth/mcp_simple_auth/server.py deleted file mode 100644 index 0320871b12..0000000000 --- a/examples/servers/simple-auth/mcp_simple_auth/server.py +++ /dev/null @@ -1,161 +0,0 @@ -"""MCP Resource Server with Token Introspection. - -This server validates tokens via Authorization Server introspection and serves MCP resources. -Demonstrates RFC 9728 Protected Resource Metadata for AS/RS separation. - -NOTE: this is a simplified example for demonstration purposes. -This is not a production-ready implementation. -""" - -import datetime -import logging -from typing import Any, Literal - -import click -from pydantic import AnyHttpUrl -from pydantic_settings import BaseSettings, SettingsConfigDict - -from mcp.server.auth.settings import AuthSettings -from mcp.server.mcpserver.server import MCPServer - -from .token_verifier import IntrospectionTokenVerifier - -logger = logging.getLogger(__name__) - - -class ResourceServerSettings(BaseSettings): - """Settings for the MCP Resource Server.""" - - model_config = SettingsConfigDict(env_prefix="MCP_RESOURCE_") - - # Server settings - host: str = "localhost" - port: int = 8001 - server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8001/mcp") - - # Authorization Server settings - auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") - auth_server_introspection_endpoint: str = "http://localhost:9000/introspect" - # No user endpoint needed - we get user data from token introspection - - # MCP settings - mcp_scope: str = "user" - - # RFC 8707 resource validation - oauth_strict: bool = False - - -def create_resource_server(settings: ResourceServerSettings) -> MCPServer: - """Create MCP Resource Server with token introspection. - - This server: - 1. Provides protected resource metadata (RFC 9728) - 2. Validates tokens via Authorization Server introspection - 3. Serves MCP tools and resources - """ - # Create token verifier for introspection with RFC 8707 resource validation - token_verifier = IntrospectionTokenVerifier( - introspection_endpoint=settings.auth_server_introspection_endpoint, - server_url=str(settings.server_url), - validate_resource=settings.oauth_strict, # Only validate when --oauth-strict is set - ) - - # Create MCPServer server as a Resource Server - app = MCPServer( - name="MCP Resource Server", - instructions="Resource Server that validates tokens via Authorization Server introspection", - debug=True, - # Auth configuration for RS mode - token_verifier=token_verifier, - auth=AuthSettings( - issuer_url=settings.auth_server_url, - required_scopes=[settings.mcp_scope], - resource_server_url=settings.server_url, - ), - ) - # Store settings for later use in run() - app._resource_server_settings = settings # type: ignore[attr-defined] - - @app.tool() - async def get_time() -> dict[str, Any]: - """Get the current server time. - - This tool demonstrates that system information can be protected - by OAuth authentication. User must be authenticated to access it. - """ - - now = datetime.datetime.now() - - return { - "current_time": now.isoformat(), - "timezone": "UTC", # Simplified for demo - "timestamp": now.timestamp(), - "formatted": now.strftime("%Y-%m-%d %H:%M:%S"), - } - - return app - - -@click.command() -@click.option("--port", default=8001, help="Port to listen on") -@click.option("--auth-server", default="http://localhost:9000", help="Authorization Server URL") -@click.option( - "--transport", - default="streamable-http", - type=click.Choice(["sse", "streamable-http"]), - help="Transport protocol to use ('sse' or 'streamable-http')", -) -@click.option( - "--oauth-strict", - is_flag=True, - help="Enable RFC 8707 resource validation", -) -def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http"], oauth_strict: bool) -> int: - """Run the MCP Resource Server. - - This server: - - Provides RFC 9728 Protected Resource Metadata - - Validates tokens via Authorization Server introspection - - Serves MCP tools requiring authentication - - Must be used with a running Authorization Server. - """ - logging.basicConfig(level=logging.INFO) - - try: - # Parse auth server URL - auth_server_url = AnyHttpUrl(auth_server) - - # Create settings - host = "localhost" - server_url = f"http://{host}:{port}/mcp" - settings = ResourceServerSettings( - host=host, - port=port, - server_url=AnyHttpUrl(server_url), - auth_server_url=auth_server_url, - auth_server_introspection_endpoint=f"{auth_server}/introspect", - oauth_strict=oauth_strict, - ) - except ValueError as e: - logger.error(f"Configuration error: {e}") - logger.error("Make sure to provide a valid Authorization Server URL") - return 1 - - try: - mcp_server = create_resource_server(settings) - - logger.info(f"🚀 MCP Resource Server running on {settings.server_url}") - logger.info(f"🔑 Using Authorization Server: {settings.auth_server_url}") - - # Run the server - this should block and keep running - mcp_server.run(transport=transport, host=host, port=port) - logger.info("Server stopped") - return 0 - except Exception: - logger.exception("Server error") - return 1 - - -if __name__ == "__main__": - main() # type: ignore[call-arg] diff --git a/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py b/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py deleted file mode 100644 index 48eb9a8414..0000000000 --- a/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py +++ /dev/null @@ -1,272 +0,0 @@ -"""Simple OAuth provider for MCP servers. - -This module contains a basic OAuth implementation using hardcoded user credentials -for demonstration purposes. No external authentication provider is required. - -NOTE: this is a simplified example for demonstration purposes. -This is not a production-ready implementation. - -""" - -import secrets -import time -from typing import Any - -from pydantic import AnyHttpUrl -from pydantic_settings import BaseSettings, SettingsConfigDict -from starlette.exceptions import HTTPException -from starlette.requests import Request -from starlette.responses import HTMLResponse, RedirectResponse, Response - -from mcp.server.auth.provider import ( - AccessToken, - AuthorizationCode, - AuthorizationParams, - OAuthAuthorizationServerProvider, - RefreshToken, - construct_redirect_uri, -) -from mcp.shared.auth import OAuthClientInformationFull, OAuthToken - - -class SimpleAuthSettings(BaseSettings): - """Simple OAuth settings for demo purposes.""" - - model_config = SettingsConfigDict(env_prefix="MCP_") - - # Demo user credentials - demo_username: str = "demo_user" - demo_password: str = "demo_password" - - # MCP OAuth scope - mcp_scope: str = "user" - - -class SimpleOAuthProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]): - """Simple OAuth provider for demo purposes. - - This provider handles the OAuth flow by: - 1. Providing a simple login form for demo credentials - 2. Issuing MCP tokens after successful authentication - 3. Maintaining token state for introspection - """ - - def __init__(self, settings: SimpleAuthSettings, auth_callback_url: str, server_url: str): - self.settings = settings - self.auth_callback_url = auth_callback_url - self.server_url = server_url - self.clients: dict[str, OAuthClientInformationFull] = {} - self.auth_codes: dict[str, AuthorizationCode] = {} - self.tokens: dict[str, AccessToken] = {} - self.state_mapping: dict[str, dict[str, str | None]] = {} - # Store authenticated user information - self.user_data: dict[str, dict[str, Any]] = {} - - async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: - """Get OAuth client information.""" - return self.clients.get(client_id) - - async def register_client(self, client_info: OAuthClientInformationFull): - """Register a new OAuth client.""" - if not client_info.client_id: - raise ValueError("No client_id provided") - self.clients[client_info.client_id] = client_info - - async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: - """Generate an authorization URL for simple login flow.""" - state = params.state or secrets.token_hex(16) - - # Store state mapping for callback - self.state_mapping[state] = { - "redirect_uri": str(params.redirect_uri), - "code_challenge": params.code_challenge, - "redirect_uri_provided_explicitly": str(params.redirect_uri_provided_explicitly), - "client_id": client.client_id, - "resource": params.resource, # RFC 8707 - } - - # Build simple login URL that points to login page - auth_url = f"{self.auth_callback_url}?state={state}&client_id={client.client_id}" - - return auth_url - - async def get_login_page(self, state: str) -> HTMLResponse: - """Generate login page HTML for the given state.""" - if not state: - raise HTTPException(400, "Missing state parameter") - - # Create simple login form HTML - html_content = f""" - - - - MCP Demo Authentication - - - -

MCP Demo Authentication

-

This is a simplified authentication demo. Use the demo credentials below:

-

Username: demo_user
- Password: demo_password

- -
- -
- - -
-
- - -
- -
- - - """ - - return HTMLResponse(content=html_content) - - async def handle_login_callback(self, request: Request) -> Response: - """Handle login form submission callback.""" - form = await request.form() - username = form.get("username") - password = form.get("password") - state = form.get("state") - - if not username or not password or not state: - raise HTTPException(400, "Missing username, password, or state parameter") - - # Ensure we have strings, not UploadFile objects - if not isinstance(username, str) or not isinstance(password, str) or not isinstance(state, str): - raise HTTPException(400, "Invalid parameter types") - - redirect_uri = await self.handle_simple_callback(username, password, state) - return RedirectResponse(url=redirect_uri, status_code=302) - - async def handle_simple_callback(self, username: str, password: str, state: str) -> str: - """Handle simple authentication callback and return redirect URI.""" - state_data = self.state_mapping.get(state) - if not state_data: - raise HTTPException(400, "Invalid state parameter") - - redirect_uri = state_data["redirect_uri"] - code_challenge = state_data["code_challenge"] - redirect_uri_provided_explicitly = state_data["redirect_uri_provided_explicitly"] == "True" - client_id = state_data["client_id"] - resource = state_data.get("resource") # RFC 8707 - - # These are required values from our own state mapping - assert redirect_uri is not None - assert code_challenge is not None - assert client_id is not None - - # Validate demo credentials - if username != self.settings.demo_username or password != self.settings.demo_password: - raise HTTPException(401, "Invalid credentials") - - # Create MCP authorization code - new_code = f"mcp_{secrets.token_hex(16)}" - auth_code = AuthorizationCode( - code=new_code, - client_id=client_id, - redirect_uri=AnyHttpUrl(redirect_uri), - redirect_uri_provided_explicitly=redirect_uri_provided_explicitly, - expires_at=time.time() + 300, - scopes=[self.settings.mcp_scope], - code_challenge=code_challenge, - resource=resource, # RFC 8707 - subject=username, - ) - self.auth_codes[new_code] = auth_code - - # Store user data - self.user_data[username] = { - "username": username, - "user_id": f"user_{secrets.token_hex(8)}", - "authenticated_at": time.time(), - } - - del self.state_mapping[state] - return construct_redirect_uri(redirect_uri, code=new_code, state=state) - - async def load_authorization_code( - self, client: OAuthClientInformationFull, authorization_code: str - ) -> AuthorizationCode | None: - """Load an authorization code.""" - return self.auth_codes.get(authorization_code) - - async def exchange_authorization_code( - self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode - ) -> OAuthToken: - """Exchange authorization code for tokens.""" - if authorization_code.code not in self.auth_codes: - raise ValueError("Invalid authorization code") - if not client.client_id: - raise ValueError("No client_id provided") - - # Generate MCP access token - mcp_token = f"mcp_{secrets.token_hex(32)}" - - # Store MCP token - self.tokens[mcp_token] = AccessToken( - token=mcp_token, - client_id=client.client_id, - scopes=authorization_code.scopes, - expires_at=int(time.time()) + 3600, - resource=authorization_code.resource, # RFC 8707 - subject=authorization_code.subject, - ) - - # Store user data mapping for this token - self.user_data[mcp_token] = { - "username": self.settings.demo_username, - "user_id": f"user_{secrets.token_hex(8)}", - "authenticated_at": time.time(), - } - - del self.auth_codes[authorization_code.code] - - return OAuthToken( - access_token=mcp_token, - token_type="Bearer", - expires_in=3600, - scope=" ".join(authorization_code.scopes), - ) - - async def load_access_token(self, token: str) -> AccessToken | None: - """Load and validate an access token.""" - access_token = self.tokens.get(token) - if not access_token: - return None - - # Check if expired - if access_token.expires_at and access_token.expires_at < time.time(): - del self.tokens[token] - return None - - return access_token - - async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None: - """Load a refresh token - not supported in this example.""" - return None - - async def exchange_refresh_token( - self, - client: OAuthClientInformationFull, - refresh_token: RefreshToken, - scopes: list[str], - ) -> OAuthToken: - """Exchange refresh token - not supported in this example.""" - raise NotImplementedError("Refresh tokens not supported") - - # TODO(Marcelo): The type hint is wrong. We need to fix, and test to check if it works. - async def revoke_token(self, token: str, token_type_hint: str | None = None) -> None: # type: ignore - """Revoke a token.""" - if token in self.tokens: - del self.tokens[token] diff --git a/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py b/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py deleted file mode 100644 index 641095a125..0000000000 --- a/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Example token verifier implementation using OAuth 2.0 Token Introspection (RFC 7662).""" - -import logging -from typing import Any - -from mcp.server.auth.provider import AccessToken, TokenVerifier -from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url - -logger = logging.getLogger(__name__) - - -class IntrospectionTokenVerifier(TokenVerifier): - """Example token verifier that uses OAuth 2.0 Token Introspection (RFC 7662). - - This is a simple example implementation for demonstration purposes. - Production implementations should consider: - - Connection pooling and reuse - - More sophisticated error handling - - Rate limiting and retry logic - - Comprehensive configuration options - """ - - def __init__( - self, - introspection_endpoint: str, - server_url: str, - validate_resource: bool = False, - ): - self.introspection_endpoint = introspection_endpoint - self.server_url = server_url - self.validate_resource = validate_resource - self.resource_url = resource_url_from_server_url(server_url) - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify token via introspection endpoint.""" - import httpx - - # Validate URL to prevent SSRF attacks - if not self.introspection_endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1")): - logger.warning(f"Rejecting introspection endpoint with unsafe scheme: {self.introspection_endpoint}") - return None - - # Configure secure HTTP client - timeout = httpx.Timeout(10.0, connect=5.0) - limits = httpx.Limits(max_connections=10, max_keepalive_connections=5) - - async with httpx.AsyncClient( - timeout=timeout, - limits=limits, - verify=True, # Enforce SSL verification - ) as client: - try: - response = await client.post( - self.introspection_endpoint, - data={"token": token}, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - - if response.status_code != 200: - logger.debug(f"Token introspection returned status {response.status_code}") - return None - - data = response.json() - if not data.get("active", False): - return None - - # RFC 8707 resource validation (only when --oauth-strict is set) - if self.validate_resource and not self._validate_resource(data): - logger.warning(f"Token resource validation failed. Expected: {self.resource_url}") - return None - - return AccessToken( - token=token, - client_id=data.get("client_id", "unknown"), - scopes=data.get("scope", "").split() if data.get("scope") else [], - expires_at=data.get("exp"), - resource=data.get("aud"), # Include resource in token - subject=data.get("sub"), # RFC 7662 subject (resource owner) - claims=data, - ) - except Exception as e: - logger.warning(f"Token introspection failed: {e}") - return None - - def _validate_resource(self, token_data: dict[str, Any]) -> bool: - """Validate token was issued for this resource server.""" - if not self.server_url or not self.resource_url: - return False # Fail if strict validation requested but URLs missing - - # Check 'aud' claim first (standard JWT audience) - aud: list[str] | str | None = token_data.get("aud") - if isinstance(aud, list): - for audience in aud: - if self._is_valid_resource(audience): - return True - return False - elif aud: - return self._is_valid_resource(aud) - - # No resource binding - invalid per RFC 8707 - return False - - def _is_valid_resource(self, resource: str) -> bool: - """Check if resource matches this server using hierarchical matching.""" - if not self.resource_url: - return False - - return check_resource_allowed(requested_resource=self.resource_url, configured_resource=resource) diff --git a/examples/servers/simple-auth/pyproject.toml b/examples/servers/simple-auth/pyproject.toml deleted file mode 100644 index 1ffe3e694b..0000000000 --- a/examples/servers/simple-auth/pyproject.toml +++ /dev/null @@ -1,33 +0,0 @@ -[project] -name = "mcp-simple-auth" -version = "0.1.0" -description = "A simple MCP server demonstrating OAuth authentication" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] -license = { text = "MIT" } -dependencies = [ - "anyio>=4.5", - "click>=8.2.0", - "httpx>=0.27", - "mcp", - "pydantic>=2.0", - "pydantic-settings>=2.5.2", - "sse-starlette>=1.6.1", - "uvicorn>=0.23.1; sys_platform != 'emscripten'", -] - -[project.scripts] -mcp-simple-auth-rs = "mcp_simple_auth.server:main" -mcp-simple-auth-as = "mcp_simple_auth.auth_server:main" -mcp-simple-auth-legacy = "mcp_simple_auth.legacy_as_server:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_simple_auth"] - -[dependency-groups] -dev = ["pyright>=1.1.391", "pytest>=8.3.4", "ruff>=0.8.5"] diff --git a/examples/servers/simple-pagination/README.md b/examples/servers/simple-pagination/README.md deleted file mode 100644 index 4cab40fd34..0000000000 --- a/examples/servers/simple-pagination/README.md +++ /dev/null @@ -1,77 +0,0 @@ -# MCP Simple Pagination - -A simple MCP server demonstrating pagination for tools, resources, and prompts using cursor-based pagination. - -## Usage - -Start the server using either stdio (default) or Streamable HTTP transport: - -```bash -# Using stdio transport (default) -uv run mcp-simple-pagination - -# Using Streamable HTTP transport on custom port -uv run mcp-simple-pagination --transport streamable-http --port 8000 -``` - -The server exposes: - -- 25 tools (paginated, 5 per page) -- 30 resources (paginated, 10 per page) -- 20 prompts (paginated, 7 per page) - -Each paginated list returns a `nextCursor` when more pages are available. Use this cursor in subsequent requests to retrieve the next page. - -## Example - -Using the MCP client, you can retrieve paginated items like this using the STDIO transport: - -```python -import asyncio -from mcp.client.session import ClientSession -from mcp.client.stdio import StdioServerParameters, stdio_client - - -async def main(): - async with stdio_client( - StdioServerParameters(command="uv", args=["run", "mcp-simple-pagination"]) - ) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - # Get first page of tools - tools_page1 = await session.list_tools() - print(f"First page: {len(tools_page1.tools)} tools") - print(f"Next cursor: {tools_page1.nextCursor}") - - # Get second page using cursor - if tools_page1.nextCursor: - tools_page2 = await session.list_tools(cursor=tools_page1.nextCursor) - print(f"Second page: {len(tools_page2.tools)} tools") - - # Similarly for resources - resources_page1 = await session.list_resources() - print(f"First page: {len(resources_page1.resources)} resources") - - # And for prompts - prompts_page1 = await session.list_prompts() - print(f"First page: {len(prompts_page1.prompts)} prompts") - - -asyncio.run(main()) -``` - -## Pagination Details - -The server uses simple numeric indices as cursors for demonstration purposes. In production scenarios, you might use: - -- Database offsets or row IDs -- Timestamps for time-based pagination -- Opaque tokens encoding pagination state - -The pagination implementation demonstrates: - -- Handling `None` cursor for the first page -- Returning `nextCursor` when more data exists -- Gracefully handling invalid cursors -- Different page sizes for different resource types diff --git a/examples/servers/simple-pagination/mcp_simple_pagination/__init__.py b/examples/servers/simple-pagination/mcp_simple_pagination/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/examples/servers/simple-pagination/mcp_simple_pagination/__main__.py b/examples/servers/simple-pagination/mcp_simple_pagination/__main__.py deleted file mode 100644 index e7ef16530b..0000000000 --- a/examples/servers/simple-pagination/mcp_simple_pagination/__main__.py +++ /dev/null @@ -1,5 +0,0 @@ -import sys - -from .server import main - -sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-pagination/mcp_simple_pagination/server.py b/examples/servers/simple-pagination/mcp_simple_pagination/server.py deleted file mode 100644 index 9aca87f730..0000000000 --- a/examples/servers/simple-pagination/mcp_simple_pagination/server.py +++ /dev/null @@ -1,176 +0,0 @@ -"""Simple MCP server demonstrating pagination for tools, resources, and prompts. - -This example shows how to implement pagination with the low-level server API -to handle large lists of items that need to be split across multiple pages. -""" - -from typing import TypeVar - -import anyio -import click -import mcp_types as types -from mcp.server import Server, ServerRequestContext - -T = TypeVar("T") - -# Sample data - in real scenarios, this might come from a database -SAMPLE_TOOLS = [ - types.Tool( - name=f"tool_{i}", - title=f"Tool {i}", - description=f"This is sample tool number {i}", - input_schema={"type": "object", "properties": {"input": {"type": "string"}}}, - ) - for i in range(1, 26) # 25 tools total -] - -SAMPLE_RESOURCES = [ - types.Resource( - uri=f"file:///path/to/resource_{i}.txt", - name=f"resource_{i}", - description=f"This is sample resource number {i}", - ) - for i in range(1, 31) # 30 resources total -] - -SAMPLE_PROMPTS = [ - types.Prompt( - name=f"prompt_{i}", - description=f"This is sample prompt number {i}", - arguments=[ - types.PromptArgument(name="arg1", description="First argument", required=True), - ], - ) - for i in range(1, 21) # 20 prompts total -] - - -def _paginate(cursor: str | None, items: list[T], page_size: int) -> tuple[list[T], str | None]: - """Helper to paginate a list of items given a cursor.""" - if cursor is not None: - try: - start_idx = int(cursor) - except (ValueError, TypeError): - return [], None - else: - start_idx = 0 - - page = items[start_idx : start_idx + page_size] - next_cursor = str(start_idx + page_size) if start_idx + page_size < len(items) else None - return page, next_cursor - - -# Paginated list_tools - returns 5 tools per page -async def handle_list_tools( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListToolsResult: - cursor = params.cursor if params is not None else None - page, next_cursor = _paginate(cursor, SAMPLE_TOOLS, page_size=5) - return types.ListToolsResult(tools=page, next_cursor=next_cursor) - - -# Paginated list_resources - returns 10 resources per page -async def handle_list_resources( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListResourcesResult: - cursor = params.cursor if params is not None else None - page, next_cursor = _paginate(cursor, SAMPLE_RESOURCES, page_size=10) - return types.ListResourcesResult(resources=page, next_cursor=next_cursor) - - -# Paginated list_prompts - returns 7 prompts per page -async def handle_list_prompts( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListPromptsResult: - cursor = params.cursor if params is not None else None - page, next_cursor = _paginate(cursor, SAMPLE_PROMPTS, page_size=7) - return types.ListPromptsResult(prompts=page, next_cursor=next_cursor) - - -async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: - # Find the tool in our sample data - tool = next((t for t in SAMPLE_TOOLS if t.name == params.name), None) - if not tool: - raise ValueError(f"Unknown tool: {params.name}") - - return types.CallToolResult( - content=[ - types.TextContent( - type="text", - text=f"Called tool '{params.name}' with arguments: {params.arguments}", - ) - ] - ) - - -async def handle_read_resource( - ctx: ServerRequestContext, params: types.ReadResourceRequestParams -) -> types.ReadResourceResult: - resource = next((r for r in SAMPLE_RESOURCES if r.uri == str(params.uri)), None) - if not resource: - raise ValueError(f"Unknown resource: {params.uri}") - - return types.ReadResourceResult( - contents=[ - types.TextResourceContents( - uri=str(params.uri), - text=f"Content of {resource.name}: This is sample content for the resource.", - mime_type="text/plain", - ) - ] - ) - - -async def handle_get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> types.GetPromptResult: - prompt = next((p for p in SAMPLE_PROMPTS if p.name == params.name), None) - if not prompt: - raise ValueError(f"Unknown prompt: {params.name}") - - message_text = f"This is the prompt '{params.name}'" - if params.arguments: - message_text += f" with arguments: {params.arguments}" - - return types.GetPromptResult( - description=prompt.description, - messages=[ - types.PromptMessage( - role="user", - content=types.TextContent(type="text", text=message_text), - ) - ], - ) - - -@click.command() -@click.option("--port", default=8000, help="Port to listen on for HTTP") -@click.option( - "--transport", - type=click.Choice(["stdio", "streamable-http"]), - default="stdio", - help="Transport type", -) -def main(port: int, transport: str) -> int: - app = Server( - "mcp-simple-pagination", - on_list_tools=handle_list_tools, - on_list_resources=handle_list_resources, - on_list_prompts=handle_list_prompts, - on_call_tool=handle_call_tool, - on_read_resource=handle_read_resource, - on_get_prompt=handle_get_prompt, - ) - - if transport == "streamable-http": - import uvicorn - - uvicorn.run(app.streamable_http_app(), host="127.0.0.1", port=port) - else: - from mcp.server.stdio import stdio_server - - async def arun(): - async with stdio_server() as streams: - await app.run(streams[0], streams[1], app.create_initialization_options()) - - anyio.run(arun) - - return 0 diff --git a/examples/servers/simple-pagination/pyproject.toml b/examples/servers/simple-pagination/pyproject.toml deleted file mode 100644 index 2d57d9cccf..0000000000 --- a/examples/servers/simple-pagination/pyproject.toml +++ /dev/null @@ -1,43 +0,0 @@ -[project] -name = "mcp-simple-pagination" -version = "0.1.0" -description = "A simple MCP server demonstrating pagination for tools, resources, and prompts" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] -keywords = ["mcp", "llm", "automation", "pagination", "cursor"] -license = { text = "MIT" } -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", -] -dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] - -[project.scripts] -mcp-simple-pagination = "mcp_simple_pagination.server:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_simple_pagination"] - -[tool.pyright] -include = ["mcp_simple_pagination"] -venvPath = "." -venv = ".venv" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = [] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[dependency-groups] -dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-prompt/.python-version b/examples/servers/simple-prompt/.python-version deleted file mode 100644 index c8cfe39591..0000000000 --- a/examples/servers/simple-prompt/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.10 diff --git a/examples/servers/simple-prompt/README.md b/examples/servers/simple-prompt/README.md deleted file mode 100644 index c837da876e..0000000000 --- a/examples/servers/simple-prompt/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# MCP Simple Prompt - -A simple MCP server that exposes a customizable prompt template with optional context and topic parameters. - -## Usage - -Start the server using either stdio (default) or Streamable HTTP transport: - -```bash -# Using stdio transport (default) -uv run mcp-simple-prompt - -# Using Streamable HTTP transport on custom port -uv run mcp-simple-prompt --transport streamable-http --port 8000 -``` - -The server exposes a prompt named "simple" that accepts two optional arguments: - -- `context`: Additional context to consider -- `topic`: Specific topic to focus on - -## Example - -Using the MCP client, you can retrieve the prompt like this using the STDIO transport: - -```python -import asyncio -from mcp.client.session import ClientSession -from mcp.client.stdio import StdioServerParameters, stdio_client - - -async def main(): - async with stdio_client( - StdioServerParameters(command="uv", args=["run", "mcp-simple-prompt"]) - ) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - # List available prompts - prompts = await session.list_prompts() - print(prompts) - - # Get the prompt with arguments - prompt = await session.get_prompt( - "simple", - { - "context": "User is a software developer", - "topic": "Python async programming", - }, - ) - print(prompt) - - -asyncio.run(main()) -``` diff --git a/examples/servers/simple-prompt/mcp_simple_prompt/__init__.py b/examples/servers/simple-prompt/mcp_simple_prompt/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/examples/servers/simple-prompt/mcp_simple_prompt/__main__.py b/examples/servers/simple-prompt/mcp_simple_prompt/__main__.py deleted file mode 100644 index e7ef16530b..0000000000 --- a/examples/servers/simple-prompt/mcp_simple_prompt/__main__.py +++ /dev/null @@ -1,5 +0,0 @@ -import sys - -from .server import main - -sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-prompt/mcp_simple_prompt/server.py b/examples/servers/simple-prompt/mcp_simple_prompt/server.py deleted file mode 100644 index 31e3eb7d76..0000000000 --- a/examples/servers/simple-prompt/mcp_simple_prompt/server.py +++ /dev/null @@ -1,98 +0,0 @@ -import anyio -import click -import mcp_types as types -from mcp.server import Server, ServerRequestContext - - -def create_messages(context: str | None = None, topic: str | None = None) -> list[types.PromptMessage]: - """Create the messages for the prompt.""" - messages: list[types.PromptMessage] = [] - - # Add context if provided - if context: - messages.append( - types.PromptMessage( - role="user", - content=types.TextContent(type="text", text=f"Here is some relevant context: {context}"), - ) - ) - - # Add the main prompt - prompt = "Please help me with " - if topic: - prompt += f"the following topic: {topic}" - else: - prompt += "whatever questions I may have." - - messages.append(types.PromptMessage(role="user", content=types.TextContent(type="text", text=prompt))) - - return messages - - -async def handle_list_prompts( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListPromptsResult: - return types.ListPromptsResult( - prompts=[ - types.Prompt( - name="simple", - title="Simple Assistant Prompt", - description="A simple prompt that can take optional context and topic arguments", - arguments=[ - types.PromptArgument( - name="context", - description="Additional context to consider", - required=False, - ), - types.PromptArgument( - name="topic", - description="Specific topic to focus on", - required=False, - ), - ], - ) - ] - ) - - -async def handle_get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> types.GetPromptResult: - if params.name != "simple": - raise ValueError(f"Unknown prompt: {params.name}") - - arguments = params.arguments or {} - - return types.GetPromptResult( - messages=create_messages(context=arguments.get("context"), topic=arguments.get("topic")), - description="A simple prompt with optional context and topic arguments", - ) - - -@click.command() -@click.option("--port", default=8000, help="Port to listen on for HTTP") -@click.option( - "--transport", - type=click.Choice(["stdio", "streamable-http"]), - default="stdio", - help="Transport type", -) -def main(port: int, transport: str) -> int: - app = Server( - "mcp-simple-prompt", - on_list_prompts=handle_list_prompts, - on_get_prompt=handle_get_prompt, - ) - - if transport == "streamable-http": - import uvicorn - - uvicorn.run(app.streamable_http_app(), host="127.0.0.1", port=port) - else: - from mcp.server.stdio import stdio_server - - async def arun(): - async with stdio_server() as streams: - await app.run(streams[0], streams[1], app.create_initialization_options()) - - anyio.run(arun) - - return 0 diff --git a/examples/servers/simple-prompt/pyproject.toml b/examples/servers/simple-prompt/pyproject.toml deleted file mode 100644 index 9d4d8e6a6b..0000000000 --- a/examples/servers/simple-prompt/pyproject.toml +++ /dev/null @@ -1,43 +0,0 @@ -[project] -name = "mcp-simple-prompt" -version = "0.1.0" -description = "A simple MCP server exposing a customizable prompt" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] -keywords = ["mcp", "llm", "automation", "web", "fetch"] -license = { text = "MIT" } -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", -] -dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] - -[project.scripts] -mcp-simple-prompt = "mcp_simple_prompt.server:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_simple_prompt"] - -[tool.pyright] -include = ["mcp_simple_prompt"] -venvPath = "." -venv = ".venv" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = [] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[dependency-groups] -dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-resource/.python-version b/examples/servers/simple-resource/.python-version deleted file mode 100644 index c8cfe39591..0000000000 --- a/examples/servers/simple-resource/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.10 diff --git a/examples/servers/simple-resource/README.md b/examples/servers/simple-resource/README.md deleted file mode 100644 index 1d8fb7c2aa..0000000000 --- a/examples/servers/simple-resource/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# MCP Simple Resource - -A simple MCP server that exposes sample text files as resources. - -## Usage - -Start the server using either stdio (default) or Streamable HTTP transport: - -```bash -# Using stdio transport (default) -uv run mcp-simple-resource - -# Using Streamable HTTP transport on custom port -uv run mcp-simple-resource --transport streamable-http --port 8000 -``` - -The server exposes some basic text file resources that can be read by clients. - -## Example - -Using the MCP client, you can retrieve resources like this using the STDIO transport: - -```python -import asyncio -from pydantic import AnyUrl -from mcp.client.session import ClientSession -from mcp.client.stdio import StdioServerParameters, stdio_client - - -async def main(): - async with stdio_client( - StdioServerParameters(command="uv", args=["run", "mcp-simple-resource"]) - ) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - # List available resources - resources = await session.list_resources() - print(resources) - - # Get a specific resource - resource = await session.read_resource(AnyUrl("file:///greeting.txt")) - print(resource) - - -asyncio.run(main()) - -``` diff --git a/examples/servers/simple-resource/mcp_simple_resource/__init__.py b/examples/servers/simple-resource/mcp_simple_resource/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/examples/servers/simple-resource/mcp_simple_resource/__main__.py b/examples/servers/simple-resource/mcp_simple_resource/__main__.py deleted file mode 100644 index e7ef16530b..0000000000 --- a/examples/servers/simple-resource/mcp_simple_resource/__main__.py +++ /dev/null @@ -1,5 +0,0 @@ -import sys - -from .server import main - -sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-resource/mcp_simple_resource/server.py b/examples/servers/simple-resource/mcp_simple_resource/server.py deleted file mode 100644 index fe9dcfb709..0000000000 --- a/examples/servers/simple-resource/mcp_simple_resource/server.py +++ /dev/null @@ -1,91 +0,0 @@ -from urllib.parse import urlparse - -import anyio -import click -import mcp_types as types -from mcp.server import Server, ServerRequestContext - -SAMPLE_RESOURCES = { - "greeting": { - "content": "Hello! This is a sample text resource.", - "title": "Welcome Message", - }, - "help": { - "content": "This server provides a few sample text resources for testing.", - "title": "Help Documentation", - }, - "about": { - "content": "This is the simple-resource MCP server implementation.", - "title": "About This Server", - }, -} - - -async def handle_list_resources( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListResourcesResult: - return types.ListResourcesResult( - resources=[ - types.Resource( - uri=f"file:///{name}.txt", - name=name, - title=SAMPLE_RESOURCES[name]["title"], - description=f"A sample text resource named {name}", - mime_type="text/plain", - ) - for name in SAMPLE_RESOURCES.keys() - ] - ) - - -async def handle_read_resource( - ctx: ServerRequestContext, params: types.ReadResourceRequestParams -) -> types.ReadResourceResult: - parsed = urlparse(str(params.uri)) - if not parsed.path: - raise ValueError(f"Invalid resource path: {params.uri}") - name = parsed.path.replace(".txt", "").lstrip("/") - - if name not in SAMPLE_RESOURCES: - raise ValueError(f"Unknown resource: {params.uri}") - - return types.ReadResourceResult( - contents=[ - types.TextResourceContents( - uri=str(params.uri), - text=SAMPLE_RESOURCES[name]["content"], - mime_type="text/plain", - ) - ] - ) - - -@click.command() -@click.option("--port", default=8000, help="Port to listen on for HTTP") -@click.option( - "--transport", - type=click.Choice(["stdio", "streamable-http"]), - default="stdio", - help="Transport type", -) -def main(port: int, transport: str) -> int: - app = Server( - "mcp-simple-resource", - on_list_resources=handle_list_resources, - on_read_resource=handle_read_resource, - ) - - if transport == "streamable-http": - import uvicorn - - uvicorn.run(app.streamable_http_app(), host="127.0.0.1", port=port) - else: - from mcp.server.stdio import stdio_server - - async def arun(): - async with stdio_server() as streams: - await app.run(streams[0], streams[1], app.create_initialization_options()) - - anyio.run(arun) - - return 0 diff --git a/examples/servers/simple-resource/pyproject.toml b/examples/servers/simple-resource/pyproject.toml deleted file mode 100644 index 34fbc8d9de..0000000000 --- a/examples/servers/simple-resource/pyproject.toml +++ /dev/null @@ -1,43 +0,0 @@ -[project] -name = "mcp-simple-resource" -version = "0.1.0" -description = "A simple MCP server exposing sample text resources" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] -keywords = ["mcp", "llm", "automation", "web", "fetch"] -license = { text = "MIT" } -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", -] -dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] - -[project.scripts] -mcp-simple-resource = "mcp_simple_resource.server:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_simple_resource"] - -[tool.pyright] -include = ["mcp_simple_resource"] -venvPath = "." -venv = ".venv" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = [] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[dependency-groups] -dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-streamablehttp-stateless/README.md b/examples/servers/simple-streamablehttp-stateless/README.md deleted file mode 100644 index a254f88d14..0000000000 --- a/examples/servers/simple-streamablehttp-stateless/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# MCP Simple StreamableHttp Stateless Server Example - -A stateless MCP server example demonstrating the StreamableHttp transport without maintaining session state. This example is ideal for understanding how to deploy MCP servers in multi-node environments where requests can be routed to any instance. - -## Features - -- Uses the StreamableHTTP transport in stateless mode (mcp_session_id=None) -- Each request creates a new ephemeral connection -- No session state maintained between requests -- Suitable for deployment in multi-node environments - -## Usage - -Start the server: - -```bash -# Using default port 3000 -uv run mcp-simple-streamablehttp-stateless - -# Using custom port -uv run mcp-simple-streamablehttp-stateless --port 3000 - -# Custom logging level -uv run mcp-simple-streamablehttp-stateless --log-level DEBUG - -# Enable JSON responses instead of SSE streams -uv run mcp-simple-streamablehttp-stateless --json-response -``` - -The server exposes a tool named "start-notification-stream" that accepts three arguments: - -- `interval`: Time between notifications in seconds (e.g., 1.0) -- `count`: Number of notifications to send (e.g., 5) -- `caller`: Identifier string for the caller - -## Client - -You can connect to this server using an HTTP client. For now, only the TypeScript SDK has streamable HTTP client examples, or you can use [Inspector](https://github.com/modelcontextprotocol/inspector) for testing. diff --git a/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__init__.py b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__main__.py b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__main__.py deleted file mode 100644 index 1664737e3a..0000000000 --- a/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__main__.py +++ /dev/null @@ -1,7 +0,0 @@ -from .server import main - -if __name__ == "__main__": - # Click will handle CLI arguments - import sys - - sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py deleted file mode 100644 index 9df18cc6a2..0000000000 --- a/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py +++ /dev/null @@ -1,116 +0,0 @@ -import logging - -import anyio -import click -import mcp_types as types -import uvicorn -from mcp.server import Server, ServerRequestContext -from starlette.middleware.cors import CORSMiddleware - -logger = logging.getLogger(__name__) - - -async def handle_list_tools( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListToolsResult: - return types.ListToolsResult( - tools=[ - types.Tool( - name="start-notification-stream", - description=("Sends a stream of notifications with configurable count and interval"), - input_schema={ - "type": "object", - "required": ["interval", "count", "caller"], - "properties": { - "interval": { - "type": "number", - "description": "Interval between notifications in seconds", - }, - "count": { - "type": "number", - "description": "Number of notifications to send", - }, - "caller": { - "type": "string", - "description": ("Identifier of the caller to include in notifications"), - }, - }, - }, - ) - ] - ) - - -async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: - arguments = params.arguments or {} - interval = arguments.get("interval", 1.0) - count = arguments.get("count", 5) - caller = arguments.get("caller", "unknown") - - # Send the specified number of notifications with the given interval - for i in range(count): - await ctx.session.send_log_message( # pyright: ignore[reportDeprecated] - level="info", - data=f"Notification {i + 1}/{count} from caller: {caller}", - logger="notification_stream", - related_request_id=ctx.request_id, - ) - if i < count - 1: # Don't wait after the last notification - await anyio.sleep(interval) - - return types.CallToolResult( - content=[ - types.TextContent( - type="text", - text=(f"Sent {count} notifications with {interval}s interval for caller: {caller}"), - ) - ] - ) - - -@click.command() -@click.option("--port", default=3000, help="Port to listen on for HTTP") -@click.option( - "--log-level", - default="INFO", - help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)", -) -@click.option( - "--json-response", - is_flag=True, - default=False, - help="Enable JSON responses instead of SSE streams", -) -def main( - port: int, - log_level: str, - json_response: bool, -) -> None: - # Configure logging - logging.basicConfig( - level=getattr(logging, log_level.upper()), - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - ) - - app = Server( - "mcp-streamable-http-stateless-demo", - on_list_tools=handle_list_tools, - on_call_tool=handle_call_tool, - ) - - starlette_app = app.streamable_http_app( - stateless_http=True, - json_response=json_response, - debug=True, - ) - - # Wrap ASGI application with CORS middleware to expose Mcp-Session-Id header - # for browser-based clients (ensures 500 errors get proper CORS headers) - starlette_app = CORSMiddleware( - starlette_app, - allow_origins=["*"], # Note: streamable_http_app() enforces localhost-only Origin by default - allow_methods=["GET", "POST", "DELETE"], # MCP streamable HTTP methods - expose_headers=["Mcp-Session-Id"], - ) - - uvicorn.run(starlette_app, host="127.0.0.1", port=port) diff --git a/examples/servers/simple-streamablehttp-stateless/pyproject.toml b/examples/servers/simple-streamablehttp-stateless/pyproject.toml deleted file mode 100644 index 38f7b1b391..0000000000 --- a/examples/servers/simple-streamablehttp-stateless/pyproject.toml +++ /dev/null @@ -1,36 +0,0 @@ -[project] -name = "mcp-simple-streamablehttp-stateless" -version = "0.1.0" -description = "A simple MCP server exposing a StreamableHttp transport in stateless mode" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] -keywords = ["mcp", "llm", "automation", "web", "fetch", "http", "streamable", "stateless"] -license = { text = "MIT" } -dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"] - -[project.scripts] -mcp-simple-streamablehttp-stateless = "mcp_simple_streamablehttp_stateless.server:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_simple_streamablehttp_stateless"] - -[tool.pyright] -include = ["mcp_simple_streamablehttp_stateless"] -venvPath = "." -venv = ".venv" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = [] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[dependency-groups] -dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-streamablehttp/README.md b/examples/servers/simple-streamablehttp/README.md deleted file mode 100644 index 3eed3320e7..0000000000 --- a/examples/servers/simple-streamablehttp/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# MCP Simple StreamableHttp Server Example - -A simple MCP server example demonstrating the StreamableHttp transport, which enables HTTP-based communication with MCP servers using streaming. - -## Features - -- Uses the StreamableHTTP transport for server-client communication -- Supports REST API operations (POST, GET, DELETE) for `/mcp` endpoint -- Ability to send multiple notifications over time to the client -- Resumability support via InMemoryEventStore - -## Usage - -Start the server on the default or custom port: - -```bash - -# Using custom port -uv run mcp-simple-streamablehttp --port 3000 - -# Custom logging level -uv run mcp-simple-streamablehttp --log-level DEBUG - -# Enable JSON responses instead of SSE streams -uv run mcp-simple-streamablehttp --json-response -``` - -The server exposes a tool named "start-notification-stream" that accepts three arguments: - -- `interval`: Time between notifications in seconds (e.g., 1.0) -- `count`: Number of notifications to send (e.g., 5) -- `caller`: Identifier string for the caller - -## Resumability Support - -This server includes resumability support through the InMemoryEventStore. This enables clients to: - -- Reconnect to the server after a disconnection -- Resume event streaming from where they left off using the Last-Event-ID header - -The server will: - -- Generate unique event IDs for each SSE message -- Store events in memory for later replay -- Replay missed events when a client reconnects with a Last-Event-ID header - -Note: The InMemoryEventStore is designed for demonstration purposes only. For production use, consider implementing a persistent storage solution. - -## Client - -You can connect to this server using an HTTP client, for now only Typescript SDK has streamable HTTP client examples or you can use [Inspector](https://github.com/modelcontextprotocol/inspector) diff --git a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__init__.py b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__main__.py b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__main__.py deleted file mode 100644 index 21862e45fb..0000000000 --- a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__main__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .server import main - -if __name__ == "__main__": - main() # type: ignore[call-arg] diff --git a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py deleted file mode 100644 index c9369cfc2c..0000000000 --- a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py +++ /dev/null @@ -1,93 +0,0 @@ -"""In-memory event store for demonstrating resumability functionality. - -This is a simple implementation intended for examples and testing, -not for production use where a persistent storage solution would be more appropriate. -""" - -import logging -from collections import deque -from dataclasses import dataclass -from uuid import uuid4 - -from mcp.server.streamable_http import EventCallback, EventId, EventMessage, EventStore, StreamId -from mcp_types import JSONRPCMessage - -logger = logging.getLogger(__name__) - - -@dataclass -class EventEntry: - """Represents an event entry in the event store.""" - - event_id: EventId - stream_id: StreamId - message: JSONRPCMessage | None - - -class InMemoryEventStore(EventStore): - """Simple in-memory implementation of the EventStore interface for resumability. - This is primarily intended for examples and testing, not for production use - where a persistent storage solution would be more appropriate. - - This implementation keeps only the last N events per stream for memory efficiency. - """ - - def __init__(self, max_events_per_stream: int = 100): - """Initialize the event store. - - Args: - max_events_per_stream: Maximum number of events to keep per stream - """ - self.max_events_per_stream = max_events_per_stream - # for maintaining last N events per stream - self.streams: dict[StreamId, deque[EventEntry]] = {} - # event_id -> EventEntry for quick lookup - self.event_index: dict[EventId, EventEntry] = {} - - async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId: - """Stores an event with a generated event ID.""" - event_id = str(uuid4()) - event_entry = EventEntry(event_id=event_id, stream_id=stream_id, message=message) - - # Get or create deque for this stream - if stream_id not in self.streams: - self.streams[stream_id] = deque(maxlen=self.max_events_per_stream) - - # If deque is full, the oldest event will be automatically removed - # We need to remove it from the event_index as well - if len(self.streams[stream_id]) == self.max_events_per_stream: - oldest_event = self.streams[stream_id][0] - self.event_index.pop(oldest_event.event_id, None) - - # Add new event - self.streams[stream_id].append(event_entry) - self.event_index[event_id] = event_entry - - return event_id - - async def replay_events_after( - self, - last_event_id: EventId, - send_callback: EventCallback, - ) -> StreamId | None: - """Replays events that occurred after the specified event ID.""" - if last_event_id not in self.event_index: - logger.warning(f"Event ID {last_event_id} not found in store") - return None - - # Get the stream and find events after the last one - last_event = self.event_index[last_event_id] - stream_id = last_event.stream_id - stream_events = self.streams.get(last_event.stream_id, deque()) - - # Events in deque are already in chronological order - found_last = False - for event in stream_events: - if found_last: - # Skip priming events (None message) - if event.message is not None: - await send_callback(EventMessage(event.message, event.event_id)) - elif event.event_id == last_event_id: - found_last = True - - return stream_id diff --git a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py deleted file mode 100644 index e650b35732..0000000000 --- a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py +++ /dev/null @@ -1,142 +0,0 @@ -import logging - -import anyio -import click -import mcp_types as types -import uvicorn -from mcp.server import Server, ServerRequestContext -from starlette.middleware.cors import CORSMiddleware - -from .event_store import InMemoryEventStore - -# Configure logging -logger = logging.getLogger(__name__) - - -async def handle_list_tools( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListToolsResult: - return types.ListToolsResult( - tools=[ - types.Tool( - name="start-notification-stream", - description="Sends a stream of notifications with configurable count and interval", - input_schema={ - "type": "object", - "required": ["interval", "count", "caller"], - "properties": { - "interval": { - "type": "number", - "description": "Interval between notifications in seconds", - }, - "count": { - "type": "number", - "description": "Number of notifications to send", - }, - "caller": { - "type": "string", - "description": "Identifier of the caller to include in notifications", - }, - }, - }, - ) - ] - ) - - -async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: - arguments = params.arguments or {} - interval = arguments.get("interval", 1.0) - count = arguments.get("count", 5) - caller = arguments.get("caller", "unknown") - - # Send the specified number of notifications with the given interval - for i in range(count): - # Include more detailed message for resumability demonstration - notification_msg = f"[{i + 1}/{count}] Event from '{caller}' - Use Last-Event-ID to resume if disconnected" - await ctx.session.send_log_message( # pyright: ignore[reportDeprecated] - level="info", - data=notification_msg, - logger="notification_stream", - # Associates this notification with the original request - # Ensures notifications are sent to the correct response stream - # Without this, notifications will either go to: - # - a standalone SSE stream (if GET request is supported) - # - nowhere (if GET request isn't supported) - related_request_id=ctx.request_id, - ) - logger.debug(f"Sent notification {i + 1}/{count} for caller: {caller}") - if i < count - 1: # Don't wait after the last notification - await anyio.sleep(interval) - - # This will send a resource notification through standalone SSE - # established by GET request - await ctx.session.send_resource_updated(uri="http:///test_resource") - return types.CallToolResult( - content=[ - types.TextContent( - type="text", - text=(f"Sent {count} notifications with {interval}s interval for caller: {caller}"), - ) - ] - ) - - -@click.command() -@click.option("--port", default=3000, help="Port to listen on for HTTP") -@click.option( - "--log-level", - default="INFO", - help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)", -) -@click.option( - "--json-response", - is_flag=True, - default=False, - help="Enable JSON responses instead of SSE streams", -) -def main( - port: int, - log_level: str, - json_response: bool, -) -> int: - # Configure logging - logging.basicConfig( - level=getattr(logging, log_level.upper()), - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - ) - - app = Server( - "mcp-streamable-http-demo", - on_list_tools=handle_list_tools, - on_call_tool=handle_call_tool, - ) - - # Create event store for resumability - # The InMemoryEventStore enables resumability support for StreamableHTTP transport. - # It stores SSE events with unique IDs, allowing clients to: - # 1. Receive event IDs for each SSE message - # 2. Resume streams by sending Last-Event-ID in GET requests - # 3. Replay missed events after reconnection - # Note: This in-memory implementation is for demonstration ONLY. - # For production, use a persistent storage solution. - event_store = InMemoryEventStore() - - starlette_app = app.streamable_http_app( - event_store=event_store, - json_response=json_response, - debug=True, - ) - - # Wrap ASGI application with CORS middleware to expose Mcp-Session-Id header - # for browser-based clients (ensures 500 errors get proper CORS headers) - starlette_app = CORSMiddleware( - starlette_app, - allow_origins=["*"], # Note: streamable_http_app() enforces localhost-only Origin by default - allow_methods=["GET", "POST", "DELETE"], # MCP streamable HTTP methods - expose_headers=["Mcp-Session-Id"], - ) - - uvicorn.run(starlette_app, host="127.0.0.1", port=port) - - return 0 diff --git a/examples/servers/simple-streamablehttp/pyproject.toml b/examples/servers/simple-streamablehttp/pyproject.toml deleted file mode 100644 index 93f7baf41b..0000000000 --- a/examples/servers/simple-streamablehttp/pyproject.toml +++ /dev/null @@ -1,36 +0,0 @@ -[project] -name = "mcp-simple-streamablehttp" -version = "0.1.0" -description = "A simple MCP server exposing a StreamableHttp transport for testing" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] -keywords = ["mcp", "llm", "automation", "web", "fetch", "http", "streamable"] -license = { text = "MIT" } -dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"] - -[project.scripts] -mcp-simple-streamablehttp = "mcp_simple_streamablehttp.server:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_simple_streamablehttp"] - -[tool.pyright] -include = ["mcp_simple_streamablehttp"] -venvPath = "." -venv = ".venv" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = [] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[dependency-groups] -dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-tool/.python-version b/examples/servers/simple-tool/.python-version deleted file mode 100644 index c8cfe39591..0000000000 --- a/examples/servers/simple-tool/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.10 diff --git a/examples/servers/simple-tool/README.md b/examples/servers/simple-tool/README.md deleted file mode 100644 index 7d3759f9de..0000000000 --- a/examples/servers/simple-tool/README.md +++ /dev/null @@ -1,48 +0,0 @@ - -A simple MCP server that exposes a website fetching tool. - -## Usage - -Start the server using either stdio (default) or Streamable HTTP transport: - -```bash -# Using stdio transport (default) -uv run mcp-simple-tool - -# Using Streamable HTTP transport on custom port -uv run mcp-simple-tool --transport streamable-http --port 8000 -``` - -The server exposes a tool named "fetch" that accepts one required argument: - -- `url`: The URL of the website to fetch - -## Example - -Using the MCP client, you can use the tool like this using the STDIO transport: - -```python -import asyncio -from mcp.client.session import ClientSession -from mcp.client.stdio import StdioServerParameters, stdio_client - - -async def main(): - async with stdio_client( - StdioServerParameters(command="uv", args=["run", "mcp-simple-tool"]) - ) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - # List available tools - tools = await session.list_tools() - print(tools) - - # Call the fetch tool - result = await session.call_tool("fetch", {"url": "https://example.com"}) - print(result) - - -asyncio.run(main()) - -``` diff --git a/examples/servers/simple-tool/mcp_simple_tool/__init__.py b/examples/servers/simple-tool/mcp_simple_tool/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/examples/servers/simple-tool/mcp_simple_tool/__main__.py b/examples/servers/simple-tool/mcp_simple_tool/__main__.py deleted file mode 100644 index e7ef16530b..0000000000 --- a/examples/servers/simple-tool/mcp_simple_tool/__main__.py +++ /dev/null @@ -1,5 +0,0 @@ -import sys - -from .server import main - -sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-tool/mcp_simple_tool/server.py b/examples/servers/simple-tool/mcp_simple_tool/server.py deleted file mode 100644 index b16249e068..0000000000 --- a/examples/servers/simple-tool/mcp_simple_tool/server.py +++ /dev/null @@ -1,80 +0,0 @@ -import anyio -import click -import mcp_types as types -from mcp.server import Server, ServerRequestContext -from mcp.shared._httpx_utils import create_mcp_http_client - - -async def fetch_website( - url: str, -) -> list[types.ContentBlock]: - headers = {"User-Agent": "MCP Test Server (github.com/modelcontextprotocol/python-sdk)"} - async with create_mcp_http_client(headers=headers) as client: - response = await client.get(url) - response.raise_for_status() - return [types.TextContent(type="text", text=response.text)] - - -async def handle_list_tools( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListToolsResult: - return types.ListToolsResult( - tools=[ - types.Tool( - name="fetch", - title="Website Fetcher", - description="Fetches a website and returns its content", - input_schema={ - "type": "object", - "required": ["url"], - "properties": { - "url": { - "type": "string", - "description": "URL to fetch", - } - }, - }, - ) - ] - ) - - -async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: - if params.name != "fetch": - raise ValueError(f"Unknown tool: {params.name}") - arguments = params.arguments or {} - if "url" not in arguments: - raise ValueError("Missing required argument 'url'") - content = await fetch_website(arguments["url"]) - return types.CallToolResult(content=content) - - -@click.command() -@click.option("--port", default=8000, help="Port to listen on for HTTP") -@click.option( - "--transport", - type=click.Choice(["stdio", "streamable-http"]), - default="stdio", - help="Transport type", -) -def main(port: int, transport: str) -> int: - app = Server( - "mcp-website-fetcher", - on_list_tools=handle_list_tools, - on_call_tool=handle_call_tool, - ) - - if transport == "streamable-http": - import uvicorn - - uvicorn.run(app.streamable_http_app(), host="127.0.0.1", port=port) - else: - from mcp.server.stdio import stdio_server - - async def arun(): - async with stdio_server() as streams: - await app.run(streams[0], streams[1], app.create_initialization_options()) - - anyio.run(arun) - - return 0 diff --git a/examples/servers/simple-tool/pyproject.toml b/examples/servers/simple-tool/pyproject.toml deleted file mode 100644 index 022e039e04..0000000000 --- a/examples/servers/simple-tool/pyproject.toml +++ /dev/null @@ -1,43 +0,0 @@ -[project] -name = "mcp-simple-tool" -version = "0.1.0" -description = "A simple MCP server exposing a website fetching tool" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] -keywords = ["mcp", "llm", "automation", "web", "fetch"] -license = { text = "MIT" } -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", -] -dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] - -[project.scripts] -mcp-simple-tool = "mcp_simple_tool.server:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_simple_tool"] - -[tool.pyright] -include = ["mcp_simple_tool"] -venvPath = "." -venv = ".venv" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = [] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[dependency-groups] -dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/sse-polling-demo/README.md b/examples/servers/sse-polling-demo/README.md deleted file mode 100644 index e9d4446e1f..0000000000 --- a/examples/servers/sse-polling-demo/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# MCP SSE Polling Demo Server - -Demonstrates the SSE polling pattern with server-initiated stream close for long-running tasks (SEP-1699). - -## Features - -- Priming events (automatic with EventStore) -- Server-initiated stream close via `close_sse_stream()` callback -- Client auto-reconnect with Last-Event-ID -- Progress notifications during long-running tasks -- Configurable retry interval - -## Usage - -```bash -# Start server on default port -uv run mcp-sse-polling-demo --port 3000 - -# Custom retry interval (milliseconds) -uv run mcp-sse-polling-demo --port 3000 --retry-interval 100 -``` - -## Tool: process_batch - -Processes items with periodic checkpoints that trigger SSE stream closes: - -- `items`: Number of items to process (1-100, default: 10) -- `checkpoint_every`: Close stream after this many items (1-20, default: 3) - -## Client - -Use the companion `mcp-sse-polling-client` to test: - -```bash -uv run mcp-sse-polling-client --url http://localhost:3000/mcp -``` diff --git a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__init__.py b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__init__.py deleted file mode 100644 index 46af2fdeed..0000000000 --- a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""SSE Polling Demo Server - demonstrates close_sse_stream for long-running tasks.""" diff --git a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__main__.py b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__main__.py deleted file mode 100644 index 23cfc85e11..0000000000 --- a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__main__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Entry point for the SSE Polling Demo server.""" - -from .server import main - -if __name__ == "__main__": - main() diff --git a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/event_store.py b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/event_store.py deleted file mode 100644 index e2cca4a2eb..0000000000 --- a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/event_store.py +++ /dev/null @@ -1,98 +0,0 @@ -"""In-memory event store for demonstrating resumability functionality. - -This is a simple implementation intended for examples and testing, -not for production use where a persistent storage solution would be more appropriate. -""" - -import logging -from collections import deque -from dataclasses import dataclass -from uuid import uuid4 - -from mcp.server.streamable_http import EventCallback, EventId, EventMessage, EventStore, StreamId -from mcp_types import JSONRPCMessage - -logger = logging.getLogger(__name__) - - -@dataclass -class EventEntry: - """Represents an event entry in the event store.""" - - event_id: EventId - stream_id: StreamId - message: JSONRPCMessage | None # None for priming events - - -class InMemoryEventStore(EventStore): - """Simple in-memory implementation of the EventStore interface for resumability. - This is primarily intended for examples and testing, not for production use - where a persistent storage solution would be more appropriate. - - This implementation keeps only the last N events per stream for memory efficiency. - """ - - def __init__(self, max_events_per_stream: int = 100): - """Initialize the event store. - - Args: - max_events_per_stream: Maximum number of events to keep per stream - """ - self.max_events_per_stream = max_events_per_stream - # for maintaining last N events per stream - self.streams: dict[StreamId, deque[EventEntry]] = {} - # event_id -> EventEntry for quick lookup - self.event_index: dict[EventId, EventEntry] = {} - - async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId: - """Stores an event with a generated event ID. - - Args: - stream_id: ID of the stream the event belongs to - message: The message to store, or None for priming events - """ - event_id = str(uuid4()) - event_entry = EventEntry(event_id=event_id, stream_id=stream_id, message=message) - - # Get or create deque for this stream - if stream_id not in self.streams: - self.streams[stream_id] = deque(maxlen=self.max_events_per_stream) - - # If deque is full, the oldest event will be automatically removed - # We need to remove it from the event_index as well - if len(self.streams[stream_id]) == self.max_events_per_stream: - oldest_event = self.streams[stream_id][0] - self.event_index.pop(oldest_event.event_id, None) - - # Add new event - self.streams[stream_id].append(event_entry) - self.event_index[event_id] = event_entry - - return event_id - - async def replay_events_after( - self, - last_event_id: EventId, - send_callback: EventCallback, - ) -> StreamId | None: - """Replays events that occurred after the specified event ID.""" - if last_event_id not in self.event_index: - logger.warning(f"Event ID {last_event_id} not found in store") - return None - - # Get the stream and find events after the last one - last_event = self.event_index[last_event_id] - stream_id = last_event.stream_id - stream_events = self.streams.get(last_event.stream_id, deque()) - - # Events in deque are already in chronological order - found_last = False - for event in stream_events: - if found_last: - # Skip priming events (None messages) during replay - if event.message is not None: - await send_callback(EventMessage(event.message, event.event_id)) - elif event.event_id == last_event_id: - found_last = True - - return stream_id diff --git a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/server.py b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/server.py deleted file mode 100644 index 7d2c60fa32..0000000000 --- a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/server.py +++ /dev/null @@ -1,160 +0,0 @@ -"""SSE Polling Demo Server - -Demonstrates the SSE polling pattern with close_sse_stream() for long-running tasks. - -Features demonstrated: -- Priming events (automatic with EventStore) -- Server-initiated stream close via close_sse_stream callback -- Client auto-reconnect with Last-Event-ID -- Progress notifications during long-running tasks - -Run with: - uv run mcp-sse-polling-demo --port 3000 -""" - -import logging - -import anyio -import click -import mcp_types as types -import uvicorn -from mcp.server import Server, ServerRequestContext - -from .event_store import InMemoryEventStore - -logger = logging.getLogger(__name__) - - -async def handle_list_tools( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListToolsResult: - """List available tools.""" - return types.ListToolsResult( - tools=[ - types.Tool( - name="process_batch", - description=( - "Process a batch of items with periodic checkpoints. " - "Demonstrates SSE polling where server closes stream periodically." - ), - input_schema={ - "type": "object", - "properties": { - "items": { - "type": "integer", - "description": "Number of items to process (1-100)", - "default": 10, - }, - "checkpoint_every": { - "type": "integer", - "description": "Close stream after this many items (1-20)", - "default": 3, - }, - }, - }, - ) - ] - ) - - -async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: - """Handle tool calls.""" - arguments = params.arguments or {} - - if params.name == "process_batch": - items = arguments.get("items", 10) - checkpoint_every = arguments.get("checkpoint_every", 3) - - if items < 1 or items > 100: - return types.CallToolResult( - content=[types.TextContent(type="text", text="Error: items must be between 1 and 100")] - ) - if checkpoint_every < 1 or checkpoint_every > 20: - return types.CallToolResult( - content=[types.TextContent(type="text", text="Error: checkpoint_every must be between 1 and 20")] - ) - - await ctx.session.send_log_message( # pyright: ignore[reportDeprecated] - level="info", - data=f"Starting batch processing of {items} items...", - logger="process_batch", - related_request_id=ctx.request_id, - ) - - for i in range(1, items + 1): - # Simulate work - await anyio.sleep(0.5) - - # Report progress - await ctx.session.send_log_message( # pyright: ignore[reportDeprecated] - level="info", - data=f"[{i}/{items}] Processing item {i}", - logger="process_batch", - related_request_id=ctx.request_id, - ) - - # Checkpoint: close stream to trigger client reconnect - if i % checkpoint_every == 0 and i < items: - await ctx.session.send_log_message( # pyright: ignore[reportDeprecated] - level="info", - data=f"Checkpoint at item {i} - closing SSE stream for polling", - logger="process_batch", - related_request_id=ctx.request_id, - ) - if ctx.close_sse_stream: - logger.info(f"Closing SSE stream at checkpoint {i}") - await ctx.close_sse_stream() - # Wait for client to reconnect (must be > retry_interval of 100ms) - await anyio.sleep(0.2) - - return types.CallToolResult( - content=[ - types.TextContent( - type="text", - text=f"Successfully processed {items} items with checkpoints every {checkpoint_every} items", - ) - ] - ) - - return types.CallToolResult(content=[types.TextContent(type="text", text=f"Unknown tool: {params.name}")]) - - -@click.command() -@click.option("--port", default=3000, help="Port to listen on") -@click.option( - "--log-level", - default="INFO", - help="Logging level (DEBUG, INFO, WARNING, ERROR)", -) -@click.option( - "--retry-interval", - default=100, - help="SSE retry interval in milliseconds (sent to client)", -) -def main(port: int, log_level: str, retry_interval: int) -> int: - """Run the SSE Polling Demo server.""" - logging.basicConfig( - level=getattr(logging, log_level.upper()), - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - ) - - app = Server( - "sse-polling-demo", - on_list_tools=handle_list_tools, - on_call_tool=handle_call_tool, - ) - - starlette_app = app.streamable_http_app( - event_store=InMemoryEventStore(), - retry_interval=retry_interval, - debug=True, - ) - - logger.info(f"SSE Polling Demo server starting on port {port}") - logger.info("Try: POST /mcp with tools/call for 'process_batch'") - uvicorn.run(starlette_app, host="127.0.0.1", port=port) - return 0 - - -if __name__ == "__main__": - main() diff --git a/examples/servers/sse-polling-demo/pyproject.toml b/examples/servers/sse-polling-demo/pyproject.toml deleted file mode 100644 index 400f6580bc..0000000000 --- a/examples/servers/sse-polling-demo/pyproject.toml +++ /dev/null @@ -1,36 +0,0 @@ -[project] -name = "mcp-sse-polling-demo" -version = "0.1.0" -description = "Demo server showing SSE polling with close_sse_stream for long-running tasks" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] -keywords = ["mcp", "sse", "polling", "streamable", "http"] -license = { text = "MIT" } -dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"] - -[project.scripts] -mcp-sse-polling-demo = "mcp_sse_polling_demo.server:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_sse_polling_demo"] - -[tool.pyright] -include = ["mcp_sse_polling_demo"] -venvPath = "." -venv = ".venv" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = [] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[dependency-groups] -dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/snippets/clients/oauth_client.py b/examples/snippets/clients/oauth_client.py index 2085b9a1db..f130d9bd09 100644 --- a/examples/snippets/clients/oauth_client.py +++ b/examples/snippets/clients/oauth_client.py @@ -1,6 +1,6 @@ """Before running, specify running MCP RS server URL. To spin up RS server locally, see - examples/servers/simple-auth/README.md + examples/stories/oauth/README.md cd to the `examples/snippets` directory and run: uv run oauth-client diff --git a/src/mcp/server/auth/middleware/bearer_auth.py b/src/mcp/server/auth/middleware/bearer_auth.py index ba66e94226..63b1460b36 100644 --- a/src/mcp/server/auth/middleware/bearer_auth.py +++ b/src/mcp/server/auth/middleware/bearer_auth.py @@ -31,9 +31,8 @@ def authorization_context(user: AuthenticatedUser) -> AuthorizationContext: verifier does not supply are `None`, so the comparison degrades to the remaining components. - See `examples/servers/simple-auth/mcp_simple_auth/token_verifier.py` for - a verifier that populates `subject` and `claims` from an introspection - response.""" + See `examples/stories/bearer_auth/server.py` for a verifier that + populates `subject` and `client_id`.""" token = user.access_token issuer = (token.claims or {}).get("iss") return AuthorizationContext( From e071dafa063c449abf6807b15551cea41c3cc458 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:12:58 +0000 Subject: [PATCH 03/14] Remove no-cover pragmas for paths now exercised by story examples --- src/mcp/server/elicitation.py | 2 +- src/mcp/server/lowlevel/server.py | 2 +- src/mcp/server/mcpserver/server.py | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/mcp/server/elicitation.py b/src/mcp/server/elicitation.py index 066d33a1be..dc0e669c8b 100644 --- a/src/mcp/server/elicitation.py +++ b/src/mcp/server/elicitation.py @@ -113,7 +113,7 @@ async def elicit_with_validation( return AcceptedElicitation(data=validated_data) elif result.action == "decline": return DeclinedElicitation() - elif result.action == "cancel": # pragma: no cover + elif result.action == "cancel": return CancelledElicitation() else: # pragma: no cover # This should never happen, but handle it just in case diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 8ad945b6a8..a4fbc10057 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -561,7 +561,7 @@ def streamable_http_app( ) ) - if custom_starlette_routes: # pragma: no cover + if custom_starlette_routes: routes.extend(custom_starlette_routes) return Starlette( diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 15308eefd7..fb64adae78 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -199,7 +199,7 @@ def __init__( self._token_verifier = token_verifier # Create token verifier from provider if needed (backwards compatibility) - if auth_server_provider and not token_verifier: # pragma: no cover + if auth_server_provider and not token_verifier: self._token_verifier = ProviderTokenVerifier(auth_server_provider) self._custom_starlette_routes: list[Route] = [] @@ -821,7 +821,7 @@ async def health_check(request: Request) -> Response: ``` """ - def decorator( # pragma: no cover + def decorator( func: Callable[[Request], Awaitable[Response]], ) -> Callable[[Request], Awaitable[Response]]: self._custom_starlette_routes.append( @@ -829,7 +829,7 @@ def decorator( # pragma: no cover ) return func - return decorator # pragma: no cover + return decorator async def run_stdio_async(self) -> None: """Run the server using stdio transport.""" From c992ec49cdd012566fb06c033b40ad9a735dbdb2 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 23 Jun 2026 21:16:08 +0000 Subject: [PATCH 04/14] Drop internal tracking codes from conftest docstrings --- tests/examples/conftest.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/examples/conftest.py b/tests/examples/conftest.py index a63f867571..b57da7e0fd 100644 --- a/tests/examples/conftest.py +++ b/tests/examples/conftest.py @@ -122,7 +122,7 @@ def _client_kw(client_module: Any) -> dict[str, Any]: def _http_client_kw(client_module: Any) -> dict[str, Any]: - """``httpx.AsyncClient(...)`` kwargs the story's client module wants threaded (G-http-kw).""" + """``httpx.AsyncClient(...)`` kwargs the story's client module wants threaded through.""" return dict(getattr(client_module, "http_client_kw", None) or {}) @@ -131,9 +131,9 @@ class Hosted: """One server/app instance hosted for the leg's whole duration. ``connect`` opens a fresh ``Client`` against that single instance on every - call (G-connect-shared-server / G-event-store-per-app: state observed by one - client is visible to the next). ``http`` is the shared raw ``httpx.AsyncClient`` - bound to the same ASGI app, or ``None`` on the in-memory leg. + call, so state observed by one client is visible to the next. ``http`` is + the shared raw ``httpx.AsyncClient`` bound to the same ASGI app, or ``None`` + on the in-memory leg. """ connect: Connect @@ -149,7 +149,7 @@ async def hosted( The leg's era pre-seeds ``mode=``; a scenario may override it per-call (R6 ``dual-in-body`` opens both eras from the same body). Auth stories thread an ``httpx.Auth`` onto the bridge client via a module-level ``build_auth(http)`` - export and/or extra ``httpx.AsyncClient`` kwargs via ``http_client_kw`` (G3). + export and/or extra ``httpx.AsyncClient`` kwargs via ``http_client_kw``. """ for key, value in cfg["env"].items(): monkeypatch.setenv(key, value) @@ -171,8 +171,8 @@ async def _connect(*, auth: AuthBuilder | None = None, **kw: Any) -> AsyncIterat # http-asgi: one Starlette app per leg. ``server_export="app"`` stories hand us the # app directly; ``"factory"`` stories are wrapped via ``asgi_from``. Either way the - # app's own lifespan is what brings the session manager up (G-app-lifespan), and the - # in-process bridge never fires ASGI lifespan events itself, so enter it explicitly. + # app's own lifespan is what brings the session manager up, and the in-process + # bridge never fires ASGI lifespan events itself, so enter it explicitly. if cfg["server_export"] == "app": app: Starlette = server_module.build_app() else: From ecfa20044b28b3b9891e872aafd03cd3c2c14b91 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 24 Jun 2026 07:44:13 +0000 Subject: [PATCH 05/14] Restore old example directories; fix typing.TypedDict on Python < 3.12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Revert removal of examples/{clients,servers/simple-*,servers/sse-polling-demo} so the old examples remain alongside stories/ for now. - schema_validators: use typing_extensions.TypedDict so pydantic accepts it as a tool parameter on Python 3.10/3.11. - pyright: add explicit extraPaths for examples/servers/simple-auth — the mcp-example-stories editable install puts examples/ on sys.path, which defeats pyright's package-root auto-detection for that one example. --- examples/clients/simple-auth-client/README.md | 98 ++++ .../mcp_simple_auth_client/__init__.py | 1 + .../mcp_simple_auth_client/main.py | 390 ++++++++++++++++ .../clients/simple-auth-client/pyproject.toml | 43 ++ .../clients/simple-chatbot/.python-version | 1 + examples/clients/simple-chatbot/README.MD | 113 +++++ .../mcp_simple_chatbot/.env.example | 1 + .../simple-chatbot/mcp_simple_chatbot/main.py | 421 ++++++++++++++++++ .../mcp_simple_chatbot/requirements.txt | 4 + .../mcp_simple_chatbot/servers_config.json | 12 + .../simple-chatbot/mcp_simple_chatbot/test.db | Bin 0 -> 8192 bytes .../clients/simple-chatbot/pyproject.toml | 47 ++ examples/clients/sse-polling-client/README.md | 30 ++ .../mcp_sse_polling_client/__init__.py | 1 + .../mcp_sse_polling_client/main.py | 102 +++++ .../clients/sse-polling-client/pyproject.toml | 36 ++ examples/servers/simple-auth/README.md | 135 ++++++ .../simple-auth/mcp_simple_auth/__init__.py | 1 + .../simple-auth/mcp_simple_auth/__main__.py | 7 + .../mcp_simple_auth/auth_server.py | 185 ++++++++ .../mcp_simple_auth/legacy_as_server.py | 137 ++++++ .../simple-auth/mcp_simple_auth/py.typed | 0 .../simple-auth/mcp_simple_auth/server.py | 161 +++++++ .../mcp_simple_auth/simple_auth_provider.py | 272 +++++++++++ .../mcp_simple_auth/token_verifier.py | 108 +++++ examples/servers/simple-auth/pyproject.toml | 33 ++ examples/servers/simple-pagination/README.md | 77 ++++ .../mcp_simple_pagination/__init__.py | 0 .../mcp_simple_pagination/__main__.py | 5 + .../mcp_simple_pagination/server.py | 176 ++++++++ .../servers/simple-pagination/pyproject.toml | 43 ++ .../servers/simple-prompt/.python-version | 1 + examples/servers/simple-prompt/README.md | 55 +++ .../mcp_simple_prompt/__init__.py | 0 .../mcp_simple_prompt/__main__.py | 5 + .../simple-prompt/mcp_simple_prompt/server.py | 98 ++++ examples/servers/simple-prompt/pyproject.toml | 43 ++ .../servers/simple-resource/.python-version | 1 + examples/servers/simple-resource/README.md | 48 ++ .../mcp_simple_resource/__init__.py | 0 .../mcp_simple_resource/__main__.py | 5 + .../mcp_simple_resource/server.py | 91 ++++ .../servers/simple-resource/pyproject.toml | 43 ++ .../simple-streamablehttp-stateless/README.md | 38 ++ .../__init__.py | 0 .../__main__.py | 7 + .../server.py | 116 +++++ .../pyproject.toml | 36 ++ .../servers/simple-streamablehttp/README.md | 51 +++ .../mcp_simple_streamablehttp/__init__.py | 0 .../mcp_simple_streamablehttp/__main__.py | 4 + .../mcp_simple_streamablehttp/event_store.py | 93 ++++ .../mcp_simple_streamablehttp/server.py | 142 ++++++ .../simple-streamablehttp/pyproject.toml | 36 ++ examples/servers/simple-tool/.python-version | 1 + examples/servers/simple-tool/README.md | 48 ++ .../simple-tool/mcp_simple_tool/__init__.py | 0 .../simple-tool/mcp_simple_tool/__main__.py | 5 + .../simple-tool/mcp_simple_tool/server.py | 80 ++++ examples/servers/simple-tool/pyproject.toml | 43 ++ examples/servers/sse-polling-demo/README.md | 36 ++ .../mcp_sse_polling_demo/__init__.py | 1 + .../mcp_sse_polling_demo/__main__.py | 6 + .../mcp_sse_polling_demo/event_store.py | 98 ++++ .../mcp_sse_polling_demo/server.py | 160 +++++++ .../servers/sse-polling-demo/pyproject.toml | 36 ++ examples/stories/schema_validators/server.py | 6 +- pyproject.toml | 10 +- uv.lock | 384 ++++++++++++++++ 69 files changed, 4464 insertions(+), 3 deletions(-) create mode 100644 examples/clients/simple-auth-client/README.md create mode 100644 examples/clients/simple-auth-client/mcp_simple_auth_client/__init__.py create mode 100644 examples/clients/simple-auth-client/mcp_simple_auth_client/main.py create mode 100644 examples/clients/simple-auth-client/pyproject.toml create mode 100644 examples/clients/simple-chatbot/.python-version create mode 100644 examples/clients/simple-chatbot/README.MD create mode 100644 examples/clients/simple-chatbot/mcp_simple_chatbot/.env.example create mode 100644 examples/clients/simple-chatbot/mcp_simple_chatbot/main.py create mode 100644 examples/clients/simple-chatbot/mcp_simple_chatbot/requirements.txt create mode 100644 examples/clients/simple-chatbot/mcp_simple_chatbot/servers_config.json create mode 100644 examples/clients/simple-chatbot/mcp_simple_chatbot/test.db create mode 100644 examples/clients/simple-chatbot/pyproject.toml create mode 100644 examples/clients/sse-polling-client/README.md create mode 100644 examples/clients/sse-polling-client/mcp_sse_polling_client/__init__.py create mode 100644 examples/clients/sse-polling-client/mcp_sse_polling_client/main.py create mode 100644 examples/clients/sse-polling-client/pyproject.toml create mode 100644 examples/servers/simple-auth/README.md create mode 100644 examples/servers/simple-auth/mcp_simple_auth/__init__.py create mode 100644 examples/servers/simple-auth/mcp_simple_auth/__main__.py create mode 100644 examples/servers/simple-auth/mcp_simple_auth/auth_server.py create mode 100644 examples/servers/simple-auth/mcp_simple_auth/legacy_as_server.py create mode 100644 examples/servers/simple-auth/mcp_simple_auth/py.typed create mode 100644 examples/servers/simple-auth/mcp_simple_auth/server.py create mode 100644 examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py create mode 100644 examples/servers/simple-auth/mcp_simple_auth/token_verifier.py create mode 100644 examples/servers/simple-auth/pyproject.toml create mode 100644 examples/servers/simple-pagination/README.md create mode 100644 examples/servers/simple-pagination/mcp_simple_pagination/__init__.py create mode 100644 examples/servers/simple-pagination/mcp_simple_pagination/__main__.py create mode 100644 examples/servers/simple-pagination/mcp_simple_pagination/server.py create mode 100644 examples/servers/simple-pagination/pyproject.toml create mode 100644 examples/servers/simple-prompt/.python-version create mode 100644 examples/servers/simple-prompt/README.md create mode 100644 examples/servers/simple-prompt/mcp_simple_prompt/__init__.py create mode 100644 examples/servers/simple-prompt/mcp_simple_prompt/__main__.py create mode 100644 examples/servers/simple-prompt/mcp_simple_prompt/server.py create mode 100644 examples/servers/simple-prompt/pyproject.toml create mode 100644 examples/servers/simple-resource/.python-version create mode 100644 examples/servers/simple-resource/README.md create mode 100644 examples/servers/simple-resource/mcp_simple_resource/__init__.py create mode 100644 examples/servers/simple-resource/mcp_simple_resource/__main__.py create mode 100644 examples/servers/simple-resource/mcp_simple_resource/server.py create mode 100644 examples/servers/simple-resource/pyproject.toml create mode 100644 examples/servers/simple-streamablehttp-stateless/README.md create mode 100644 examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__init__.py create mode 100644 examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__main__.py create mode 100644 examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py create mode 100644 examples/servers/simple-streamablehttp-stateless/pyproject.toml create mode 100644 examples/servers/simple-streamablehttp/README.md create mode 100644 examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__init__.py create mode 100644 examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__main__.py create mode 100644 examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py create mode 100644 examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py create mode 100644 examples/servers/simple-streamablehttp/pyproject.toml create mode 100644 examples/servers/simple-tool/.python-version create mode 100644 examples/servers/simple-tool/README.md create mode 100644 examples/servers/simple-tool/mcp_simple_tool/__init__.py create mode 100644 examples/servers/simple-tool/mcp_simple_tool/__main__.py create mode 100644 examples/servers/simple-tool/mcp_simple_tool/server.py create mode 100644 examples/servers/simple-tool/pyproject.toml create mode 100644 examples/servers/sse-polling-demo/README.md create mode 100644 examples/servers/sse-polling-demo/mcp_sse_polling_demo/__init__.py create mode 100644 examples/servers/sse-polling-demo/mcp_sse_polling_demo/__main__.py create mode 100644 examples/servers/sse-polling-demo/mcp_sse_polling_demo/event_store.py create mode 100644 examples/servers/sse-polling-demo/mcp_sse_polling_demo/server.py create mode 100644 examples/servers/sse-polling-demo/pyproject.toml diff --git a/examples/clients/simple-auth-client/README.md b/examples/clients/simple-auth-client/README.md new file mode 100644 index 0000000000..708c0371b8 --- /dev/null +++ b/examples/clients/simple-auth-client/README.md @@ -0,0 +1,98 @@ +# Simple Auth Client Example + +A demonstration of how to use the MCP Python SDK with OAuth authentication over streamable HTTP or SSE transport. + +## Features + +- OAuth 2.0 authentication with PKCE +- Support for both StreamableHTTP and SSE transports +- Interactive command-line interface + +## Installation + +```bash +cd examples/clients/simple-auth-client +uv sync --reinstall +``` + +## Usage + +### 1. Start an MCP server with OAuth support + +The simple-auth server example provides three server configurations. See [examples/servers/simple-auth/README.md](../../servers/simple-auth/README.md) for full details. + +#### Option A: New Architecture (Recommended) + +Separate Authorization Server and Resource Server: + +```bash +# Terminal 1: Start Authorization Server on port 9000 +cd examples/servers/simple-auth +uv run mcp-simple-auth-as --port=9000 + +# Terminal 2: Start Resource Server on port 8001 +cd examples/servers/simple-auth +uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http +``` + +#### Option B: Legacy Server (Backwards Compatibility) + +```bash +# Single server that acts as both AS and RS (port 8000) +cd examples/servers/simple-auth +uv run mcp-simple-auth-legacy --port=8000 --transport=streamable-http +``` + +### 2. Run the client + +```bash +# Connect to Resource Server (new architecture, default port 8001) +MCP_SERVER_PORT=8001 uv run mcp-simple-auth-client + +# Connect to Legacy Server (port 8000) +uv run mcp-simple-auth-client + +# Use SSE transport +MCP_SERVER_PORT=8001 MCP_TRANSPORT_TYPE=sse uv run mcp-simple-auth-client +``` + +### 3. Complete OAuth flow + +The client will open your browser for authentication. After completing OAuth, you can use commands: + +- `list` - List available tools +- `call [args]` - Call a tool with optional JSON arguments +- `quit` - Exit + +## Example + +```markdown +🚀 Simple MCP Auth Client +Connecting to: http://localhost:8001/mcp +Transport type: streamable-http + +🔗 Attempting to connect to http://localhost:8001/mcp... +📡 Opening StreamableHTTP transport connection with auth... +Opening browser for authorization: http://localhost:9000/authorize?... + +✅ Connected to MCP server at http://localhost:8001/mcp + +mcp> list +📋 Available tools: +1. get_time + Description: Get the current server time. + +mcp> call get_time +🔧 Tool 'get_time' result: +{"current_time": "2024-01-15T10:30:00", "timezone": "UTC", ...} + +mcp> quit +``` + +## Configuration + +| Environment Variable | Description | Default | +|---------------------|-------------|---------| +| `MCP_SERVER_PORT` | Port number of the MCP server | `8000` | +| `MCP_TRANSPORT_TYPE` | Transport type: `streamable-http` or `sse` | `streamable-http` | +| `MCP_CLIENT_METADATA_URL` | Optional URL for client metadata (CIMD) | None | diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/__init__.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/__init__.py new file mode 100644 index 0000000000..06eb1f29d1 --- /dev/null +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/__init__.py @@ -0,0 +1 @@ +"""Simple OAuth client for MCP simple-auth server.""" diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py new file mode 100644 index 0000000000..0d461d5d11 --- /dev/null +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py @@ -0,0 +1,390 @@ +#!/usr/bin/env python3 +"""Simple MCP client example with OAuth authentication support. + +This client connects to an MCP server using streamable HTTP transport with OAuth. + +""" + +from __future__ import annotations as _annotations + +import asyncio +import os +import socketserver +import threading +import time +import webbrowser +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any +from urllib.parse import parse_qs, urlparse + +import httpx +from mcp.client._transport import ReadStream, WriteStream +from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider, TokenStorage +from mcp.client.session import ClientSession +from mcp.client.sse import sse_client +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken +from mcp.shared.message import SessionMessage + + +class InMemoryTokenStorage(TokenStorage): + """Simple in-memory token storage implementation.""" + + def __init__(self): + self._tokens: OAuthToken | None = None + self._client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self._tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + self._tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self._client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self._client_info = client_info + + +class CallbackHandler(BaseHTTPRequestHandler): + """Simple HTTP handler to capture OAuth callback.""" + + def __init__( + self, + request: Any, + client_address: tuple[str, int], + server: socketserver.BaseServer, + callback_data: dict[str, Any], + ): + """Initialize with callback data storage.""" + self.callback_data = callback_data + super().__init__(request, client_address, server) + + def do_GET(self): + """Handle GET request from OAuth redirect.""" + parsed = urlparse(self.path) + query_params = parse_qs(parsed.query) + + if "code" in query_params: + self.callback_data["authorization_code"] = query_params["code"][0] + self.callback_data["state"] = query_params.get("state", [None])[0] + self.callback_data["iss"] = query_params.get("iss", [None])[0] + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write(b""" + + +

Authorization Successful!

+

You can close this window and return to the terminal.

+ + + + """) + elif "error" in query_params: + self.callback_data["error"] = query_params["error"][0] + self.send_response(400) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write( + f""" + + +

Authorization Failed

+

Error: {query_params["error"][0]}

+

You can close this window and return to the terminal.

+ + + """.encode() + ) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format: str, *args: Any): + """Suppress default logging.""" + + +class CallbackServer: + """Simple server to handle OAuth callbacks.""" + + def __init__(self, port: int = 3000): + self.port = port + self.server = None + self.thread = None + self.callback_data = {"authorization_code": None, "state": None, "iss": None, "error": None} + + def _create_handler_with_data(self): + """Create a handler class with access to callback data.""" + callback_data = self.callback_data + + class DataCallbackHandler(CallbackHandler): + def __init__( + self, + request: BaseHTTPRequestHandler, + client_address: tuple[str, int], + server: socketserver.BaseServer, + ): + super().__init__(request, client_address, server, callback_data) + + return DataCallbackHandler + + def start(self): + """Start the callback server in a background thread.""" + handler_class = self._create_handler_with_data() + self.server = HTTPServer(("localhost", self.port), handler_class) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + print(f"🖥️ Started callback server on http://localhost:{self.port}") + + def stop(self): + """Stop the callback server.""" + if self.server: + self.server.shutdown() + self.server.server_close() + if self.thread: + self.thread.join(timeout=1) + + def wait_for_callback(self, timeout: int = 300): + """Wait for OAuth callback with timeout.""" + start_time = time.time() + while time.time() - start_time < timeout: + if self.callback_data["authorization_code"]: + return self.callback_data["authorization_code"] + elif self.callback_data["error"]: + raise Exception(f"OAuth error: {self.callback_data['error']}") + time.sleep(0.1) + raise Exception("Timeout waiting for OAuth callback") + + @property + def state(self): + """The received state parameter.""" + return self.callback_data["state"] + + @property + def iss(self): + """The received iss parameter.""" + return self.callback_data["iss"] + + +class SimpleAuthClient: + """Simple MCP client with auth support.""" + + def __init__( + self, + server_url: str, + transport_type: str = "streamable-http", + client_metadata_url: str | None = None, + ): + self.server_url = server_url + self.transport_type = transport_type + self.client_metadata_url = client_metadata_url + self.session: ClientSession | None = None + + async def connect(self): + """Connect to the MCP server.""" + print(f"🔗 Attempting to connect to {self.server_url}...") + + try: + callback_server = CallbackServer(port=3030) + callback_server.start() + + async def callback_handler() -> AuthorizationCodeResult: + """Wait for OAuth callback and return auth code, state, and iss.""" + print("⏳ Waiting for authorization callback...") + try: + auth_code = callback_server.wait_for_callback(timeout=300) + return AuthorizationCodeResult(code=auth_code, state=callback_server.state, iss=callback_server.iss) + finally: + callback_server.stop() + + client_metadata_dict = { + "client_name": "Simple Auth Client", + "redirect_uris": ["http://localhost:3030/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + } + + async def _default_redirect_handler(authorization_url: str) -> None: + """Default redirect handler that opens the URL in a browser.""" + print(f"Opening browser for authorization: {authorization_url}") + webbrowser.open(authorization_url) + + # Create OAuth authentication handler using the new interface + # Use client_metadata_url to enable CIMD when the server supports it + oauth_auth = OAuthClientProvider( + server_url=self.server_url.replace("/mcp", ""), + client_metadata=OAuthClientMetadata.model_validate(client_metadata_dict), + storage=InMemoryTokenStorage(), + redirect_handler=_default_redirect_handler, + callback_handler=callback_handler, + client_metadata_url=self.client_metadata_url, + ) + + # Create transport with auth handler based on transport type + if self.transport_type == "sse": + print("📡 Opening SSE transport connection with auth...") + async with sse_client( + url=self.server_url, + auth=oauth_auth, + timeout=60.0, + ) as (read_stream, write_stream): + await self._run_session(read_stream, write_stream) + else: + print("📡 Opening StreamableHTTP transport connection with auth...") + async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: + async with streamable_http_client(url=self.server_url, http_client=custom_client) as ( + read_stream, + write_stream, + ): + await self._run_session(read_stream, write_stream) + + except Exception as e: + print(f"❌ Failed to connect: {e}") + import traceback + + traceback.print_exc() + + async def _run_session( + self, + read_stream: ReadStream[SessionMessage | Exception], + write_stream: WriteStream[SessionMessage], + ): + """Run the MCP session with the given streams.""" + print("🤝 Initializing MCP session...") + async with ClientSession(read_stream, write_stream) as session: + self.session = session + print("⚡ Starting session initialization...") + await session.initialize() + print("✨ Session initialization complete!") + + print(f"\n✅ Connected to MCP server at {self.server_url}") + + # Run interactive loop + await self.interactive_loop() + + async def list_tools(self): + """List available tools from the server.""" + if not self.session: + print("❌ Not connected to server") + return + + try: + result = await self.session.list_tools() + if hasattr(result, "tools") and result.tools: + print("\n📋 Available tools:") + for i, tool in enumerate(result.tools, 1): + print(f"{i}. {tool.name}") + if tool.description: + print(f" Description: {tool.description}") + print() + else: + print("No tools available") + except Exception as e: + print(f"❌ Failed to list tools: {e}") + + async def call_tool(self, tool_name: str, arguments: dict[str, Any] | None = None): + """Call a specific tool.""" + if not self.session: + print("❌ Not connected to server") + return + + try: + result = await self.session.call_tool(tool_name, arguments or {}) + print(f"\n🔧 Tool '{tool_name}' result:") + if hasattr(result, "content"): + for content in result.content: + if content.type == "text": + print(content.text) + else: + print(content) + else: + print(result) + except Exception as e: + print(f"❌ Failed to call tool '{tool_name}': {e}") + + async def interactive_loop(self): + """Run interactive command loop.""" + print("\n🎯 Interactive MCP Client") + print("Commands:") + print(" list - List available tools") + print(" call [args] - Call a tool") + print(" quit - Exit the client") + print() + + while True: + try: + command = input("mcp> ").strip() + + if not command: + continue + + if command == "quit": + break + + elif command == "list": + await self.list_tools() + + elif command.startswith("call "): + parts = command.split(maxsplit=2) + tool_name = parts[1] if len(parts) > 1 else "" + + if not tool_name: + print("❌ Please specify a tool name") + continue + + # Parse arguments (simple JSON-like format) + arguments: dict[str, Any] = {} + if len(parts) > 2: + import json + + try: + arguments = json.loads(parts[2]) + except json.JSONDecodeError: + print("❌ Invalid arguments format (expected JSON)") + continue + + await self.call_tool(tool_name, arguments) + + else: + print("❌ Unknown command. Try 'list', 'call ', or 'quit'") + + except KeyboardInterrupt: + print("\n\n👋 Goodbye!") + break + except EOFError: + break + + +async def main(): + """Main entry point.""" + # Default server URL - can be overridden with environment variable + # Most MCP streamable HTTP servers use /mcp as the endpoint + server_url = os.getenv("MCP_SERVER_PORT", 8000) + transport_type = os.getenv("MCP_TRANSPORT_TYPE", "streamable-http") + client_metadata_url = os.getenv("MCP_CLIENT_METADATA_URL") + server_url = ( + f"http://localhost:{server_url}/mcp" + if transport_type == "streamable-http" + else f"http://localhost:{server_url}/sse" + ) + + print("🚀 Simple MCP Auth Client") + print(f"Connecting to: {server_url}") + print(f"Transport type: {transport_type}") + if client_metadata_url: + print(f"Client metadata URL: {client_metadata_url}") + + # Start connection flow - OAuth will be handled automatically + client = SimpleAuthClient(server_url, transport_type, client_metadata_url) + await client.connect() + + +def cli(): + """CLI entry point for uv script.""" + asyncio.run(main()) + + +if __name__ == "__main__": + cli() diff --git a/examples/clients/simple-auth-client/pyproject.toml b/examples/clients/simple-auth-client/pyproject.toml new file mode 100644 index 0000000000..f84d1430fe --- /dev/null +++ b/examples/clients/simple-auth-client/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "mcp-simple-auth-client" +version = "0.1.0" +description = "A simple OAuth client for the MCP simple-auth server" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "oauth", "client", "auth"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["click>=8.2.0", "mcp"] + +[project.scripts] +mcp-simple-auth-client = "mcp_simple_auth_client.main:cli" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_auth_client"] + +[tool.pyright] +include = ["mcp_simple_auth_client"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/clients/simple-chatbot/.python-version b/examples/clients/simple-chatbot/.python-version new file mode 100644 index 0000000000..c8cfe39591 --- /dev/null +++ b/examples/clients/simple-chatbot/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/examples/clients/simple-chatbot/README.MD b/examples/clients/simple-chatbot/README.MD new file mode 100644 index 0000000000..482109f97b --- /dev/null +++ b/examples/clients/simple-chatbot/README.MD @@ -0,0 +1,113 @@ +# MCP Simple Chatbot + +This example demonstrates how to integrate the Model Context Protocol (MCP) into a simple CLI chatbot. The implementation showcases MCP's flexibility by supporting multiple tools through MCP servers and is compatible with any LLM provider that follows OpenAI API standards. + +## Requirements + +- Python 3.10 +- `python-dotenv` +- `requests` +- `mcp` +- `uvicorn` + +## Installation + +1. **Install the dependencies:** + + ```bash + pip install -r requirements.txt + ``` + +2. **Set up environment variables:** + + Create a `.env` file in the root directory and add your API key: + + ```plaintext + LLM_API_KEY=your_api_key_here + ``` + + **Note:** The current implementation is configured to use the Groq API endpoint (`https://api.groq.com/openai/v1/chat/completions`) with the `llama-3.2-90b-vision-preview` model. If you plan to use a different LLM provider, you'll need to modify the `LLMClient` class in `main.py` to use the appropriate endpoint URL and model parameters. + +3. **Configure servers:** + + The `servers_config.json` follows the same structure as Claude Desktop, allowing for easy integration of multiple servers. + Here's an example: + + ```json + { + "mcpServers": { + "sqlite": { + "command": "uvx", + "args": ["mcp-server-sqlite", "--db-path", "./test.db"] + }, + "puppeteer": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-puppeteer"] + } + } + } + ``` + + Environment variables are supported as well. Pass them as you would with the Claude Desktop App. + + Example: + + ```json + { + "mcpServers": { + "server_name": { + "command": "uvx", + "args": ["mcp-server-name", "--additional-args"], + "env": { + "API_KEY": "your_api_key_here" + } + } + } + } + ``` + +## Usage + +1. **Run the client:** + + ```bash + python main.py + ``` + +2. **Interact with the assistant:** + + The assistant will automatically detect available tools and can respond to queries based on the tools provided by the configured servers. + +3. **Exit the session:** + + Type `quit` or `exit` to end the session. + +## Architecture + +- **Tool Discovery**: Tools are automatically discovered from configured servers. +- **System Prompt**: Tools are dynamically included in the system prompt, allowing the LLM to understand available capabilities. +- **Server Integration**: Supports any MCP-compatible server, tested with various server implementations including Uvicorn and Node.js. + +### Class Structure + +- **Configuration**: Manages environment variables and server configurations +- **Server**: Handles MCP server initialization, tool discovery, and execution +- **Tool**: Represents individual tools with their properties and formatting +- **LLMClient**: Manages communication with the LLM provider +- **ChatSession**: Orchestrates the interaction between user, LLM, and tools + +### Logic Flow + +1. **Tool Integration**: + - Tools are dynamically discovered from MCP servers + - Tool descriptions are automatically included in system prompt + - Tool execution is handled through standardized MCP protocol + +2. **Runtime Flow**: + - User input is received + - Input is sent to LLM with context of available tools + - LLM response is parsed: + - If it's a tool call → execute tool and return result + - If it's a direct response → return to user + - Tool results are sent back to LLM for interpretation + - Final response is presented to user diff --git a/examples/clients/simple-chatbot/mcp_simple_chatbot/.env.example b/examples/clients/simple-chatbot/mcp_simple_chatbot/.env.example new file mode 100644 index 0000000000..39be363c20 --- /dev/null +++ b/examples/clients/simple-chatbot/mcp_simple_chatbot/.env.example @@ -0,0 +1 @@ +LLM_API_KEY=gsk_1234567890 diff --git a/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py b/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py new file mode 100644 index 0000000000..72b1a6f204 --- /dev/null +++ b/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py @@ -0,0 +1,421 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import os +import shutil +from contextlib import AsyncExitStack +from typing import Any + +import httpx +from dotenv import load_dotenv +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +# Configure logging +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") + + +class Configuration: + """Manages configuration and environment variables for the MCP client.""" + + def __init__(self) -> None: + """Initialize configuration with environment variables.""" + self.load_env() + self.api_key = os.getenv("LLM_API_KEY") + + @staticmethod + def load_env() -> None: + """Load environment variables from .env file.""" + load_dotenv() + + @staticmethod + def load_config(file_path: str) -> dict[str, Any]: + """Load server configuration from JSON file. + + Args: + file_path: Path to the JSON configuration file. + + Returns: + Dict containing server configuration. + + Raises: + FileNotFoundError: If configuration file doesn't exist. + JSONDecodeError: If configuration file is invalid JSON. + """ + with open(file_path, "r") as f: + return json.load(f) + + @property + def llm_api_key(self) -> str: + """Get the LLM API key. + + Returns: + The API key as a string. + + Raises: + ValueError: If the API key is not found in environment variables. + """ + if not self.api_key: + raise ValueError("LLM_API_KEY not found in environment variables") + return self.api_key + + +class Server: + """Manages MCP server connections and tool execution.""" + + def __init__(self, name: str, config: dict[str, Any]) -> None: + self.name: str = name + self.config: dict[str, Any] = config + self.stdio_context: Any | None = None + self.session: ClientSession | None = None + self._cleanup_lock: asyncio.Lock = asyncio.Lock() + self.exit_stack: AsyncExitStack = AsyncExitStack() + + async def initialize(self) -> None: + """Initialize the server connection.""" + command = shutil.which("npx") if self.config["command"] == "npx" else self.config["command"] + if command is None: + raise ValueError("The command must be a valid string and cannot be None.") + + server_params = StdioServerParameters( + command=command, + args=self.config["args"], + env={**os.environ, **self.config["env"]} if self.config.get("env") else None, + ) + try: + stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params)) + read, write = stdio_transport + session = await self.exit_stack.enter_async_context(ClientSession(read, write)) + await session.initialize() + self.session = session + except Exception as e: + logging.error(f"Error initializing server {self.name}: {e}") + await self.cleanup() + raise + + async def list_tools(self) -> list[Tool]: + """List available tools from the server. + + Returns: + A list of available tools. + + Raises: + RuntimeError: If the server is not initialized. + """ + if not self.session: + raise RuntimeError(f"Server {self.name} not initialized") + + tools_response = await self.session.list_tools() + tools: list[Tool] = [] + + for item in tools_response: + if item[0] == "tools": + tools.extend(Tool(tool.name, tool.description, tool.input_schema, tool.title) for tool in item[1]) + + return tools + + async def execute_tool( + self, + tool_name: str, + arguments: dict[str, Any], + retries: int = 2, + delay: float = 1.0, + ) -> Any: + """Execute a tool with retry mechanism. + + Args: + tool_name: Name of the tool to execute. + arguments: Tool arguments. + retries: Number of retry attempts. + delay: Delay between retries in seconds. + + Returns: + Tool execution result. + + Raises: + RuntimeError: If server is not initialized. + Exception: If tool execution fails after all retries. + """ + if not self.session: + raise RuntimeError(f"Server {self.name} not initialized") + + attempt = 0 + while attempt < retries: + try: + logging.info(f"Executing {tool_name}...") + result = await self.session.call_tool(tool_name, arguments) + + return result + + except Exception as e: + attempt += 1 + logging.warning(f"Error executing tool: {e}. Attempt {attempt} of {retries}.") + if attempt < retries: + logging.info(f"Retrying in {delay} seconds...") + await asyncio.sleep(delay) + else: + logging.error("Max retries reached. Failing.") + raise + + async def cleanup(self) -> None: + """Clean up server resources.""" + async with self._cleanup_lock: + try: + await self.exit_stack.aclose() + self.session = None + self.stdio_context = None + except Exception as e: + logging.error(f"Error during cleanup of server {self.name}: {e}") + + +class Tool: + """Represents a tool with its properties and formatting.""" + + def __init__( + self, + name: str, + description: str, + input_schema: dict[str, Any], + title: str | None = None, + ) -> None: + self.name: str = name + self.title: str | None = title + self.description: str = description + self.input_schema: dict[str, Any] = input_schema + + def format_for_llm(self) -> str: + """Format tool information for LLM. + + Returns: + A formatted string describing the tool. + """ + args_desc: list[str] = [] + if "properties" in self.input_schema: + for param_name, param_info in self.input_schema["properties"].items(): + arg_desc = f"- {param_name}: {param_info.get('description', 'No description')}" + if param_name in self.input_schema.get("required", []): + arg_desc += " (required)" + args_desc.append(arg_desc) + + # Build the formatted output with title as a separate field + output = f"Tool: {self.name}\n" + + # Add human-readable title if available + if self.title: + output += f"User-readable title: {self.title}\n" + + output += f"""Description: {self.description} +Arguments: +{chr(10).join(args_desc)} +""" + + return output + + +class LLMClient: + """Manages communication with the LLM provider.""" + + def __init__(self, api_key: str) -> None: + self.api_key: str = api_key + + def get_response(self, messages: list[dict[str, str]]) -> str: + """Get a response from the LLM. + + Args: + messages: A list of message dictionaries. + + Returns: + The LLM's response as a string. + + Raises: + httpx.RequestError: If the request to the LLM fails. + """ + url = "https://api.groq.com/openai/v1/chat/completions" + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + } + payload = { + "messages": messages, + "model": "meta-llama/llama-4-scout-17b-16e-instruct", + "temperature": 0.7, + "max_tokens": 4096, + "top_p": 1, + "stream": False, + "stop": None, + } + + try: + with httpx.Client() as client: + response = client.post(url, headers=headers, json=payload) + response.raise_for_status() + data = response.json() + return data["choices"][0]["message"]["content"] + + except httpx.RequestError as e: + error_message = f"Error getting LLM response: {str(e)}" + logging.error(error_message) + + if isinstance(e, httpx.HTTPStatusError): + status_code = e.response.status_code + logging.error(f"Status code: {status_code}") + logging.error(f"Response details: {e.response.text}") + + return f"I encountered an error: {error_message}. Please try again or rephrase your request." + + +class ChatSession: + """Orchestrates the interaction between user, LLM, and tools.""" + + def __init__(self, servers: list[Server], llm_client: LLMClient) -> None: + self.servers: list[Server] = servers + self.llm_client: LLMClient = llm_client + + async def cleanup_servers(self) -> None: + """Clean up all servers properly.""" + for server in reversed(self.servers): + try: + await server.cleanup() + except Exception as e: + logging.warning(f"Warning during final cleanup: {e}") + + async def process_llm_response(self, llm_response: str) -> str: + """Process the LLM response and execute tools if needed. + + Args: + llm_response: The response from the LLM. + + Returns: + The result of tool execution or the original response. + """ + import json + + def _clean_json_string(json_string: str) -> str: + """Remove ```json ... ``` or ``` ... ``` wrappers if the LLM response is fenced.""" + import re + + pattern = r"^```(?:\s*json)?\s*(.*?)\s*```$" + return re.sub(pattern, r"\1", json_string, flags=re.DOTALL | re.IGNORECASE).strip() + + try: + tool_call = json.loads(_clean_json_string(llm_response)) + if "tool" in tool_call and "arguments" in tool_call: + logging.info(f"Executing tool: {tool_call['tool']}") + logging.info(f"With arguments: {tool_call['arguments']}") + + for server in self.servers: + tools = await server.list_tools() + if any(tool.name == tool_call["tool"] for tool in tools): + try: + result = await server.execute_tool(tool_call["tool"], tool_call["arguments"]) + + if isinstance(result, dict) and "progress" in result: + progress = result["progress"] # type: ignore + total = result["total"] # type: ignore + percentage = (progress / total) * 100 # type: ignore + logging.info(f"Progress: {progress}/{total} ({percentage:.1f}%)") + + return f"Tool execution result: {result}" + except Exception as e: + error_msg = f"Error executing tool: {str(e)}" + logging.error(error_msg) + return error_msg + + return f"No server found with tool: {tool_call['tool']}" + return llm_response + except json.JSONDecodeError: + return llm_response + + async def start(self) -> None: + """Main chat session handler.""" + try: + for server in self.servers: + try: + await server.initialize() + except Exception as e: + logging.error(f"Failed to initialize server: {e}") + await self.cleanup_servers() + return + + all_tools: list[Tool] = [] + for server in self.servers: + tools = await server.list_tools() + all_tools.extend(tools) + + tools_description = "\n".join([tool.format_for_llm() for tool in all_tools]) + + system_message = ( + "You are a helpful assistant with access to these tools:\n\n" + f"{tools_description}\n" + "Choose the appropriate tool based on the user's question. " + "If no tool is needed, reply directly.\n\n" + "IMPORTANT: When you need to use a tool, you must ONLY respond with " + "the exact JSON object format below, nothing else:\n" + "{\n" + ' "tool": "tool-name",\n' + ' "arguments": {\n' + ' "argument-name": "value"\n' + " }\n" + "}\n\n" + "After receiving a tool's response:\n" + "1. Transform the raw data into a natural, conversational response\n" + "2. Keep responses concise but informative\n" + "3. Focus on the most relevant information\n" + "4. Use appropriate context from the user's question\n" + "5. Avoid simply repeating the raw data\n\n" + "Please use only the tools that are explicitly defined above." + ) + + messages = [{"role": "system", "content": system_message}] + + while True: + try: + user_input = input("You: ").strip().lower() + if user_input in ["quit", "exit"]: + logging.info("\nExiting...") + break + + messages.append({"role": "user", "content": user_input}) + + llm_response = self.llm_client.get_response(messages) + logging.info("\nAssistant: %s", llm_response) + + result = await self.process_llm_response(llm_response) + + if result != llm_response: + messages.append({"role": "assistant", "content": llm_response}) + messages.append({"role": "system", "content": result}) + + final_response = self.llm_client.get_response(messages) + logging.info("\nFinal response: %s", final_response) + messages.append({"role": "assistant", "content": final_response}) + else: + messages.append({"role": "assistant", "content": llm_response}) + + except KeyboardInterrupt: + logging.info("\nExiting...") + break + + finally: + await self.cleanup_servers() + + +async def run() -> None: + """Initialize and run the chat session.""" + config = Configuration() + server_config = config.load_config("servers_config.json") + servers = [Server(name, srv_config) for name, srv_config in server_config["mcpServers"].items()] + llm_client = LLMClient(config.llm_api_key) + chat_session = ChatSession(servers, llm_client) + await chat_session.start() + + +def main() -> None: + asyncio.run(run()) + + +if __name__ == "__main__": + main() diff --git a/examples/clients/simple-chatbot/mcp_simple_chatbot/requirements.txt b/examples/clients/simple-chatbot/mcp_simple_chatbot/requirements.txt new file mode 100644 index 0000000000..2292072ffa --- /dev/null +++ b/examples/clients/simple-chatbot/mcp_simple_chatbot/requirements.txt @@ -0,0 +1,4 @@ +python-dotenv>=1.0.0 +requests>=2.31.0 +mcp>=1.0.0 +uvicorn>=0.32.1 diff --git a/examples/clients/simple-chatbot/mcp_simple_chatbot/servers_config.json b/examples/clients/simple-chatbot/mcp_simple_chatbot/servers_config.json new file mode 100644 index 0000000000..3a92d05d1e --- /dev/null +++ b/examples/clients/simple-chatbot/mcp_simple_chatbot/servers_config.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "sqlite": { + "command": "uvx", + "args": ["mcp-server-sqlite", "--db-path", "./test.db"] + }, + "puppeteer": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-puppeteer"] + } + } +} diff --git a/examples/clients/simple-chatbot/mcp_simple_chatbot/test.db b/examples/clients/simple-chatbot/mcp_simple_chatbot/test.db new file mode 100644 index 0000000000000000000000000000000000000000..d08dabc936040dd0098f4586d03152f7c6210c6f GIT binary patch literal 8192 zcmeI#OK;OK8~|{qco+&xwyse2sOD)x+r(psT_&M{yESX4b*OC$V+bVN#9PCfhn%z$ zLt^5<2?;Ly25{hjcIJYRkoX2jaNQNSazPryNjk2ak!&S#;=liX{Bp8UtJopKn;v&) zi08lnP!vEg1^}dbx2WQxh$rm5M^*IwuaW{+yU+VYFGbq};*v!QNC7Dz1*Cu!kOERb z3P=GdAO)m=6zB;&R8TITSDsFVwAp4p_pFW?2FG=3sje3qI&KseD>^=I#nWkwZ40m5 zZ0IGuj;r;RwL*OZuj(6@MZ~2J!wvmTBl_^UZ89uIsibG3vw2oUK>MM6)jnx&wHKPN z)wFpnul1?l)X(aB^^LlxcGbJ;b#+2?ltl_i0VyB_q<|EV0#ZNN^BPTYDBH7&C&+Z(QM~5J|gqH2lWkG-&oSNH=llw>arhk5XIg){329>B|yIZ)- zs1>k~m`8Eq$Y2x`30>C41&jKj$h&svzxtA17#o1#3NlR2n2W323z->uoJ{^YDD7t- zXEX?g(K>6I)FHLqc%~s0f>CtKSj6Q{laznIt9^eqey$&aakS=j0*0&9A~Q*T_ACT> zw8|beJ<2We;6}Wj;WH3Sp^9F_MHX!13Uz$4oQ!im4Z#Fjvt1h(x#u#%KZr&8uZK^^ zDPrlKkBv5CT}BE|;;D0|AecnemgtR*kh&I`O(xGH2xie@yTd}y3tQOm8I8n@L~QJ2 zwDZ5vOD)Q`M0CFA(icV7ZO+<}woZAoV+G`PVvvK;oQ6Yrh}UUown!_X9y|d-4wdX3 e$0JM0mNKcxutcpb@kbVt(KAZyYR7GJN!uSOx7u9* literal 0 HcmV?d00001 diff --git a/examples/clients/simple-chatbot/pyproject.toml b/examples/clients/simple-chatbot/pyproject.toml new file mode 100644 index 0000000000..2d7205735a --- /dev/null +++ b/examples/clients/simple-chatbot/pyproject.toml @@ -0,0 +1,47 @@ +[project] +name = "mcp-simple-chatbot" +version = "0.1.0" +description = "A simple CLI chatbot using the Model Context Protocol (MCP)" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "chatbot", "cli"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = [ + "python-dotenv>=1.0.0", + "mcp", + "uvicorn>=0.32.1", +] + +[project.scripts] +mcp-simple-chatbot = "mcp_simple_chatbot.main:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_chatbot"] + +[tool.pyright] +include = ["mcp_simple_chatbot"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/clients/sse-polling-client/README.md b/examples/clients/sse-polling-client/README.md new file mode 100644 index 0000000000..78449aa832 --- /dev/null +++ b/examples/clients/sse-polling-client/README.md @@ -0,0 +1,30 @@ +# MCP SSE Polling Demo Client + +Demonstrates client-side auto-reconnect for the SSE polling pattern (SEP-1699). + +## Features + +- Connects to SSE polling demo server +- Automatically reconnects when server closes SSE stream +- Resumes from Last-Event-ID to avoid missing messages +- Respects server-provided retry interval + +## Usage + +```bash +# First start the server: +uv run mcp-sse-polling-demo --port 3000 + +# Then run this client: +uv run mcp-sse-polling-client --url http://localhost:3000/mcp + +# Custom options: +uv run mcp-sse-polling-client --url http://localhost:3000/mcp --items 20 --checkpoint-every 5 +``` + +## Options + +- `--url`: Server URL (default: ) +- `--items`: Number of items to process (default: 10) +- `--checkpoint-every`: Checkpoint interval (default: 3) +- `--log-level`: Logging level (default: DEBUG) diff --git a/examples/clients/sse-polling-client/mcp_sse_polling_client/__init__.py b/examples/clients/sse-polling-client/mcp_sse_polling_client/__init__.py new file mode 100644 index 0000000000..ee69b32c96 --- /dev/null +++ b/examples/clients/sse-polling-client/mcp_sse_polling_client/__init__.py @@ -0,0 +1 @@ +"""SSE Polling Demo Client - demonstrates auto-reconnect for long-running tasks.""" diff --git a/examples/clients/sse-polling-client/mcp_sse_polling_client/main.py b/examples/clients/sse-polling-client/mcp_sse_polling_client/main.py new file mode 100644 index 0000000000..e91ed9d527 --- /dev/null +++ b/examples/clients/sse-polling-client/mcp_sse_polling_client/main.py @@ -0,0 +1,102 @@ +"""SSE Polling Demo Client + +Demonstrates the client-side auto-reconnect for SSE polling pattern. + +This client connects to the SSE Polling Demo server and calls process_batch, +which triggers periodic server-side stream closes. The client automatically +reconnects using Last-Event-ID and resumes receiving messages. + +Run with: + # First start the server: + uv run mcp-sse-polling-demo --port 3000 + + # Then run this client: + uv run mcp-sse-polling-client --url http://localhost:3000/mcp +""" + +import asyncio +import logging + +import click +from mcp import ClientSession +from mcp.client.streamable_http import streamable_http_client + + +async def run_demo(url: str, items: int, checkpoint_every: int) -> None: + """Run the SSE polling demo.""" + print(f"\n{'=' * 60}") + print("SSE Polling Demo Client") + print(f"{'=' * 60}") + print(f"Server URL: {url}") + print(f"Processing {items} items with checkpoints every {checkpoint_every}") + print(f"{'=' * 60}\n") + + async with streamable_http_client(url) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + # Initialize the connection + print("Initializing connection...") + await session.initialize() + print("Connected!\n") + + # List available tools + tools = await session.list_tools() + print(f"Available tools: {[t.name for t in tools.tools]}\n") + + # Call the process_batch tool + print(f"Calling process_batch(items={items}, checkpoint_every={checkpoint_every})...\n") + print("-" * 40) + + result = await session.call_tool( + "process_batch", + { + "items": items, + "checkpoint_every": checkpoint_every, + }, + ) + + print("-" * 40) + if result.content: + content = result.content[0] + text = getattr(content, "text", str(content)) + print(f"\nResult: {text}") + else: + print("\nResult: No content") + print(f"{'=' * 60}\n") + + +@click.command() +@click.option( + "--url", + default="http://localhost:3000/mcp", + help="Server URL", +) +@click.option( + "--items", + default=10, + help="Number of items to process", +) +@click.option( + "--checkpoint-every", + default=3, + help="Checkpoint interval", +) +@click.option( + "--log-level", + default="INFO", + help="Logging level", +) +def main(url: str, items: int, checkpoint_every: int, log_level: str) -> None: + """Run the SSE Polling Demo client.""" + logging.basicConfig( + level=getattr(logging, log_level.upper()), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + # Suppress noisy HTTP client logging + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("httpcore").setLevel(logging.WARNING) + + asyncio.run(run_demo(url, items, checkpoint_every)) + + +if __name__ == "__main__": + main() diff --git a/examples/clients/sse-polling-client/pyproject.toml b/examples/clients/sse-polling-client/pyproject.toml new file mode 100644 index 0000000000..4db29857fd --- /dev/null +++ b/examples/clients/sse-polling-client/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "mcp-sse-polling-client" +version = "0.1.0" +description = "Demo client for SSE polling with auto-reconnect" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "sse", "polling", "client"] +license = { text = "MIT" } +dependencies = ["click>=8.2.0", "mcp"] + +[project.scripts] +mcp-sse-polling-client = "mcp_sse_polling_client.main:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_sse_polling_client"] + +[tool.pyright] +include = ["mcp_sse_polling_client"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-auth/README.md b/examples/servers/simple-auth/README.md new file mode 100644 index 0000000000..d4a10c43b0 --- /dev/null +++ b/examples/servers/simple-auth/README.md @@ -0,0 +1,135 @@ +# MCP OAuth Authentication Demo + +This example demonstrates OAuth 2.0 authentication with the Model Context Protocol using **separate Authorization Server (AS) and Resource Server (RS)** to comply with the new RFC 9728 specification. + +--- + +## Running the Servers + +### Step 1: Start Authorization Server + +```bash +# Navigate to the simple-auth directory +cd examples/servers/simple-auth + +# Start Authorization Server on port 9000 +uv run mcp-simple-auth-as --port=9000 +``` + +**What it provides:** + +- OAuth 2.0 flows (registration, authorization, token exchange) +- Simple credential-based authentication (no external provider needed) +- Token introspection endpoint for Resource Servers (`/introspect`) + +--- + +### Step 2: Start Resource Server (MCP Server) + +```bash +# In another terminal, navigate to the simple-auth directory +cd examples/servers/simple-auth + +# Start Resource Server on port 8001, connected to Authorization Server +uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http + +# With RFC 8707 strict resource validation (recommended for production) +uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http --oauth-strict + +``` + +### Step 3: Test with Client + +```bash +cd examples/clients/simple-auth-client +# Start client with streamable HTTP +MCP_SERVER_PORT=8001 MCP_TRANSPORT_TYPE=streamable-http uv run mcp-simple-auth-client +``` + +## How It Works + +### RFC 9728 Discovery + +**Client → Resource Server:** + +```bash +curl http://localhost:8001/.well-known/oauth-protected-resource +``` + +```json +{ + "resource": "http://localhost:8001", + "authorization_servers": ["http://localhost:9000"] +} +``` + +**Client → Authorization Server:** + +```bash +curl http://localhost:9000/.well-known/oauth-authorization-server +``` + +```json +{ + "issuer": "http://localhost:9000", + "authorization_endpoint": "http://localhost:9000/authorize", + "token_endpoint": "http://localhost:9000/token" +} +``` + +## Legacy MCP Server as Authorization Server (Backwards Compatibility) + +For backwards compatibility with older MCP implementations, a legacy server is provided that acts as an Authorization Server (following the old spec where MCP servers could optionally provide OAuth): + +### Running the Legacy Server + +```bash +# Start legacy server on port 8000 (the default) +cd examples/servers/simple-auth +uv run mcp-simple-auth-legacy --port=8000 --transport=streamable-http +``` + +**Differences from the new architecture:** + +- **MCP server acts as AS:** The MCP server itself provides OAuth endpoints (old spec behavior) +- **No separate RS:** The server handles both authentication and MCP tools +- **Local token validation:** Tokens are validated internally without introspection +- **No RFC 9728 support:** Does not provide `/.well-known/oauth-protected-resource` +- **Direct OAuth discovery:** OAuth metadata is at the MCP server's URL + +### Testing with Legacy Server + +```bash +# Test with client (will automatically fall back to legacy discovery) +cd examples/clients/simple-auth-client +MCP_SERVER_PORT=8000 MCP_TRANSPORT_TYPE=streamable-http uv run mcp-simple-auth-client +``` + +The client will: + +1. Try RFC 9728 discovery at `/.well-known/oauth-protected-resource` (404 on legacy server) +2. Fall back to direct OAuth discovery at `/.well-known/oauth-authorization-server` +3. Complete authentication with the MCP server acting as its own AS + +This ensures existing MCP servers (which could optionally act as Authorization Servers under the old spec) continue to work while the ecosystem transitions to the new architecture where MCP servers are Resource Servers only. + +## Manual Testing + +### Test Discovery + +```bash +# Test Resource Server discovery endpoint (new architecture) +curl -v http://localhost:8001/.well-known/oauth-protected-resource + +# Test Authorization Server metadata +curl -v http://localhost:9000/.well-known/oauth-authorization-server +``` + +### Test Token Introspection + +```bash +# After getting a token through OAuth flow: +curl -X POST http://localhost:9000/introspect \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "token=your_access_token" +``` diff --git a/examples/servers/simple-auth/mcp_simple_auth/__init__.py b/examples/servers/simple-auth/mcp_simple_auth/__init__.py new file mode 100644 index 0000000000..3e12b31832 --- /dev/null +++ b/examples/servers/simple-auth/mcp_simple_auth/__init__.py @@ -0,0 +1 @@ +"""Simple MCP server with GitHub OAuth authentication.""" diff --git a/examples/servers/simple-auth/mcp_simple_auth/__main__.py b/examples/servers/simple-auth/mcp_simple_auth/__main__.py new file mode 100644 index 0000000000..2365ff5a1b --- /dev/null +++ b/examples/servers/simple-auth/mcp_simple_auth/__main__.py @@ -0,0 +1,7 @@ +"""Main entry point for simple MCP server with GitHub OAuth authentication.""" + +import sys + +from mcp_simple_auth.server import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-auth/mcp_simple_auth/auth_server.py b/examples/servers/simple-auth/mcp_simple_auth/auth_server.py new file mode 100644 index 0000000000..26c87c5ef2 --- /dev/null +++ b/examples/servers/simple-auth/mcp_simple_auth/auth_server.py @@ -0,0 +1,185 @@ +"""Authorization Server for MCP Split Demo. + +This server handles OAuth flows, client registration, and token issuance. +Can be replaced with enterprise authorization servers like Auth0, Entra ID, etc. + +NOTE: this is a simplified example for demonstration purposes. +This is not a production-ready implementation. + +""" + +import asyncio +import logging +import time + +import click +from pydantic import AnyHttpUrl, BaseModel +from starlette.applications import Starlette +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Route +from uvicorn import Config, Server + +from mcp.server.auth.routes import cors_middleware, create_auth_routes +from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions + +from .simple_auth_provider import SimpleAuthSettings, SimpleOAuthProvider + +logger = logging.getLogger(__name__) + + +class AuthServerSettings(BaseModel): + """Settings for the Authorization Server.""" + + # Server settings + host: str = "localhost" + port: int = 9000 + server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") + auth_callback_path: str = "http://localhost:9000/login/callback" + + +class SimpleAuthProvider(SimpleOAuthProvider): + """Authorization Server provider with simple demo authentication. + + This provider: + 1. Issues MCP tokens after simple credential authentication + 2. Stores token state for introspection by Resource Servers + """ + + def __init__(self, auth_settings: SimpleAuthSettings, auth_callback_path: str, server_url: str): + super().__init__(auth_settings, auth_callback_path, server_url) + + +def create_authorization_server(server_settings: AuthServerSettings, auth_settings: SimpleAuthSettings) -> Starlette: + """Create the Authorization Server application.""" + oauth_provider = SimpleAuthProvider( + auth_settings, server_settings.auth_callback_path, str(server_settings.server_url) + ) + + mcp_auth_settings = AuthSettings( + issuer_url=server_settings.server_url, + client_registration_options=ClientRegistrationOptions( + enabled=True, + valid_scopes=[auth_settings.mcp_scope], + default_scopes=[auth_settings.mcp_scope], + ), + required_scopes=[auth_settings.mcp_scope], + resource_server_url=None, + ) + + # Create OAuth routes + routes = create_auth_routes( + provider=oauth_provider, + issuer_url=mcp_auth_settings.issuer_url, + service_documentation_url=mcp_auth_settings.service_documentation_url, + client_registration_options=mcp_auth_settings.client_registration_options, + revocation_options=mcp_auth_settings.revocation_options, + ) + + # Add login page route (GET) + async def login_page_handler(request: Request) -> Response: + """Show login form.""" + state = request.query_params.get("state") + if not state: + raise HTTPException(400, "Missing state parameter") + return await oauth_provider.get_login_page(state) + + routes.append(Route("/login", endpoint=login_page_handler, methods=["GET"])) + + # Add login callback route (POST) + async def login_callback_handler(request: Request) -> Response: + """Handle simple authentication callback.""" + return await oauth_provider.handle_login_callback(request) + + routes.append(Route("/login/callback", endpoint=login_callback_handler, methods=["POST"])) + + # Add token introspection endpoint (RFC 7662) for Resource Servers + async def introspect_handler(request: Request) -> Response: + """Token introspection endpoint for Resource Servers. + + Resource Servers call this endpoint to validate tokens without + needing direct access to token storage. + """ + form = await request.form() + token = form.get("token") + if not token or not isinstance(token, str): + return JSONResponse({"active": False}, status_code=400) + + # Look up token in provider + access_token = await oauth_provider.load_access_token(token) + if not access_token: + return JSONResponse({"active": False}) + + return JSONResponse( + { + "active": True, + "client_id": access_token.client_id, + "scope": " ".join(access_token.scopes), + "exp": access_token.expires_at, + "iat": int(time.time()), + "token_type": "Bearer", + "aud": access_token.resource, # RFC 8707 audience claim + "sub": access_token.subject, # RFC 7662 subject + "iss": str(server_settings.server_url), + } + ) + + routes.append( + Route( + "/introspect", + endpoint=cors_middleware(introspect_handler, ["POST", "OPTIONS"]), + methods=["POST", "OPTIONS"], + ) + ) + + return Starlette(routes=routes) + + +async def run_server(server_settings: AuthServerSettings, auth_settings: SimpleAuthSettings): + """Run the Authorization Server.""" + auth_server = create_authorization_server(server_settings, auth_settings) + + config = Config( + auth_server, + host=server_settings.host, + port=server_settings.port, + log_level="info", + ) + server = Server(config) + + logger.info(f"🚀 MCP Authorization Server running on {server_settings.server_url}") + + await server.serve() + + +@click.command() +@click.option("--port", default=9000, help="Port to listen on") +def main(port: int) -> int: + """Run the MCP Authorization Server. + + This server handles OAuth flows and can be used by multiple Resource Servers. + + Uses simple hardcoded credentials for demo purposes. + """ + logging.basicConfig(level=logging.INFO) + + # Load simple auth settings + auth_settings = SimpleAuthSettings() + + # Create server settings + host = "localhost" + server_url = f"http://{host}:{port}" + server_settings = AuthServerSettings( + host=host, + port=port, + server_url=AnyHttpUrl(server_url), + auth_callback_path=f"{server_url}/login", + ) + + asyncio.run(run_server(server_settings, auth_settings)) + return 0 + + +if __name__ == "__main__": + main() # type: ignore[call-arg] diff --git a/examples/servers/simple-auth/mcp_simple_auth/legacy_as_server.py b/examples/servers/simple-auth/mcp_simple_auth/legacy_as_server.py new file mode 100644 index 0000000000..ab7773b5bb --- /dev/null +++ b/examples/servers/simple-auth/mcp_simple_auth/legacy_as_server.py @@ -0,0 +1,137 @@ +"""Legacy Combined Authorization Server + Resource Server for MCP. + +This server implements the old spec where MCP servers could act as both AS and RS. +Used for backwards compatibility testing with the new split AS/RS architecture. + +NOTE: this is a simplified example for demonstration purposes. +This is not a production-ready implementation. + +""" + +import datetime +import logging +from typing import Any, Literal + +import click +from pydantic import AnyHttpUrl, BaseModel +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import Response + +from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions +from mcp.server.mcpserver.server import MCPServer + +from .simple_auth_provider import SimpleAuthSettings, SimpleOAuthProvider + +logger = logging.getLogger(__name__) + + +class ServerSettings(BaseModel): + """Settings for the simple auth MCP server.""" + + # Server settings + host: str = "localhost" + port: int = 8000 + server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8000") + auth_callback_path: str = "http://localhost:8000/login/callback" + + +class LegacySimpleOAuthProvider(SimpleOAuthProvider): + """Simple OAuth provider for legacy MCP server.""" + + def __init__(self, auth_settings: SimpleAuthSettings, auth_callback_path: str, server_url: str): + super().__init__(auth_settings, auth_callback_path, server_url) + + +def create_simple_mcp_server(server_settings: ServerSettings, auth_settings: SimpleAuthSettings) -> MCPServer: + """Create a simple MCPServer server with simple authentication.""" + oauth_provider = LegacySimpleOAuthProvider( + auth_settings, server_settings.auth_callback_path, str(server_settings.server_url) + ) + + mcp_auth_settings = AuthSettings( + issuer_url=server_settings.server_url, + client_registration_options=ClientRegistrationOptions( + enabled=True, + valid_scopes=[auth_settings.mcp_scope], + default_scopes=[auth_settings.mcp_scope], + ), + required_scopes=[auth_settings.mcp_scope], + # No resource_server_url parameter in legacy mode + resource_server_url=None, + ) + + app = MCPServer( + name="Simple Auth MCP Server", + instructions="A simple MCP server with simple credential authentication", + auth_server_provider=oauth_provider, + debug=True, + auth=mcp_auth_settings, + ) + # Store server settings for later use in run() + app._server_settings = server_settings # type: ignore[attr-defined] + + @app.custom_route("/login", methods=["GET"]) + async def login_page_handler(request: Request) -> Response: + """Show login form.""" + state = request.query_params.get("state") + if not state: + raise HTTPException(400, "Missing state parameter") + return await oauth_provider.get_login_page(state) + + @app.custom_route("/login/callback", methods=["POST"]) + async def login_callback_handler(request: Request) -> Response: + """Handle simple authentication callback.""" + return await oauth_provider.handle_login_callback(request) + + @app.tool() + async def get_time() -> dict[str, Any]: + """Get the current server time. + + This tool demonstrates that system information can be protected + by OAuth authentication. User must be authenticated to access it. + """ + + now = datetime.datetime.now() + + return { + "current_time": now.isoformat(), + "timezone": "UTC", # Simplified for demo + "timestamp": now.timestamp(), + "formatted": now.strftime("%Y-%m-%d %H:%M:%S"), + } + + return app + + +@click.command() +@click.option("--port", default=8000, help="Port to listen on") +@click.option( + "--transport", + default="streamable-http", + type=click.Choice(["sse", "streamable-http"]), + help="Transport protocol to use ('sse' or 'streamable-http')", +) +def main(port: int, transport: Literal["sse", "streamable-http"]) -> int: + """Run the simple auth MCP server.""" + logging.basicConfig(level=logging.INFO) + + auth_settings = SimpleAuthSettings() + # Create server settings + host = "localhost" + server_url = f"http://{host}:{port}" + server_settings = ServerSettings( + host=host, + port=port, + server_url=AnyHttpUrl(server_url), + auth_callback_path=f"{server_url}/login", + ) + + mcp_server = create_simple_mcp_server(server_settings, auth_settings) + logger.info(f"🚀 MCP Legacy Server running on {server_url}") + mcp_server.run(transport=transport, host=host, port=port) + return 0 + + +if __name__ == "__main__": + main() # type: ignore[call-arg] diff --git a/examples/servers/simple-auth/mcp_simple_auth/py.typed b/examples/servers/simple-auth/mcp_simple_auth/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/servers/simple-auth/mcp_simple_auth/server.py b/examples/servers/simple-auth/mcp_simple_auth/server.py new file mode 100644 index 0000000000..0320871b12 --- /dev/null +++ b/examples/servers/simple-auth/mcp_simple_auth/server.py @@ -0,0 +1,161 @@ +"""MCP Resource Server with Token Introspection. + +This server validates tokens via Authorization Server introspection and serves MCP resources. +Demonstrates RFC 9728 Protected Resource Metadata for AS/RS separation. + +NOTE: this is a simplified example for demonstration purposes. +This is not a production-ready implementation. +""" + +import datetime +import logging +from typing import Any, Literal + +import click +from pydantic import AnyHttpUrl +from pydantic_settings import BaseSettings, SettingsConfigDict + +from mcp.server.auth.settings import AuthSettings +from mcp.server.mcpserver.server import MCPServer + +from .token_verifier import IntrospectionTokenVerifier + +logger = logging.getLogger(__name__) + + +class ResourceServerSettings(BaseSettings): + """Settings for the MCP Resource Server.""" + + model_config = SettingsConfigDict(env_prefix="MCP_RESOURCE_") + + # Server settings + host: str = "localhost" + port: int = 8001 + server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8001/mcp") + + # Authorization Server settings + auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") + auth_server_introspection_endpoint: str = "http://localhost:9000/introspect" + # No user endpoint needed - we get user data from token introspection + + # MCP settings + mcp_scope: str = "user" + + # RFC 8707 resource validation + oauth_strict: bool = False + + +def create_resource_server(settings: ResourceServerSettings) -> MCPServer: + """Create MCP Resource Server with token introspection. + + This server: + 1. Provides protected resource metadata (RFC 9728) + 2. Validates tokens via Authorization Server introspection + 3. Serves MCP tools and resources + """ + # Create token verifier for introspection with RFC 8707 resource validation + token_verifier = IntrospectionTokenVerifier( + introspection_endpoint=settings.auth_server_introspection_endpoint, + server_url=str(settings.server_url), + validate_resource=settings.oauth_strict, # Only validate when --oauth-strict is set + ) + + # Create MCPServer server as a Resource Server + app = MCPServer( + name="MCP Resource Server", + instructions="Resource Server that validates tokens via Authorization Server introspection", + debug=True, + # Auth configuration for RS mode + token_verifier=token_verifier, + auth=AuthSettings( + issuer_url=settings.auth_server_url, + required_scopes=[settings.mcp_scope], + resource_server_url=settings.server_url, + ), + ) + # Store settings for later use in run() + app._resource_server_settings = settings # type: ignore[attr-defined] + + @app.tool() + async def get_time() -> dict[str, Any]: + """Get the current server time. + + This tool demonstrates that system information can be protected + by OAuth authentication. User must be authenticated to access it. + """ + + now = datetime.datetime.now() + + return { + "current_time": now.isoformat(), + "timezone": "UTC", # Simplified for demo + "timestamp": now.timestamp(), + "formatted": now.strftime("%Y-%m-%d %H:%M:%S"), + } + + return app + + +@click.command() +@click.option("--port", default=8001, help="Port to listen on") +@click.option("--auth-server", default="http://localhost:9000", help="Authorization Server URL") +@click.option( + "--transport", + default="streamable-http", + type=click.Choice(["sse", "streamable-http"]), + help="Transport protocol to use ('sse' or 'streamable-http')", +) +@click.option( + "--oauth-strict", + is_flag=True, + help="Enable RFC 8707 resource validation", +) +def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http"], oauth_strict: bool) -> int: + """Run the MCP Resource Server. + + This server: + - Provides RFC 9728 Protected Resource Metadata + - Validates tokens via Authorization Server introspection + - Serves MCP tools requiring authentication + + Must be used with a running Authorization Server. + """ + logging.basicConfig(level=logging.INFO) + + try: + # Parse auth server URL + auth_server_url = AnyHttpUrl(auth_server) + + # Create settings + host = "localhost" + server_url = f"http://{host}:{port}/mcp" + settings = ResourceServerSettings( + host=host, + port=port, + server_url=AnyHttpUrl(server_url), + auth_server_url=auth_server_url, + auth_server_introspection_endpoint=f"{auth_server}/introspect", + oauth_strict=oauth_strict, + ) + except ValueError as e: + logger.error(f"Configuration error: {e}") + logger.error("Make sure to provide a valid Authorization Server URL") + return 1 + + try: + mcp_server = create_resource_server(settings) + + logger.info(f"🚀 MCP Resource Server running on {settings.server_url}") + logger.info(f"🔑 Using Authorization Server: {settings.auth_server_url}") + + # Run the server - this should block and keep running + mcp_server.run(transport=transport, host=host, port=port) + logger.info("Server stopped") + return 0 + except Exception: + logger.exception("Server error") + return 1 + + +if __name__ == "__main__": + main() # type: ignore[call-arg] diff --git a/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py b/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py new file mode 100644 index 0000000000..48eb9a8414 --- /dev/null +++ b/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py @@ -0,0 +1,272 @@ +"""Simple OAuth provider for MCP servers. + +This module contains a basic OAuth implementation using hardcoded user credentials +for demonstration purposes. No external authentication provider is required. + +NOTE: this is a simplified example for demonstration purposes. +This is not a production-ready implementation. + +""" + +import secrets +import time +from typing import Any + +from pydantic import AnyHttpUrl +from pydantic_settings import BaseSettings, SettingsConfigDict +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import HTMLResponse, RedirectResponse, Response + +from mcp.server.auth.provider import ( + AccessToken, + AuthorizationCode, + AuthorizationParams, + OAuthAuthorizationServerProvider, + RefreshToken, + construct_redirect_uri, +) +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken + + +class SimpleAuthSettings(BaseSettings): + """Simple OAuth settings for demo purposes.""" + + model_config = SettingsConfigDict(env_prefix="MCP_") + + # Demo user credentials + demo_username: str = "demo_user" + demo_password: str = "demo_password" + + # MCP OAuth scope + mcp_scope: str = "user" + + +class SimpleOAuthProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]): + """Simple OAuth provider for demo purposes. + + This provider handles the OAuth flow by: + 1. Providing a simple login form for demo credentials + 2. Issuing MCP tokens after successful authentication + 3. Maintaining token state for introspection + """ + + def __init__(self, settings: SimpleAuthSettings, auth_callback_url: str, server_url: str): + self.settings = settings + self.auth_callback_url = auth_callback_url + self.server_url = server_url + self.clients: dict[str, OAuthClientInformationFull] = {} + self.auth_codes: dict[str, AuthorizationCode] = {} + self.tokens: dict[str, AccessToken] = {} + self.state_mapping: dict[str, dict[str, str | None]] = {} + # Store authenticated user information + self.user_data: dict[str, dict[str, Any]] = {} + + async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: + """Get OAuth client information.""" + return self.clients.get(client_id) + + async def register_client(self, client_info: OAuthClientInformationFull): + """Register a new OAuth client.""" + if not client_info.client_id: + raise ValueError("No client_id provided") + self.clients[client_info.client_id] = client_info + + async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: + """Generate an authorization URL for simple login flow.""" + state = params.state or secrets.token_hex(16) + + # Store state mapping for callback + self.state_mapping[state] = { + "redirect_uri": str(params.redirect_uri), + "code_challenge": params.code_challenge, + "redirect_uri_provided_explicitly": str(params.redirect_uri_provided_explicitly), + "client_id": client.client_id, + "resource": params.resource, # RFC 8707 + } + + # Build simple login URL that points to login page + auth_url = f"{self.auth_callback_url}?state={state}&client_id={client.client_id}" + + return auth_url + + async def get_login_page(self, state: str) -> HTMLResponse: + """Generate login page HTML for the given state.""" + if not state: + raise HTTPException(400, "Missing state parameter") + + # Create simple login form HTML + html_content = f""" + + + + MCP Demo Authentication + + + +

MCP Demo Authentication

+

This is a simplified authentication demo. Use the demo credentials below:

+

Username: demo_user
+ Password: demo_password

+ +
+ +
+ + +
+
+ + +
+ +
+ + + """ + + return HTMLResponse(content=html_content) + + async def handle_login_callback(self, request: Request) -> Response: + """Handle login form submission callback.""" + form = await request.form() + username = form.get("username") + password = form.get("password") + state = form.get("state") + + if not username or not password or not state: + raise HTTPException(400, "Missing username, password, or state parameter") + + # Ensure we have strings, not UploadFile objects + if not isinstance(username, str) or not isinstance(password, str) or not isinstance(state, str): + raise HTTPException(400, "Invalid parameter types") + + redirect_uri = await self.handle_simple_callback(username, password, state) + return RedirectResponse(url=redirect_uri, status_code=302) + + async def handle_simple_callback(self, username: str, password: str, state: str) -> str: + """Handle simple authentication callback and return redirect URI.""" + state_data = self.state_mapping.get(state) + if not state_data: + raise HTTPException(400, "Invalid state parameter") + + redirect_uri = state_data["redirect_uri"] + code_challenge = state_data["code_challenge"] + redirect_uri_provided_explicitly = state_data["redirect_uri_provided_explicitly"] == "True" + client_id = state_data["client_id"] + resource = state_data.get("resource") # RFC 8707 + + # These are required values from our own state mapping + assert redirect_uri is not None + assert code_challenge is not None + assert client_id is not None + + # Validate demo credentials + if username != self.settings.demo_username or password != self.settings.demo_password: + raise HTTPException(401, "Invalid credentials") + + # Create MCP authorization code + new_code = f"mcp_{secrets.token_hex(16)}" + auth_code = AuthorizationCode( + code=new_code, + client_id=client_id, + redirect_uri=AnyHttpUrl(redirect_uri), + redirect_uri_provided_explicitly=redirect_uri_provided_explicitly, + expires_at=time.time() + 300, + scopes=[self.settings.mcp_scope], + code_challenge=code_challenge, + resource=resource, # RFC 8707 + subject=username, + ) + self.auth_codes[new_code] = auth_code + + # Store user data + self.user_data[username] = { + "username": username, + "user_id": f"user_{secrets.token_hex(8)}", + "authenticated_at": time.time(), + } + + del self.state_mapping[state] + return construct_redirect_uri(redirect_uri, code=new_code, state=state) + + async def load_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: str + ) -> AuthorizationCode | None: + """Load an authorization code.""" + return self.auth_codes.get(authorization_code) + + async def exchange_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode + ) -> OAuthToken: + """Exchange authorization code for tokens.""" + if authorization_code.code not in self.auth_codes: + raise ValueError("Invalid authorization code") + if not client.client_id: + raise ValueError("No client_id provided") + + # Generate MCP access token + mcp_token = f"mcp_{secrets.token_hex(32)}" + + # Store MCP token + self.tokens[mcp_token] = AccessToken( + token=mcp_token, + client_id=client.client_id, + scopes=authorization_code.scopes, + expires_at=int(time.time()) + 3600, + resource=authorization_code.resource, # RFC 8707 + subject=authorization_code.subject, + ) + + # Store user data mapping for this token + self.user_data[mcp_token] = { + "username": self.settings.demo_username, + "user_id": f"user_{secrets.token_hex(8)}", + "authenticated_at": time.time(), + } + + del self.auth_codes[authorization_code.code] + + return OAuthToken( + access_token=mcp_token, + token_type="Bearer", + expires_in=3600, + scope=" ".join(authorization_code.scopes), + ) + + async def load_access_token(self, token: str) -> AccessToken | None: + """Load and validate an access token.""" + access_token = self.tokens.get(token) + if not access_token: + return None + + # Check if expired + if access_token.expires_at and access_token.expires_at < time.time(): + del self.tokens[token] + return None + + return access_token + + async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None: + """Load a refresh token - not supported in this example.""" + return None + + async def exchange_refresh_token( + self, + client: OAuthClientInformationFull, + refresh_token: RefreshToken, + scopes: list[str], + ) -> OAuthToken: + """Exchange refresh token - not supported in this example.""" + raise NotImplementedError("Refresh tokens not supported") + + # TODO(Marcelo): The type hint is wrong. We need to fix, and test to check if it works. + async def revoke_token(self, token: str, token_type_hint: str | None = None) -> None: # type: ignore + """Revoke a token.""" + if token in self.tokens: + del self.tokens[token] diff --git a/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py b/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py new file mode 100644 index 0000000000..641095a125 --- /dev/null +++ b/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py @@ -0,0 +1,108 @@ +"""Example token verifier implementation using OAuth 2.0 Token Introspection (RFC 7662).""" + +import logging +from typing import Any + +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url + +logger = logging.getLogger(__name__) + + +class IntrospectionTokenVerifier(TokenVerifier): + """Example token verifier that uses OAuth 2.0 Token Introspection (RFC 7662). + + This is a simple example implementation for demonstration purposes. + Production implementations should consider: + - Connection pooling and reuse + - More sophisticated error handling + - Rate limiting and retry logic + - Comprehensive configuration options + """ + + def __init__( + self, + introspection_endpoint: str, + server_url: str, + validate_resource: bool = False, + ): + self.introspection_endpoint = introspection_endpoint + self.server_url = server_url + self.validate_resource = validate_resource + self.resource_url = resource_url_from_server_url(server_url) + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify token via introspection endpoint.""" + import httpx + + # Validate URL to prevent SSRF attacks + if not self.introspection_endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1")): + logger.warning(f"Rejecting introspection endpoint with unsafe scheme: {self.introspection_endpoint}") + return None + + # Configure secure HTTP client + timeout = httpx.Timeout(10.0, connect=5.0) + limits = httpx.Limits(max_connections=10, max_keepalive_connections=5) + + async with httpx.AsyncClient( + timeout=timeout, + limits=limits, + verify=True, # Enforce SSL verification + ) as client: + try: + response = await client.post( + self.introspection_endpoint, + data={"token": token}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + if response.status_code != 200: + logger.debug(f"Token introspection returned status {response.status_code}") + return None + + data = response.json() + if not data.get("active", False): + return None + + # RFC 8707 resource validation (only when --oauth-strict is set) + if self.validate_resource and not self._validate_resource(data): + logger.warning(f"Token resource validation failed. Expected: {self.resource_url}") + return None + + return AccessToken( + token=token, + client_id=data.get("client_id", "unknown"), + scopes=data.get("scope", "").split() if data.get("scope") else [], + expires_at=data.get("exp"), + resource=data.get("aud"), # Include resource in token + subject=data.get("sub"), # RFC 7662 subject (resource owner) + claims=data, + ) + except Exception as e: + logger.warning(f"Token introspection failed: {e}") + return None + + def _validate_resource(self, token_data: dict[str, Any]) -> bool: + """Validate token was issued for this resource server.""" + if not self.server_url or not self.resource_url: + return False # Fail if strict validation requested but URLs missing + + # Check 'aud' claim first (standard JWT audience) + aud: list[str] | str | None = token_data.get("aud") + if isinstance(aud, list): + for audience in aud: + if self._is_valid_resource(audience): + return True + return False + elif aud: + return self._is_valid_resource(aud) + + # No resource binding - invalid per RFC 8707 + return False + + def _is_valid_resource(self, resource: str) -> bool: + """Check if resource matches this server using hierarchical matching.""" + if not self.resource_url: + return False + + return check_resource_allowed(requested_resource=self.resource_url, configured_resource=resource) diff --git a/examples/servers/simple-auth/pyproject.toml b/examples/servers/simple-auth/pyproject.toml new file mode 100644 index 0000000000..1ffe3e694b --- /dev/null +++ b/examples/servers/simple-auth/pyproject.toml @@ -0,0 +1,33 @@ +[project] +name = "mcp-simple-auth" +version = "0.1.0" +description = "A simple MCP server demonstrating OAuth authentication" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +license = { text = "MIT" } +dependencies = [ + "anyio>=4.5", + "click>=8.2.0", + "httpx>=0.27", + "mcp", + "pydantic>=2.0", + "pydantic-settings>=2.5.2", + "sse-starlette>=1.6.1", + "uvicorn>=0.23.1; sys_platform != 'emscripten'", +] + +[project.scripts] +mcp-simple-auth-rs = "mcp_simple_auth.server:main" +mcp-simple-auth-as = "mcp_simple_auth.auth_server:main" +mcp-simple-auth-legacy = "mcp_simple_auth.legacy_as_server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_auth"] + +[dependency-groups] +dev = ["pyright>=1.1.391", "pytest>=8.3.4", "ruff>=0.8.5"] diff --git a/examples/servers/simple-pagination/README.md b/examples/servers/simple-pagination/README.md new file mode 100644 index 0000000000..4cab40fd34 --- /dev/null +++ b/examples/servers/simple-pagination/README.md @@ -0,0 +1,77 @@ +# MCP Simple Pagination + +A simple MCP server demonstrating pagination for tools, resources, and prompts using cursor-based pagination. + +## Usage + +Start the server using either stdio (default) or Streamable HTTP transport: + +```bash +# Using stdio transport (default) +uv run mcp-simple-pagination + +# Using Streamable HTTP transport on custom port +uv run mcp-simple-pagination --transport streamable-http --port 8000 +``` + +The server exposes: + +- 25 tools (paginated, 5 per page) +- 30 resources (paginated, 10 per page) +- 20 prompts (paginated, 7 per page) + +Each paginated list returns a `nextCursor` when more pages are available. Use this cursor in subsequent requests to retrieve the next page. + +## Example + +Using the MCP client, you can retrieve paginated items like this using the STDIO transport: + +```python +import asyncio +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client + + +async def main(): + async with stdio_client( + StdioServerParameters(command="uv", args=["run", "mcp-simple-pagination"]) + ) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + # Get first page of tools + tools_page1 = await session.list_tools() + print(f"First page: {len(tools_page1.tools)} tools") + print(f"Next cursor: {tools_page1.nextCursor}") + + # Get second page using cursor + if tools_page1.nextCursor: + tools_page2 = await session.list_tools(cursor=tools_page1.nextCursor) + print(f"Second page: {len(tools_page2.tools)} tools") + + # Similarly for resources + resources_page1 = await session.list_resources() + print(f"First page: {len(resources_page1.resources)} resources") + + # And for prompts + prompts_page1 = await session.list_prompts() + print(f"First page: {len(prompts_page1.prompts)} prompts") + + +asyncio.run(main()) +``` + +## Pagination Details + +The server uses simple numeric indices as cursors for demonstration purposes. In production scenarios, you might use: + +- Database offsets or row IDs +- Timestamps for time-based pagination +- Opaque tokens encoding pagination state + +The pagination implementation demonstrates: + +- Handling `None` cursor for the first page +- Returning `nextCursor` when more data exists +- Gracefully handling invalid cursors +- Different page sizes for different resource types diff --git a/examples/servers/simple-pagination/mcp_simple_pagination/__init__.py b/examples/servers/simple-pagination/mcp_simple_pagination/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/servers/simple-pagination/mcp_simple_pagination/__main__.py b/examples/servers/simple-pagination/mcp_simple_pagination/__main__.py new file mode 100644 index 0000000000..e7ef16530b --- /dev/null +++ b/examples/servers/simple-pagination/mcp_simple_pagination/__main__.py @@ -0,0 +1,5 @@ +import sys + +from .server import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-pagination/mcp_simple_pagination/server.py b/examples/servers/simple-pagination/mcp_simple_pagination/server.py new file mode 100644 index 0000000000..9aca87f730 --- /dev/null +++ b/examples/servers/simple-pagination/mcp_simple_pagination/server.py @@ -0,0 +1,176 @@ +"""Simple MCP server demonstrating pagination for tools, resources, and prompts. + +This example shows how to implement pagination with the low-level server API +to handle large lists of items that need to be split across multiple pages. +""" + +from typing import TypeVar + +import anyio +import click +import mcp_types as types +from mcp.server import Server, ServerRequestContext + +T = TypeVar("T") + +# Sample data - in real scenarios, this might come from a database +SAMPLE_TOOLS = [ + types.Tool( + name=f"tool_{i}", + title=f"Tool {i}", + description=f"This is sample tool number {i}", + input_schema={"type": "object", "properties": {"input": {"type": "string"}}}, + ) + for i in range(1, 26) # 25 tools total +] + +SAMPLE_RESOURCES = [ + types.Resource( + uri=f"file:///path/to/resource_{i}.txt", + name=f"resource_{i}", + description=f"This is sample resource number {i}", + ) + for i in range(1, 31) # 30 resources total +] + +SAMPLE_PROMPTS = [ + types.Prompt( + name=f"prompt_{i}", + description=f"This is sample prompt number {i}", + arguments=[ + types.PromptArgument(name="arg1", description="First argument", required=True), + ], + ) + for i in range(1, 21) # 20 prompts total +] + + +def _paginate(cursor: str | None, items: list[T], page_size: int) -> tuple[list[T], str | None]: + """Helper to paginate a list of items given a cursor.""" + if cursor is not None: + try: + start_idx = int(cursor) + except (ValueError, TypeError): + return [], None + else: + start_idx = 0 + + page = items[start_idx : start_idx + page_size] + next_cursor = str(start_idx + page_size) if start_idx + page_size < len(items) else None + return page, next_cursor + + +# Paginated list_tools - returns 5 tools per page +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + cursor = params.cursor if params is not None else None + page, next_cursor = _paginate(cursor, SAMPLE_TOOLS, page_size=5) + return types.ListToolsResult(tools=page, next_cursor=next_cursor) + + +# Paginated list_resources - returns 10 resources per page +async def handle_list_resources( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListResourcesResult: + cursor = params.cursor if params is not None else None + page, next_cursor = _paginate(cursor, SAMPLE_RESOURCES, page_size=10) + return types.ListResourcesResult(resources=page, next_cursor=next_cursor) + + +# Paginated list_prompts - returns 7 prompts per page +async def handle_list_prompts( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListPromptsResult: + cursor = params.cursor if params is not None else None + page, next_cursor = _paginate(cursor, SAMPLE_PROMPTS, page_size=7) + return types.ListPromptsResult(prompts=page, next_cursor=next_cursor) + + +async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + # Find the tool in our sample data + tool = next((t for t in SAMPLE_TOOLS if t.name == params.name), None) + if not tool: + raise ValueError(f"Unknown tool: {params.name}") + + return types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=f"Called tool '{params.name}' with arguments: {params.arguments}", + ) + ] + ) + + +async def handle_read_resource( + ctx: ServerRequestContext, params: types.ReadResourceRequestParams +) -> types.ReadResourceResult: + resource = next((r for r in SAMPLE_RESOURCES if r.uri == str(params.uri)), None) + if not resource: + raise ValueError(f"Unknown resource: {params.uri}") + + return types.ReadResourceResult( + contents=[ + types.TextResourceContents( + uri=str(params.uri), + text=f"Content of {resource.name}: This is sample content for the resource.", + mime_type="text/plain", + ) + ] + ) + + +async def handle_get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> types.GetPromptResult: + prompt = next((p for p in SAMPLE_PROMPTS if p.name == params.name), None) + if not prompt: + raise ValueError(f"Unknown prompt: {params.name}") + + message_text = f"This is the prompt '{params.name}'" + if params.arguments: + message_text += f" with arguments: {params.arguments}" + + return types.GetPromptResult( + description=prompt.description, + messages=[ + types.PromptMessage( + role="user", + content=types.TextContent(type="text", text=message_text), + ) + ], + ) + + +@click.command() +@click.option("--port", default=8000, help="Port to listen on for HTTP") +@click.option( + "--transport", + type=click.Choice(["stdio", "streamable-http"]), + default="stdio", + help="Transport type", +) +def main(port: int, transport: str) -> int: + app = Server( + "mcp-simple-pagination", + on_list_tools=handle_list_tools, + on_list_resources=handle_list_resources, + on_list_prompts=handle_list_prompts, + on_call_tool=handle_call_tool, + on_read_resource=handle_read_resource, + on_get_prompt=handle_get_prompt, + ) + + if transport == "streamable-http": + import uvicorn + + uvicorn.run(app.streamable_http_app(), host="127.0.0.1", port=port) + else: + from mcp.server.stdio import stdio_server + + async def arun(): + async with stdio_server() as streams: + await app.run(streams[0], streams[1], app.create_initialization_options()) + + anyio.run(arun) + + return 0 diff --git a/examples/servers/simple-pagination/pyproject.toml b/examples/servers/simple-pagination/pyproject.toml new file mode 100644 index 0000000000..2d57d9cccf --- /dev/null +++ b/examples/servers/simple-pagination/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "mcp-simple-pagination" +version = "0.1.0" +description = "A simple MCP server demonstrating pagination for tools, resources, and prompts" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "automation", "pagination", "cursor"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] + +[project.scripts] +mcp-simple-pagination = "mcp_simple_pagination.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_pagination"] + +[tool.pyright] +include = ["mcp_simple_pagination"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-prompt/.python-version b/examples/servers/simple-prompt/.python-version new file mode 100644 index 0000000000..c8cfe39591 --- /dev/null +++ b/examples/servers/simple-prompt/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/examples/servers/simple-prompt/README.md b/examples/servers/simple-prompt/README.md new file mode 100644 index 0000000000..c837da876e --- /dev/null +++ b/examples/servers/simple-prompt/README.md @@ -0,0 +1,55 @@ +# MCP Simple Prompt + +A simple MCP server that exposes a customizable prompt template with optional context and topic parameters. + +## Usage + +Start the server using either stdio (default) or Streamable HTTP transport: + +```bash +# Using stdio transport (default) +uv run mcp-simple-prompt + +# Using Streamable HTTP transport on custom port +uv run mcp-simple-prompt --transport streamable-http --port 8000 +``` + +The server exposes a prompt named "simple" that accepts two optional arguments: + +- `context`: Additional context to consider +- `topic`: Specific topic to focus on + +## Example + +Using the MCP client, you can retrieve the prompt like this using the STDIO transport: + +```python +import asyncio +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client + + +async def main(): + async with stdio_client( + StdioServerParameters(command="uv", args=["run", "mcp-simple-prompt"]) + ) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + # List available prompts + prompts = await session.list_prompts() + print(prompts) + + # Get the prompt with arguments + prompt = await session.get_prompt( + "simple", + { + "context": "User is a software developer", + "topic": "Python async programming", + }, + ) + print(prompt) + + +asyncio.run(main()) +``` diff --git a/examples/servers/simple-prompt/mcp_simple_prompt/__init__.py b/examples/servers/simple-prompt/mcp_simple_prompt/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/servers/simple-prompt/mcp_simple_prompt/__main__.py b/examples/servers/simple-prompt/mcp_simple_prompt/__main__.py new file mode 100644 index 0000000000..e7ef16530b --- /dev/null +++ b/examples/servers/simple-prompt/mcp_simple_prompt/__main__.py @@ -0,0 +1,5 @@ +import sys + +from .server import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-prompt/mcp_simple_prompt/server.py b/examples/servers/simple-prompt/mcp_simple_prompt/server.py new file mode 100644 index 0000000000..31e3eb7d76 --- /dev/null +++ b/examples/servers/simple-prompt/mcp_simple_prompt/server.py @@ -0,0 +1,98 @@ +import anyio +import click +import mcp_types as types +from mcp.server import Server, ServerRequestContext + + +def create_messages(context: str | None = None, topic: str | None = None) -> list[types.PromptMessage]: + """Create the messages for the prompt.""" + messages: list[types.PromptMessage] = [] + + # Add context if provided + if context: + messages.append( + types.PromptMessage( + role="user", + content=types.TextContent(type="text", text=f"Here is some relevant context: {context}"), + ) + ) + + # Add the main prompt + prompt = "Please help me with " + if topic: + prompt += f"the following topic: {topic}" + else: + prompt += "whatever questions I may have." + + messages.append(types.PromptMessage(role="user", content=types.TextContent(type="text", text=prompt))) + + return messages + + +async def handle_list_prompts( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListPromptsResult: + return types.ListPromptsResult( + prompts=[ + types.Prompt( + name="simple", + title="Simple Assistant Prompt", + description="A simple prompt that can take optional context and topic arguments", + arguments=[ + types.PromptArgument( + name="context", + description="Additional context to consider", + required=False, + ), + types.PromptArgument( + name="topic", + description="Specific topic to focus on", + required=False, + ), + ], + ) + ] + ) + + +async def handle_get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> types.GetPromptResult: + if params.name != "simple": + raise ValueError(f"Unknown prompt: {params.name}") + + arguments = params.arguments or {} + + return types.GetPromptResult( + messages=create_messages(context=arguments.get("context"), topic=arguments.get("topic")), + description="A simple prompt with optional context and topic arguments", + ) + + +@click.command() +@click.option("--port", default=8000, help="Port to listen on for HTTP") +@click.option( + "--transport", + type=click.Choice(["stdio", "streamable-http"]), + default="stdio", + help="Transport type", +) +def main(port: int, transport: str) -> int: + app = Server( + "mcp-simple-prompt", + on_list_prompts=handle_list_prompts, + on_get_prompt=handle_get_prompt, + ) + + if transport == "streamable-http": + import uvicorn + + uvicorn.run(app.streamable_http_app(), host="127.0.0.1", port=port) + else: + from mcp.server.stdio import stdio_server + + async def arun(): + async with stdio_server() as streams: + await app.run(streams[0], streams[1], app.create_initialization_options()) + + anyio.run(arun) + + return 0 diff --git a/examples/servers/simple-prompt/pyproject.toml b/examples/servers/simple-prompt/pyproject.toml new file mode 100644 index 0000000000..9d4d8e6a6b --- /dev/null +++ b/examples/servers/simple-prompt/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "mcp-simple-prompt" +version = "0.1.0" +description = "A simple MCP server exposing a customizable prompt" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "automation", "web", "fetch"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] + +[project.scripts] +mcp-simple-prompt = "mcp_simple_prompt.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_prompt"] + +[tool.pyright] +include = ["mcp_simple_prompt"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-resource/.python-version b/examples/servers/simple-resource/.python-version new file mode 100644 index 0000000000..c8cfe39591 --- /dev/null +++ b/examples/servers/simple-resource/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/examples/servers/simple-resource/README.md b/examples/servers/simple-resource/README.md new file mode 100644 index 0000000000..1d8fb7c2aa --- /dev/null +++ b/examples/servers/simple-resource/README.md @@ -0,0 +1,48 @@ +# MCP Simple Resource + +A simple MCP server that exposes sample text files as resources. + +## Usage + +Start the server using either stdio (default) or Streamable HTTP transport: + +```bash +# Using stdio transport (default) +uv run mcp-simple-resource + +# Using Streamable HTTP transport on custom port +uv run mcp-simple-resource --transport streamable-http --port 8000 +``` + +The server exposes some basic text file resources that can be read by clients. + +## Example + +Using the MCP client, you can retrieve resources like this using the STDIO transport: + +```python +import asyncio +from pydantic import AnyUrl +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client + + +async def main(): + async with stdio_client( + StdioServerParameters(command="uv", args=["run", "mcp-simple-resource"]) + ) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + # List available resources + resources = await session.list_resources() + print(resources) + + # Get a specific resource + resource = await session.read_resource(AnyUrl("file:///greeting.txt")) + print(resource) + + +asyncio.run(main()) + +``` diff --git a/examples/servers/simple-resource/mcp_simple_resource/__init__.py b/examples/servers/simple-resource/mcp_simple_resource/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/servers/simple-resource/mcp_simple_resource/__main__.py b/examples/servers/simple-resource/mcp_simple_resource/__main__.py new file mode 100644 index 0000000000..e7ef16530b --- /dev/null +++ b/examples/servers/simple-resource/mcp_simple_resource/__main__.py @@ -0,0 +1,5 @@ +import sys + +from .server import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-resource/mcp_simple_resource/server.py b/examples/servers/simple-resource/mcp_simple_resource/server.py new file mode 100644 index 0000000000..fe9dcfb709 --- /dev/null +++ b/examples/servers/simple-resource/mcp_simple_resource/server.py @@ -0,0 +1,91 @@ +from urllib.parse import urlparse + +import anyio +import click +import mcp_types as types +from mcp.server import Server, ServerRequestContext + +SAMPLE_RESOURCES = { + "greeting": { + "content": "Hello! This is a sample text resource.", + "title": "Welcome Message", + }, + "help": { + "content": "This server provides a few sample text resources for testing.", + "title": "Help Documentation", + }, + "about": { + "content": "This is the simple-resource MCP server implementation.", + "title": "About This Server", + }, +} + + +async def handle_list_resources( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListResourcesResult: + return types.ListResourcesResult( + resources=[ + types.Resource( + uri=f"file:///{name}.txt", + name=name, + title=SAMPLE_RESOURCES[name]["title"], + description=f"A sample text resource named {name}", + mime_type="text/plain", + ) + for name in SAMPLE_RESOURCES.keys() + ] + ) + + +async def handle_read_resource( + ctx: ServerRequestContext, params: types.ReadResourceRequestParams +) -> types.ReadResourceResult: + parsed = urlparse(str(params.uri)) + if not parsed.path: + raise ValueError(f"Invalid resource path: {params.uri}") + name = parsed.path.replace(".txt", "").lstrip("/") + + if name not in SAMPLE_RESOURCES: + raise ValueError(f"Unknown resource: {params.uri}") + + return types.ReadResourceResult( + contents=[ + types.TextResourceContents( + uri=str(params.uri), + text=SAMPLE_RESOURCES[name]["content"], + mime_type="text/plain", + ) + ] + ) + + +@click.command() +@click.option("--port", default=8000, help="Port to listen on for HTTP") +@click.option( + "--transport", + type=click.Choice(["stdio", "streamable-http"]), + default="stdio", + help="Transport type", +) +def main(port: int, transport: str) -> int: + app = Server( + "mcp-simple-resource", + on_list_resources=handle_list_resources, + on_read_resource=handle_read_resource, + ) + + if transport == "streamable-http": + import uvicorn + + uvicorn.run(app.streamable_http_app(), host="127.0.0.1", port=port) + else: + from mcp.server.stdio import stdio_server + + async def arun(): + async with stdio_server() as streams: + await app.run(streams[0], streams[1], app.create_initialization_options()) + + anyio.run(arun) + + return 0 diff --git a/examples/servers/simple-resource/pyproject.toml b/examples/servers/simple-resource/pyproject.toml new file mode 100644 index 0000000000..34fbc8d9de --- /dev/null +++ b/examples/servers/simple-resource/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "mcp-simple-resource" +version = "0.1.0" +description = "A simple MCP server exposing sample text resources" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "automation", "web", "fetch"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] + +[project.scripts] +mcp-simple-resource = "mcp_simple_resource.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_resource"] + +[tool.pyright] +include = ["mcp_simple_resource"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-streamablehttp-stateless/README.md b/examples/servers/simple-streamablehttp-stateless/README.md new file mode 100644 index 0000000000..a254f88d14 --- /dev/null +++ b/examples/servers/simple-streamablehttp-stateless/README.md @@ -0,0 +1,38 @@ +# MCP Simple StreamableHttp Stateless Server Example + +A stateless MCP server example demonstrating the StreamableHttp transport without maintaining session state. This example is ideal for understanding how to deploy MCP servers in multi-node environments where requests can be routed to any instance. + +## Features + +- Uses the StreamableHTTP transport in stateless mode (mcp_session_id=None) +- Each request creates a new ephemeral connection +- No session state maintained between requests +- Suitable for deployment in multi-node environments + +## Usage + +Start the server: + +```bash +# Using default port 3000 +uv run mcp-simple-streamablehttp-stateless + +# Using custom port +uv run mcp-simple-streamablehttp-stateless --port 3000 + +# Custom logging level +uv run mcp-simple-streamablehttp-stateless --log-level DEBUG + +# Enable JSON responses instead of SSE streams +uv run mcp-simple-streamablehttp-stateless --json-response +``` + +The server exposes a tool named "start-notification-stream" that accepts three arguments: + +- `interval`: Time between notifications in seconds (e.g., 1.0) +- `count`: Number of notifications to send (e.g., 5) +- `caller`: Identifier string for the caller + +## Client + +You can connect to this server using an HTTP client. For now, only the TypeScript SDK has streamable HTTP client examples, or you can use [Inspector](https://github.com/modelcontextprotocol/inspector) for testing. diff --git a/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__init__.py b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__main__.py b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__main__.py new file mode 100644 index 0000000000..1664737e3a --- /dev/null +++ b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__main__.py @@ -0,0 +1,7 @@ +from .server import main + +if __name__ == "__main__": + # Click will handle CLI arguments + import sys + + sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py new file mode 100644 index 0000000000..9df18cc6a2 --- /dev/null +++ b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py @@ -0,0 +1,116 @@ +import logging + +import anyio +import click +import mcp_types as types +import uvicorn +from mcp.server import Server, ServerRequestContext +from starlette.middleware.cors import CORSMiddleware + +logger = logging.getLogger(__name__) + + +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="start-notification-stream", + description=("Sends a stream of notifications with configurable count and interval"), + input_schema={ + "type": "object", + "required": ["interval", "count", "caller"], + "properties": { + "interval": { + "type": "number", + "description": "Interval between notifications in seconds", + }, + "count": { + "type": "number", + "description": "Number of notifications to send", + }, + "caller": { + "type": "string", + "description": ("Identifier of the caller to include in notifications"), + }, + }, + }, + ) + ] + ) + + +async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + arguments = params.arguments or {} + interval = arguments.get("interval", 1.0) + count = arguments.get("count", 5) + caller = arguments.get("caller", "unknown") + + # Send the specified number of notifications with the given interval + for i in range(count): + await ctx.session.send_log_message( # pyright: ignore[reportDeprecated] + level="info", + data=f"Notification {i + 1}/{count} from caller: {caller}", + logger="notification_stream", + related_request_id=ctx.request_id, + ) + if i < count - 1: # Don't wait after the last notification + await anyio.sleep(interval) + + return types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=(f"Sent {count} notifications with {interval}s interval for caller: {caller}"), + ) + ] + ) + + +@click.command() +@click.option("--port", default=3000, help="Port to listen on for HTTP") +@click.option( + "--log-level", + default="INFO", + help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)", +) +@click.option( + "--json-response", + is_flag=True, + default=False, + help="Enable JSON responses instead of SSE streams", +) +def main( + port: int, + log_level: str, + json_response: bool, +) -> None: + # Configure logging + logging.basicConfig( + level=getattr(logging, log_level.upper()), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + app = Server( + "mcp-streamable-http-stateless-demo", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, + ) + + starlette_app = app.streamable_http_app( + stateless_http=True, + json_response=json_response, + debug=True, + ) + + # Wrap ASGI application with CORS middleware to expose Mcp-Session-Id header + # for browser-based clients (ensures 500 errors get proper CORS headers) + starlette_app = CORSMiddleware( + starlette_app, + allow_origins=["*"], # Note: streamable_http_app() enforces localhost-only Origin by default + allow_methods=["GET", "POST", "DELETE"], # MCP streamable HTTP methods + expose_headers=["Mcp-Session-Id"], + ) + + uvicorn.run(starlette_app, host="127.0.0.1", port=port) diff --git a/examples/servers/simple-streamablehttp-stateless/pyproject.toml b/examples/servers/simple-streamablehttp-stateless/pyproject.toml new file mode 100644 index 0000000000..38f7b1b391 --- /dev/null +++ b/examples/servers/simple-streamablehttp-stateless/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "mcp-simple-streamablehttp-stateless" +version = "0.1.0" +description = "A simple MCP server exposing a StreamableHttp transport in stateless mode" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "automation", "web", "fetch", "http", "streamable", "stateless"] +license = { text = "MIT" } +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"] + +[project.scripts] +mcp-simple-streamablehttp-stateless = "mcp_simple_streamablehttp_stateless.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_streamablehttp_stateless"] + +[tool.pyright] +include = ["mcp_simple_streamablehttp_stateless"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-streamablehttp/README.md b/examples/servers/simple-streamablehttp/README.md new file mode 100644 index 0000000000..3eed3320e7 --- /dev/null +++ b/examples/servers/simple-streamablehttp/README.md @@ -0,0 +1,51 @@ +# MCP Simple StreamableHttp Server Example + +A simple MCP server example demonstrating the StreamableHttp transport, which enables HTTP-based communication with MCP servers using streaming. + +## Features + +- Uses the StreamableHTTP transport for server-client communication +- Supports REST API operations (POST, GET, DELETE) for `/mcp` endpoint +- Ability to send multiple notifications over time to the client +- Resumability support via InMemoryEventStore + +## Usage + +Start the server on the default or custom port: + +```bash + +# Using custom port +uv run mcp-simple-streamablehttp --port 3000 + +# Custom logging level +uv run mcp-simple-streamablehttp --log-level DEBUG + +# Enable JSON responses instead of SSE streams +uv run mcp-simple-streamablehttp --json-response +``` + +The server exposes a tool named "start-notification-stream" that accepts three arguments: + +- `interval`: Time between notifications in seconds (e.g., 1.0) +- `count`: Number of notifications to send (e.g., 5) +- `caller`: Identifier string for the caller + +## Resumability Support + +This server includes resumability support through the InMemoryEventStore. This enables clients to: + +- Reconnect to the server after a disconnection +- Resume event streaming from where they left off using the Last-Event-ID header + +The server will: + +- Generate unique event IDs for each SSE message +- Store events in memory for later replay +- Replay missed events when a client reconnects with a Last-Event-ID header + +Note: The InMemoryEventStore is designed for demonstration purposes only. For production use, consider implementing a persistent storage solution. + +## Client + +You can connect to this server using an HTTP client, for now only Typescript SDK has streamable HTTP client examples or you can use [Inspector](https://github.com/modelcontextprotocol/inspector) diff --git a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__init__.py b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__main__.py b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__main__.py new file mode 100644 index 0000000000..21862e45fb --- /dev/null +++ b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__main__.py @@ -0,0 +1,4 @@ +from .server import main + +if __name__ == "__main__": + main() # type: ignore[call-arg] diff --git a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py new file mode 100644 index 0000000000..c9369cfc2c --- /dev/null +++ b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py @@ -0,0 +1,93 @@ +"""In-memory event store for demonstrating resumability functionality. + +This is a simple implementation intended for examples and testing, +not for production use where a persistent storage solution would be more appropriate. +""" + +import logging +from collections import deque +from dataclasses import dataclass +from uuid import uuid4 + +from mcp.server.streamable_http import EventCallback, EventId, EventMessage, EventStore, StreamId +from mcp_types import JSONRPCMessage + +logger = logging.getLogger(__name__) + + +@dataclass +class EventEntry: + """Represents an event entry in the event store.""" + + event_id: EventId + stream_id: StreamId + message: JSONRPCMessage | None + + +class InMemoryEventStore(EventStore): + """Simple in-memory implementation of the EventStore interface for resumability. + This is primarily intended for examples and testing, not for production use + where a persistent storage solution would be more appropriate. + + This implementation keeps only the last N events per stream for memory efficiency. + """ + + def __init__(self, max_events_per_stream: int = 100): + """Initialize the event store. + + Args: + max_events_per_stream: Maximum number of events to keep per stream + """ + self.max_events_per_stream = max_events_per_stream + # for maintaining last N events per stream + self.streams: dict[StreamId, deque[EventEntry]] = {} + # event_id -> EventEntry for quick lookup + self.event_index: dict[EventId, EventEntry] = {} + + async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId: + """Stores an event with a generated event ID.""" + event_id = str(uuid4()) + event_entry = EventEntry(event_id=event_id, stream_id=stream_id, message=message) + + # Get or create deque for this stream + if stream_id not in self.streams: + self.streams[stream_id] = deque(maxlen=self.max_events_per_stream) + + # If deque is full, the oldest event will be automatically removed + # We need to remove it from the event_index as well + if len(self.streams[stream_id]) == self.max_events_per_stream: + oldest_event = self.streams[stream_id][0] + self.event_index.pop(oldest_event.event_id, None) + + # Add new event + self.streams[stream_id].append(event_entry) + self.event_index[event_id] = event_entry + + return event_id + + async def replay_events_after( + self, + last_event_id: EventId, + send_callback: EventCallback, + ) -> StreamId | None: + """Replays events that occurred after the specified event ID.""" + if last_event_id not in self.event_index: + logger.warning(f"Event ID {last_event_id} not found in store") + return None + + # Get the stream and find events after the last one + last_event = self.event_index[last_event_id] + stream_id = last_event.stream_id + stream_events = self.streams.get(last_event.stream_id, deque()) + + # Events in deque are already in chronological order + found_last = False + for event in stream_events: + if found_last: + # Skip priming events (None message) + if event.message is not None: + await send_callback(EventMessage(event.message, event.event_id)) + elif event.event_id == last_event_id: + found_last = True + + return stream_id diff --git a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py new file mode 100644 index 0000000000..e650b35732 --- /dev/null +++ b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py @@ -0,0 +1,142 @@ +import logging + +import anyio +import click +import mcp_types as types +import uvicorn +from mcp.server import Server, ServerRequestContext +from starlette.middleware.cors import CORSMiddleware + +from .event_store import InMemoryEventStore + +# Configure logging +logger = logging.getLogger(__name__) + + +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="start-notification-stream", + description="Sends a stream of notifications with configurable count and interval", + input_schema={ + "type": "object", + "required": ["interval", "count", "caller"], + "properties": { + "interval": { + "type": "number", + "description": "Interval between notifications in seconds", + }, + "count": { + "type": "number", + "description": "Number of notifications to send", + }, + "caller": { + "type": "string", + "description": "Identifier of the caller to include in notifications", + }, + }, + }, + ) + ] + ) + + +async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + arguments = params.arguments or {} + interval = arguments.get("interval", 1.0) + count = arguments.get("count", 5) + caller = arguments.get("caller", "unknown") + + # Send the specified number of notifications with the given interval + for i in range(count): + # Include more detailed message for resumability demonstration + notification_msg = f"[{i + 1}/{count}] Event from '{caller}' - Use Last-Event-ID to resume if disconnected" + await ctx.session.send_log_message( # pyright: ignore[reportDeprecated] + level="info", + data=notification_msg, + logger="notification_stream", + # Associates this notification with the original request + # Ensures notifications are sent to the correct response stream + # Without this, notifications will either go to: + # - a standalone SSE stream (if GET request is supported) + # - nowhere (if GET request isn't supported) + related_request_id=ctx.request_id, + ) + logger.debug(f"Sent notification {i + 1}/{count} for caller: {caller}") + if i < count - 1: # Don't wait after the last notification + await anyio.sleep(interval) + + # This will send a resource notification through standalone SSE + # established by GET request + await ctx.session.send_resource_updated(uri="http:///test_resource") + return types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=(f"Sent {count} notifications with {interval}s interval for caller: {caller}"), + ) + ] + ) + + +@click.command() +@click.option("--port", default=3000, help="Port to listen on for HTTP") +@click.option( + "--log-level", + default="INFO", + help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)", +) +@click.option( + "--json-response", + is_flag=True, + default=False, + help="Enable JSON responses instead of SSE streams", +) +def main( + port: int, + log_level: str, + json_response: bool, +) -> int: + # Configure logging + logging.basicConfig( + level=getattr(logging, log_level.upper()), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + app = Server( + "mcp-streamable-http-demo", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, + ) + + # Create event store for resumability + # The InMemoryEventStore enables resumability support for StreamableHTTP transport. + # It stores SSE events with unique IDs, allowing clients to: + # 1. Receive event IDs for each SSE message + # 2. Resume streams by sending Last-Event-ID in GET requests + # 3. Replay missed events after reconnection + # Note: This in-memory implementation is for demonstration ONLY. + # For production, use a persistent storage solution. + event_store = InMemoryEventStore() + + starlette_app = app.streamable_http_app( + event_store=event_store, + json_response=json_response, + debug=True, + ) + + # Wrap ASGI application with CORS middleware to expose Mcp-Session-Id header + # for browser-based clients (ensures 500 errors get proper CORS headers) + starlette_app = CORSMiddleware( + starlette_app, + allow_origins=["*"], # Note: streamable_http_app() enforces localhost-only Origin by default + allow_methods=["GET", "POST", "DELETE"], # MCP streamable HTTP methods + expose_headers=["Mcp-Session-Id"], + ) + + uvicorn.run(starlette_app, host="127.0.0.1", port=port) + + return 0 diff --git a/examples/servers/simple-streamablehttp/pyproject.toml b/examples/servers/simple-streamablehttp/pyproject.toml new file mode 100644 index 0000000000..93f7baf41b --- /dev/null +++ b/examples/servers/simple-streamablehttp/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "mcp-simple-streamablehttp" +version = "0.1.0" +description = "A simple MCP server exposing a StreamableHttp transport for testing" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "automation", "web", "fetch", "http", "streamable"] +license = { text = "MIT" } +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"] + +[project.scripts] +mcp-simple-streamablehttp = "mcp_simple_streamablehttp.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_streamablehttp"] + +[tool.pyright] +include = ["mcp_simple_streamablehttp"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-tool/.python-version b/examples/servers/simple-tool/.python-version new file mode 100644 index 0000000000..c8cfe39591 --- /dev/null +++ b/examples/servers/simple-tool/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/examples/servers/simple-tool/README.md b/examples/servers/simple-tool/README.md new file mode 100644 index 0000000000..7d3759f9de --- /dev/null +++ b/examples/servers/simple-tool/README.md @@ -0,0 +1,48 @@ + +A simple MCP server that exposes a website fetching tool. + +## Usage + +Start the server using either stdio (default) or Streamable HTTP transport: + +```bash +# Using stdio transport (default) +uv run mcp-simple-tool + +# Using Streamable HTTP transport on custom port +uv run mcp-simple-tool --transport streamable-http --port 8000 +``` + +The server exposes a tool named "fetch" that accepts one required argument: + +- `url`: The URL of the website to fetch + +## Example + +Using the MCP client, you can use the tool like this using the STDIO transport: + +```python +import asyncio +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client + + +async def main(): + async with stdio_client( + StdioServerParameters(command="uv", args=["run", "mcp-simple-tool"]) + ) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + # List available tools + tools = await session.list_tools() + print(tools) + + # Call the fetch tool + result = await session.call_tool("fetch", {"url": "https://example.com"}) + print(result) + + +asyncio.run(main()) + +``` diff --git a/examples/servers/simple-tool/mcp_simple_tool/__init__.py b/examples/servers/simple-tool/mcp_simple_tool/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/servers/simple-tool/mcp_simple_tool/__main__.py b/examples/servers/simple-tool/mcp_simple_tool/__main__.py new file mode 100644 index 0000000000..e7ef16530b --- /dev/null +++ b/examples/servers/simple-tool/mcp_simple_tool/__main__.py @@ -0,0 +1,5 @@ +import sys + +from .server import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-tool/mcp_simple_tool/server.py b/examples/servers/simple-tool/mcp_simple_tool/server.py new file mode 100644 index 0000000000..b16249e068 --- /dev/null +++ b/examples/servers/simple-tool/mcp_simple_tool/server.py @@ -0,0 +1,80 @@ +import anyio +import click +import mcp_types as types +from mcp.server import Server, ServerRequestContext +from mcp.shared._httpx_utils import create_mcp_http_client + + +async def fetch_website( + url: str, +) -> list[types.ContentBlock]: + headers = {"User-Agent": "MCP Test Server (github.com/modelcontextprotocol/python-sdk)"} + async with create_mcp_http_client(headers=headers) as client: + response = await client.get(url) + response.raise_for_status() + return [types.TextContent(type="text", text=response.text)] + + +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="fetch", + title="Website Fetcher", + description="Fetches a website and returns its content", + input_schema={ + "type": "object", + "required": ["url"], + "properties": { + "url": { + "type": "string", + "description": "URL to fetch", + } + }, + }, + ) + ] + ) + + +async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + if params.name != "fetch": + raise ValueError(f"Unknown tool: {params.name}") + arguments = params.arguments or {} + if "url" not in arguments: + raise ValueError("Missing required argument 'url'") + content = await fetch_website(arguments["url"]) + return types.CallToolResult(content=content) + + +@click.command() +@click.option("--port", default=8000, help="Port to listen on for HTTP") +@click.option( + "--transport", + type=click.Choice(["stdio", "streamable-http"]), + default="stdio", + help="Transport type", +) +def main(port: int, transport: str) -> int: + app = Server( + "mcp-website-fetcher", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, + ) + + if transport == "streamable-http": + import uvicorn + + uvicorn.run(app.streamable_http_app(), host="127.0.0.1", port=port) + else: + from mcp.server.stdio import stdio_server + + async def arun(): + async with stdio_server() as streams: + await app.run(streams[0], streams[1], app.create_initialization_options()) + + anyio.run(arun) + + return 0 diff --git a/examples/servers/simple-tool/pyproject.toml b/examples/servers/simple-tool/pyproject.toml new file mode 100644 index 0000000000..022e039e04 --- /dev/null +++ b/examples/servers/simple-tool/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "mcp-simple-tool" +version = "0.1.0" +description = "A simple MCP server exposing a website fetching tool" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "automation", "web", "fetch"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] + +[project.scripts] +mcp-simple-tool = "mcp_simple_tool.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_tool"] + +[tool.pyright] +include = ["mcp_simple_tool"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/sse-polling-demo/README.md b/examples/servers/sse-polling-demo/README.md new file mode 100644 index 0000000000..e9d4446e1f --- /dev/null +++ b/examples/servers/sse-polling-demo/README.md @@ -0,0 +1,36 @@ +# MCP SSE Polling Demo Server + +Demonstrates the SSE polling pattern with server-initiated stream close for long-running tasks (SEP-1699). + +## Features + +- Priming events (automatic with EventStore) +- Server-initiated stream close via `close_sse_stream()` callback +- Client auto-reconnect with Last-Event-ID +- Progress notifications during long-running tasks +- Configurable retry interval + +## Usage + +```bash +# Start server on default port +uv run mcp-sse-polling-demo --port 3000 + +# Custom retry interval (milliseconds) +uv run mcp-sse-polling-demo --port 3000 --retry-interval 100 +``` + +## Tool: process_batch + +Processes items with periodic checkpoints that trigger SSE stream closes: + +- `items`: Number of items to process (1-100, default: 10) +- `checkpoint_every`: Close stream after this many items (1-20, default: 3) + +## Client + +Use the companion `mcp-sse-polling-client` to test: + +```bash +uv run mcp-sse-polling-client --url http://localhost:3000/mcp +``` diff --git a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__init__.py b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__init__.py new file mode 100644 index 0000000000..46af2fdeed --- /dev/null +++ b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__init__.py @@ -0,0 +1 @@ +"""SSE Polling Demo Server - demonstrates close_sse_stream for long-running tasks.""" diff --git a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__main__.py b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__main__.py new file mode 100644 index 0000000000..23cfc85e11 --- /dev/null +++ b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__main__.py @@ -0,0 +1,6 @@ +"""Entry point for the SSE Polling Demo server.""" + +from .server import main + +if __name__ == "__main__": + main() diff --git a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/event_store.py b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/event_store.py new file mode 100644 index 0000000000..e2cca4a2eb --- /dev/null +++ b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/event_store.py @@ -0,0 +1,98 @@ +"""In-memory event store for demonstrating resumability functionality. + +This is a simple implementation intended for examples and testing, +not for production use where a persistent storage solution would be more appropriate. +""" + +import logging +from collections import deque +from dataclasses import dataclass +from uuid import uuid4 + +from mcp.server.streamable_http import EventCallback, EventId, EventMessage, EventStore, StreamId +from mcp_types import JSONRPCMessage + +logger = logging.getLogger(__name__) + + +@dataclass +class EventEntry: + """Represents an event entry in the event store.""" + + event_id: EventId + stream_id: StreamId + message: JSONRPCMessage | None # None for priming events + + +class InMemoryEventStore(EventStore): + """Simple in-memory implementation of the EventStore interface for resumability. + This is primarily intended for examples and testing, not for production use + where a persistent storage solution would be more appropriate. + + This implementation keeps only the last N events per stream for memory efficiency. + """ + + def __init__(self, max_events_per_stream: int = 100): + """Initialize the event store. + + Args: + max_events_per_stream: Maximum number of events to keep per stream + """ + self.max_events_per_stream = max_events_per_stream + # for maintaining last N events per stream + self.streams: dict[StreamId, deque[EventEntry]] = {} + # event_id -> EventEntry for quick lookup + self.event_index: dict[EventId, EventEntry] = {} + + async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId: + """Stores an event with a generated event ID. + + Args: + stream_id: ID of the stream the event belongs to + message: The message to store, or None for priming events + """ + event_id = str(uuid4()) + event_entry = EventEntry(event_id=event_id, stream_id=stream_id, message=message) + + # Get or create deque for this stream + if stream_id not in self.streams: + self.streams[stream_id] = deque(maxlen=self.max_events_per_stream) + + # If deque is full, the oldest event will be automatically removed + # We need to remove it from the event_index as well + if len(self.streams[stream_id]) == self.max_events_per_stream: + oldest_event = self.streams[stream_id][0] + self.event_index.pop(oldest_event.event_id, None) + + # Add new event + self.streams[stream_id].append(event_entry) + self.event_index[event_id] = event_entry + + return event_id + + async def replay_events_after( + self, + last_event_id: EventId, + send_callback: EventCallback, + ) -> StreamId | None: + """Replays events that occurred after the specified event ID.""" + if last_event_id not in self.event_index: + logger.warning(f"Event ID {last_event_id} not found in store") + return None + + # Get the stream and find events after the last one + last_event = self.event_index[last_event_id] + stream_id = last_event.stream_id + stream_events = self.streams.get(last_event.stream_id, deque()) + + # Events in deque are already in chronological order + found_last = False + for event in stream_events: + if found_last: + # Skip priming events (None messages) during replay + if event.message is not None: + await send_callback(EventMessage(event.message, event.event_id)) + elif event.event_id == last_event_id: + found_last = True + + return stream_id diff --git a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/server.py b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/server.py new file mode 100644 index 0000000000..7d2c60fa32 --- /dev/null +++ b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/server.py @@ -0,0 +1,160 @@ +"""SSE Polling Demo Server + +Demonstrates the SSE polling pattern with close_sse_stream() for long-running tasks. + +Features demonstrated: +- Priming events (automatic with EventStore) +- Server-initiated stream close via close_sse_stream callback +- Client auto-reconnect with Last-Event-ID +- Progress notifications during long-running tasks + +Run with: + uv run mcp-sse-polling-demo --port 3000 +""" + +import logging + +import anyio +import click +import mcp_types as types +import uvicorn +from mcp.server import Server, ServerRequestContext + +from .event_store import InMemoryEventStore + +logger = logging.getLogger(__name__) + + +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + """List available tools.""" + return types.ListToolsResult( + tools=[ + types.Tool( + name="process_batch", + description=( + "Process a batch of items with periodic checkpoints. " + "Demonstrates SSE polling where server closes stream periodically." + ), + input_schema={ + "type": "object", + "properties": { + "items": { + "type": "integer", + "description": "Number of items to process (1-100)", + "default": 10, + }, + "checkpoint_every": { + "type": "integer", + "description": "Close stream after this many items (1-20)", + "default": 3, + }, + }, + }, + ) + ] + ) + + +async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + """Handle tool calls.""" + arguments = params.arguments or {} + + if params.name == "process_batch": + items = arguments.get("items", 10) + checkpoint_every = arguments.get("checkpoint_every", 3) + + if items < 1 or items > 100: + return types.CallToolResult( + content=[types.TextContent(type="text", text="Error: items must be between 1 and 100")] + ) + if checkpoint_every < 1 or checkpoint_every > 20: + return types.CallToolResult( + content=[types.TextContent(type="text", text="Error: checkpoint_every must be between 1 and 20")] + ) + + await ctx.session.send_log_message( # pyright: ignore[reportDeprecated] + level="info", + data=f"Starting batch processing of {items} items...", + logger="process_batch", + related_request_id=ctx.request_id, + ) + + for i in range(1, items + 1): + # Simulate work + await anyio.sleep(0.5) + + # Report progress + await ctx.session.send_log_message( # pyright: ignore[reportDeprecated] + level="info", + data=f"[{i}/{items}] Processing item {i}", + logger="process_batch", + related_request_id=ctx.request_id, + ) + + # Checkpoint: close stream to trigger client reconnect + if i % checkpoint_every == 0 and i < items: + await ctx.session.send_log_message( # pyright: ignore[reportDeprecated] + level="info", + data=f"Checkpoint at item {i} - closing SSE stream for polling", + logger="process_batch", + related_request_id=ctx.request_id, + ) + if ctx.close_sse_stream: + logger.info(f"Closing SSE stream at checkpoint {i}") + await ctx.close_sse_stream() + # Wait for client to reconnect (must be > retry_interval of 100ms) + await anyio.sleep(0.2) + + return types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=f"Successfully processed {items} items with checkpoints every {checkpoint_every} items", + ) + ] + ) + + return types.CallToolResult(content=[types.TextContent(type="text", text=f"Unknown tool: {params.name}")]) + + +@click.command() +@click.option("--port", default=3000, help="Port to listen on") +@click.option( + "--log-level", + default="INFO", + help="Logging level (DEBUG, INFO, WARNING, ERROR)", +) +@click.option( + "--retry-interval", + default=100, + help="SSE retry interval in milliseconds (sent to client)", +) +def main(port: int, log_level: str, retry_interval: int) -> int: + """Run the SSE Polling Demo server.""" + logging.basicConfig( + level=getattr(logging, log_level.upper()), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + app = Server( + "sse-polling-demo", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, + ) + + starlette_app = app.streamable_http_app( + event_store=InMemoryEventStore(), + retry_interval=retry_interval, + debug=True, + ) + + logger.info(f"SSE Polling Demo server starting on port {port}") + logger.info("Try: POST /mcp with tools/call for 'process_batch'") + uvicorn.run(starlette_app, host="127.0.0.1", port=port) + return 0 + + +if __name__ == "__main__": + main() diff --git a/examples/servers/sse-polling-demo/pyproject.toml b/examples/servers/sse-polling-demo/pyproject.toml new file mode 100644 index 0000000000..400f6580bc --- /dev/null +++ b/examples/servers/sse-polling-demo/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "mcp-sse-polling-demo" +version = "0.1.0" +description = "Demo server showing SSE polling with close_sse_stream for long-running tasks" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "sse", "polling", "streamable", "http"] +license = { text = "MIT" } +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"] + +[project.scripts] +mcp-sse-polling-demo = "mcp_sse_polling_demo.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_sse_polling_demo"] + +[tool.pyright] +include = ["mcp_sse_polling_demo"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/stories/schema_validators/server.py b/examples/stories/schema_validators/server.py index 527c177e07..8648e211df 100644 --- a/examples/stories/schema_validators/server.py +++ b/examples/stories/schema_validators/server.py @@ -1,10 +1,14 @@ """Four ways to type a tool parameter so MCPServer derives and enforces inputSchema.""" from dataclasses import dataclass -from typing import Any, TypedDict +from typing import Any from pydantic import BaseModel +# pydantic requires typing_extensions.TypedDict (not typing.TypedDict) on Python < 3.12 +# when a TypedDict is used as a field/parameter type. +from typing_extensions import TypedDict + from mcp.server.mcpserver import MCPServer from stories._hosting import run_server_from_args diff --git a/pyproject.toml b/pyproject.toml index 051f7199cd..6922a9cf8f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -140,6 +140,7 @@ include = [ "examples/stories", "examples/servers", "examples/snippets", + "examples/clients", ] venvPath = "." venv = ".venv" @@ -159,7 +160,12 @@ executionEnvironments = [ { root = "examples/stories", extraPaths = [ "examples", ], reportUnusedFunction = false }, - { root = "examples/servers", reportUnusedFunction = false }, + # The `mcp-example-stories` editable install puts `examples/` on sys.path, + # which defeats pyright's auto-detection of `simple-auth/` as a package + # root (it's the one server example that imports itself by absolute name). + { root = "examples/servers", extraPaths = [ + "examples/servers/simple-auth", + ], reportUnusedFunction = false }, ] [tool.ruff] @@ -203,7 +209,7 @@ max-returns = 13 # Default is 6 max-statements = 102 # Default is 50 [tool.uv.workspace] -members = ["src/mcp-types", "examples", "examples/servers/*", "examples/snippets"] +members = ["src/mcp-types", "examples", "examples/clients/*", "examples/servers/*", "examples/snippets"] [tool.uv.sources] mcp = { workspace = true } diff --git a/uv.lock b/uv.lock index b9ffd968cc..f0676c6816 100644 --- a/uv.lock +++ b/uv.lock @@ -11,7 +11,18 @@ members = [ "mcp", "mcp-everything-server", "mcp-example-stories", + "mcp-simple-auth", + "mcp-simple-auth-client", + "mcp-simple-chatbot", + "mcp-simple-pagination", + "mcp-simple-prompt", + "mcp-simple-resource", + "mcp-simple-streamablehttp", + "mcp-simple-streamablehttp-stateless", + "mcp-simple-tool", "mcp-snippets", + "mcp-sse-polling-client", + "mcp-sse-polling-demo", "mcp-structured-output-lowlevel", "mcp-types", ] @@ -1061,6 +1072,313 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "mcp", editable = "." }] +[[package]] +name = "mcp-simple-auth" +version = "0.1.0" +source = { editable = "examples/servers/simple-auth" } +dependencies = [ + { name = "anyio" }, + { name = "click" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "sse-starlette" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.5" }, + { name = "click", specifier = ">=8.2.0" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "mcp" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "pydantic-settings", specifier = ">=2.5.2" }, + { name = "sse-starlette", specifier = ">=1.6.1" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'", specifier = ">=0.23.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.391" }, + { name = "pytest", specifier = ">=8.3.4" }, + { name = "ruff", specifier = ">=0.8.5" }, +] + +[[package]] +name = "mcp-simple-auth-client" +version = "0.1.0" +source = { editable = "examples/clients/simple-auth-client" } +dependencies = [ + { name = "click" }, + { name = "mcp" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.2.0" }, + { name = "mcp" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.379" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + +[[package]] +name = "mcp-simple-chatbot" +version = "0.1.0" +source = { editable = "examples/clients/simple-chatbot" } +dependencies = [ + { name = "mcp" }, + { name = "python-dotenv" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "mcp" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "uvicorn", specifier = ">=0.32.1" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.379" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + +[[package]] +name = "mcp-simple-pagination" +version = "0.1.0" +source = { editable = "examples/servers/simple-pagination" } +dependencies = [ + { name = "anyio" }, + { name = "click" }, + { name = "httpx" }, + { name = "mcp" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.5" }, + { name = "click", specifier = ">=8.2.0" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "mcp" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.378" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + +[[package]] +name = "mcp-simple-prompt" +version = "0.1.0" +source = { editable = "examples/servers/simple-prompt" } +dependencies = [ + { name = "anyio" }, + { name = "click" }, + { name = "httpx" }, + { name = "mcp" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.5" }, + { name = "click", specifier = ">=8.2.0" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "mcp" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.378" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + +[[package]] +name = "mcp-simple-resource" +version = "0.1.0" +source = { editable = "examples/servers/simple-resource" } +dependencies = [ + { name = "anyio" }, + { name = "click" }, + { name = "httpx" }, + { name = "mcp" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.5" }, + { name = "click", specifier = ">=8.2.0" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "mcp" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.378" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + +[[package]] +name = "mcp-simple-streamablehttp" +version = "0.1.0" +source = { editable = "examples/servers/simple-streamablehttp" } +dependencies = [ + { name = "anyio" }, + { name = "click" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "starlette" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.5" }, + { name = "click", specifier = ">=8.2.0" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "mcp" }, + { name = "starlette" }, + { name = "uvicorn" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.378" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + +[[package]] +name = "mcp-simple-streamablehttp-stateless" +version = "0.1.0" +source = { editable = "examples/servers/simple-streamablehttp-stateless" } +dependencies = [ + { name = "anyio" }, + { name = "click" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "starlette" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.5" }, + { name = "click", specifier = ">=8.2.0" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "mcp" }, + { name = "starlette" }, + { name = "uvicorn" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.378" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + +[[package]] +name = "mcp-simple-tool" +version = "0.1.0" +source = { editable = "examples/servers/simple-tool" } +dependencies = [ + { name = "anyio" }, + { name = "click" }, + { name = "httpx" }, + { name = "mcp" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.5" }, + { name = "click", specifier = ">=8.2.0" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "mcp" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.378" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + [[package]] name = "mcp-snippets" version = "0.1.0" @@ -1072,6 +1390,72 @@ dependencies = [ [package.metadata] requires-dist = [{ name = "mcp" }] +[[package]] +name = "mcp-sse-polling-client" +version = "0.1.0" +source = { editable = "examples/clients/sse-polling-client" } +dependencies = [ + { name = "click" }, + { name = "mcp" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.2.0" }, + { name = "mcp" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.378" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + +[[package]] +name = "mcp-sse-polling-demo" +version = "0.1.0" +source = { editable = "examples/servers/sse-polling-demo" } +dependencies = [ + { name = "anyio" }, + { name = "click" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "starlette" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "anyio", specifier = ">=4.5" }, + { name = "click", specifier = ">=8.2.0" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "mcp" }, + { name = "starlette" }, + { name = "uvicorn" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1.378" }, + { name = "pytest", specifier = ">=8.3.3" }, + { name = "ruff", specifier = ">=0.6.9" }, +] + [[package]] name = "mcp-structured-output-lowlevel" version = "0.1.0" From 4052b31f7ce78e0c2ad95d967bfdf27737ceb097 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 24 Jun 2026 07:45:30 +0000 Subject: [PATCH 06/14] Restore original references to old example dirs (revert remainder) --- README.v2.md | 13 ++++++++----- examples/mcpserver/complex_inputs.py | 1 - examples/mcpserver/desktop.py | 1 - .../mcpserver/direct_call_tool_result_return.py | 1 - examples/mcpserver/simple_echo.py | 1 - examples/snippets/clients/oauth_client.py | 2 +- src/mcp/server/auth/middleware/bearer_auth.py | 5 +++-- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.v2.md b/README.v2.md index 8590f64126..b9896d9412 100644 --- a/README.v2.md +++ b/README.v2.md @@ -1058,7 +1058,7 @@ if __name__ == "__main__": _Full example: [examples/snippets/servers/oauth_server.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/oauth_server.py)_ -For a complete example with separate Authorization Server and Resource Server implementations, see [`examples/stories/oauth/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/stories/oauth/) (and [`examples/stories/bearer_auth/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/stories/bearer_auth/) for static-token auth). +For a complete example with separate Authorization Server and Resource Server implementations, see [`examples/servers/simple-auth/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/servers/simple-auth/). **Architecture:** @@ -1343,7 +1343,10 @@ app = Starlette( _Full example: [examples/snippets/servers/streamable_starlette_mount.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_starlette_mount.py)_ -For low level server with Streamable HTTP implementations, see [`examples/stories/stateless_legacy/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/stories/stateless_legacy/). +For low level server with Streamable HTTP implementations, see: + +- Stateful server: [`examples/servers/simple-streamablehttp/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/servers/simple-streamablehttp/) +- Stateless server: [`examples/servers/simple-streamablehttp-stateless/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/servers/simple-streamablehttp-stateless/) The streamable HTTP transport supports: @@ -2097,7 +2100,7 @@ _Full example: [examples/snippets/clients/pagination_client.py](https://github.c - **Backward compatible** - clients that don't support pagination will still work (they'll just get the first page) - **Flexible page sizes** - Each endpoint can define its own page size based on data characteristics -See the [pagination example](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/stories/pagination) for a complete implementation. +See the [simple-pagination example](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/servers/simple-pagination) for a complete implementation. ### Writing MCP Clients @@ -2317,7 +2320,7 @@ The SDK includes [authorization support](https://modelcontextprotocol.io/specifi ```python """Before running, specify running MCP RS server URL. To spin up RS server locally, see - examples/stories/oauth/README.md + examples/servers/simple-auth/README.md cd to the `examples/snippets` directory and run: uv run oauth-client @@ -2412,7 +2415,7 @@ if __name__ == "__main__": _Full example: [examples/snippets/clients/oauth_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/oauth_client.py)_ -For a complete working example, see [`examples/stories/oauth/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/stories/oauth/). +For a complete working example, see [`examples/clients/simple-auth-client/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/clients/simple-auth-client/). ### Parsing Tool Results diff --git a/examples/mcpserver/complex_inputs.py b/examples/mcpserver/complex_inputs.py index 704ba052a0..93a42d1c89 100644 --- a/examples/mcpserver/complex_inputs.py +++ b/examples/mcpserver/complex_inputs.py @@ -1,4 +1,3 @@ -# TODO: superseded by examples/stories/schema_validators/; remove once tests/test_examples.py is migrated. """MCPServer Complex inputs Example Demonstrates validation via pydantic with complex models. diff --git a/examples/mcpserver/desktop.py b/examples/mcpserver/desktop.py index c2999714f4..804184516d 100644 --- a/examples/mcpserver/desktop.py +++ b/examples/mcpserver/desktop.py @@ -1,4 +1,3 @@ -# TODO: superseded by examples/stories/resources/; remove once tests/test_examples.py is migrated. """MCPServer Desktop Example A simple example that exposes the desktop directory as a resource. diff --git a/examples/mcpserver/direct_call_tool_result_return.py b/examples/mcpserver/direct_call_tool_result_return.py index 2f9e63a044..c73e6164f5 100644 --- a/examples/mcpserver/direct_call_tool_result_return.py +++ b/examples/mcpserver/direct_call_tool_result_return.py @@ -1,4 +1,3 @@ -# TODO: superseded by examples/stories/tools/; remove once tests/test_examples.py is migrated. """MCPServer Echo Server with direct CallToolResult return""" from typing import Annotated diff --git a/examples/mcpserver/simple_echo.py b/examples/mcpserver/simple_echo.py index f2184d4af5..3d8142a665 100644 --- a/examples/mcpserver/simple_echo.py +++ b/examples/mcpserver/simple_echo.py @@ -1,4 +1,3 @@ -# TODO: superseded by examples/stories/tools/; remove once tests/test_examples.py is migrated. """MCPServer Echo Server""" from mcp.server.mcpserver import MCPServer diff --git a/examples/snippets/clients/oauth_client.py b/examples/snippets/clients/oauth_client.py index f130d9bd09..2085b9a1db 100644 --- a/examples/snippets/clients/oauth_client.py +++ b/examples/snippets/clients/oauth_client.py @@ -1,6 +1,6 @@ """Before running, specify running MCP RS server URL. To spin up RS server locally, see - examples/stories/oauth/README.md + examples/servers/simple-auth/README.md cd to the `examples/snippets` directory and run: uv run oauth-client diff --git a/src/mcp/server/auth/middleware/bearer_auth.py b/src/mcp/server/auth/middleware/bearer_auth.py index 63b1460b36..ba66e94226 100644 --- a/src/mcp/server/auth/middleware/bearer_auth.py +++ b/src/mcp/server/auth/middleware/bearer_auth.py @@ -31,8 +31,9 @@ def authorization_context(user: AuthenticatedUser) -> AuthorizationContext: verifier does not supply are `None`, so the comparison degrades to the remaining components. - See `examples/stories/bearer_auth/server.py` for a verifier that - populates `subject` and `client_id`.""" + See `examples/servers/simple-auth/mcp_simple_auth/token_verifier.py` for + a verifier that populates `subject` and `claims` from an introspection + response.""" token = user.access_token issuer = (token.claims or {}).get("iss") return AuthorizationContext( From 60c8b07568f270a8f36ce4254dba2e39c1ac83fe Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Wed, 24 Jun 2026 07:50:03 +0000 Subject: [PATCH 07/14] Add lax no cover to both arms of tomllib version-gate The else arm had it; the if arm did not, so on 3.10 the unreachable import tomllib line counted as a miss. --- tests/examples/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/examples/conftest.py b/tests/examples/conftest.py index b57da7e0fd..0374a31549 100644 --- a/tests/examples/conftest.py +++ b/tests/examples/conftest.py @@ -32,7 +32,7 @@ from mcp.shared.version import LATEST_MODERN_VERSION from tests.interaction.transports._bridge import StreamingASGITransport -if sys.version_info >= (3, 11): +if sys.version_info >= (3, 11): # pragma: lax no cover import tomllib else: # pragma: lax no cover import tomli as tomllib From 6942ebe8dadfe3687d61407c07c0cd70a9007842 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:09:35 +0000 Subject: [PATCH 08/14] 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. --- examples/stories/README.md | 90 +++++----- examples/stories/__init__.py | 2 +- examples/stories/_harness.py | 155 ++++++++++-------- examples/stories/bearer_auth/README.md | 19 ++- examples/stories/bearer_auth/client.py | 77 ++++----- examples/stories/client_session/README.md | 66 -------- examples/stories/client_session/client.py | 33 ---- examples/stories/client_session/server.py | 19 --- examples/stories/custom_methods/README.md | 3 + examples/stories/custom_methods/client.py | 24 +-- examples/stories/custom_version/README.md | 47 ------ examples/stories/custom_version/__init__.py | 0 examples/stories/custom_version/client.py | 22 --- examples/stories/custom_version/server.py | 19 --- .../stories/custom_version/server_lowlevel.py | 33 ---- examples/stories/dual_era/README.md | 23 ++- examples/stories/dual_era/client.py | 36 ++-- examples/stories/dual_era/server.py | 2 + examples/stories/dual_era/server_lowlevel.py | 2 + examples/stories/elicitation/__init__.py | 0 examples/stories/error_handling/README.md | 6 +- examples/stories/error_handling/client.py | 49 +++--- examples/stories/json_response/README.md | 13 +- examples/stories/json_response/client.py | 76 ++++----- .../README.md | 32 ++-- .../__init__.py | 0 .../client.py | 24 ++- .../server.py | 0 .../server_lowlevel.py | 0 examples/stories/legacy_routing/README.md | 18 +- examples/stories/legacy_routing/client.py | 18 +- examples/stories/lifespan/README.md | 4 + examples/stories/lifespan/client.py | 19 ++- examples/stories/manifest.toml | 91 +++++----- examples/stories/middleware/README.md | 12 +- examples/stories/middleware/client.py | 29 ++-- examples/stories/middleware/server.py | 48 ++++-- .../stories/middleware/server_lowlevel.py | 49 ------ examples/stories/mrtr/README.md | 4 +- examples/stories/oauth/README.md | 37 +++-- examples/stories/oauth/client.py | 114 +++++-------- .../oauth_client_credentials/README.md | 16 +- .../oauth_client_credentials/client.py | 45 ++--- examples/stories/pagination/README.md | 4 + examples/stories/pagination/client.py | 35 ++-- examples/stories/parallel_calls/README.md | 46 +++--- examples/stories/parallel_calls/client.py | 34 ++-- examples/stories/prompts/README.md | 3 + examples/stories/prompts/client.py | 61 +++---- examples/stories/reconnect/README.md | 32 ++-- examples/stories/reconnect/client.py | 58 +++---- examples/stories/resources/README.md | 4 + examples/stories/resources/client.py | 33 ++-- examples/stories/roots/README.md | 27 +-- examples/stories/roots/client.py | 25 ++- examples/stories/sampling/README.md | 23 ++- examples/stories/sampling/client.py | 23 +-- examples/stories/schema_validators/README.md | 3 + examples/stories/schema_validators/client.py | 57 +++---- examples/stories/serve_one/README.md | 44 +++-- examples/stories/serve_one/client.py | 20 +-- examples/stories/serve_one/server.py | 106 ++++++++++-- examples/stories/serve_one/server_lowlevel.py | 107 ------------ examples/stories/sse_polling/README.md | 20 ++- examples/stories/sse_polling/client.py | 33 ++-- examples/stories/standalone_get/README.md | 21 ++- examples/stories/standalone_get/client.py | 56 +++---- examples/stories/starlette_mount/README.md | 3 + examples/stories/starlette_mount/client.py | 25 +-- examples/stories/stateless_legacy/README.md | 7 +- examples/stories/stateless_legacy/client.py | 29 ++-- examples/stories/stickynotes/README.md | 11 +- examples/stories/stickynotes/client.py | 153 ++++++++--------- examples/stories/stickynotes/server.py | 14 +- examples/stories/streaming/README.md | 23 ++- examples/stories/streaming/client.py | 75 ++++----- examples/stories/tools/client.py | 37 +++-- tests/examples/conftest.py | 83 +++------- tests/examples/test_stories.py | 44 ++--- tests/examples/test_stories_smoke.py | 9 +- 80 files changed, 1248 insertions(+), 1516 deletions(-) delete mode 100644 examples/stories/client_session/README.md delete mode 100644 examples/stories/client_session/client.py delete mode 100644 examples/stories/client_session/server.py delete mode 100644 examples/stories/custom_version/README.md delete mode 100644 examples/stories/custom_version/__init__.py delete mode 100644 examples/stories/custom_version/client.py delete mode 100644 examples/stories/custom_version/server.py delete mode 100644 examples/stories/custom_version/server_lowlevel.py delete mode 100644 examples/stories/elicitation/__init__.py rename examples/stories/{elicitation => legacy_elicitation}/README.md (66%) rename examples/stories/{client_session => legacy_elicitation}/__init__.py (100%) rename examples/stories/{elicitation => legacy_elicitation}/client.py (53%) rename examples/stories/{elicitation => legacy_elicitation}/server.py (100%) rename examples/stories/{elicitation => legacy_elicitation}/server_lowlevel.py (100%) delete mode 100644 examples/stories/middleware/server_lowlevel.py delete mode 100644 examples/stories/serve_one/server_lowlevel.py diff --git a/examples/stories/README.md b/examples/stories/README.md index bab972fc95..330f3fdbf7 100644 --- a/examples/stories/README.md +++ b/examples/stories/README.md @@ -2,9 +2,20 @@ 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 `scenario(client)` 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. +`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. + +## 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 @@ -27,54 +38,59 @@ 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, and -variants; `tests/examples/` expands it. +[`manifest.toml`](manifest.toml) declares each story's transports, era, status, +and variants; `tests/examples/` expands it. ## Layout -`_harness.py` and `_hosting.py` are scaffolding that adapts a story's -`build_server()` / `build_app()` to argv (stdio vs `--http`) and to the -in-process test bridge. They isolate the parts of the SDK's hosting surface +`_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 URL under +`--http`). 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 | ready | -| [`prompts`](prompts/) | `@mcp.prompt()`, list/get, argument completion | ready | -| [`resources`](resources/) | `@mcp.resource()`, list/read, URI templates | ready | -| [`lifespan`](lifespan/) | startup/shutdown lifespan, per-request state injection | ready | -| [`dual_era`](dual_era/) | one server factory serving both protocol eras; era-neutral accessors | ready | -| [`custom_version`](custom_version/) | restricting `supported_protocol_versions` | ready | +| [`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 | ready | -| [`elicitation`](elicitation/) | server pauses a tool to ask the user (form + url) | ready (legacy-era) | -| [`sampling`](sampling/) | server asks the client's LLM mid-tool (push request) | ready (legacy-era) | -| [`stickynotes`](stickynotes/) | capstone: tools mutate state → resources + `list_changed` + elicit guard | ready | -| [`custom_methods`](custom_methods/) | vendor-prefixed JSON-RPC via `add_request_handler` / `send_request` | ready | -| [`schema_validators`](schema_validators/) | tool input schema from pydantic / TypedDict / dataclass / dict | ready | -| [`middleware`](middleware/) | server-side request/response middleware | ready | -| [`parallel_calls`](parallel_calls/) | N×M concurrent calls; per-call notification attribution | ready | -| [`roots`](roots/) | client-declared roots, server reads them via `ctx` | ready (legacy-era) | -| [`pagination`](pagination/) | manual cursor loop over list endpoints | ready | -| [`error_handling`](error_handling/) | `is_error` results vs `MCPError`; `ToolError` | ready | -| [`client_session`](client_session/) | dropping to `client.session` / `ClientSession` mechanics | ready | -| [`serve_one`](serve_one/) | building a `Connection` by hand and calling `serve_one` directly | ready | +| [`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()` default posture; the one-liner deploy | ready | -| [`json_response`](json_response/) | `json_response=True` mode; raw 2026 POST envelope on the wire | ready | -| [`legacy_routing`](legacy_routing/) | `is_legacy_request()` classifier in front of a sessionful 1.x deploy | ready | -| [`starlette_mount`](starlette_mount/) | mounting `streamable_http_app()` under a Starlette/FastAPI sub-path | ready | -| [`sse_polling`](sse_polling/) | SEP-1699 `closeSSE()` + `Last-Event-ID` resume via `EventStore` | ready | -| [`standalone_get`](standalone_get/) | server-initiated `list_changed` over the sessionful GET stream | ready | -| [`reconnect`](reconnect/) | explicit `discover()`, persist `DiscoverResult`, zero-RTT reconnect | ready | -| [`bearer_auth`](bearer_auth/) | `requireBearerAuth`, PRM metadata, static-token verifier, `ctx.authInfo` | ready | -| [`oauth`](oauth/) | full `authorization_code` grant against an in-process AS | ready | -| [`oauth_client_credentials`](oauth_client_credentials/) | `client_credentials` grant; minimal in-process token endpoint | ready | +| [`stateless_legacy`](stateless_legacy/) | `streamable_http_app()` default posture; 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 | +| [`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) | diff --git a/examples/stories/__init__.py b/examples/stories/__init__.py index a3db033cda..6f4d6055a7 100644 --- a/examples/stories/__init__.py +++ b/examples/stories/__init__.py @@ -1,6 +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 ``scenario(client)`` runs against both. +plus a ``client.py`` whose ``main(target, *, mode)`` runs against both. ``tests/examples/`` drives every story over an in-process matrix. """ diff --git a/examples/stories/_harness.py b/examples/stories/_harness.py index baf3081fee..39718b23ed 100644 --- a/examples/stories/_harness.py +++ b/examples/stories/_harness.py @@ -1,41 +1,43 @@ """Client-side scaffold for story examples. -A story's ``client.py`` imports only from here. The ``Connect`` factory and -``run_client`` ride the locked ``Client(transport, mode=...)`` surface; the one -volatile line is the stdio wrap (marked inline). +A story's ``client.py`` imports ``Target`` (or ``TargetFactory``) for its ``main`` +signature and calls ``run_client(main)`` from ``__main__``. The story owns the +``Client(target, mode=...)`` construction; this module only decides WHICH target +``__main__`` hands it. """ from __future__ import annotations import sys import traceback -from collections.abc import AsyncIterator, Awaitable, Callable -from contextlib import AbstractAsyncContextManager, asynccontextmanager +from collections.abc import Awaitable, Callable from pathlib import Path -from typing import Any, Protocol +from typing import Any, TypeAlias +from urllib.parse import urlsplit import anyio import httpx from mcp import StdioServerParameters, stdio_client -from mcp.client import Client +from mcp.client import Transport +from mcp.client.streamable_http import streamable_http_client +from mcp.server import Server +from mcp.server.mcpserver import MCPServer from mcp.shared.version import LATEST_MODERN_VERSION -Scenario = Callable[[Client], Awaitable[None]] -ScenarioWithConnect = Callable[[Client, "Connect"], Awaitable[None]] -AuthBuilder = Callable[[httpx.AsyncClient], httpx.Auth] -"""Builds an ``httpx.Auth`` bound to the in-process HTTP client (auth-story harness seam).""" +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib +Target: TypeAlias = "Server[Any] | MCPServer | Transport | str" +"""Anything ``Client(...)`` accepts: an in-process server, a ``Transport``, or an HTTP URL.""" -class Connect(Protocol): - """A factory yielding a connected ``Client``; accepts the same kwargs as ``Client``. - - ``auth`` is the HTTP-only escape hatch for auth stories: when given, the factory - builds a fresh ``httpx.AsyncClient`` against the same app, applies ``auth(http)`` - to it, and wraps the result in ``streamable_http_client`` before entering ``Client``. - """ +TargetFactory = Callable[[], Target] +"""Yields a FRESH target against the same server/app on every call (``multi_connection`` stories).""" - def __call__(self, *, auth: AuthBuilder | None = None, **client_kw: Any) -> AbstractAsyncContextManager[Client]: ... +AuthBuilder = Callable[[httpx.AsyncClient], httpx.Auth] +"""Builds an ``httpx.Auth`` bound to the in-process HTTP client (auth-story harness seam).""" def argv_after(flag: str, *, default: str | None = None) -> str: @@ -48,67 +50,84 @@ def argv_after(flag: str, *, default: str | None = None) -> str: return default -def connect_from_args(file: str) -> Connect: - """Build a ``Connect`` targeting the sibling server over the argv-selected transport. +def target_from_args(file: str) -> TargetFactory: + """Build a ``TargetFactory`` for the sibling server over the argv-selected transport. - ``--http `` connects over streamable HTTP; ``--stdio`` (the default) spawns the - sibling ``server.py`` as a subprocess. ``--server `` selects ``.py`` - (e.g. ``server_lowlevel``). ``--legacy`` pins the handshake era; otherwise the - modern era is used. ``file`` is the caller's ``__file__``. + ``--http `` targets that streamable-HTTP URL; ``--stdio`` (the default) spawns + the sibling ``server.py`` as a fresh subprocess on each call. ``--server `` + selects ``.py`` (e.g. ``server_lowlevel``). ``file`` is the caller's ``__file__``. """ - here = Path(file).parent - server_stem = argv_after("--server", default="server") - # Never rely on the SDK's mode= default — be explicit. stdio is legacy-only until - # the SDK's stdio entry can negotiate the era; the modern arm is --http only for now. if "--http" in sys.argv: - mode = "legacy" if "--legacy" in sys.argv else LATEST_MODERN_VERSION - else: - mode = "legacy" # stdio gains a modern arm once serve_stdio() lands - - @asynccontextmanager - async def _connect(*, auth: AuthBuilder | None = None, **client_kw: Any) -> AsyncIterator[Client]: - assert auth is None, "auth= via connect_from_args is not wired; auth stories own their __main__" - client_kw.setdefault("mode", mode) - target: Any - if "--http" in sys.argv: - target = argv_after("--http") - else: - params = StdioServerParameters(command=sys.executable, args=[str(here / f"{server_stem}.py")]) - target = stdio_client(params) # becomes Client(params) once that overload lands - async with Client(target, **client_kw) as client: - yield client - - return _connect - - -def run_client( - scenario: Scenario | ScenarioWithConnect, - *, - connect: Connect, - needs_connect: bool = False, - **client_kw: Any, -) -> None: + url = argv_after("--http") + return lambda: url + # stdio is legacy-only until serve_stdio() lands; the modern arm is --http only for now. + server = Path(file).parent / f"{argv_after('--server', default='server')}.py" + params = StdioServerParameters(command=sys.executable, args=[str(server)]) + return lambda: stdio_client(params) # becomes Client(params) once that overload lands + + +def _story_cfg(name: str) -> dict[str, Any]: + """The manifest entry for the story ``name`` with ``[defaults]`` applied.""" + manifest: dict[str, Any] = tomllib.loads((Path(__file__).parent / "manifest.toml").read_text()) + return manifest["defaults"] | manifest["story"].get(name, {}) + + +def _authed_targets(url: str, http: httpx.AsyncClient) -> TargetFactory: + """Fresh streamable-HTTP transports over an already-authed ``httpx`` client.""" + return lambda: streamable_http_client(url, http_client=http) + + +def run_client(main: Callable[..., Awaitable[None]]) -> None: """Entry point for ``if __name__ == "__main__"`` in every ``client.py``. - Runs ``scenario`` inside a connected client; prints ``OK:``/``FAIL:`` to stderr and - exits 0/1. ``needs_connect=True`` passes ``connect`` as the second argument so the - scenario can open additional clients. + Builds the argv-selected target(s) for the story that defines ``main``, picks the + era from argv, and calls ``main`` with an explicit ``mode=``. If the story module + exports ``build_auth``, the ``--http`` target is routed through an ``httpx.AsyncClient`` + that carries the returned ``httpx.Auth``. Prints ``OK:``/``FAIL:`` to stderr, exits 0/1. """ - file = getattr(scenario, "__globals__", {}).get("__file__", "") + globals_ = getattr(main, "__globals__", {}) + file = str(globals_.get("__file__", "")) name = Path(file).parent.name + cfg = _story_cfg(name) + targets = target_from_args(file) + build_auth: AuthBuilder | None = globals_.get("build_auth") transport = "http" if "--http" in sys.argv else "stdio" + # Never rely on the SDK's mode= default — be explicit. stdio is legacy-only until + # the SDK's stdio entry can negotiate the era, so only --http gets a modern arm. era = "modern" if transport == "http" and "--legacy" not in sys.argv else "legacy" - - async def _main() -> None: - with anyio.fail_after(30): - async with connect(**client_kw) as client: - if needs_connect: - await scenario(client, connect) # type: ignore[call-arg] + if cfg["era"] == "dual-in-body": + # The story pins its connection modes inside ``main`` itself, so hand it the + # real-user "auto" default and let those in-body pins decide. A hard version pin + # here would skip the discover probe and leave ``server_info`` blank. + era = "in-body" + mode = {"modern": LATEST_MODERN_VERSION, "legacy": "legacy", "in-body": "auto"}[era] + + async def _run() -> None: + with anyio.fail_after(cfg["timeout_s"]): + if not cfg["needs_http"] and (build_auth is None or transport != "http"): + await main(targets if cfg["multi_connection"] else targets(), mode=mode) + return + # Auth and needs_http stories want the raw httpx client underneath the transport: + # build_auth threads an httpx.Auth onto it (Client(url, auth=...) doesn't exist + # yet), and needs_http stories assert on raw responses, so root the client at the + # server origin and relative paths like "/mcp" resolve. + if transport != "http": + raise SystemExit(f"{name} asserts on raw HTTP responses; run it with --http ") + url = argv_after("--http") + parts = urlsplit(url) + async with httpx.AsyncClient(base_url=f"{parts.scheme}://{parts.netloc}") as http: + make = targets + if build_auth is not None: + http.auth = build_auth(http) + make = _authed_targets(url, http) + target: Any = make if cfg["multi_connection"] else make() + if cfg["needs_http"]: + await main(target, mode=mode, http=http) else: - await scenario(client) # type: ignore[call-arg] + await main(target, mode=mode) try: - anyio.run(_main) + anyio.run(_run) except Exception: print(f"FAIL: {name} ({transport}/{era})", file=sys.stderr) traceback.print_exc() diff --git a/examples/stories/bearer_auth/README.md b/examples/stories/bearer_auth/README.md index 1ca7f3f717..9849057363 100644 --- a/examples/stories/bearer_auth/README.md +++ b/examples/stories/bearer_auth/README.md @@ -24,6 +24,12 @@ uv run python -m stories.bearer_auth.server_lowlevel --port 8001 & uv run python -m stories.bearer_auth.client --http http://127.0.0.1:8001/mcp ``` +`Client(url)` has no `auth=` passthrough, so a target built from a bare URL +can't carry the token. Both runners close that gap the same way: `run_client` +(above) and the pytest harness thread the module's `build_auth` export onto the +`httpx.AsyncClient` underneath the transport and hand `main` a target that is +already routed through it. + ## Try it without the SDK client ```bash @@ -38,6 +44,14 @@ curl -s http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp | jq ## What to look at +- `client.py` `main` — opens with `async with Client(target, mode=mode) as + client:` and that is the whole program. The `target` it receives is a + transport that already carries the bearer token; nothing in the body knows + auth exists. +- `client.py` `build_auth` / `StaticBearerAuth` — bearer auth client-side is + five lines of `httpx.Auth`. `Client(url, auth=...)` is the ergonomic the SDK + is missing; until it lands, the auth has to be threaded onto the + `httpx.AsyncClient` underneath the transport, outside `main`. - `server.py` — `MCPServer(token_verifier=..., auth=AuthSettings(...))` is the whole recipe; `streamable_http_app()` reads those constructor kwargs and mounts the bearer gate + PRM route. @@ -48,11 +62,6 @@ curl -s http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp | jq - `whoami()` — `get_access_token()` returns the per-HTTP-request `AccessToken`. It is **not** on `Context` (unlike other SDKs' `ctx.authInfo`); a later release will namespace it as `ctx.transport.auth`. -- `client.py` — `http_client_kw` carries the `Authorization` header at the - `httpx.AsyncClient` layer because `Client(url)` has no `auth=` passthrough - yet. The `__main__` block shows the hand-built - `httpx.AsyncClient → streamable_http_client → Client` chain a real caller - would write today. ## Caveats diff --git a/examples/stories/bearer_auth/client.py b/examples/stories/bearer_auth/client.py index 96d030f842..d1143ec22e 100644 --- a/examples/stories/bearer_auth/client.py +++ b/examples/stories/bearer_auth/client.py @@ -1,57 +1,48 @@ -"""Call the bearer-gated server with a static ``Authorization`` header; assert the principal.""" +"""Call the bearer-gated server through an already-authed transport; assert the ``whoami`` principal.""" -import sys -import traceback -from typing import Any +from collections.abc import Generator -import anyio import httpx from mcp.client import Client -from mcp.client.streamable_http import streamable_http_client -from mcp.shared.version import LATEST_MODERN_VERSION -from stories._harness import argv_after +from stories._harness import Target, run_client from .server import DEMO_TOKEN, REQUIRED_SCOPE -# ``Client(url)`` has no ``auth=`` / ``http_client=`` passthrough yet, so the bearer -# header is threaded at the ``httpx.AsyncClient`` layer. The harness reads this -# module-level dict and splats it into the in-process bridge client. -http_client_kw: dict[str, Any] = {"headers": {"authorization": f"Bearer {DEMO_TOKEN}"}} +class StaticBearerAuth(httpx.Auth): + """``httpx.Auth`` that attaches a fixed ``Authorization: Bearer `` to every request.""" -async def scenario(client: Client) -> None: - listed = await client.list_tools() - assert [t.name for t in listed.tools] == ["whoami"] + def __init__(self, token: str) -> None: + self.token = token - result = await client.call_tool("whoami", {}) - assert not result.is_error, result - assert result.structured_content == { - "subject": "demo-user", - "client_id": "demo-client", - "scopes": [REQUIRED_SCOPE], - }, result.structured_content + def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]: + request.headers["Authorization"] = f"Bearer {self.token}" + yield request + + +def build_auth(_http: httpx.AsyncClient) -> httpx.Auth: + """The demo bearer token as an ``httpx.Auth``. + + ``Client(url, auth=...)`` doesn't exist yet, so the harness threads this onto the underlying + ``httpx.AsyncClient`` and the target ``main`` receives is already routed through it. + """ + return StaticBearerAuth(DEMO_TOKEN) + + +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + listed = await client.list_tools() + assert [t.name for t in listed.tools] == ["whoami"] + + result = await client.call_tool("whoami", {}) + assert not result.is_error, result + assert result.structured_content == { + "subject": "demo-user", + "client_id": "demo-client", + "scopes": [REQUIRED_SCOPE], + }, result.structured_content if __name__ == "__main__": - # HTTP-only auth story; --http takes the MCP endpoint URL. Hand-rolled because - # ``connect_from_args`` cannot thread the bearer header; this IS the recipe. - url = argv_after("--http", default="http://127.0.0.1:8000/mcp") - mode = "legacy" if "--legacy" in sys.argv else LATEST_MODERN_VERSION - - async def _main() -> None: - with anyio.fail_after(30): - async with ( - httpx.AsyncClient(**http_client_kw) as http_client, - Client(streamable_http_client(url, http_client=http_client), mode=mode) as client, - ): - await scenario(client) - - try: - anyio.run(_main) - except Exception: - print("FAIL: bearer_auth (http)", file=sys.stderr) - traceback.print_exc() - raise SystemExit(1) from None - print("OK: bearer_auth (http)", file=sys.stderr) - raise SystemExit(0) + run_client(main) diff --git a/examples/stories/client_session/README.md b/examples/stories/client_session/README.md deleted file mode 100644 index b6fd47f85f..0000000000 --- a/examples/stories/client_session/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# client-session - -`Client` is a thin shell over `ClientSession`. This story shows the -`client.session` escape hatch: the era-specific result slots -(`initialize_result` / `discover_result`), the Optional-vs-narrowed accessors, -and the generic `send_request()` that every typed `client.*()` method wraps. - -## Run it - -```bash -# stdio (default — the client spawns the server as a subprocess) -uv run python -m stories.client_session.client - -# against a running HTTP server -uv run python -m stories.client_session.server --http --port 8000 & -uv run python -m stories.client_session.client --http http://127.0.0.1:8000/mcp -``` - -## What to look at - -- **`client.session`** returns the live `ClientSession`. This is the documented - escape hatch when `Client` doesn't expose what you need (custom JSON-RPC, - era-specific result objects, the connect primitives). -- **Exactly one of `initialize_result` / `discover_result`** is ever non-None. - `Client.__aenter__` ran the connect ladder for you (`mode="legacy"` → - `initialize()`; `mode="auto"` → `discover()` with fallback; `mode=` - → `adopt()`); which slot is filled tells you which path it took. -- **`ClientSession.protocol_version` is `str | None`; `Client.protocol_version` - is `str`.** Same value, different Optional-ness — `Client` guarantees it's - set inside the `async with` block. Same for `server_info` / - `server_capabilities`. -- **`send_request(request_model, result_type)`** is the layer beneath - `client.list_tools()`. See `custom_methods/` for using it to call vendor - methods `Client` doesn't model. - -> When `mode=` is set without `prior_discover=`, the SDK synthesizes -> a placeholder `DiscoverResult` (empty `server_info` / `capabilities`); only -> `protocol_version` is meaningful on that path. - -## Building a ClientSession directly - -`Client` builds the `ClientSession` for you. To own the connect step yourself -(e.g. to call `discover()` and cache the result, or to drive raw streams from -a custom transport), construct `ClientSession` over a stream pair: - -```python -from mcp import ClientSession, StdioServerParameters, stdio_client - -async with stdio_client(StdioServerParameters(command="./server")) as (read, write): - async with ClientSession(read, write) as session: - result = await session.initialize() # or: await session.discover() - tools = await session.list_tools() -``` - -This is the v1-lineage shape; `Client` exists so you usually don't write it. - -## Spec - -- [Lifecycle — 2025-11-25](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle) -- [Lifecycle — 2026-07-28 discover](https://modelcontextprotocol.io/specification/2026-07-28/basic/lifecycle#discover) - -## See also - -`serve_one/` (server-side mechanics counterpart), `custom_methods/` -(`send_request` for vendor JSON-RPC), `dual_era/` (the connect ladder as the -teaching point). diff --git a/examples/stories/client_session/client.py b/examples/stories/client_session/client.py deleted file mode 100644 index bb23aec74c..0000000000 --- a/examples/stories/client_session/client.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Drop from `Client` to `client.session`: the `ClientSession` mechanics layer beneath.""" - -from mcp import types -from mcp.client import Client, ClientSession -from stories._harness import connect_from_args, run_client - - -async def scenario(client: Client) -> None: - # client.session is the ClientSession that Client.__aenter__ connected for you. - session: ClientSession = client.session - - # __aenter__ ran exactly one of initialize() / discover() / adopt(), so exactly one - # era-specific result slot is populated — whichever era was negotiated. - assert (session.initialize_result is None) != (session.discover_result is None) - - # ClientSession's accessors are Optional (None until a result is adopted); Client's - # same-named properties narrow them to non-Optional inside the `async with` block. - assert session.protocol_version is not None - assert session.protocol_version == client.protocol_version - assert session.server_info == client.server_info - assert session.server_capabilities == client.server_capabilities - - # send_request() is the generic primitive every typed client.*() method wraps: - # any ClientRequest model + the expected result type. - listed = await session.send_request(types.ListToolsRequest(), types.ListToolsResult) - assert [t.name for t in listed.tools] == ["add"] - - # The typed wrapper produces the same result. - assert await client.list_tools() == listed - - -if __name__ == "__main__": - run_client(scenario, connect=connect_from_args(__file__)) diff --git a/examples/stories/client_session/server.py b/examples/stories/client_session/server.py deleted file mode 100644 index 746d39a55b..0000000000 --- a/examples/stories/client_session/server.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Minimal server for the client-session story (the teaching point is client-side).""" - -from mcp.server.mcpserver import MCPServer -from stories._hosting import run_server_from_args - - -def build_server() -> MCPServer: - mcp = MCPServer("client-session-example") - - @mcp.tool() - def add(a: int, b: int) -> int: - """Add two integers.""" - return a + b - - return mcp - - -if __name__ == "__main__": - run_server_from_args(build_server) diff --git a/examples/stories/custom_methods/README.md b/examples/stories/custom_methods/README.md index ce7416109e..54fcb4785f 100644 --- a/examples/stories/custom_methods/README.md +++ b/examples/stories/custom_methods/README.md @@ -19,6 +19,9 @@ uv run python -m stories.custom_methods.client --http http://127.0.0.1:8000/mcp ## What to look at +- `client.py` `main` — the body opens with `Client(target, mode=mode)`. The + vendor request rides whichever protocol era `mode` selects; nothing else in + the story changes between eras. - `server.py` `SearchParams` — subclasses `types.RequestParams` so `_meta` (and on a 2026-07-28 connection, the reserved `io.modelcontextprotocol/*` envelope keys) parse uniformly without extra code. diff --git a/examples/stories/custom_methods/client.py b/examples/stories/custom_methods/client.py index 700179f5ca..df9a4b89d9 100644 --- a/examples/stories/custom_methods/client.py +++ b/examples/stories/custom_methods/client.py @@ -4,7 +4,7 @@ from mcp import types from mcp.client import Client -from stories._harness import connect_from_args, run_client +from stories._harness import Target, run_client class SearchParams(types.RequestParams): @@ -21,16 +21,18 @@ class SearchResult(types.Result): items: list[str] -async def scenario(client: Client) -> None: - # `Client` only exposes spec-defined verbs. For vendor methods, drop one - # layer to `client.session` — the sanctioned escape hatch. `send_request` - # is typed against the closed `ClientRequest` union, hence the cast; at - # runtime the body only calls `.model_dump()` and the unknown method skips - # the per-spec result-validation registry. - request = SearchRequest(params=SearchParams(query="mcp", limit=3)) - result = await client.session.send_request(cast("types.ClientRequest", request), SearchResult) - assert result.items == ["mcp-0", "mcp-1", "mcp-2"], result +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + # `Client` only exposes spec-defined verbs, so vendor methods have to drop one + # layer to `client.session` today — there is no `Client`-level API for them + # yet, and whether `.session` stays public is undecided. `send_request` is + # typed against the closed `ClientRequest` union, hence the cast; at runtime + # the body only calls `.model_dump()` and the unknown method skips the + # per-spec result-validation registry. + request = SearchRequest(params=SearchParams(query="mcp", limit=3)) + result = await client.session.send_request(cast("types.ClientRequest", request), SearchResult) + assert result.items == ["mcp-0", "mcp-1", "mcp-2"], result if __name__ == "__main__": - run_client(scenario, connect=connect_from_args(__file__)) + run_client(main) diff --git a/examples/stories/custom_version/README.md b/examples/stories/custom_version/README.md deleted file mode 100644 index a4c9dd682f..0000000000 --- a/examples/stories/custom_version/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# custom-version - -Where the negotiated protocol version lives after the 2025-era `initialize` -handshake: `client.protocol_version` on the client, and -`ctx.request_context.protocol_version` (or `ctx.protocol_version` directly on -`ServerRequestContext` in the lowlevel API) inside a handler. The scenario -proves both sides agree by round-tripping the server's view through a tool -call and comparing it to the client's accessor. - -## Run it - -```bash -# stdio (default — the client spawns the server as a subprocess) -uv run python -m stories.custom_version.client - -# against a running HTTP server -uv run python -m stories.custom_version.server --http --port 8000 & -uv run python -m stories.custom_version.client --http http://127.0.0.1:8000/mcp --legacy -``` - -## What to look at - -- `server.py` — `ctx.request_context.protocol_version`: the version the - `initialize` handshake settled on, available to every handler. -- `server_lowlevel.py` — `ctx.protocol_version`: the same field directly on - `ServerRequestContext`. -- `client.py` — `client.protocol_version`: the era-neutral accessor (populated - whether the connection used `initialize` or `server/discover`). - -## Not yet: overriding the supported-version set - -The TypeScript SDK lets a server declare `supportedProtocolVersions: [...]` to -accept a version string the SDK doesn't yet ship (the first entry is the -counter-offer when the client requests something unknown). The python-sdk -doesn't expose this knob yet — server-side negotiation is fixed to -`mcp.shared.version.HANDSHAKE_PROTOCOL_VERSIONS`. When that kwarg lands, -`build_server()` grows one argument and `scenario()` asserts a custom version -round-trips. Tracked for pre-beta. - -## Spec - -[Lifecycle — version negotiation](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle) - -## See also - -`dual_era/` (one server serving both eras), `legacy_routing/` (HTTP-layer era -classifier). diff --git a/examples/stories/custom_version/__init__.py b/examples/stories/custom_version/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/examples/stories/custom_version/client.py b/examples/stories/custom_version/client.py deleted file mode 100644 index 4e808089cf..0000000000 --- a/examples/stories/custom_version/client.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Assert the client and server agree on the negotiated protocol version.""" - -from mcp import types -from mcp.client import Client -from mcp.shared.version import HANDSHAKE_PROTOCOL_VERSIONS -from stories._harness import connect_from_args, run_client - - -async def scenario(client: Client) -> None: - # Era-neutral accessor: populated from InitializeResult under mode="legacy". - assert client.protocol_version in HANDSHAKE_PROTOCOL_VERSIONS, client.protocol_version - - listed = await client.list_tools() - assert [t.name for t in listed.tools] == ["protocol_info"] - - result = await client.call_tool("protocol_info", {}) - assert isinstance(result.content[0], types.TextContent) - assert result.content[0].text == client.protocol_version, result - - -if __name__ == "__main__": - run_client(scenario, connect=connect_from_args(__file__)) diff --git a/examples/stories/custom_version/server.py b/examples/stories/custom_version/server.py deleted file mode 100644 index 8333fe2ef8..0000000000 --- a/examples/stories/custom_version/server.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Read the negotiated protocol version inside a tool handler (initialize handshake).""" - -from mcp.server.mcpserver import Context, MCPServer -from stories._hosting import run_server_from_args - - -def build_server() -> MCPServer: - mcp = MCPServer("custom-version-example") - - @mcp.tool() - def protocol_info(ctx: Context) -> str: - """Return the protocol version this connection negotiated.""" - return ctx.request_context.protocol_version - - return mcp - - -if __name__ == "__main__": - run_server_from_args(build_server) diff --git a/examples/stories/custom_version/server_lowlevel.py b/examples/stories/custom_version/server_lowlevel.py deleted file mode 100644 index f1458098e5..0000000000 --- a/examples/stories/custom_version/server_lowlevel.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Read the negotiated protocol version inside a lowlevel handler (initialize handshake).""" - -from typing import Any - -from mcp import types -from mcp.server.context import ServerRequestContext -from mcp.server.lowlevel import Server -from stories._hosting import run_server_from_args - - -def build_server() -> Server[Any]: - async def list_tools( - ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None - ) -> types.ListToolsResult: - return types.ListToolsResult( - tools=[ - types.Tool( - name="protocol_info", - description="Return the protocol version this connection negotiated.", - input_schema={"type": "object"}, - ) - ] - ) - - async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: - assert params.name == "protocol_info" - return types.CallToolResult(content=[types.TextContent(text=ctx.protocol_version)]) - - return Server("custom-version-example", on_list_tools=list_tools, on_call_tool=call_tool) - - -if __name__ == "__main__": - run_server_from_args(build_server) diff --git a/examples/stories/dual_era/README.md b/examples/stories/dual_era/README.md index 83d399aef3..4ed87722cc 100644 --- a/examples/stories/dual_era/README.md +++ b/examples/stories/dual_era/README.md @@ -16,7 +16,7 @@ uv run python -m stories.dual_era.client --http http://127.0.0.1:8000/mcp # lowlevel server variant uv run python -m stories.dual_era.server_lowlevel --http --port 8000 & -uv run python -m stories.dual_era.client --http http://127.0.0.1:8000/mcp --server server_lowlevel +uv run python -m stories.dual_era.client --http http://127.0.0.1:8000/mcp ``` The bare stdio invocation (`uv run python -m stories.dual_era.client`) is @@ -25,14 +25,21 @@ leg fails there today — run over `--http`. ## What to look at -- `server.py` — `ctx.request_context.protocol_version` is the era branch key - (lowlevel: `ctx.protocol_version` directly). Compare against - `MODERN_PROTOCOL_VERSIONS`, never a date literal. +- `client.py` — both connections are visible, against the same `targets()` + factory: `Client(targets(), mode=mode)` (default `"auto"`, the + discover-then-fallback ladder) and `Client(targets(), mode="legacy")` (forces + the `initialize` handshake). The era decision is one explicit `mode=` argument + at construction; no date strings appear in the body. - `client.py` — `client.protocol_version` / `client.server_info` / `client.server_capabilities` are era-neutral: populated by `initialize` *or* `server/discover`, whichever ran. -- `client.py` — `mode="auto"` is the discover-then-fallback ladder; - `mode="legacy"` forces the handshake. No date strings appear in the body. +- `server.py` — `ctx.request_context.protocol_version` is the era branch key + (lowlevel: `ctx.protocol_version` directly). Compare against + `MODERN_PROTOCOL_VERSIONS`, never a date literal. +- **Where to read the negotiated version.** One value, three read paths: + `client.protocol_version` on the client after connect; `ctx.protocol_version` + inside a lowlevel handler; `ctx.request_context.protocol_version` inside an + `MCPServer` handler. ## Caveats @@ -49,5 +56,5 @@ leg fails there today — run over `--http`. ## See also -`custom_version/` (pin the server to one era), `legacy_routing/` (route eras -yourself), `reconnect/` (persist `DiscoverResult` for zero-RTT reconnect). +`legacy_routing/` (route eras yourself), `reconnect/` (persist `DiscoverResult` +for zero-RTT reconnect). diff --git a/examples/stories/dual_era/client.py b/examples/stories/dual_era/client.py index 29225f3120..a65b8b4914 100644 --- a/examples/stories/dual_era/client.py +++ b/examples/stories/dual_era/client.py @@ -3,27 +3,29 @@ from mcp import types from mcp.client import Client from mcp.shared.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION -from stories._harness import Connect, connect_from_args, run_client +from stories._harness import TargetFactory, run_client -async def scenario(client: Client, connect: Connect) -> None: - # ── modern leg: the harness-supplied client connected at mode="auto", so __aenter__ - # sent server/discover and adopted the result — no initialize handshake ran. - assert client.protocol_version == LATEST_MODERN_VERSION - assert client.server_info.name == "dual-era-example" - assert client.server_capabilities.tools is not None +async def main(targets: TargetFactory, *, mode: str = "auto") -> None: + # ── modern arm: the caller's mode (the real-user "auto" default) probes + # ``server/discover`` and adopts the result — no ``initialize`` handshake runs. + # The version/info/capabilities accessors are era-neutral. + async with Client(targets(), mode=mode) as modern: + assert modern.protocol_version == LATEST_MODERN_VERSION + assert modern.server_info.name == "dual-era-example" + assert modern.server_capabilities.tools is not None - listed = await client.list_tools() - assert [t.name for t in listed.tools] == ["greet"] + listed = await modern.list_tools() + assert [t.name for t in listed.tools] == ["greet"] - result = await client.call_tool("greet", {"name": "2026 client"}) - first = result.content[0] - assert isinstance(first, types.TextContent) - assert first.text == f"Hello, 2026 client! (served on the modern era at {LATEST_MODERN_VERSION})" + result = await modern.call_tool("greet", {"name": "2026 client"}) + first = result.content[0] + assert isinstance(first, types.TextContent) + assert first.text == f"Hello, 2026 client! (served on the modern era at {LATEST_MODERN_VERSION})" - # ── legacy leg: a fresh client at mode="legacy" runs the initialize handshake against - # the SAME server factory. The era-neutral accessors are populated identically. - async with connect(mode="legacy") as legacy: + # ── legacy arm: a fresh connection to the SAME server, pinned to the handshake era. + # The same accessors are populated identically — here by ``initialize``. + async with Client(targets(), mode="legacy") as legacy: assert legacy.protocol_version == LATEST_HANDSHAKE_VERSION assert legacy.server_info.name == "dual-era-example" assert legacy.server_capabilities.tools is not None @@ -35,4 +37,4 @@ async def scenario(client: Client, connect: Connect) -> None: if __name__ == "__main__": - run_client(scenario, connect=connect_from_args(__file__), needs_connect=True, mode="auto") + run_client(main) diff --git a/examples/stories/dual_era/server.py b/examples/stories/dual_era/server.py index 5135c239c5..16486a4dce 100644 --- a/examples/stories/dual_era/server.py +++ b/examples/stories/dual_era/server.py @@ -6,6 +6,8 @@ def build_server() -> MCPServer: + # The same factory serves both eras with no configuration. Which era a request is + # on is decided by the entry point / transport, never by the server. mcp = MCPServer("dual-era-example", instructions="A small dual-era demo server.") @mcp.tool() diff --git a/examples/stories/dual_era/server_lowlevel.py b/examples/stories/dual_era/server_lowlevel.py index a891a01efd..a5625139dc 100644 --- a/examples/stories/dual_era/server_lowlevel.py +++ b/examples/stories/dual_era/server_lowlevel.py @@ -35,6 +35,8 @@ async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolReques text = f"Hello, {params.arguments['name']}! (served on the {era} era at {ctx.protocol_version})" return types.CallToolResult(content=[types.TextContent(text=text)]) + # The same factory serves both eras with no configuration. Which era a request is + # on is decided by the entry point / transport, never by the server. return Server( "dual-era-example", instructions="A small dual-era demo server.", diff --git a/examples/stories/elicitation/__init__.py b/examples/stories/elicitation/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/examples/stories/error_handling/README.md b/examples/stories/error_handling/README.md index 7f9d8f6605..81d2eabc8d 100644 --- a/examples/stories/error_handling/README.md +++ b/examples/stories/error_handling/README.md @@ -21,13 +21,15 @@ uv run python -m stories.error_handling.client --http http://127.0.0.1:8000/mcp ## What to look at +- `client.py` `main` — opens with `async with Client(target, mode=mode) as + client:`. Inside it, `await` returns for `is_error` results and + `except MCPError` catches protocol errors; the client never auto-raises on + `is_error`. - `server.py` — `raise ToolError(...)` vs `raise MCPError(...)`: same `raise` keyword, opposite wire channel. The tool wrapper re-raises `MCPError` verbatim and wraps everything else as an `is_error` result. - `server_lowlevel.py` — no wrapper: you build `CallToolResult(is_error=True)` yourself, and `MCPError` is the only way to pick a JSON-RPC error code. -- `client.py` — `await` returns for `is_error` results; `except MCPError` - catches protocol errors. The client never auto-raises on `is_error`. ## Caveats diff --git a/examples/stories/error_handling/client.py b/examples/stories/error_handling/client.py index 1cdea3d760..d4172a2d90 100644 --- a/examples/stories/error_handling/client.py +++ b/examples/stories/error_handling/client.py @@ -3,34 +3,35 @@ from mcp import MCPError from mcp.client import Client from mcp.types import INVALID_PARAMS, TextContent -from stories._harness import connect_from_args, run_client +from stories._harness import Target, run_client -async def scenario(client: Client) -> None: - # Success: is_error defaults to False. - ok = await client.call_tool("divide", {"a": 6, "b": 2}) - assert ok.is_error is False, ok - assert isinstance(ok.content[0], TextContent) - assert ok.content[0].text == "3.0" +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + # Success: is_error defaults to False. + ok = await client.call_tool("divide", {"a": 6, "b": 2}) + assert ok.is_error is False, ok + assert isinstance(ok.content[0], TextContent) + assert ok.content[0].text == "3.0" - # Execution error: arrives as a *result* — await returns, no exception. - failed = await client.call_tool("divide", {"a": 1, "b": 0}) - assert failed.is_error is True, "execution errors ride CallToolResult, not an exception" - assert isinstance(failed.content[0], TextContent) - # MCPServer prefixes "Error executing tool divide: ..."; lowlevel returns - # the message verbatim. Assert the substring both produce. - assert "cannot divide by zero" in failed.content[0].text + # Execution error: arrives as a *result* — await returns, no exception. + failed = await client.call_tool("divide", {"a": 1, "b": 0}) + assert failed.is_error is True, "execution errors ride CallToolResult, not an exception" + assert isinstance(failed.content[0], TextContent) + # MCPServer prefixes "Error executing tool divide: ..."; lowlevel returns + # the message verbatim. Assert the substring both produce. + assert "cannot divide by zero" in failed.content[0].text - # Protocol error: arrives as a raised MCPError. - try: - await client.call_tool("restricted", {}) - except MCPError as e: - assert e.code == INVALID_PARAMS - assert e.message == "this tool is gated" - assert e.error.data == {"reason": "demo"} - else: - raise AssertionError("expected MCPError for a protocol-level rejection") + # Protocol error: arrives as a raised MCPError. + try: + await client.call_tool("restricted", {}) + except MCPError as e: + assert e.code == INVALID_PARAMS + assert e.message == "this tool is gated" + assert e.error.data == {"reason": "demo"} + else: + raise AssertionError("expected MCPError for a protocol-level rejection") if __name__ == "__main__": - run_client(scenario, connect=connect_from_args(__file__)) + run_client(main) diff --git a/examples/stories/json_response/README.md b/examples/stories/json_response/README.md index 4649bd45af..1f1f630b14 100644 --- a/examples/stories/json_response/README.md +++ b/examples/stories/json_response/README.md @@ -13,7 +13,7 @@ same endpoint behave the same way. uv run python -m stories.json_response.server --port 8000 & # high-level Client + raw-envelope probe against it -uv run python -m stories.json_response.client --http http://127.0.0.1:8000 +uv run python -m stories.json_response.client --http http://127.0.0.1:8000/mcp # or POST the raw envelope yourself curl -s http://127.0.0.1:8000/mcp \ @@ -26,12 +26,17 @@ curl -s http://127.0.0.1:8000/mcp \ ## What to look at +- `client.py` `main` — `async with Client(target, mode=mode) as client:` is an + ordinary high-level client; nothing about JSON mode is visible from this side. + The same `main` also takes the raw `httpx.AsyncClient` so it can prove what + the wire looks like underneath. - `client.py` `RAW_ENVELOPE_BODY` / `MODERN_HEADERS` — the exact 2026 wire shape: three `io.modelcontextprotocol/*` `_meta` keys replace the initialize handshake; `MCP-Protocol-Version` + `Mcp-Method` headers mirror the body so - gateways can route without parsing JSON. -- `server.py` `greet` calls `ctx.report_progress(0.5)` — and `scenario` proves - the client's `progress_callback` is **never invoked**: JSON mode has no + gateways can route without parsing JSON. `main` posts it by hand and asserts + a single `application/json` response with no `Mcp-Session-Id`. +- `server.py` `greet` calls `ctx.report_progress(0.5)` — and `main` proves the + client's `progress_callback` is **never invoked**: JSON mode has no back-channel for mid-call notifications (the `progress_seen == []` assertion flips to `== [0.5]` once SSE buffering lands for the modern path). - `server_lowlevel.py` — same ASGI app built from `lowlevel.Server`; the diff --git a/examples/stories/json_response/client.py b/examples/stories/json_response/client.py index 380a7a72ab..4ac0a2b45f 100644 --- a/examples/stories/json_response/client.py +++ b/examples/stories/json_response/client.py @@ -1,22 +1,22 @@ """Regular ``Client`` against a JSON-only server; assert mid-call progress is dropped. ``RAW_ENVELOPE_BODY`` / ``MODERN_HEADERS`` are the exact wire shape a 2026-era client -sends — this is the only story that shows it. ``scenario`` posts that body by hand -and asserts the response is a single ``application/json`` body with no session id. +sends — this is the only story that shows it. ``main`` posts that body by hand and +asserts the response is a single ``application/json`` body with no session id. """ -import sys -import traceback - -import anyio import httpx from mcp.client import Client from mcp.shared.version import LATEST_MODERN_VERSION from mcp.types import TextContent -from stories._harness import argv_after +from stories._harness import Target, run_client # The raw 2026-07-28 POST envelope: per-request `_meta` replaces the initialize handshake. +# The key/header strings are spelled out on purpose — this is the raw-wire story. In code +# use the named constants instead: `mcp.types.PROTOCOL_VERSION_META_KEY` / +# `CLIENT_INFO_META_KEY` / `CLIENT_CAPABILITIES_META_KEY` and +# `mcp.shared.inbound.MCP_PROTOCOL_VERSION_HEADER` (`legacy_routing/` shows that form). RAW_ENVELOPE_BODY: dict[str, object] = { "jsonrpc": "2.0", "id": 1, @@ -37,51 +37,33 @@ } -async def scenario(client: Client, http: httpx.AsyncClient) -> None: - assert client.protocol_version == LATEST_MODERN_VERSION +async def main(target: Target, *, mode: str = "auto", http: httpx.AsyncClient) -> None: + async with Client(target, mode=mode) as client: + assert client.protocol_version == LATEST_MODERN_VERSION - progress_seen: list[float] = [] + progress_seen: list[float] = [] - async def _on_progress(progress: float, total: float | None, message: str | None) -> None: - progress_seen.append(progress) + async def on_progress(progress: float, total: float | None, message: str | None) -> None: + progress_seen.append(progress) - result = await client.call_tool("greet", {"name": "json"}, progress_callback=_on_progress) - assert isinstance(result.content[0], TextContent) and result.content[0].text == "Hello, json!" - assert result.structured_content == {"result": "Hello, json!"}, result + result = await client.call_tool("greet", {"name": "json"}, progress_callback=on_progress) + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Hello, json!" + assert result.structured_content == {"result": "Hello, json!"}, result - # The tool called report_progress(0.5) but the modern HTTP JSON path has no - # back-channel for mid-call notifications, so the callback is never invoked. - assert progress_seen == [], f"expected progress to be dropped, got {progress_seen}" + # The tool called report_progress(0.5) but the modern HTTP JSON path has no + # back-channel for mid-call notifications, so the callback is never invoked. + assert progress_seen == [], f"expected progress to be dropped, got {progress_seen}" - # Hand-craft a 2026 POST and assert it comes back as a single JSON body, no session. - response = await http.post("/mcp", json=RAW_ENVELOPE_BODY, headers=MODERN_HEADERS) - assert response.status_code == 200, response.text - assert response.headers["content-type"].split(";", 1)[0] == "application/json" - assert "mcp-session-id" not in response.headers - payload = response.json() - assert payload["id"] == 1 - assert [t["name"] for t in payload["result"]["tools"]] == ["greet"] + # Hand-craft a 2026 POST and assert it comes back as a single JSON body, no session. + response = await http.post("/mcp", json=RAW_ENVELOPE_BODY, headers=MODERN_HEADERS) + assert response.status_code == 200, response.text + assert response.headers["content-type"].split(";", 1)[0] == "application/json" + assert "mcp-session-id" not in response.headers + payload = response.json() + assert payload["id"] == 1 + assert [t["name"] for t in payload["result"]["tools"]] == ["greet"] if __name__ == "__main__": - # HTTP-only story; --http takes the server origin (without /mcp). - # Hand-rolled because `run_client` has no needs_http arm; matches its - # fail_after(30) + FAIL/OK + exit-code semantics inline. - origin = argv_after("--http") - - async def _main() -> None: - with anyio.fail_after(30): - async with ( - httpx.AsyncClient(base_url=origin) as http_client, - Client(f"{origin}/mcp", mode=LATEST_MODERN_VERSION) as client, - ): - await scenario(client, http_client) - - try: - anyio.run(_main) - except Exception: - print("FAIL: json_response (http/modern)", file=sys.stderr) - traceback.print_exc() - raise SystemExit(1) from None - print("OK: json_response (http/modern)", file=sys.stderr) - raise SystemExit(0) + run_client(main) diff --git a/examples/stories/elicitation/README.md b/examples/stories/legacy_elicitation/README.md similarity index 66% rename from examples/stories/elicitation/README.md rename to examples/stories/legacy_elicitation/README.md index daad6c4c73..540870b117 100644 --- a/examples/stories/elicitation/README.md +++ b/examples/stories/legacy_elicitation/README.md @@ -1,4 +1,11 @@ -# elicitation +# legacy-elicitation + +> **Legacy mechanism (2025 handshake era).** This story shows the push-style +> server→client `elicitation/create` request; the 2026-07-28 protocol carries +> elicitation as an `InputRequiredResult` round-trip instead — that path is the +> [`mrtr/`](../mrtr/) story. Elicitation itself is **not** deprecated. +> TODO(maxisbey): unify once the MRTR runtime lands +> ([#2898](https://github.com/modelcontextprotocol/python-sdk/issues/2898)). A tool pauses mid-call to ask the user for structured input. On the handshake-era protocol the server pushes an `elicitation/create` *request* to @@ -13,15 +20,20 @@ flow finishes). ```bash # stdio (default — the client spawns the server as a subprocess) -uv run python -m stories.elicitation.client +uv run python -m stories.legacy_elicitation.client -# against a running HTTP server -uv run python -m stories.elicitation.server --http --port 8000 & -uv run python -m stories.elicitation.client --http http://127.0.0.1:8000/mcp +# against a running HTTP server (--legacy: the push request needs the handshake era) +uv run python -m stories.legacy_elicitation.server --http --port 8000 & +uv run python -m stories.legacy_elicitation.client --http http://127.0.0.1:8000/mcp --legacy ``` ## What to look at +- `client.py` `main` — the whole client setup is one visible construction: + `Client(target, mode=mode, elicitation_callback=on_elicit)`. Supplying + `elicitation_callback` is what advertises the `elicitation: {form, url}` + capability; `on_elicit` serves *both* modes by branching on + `isinstance(params, ElicitRequestURLParams)`. - `server.py` `register_user` — `await ctx.elicit("...", Registration)` derives the form schema from the pydantic model and returns a typed `ElicitationResult[Registration]`; narrow with `isinstance(answer, @@ -29,21 +41,11 @@ uv run python -m stories.elicitation.client --http http://127.0.0.1:8000/mcp - `server.py` `link_account` — `ctx.elicit_url(...)` for out-of-band flows; after the user finishes, `send_elicit_complete` emits `notifications/elicitation/complete` so the client can correlate. -- `client.py` `on_elicit` — one callback serves *both* modes by branching on - `isinstance(params, ElicitRequestURLParams)`. Supplying - `elicitation_callback` auto-advertises the `elicitation: {form, url}` - capability. - `server_lowlevel.py` — the same flow via `ctx.session.elicit_form` / `ctx.session.elicit_url` and a hand-written `requestedSchema`. ## Caveats -- **Handshake-era only.** The push-style `ctx.elicit()` requires a - server→client request channel. The 2026-07-28 protocol carries elicitation as - an `InputRequiredResult` round-trip instead — that path lands with the `mrtr` - story ([#2898](https://github.com/modelcontextprotocol/python-sdk/issues/2898)), - at which point this example becomes dual-era. Elicitation itself is **not** - deprecated by SEP-2577 (unlike roots/sampling/logging). - **Context paths.** `ctx.elicit` / `ctx.elicit_url` and the 2-hop `ctx.request_context.session.send_elicit_complete` are interim; a later release will shorten these. diff --git a/examples/stories/client_session/__init__.py b/examples/stories/legacy_elicitation/__init__.py similarity index 100% rename from examples/stories/client_session/__init__.py rename to examples/stories/legacy_elicitation/__init__.py diff --git a/examples/stories/elicitation/client.py b/examples/stories/legacy_elicitation/client.py similarity index 53% rename from examples/stories/elicitation/client.py rename to examples/stories/legacy_elicitation/client.py index a9da6a001c..23b1aef7a6 100644 --- a/examples/stories/elicitation/client.py +++ b/examples/stories/legacy_elicitation/client.py @@ -1,10 +1,8 @@ """Auto-answer form and URL elicitations and assert the tool result reflects them.""" -from typing import Any - from mcp import types from mcp.client import Client, ClientRequestContext -from stories._harness import connect_from_args, run_client +from stories._harness import Target, run_client async def on_elicit(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult: @@ -17,18 +15,16 @@ async def on_elicit(context: ClientRequestContext, params: types.ElicitRequestPa return types.ElicitResult(action="accept", content={"username": "alice", "plan": "pro"}) -client_kw: dict[str, Any] = {"elicitation_callback": on_elicit} - - -async def scenario(client: Client) -> None: - registered = await client.call_tool("register_user", {}) - assert isinstance(registered.content[0], types.TextContent) - assert registered.content[0].text == "registered alice (plan: pro)", registered +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode, elicitation_callback=on_elicit) as client: + registered = await client.call_tool("register_user", {}) + assert isinstance(registered.content[0], types.TextContent) + assert registered.content[0].text == "registered alice (plan: pro)", registered - linked = await client.call_tool("link_account", {"provider": "github"}) - assert isinstance(linked.content[0], types.TextContent) - assert linked.content[0].text == "linked github", linked + linked = await client.call_tool("link_account", {"provider": "github"}) + assert isinstance(linked.content[0], types.TextContent) + assert linked.content[0].text == "linked github", linked if __name__ == "__main__": - run_client(scenario, connect=connect_from_args(__file__), **client_kw) + run_client(main) diff --git a/examples/stories/elicitation/server.py b/examples/stories/legacy_elicitation/server.py similarity index 100% rename from examples/stories/elicitation/server.py rename to examples/stories/legacy_elicitation/server.py diff --git a/examples/stories/elicitation/server_lowlevel.py b/examples/stories/legacy_elicitation/server_lowlevel.py similarity index 100% rename from examples/stories/elicitation/server_lowlevel.py rename to examples/stories/legacy_elicitation/server_lowlevel.py diff --git a/examples/stories/legacy_routing/README.md b/examples/stories/legacy_routing/README.md index e912e543ad..e9467605f4 100644 --- a/examples/stories/legacy_routing/README.md +++ b/examples/stories/legacy_routing/README.md @@ -20,11 +20,22 @@ uv run python -m stories.legacy_routing.client --http http://127.0.0.1:8000/mcp # lowlevel server variant uv run python -m stories.legacy_routing.server_lowlevel --port 8000 & -uv run python -m stories.legacy_routing.client --http http://127.0.0.1:8000/mcp --server server_lowlevel +uv run python -m stories.legacy_routing.client --http http://127.0.0.1:8000/mcp ``` ## What to look at +- `client.py` — two visible connections to the SAME `/mcp` endpoint from one + `targets()` factory: `Client(targets(), mode=mode)` (default `"auto"` → + `server/discover` → the modern arm) and `Client(targets(), mode="legacy")` + (the `initialize` handshake → the legacy arm). Each asserts `which_arm` + reports the era the built-in router actually dispatched to. The era decision + is one explicit `mode=` argument at construction. +- `client.py` — the predicate then shown directly against a modern body, a + legacy body, and a malformed-modern body. The runnable `build_app()` uses the + SDK's built-in router; the predicate itself is exercised as a pure + function — see the user-land composition recipe below for wiring it into + your own ingress. - `server.py` `classify_era` — the tri-state wrapper. `InboundModernRoute` → `"modern"`; rung-1 `INVALID_PARAMS` (no envelope keys) → `"legacy"`; any other `InboundLadderRejection` (header mismatch, unsupported version) is a @@ -32,11 +43,6 @@ uv run python -m stories.legacy_routing.client --http http://127.0.0.1:8000/mcp - `server.py` `build_app` — `streamable_http_app()` + `CORSMiddleware`. The `which_arm` tool reads `ctx.request_context.protocol_version` to prove which path the built-in router took. -- `client.py` — same endpoint, two `mode=` values, two arms; then the predicate - shown directly against a modern body, a legacy body, and a malformed-modern - body. The runnable `build_app()` uses the SDK's built-in router; the - predicate itself is exercised as a pure function — see the user-land - composition recipe below for wiring it into your own ingress. - `server_lowlevel.py` — same `classify_era` and CORS recipe (re-used from `server.py`); `build_app` wires `lowlevel.Server` instead of `MCPServer` and reads `ctx.protocol_version` directly. diff --git a/examples/stories/legacy_routing/client.py b/examples/stories/legacy_routing/client.py index f11f93d13c..73e2e9d01e 100644 --- a/examples/stories/legacy_routing/client.py +++ b/examples/stories/legacy_routing/client.py @@ -7,7 +7,7 @@ from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER, InboundLadderRejection from mcp.shared.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION from mcp.types import CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY -from stories._harness import Connect, connect_from_args, run_client +from stories._harness import TargetFactory, run_client from .server import classify_era @@ -18,13 +18,15 @@ def _arm(result: types.CallToolResult) -> str: return first.text -async def scenario(client: Client, connect: Connect) -> None: - # ── modern leg: harness-supplied client at mode="auto" probed server/discover. - assert client.protocol_version == LATEST_MODERN_VERSION - assert _arm(await client.call_tool("which_arm", {})) == "modern" +async def main(targets: TargetFactory, *, mode: str = "auto") -> None: + # ── modern arm: the caller's mode (the real-user "auto" default) probes + # ``server/discover`` → the stateless 2026 path. + async with Client(targets(), mode=mode) as modern: + assert modern.protocol_version == LATEST_MODERN_VERSION + assert _arm(await modern.call_tool("which_arm", {})) == "modern" - # ── legacy leg: same /mcp endpoint, initialize handshake → sessionful 2025 path. - async with connect(mode="legacy") as legacy: + # ── legacy arm: the SAME /mcp endpoint, ``initialize`` handshake → sessionful 2025 path. + async with Client(targets(), mode="legacy") as legacy: assert legacy.protocol_version == LATEST_HANDSHAKE_VERSION assert _arm(await legacy.call_tool("which_arm", {})) == "legacy" @@ -53,4 +55,4 @@ async def scenario(client: Client, connect: Connect) -> None: if __name__ == "__main__": - run_client(scenario, connect=connect_from_args(__file__), needs_connect=True, mode="auto") + run_client(main) diff --git a/examples/stories/lifespan/README.md b/examples/stories/lifespan/README.md index e8b75c2637..932ff2c946 100644 --- a/examples/stories/lifespan/README.md +++ b/examples/stories/lifespan/README.md @@ -18,6 +18,10 @@ uv run python -m stories.lifespan.client --http http://127.0.0.1:8000/mcp ## What to look at +- `client.py` `main` — opens with `Client(target, mode=mode)`; the story owns + the construction, the harness only chooses the target and era. Lifespan is + invisible from here: the client speaks plain MCP, and the `lookup` results + are the only proof the yielded state was wired through. - `app_lifespan` in `server.py` — the `try / yield / finally` shape is the startup/shutdown contract; the `finally` block runs once on process exit, not per request. diff --git a/examples/stories/lifespan/client.py b/examples/stories/lifespan/client.py index d348fdcaa7..f84895cd9d 100644 --- a/examples/stories/lifespan/client.py +++ b/examples/stories/lifespan/client.py @@ -2,19 +2,20 @@ from mcp.client import Client from mcp.types import TextContent -from stories._harness import connect_from_args, run_client +from stories._harness import Target, run_client -async def scenario(client: Client) -> None: - listed = await client.list_tools() - assert [t.name for t in listed.tools] == ["lookup"] +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + listed = await client.list_tools() + assert [t.name for t in listed.tools] == ["lookup"] - result = await client.call_tool("lookup", {"key": "alpha"}) - assert isinstance(result.content[0], TextContent) and result.content[0].text == "one", result + result = await client.call_tool("lookup", {"key": "alpha"}) + assert isinstance(result.content[0], TextContent) and result.content[0].text == "one", result - result = await client.call_tool("lookup", {"key": "beta"}) - assert isinstance(result.content[0], TextContent) and result.content[0].text == "two", result + result = await client.call_tool("lookup", {"key": "beta"}) + assert isinstance(result.content[0], TextContent) and result.content[0].text == "two", result if __name__ == "__main__": - run_client(scenario, connect=connect_from_args(__file__)) + run_client(main) diff --git a/examples/stories/manifest.toml b/examples/stories/manifest.toml index 820cba10f3..60557ec50f 100644 --- a/examples/stories/manifest.toml +++ b/examples/stories/manifest.toml @@ -4,17 +4,18 @@ # asserts [story.*] keys == story dirs with a client.py. [defaults] -transports = ["in-memory", "http-asgi"] # in-memory = Client(server); http-asgi = StreamingASGITransport -era = "dual" # "dual" | "modern" | "legacy" | "dual-in-body" -lowlevel = true # also run scenario against server_lowlevel.build_server()/build_app() -server_export = "factory" # "factory" -> build_server() | "app" -> build_app() -needs_connect = false # scenario(client) vs scenario(client, connect) -needs_http = false # scenario(..., http) gets the raw httpx.AsyncClient (http-asgi only) -timeout_s = 30 -smoke = false -mcp_path = "/mcp" -xfail = [] # [":", ...] -> strict xfail on that leg -env = {} # env vars set for the leg via monkeypatch +transports = ["in-memory", "http-asgi"] # in-memory = Client(server); http-asgi = StreamingASGITransport +era = "dual" # "dual" | "modern" | "legacy" | "dual-in-body" +status = "current" # "current" | "legacy" | "deprecated" — the feature's future, not the transport +lowlevel = true # also run main against server_lowlevel.build_server()/build_app() +server_export = "factory" # "factory" -> build_server() | "app" -> build_app() +multi_connection = false # main(target, ...) vs main(targets, ...); targets() -> fresh target per call +needs_http = false # main(..., http=) gets the raw httpx.AsyncClient (http-asgi only) +timeout_s = 30 +smoke = false +mcp_path = "/mcp" +xfail = [] # [":", ...] -> strict xfail on that leg +env = {} # env vars set for the leg via monkeypatch # ───────────────────────────── start here ───────────────────────────── @@ -28,21 +29,20 @@ smoke = true [story.lifespan] [story.dual_era] -era = "dual-in-body" -needs_connect = true - -[story.custom_version] -era = "legacy" +era = "dual-in-body" +multi_connection = true [story.streaming] # progress + log notifications dropped on the modern streamable-HTTP path pending SSE wiring xfail = ["http-asgi:modern"] -[story.elicitation] -era = "legacy" +[story.legacy_elicitation] +era = "legacy" +status = "legacy" [story.sampling] -era = "legacy" +era = "legacy" +status = "deprecated" [story.stickynotes] @@ -52,30 +52,35 @@ lowlevel = false [story.schema_validators] [story.middleware] +# Lowlevel-only: `Server.middleware` is the one public hook (no MCPServer accessor yet). +lowlevel = false [story.parallel_calls] -# modern single-exchange dispatch context no-ops notify() over streamable-http -xfail = ["http-asgi:modern"] +# A per-client fresh target over a real ASGI transport is harness machinery, not user +# code; the same client body works unchanged over HTTP. +transports = ["in-memory"] +multi_connection = true [story.roots] -era = "legacy" +era = "legacy" +status = "deprecated" [story.pagination] [story.error_handling] -[story.client_session] -lowlevel = false - [story.serve_one] +# Lowlevel-only: the kernel drivers take a `lowlevel.Server`; `MCPServer` has no public +# accessor for its underlying one yet, so there is no MCPServer-tier variant to show. transports = ["in-memory"] +lowlevel = false [story.stateless_legacy] -transports = ["http-asgi"] -server_export = "app" -era = "dual-in-body" -needs_connect = true -smoke = true +transports = ["http-asgi"] +server_export = "app" +era = "dual-in-body" +multi_connection = true +smoke = true [story.json_response] transports = ["http-asgi"] @@ -84,10 +89,10 @@ era = "modern" needs_http = true [story.legacy_routing] -transports = ["http-asgi"] -server_export = "app" -era = "dual-in-body" -needs_connect = true +transports = ["http-asgi"] +server_export = "app" +era = "dual-in-body" +multi_connection = true [story.starlette_mount] transports = ["http-asgi"] @@ -99,27 +104,31 @@ mcp_path = "/api/" transports = ["http-asgi"] server_export = "app" era = "legacy" +status = "legacy" timeout_s = 20 # event_store.py is local; example-grade only (sequential IDs, no eviction). [story.standalone_get] transports = ["http-asgi"] era = "legacy" +status = "legacy" [story.reconnect] -transports = ["http-asgi"] -era = "modern" -needs_connect = true +transports = ["http-asgi"] +# Both connection modes are pinned inside main itself ("auto" to populate the discover +# cache, then a hard pin + prior_discover=); the leg hands it the real-user default. +era = "dual-in-body" +multi_connection = true [story.bearer_auth] transports = ["http-asgi"] server_export = "app" [story.oauth] -transports = ["http-asgi"] -server_export = "app" -needs_connect = true -env = { OAUTH_DEMO_AUTO_CONSENT = "1" } +transports = ["http-asgi"] +server_export = "app" +multi_connection = true +env = { OAUTH_DEMO_AUTO_CONSENT = "1" } [story.oauth_client_credentials] transports = ["http-asgi"] diff --git a/examples/stories/middleware/README.md b/examples/stories/middleware/README.md index f270418181..3b20e04a05 100644 --- a/examples/stories/middleware/README.md +++ b/examples/stories/middleware/README.md @@ -20,17 +20,21 @@ uv run python -m stories.middleware.client --http http://127.0.0.1:8000/mcp ## What to look at -- `server_lowlevel.py` — `server.middleware.append(record_calls)` is the public +- `client.py` `main` — opens with `async with Client(target, mode=mode)`. The + story owns that construction; the harness only picks the target and era. + Middleware is invisible from this side — only the `audit_log` result proves + the wrap happened. +- `server.py` — `server.middleware.append(record_calls)` is the public registration point on `mcp.server.lowlevel.Server`. -- `server.py` — `MCPServer` has no public hook yet, so the example reaches - `mcp._lowlevel_server.middleware` (a public `MCPServer.middleware` accessor - is planned before beta — prefer the lowlevel variant until then). - `client.py` — the asserted log ends at `"tools/call"` without a `:done` suffix: `audit_log` runs *inside* `call_next(ctx)`, so the `finally` hasn't fired yet. That's the wrap. ## Caveats +- **Lowlevel-only.** `Server.middleware` on `mcp.server.lowlevel.Server` is the + one public hook; `MCPServer` has no public accessor for it yet (a + `MCPServer.middleware` accessor is planned before beta). - The middleware signature is **provisional** (see the TODO in `src/mcp/server/lowlevel/server.py`): it tightens to a covariant `Context[L]` and gains an outbound seam before v2 final. diff --git a/examples/stories/middleware/client.py b/examples/stories/middleware/client.py index ba3c05f56b..840d62cd15 100644 --- a/examples/stories/middleware/client.py +++ b/examples/stories/middleware/client.py @@ -1,24 +1,25 @@ """Prove the middleware wrapped both `tools/list` and the in-flight `tools/call`.""" from mcp.client import Client -from stories._harness import connect_from_args, run_client +from stories._harness import Target, run_client -async def scenario(client: Client) -> None: - listed = await client.list_tools() - assert [t.name for t in listed.tools] == ["audit_log"] +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + listed = await client.list_tools() + assert [t.name for t in listed.tools] == ["audit_log"] - result = await client.call_tool("audit_log", {}) - assert not result.is_error - assert result.structured_content is not None, result + result = await client.call_tool("audit_log", {}) + assert not result.is_error + assert result.structured_content is not None, result - # Era-neutral: legacy adds initialize + notifications/initialized; modern HTTP - # adds server/discover; modern in-memory adds nothing. Filter to the methods - # this scenario drove. - seen = [m for m in result.structured_content["result"] if m.startswith("tools/")] - # tools/call:done is absent — the handler ran inside the middleware frame. - assert seen == ["tools/list", "tools/list:done", "tools/call"], seen + # Era-neutral: legacy adds initialize + notifications/initialized; modern HTTP + # adds server/discover; modern in-memory adds nothing. Filter to the methods + # this client drove. + seen = [m for m in result.structured_content["result"] if m.startswith("tools/")] + # tools/call:done is absent — the handler ran inside the middleware frame. + assert seen == ["tools/list", "tools/list:done", "tools/call"], seen if __name__ == "__main__": - run_client(scenario, connect=connect_from_args(__file__)) + run_client(main) diff --git a/examples/stories/middleware/server.py b/examples/stories/middleware/server.py index 64b3bce78b..f3bf8094e9 100644 --- a/examples/stories/middleware/server.py +++ b/examples/stories/middleware/server.py @@ -1,14 +1,19 @@ -"""Dispatch-layer middleware: one function wraps every inbound MCP message.""" +"""Dispatch-layer middleware: `Server.middleware` is the public hook. +A lowlevel-only story: `MCPServer` has no public middleware accessor yet, so the +one supported registration point is the `middleware` list on `lowlevel.Server`. +""" + +import json from typing import Any +from mcp import types from mcp.server.context import CallNext, HandlerResult, ServerRequestContext -from mcp.server.mcpserver import MCPServer +from mcp.server.lowlevel import Server from stories._hosting import run_server_from_args -def build_server() -> MCPServer: - mcp = MCPServer("middleware-example") +def build_server() -> Server[Any]: log: list[str] = [] async def record_calls(ctx: ServerRequestContext[Any], call_next: CallNext) -> HandlerResult: @@ -18,17 +23,30 @@ async def record_calls(ctx: ServerRequestContext[Any], call_next: CallNext) -> H finally: log.append(f"{ctx.method}:done") - # MCPServer exposes no public middleware hook yet; the list lives on the wrapped - # lowlevel Server. DO NOT copy this private reach — see server_lowlevel.py for the - # public `server.middleware.append(...)` registration. - mcp._lowlevel_server.middleware.append(record_calls) # pyright: ignore[reportPrivateUsage] - - @mcp.tool() - def audit_log() -> list[str]: - """Return every method the middleware has observed so far.""" - return list(log) - - return mcp + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="audit_log", + description="Return every method the middleware has observed so far.", + input_schema={"type": "object"}, + ) + ] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "audit_log" + snapshot = list(log) + return types.CallToolResult( + content=[types.TextContent(text=json.dumps(snapshot))], + structured_content={"result": snapshot}, + ) + + server = Server("middleware-example", on_list_tools=list_tools, on_call_tool=call_tool) + server.middleware.append(record_calls) + return server if __name__ == "__main__": diff --git a/examples/stories/middleware/server_lowlevel.py b/examples/stories/middleware/server_lowlevel.py deleted file mode 100644 index 2fc4ff0398..0000000000 --- a/examples/stories/middleware/server_lowlevel.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Dispatch-layer middleware (lowlevel API): `Server.middleware` is the public hook.""" - -import json -from typing import Any - -from mcp import types -from mcp.server.context import CallNext, HandlerResult, ServerRequestContext -from mcp.server.lowlevel import Server -from stories._hosting import run_server_from_args - - -def build_server() -> Server[Any]: - log: list[str] = [] - - async def record_calls(ctx: ServerRequestContext[Any], call_next: CallNext) -> HandlerResult: - log.append(ctx.method) - try: - return await call_next(ctx) - finally: - log.append(f"{ctx.method}:done") - - async def list_tools( - ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None - ) -> types.ListToolsResult: - return types.ListToolsResult( - tools=[ - types.Tool( - name="audit_log", - description="Return every method the middleware has observed so far.", - input_schema={"type": "object"}, - ) - ] - ) - - async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: - assert params.name == "audit_log" - snapshot = list(log) - return types.CallToolResult( - content=[types.TextContent(text=json.dumps(snapshot))], - structured_content={"result": snapshot}, - ) - - server = Server("middleware-example", on_list_tools=list_tools, on_call_tool=call_tool) - server.middleware.append(record_calls) - return server - - -if __name__ == "__main__": - run_server_from_args(build_server) diff --git a/examples/stories/mrtr/README.md b/examples/stories/mrtr/README.md index 226c44f83f..90179cf953 100644 --- a/examples/stories/mrtr/README.md +++ b/examples/stories/mrtr/README.md @@ -22,5 +22,5 @@ The TypeScript SDK ships a runnable `mrtr` story: ## See also -`elicitation/` and `sampling/` — the handshake-era push equivalents that this -mechanism replaces on the 2026 protocol. +`legacy_elicitation/` and `sampling/` — the handshake-era push equivalents that +this mechanism replaces on the 2026 protocol. diff --git a/examples/stories/oauth/README.md b/examples/stories/oauth/README.md index c34ba5a2f0..58e28275bb 100644 --- a/examples/stories/oauth/README.md +++ b/examples/stories/oauth/README.md @@ -8,7 +8,7 @@ protected-resource metadata route, the AS routes (`/register`, `/authorize`, `/mcp` endpoint on a single Starlette app. On the **client** side: `OAuthClientProvider` is an `httpx.Auth` that reacts to the first `401` by walking PRM discovery → AS metadata → DCR → PKCE authorize → token exchange → -bearer retry — all inside `Client.__aenter__`, with no user-visible +bearer retry — all inside the first awaited request, with no user-visible `UnauthorizedError`. ## Run it @@ -24,12 +24,37 @@ uv run python -m stories.oauth.client --http http://127.0.0.1:8000/mcp OAUTH_DEMO_AUTO_CONSENT=1 uv run python -m stories.oauth.server_lowlevel --port 8000 ``` +The port must be **8000**: the demo AS metadata (`_shared/auth.py` `BASE_URL`) +is pinned to it on both the client and server side, so on any other port the +PRM/AS discovery chain points at the wrong origin. + `OAUTH_DEMO_AUTO_CONSENT=1` makes the demo AS skip the consent screen and 302 straight back with `?code=...`; without it the authorize step returns `error=interaction_required` so you can see where a real browser would open. +`Client(url)` has no `auth=` passthrough, so a target built from a bare URL +can't carry the flow. Both runners close that gap the same way: `run_client` +(terminal 2) and the pytest harness build an authed `httpx.AsyncClient` from +this module's `build_auth` export and hand `main` targets that are already +routed through it. + ## What to look at +- **`client.py` — `Client(targets(), mode=mode)`, twice.** The target `main` + receives is already authed. The first construction is where the whole flow + happens: the first request `401`s and `OAuthClientProvider` runs PRM + discovery → AS metadata → DCR → PKCE authorize → token exchange → bearer + retry before `whoami`'s result reaches the body. +- **`client.py` — the second `Client(targets(), mode=mode)`.** A `Client` + cannot be re-entered after `__aexit__`; reconnecting means constructing a new + one. The provider's `TokenStorage` persisted the tokens and the DCR + registration, so this one sends `Authorization: Bearer ...` on its very first + request — no second `/authorize`, no second `/register`. The demo AS mints a + fresh `client_id` per DCR call, so `whoami` returning the *same* `client_id` + is the reuse proof. +- **`client.py` — `build_auth()`.** `OAuthClientProvider` is an `httpx.Auth`. + `Client(url, auth=...)` is the ergonomic the SDK is missing; until it lands + the auth has to be threaded onto the underlying `httpx.AsyncClient` by hand. - **`server.py` — `MCPServer(auth=..., auth_server_provider=...)`.** The constructor wires everything; `streamable_http_app()` reads it back. (Don't also pass `token_verifier=` — `auth_server_provider` and `token_verifier` are @@ -40,14 +65,6 @@ straight back with `?code=...`; without it the authorize step returns `auth=`/`token_verifier=`/`auth_server_provider=` on `streamable_http_app()` rather than the constructor. `mcp.server.auth.*` is a helper tier the lowlevel API may import directly. -- **`client.py` — `_auth_with()` / `build_auth()`.** `OAuthClientProvider` is - threaded onto `httpx.AsyncClient.auth`; `Client(url)` has no `auth=` kwarg - yet, so the transport is built by hand: - `Client(streamable_http_client(url, http_client=http))`. -- **`client.py` — token reuse.** A `Client` cannot be re-entered after - `__aexit__`. The third connection reuses the same `TokenStorage`, so it sends - `Authorization: Bearer ...` on the very first request — no `/authorize`, no - `/register` — and `whoami` returns the DCR-persisted `client_id`. ## Caveats @@ -67,4 +84,4 @@ straight back with `?code=...`; without it the authorize step returns `bearer_auth/` (RS-only, static token, no AS) · `oauth_client_credentials/` (M2M `client_credentials` grant — no browser, no DCR) · `reconnect/` (the other -`connect: Connect` consumer). +multi-connection `targets()` consumer, no auth). diff --git a/examples/stories/oauth/client.py b/examples/stories/oauth/client.py index 1ee67111be..5ddd3df480 100644 --- a/examples/stories/oauth/client.py +++ b/examples/stories/oauth/client.py @@ -1,89 +1,61 @@ """OAuth authorization-code flow: 401 → PRM → AS metadata → DCR → PKCE authorize → token → retry.""" -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from typing import Any - -import anyio import httpx from pydantic import AnyUrl from mcp.client import Client from mcp.client.auth import OAuthClientProvider -from mcp.client.streamable_http import streamable_http_client from mcp.shared.auth import OAuthClientMetadata -from mcp.shared.version import LATEST_MODERN_VERSION -from stories._harness import AuthBuilder, Connect, argv_after, run_client -from stories._shared.auth import MCP_URL, REDIRECT_URI, HeadlessOAuth, InMemoryTokenStorage - - -def _auth_with(storage: InMemoryTokenStorage) -> AuthBuilder: - """Build an ``OAuthClientProvider`` over ``storage``, completing the redirect headlessly.""" +from stories._harness import TargetFactory, run_client - def builder(http_client: httpx.AsyncClient) -> httpx.Auth: - headless = HeadlessOAuth() - headless.bind(http_client) - return OAuthClientProvider( - server_url=MCP_URL, - client_metadata=OAuthClientMetadata( - client_name="oauth-story-client", - redirect_uris=[AnyUrl(REDIRECT_URI)], - grant_types=["authorization_code", "refresh_token"], - ), - storage=storage, - redirect_handler=headless.redirect_handler, - callback_handler=headless.callback_handler, - ) - - return builder +# MCP_URL pins the resource to :8000. The demo AS's own metadata (issuer, PRM `resource`) +# is built from the same constant on the server side, so the whole story is bound to that +# port — run the server on 8000 or both halves of the discovery chain point at the wrong origin. +from stories._shared.auth import MCP_URL, REDIRECT_URI, HeadlessOAuth, InMemoryTokenStorage def build_auth(http_client: httpx.AsyncClient) -> httpx.Auth: - """Harness hook: fresh storage so each leg's first connection runs the full flow.""" - return _auth_with(InMemoryTokenStorage())(http_client) - + """An `OAuthClientProvider` over fresh storage, completing the authorize redirect headlessly. -async def scenario(client: Client, connect: Connect) -> None: - # The harness entered ``client`` with auth=build_auth(...); the first /mcp request - # 401'd and OAuthClientProvider walked PRM discovery → AS metadata → DCR → PKCE - # authorize → token exchange → bearer retry — all inside __aenter__. Prove it landed: - result = await client.call_tool("whoami", {}) - assert result.structured_content is not None - assert "mcp" in result.structured_content["scopes"], result - - # TokenStorage contract: a fresh provider over fresh storage runs the full flow and - # persists both the issued tokens and the DCR-registered client info. - storage = InMemoryTokenStorage() - with anyio.fail_after(5): - async with connect(auth=_auth_with(storage)) as second: - await second.call_tool("whoami", {}) - assert storage.tokens is not None - assert storage.client_info is not None and storage.client_info.client_id is not None - registered_id = storage.client_info.client_id - - # Token reuse: a fresh Client over the SAME storage sends Bearer on the very first - # request — no /authorize, no /register. The principal is the one DCR persisted. - with anyio.fail_after(5): - async with connect(auth=_auth_with(storage)) as third: - again = await third.call_tool("whoami", {}) + `Client(url, auth=...)` doesn't exist yet, so the harness threads this onto the underlying + `httpx.AsyncClient` and every target `main` receives is already routed through it. + """ + headless = HeadlessOAuth() + headless.bind(http_client) + return OAuthClientProvider( + server_url=MCP_URL, + client_metadata=OAuthClientMetadata( + client_name="oauth-story-client", + redirect_uris=[AnyUrl(REDIRECT_URI)], + grant_types=["authorization_code", "refresh_token"], + ), + storage=InMemoryTokenStorage(), + redirect_handler=headless.redirect_handler, + callback_handler=headless.callback_handler, + ) + + +async def main(targets: TargetFactory, *, mode: str = "auto") -> None: + # The target is already authed with build_auth's OAuthClientProvider. The first request to + # hit the wire 401s, and the provider walks PRM discovery → AS metadata → DCR → PKCE + # authorize → token exchange → bearer retry before any result reaches this body. No + # UnauthorizedError ever surfaces. + async with Client(targets(), mode=mode) as client: + first = await client.call_tool("whoami", {}) + assert first.structured_content is not None + assert "mcp" in first.structured_content["scopes"], first + registered_id = first.structured_content["client_id"] + + # A Client cannot be re-entered after __aexit__; reconnecting means constructing a new one. + # The provider's TokenStorage persisted both the issued tokens and the DCR registration, so + # this connection sends `Authorization: Bearer ...` on its very first request — no second + # /authorize, no second /register. The demo AS mints a fresh client_id per DCR call, so the + # same principal coming back IS the reuse proof. + async with Client(targets(), mode=mode) as reconnected: + again = await reconnected.call_tool("whoami", {}) assert again.structured_content is not None assert again.structured_content["client_id"] == registered_id, again -@asynccontextmanager -async def _connect_real(*, auth: AuthBuilder | None = None, **kw: Any) -> AsyncIterator[Client]: - """Real-socket ``Connect`` for ``__main__``. - - ``Client(url)`` has no ``auth=`` passthrough yet, so build ``httpx.AsyncClient`` → - ``streamable_http_client`` → ``Client`` by hand and thread the auth onto httpx. - """ - url = argv_after("--http", default=MCP_URL) - kw.setdefault("mode", LATEST_MODERN_VERSION) - async with httpx.AsyncClient() as http: - http.auth = (auth or build_auth)(http) - async with Client(streamable_http_client(url, http_client=http), **kw) as c: - yield c - - if __name__ == "__main__": - run_client(scenario, connect=_connect_real, needs_connect=True) + run_client(main) diff --git a/examples/stories/oauth_client_credentials/README.md b/examples/stories/oauth_client_credentials/README.md index 1037927014..49cefb0fbc 100644 --- a/examples/stories/oauth_client_credentials/README.md +++ b/examples/stories/oauth_client_credentials/README.md @@ -19,10 +19,15 @@ uv run python -m stories.oauth_client_credentials.client --http http://127.0.0.1 ``` OAuth is an HTTP-layer concern; stdio servers receive credentials via the -environment per the spec, so there is no stdio leg. +environment per the spec, so there is no stdio leg. The port must be **8000**: +the demo AS metadata (`_shared/auth.py` `BASE_URL`) is pinned to it on both +the client and server side. ## What to look at +- `client.py` `main` — opens with `async with Client(target, mode=mode) as + client:` and that's the whole program. `target` is a transport that already + carries the OAuth `httpx.Auth`; the body never touches a token. - `client.py` `build_auth` — five lines of `ClientCredentialsOAuthProvider` config is all the caller writes; the SDK does RFC 9728 PRM → RFC 8414 AS-metadata discovery and token exchange on the first 401. @@ -38,9 +43,12 @@ environment per the spec, so there is no stdio leg. ## Caveats -- `Client(url)` has no `auth=` passthrough — you build `httpx.AsyncClient` → - `streamable_http_client(url, http_client=hc)` → `Client(transport)` yourself. - The `__main__` block shows the chain. +- `Client(url, auth=build_auth(http))` is the ergonomic the SDK is missing — + `Client(url)` has no `auth=` passthrough. Until it lands, the authed + `httpx.AsyncClient` → `streamable_http_client(url, http_client=hc)` chain has + to be built *outside* `main` and handed in as `target`; both `run_client` + (the standalone `--http` run) and the test harness do that from the + `build_auth` export. - `transport_security=NO_DNS_REBIND` — DNS-rebinding protection is on by default for localhost binds; the harness disables it because the in-process httpx client sends no `Origin` header. Drop the kwarg for a real deployment. diff --git a/examples/stories/oauth_client_credentials/client.py b/examples/stories/oauth_client_credentials/client.py index afb290eedf..9d972a464a 100644 --- a/examples/stories/oauth_client_credentials/client.py +++ b/examples/stories/oauth_client_credentials/client.py @@ -1,16 +1,13 @@ """Connect with ``ClientCredentialsOAuthProvider``; assert ``whoami`` round-trips client_id + scopes.""" -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from typing import Any - import httpx from mcp.client import Client from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider -from mcp.client.streamable_http import streamable_http_client -from mcp.shared.version import LATEST_MODERN_VERSION -from stories._harness import argv_after, run_client +from stories._harness import Target, run_client + +# MCP_URL pins the resource to :8000, and the server side builds its PRM/AS metadata from +# the same constant — run the server on 8000 or the discovery chain points at the wrong origin. from stories._shared.auth import MCP_URL, InMemoryTokenStorage from .server import DEMO_CLIENT_ID, DEMO_CLIENT_SECRET, DEMO_SCOPE @@ -20,7 +17,9 @@ def build_auth(_http: httpx.AsyncClient) -> httpx.Auth: """The ``httpx.Auth`` for the ``client_credentials`` grant — five lines of provider config. The SDK then handles 401 → RFC 9728 PRM → RFC 8414 AS-metadata discovery → token POST → - Bearer attachment automatically. Signature satisfies the harness ``AuthBuilder`` hook. + Bearer attachment automatically. ``Client(url)`` has no ``auth=`` passthrough yet, so the + harness threads this onto the transport's ``httpx.AsyncClient`` and hands ``main`` the + already-authed ``target``. """ return ClientCredentialsOAuthProvider( server_url=MCP_URL, @@ -31,27 +30,17 @@ def build_auth(_http: httpx.AsyncClient) -> httpx.Auth: ) -async def scenario(client: Client) -> None: - listed = await client.list_tools() - assert [t.name for t in listed.tools] == ["whoami"] +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + listed = await client.list_tools() + assert [t.name for t in listed.tools] == ["whoami"] - result = await client.call_tool("whoami", {}) - assert not result.is_error - assert result.structured_content is not None - assert result.structured_content["client_id"] == DEMO_CLIENT_ID, result - assert DEMO_SCOPE in result.structured_content["scopes"] + result = await client.call_tool("whoami", {}) + assert not result.is_error + assert result.structured_content is not None + assert result.structured_content["client_id"] == DEMO_CLIENT_ID, result + assert DEMO_SCOPE in result.structured_content["scopes"] if __name__ == "__main__": - url = argv_after("--http", default=MCP_URL) - - # Client(url) has no auth= passthrough yet, so build the httpx → streamable_http_client - # → Client chain by hand and thread the auth onto httpx. - @asynccontextmanager - async def _connect(**kw: Any) -> AsyncIterator[Client]: - async with httpx.AsyncClient() as http: - http.auth = build_auth(http) - async with Client(streamable_http_client(url, http_client=http), **kw) as client: - yield client - - run_client(scenario, connect=_connect, mode=LATEST_MODERN_VERSION) + run_client(main) diff --git a/examples/stories/pagination/README.md b/examples/stories/pagination/README.md index 6fa188a6e7..27ea6cb9d8 100644 --- a/examples/stories/pagination/README.md +++ b/examples/stories/pagination/README.md @@ -21,6 +21,10 @@ Swap `server_lowlevel` → `server` to run against the `MCPServer` variant ## What to look at +- `client.py` `main` — `async with Client(target, mode=mode) as client:` is the + whole connection. The story owns the construction; `target` is whatever + `Client()` accepts (an in-process server, a transport, or an HTTP URL) and + the entry point picks it. - `client.py` — `if page.next_cursor is None: break`. Termination is key-absent, not falsy; `while cursor:` would be a spec bug. - `server_lowlevel.py` — the handler owns the cursor encoding (here: an diff --git a/examples/stories/pagination/client.py b/examples/stories/pagination/client.py index ca22408b25..a952a32088 100644 --- a/examples/stories/pagination/client.py +++ b/examples/stories/pagination/client.py @@ -1,26 +1,27 @@ """Walk every page of resources/list by hand until next_cursor is absent.""" from mcp.client import Client -from stories._harness import connect_from_args, run_client +from stories._harness import Target, run_client -async def scenario(client: Client) -> None: - names: list[str] = [] - cursor: str | None = None - pages_fetched = 0 - while True: - page = await client.list_resources(cursor=cursor) - pages_fetched += 1 - assert pages_fetched <= 6, "server kept returning next_cursor — runaway guard" - names.extend(r.name for r in page.resources) - if page.next_cursor is None: # terminate on absent, NOT on falsy: "" is a valid cursor - break - cursor = page.next_cursor +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + names: list[str] = [] + cursor: str | None = None + pages_fetched = 0 + while True: + page = await client.list_resources(cursor=cursor) + pages_fetched += 1 + assert pages_fetched <= 6, "server kept returning next_cursor — runaway guard" + names.extend(r.name for r in page.resources) + if page.next_cursor is None: # terminate on absent, NOT on falsy: "" is a valid cursor + break + cursor = page.next_cursor - assert names == ["alpha", "beta", "gamma", "delta", "epsilon", "zeta"], names - # server_lowlevel.py emits 3 pages of 2; server.py (MCPServer's flat registry) emits 1. - assert pages_fetched in (1, 3), pages_fetched + assert names == ["alpha", "beta", "gamma", "delta", "epsilon", "zeta"], names + # server_lowlevel.py emits 3 pages of 2; server.py (MCPServer's flat registry) emits 1. + assert pages_fetched in (1, 3), pages_fetched if __name__ == "__main__": - run_client(scenario, connect=connect_from_args(__file__)) + run_client(main) diff --git a/examples/stories/parallel_calls/README.md b/examples/stories/parallel_calls/README.md index 95836e4925..67924e7f87 100644 --- a/examples/stories/parallel_calls/README.md +++ b/examples/stories/parallel_calls/README.md @@ -1,30 +1,41 @@ # parallel-calls -One `Client`, two `call_tool` requests in flight at once. Each caller gets its -own answer, and the per-call `progress_callback=` sees only the progress -notifications for *that* request — the SDK demultiplexes by progress token, not -by arrival order. +Two `Client`s connected to the same server, each with a `call_tool` in flight +at once. The `meet` tool is a rendezvous: a handler signals its own arrival, +then blocks until every named peer has arrived too — so neither call can return +unless the server runs both handlers concurrently. Each caller's +`progress_callback=` sees only the notifications for *its* request — the SDK +demultiplexes by progress token, not by arrival order. ## Run it -```bash -# stdio (default — the client spawns the server as a subprocess) -uv run python -m stories.parallel_calls.client +The tested legs run in-memory (`Client(server)`); the identical `main` body +works unchanged against an HTTP URL — both clients just reach the same running +server: -# against a running HTTP server +```bash uv run python -m stories.parallel_calls.server --http --port 8000 & -uv run python -m stories.parallel_calls.client --http http://127.0.0.1:8000/mcp +# --legacy because handler-emitted progress is dropped on the modern +# streamable-HTTP path today (see Caveats). +uv run python -m stories.parallel_calls.client --http http://127.0.0.1:8000/mcp --legacy ``` +There is no stdio run for this story: the stdio default spawns a fresh server +subprocess per connection, so two clients there could never rendezvous. + ## What to look at +- **`client.py` — the two visible `Client(targets(), mode=...)` blocks.** Each + connection is constructed inside `attend(...)`; `targets()` yields a fresh + target on every call and both land on the same server instance. The two + blocks run in one `anyio` task group. - **`server.py` — the `arrivals` barrier.** Each handler sets its own `anyio.Event` then waits for every peer's. A server that processed requests sequentially would never set the second event, so the client would time out — the timeout *is* the concurrency assertion. No sleeps. -- **`client.py` — `progress_callback=` per call.** Two concurrent calls each - pass a separate callback; `received == {"a": ["a"], "b": ["b"]}` proves the - SDK routes in-flight progress per request. +- **`client.py` — `progress_callback=` per call.** Each call passes its own + callback; `received == {"a": ["a"], "b": ["b"]}` proves the SDK routes + in-flight progress per request. - **`server_lowlevel.py`** — same wire contract on the lowlevel `Server`, reporting via `ctx.session.report_progress(...)`. @@ -32,12 +43,8 @@ uv run python -m stories.parallel_calls.client --http http://127.0.0.1:8000/mcp - Over Streamable HTTP in the modern (2026-07-28) era, handler-emitted progress is currently dropped (the single-exchange dispatch context no-ops `notify()`). - That cell is `xfail`; in-memory and legacy-era HTTP both deliver progress - correctly. -- The N-clients × 1-server variant is omitted: the harness `connect()` factory - rebuilds the server per call, so a cross-client rendezvous would deadlock. - Over a long-running HTTP server it works exactly as the single-client case — - open a second `Client` against the same URL. + In-memory (both eras) and legacy-era HTTP deliver progress correctly — hence + the `--legacy` above. ## Spec @@ -45,4 +52,5 @@ uv run python -m stories.parallel_calls.client --http http://127.0.0.1:8000/mcp ## See also -`streaming/` (progress + cancellation on one call), `tools/` (basics). +`streaming/` (progress + cancellation on one call), `reconnect/` (the other +multi-connection client), `tools/` (basics). diff --git a/examples/stories/parallel_calls/client.py b/examples/stories/parallel_calls/client.py index e28b1eb285..9d512a3c67 100644 --- a/examples/stories/parallel_calls/client.py +++ b/examples/stories/parallel_calls/client.py @@ -1,40 +1,40 @@ -"""Issue concurrent `call_tool` requests on one `Client`; assert per-call progress demux.""" +"""Two concurrent `Client`s against one server; the rendezvous tool proves concurrent dispatch.""" import anyio from mcp.client import Client from mcp.types import TextContent -from stories._harness import connect_from_args, run_client +from stories._harness import TargetFactory, run_client -async def scenario(client: Client) -> None: +async def main(targets: TargetFactory, *, mode: str = "auto") -> None: party = ["a", "b"] results: dict[str, str] = {} received: dict[str, list[str | None]] = {tag: [] for tag in party} - def collector(tag: str): + async def attend(tag: str) -> None: async def on_progress(progress: float, total: float | None, message: str | None) -> None: received[tag].append(message) - return on_progress + # targets() yields a fresh connection target on every call; both land on the SAME + # server instance, so the two `meet` handlers can observe each other's arrival. + async with Client(targets(), mode=mode) as client: + result = await client.call_tool("meet", {"tag": tag, "party": party}, progress_callback=on_progress) + assert not result.is_error, result + assert isinstance(result.content[0], TextContent) + results[tag] = result.content[0].text - async def call(tag: str) -> None: - result = await client.call_tool("meet", {"tag": tag, "party": party}, progress_callback=collector(tag)) - assert not result.is_error, result - assert isinstance(result.content[0], TextContent) - results[tag] = result.content[0].text - - # Neither call can return until both handlers are running concurrently; a server that - # processed requests one-at-a-time would never set the second event and we'd time out here. + # Neither call can return until both handlers are running at once; a server that processed + # requests one-at-a-time would never set the second event and we'd time out here. with anyio.fail_after(5): async with anyio.create_task_group() as tg: - tg.start_soon(call, "a") - tg.start_soon(call, "b") + tg.start_soon(attend, "a") + tg.start_soon(attend, "b") assert results == {"a": "a", "b": "b"}, results - # Progress is token-keyed per request: each callback saw only its own tag, never the sibling's. + # Progress is routed by progress token: each callback saw only its own tag, never the sibling's. assert received == {"a": ["a"], "b": ["b"]}, received if __name__ == "__main__": - run_client(scenario, connect=connect_from_args(__file__)) + run_client(main) diff --git a/examples/stories/prompts/README.md b/examples/stories/prompts/README.md index e9732d991d..36befc767d 100644 --- a/examples/stories/prompts/README.md +++ b/examples/stories/prompts/README.md @@ -19,6 +19,9 @@ uv run python -m stories.prompts.client --http http://127.0.0.1:8000/mcp ## What to look at +- `client.py` `main` — the body opens with `async with Client(target, + mode=mode) as client:`; `target` is anything `Client(...)` accepts (an + in-process server, a `Transport`, or an HTTP URL). - `server.py` `greet` vs `code_review` — return a bare `str` (wrapped as one user message) or a `list[Message]` for a multi-turn seed conversation. - `server.py` `complete()` — one global handler dispatches on `ref` + diff --git a/examples/stories/prompts/client.py b/examples/stories/prompts/client.py index 932cdef073..d683713204 100644 --- a/examples/stories/prompts/client.py +++ b/examples/stories/prompts/client.py @@ -2,36 +2,37 @@ from mcp.client import Client from mcp.types import PromptReference, TextContent -from stories._harness import connect_from_args, run_client - - -async def scenario(client: Client) -> None: - listed = await client.list_prompts() - by_name = {p.name: p for p in listed.prompts} - assert set(by_name) == {"greet", "code_review"} - assert by_name["greet"].arguments is not None - assert [a.name for a in by_name["greet"].arguments] == ["name"] - assert by_name["greet"].arguments[0].required is True - assert by_name["code_review"].title == "Code Review" - - completion = await client.complete( - PromptReference(name="code_review"), - argument={"name": "language", "value": "py"}, - ) - assert completion.completion.values == ["python", "pytorch"], completion - - greeted = await client.get_prompt("greet", {"name": "Ada"}) - assert len(greeted.messages) == 1 - assert greeted.messages[0].role == "user" - assert isinstance(greeted.messages[0].content, TextContent) - assert "Ada" in greeted.messages[0].content.text - - reviewed = await client.get_prompt("code_review", {"language": "rust", "code": "fn main() {}"}) - assert [m.role for m in reviewed.messages] == ["user", "assistant"] - first = reviewed.messages[0].content - assert isinstance(first, TextContent) - assert "rust" in first.text and "fn main() {}" in first.text +from stories._harness import Target, run_client + + +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + listed = await client.list_prompts() + by_name = {p.name: p for p in listed.prompts} + assert set(by_name) == {"greet", "code_review"} + assert by_name["greet"].arguments is not None + assert [a.name for a in by_name["greet"].arguments] == ["name"] + assert by_name["greet"].arguments[0].required is True + assert by_name["code_review"].title == "Code Review" + + completion = await client.complete( + PromptReference(name="code_review"), + argument={"name": "language", "value": "py"}, + ) + assert completion.completion.values == ["python", "pytorch"], completion + + greeted = await client.get_prompt("greet", {"name": "Ada"}) + assert len(greeted.messages) == 1 + assert greeted.messages[0].role == "user" + assert isinstance(greeted.messages[0].content, TextContent) + assert "Ada" in greeted.messages[0].content.text + + reviewed = await client.get_prompt("code_review", {"language": "rust", "code": "fn main() {}"}) + assert [m.role for m in reviewed.messages] == ["user", "assistant"] + first = reviewed.messages[0].content + assert isinstance(first, TextContent) + assert "rust" in first.text and "fn main() {}" in first.text if __name__ == "__main__": - run_client(scenario, connect=connect_from_args(__file__)) + run_client(main) diff --git a/examples/stories/reconnect/README.md b/examples/stories/reconnect/README.md index d7204b0461..2b225a5880 100644 --- a/examples/stories/reconnect/README.md +++ b/examples/stories/reconnect/README.md @@ -15,23 +15,24 @@ uv run python -m stories.reconnect.client --http http://127.0.0.1:8000/mcp # lowlevel server variant uv run python -m stories.reconnect.server_lowlevel --http --port 8000 & -uv run python -m stories.reconnect.client --http http://127.0.0.1:8000/mcp --server server_lowlevel +uv run python -m stories.reconnect.client --http http://127.0.0.1:8000/mcp ``` ## What to look at -- `client.py` — `client.session.discover_result`. The `mode="auto"` connect - ladder ran `server/discover` inside `__aenter__`; this property is the cached - result. Round-trip it through `model_dump_json()` / - `DiscoverResult.model_validate_json()` to model an on-disk cache. -- `client.py` — `connect(mode=LATEST_MODERN_VERSION, prior_discover=...)`. A - version pin plus a prior `DiscoverResult` installs the cached state via - `ClientSession.adopt()` with no `initialize` and no `server/discover` on the - wire — the era-neutral `client.server_info` / `.server_capabilities` - accessors are populated before the first request. -- `client.py` — the `connect: Connect` factory. A `Client` cannot be re-entered - after exit; build a fresh one via `connect()` for each attempt (see - `docs/migration.md`). +- `client.py` — the first `Client(targets(), mode="auto")`. The `mode="auto"` + connect ladder runs `server/discover` inside `__aenter__`; + `client.session.discover_result` is the cached result. Round-trip it through + `model_dump_json()` / `DiscoverResult.model_validate_json()` to model an + on-disk cache. +- `client.py` — `Client(targets(), mode=LATEST_MODERN_VERSION, + prior_discover=rehydrated)`. A version pin plus a prior `DiscoverResult` + installs the cached state via `ClientSession.adopt()` with no `initialize` + and no `server/discover` on the wire — the era-neutral `client.server_info` / + `.server_capabilities` accessors are populated before the first request. +- `client.py` — `targets()`. A `Client` cannot be re-entered after exit; each + call yields a fresh target against the same server, so the reconnect is a + genuinely new connection. ## Caveats @@ -53,6 +54,5 @@ uv run python -m stories.reconnect.client --http http://127.0.0.1:8000/mcp --ser ## See also -`dual_era/` (auto-discover + era-neutral accessors), `client_session/` (the -`initialize`/`discover`/`adopt` mechanics layer), `parallel_calls/` (the other -`connect: Connect` consumer). +`dual_era/` (auto-discover + era-neutral accessors), `parallel_calls/` (the +other multi-connection client). diff --git a/examples/stories/reconnect/client.py b/examples/stories/reconnect/client.py index f2d4fc9c59..3e163b17d0 100644 --- a/examples/stories/reconnect/client.py +++ b/examples/stories/reconnect/client.py @@ -1,49 +1,43 @@ """Probe server/discover once, persist the DiscoverResult, then reconnect with zero round-trips.""" -from typing import Any - -import anyio - from mcp.client import Client from mcp.shared.version import LATEST_MODERN_VERSION from mcp.types import DiscoverResult -from stories._harness import Connect, connect_from_args, run_client - -# The harness pins era="modern" → mode=LATEST_MODERN_VERSION (R8); override to "auto" so the -# first connection actually probes server/discover and caches the real DiscoverResult. -client_kw: dict[str, Any] = {"mode": "auto"} +from stories._harness import TargetFactory, run_client -async def scenario(client: Client, connect: Connect) -> None: - # ── first connection: mode="auto" probed server/discover inside __aenter__ ── - discovered = client.session.discover_result - assert discovered is not None, "mode='auto' against a modern server populates discover_result" - assert client.protocol_version == LATEST_MODERN_VERSION - assert client.server_info.name == "reconnect-example" - assert LATEST_MODERN_VERSION in discovered.supported_versions +async def main(targets: TargetFactory, *, mode: str = "auto") -> None: + # The caller's mode (the real-user "auto" default) probes server/discover inside + # __aenter__ and caches the result; a hard version pin would skip the probe and + # never see the server's real DiscoverResult. + async with Client(targets(), mode=mode) as client: + discovered = client.session.discover_result + assert discovered is not None, "mode='auto' against a modern server populates discover_result" + assert client.protocol_version == LATEST_MODERN_VERSION + assert client.server_info.name == "reconnect-example" + assert LATEST_MODERN_VERSION in discovered.supported_versions - result = await client.call_tool("add", {"a": 2, "b": 3}) - assert result.structured_content == {"result": 5}, result + result = await client.call_tool("add", {"a": 2, "b": 3}) + assert result.structured_content == {"result": 5}, result - # ── persist: round-trip through JSON to model loading from a cache on disk ── + # Round-trip through JSON to model loading the result from an on-disk cache. saved = discovered.model_dump_json(by_alias=True) rehydrated = DiscoverResult.model_validate_json(saved) assert rehydrated == discovered - # ── second connection: zero-RTT — mode= + prior_discover= sends nothing on entry. - # A Client cannot be re-entered after exit; build a fresh one via connect(). Without - # prior_discover= a bare pin would synthesize a blank server_info — passing the cached - # result is what makes the era-neutral accessors useful on reconnect. - with anyio.fail_after(5): - async with connect(mode=LATEST_MODERN_VERSION, prior_discover=rehydrated) as second: - assert second.protocol_version == LATEST_MODERN_VERSION - assert second.server_info.name == "reconnect-example" - assert second.server_capabilities.tools is not None - assert second.session.discover_result == rehydrated + # Reconnect: a version pin plus the cached DiscoverResult adopts the prior state with + # zero round-trips on entry. A Client cannot be re-entered after exit, so targets() + # yields a fresh one. Without prior_discover= a bare pin would synthesize a blank + # server_info — the cache is what makes the era-neutral accessors useful here. + async with Client(targets(), mode=LATEST_MODERN_VERSION, prior_discover=rehydrated) as second: + assert second.protocol_version == LATEST_MODERN_VERSION + assert second.server_info.name == "reconnect-example" + assert second.server_capabilities.tools is not None + assert second.session.discover_result == rehydrated - result = await second.call_tool("add", {"a": 1, "b": 1}) - assert result.structured_content == {"result": 2}, result + result = await second.call_tool("add", {"a": 1, "b": 1}) + assert result.structured_content == {"result": 2}, result if __name__ == "__main__": - run_client(scenario, connect=connect_from_args(__file__), needs_connect=True, **client_kw) + run_client(main) diff --git a/examples/stories/resources/README.md b/examples/stories/resources/README.md index 2a29116cac..9ab7e7c4e7 100644 --- a/examples/stories/resources/README.md +++ b/examples/stories/resources/README.md @@ -21,6 +21,10 @@ uv run python -m stories.resources.client --server server_lowlevel ## What to look at +- `client.py` `async with Client(target, mode=mode) as client:` — the one line + every client example exists to teach. `target` is anything `Client()` + accepts (an in-process server, a transport, or an HTTP URL) and `mode=` is + always explicit; the rest of the story is the body of that `async with`. - `server.py` `app_config` vs `greeting` — a URI with no `{}` registers a static resource (appears in `resources/list`); a URI with `{name}` registers a template (appears only in `resources/templates/list`) and the placeholder diff --git a/examples/stories/resources/client.py b/examples/stories/resources/client.py index 69ec056056..9e12e51e7f 100644 --- a/examples/stories/resources/client.py +++ b/examples/stories/resources/client.py @@ -2,27 +2,28 @@ from mcp.client import Client from mcp.types import TextResourceContents -from stories._harness import connect_from_args, run_client +from stories._harness import Target, run_client -async def scenario(client: Client) -> None: - listed = await client.list_resources() - assert [r.uri for r in listed.resources] == ["config://app"] +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + listed = await client.list_resources() + assert [r.uri for r in listed.resources] == ["config://app"] - templates = await client.list_resource_templates() - assert [t.uri_template for t in templates.resource_templates] == ["greeting://{name}"] + templates = await client.list_resource_templates() + assert [t.uri_template for t in templates.resource_templates] == ["greeting://{name}"] - config = await client.read_resource("config://app") - entry = config.contents[0] - assert isinstance(entry, TextResourceContents) - assert entry.text == '{"feature": true}' - assert entry.mime_type == "application/json" + config = await client.read_resource("config://app") + entry = config.contents[0] + assert isinstance(entry, TextResourceContents) + assert entry.text == '{"feature": true}' + assert entry.mime_type == "application/json" - hello = await client.read_resource("greeting://world") - entry = hello.contents[0] - assert isinstance(entry, TextResourceContents) - assert entry.text == "Hello, world!" + hello = await client.read_resource("greeting://world") + entry = hello.contents[0] + assert isinstance(entry, TextResourceContents) + assert entry.text == "Hello, world!" if __name__ == "__main__": - run_client(scenario, connect=connect_from_args(__file__)) + run_client(main) diff --git a/examples/stories/roots/README.md b/examples/stories/roots/README.md index 9acaa7d63f..0936519a58 100644 --- a/examples/stories/roots/README.md +++ b/examples/stories/roots/README.md @@ -1,14 +1,14 @@ # roots -The client registers a `list_roots_callback` returning the filesystem locations -it is willing to expose; a server tool calls `ctx.session.list_roots()` -mid-request and the client's callback answers it. Registering the callback is -what makes the client advertise the `roots` capability — there is no separate -flag. +> **Deprecated** in the 2026-07-28 protocol (SEP-2577); functional through the +> deprecation window. Migration: accept directory paths as ordinary tool +> parameters or resource URIs instead of relying on `roots/list`. +> TODO(maxisbey): revisit before beta. -> **Deprecated.** The roots capability is deprecated as of 2026-07-28 -> (SEP-2577). New servers should accept directory paths as ordinary tool -> parameters or resource URIs instead. +The client passes a `list_roots_callback` returning the filesystem locations it +is willing to expose; a server tool calls `ctx.session.list_roots()` mid-request +and the client's callback answers it. Passing the callback is what makes the +client advertise the `roots` capability — there is no separate flag. ## Run it @@ -23,9 +23,12 @@ uv run python -m stories.roots.client --http http://127.0.0.1:8000/mcp --legacy ## What to look at +- `client.py` `main` — the + `Client(target, mode=mode, list_roots_callback=list_roots)` construction is + the whole client-side story: the callback is wired in as a constructor + argument, and that alone advertises the capability. - `client.py` `list_roots` — the callback takes a `ClientRequestContext` and - returns `ListRootsResult`; passing it as `list_roots_callback=` is what - advertises the capability. + returns `ListRootsResult`. - `server.py` — `await ctx.session.list_roots()` inside the tool body: a server→client request that blocks until the callback answers. - `server_lowlevel.py` — the same call from `ServerRequestContext.session`, @@ -50,5 +53,5 @@ uv run python -m stories.roots.client --http http://127.0.0.1:8000/mcp --legacy ## See also -`elicitation/`, `sampling/` — sibling server→client requests on the same MRTR -migration path. +`legacy_elicitation/`, `sampling/` — sibling server→client requests on the same +MRTR migration path. diff --git a/examples/stories/roots/client.py b/examples/stories/roots/client.py index e85b420e62..ce18cd10dc 100644 --- a/examples/stories/roots/client.py +++ b/examples/stories/roots/client.py @@ -1,12 +1,10 @@ """Expose two filesystem roots and verify the server's tool can read them back.""" -from typing import Any - from pydantic import FileUrl from mcp.client import Client, ClientRequestContext from mcp.types import ListRootsResult, Root, TextContent -from stories._harness import connect_from_args, run_client +from stories._harness import Target, run_client async def list_roots(context: ClientRequestContext) -> ListRootsResult: @@ -18,19 +16,16 @@ async def list_roots(context: ClientRequestContext) -> ListRootsResult: ) -def client_kw() -> dict[str, Any]: - return {"list_roots_callback": list_roots} - +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode, list_roots_callback=list_roots) as client: + result = await client.call_tool("show_roots", {}) -async def scenario(client: Client) -> None: - result = await client.call_tool("show_roots", {}) - - assert not result.is_error, result - assert isinstance(result.content[0], TextContent) - assert result.content[0].text == ("file:///workspace/project (project)\nfile:///workspace/scratch (unnamed)"), ( - result.content[0].text - ) + assert not result.is_error, result + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == ("file:///workspace/project (project)\nfile:///workspace/scratch (unnamed)"), ( + result.content[0].text + ) if __name__ == "__main__": - run_client(scenario, connect=connect_from_args(__file__), **client_kw()) + run_client(main) diff --git a/examples/stories/sampling/README.md b/examples/stories/sampling/README.md index f6a3238e64..1fb060fd65 100644 --- a/examples/stories/sampling/README.md +++ b/examples/stories/sampling/README.md @@ -1,15 +1,16 @@ # sampling +> **Deprecated** in the 2026-07-28 protocol (SEP-2577); functional through the +> deprecation window. Migration: call your LLM provider directly from the +> server instead of requesting completions through the client. +> TODO(maxisbey): revisit before beta. + A tool that asks the **client's** LLM for a completion mid-call — the inverted MCP direction. The server holds no model API key; it awaits `ctx.session.create_message(...)` and the client's `sampling_callback` answers. Registering the callback is what makes the client advertise the `sampling` capability — there is no separate flag. -> **Deprecated.** The sampling capability is deprecated as of 2026-07-28 -> (SEP-2577). New servers should call an LLM provider directly instead of -> requesting completions through the client. - ## Run it ```bash @@ -23,9 +24,13 @@ uv run python -m stories.sampling.client --http http://127.0.0.1:8000/mcp --lega ## What to look at -- `client.py` `sampling_callback` — takes `(ClientRequestContext, - CreateMessageRequestParams)` and returns `CreateMessageResult`; passing it as - `sampling_callback=` is what advertises the capability. +- `client.py` `main` — `async with Client(target, mode=mode, + sampling_callback=on_sample) as client:`. The callback is an ordinary + constructor kwarg; registering it is the whole opt-in. +- `client.py` `on_sample` — takes `(ClientRequestContext, + CreateMessageRequestParams)` and returns a `CreateMessageResult`. A real + host calls its LLM provider here; the example returns a canned answer so the + round-trip is assertable. - `server.py` — `await ctx.session.create_message(...)` inside the tool body: a server→client request that blocks until the callback answers. There is no `Context.sample()` sugar; reaching `ctx.session` is the public path. @@ -52,5 +57,5 @@ uv run python -m stories.sampling.client --http http://127.0.0.1:8000/mcp --lega ## See also -`elicitation/`, `roots/` — sibling server→client requests on the same MRTR -migration path. +`legacy_elicitation/`, `roots/` — sibling server→client requests on the same +MRTR migration path. diff --git a/examples/stories/sampling/client.py b/examples/stories/sampling/client.py index 31cd61451e..93d3dddf1c 100644 --- a/examples/stories/sampling/client.py +++ b/examples/stories/sampling/client.py @@ -1,13 +1,11 @@ """Supply a canned sampling_callback and assert its text round-trips through the tool.""" -from typing import Any - from mcp.client import Client, ClientRequestContext from mcp.types import CreateMessageRequestParams, CreateMessageResult, TextContent -from stories._harness import connect_from_args, run_client +from stories._harness import Target, run_client -async def sampling_callback(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult: +async def on_sample(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult: # A real host would call its LLM provider here; the example returns a deterministic # canned answer so the round-trip is assertable. return CreateMessageResult( @@ -18,17 +16,14 @@ async def sampling_callback(context: ClientRequestContext, params: CreateMessage ) -def client_kw() -> dict[str, Any]: - return {"sampling_callback": sampling_callback} - - -async def scenario(client: Client) -> None: - result = await client.call_tool("summarize", {"text": "hello world"}) +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode, sampling_callback=on_sample) as client: + result = await client.call_tool("summarize", {"text": "hello world"}) - assert not result.is_error, result - assert isinstance(result.content[0], TextContent) - assert result.content[0].text == "[canned summary]", result.content[0].text + assert not result.is_error, result + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "[canned summary]", result.content[0].text if __name__ == "__main__": - run_client(scenario, connect=connect_from_args(__file__), **client_kw()) + run_client(main) diff --git a/examples/stories/schema_validators/README.md b/examples/stories/schema_validators/README.md index c594a01cf1..55de59f8b6 100644 --- a/examples/stories/schema_validators/README.md +++ b/examples/stories/schema_validators/README.md @@ -18,6 +18,9 @@ uv run python -m stories.schema_validators.client --http http://127.0.0.1:8000/m ## What to look at +- `client.py` `main` — the body opens with `async with Client(target, mode=mode) + as client:`. `target` is anything `Client` accepts (an in-process server, a + transport, or an HTTP URL); the entry point picks it, the story constructs it. - `server.py` — `who.name` vs `who["name"]`: pydantic and dataclass parameters arrive as **instances** (attribute access); TypedDict and `dict[str, Any]` arrive as plain dicts. diff --git a/examples/stories/schema_validators/client.py b/examples/stories/schema_validators/client.py index 9c156816f6..66e990bc61 100644 --- a/examples/stories/schema_validators/client.py +++ b/examples/stories/schema_validators/client.py @@ -2,35 +2,36 @@ from mcp.client import Client from mcp.types import TextContent -from stories._harness import connect_from_args, run_client - - -async def scenario(client: Client) -> None: - listed = await client.list_tools() - by_name = {t.name: t for t in listed.tools} - assert set(by_name) == {"greet_pydantic", "greet_typeddict", "greet_dataclass", "greet_dict"} - - for name in ("greet_pydantic", "greet_typeddict", "greet_dataclass"): - schema = by_name[name].input_schema - assert schema["required"] == ["who"], schema - # MCPServer emits a $defs/$ref pair; lowlevel inlines. Resolve either. - who = schema["properties"]["who"] - if "$ref" in who: - who = schema["$defs"][who["$ref"].rsplit("/", 1)[-1]] - assert "name" in who["properties"], who - - result = await client.call_tool(name, {"who": {"name": "Ada", "title": "colleague"}}) - assert not result.is_error, result +from stories._harness import Target, run_client + + +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + listed = await client.list_tools() + by_name = {t.name: t for t in listed.tools} + assert set(by_name) == {"greet_pydantic", "greet_typeddict", "greet_dataclass", "greet_dict"} + + for name in ("greet_pydantic", "greet_typeddict", "greet_dataclass"): + schema = by_name[name].input_schema + assert schema["required"] == ["who"], schema + # MCPServer emits a $defs/$ref pair; lowlevel inlines. Resolve either. + who = schema["properties"]["who"] + if "$ref" in who: + who = schema["$defs"][who["$ref"].rsplit("/", 1)[-1]] + assert "name" in who["properties"], who + + result = await client.call_tool(name, {"who": {"name": "Ada", "title": "colleague"}}) + assert not result.is_error, result + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Hello Ada, my colleague" + + # dict[str, Any] → free-form object schema, no nested `properties` required. + dict_who = by_name["greet_dict"].input_schema["properties"]["who"] + assert dict_who["type"] == "object" and "$ref" not in dict_who + result = await client.call_tool("greet_dict", {"who": {"name": "Ada"}}) assert isinstance(result.content[0], TextContent) - assert result.content[0].text == "Hello Ada, my colleague" - - # dict[str, Any] → free-form object schema, no nested `properties` required. - dict_who = by_name["greet_dict"].input_schema["properties"]["who"] - assert dict_who["type"] == "object" and "$ref" not in dict_who - result = await client.call_tool("greet_dict", {"who": {"name": "Ada"}}) - assert isinstance(result.content[0], TextContent) - assert result.content[0].text == "Hello Ada, my friend" + assert result.content[0].text == "Hello Ada, my friend" if __name__ == "__main__": - run_client(scenario, connect=connect_from_args(__file__)) + run_client(main) diff --git a/examples/stories/serve_one/README.md b/examples/stories/serve_one/README.md index d59f909d27..67f163e183 100644 --- a/examples/stories/serve_one/README.md +++ b/examples/stories/serve_one/README.md @@ -5,46 +5,41 @@ transport entry composes the same three pieces: a `lowlevel.Server` (the handler registry), a `Connection` (per-peer state), and a driver — `serve_one` for one request → result dict, or `serve_connection` for a dispatcher loop. This is what you write to bring up MCP over a custom transport. Uniquely, the -server files here build the stdio entry by hand instead of importing +server file here builds the stdio entry by hand instead of importing `stories._hosting`. ## Run it ```bash # stdio (default — the client spawns server.py as a subprocess; its __main__ -# is the hand-built serve_loop recipe) +# is the hand-built serve_connection loop) uv run python -m stories.serve_one.client - -# drive the lowlevel hand-built loop instead -uv run python -m stories.serve_one.client --server server_lowlevel ``` ## What to look at -- `server_lowlevel.py::handle_one` — `Connection.from_envelope(...)` + - `serve_one(...)` returns the raw result dict for one request. No handshake, - no streams; the entry owns wire encoding and exception→error mapping. -- `server_lowlevel.py::main` — `JSONRPCDispatcher` + `Connection.for_loop(...)` - + `serve_connection(...)`: exactly what `Server.run()` does internally for +- `server.py::handle_one` — `Connection.from_envelope(...)` + `serve_one(...)` + returns the raw result dict for one request. No handshake, no streams; the + entry owns wire encoding and exception→error mapping. +- `server.py::main` — `JSONRPCDispatcher` + `Connection.for_loop(...)` + + `serve_connection(...)`: exactly what `Server.run()` does internally for stdio. -- `server_lowlevel.py::SingleExchangeContext` — the per-request - `DispatchContext` a custom entry must supply. The SDK ships no public - concrete class for this yet. -- `server.py::main` — `serve_loop(...)` over an `MCPServer`'s underlying - `lowlevel.Server`; surfaces the missing public accessor. +- `server.py::SingleExchangeContext` — the per-request `DispatchContext` a + custom entry must supply. The SDK ships no public concrete class for this + yet. - `client.py` — drives `handle_one` directly and asserts the raw result-dict shape (`structuredContent` / `content`), then proves the loop-mode driver works over the wire. ## Caveats -- **Deep imports** — `serve_one`, `serve_connection`, `serve_loop`, - `Connection` are only reachable at `mcp.server.runner` / - `mcp.server.connection` today; a shorter `mcp.server.*` re-export is tracked - for beta. -- **`MCPServer` accessor** — `server.py` reaches `mcp._lowlevel_server` because - there's no public way to hand an `MCPServer` to the drivers. Prefer the - lowlevel variant until that lands. +- **Deep imports** — `serve_one`, `serve_connection`, and `Connection` are only + reachable at `mcp.server.runner` / `mcp.server.connection` today; a shorter + `mcp.server.*` re-export is tracked for beta. +- **Lowlevel-only.** The drivers take a `lowlevel.Server` and `MCPServer` has + no public accessor for its underlying one (`_lowlevel_server` is private), so + there is no `MCPServer`-tier variant of this story. Build the lowlevel + `Server` directly until that accessor lands. - **No public `DispatchContext`** — `SingleExchangeContext` is hand-rolled boilerplate; a public helper (or a `serve_one` overload that builds one) is tracked for beta. @@ -61,6 +56,5 @@ uv run python -m stories.serve_one.client --server server_lowlevel ## See also -`client_session/` (the client-side mechanics counterpart), `legacy_routing/` -(composing `serve_one` behind `classify_inbound_request`), `dual_era/` -(`Connection.protocol_version` in handlers). +`legacy_routing/` (composing `serve_one` behind `classify_inbound_request`), +`dual_era/` (`Connection.protocol_version` in handlers). diff --git a/examples/stories/serve_one/client.py b/examples/stories/serve_one/client.py index 42342dd2c4..da114f8519 100644 --- a/examples/stories/serve_one/client.py +++ b/examples/stories/serve_one/client.py @@ -3,15 +3,14 @@ from mcp import types from mcp.client import Client from mcp.shared.version import LATEST_MODERN_VERSION -from stories._harness import connect_from_args, run_client -from stories.serve_one.server_lowlevel import build_server as build_lowlevel -from stories.serve_one.server_lowlevel import handle_one +from stories._harness import Target, run_client +from stories.serve_one.server import build_server, handle_one -async def scenario(client: Client) -> None: +async def main(target: Target, *, mode: str = "auto") -> None: # ── direct: the namesake recipe — Connection.from_envelope + serve_one → raw result dict. # The entry enters lifespan once and threads it to every per-request handle_one(). - server = build_lowlevel() + server = build_server() params = { "name": "add", "arguments": {"a": 2, "b": 3}, @@ -27,12 +26,13 @@ async def scenario(client: Client) -> None: assert raw["content"][0] == {"type": "text", "text": "5"}, raw # ── over the wire: the loop-mode driver behind the connected client. - listed = await client.list_tools() - assert [t.name for t in listed.tools] == ["add"] + async with Client(target, mode=mode) as client: + listed = await client.list_tools() + assert [t.name for t in listed.tools] == ["add"] - result = await client.call_tool("add", {"a": 2, "b": 3}) - assert result.structured_content == {"result": 5}, result + result = await client.call_tool("add", {"a": 2, "b": 3}) + assert result.structured_content == {"result": 5}, result if __name__ == "__main__": - run_client(scenario, connect=connect_from_args(__file__)) + run_client(main) diff --git a/examples/stories/serve_one/server.py b/examples/stories/serve_one/server.py index 98174765c3..f33a03fdaf 100644 --- a/examples/stories/serve_one/server.py +++ b/examples/stories/serve_one/server.py @@ -1,34 +1,108 @@ -"""Kernel drivers: drive an `MCPServer` via `serve_loop` directly. +"""serve_one / serve_connection mechanics: the kernel drivers a transport entry composes. -The drivers (`serve_loop` / `serve_one`) take a `lowlevel.Server`; `MCPServer` -has no public accessor for its underlying one yet, so this file reaches -`_lowlevel_server`. See `server_lowlevel.py` for the clean shape. +`handle_one()` is the modern single-exchange recipe (`Connection.from_envelope` ++ `serve_one` → raw result dict). `main()` is the loop recipe +(`JSONRPCDispatcher` + `Connection.for_loop` + `serve_connection`) — what +`Server.run()` does for stdio. Both drivers take a `lowlevel.Server`, so this is +a lowlevel-only story: `MCPServer` has no public accessor for its underlying +`Server` yet. """ +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import Any + import anyio -from mcp.server.mcpserver import MCPServer -from mcp.server.runner import serve_loop # deep-path import; shorter re-export planned +from mcp import types +from mcp.server.connection import Connection # deep-path import; shorter re-export planned +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from mcp.server.runner import serve_connection, serve_one # deep-path import; shorter re-export planned from mcp.server.stdio import stdio_server +from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher +from mcp.shared.transport_context import TransportContext +from mcp.shared.version import LATEST_MODERN_VERSION + +__all__ = ["SingleExchangeContext", "build_server", "handle_one"] + + +def build_server() -> Server[Any]: + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[types.Tool(name="add", description="Add two integers.", input_schema={"type": "object"})] + ) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "add" and params.arguments is not None + total = params.arguments["a"] + params.arguments["b"] + return types.CallToolResult(content=[types.TextContent(text=str(total))], structured_content={"result": total}) + + return Server("serve-one-example", on_list_tools=list_tools, on_call_tool=call_tool) + + +@dataclass +class SingleExchangeContext: + """Minimal `DispatchContext` for one inbound request with no back-channel. + + A custom transport entry hand-builds one of these per request. The SDK + ships no public concrete class for this yet; this is the structural minimum. + """ + + request_id: int | str | None + transport: TransportContext = field(default_factory=lambda: TransportContext(kind="custom", can_send_request=False)) + message_metadata: None = None + can_send_request: bool = False + cancel_requested: anyio.Event = field(default_factory=anyio.Event) + + async def send_raw_request(self, method: str, params: Mapping[str, Any] | None, opts: Any = None) -> dict[str, Any]: + raise NotImplementedError # no back-channel on the single-exchange path + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: Any = None) -> None: + return None + async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: + return None -def build_server() -> MCPServer: - mcp = MCPServer("serve-one-example") - @mcp.tool() - def add(a: int, b: int) -> int: - """Add two integers.""" - return a + b +async def handle_one( + server: Server[Any], method: str, params: Mapping[str, Any], *, lifespan_state: Any +) -> dict[str, Any]: + """Serve exactly one modern-era request and return its raw result dict. - return mcp + Reads the envelope from `params._meta` (the 2026 wire shape), builds a + born-ready `Connection.from_envelope`, and drives `serve_one`. The transport + entry enters `server.lifespan(server)` once and threads `lifespan_state` to + every call — never enter the lifespan per-request. + """ + meta = params.get("_meta", {}) + connection = Connection.from_envelope( + meta.get(types.PROTOCOL_VERSION_META_KEY, LATEST_MODERN_VERSION), + meta.get(types.CLIENT_INFO_META_KEY), + meta.get(types.CLIENT_CAPABILITIES_META_KEY), + ) + return await serve_one( + server, + SingleExchangeContext(request_id=1), + method, + params, + connection=connection, + lifespan_state=lifespan_state, + ) async def main() -> None: - mcp = build_server() - server = mcp._lowlevel_server # pyright: ignore[reportPrivateUsage] # no public accessor yet + """Serve over stdio by building the dispatcher + Connection by hand (loop mode).""" + server = build_server() async with server.lifespan(server) as lifespan_state: async with stdio_server() as (read_stream, write_stream): - await serve_loop(server, read_stream, write_stream, lifespan_state=lifespan_state) + dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher( + read_stream, write_stream, inline_methods=frozenset({"initialize"}) + ) + connection = Connection.for_loop(dispatcher) + await serve_connection(server, dispatcher, connection=connection, lifespan_state=lifespan_state) if __name__ == "__main__": diff --git a/examples/stories/serve_one/server_lowlevel.py b/examples/stories/serve_one/server_lowlevel.py deleted file mode 100644 index 7ce3962ee7..0000000000 --- a/examples/stories/serve_one/server_lowlevel.py +++ /dev/null @@ -1,107 +0,0 @@ -"""serve_one / serve_connection mechanics: the kernel drivers a transport entry composes. - -`handle_one()` is the modern single-exchange recipe (`Connection.from_envelope` -+ `serve_one` → raw result dict). `main()` is the loop recipe -(`JSONRPCDispatcher` + `Connection.for_loop` + `serve_connection`) — what -`Server.run()` does for stdio. -""" - -from collections.abc import Mapping -from dataclasses import dataclass, field -from typing import Any - -import anyio - -from mcp import types -from mcp.server.connection import Connection # deep-path import; shorter re-export planned -from mcp.server.context import ServerRequestContext -from mcp.server.lowlevel import Server -from mcp.server.runner import serve_connection, serve_one # deep-path import; shorter re-export planned -from mcp.server.stdio import stdio_server -from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher -from mcp.shared.transport_context import TransportContext -from mcp.shared.version import LATEST_MODERN_VERSION - -__all__ = ["SingleExchangeContext", "build_server", "handle_one"] - - -def build_server() -> Server[Any]: - async def list_tools( - ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None - ) -> types.ListToolsResult: - return types.ListToolsResult( - tools=[types.Tool(name="add", description="Add two integers.", input_schema={"type": "object"})] - ) - - async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: - assert params.name == "add" and params.arguments is not None - total = params.arguments["a"] + params.arguments["b"] - return types.CallToolResult(content=[types.TextContent(text=str(total))], structured_content={"result": total}) - - return Server("serve-one-example", on_list_tools=list_tools, on_call_tool=call_tool) - - -@dataclass -class SingleExchangeContext: - """Minimal `DispatchContext` for one inbound request with no back-channel. - - A custom transport entry hand-builds one of these per request. The SDK - ships no public concrete class for this yet; this is the structural minimum. - """ - - request_id: int | str | None - transport: TransportContext = field(default_factory=lambda: TransportContext(kind="custom", can_send_request=False)) - message_metadata: None = None - can_send_request: bool = False - cancel_requested: anyio.Event = field(default_factory=anyio.Event) - - async def send_raw_request(self, method: str, params: Mapping[str, Any] | None, opts: Any = None) -> dict[str, Any]: - raise NotImplementedError # no back-channel on the single-exchange path - - async def notify(self, method: str, params: Mapping[str, Any] | None, opts: Any = None) -> None: - return None - - async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: - return None - - -async def handle_one( - server: Server[Any], method: str, params: Mapping[str, Any], *, lifespan_state: Any -) -> dict[str, Any]: - """Serve exactly one modern-era request and return its raw result dict. - - Reads the envelope from `params._meta` (the 2026 wire shape), builds a - born-ready `Connection.from_envelope`, and drives `serve_one`. The transport - entry enters `server.lifespan(server)` once and threads `lifespan_state` to - every call — never enter the lifespan per-request. - """ - meta = params.get("_meta", {}) - connection = Connection.from_envelope( - meta.get(types.PROTOCOL_VERSION_META_KEY, LATEST_MODERN_VERSION), - meta.get(types.CLIENT_INFO_META_KEY), - meta.get(types.CLIENT_CAPABILITIES_META_KEY), - ) - return await serve_one( - server, - SingleExchangeContext(request_id=1), - method, - params, - connection=connection, - lifespan_state=lifespan_state, - ) - - -async def main() -> None: - """Serve over stdio by building the dispatcher + Connection by hand (loop mode).""" - server = build_server() - async with server.lifespan(server) as lifespan_state: - async with stdio_server() as (read_stream, write_stream): - dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher( - read_stream, write_stream, inline_methods=frozenset({"initialize"}) - ) - connection = Connection.for_loop(dispatcher) - await serve_connection(server, dispatcher, connection=connection, lifespan_state=lifespan_state) - - -if __name__ == "__main__": - anyio.run(main) diff --git a/examples/stories/sse_polling/README.md b/examples/stories/sse_polling/README.md index ce3ff2e1ff..c36b6f2eea 100644 --- a/examples/stories/sse_polling/README.md +++ b/examples/stories/sse_polling/README.md @@ -1,5 +1,11 @@ # sse-polling +> **Legacy mechanism (2025 handshake era).** `Last-Event-ID` resumability and +> the sessionful transport are removed in the 2026-07-28 protocol (SEP-2575) +> with no modern-era equivalent; the closest 2026-era pattern is client-side +> reconnection over a persisted `DiscoverResult` — +> [`reconnect/`](../reconnect/). TODO(maxisbey): revisit before beta. + SEP-1699 server-initiated SSE disconnection with `Last-Event-ID` replay. The server's `EventStore` stamps every SSE event with an ID and opens each response stream with a priming event; mid-handler the tool calls @@ -9,10 +15,6 @@ The client transport sees the stream end, reconnects with `Last-Event-ID`, and the event store replays everything it missed — `await client.call_tool(...)` resolves as if the disconnect never happened. -**2025-era only.** `Last-Event-ID` resumability and the sessionful transport -are removed in the 2026-07-28 spec (SEP-2575); there is no modern-era -equivalent. - ## Run it ```bash @@ -24,6 +26,12 @@ uv run python -m stories.sse_polling.client --http http://127.0.0.1:8000/mcp --l ## What to look at +- **`client.py` `main` — opens with `async with Client(target, mode=mode)`.** + There is no client-side resumability configuration: the `Client` and the + `streamable_http_client` transport handle the priming event, the SSE `retry:` + hint, and the `Last-Event-ID` reconnect automatically. The assertion that the + `"after-close"` progress message arrived is the proof — it was emitted while + no SSE stream was open. - **`server.py` — `streamable_http_app(event_store=..., retry_interval=0)`.** Passing an `EventStore` is what enables resumability: every SSE event gets an ID and the response opens with a priming event so the client always has a @@ -36,10 +44,6 @@ uv run python -m stories.sse_polling.client --http http://127.0.0.1:8000/mcp --l - **`server_lowlevel.py` — `ctx.close_sse_stream`.** On the lowlevel API the callback is an optional field on `ServerRequestContext`; it is `None` unless an event store is wired and the negotiated version is in the 2025 era. -- **`client.py` — nothing special.** The `Client` and `streamable_http_client` - transport handle the priming event, the `retry:` hint, and the - `Last-Event-ID` reconnect automatically. The assertion that `"after-close"` - arrived is the proof. ## Caveats diff --git a/examples/stories/sse_polling/client.py b/examples/stories/sse_polling/client.py index d154f1844f..b77bf6ac83 100644 --- a/examples/stories/sse_polling/client.py +++ b/examples/stories/sse_polling/client.py @@ -4,28 +4,29 @@ from mcp.client import Client from mcp.types import TextContent -from stories._harness import connect_from_args, run_client +from stories._harness import Target, run_client -async def scenario(client: Client) -> None: - messages: list[str | None] = [] +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + messages: list[str | None] = [] - async def on_progress(progress: float, total: float | None, message: str | None) -> None: - messages.append(message) + async def on_progress(progress: float, total: float | None, message: str | None) -> None: + messages.append(message) - with anyio.fail_after(10): - result = await client.call_tool("long_operation", {}, progress_callback=on_progress) + with anyio.fail_after(10): + result = await client.call_tool("long_operation", {}, progress_callback=on_progress) - # The result arrived — the client transport survived the server-initiated close, - # reconnected with Last-Event-ID, and received the replayed response. - assert not result.is_error, result - assert isinstance(result.content[0], TextContent) - assert result.content[0].text == "resumed" + # The result arrived — the client transport survived the server-initiated close, + # reconnected with Last-Event-ID, and received the replayed response. + assert not result.is_error, result + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "resumed" - # "after-close" was emitted while no SSE stream was open; receiving it proves the - # event store buffered it and the reconnect replayed it. - assert messages == ["before-close", "after-close"], messages + # "after-close" was emitted while no SSE stream was open; receiving it proves the + # event store buffered it and the reconnect replayed it. + assert messages == ["before-close", "after-close"], messages if __name__ == "__main__": - run_client(scenario, connect=connect_from_args(__file__)) + run_client(main) diff --git a/examples/stories/standalone_get/README.md b/examples/stories/standalone_get/README.md index 9b56b51f27..086ea970c2 100644 --- a/examples/stories/standalone_get/README.md +++ b/examples/stories/standalone_get/README.md @@ -1,5 +1,11 @@ # standalone-get +> **Legacy mechanism (2025 handshake era).** The 2026-07-28 protocol delivers +> server-initiated notifications over a `subscriptions/listen` stream instead +> of the standalone GET stream. TODO(maxisbey): unify once +> `subscriptions/listen` lands +> ([#2901](https://github.com/modelcontextprotocol/python-sdk/issues/2901)). + Server-initiated `notifications/resources/list_changed` delivered over the **standalone GET SSE stream** of a sessionful Streamable-HTTP connection. The `add_note` tool mutates the resource list and emits the notification with no @@ -17,23 +23,20 @@ uv run python -m stories.standalone_get.client --http http://127.0.0.1:8000/mcp ## What to look at +- **`client.py` — `Client(target, mode=mode, message_handler=on_message)`.** + Unsolicited notifications have no typed callback, so the catch-all + `message_handler` is wired at construction — it (and the `anyio.Event` it + sets) must exist *before* the connection does. The notification is not + guaranteed to arrive before the tool result (different streams), so the body + `await`s the event, bounded by `anyio.fail_after(5)`. - **`server.py` — `await ctx.session.send_resource_list_changed()`.** `MCPServer.add_resource` does **not** auto-emit (unlike the TypeScript SDK's `registerResource`); the explicit call is the teaching point. Because `send_*_list_changed()` carries no `related_request_id`, the only route to the client is the standalone GET stream. -- **`client.py` — `message_handler=` + `anyio.Event`.** The notification is not - guaranteed to arrive before the tool result (different streams), so the - scenario `await`s an event the handler sets, bounded by `anyio.fail_after(5)`. - `client_kw()` is a callable so each run wires a fresh `anyio.Event` into - `message_handler`. ## Caveats -- **Legacy-era only.** The standalone GET stream is a sessionful 2025-era - transport feature; in 2026-07-28 these notifications travel on a - `subscriptions/listen` stream instead — not yet wired in this SDK - ([#2901](https://github.com/modelcontextprotocol/python-sdk/issues/2901)). - DNS-rebinding protection is disabled via `transport_security=NO_DNS_REBIND` because the in-process httpx client sends no `Origin` header. Drop the kwarg for a real deployment. diff --git a/examples/stories/standalone_get/client.py b/examples/stories/standalone_get/client.py index ced7348bbc..738d4be92c 100644 --- a/examples/stories/standalone_get/client.py +++ b/examples/stories/standalone_get/client.py @@ -1,48 +1,40 @@ """Receive `notifications/resources/list_changed` over the standalone GET stream, then re-list.""" -from typing import Any - import anyio from mcp import types from mcp.client import Client -from stories._harness import connect_from_args, run_client - -# Shared between the `message_handler` (wired at connect time) and `scenario()`. -# Reset per leg by `client_kw()` so each (variant × era) starts clean. -_received: list[types.ResourceListChangedNotification] = [] -_seen: list[anyio.Event] = [] - - -async def _on_message(message: object) -> None: - if isinstance(message, types.ResourceListChangedNotification): - _received.append(message) - _seen[0].set() +from stories._harness import Target, run_client -def client_kw() -> dict[str, Any]: - _received[:] = [] - _seen[:] = [anyio.Event()] - return {"message_handler": _on_message} +async def main(target: Target, *, mode: str = "auto") -> None: + # `message_handler` is constructor-only on `Client`, so the event it sets + # has to exist before the connection does. + received: list[types.ResourceListChangedNotification] = [] + seen = anyio.Event() + async def on_message(message: object) -> None: + if isinstance(message, types.ResourceListChangedNotification): + received.append(message) + seen.set() -async def scenario(client: Client) -> None: - before = await client.list_resources() - assert len(before.resources) >= 1, before + async with Client(target, mode=mode, message_handler=on_message) as client: + before = await client.list_resources() + assert len(before.resources) >= 1, before - result = await client.call_tool("add_note", {"content": "hello"}) - assert not result.is_error, result + result = await client.call_tool("add_note", {"content": "hello"}) + assert not result.is_error, result - # The notification rides the standalone GET stream, not the call's POST stream — - # delivery order vs the tool result is not guaranteed, so wait. - with anyio.fail_after(5): - await _seen[0].wait() - assert len(_received) == 1, _received + # The notification rides the standalone GET stream, not the call's POST stream — + # delivery order vs the tool result is not guaranteed, so wait. + with anyio.fail_after(5): + await seen.wait() + assert len(received) == 1, received - after = await client.list_resources() - assert len(after.resources) == len(before.resources) + 1, after - assert {r.name for r in after.resources} >= {"initial", "note-1"} + after = await client.list_resources() + assert len(after.resources) == len(before.resources) + 1, after + assert {r.name for r in after.resources} >= {"initial", "note-1"} if __name__ == "__main__": - run_client(scenario, connect=connect_from_args(__file__), **client_kw()) + run_client(main) diff --git a/examples/stories/starlette_mount/README.md b/examples/stories/starlette_mount/README.md index da4231433f..ffdf86e622 100644 --- a/examples/stories/starlette_mount/README.md +++ b/examples/stories/starlette_mount/README.md @@ -17,6 +17,9 @@ uv run python -m stories.starlette_mount.client --http http://127.0.0.1:8000/api ## What to look at +- `client.py` `main` — opens with `async with Client(target, mode=mode) as + client:`. Nothing on the client side knows about the mount: the `/api/` URL + handed in as `target` is just another streamable-HTTP endpoint. - `server.py` `streamable_http_path="/"` — without this the endpoint would be `/api/mcp`; with it, `Mount("/api", ...)` serves MCP at `/api/` (trailing slash required — Starlette's `Mount` forwards `/api` as an empty path that diff --git a/examples/stories/starlette_mount/client.py b/examples/stories/starlette_mount/client.py index 5f8065f8a6..3c7233b6ab 100644 --- a/examples/stories/starlette_mount/client.py +++ b/examples/stories/starlette_mount/client.py @@ -1,21 +1,22 @@ -"""Connect to the sub-mounted MCP endpoint at /api, list tools and call greet.""" +"""Connect to the sub-mounted MCP endpoint at /api/, list tools and call greet.""" from mcp.client import Client from mcp.types import TextContent -from stories._harness import connect_from_args, run_client +from stories._harness import Target, run_client -async def scenario(client: Client) -> None: - listed = await client.list_tools() - assert [t.name for t in listed.tools] == ["greet"] +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + listed = await client.list_tools() + assert [t.name for t in listed.tools] == ["greet"] - result = await client.call_tool("greet", {"name": "Starlette"}) - assert not result.is_error - first = result.content[0] - assert isinstance(first, TextContent) - assert "Hello, Starlette!" in first.text, result - assert result.structured_content == {"result": "Hello, Starlette! (served from a Starlette sub-mount)"} + result = await client.call_tool("greet", {"name": "Starlette"}) + assert not result.is_error + first = result.content[0] + assert isinstance(first, TextContent) + assert "Hello, Starlette!" in first.text, result + assert result.structured_content == {"result": "Hello, Starlette! (served from a Starlette sub-mount)"} if __name__ == "__main__": - run_client(scenario, connect=connect_from_args(__file__)) + run_client(main) diff --git a/examples/stories/stateless_legacy/README.md b/examples/stories/stateless_legacy/README.md index 00169dfa3b..ae52235d0f 100644 --- a/examples/stories/stateless_legacy/README.md +++ b/examples/stories/stateless_legacy/README.md @@ -24,14 +24,17 @@ uv run python -m stories.stateless_legacy.client --http http://127.0.0.1:8001/mc ## What to look at +- `client.py` — two visible `Client(targets(), mode=...)` constructions against + the same URL. The first connects at the caller's `mode` (the real-user + `"auto"` default routes to the 2026 envelope path); the second pins + `mode="legacy"` and runs the `initialize` handshake. `client.protocol_version` + is the era-neutral accessor: two negotiated versions, identical tool result. - `server.py` — `stateless_http=True` is the only knob; era routing is automatic inside `StreamableHTTPSessionManager.handle_request`. The returned `Starlette` already wires `lifespan=session_manager.run()`, so `uvicorn.run(app, ...)` works with no parent-lifespan ceremony. - `server_lowlevel.py` — `lowlevel.Server.streamable_http_app()` is the same call; `MCPServer` delegates to it. -- `client.py` — `client.protocol_version` is the era-neutral accessor; same - scenario body, two different negotiated versions, identical tool result. ## Caveats diff --git a/examples/stories/stateless_legacy/client.py b/examples/stories/stateless_legacy/client.py index 4af06891c9..7c6e85c053 100644 --- a/examples/stories/stateless_legacy/client.py +++ b/examples/stories/stateless_legacy/client.py @@ -3,26 +3,27 @@ from mcp.client import Client from mcp.shared.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION from mcp.types import TextContent -from stories._harness import Connect, connect_from_args, run_client +from stories._harness import TargetFactory, run_client -async def scenario(client: Client, connect: Connect) -> None: - # ── modern leg: the harness-supplied client connected at mode="auto"; the entry routed - # this request through the 2026 envelope path. No initialize handshake, no session id. - assert client.protocol_version == LATEST_MODERN_VERSION +async def main(targets: TargetFactory, *, mode: str = "auto") -> None: + # ── modern era: the caller's mode (the real-user "auto" default) routes this connection + # through the 2026 envelope path. No initialize handshake, no session id. + async with Client(targets(), mode=mode) as client: + assert client.protocol_version == LATEST_MODERN_VERSION - listed = await client.list_tools() - assert [t.name for t in listed.tools] == ["greet"] + listed = await client.list_tools() + assert [t.name for t in listed.tools] == ["greet"] - result = await client.call_tool("greet", {"name": "world"}) - assert not result.is_error - assert isinstance(result.content[0], TextContent) - assert result.content[0].text == "Hello, world!", result + result = await client.call_tool("greet", {"name": "world"}) + assert not result.is_error + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "Hello, world!", result - # ── legacy leg: a fresh mode="legacy" client runs the initialize handshake against the + # ── legacy era: a fresh mode="legacy" client runs the initialize handshake against the # SAME stateless app. It is answered statelessly (no Mcp-Session-Id) and the same tool # gives the same answer — the era is invisible to the server body. - async with connect(mode="legacy") as legacy: + async with Client(targets(), mode="legacy") as legacy: assert legacy.protocol_version == LATEST_HANDSHAKE_VERSION result = await legacy.call_tool("greet", {"name": "world"}) @@ -32,4 +33,4 @@ async def scenario(client: Client, connect: Connect) -> None: if __name__ == "__main__": - run_client(scenario, connect=connect_from_args(__file__), needs_connect=True, mode="auto") + run_client(main) diff --git a/examples/stories/stickynotes/README.md b/examples/stories/stickynotes/README.md index 0835a4b16d..d444d48ef3 100644 --- a/examples/stories/stickynotes/README.md +++ b/examples/stories/stickynotes/README.md @@ -19,6 +19,11 @@ uv run python -m stories.stickynotes.client --http http://127.0.0.1:8000/mcp ## What to look at +- **`client.py` `main` → `Client(target, mode=mode, elicitation_callback=..., + message_handler=...)`** — the construction is the example: callbacks are + plain constructor kwargs, and `mode=` is explicit. The scripted elicitation + answer and the `list_changed` event are locals of `main`, so every + connection starts clean. - **`server.py` `lifespan` → `Board`** — long-lived mutable state belongs in the lifespan context, never a module global. Tools reach it via `ctx.request_context.lifespan_context`; this 2-hop path is interim and will @@ -41,10 +46,8 @@ uv run python -m stories.stickynotes.client --http http://127.0.0.1:8000/mcp - `list_changed` and `ctx.elicit()` are skipped on modern legs: the notification needs a standalone stream and `ctx.elicit()` would raise - `NoBackChannelError`. The scenario branches on + `NoBackChannelError`. `main` branches on `client.protocol_version in HANDSHAKE_PROTOCOL_VERSIONS`. -- `client_kw()` is callable so each test leg gets fresh callback state (the - scripted elicit answer and the `list_changed` event). ## Spec @@ -54,5 +57,5 @@ uv run python -m stories.stickynotes.client --http http://127.0.0.1:8000/mcp ## See also -`tools/`, `resources/`, `elicitation/`, `lifespan/`, `standalone_get/` +`tools/`, `resources/`, `legacy_elicitation/`, `lifespan/`, `standalone_get/` (`list_changed` over the GET stream). diff --git a/examples/stories/stickynotes/client.py b/examples/stories/stickynotes/client.py index b1210b31f2..288ab4caae 100644 --- a/examples/stories/stickynotes/client.py +++ b/examples/stories/stickynotes/client.py @@ -1,92 +1,81 @@ """Drive the sticky-notes board end to end and prove `remove_all` clears only on a confirmed elicitation.""" -from typing import Any - import anyio from mcp import types from mcp.client import Client, ClientRequestContext from mcp.shared.version import HANDSHAKE_PROTOCOL_VERSIONS -from stories._harness import connect_from_args, run_client - -# Shared state between the client callbacks (wired at connect time) and `scenario()`. -# Reset per leg by `client_kw()` so each (transport × era × variant) starts clean. -_elicit_answer: list[str] = ["cancel"] -_list_changed: list[anyio.Event] = [] - - -async def _on_elicit(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult: - if _elicit_answer[0] == "cancel": - return types.ElicitResult(action="cancel") - return types.ElicitResult(action="accept", content={"confirm": _elicit_answer[0] == "confirm"}) - - -async def _on_message(message: object) -> None: - if isinstance(message, types.ResourceListChangedNotification): - _list_changed[0].set() - - -def client_kw() -> dict[str, Any]: - _elicit_answer[:] = ["cancel"] - _list_changed[:] = [anyio.Event()] - return {"elicitation_callback": _on_elicit, "message_handler": _on_message} - - -async def scenario(client: Client) -> None: - legacy = client.protocol_version in HANDSHAKE_PROTOCOL_VERSIONS - - # Add two notes. - first = await client.call_tool("add_note", {"text": "Buy milk"}) - assert first.structured_content is not None - first_id, first_uri = first.structured_content["id"], first.structured_content["uri"] - assert first_uri.startswith("note:///") - second = await client.call_tool("add_note", {"text": "Walk the dog"}) - assert second.structured_content is not None - second_id, second_uri = second.structured_content["id"], second.structured_content["uri"] - assert first_id != second_id - - # List + read — both notes appear as resources; first reads back its text. - listed = await client.list_resources() - uris = {str(r.uri) for r in listed.resources} - assert first_uri in uris and second_uri in uris, uris - read = await client.read_resource(first_uri) - assert isinstance(read.contents[0], types.TextResourceContents) - assert read.contents[0].text == "Buy milk" - - # list_changed rides the standalone stream — only deliverable on a legacy-era connection. - if legacy: - with anyio.fail_after(5): - await _list_changed[0].wait() - - # Remove one. - removed = await client.call_tool("remove_note", {"note_id": first_id}) - assert removed.structured_content == {"result": True} - after = await client.list_resources() - assert first_uri not in {str(r.uri) for r in after.resources} - - # remove_all uses push-style elicitation: legacy-era only (modern equivalent lands with the mrtr/ story). - if not legacy: - gone = await client.call_tool("remove_note", {"note_id": second_id}) - assert gone.structured_content == {"result": True} - return - - _elicit_answer[0] = "cancel" - cancelled = await client.call_tool("remove_all", {}) - assert cancelled.structured_content == {"status": "cancelled", "removed": 0} - - _elicit_answer[0] = "unchecked" - declined = await client.call_tool("remove_all", {}) - assert declined.structured_content == {"status": "declined", "removed": 0} - - _elicit_answer[0] = "confirm" - cleared = await client.call_tool("remove_all", {}) - assert cleared.structured_content == {"status": "cleared", "removed": 1} - final = await client.list_resources() - assert not [r for r in final.resources if str(r.uri).startswith("note:///")] - - empty = await client.call_tool("remove_all", {}) - assert empty.structured_content == {"status": "empty", "removed": 0} +from stories._harness import Target, run_client + + +async def main(target: Target, *, mode: str = "auto") -> None: + # Scripted reply for the server's `remove_all` elicitation; rebound between calls below. + answer = "cancel" + list_changed = anyio.Event() + + async def on_elicit(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult: + if answer == "cancel": + return types.ElicitResult(action="cancel") + return types.ElicitResult(action="accept", content={"confirm": answer == "confirm"}) + + async def on_message(message: object) -> None: + if isinstance(message, types.ResourceListChangedNotification): + list_changed.set() + + async with Client(target, mode=mode, elicitation_callback=on_elicit, message_handler=on_message) as client: + legacy = client.protocol_version in HANDSHAKE_PROTOCOL_VERSIONS + + # Add two notes. + first = await client.call_tool("add_note", {"text": "Buy milk"}) + assert first.structured_content is not None + first_id, first_uri = first.structured_content["id"], first.structured_content["uri"] + assert first_uri.startswith("note:///") + second = await client.call_tool("add_note", {"text": "Walk the dog"}) + assert second.structured_content is not None + second_id, second_uri = second.structured_content["id"], second.structured_content["uri"] + assert first_id != second_id + + # List + read — both notes appear as resources; first reads back its text. + listed = await client.list_resources() + uris = {str(r.uri) for r in listed.resources} + assert first_uri in uris and second_uri in uris, uris + read = await client.read_resource(first_uri) + assert isinstance(read.contents[0], types.TextResourceContents) + assert read.contents[0].text == "Buy milk" + + # list_changed rides the standalone stream — only deliverable on a legacy-era connection. + if legacy: + with anyio.fail_after(5): + await list_changed.wait() + + # Remove one. + removed = await client.call_tool("remove_note", {"note_id": first_id}) + assert removed.structured_content == {"result": True} + after = await client.list_resources() + assert first_uri not in {str(r.uri) for r in after.resources} + + # remove_all uses push-style elicitation: legacy-era only (modern equivalent lands with the mrtr/ story). + if not legacy: + gone = await client.call_tool("remove_note", {"note_id": second_id}) + assert gone.structured_content == {"result": True} + return + + cancelled = await client.call_tool("remove_all", {}) + assert cancelled.structured_content == {"status": "cancelled", "removed": 0} + + answer = "unchecked" + declined = await client.call_tool("remove_all", {}) + assert declined.structured_content == {"status": "declined", "removed": 0} + + answer = "confirm" + cleared = await client.call_tool("remove_all", {}) + assert cleared.structured_content == {"status": "cleared", "removed": 1} + final = await client.list_resources() + assert not [r for r in final.resources if str(r.uri).startswith("note:///")] + + empty = await client.call_tool("remove_all", {}) + assert empty.structured_content == {"status": "empty", "removed": 0} if __name__ == "__main__": - run_client(scenario, connect=connect_from_args(__file__), **client_kw()) + run_client(main) diff --git a/examples/stories/stickynotes/server.py b/examples/stories/stickynotes/server.py index 05002f7a5d..4c6c9d0a7e 100644 --- a/examples/stories/stickynotes/server.py +++ b/examples/stories/stickynotes/server.py @@ -43,6 +43,14 @@ async def lifespan(_: MCPServer) -> AsyncIterator[Board]: mcp = MCPServer("stickynotes-example", lifespan=lifespan) + def unregister_note(note_id: str) -> None: + # DO NOT copy this line into your own server. `MCPServer` has no public + # `remove_resource()` yet (only `add_resource`), so unregistering a runtime-added + # resource has to reach a private attribute. `server_lowlevel.py` shows the clean + # shape: `on_list_resources` rebuilds the list from the board on every call, so + # removal never touches a registry at all. + mcp._resource_manager._resources.pop(f"note:///{note_id}", None) # pyright: ignore[reportPrivateUsage] + @mcp.tool() async def add_note(text: str, ctx: Context[Board]) -> AddResult: """Add a sticky note and register a `note:///{id}` resource for it.""" @@ -62,9 +70,7 @@ async def remove_note(note_id: str, ctx: Context[Board]) -> bool: board = ctx.request_context.lifespan_context removed = board.notes.pop(note_id, None) is not None if removed: - # MCPServer has no public remove_resource() yet — DO NOT copy this private - # reach; see server_lowlevel.py for the clean pattern (rebuild the list per call). - mcp._resource_manager._resources.pop(f"note:///{note_id}", None) # pyright: ignore[reportPrivateUsage] + unregister_note(note_id) await ctx.session.send_resource_list_changed() return removed @@ -81,7 +87,7 @@ async def remove_all(ctx: Context[Board]) -> ClearResult: return ClearResult(status="declined", removed=0) count = len(board.notes) for nid in list(board.notes): - mcp._resource_manager._resources.pop(f"note:///{nid}", None) # pyright: ignore[reportPrivateUsage] + unregister_note(nid) board.notes.clear() await ctx.session.send_resource_list_changed() return ClearResult(status="cleared", removed=count) diff --git a/examples/stories/streaming/README.md b/examples/stories/streaming/README.md index f5b5193ac0..5363cbde19 100644 --- a/examples/stories/streaming/README.md +++ b/examples/stories/streaming/README.md @@ -16,13 +16,22 @@ inside the progress callback (event-driven, no `sleep`). uv run python -m stories.streaming.client uv run python -m stories.streaming.client --server server_lowlevel -# against a running HTTP server +# against a running HTTP server (--legacy: see the note below) uv run python -m stories.streaming.server --http --port 8000 & -uv run python -m stories.streaming.client --http http://127.0.0.1:8000/mcp +uv run python -m stories.streaming.client --http http://127.0.0.1:8000/mcp --legacy ``` +The modern HTTP leg (drop `--legacy`) is `xfail` until the SSE wiring lands — +mid-call progress and log notifications are currently dropped there (see +Caveats). + ## What to look at +- `client.py` `main` — opens with `async with Client(target, mode=mode, + logging_callback=on_log)`. The story owns that construction; the harness only + picks the target and era. `logging_callback` is constructor-only on `Client` + (no setter after connect), so the callback and the `logs` list it fills are + closed over right above the `Client(...)` call. - `server.py` — `ctx.report_progress(i, steps, msg)` is a silent no-op when the caller passed no `progress_callback`; the SDK reads the token from the request's `_meta` for you. The log notification is sent via the raw @@ -44,9 +53,11 @@ uv run python -m stories.streaming.client --http http://127.0.0.1:8000/mcp ## Caveats -- **Logging is deprecated** as of 2026-07-28 (SEP-2577); migrate to stderr / - OpenTelemetry. It is shown here because servers still need to support - 2025-era clients during the deprecation window. +- **Logging is deprecated** in the 2026-07-28 protocol (SEP-2577); functional + through the deprecation window. Migration: write to stderr or emit + OpenTelemetry instead of `notifications/message`. It is shown here because + servers still need to support 2025-era clients during that window. Progress + and cancellation are **not** deprecated. TODO(maxisbey): revisit before beta. - On the modern (2026-07-28) streamable-HTTP path, mid-call progress and log notifications are currently dropped pending the SSE wiring; the `http-asgi:modern` leg of this story is `xfail` until that lands. @@ -54,8 +65,6 @@ uv run python -m stories.streaming.client --http http://127.0.0.1:8000/mcp `ErrorData(code=0, message="Request cancelled")`; the spec says it should not reply at all. The client never observes it (its awaiting task is already cancelled), so this story does not assert on the reply. -- `Client.logging_callback` is constructor-only (no setter), so the callback - and the list it fills are module-level; `scenario()` clears the list at start. ## Spec diff --git a/examples/stories/streaming/client.py b/examples/stories/streaming/client.py index 1e41ebf2e4..99398265ce 100644 --- a/examples/stories/streaming/client.py +++ b/examples/stories/streaming/client.py @@ -1,61 +1,54 @@ """Asserts progress + log notifications arrive in order, then cancels a call mid-flight.""" -from typing import Any - import anyio from mcp.client import Client from mcp.types import LoggingMessageNotificationParams -from stories._harness import connect_from_args, run_client - -# `logging_callback` is constructor-only on `Client`, so the callback and the -# list it fills must be module-level for `scenario()` to read them. Cleared per run. -_logs: list[LoggingMessageNotificationParams] = [] - - -async def _on_log(params: LoggingMessageNotificationParams) -> None: - _logs.append(params) - +from stories._harness import Target, run_client -client_kw: dict[str, Any] = {"logging_callback": _on_log} +async def main(target: Target, *, mode: str = "auto") -> None: + # `logging_callback` is constructor-only on `Client`, so the list it fills + # has to exist before the connection does. + logs: list[LoggingMessageNotificationParams] = [] -async def scenario(client: Client) -> None: - _logs.clear() + async def on_log(params: LoggingMessageNotificationParams) -> None: + logs.append(params) - # ── progress + logging: a short countdown delivers exactly `steps` of each, in order ── - updates: list[tuple[float, float | None, str | None]] = [] + async with Client(target, mode=mode, logging_callback=on_log) as client: + # ── progress + logging: a short countdown delivers exactly `steps` of each, in order ── + updates: list[tuple[float, float | None, str | None]] = [] - async def collect(progress: float, total: float | None, message: str | None) -> None: - updates.append((progress, total, message)) + async def collect(progress: float, total: float | None, message: str | None) -> None: + updates.append((progress, total, message)) - result = await client.call_tool("countdown", {"steps": 3}, progress_callback=collect) - assert result.structured_content == {"completed": 3, "total": 3}, result - assert updates == [(1.0, 3.0, "step 1/3"), (2.0, 3.0, "step 2/3"), (3.0, 3.0, "step 3/3")] - assert [(m.level, m.logger, m.data) for m in _logs] == [ - ("info", "countdown", "step 1/3"), - ("info", "countdown", "step 2/3"), - ("info", "countdown", "step 3/3"), - ] + result = await client.call_tool("countdown", {"steps": 3}, progress_callback=collect) + assert result.structured_content == {"completed": 3, "total": 3}, result + assert updates == [(1.0, 3.0, "step 1/3"), (2.0, 3.0, "step 2/3"), (3.0, 3.0, "step 3/3")] + assert [(m.level, m.logger, m.data) for m in logs] == [ + ("info", "countdown", "step 1/3"), + ("info", "countdown", "step 2/3"), + ("info", "countdown", "step 3/3"), + ] - # ── cancellation: abandon the awaiting scope once the call is provably in flight ── - in_flight = anyio.Event() - with anyio.fail_after(5): - with anyio.CancelScope() as scope: + # ── cancellation: abandon the awaiting scope once the call is provably in flight ── + in_flight = anyio.Event() + with anyio.fail_after(5): + with anyio.CancelScope() as scope: - async def cancel_once_in_flight(progress: float, total: float | None, message: str | None) -> None: - in_flight.set() - scope.cancel() + async def cancel_once_in_flight(progress: float, total: float | None, message: str | None) -> None: + in_flight.set() + scope.cancel() - await client.call_tool("countdown", {"steps": 1_000}, progress_callback=cancel_once_in_flight) + await client.call_tool("countdown", {"steps": 1_000}, progress_callback=cancel_once_in_flight) - assert in_flight.is_set(), "the call must have started before it was cancelled" - assert scope.cancelled_caught, "abandoning the scope should have cancelled the in-flight call" + assert in_flight.is_set(), "the call must have started before it was cancelled" + assert scope.cancelled_caught, "abandoning the scope should have cancelled the in-flight call" - # The session survives cancellation: a follow-up call still works. - after = await client.call_tool("countdown", {"steps": 1}, progress_callback=collect) - assert after.structured_content == {"completed": 1, "total": 1} + # The session survives cancellation: a follow-up call still works. + after = await client.call_tool("countdown", {"steps": 1}, progress_callback=collect) + assert after.structured_content == {"completed": 1, "total": 1} if __name__ == "__main__": - run_client(scenario, connect=connect_from_args(__file__), **client_kw) + run_client(main) diff --git a/examples/stories/tools/client.py b/examples/stories/tools/client.py index 0ebfde1800..55c22e3b64 100644 --- a/examples/stories/tools/client.py +++ b/examples/stories/tools/client.py @@ -2,29 +2,30 @@ from mcp.client import Client from mcp.types import TextContent -from stories._harness import connect_from_args, run_client +from stories._harness import Target, run_client -async def scenario(client: Client) -> None: - listed = await client.list_tools() - by_name = {t.name: t for t in listed.tools} - assert set(by_name) == {"calc", "echo"} +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + listed = await client.list_tools() + by_name = {t.name: t for t in listed.tools} + assert set(by_name) == {"calc", "echo"} - calc = by_name["calc"] - assert calc.annotations is not None and calc.annotations.read_only_hint is True - assert calc.annotations.idempotent_hint is True - assert calc.output_schema is not None - assert set(calc.input_schema.get("required", ())) >= {"op", "a", "b"} - assert by_name["echo"].output_schema is None + calc = by_name["calc"] + assert calc.annotations is not None and calc.annotations.read_only_hint is True + assert calc.annotations.idempotent_hint is True + assert calc.output_schema is not None + assert set(calc.input_schema.get("required", ())) >= {"op", "a", "b"} + assert by_name["echo"].output_schema is None - result = await client.call_tool("calc", {"op": "add", "a": 2, "b": 3}) - assert not result.is_error - assert result.structured_content == {"op": "add", "result": 5.0}, result + result = await client.call_tool("calc", {"op": "add", "a": 2, "b": 3}) + assert not result.is_error + assert result.structured_content == {"op": "add", "result": 5.0}, result - echoed = await client.call_tool("echo", {"text": "hi"}) - assert echoed.structured_content is None - assert isinstance(echoed.content[0], TextContent) and echoed.content[0].text == "hi" + echoed = await client.call_tool("echo", {"text": "hi"}) + assert echoed.structured_content is None + assert isinstance(echoed.content[0], TextContent) and echoed.content[0].text == "hi" if __name__ == "__main__": - run_client(scenario, connect=connect_from_args(__file__)) + run_client(main) diff --git a/tests/examples/conftest.py b/tests/examples/conftest.py index 0374a31549..9e18661399 100644 --- a/tests/examples/conftest.py +++ b/tests/examples/conftest.py @@ -14,8 +14,7 @@ import importlib import sys -from collections.abc import AsyncIterator, Callable -from contextlib import asynccontextmanager +from collections.abc import AsyncIterator from dataclasses import dataclass from pathlib import Path from typing import Any @@ -24,10 +23,9 @@ import pytest import stories from starlette.applications import Starlette -from stories._harness import AuthBuilder, Connect +from stories._harness import AuthBuilder, TargetFactory from stories._hosting import asgi_from -from mcp.client import Client from mcp.client.streamable_http import streamable_http_client from mcp.shared.version import LATEST_MODERN_VERSION from tests.interaction.transports._bridge import StreamingASGITransport @@ -44,9 +42,10 @@ DEFAULTS: dict[str, Any] = MANIFEST["defaults"] STORIES: dict[str, dict[str, Any]] = MANIFEST["story"] -_ERA_TO_MODE = {"modern": LATEST_MODERN_VERSION, "legacy": "legacy"} -"""R8 maps era→mode. ``Client`` rejects handshake-era version strings, so -``legacy`` resolves to ``mode='legacy'`` rather than ``LATEST_HANDSHAKE_VERSION``.""" +_ERA_TO_MODE = {"modern": LATEST_MODERN_VERSION, "legacy": "legacy", "in-body": "auto"} +"""``Client`` rejects handshake-era version strings, so ``legacy`` resolves to +``mode='legacy'`` rather than ``LATEST_HANDSHAKE_VERSION``. ``in-body`` legs pin +their connection modes inside ``main`` themselves, so they get the real-user default.""" def story_cfg(name: str) -> dict[str, Any]: @@ -72,6 +71,11 @@ class Leg: def id(self) -> str: return "-".join((self.story, self.server_variant, self.transport, self.era)) + @property + def mode(self) -> str: + """The explicit ``mode=`` this leg passes to the story's ``main``.""" + return _ERA_TO_MODE[self.era] + def _legs() -> list[tuple[Leg, dict[str, Any]]]: out: list[tuple[Leg, dict[str, Any]]] = [] @@ -114,29 +118,17 @@ def client_module(leg: Leg) -> Any: return importlib.import_module(f"stories.{leg.story}.client") -def _client_kw(client_module: Any) -> dict[str, Any]: - raw: dict[str, Any] | Callable[[], dict[str, Any]] | None = getattr(client_module, "client_kw", None) - if raw is None: - return {} - return raw() if callable(raw) else dict(raw) - - -def _http_client_kw(client_module: Any) -> dict[str, Any]: - """``httpx.AsyncClient(...)`` kwargs the story's client module wants threaded through.""" - return dict(getattr(client_module, "http_client_kw", None) or {}) - - @dataclass class Hosted: """One server/app instance hosted for the leg's whole duration. - ``connect`` opens a fresh ``Client`` against that single instance on every - call, so state observed by one client is visible to the next. ``http`` is - the shared raw ``httpx.AsyncClient`` bound to the same ASGI app, or ``None`` - on the in-memory leg. + ``targets`` yields a fresh connection target against that single instance on + every call, so state observed by one connection is visible to the next. + ``http`` is the shared raw ``httpx.AsyncClient`` bound to the same ASGI app, + or ``None`` on the in-memory leg. """ - connect: Connect + targets: TargetFactory http: httpx.AsyncClient | None @@ -146,27 +138,17 @@ async def hosted( ) -> AsyncIterator[Hosted]: """Build the leg's server/app once and keep it running for the test. - The leg's era pre-seeds ``mode=``; a scenario may override it per-call (R6 - ``dual-in-body`` opens both eras from the same body). Auth stories thread an - ``httpx.Auth`` onto the bridge client via a module-level ``build_auth(http)`` - export and/or extra ``httpx.AsyncClient`` kwargs via ``http_client_kw``. + The story's ``main`` owns the ``Client(target, mode=...)`` construction; this + fixture only decides what ``target`` is. Auth stories thread an ``httpx.Auth`` + onto the bridge client via a module-level ``build_auth(http)`` export. """ for key, value in cfg["env"].items(): monkeypatch.setenv(key, value) - mode = _ERA_TO_MODE.get(leg.era, "auto") path = cfg["mcp_path"] if leg.transport == "in-memory": server = server_module.build_server() - - @asynccontextmanager - async def _connect(*, auth: AuthBuilder | None = None, **kw: Any) -> AsyncIterator[Client]: - assert auth is None, "auth= is HTTP-only; restrict the story to transports=['http-asgi']" - kw.setdefault("mode", mode) - async with Client(server, **kw) as client: - yield client - - yield Hosted(_connect, None) + yield Hosted(lambda: server, None) return # http-asgi: one Starlette app per leg. ``server_export="app"`` stories hand us the @@ -177,32 +159,11 @@ async def _connect(*, auth: AuthBuilder | None = None, **kw: Any) -> AsyncIterat app: Starlette = server_module.build_app() else: app = asgi_from(server_module.build_server(), path=path) - http_kw = _http_client_kw(client_module) build_auth: AuthBuilder | None = getattr(client_module, "build_auth", None) async with ( app.router.lifespan_context(app), - httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL, **http_kw) as http_client, + httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http_client, ): if build_auth is not None: http_client.auth = build_auth(http_client) - - @asynccontextmanager - async def _connect(*, auth: AuthBuilder | None = None, **kw: Any) -> AsyncIterator[Client]: - kw.setdefault("mode", mode) - if auth is None: - async with Client(streamable_http_client(f"{BASE_URL}{path}", http_client=http_client), **kw) as client: - yield client - return - # A scenario-supplied ``auth`` gets a fresh httpx client against the same app so - # the second connection's auth flow doesn't share request-level state with the first. - async with httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL, **http_kw) as fresh: - fresh.auth = auth(fresh) - async with Client(streamable_http_client(f"{BASE_URL}{path}", http_client=fresh), **kw) as client: - yield client - - yield Hosted(_connect, http_client) - - -@pytest.fixture -def scenario_kw(client_module: Any) -> dict[str, Any]: - return _client_kw(client_module) + yield Hosted(lambda: streamable_http_client(f"{BASE_URL}{path}", http_client=http_client), http_client) diff --git a/tests/examples/test_stories.py b/tests/examples/test_stories.py index de1c95dfc5..f56106a797 100644 --- a/tests/examples/test_stories.py +++ b/tests/examples/test_stories.py @@ -1,4 +1,4 @@ -"""Run every story's ``scenario`` over the in-process (transport × era × variant) matrix.""" +"""Run every story's ``main`` over the in-process (transport × era × variant) matrix.""" from __future__ import annotations @@ -14,22 +14,15 @@ pytestmark = pytest.mark.anyio -async def test_story( - leg: Leg, - cfg: dict[str, Any], - hosted: Hosted, - client_module: Any, - scenario_kw: dict[str, Any], -) -> None: - scenario = client_module.scenario +async def test_story(leg: Leg, cfg: dict[str, Any], hosted: Hosted, client_module: Any) -> None: + kwargs: dict[str, Any] = {"mode": leg.mode} + if cfg["needs_http"]: + kwargs["http"] = hosted.http with anyio.fail_after(cfg["timeout_s"]): - async with hosted.connect(**scenario_kw) as client: - args: list[Any] = [client] - if cfg["needs_connect"] or leg.era == "in-body": - args.append(hosted.connect) - if cfg["needs_http"]: - args.append(hosted.http) - await scenario(*args) + if cfg["multi_connection"]: + await client_module.main(hosted.targets, **kwargs) + else: + await client_module.main(hosted.targets(), **kwargs) def test_manifest_matches_filesystem() -> None: @@ -56,7 +49,7 @@ def test_manifest_schema_valid() -> None: """Declared manifest values are mutually consistent with the story files.""" for name in STORIES: cfg = story_cfg(name) - assert "-" not in name, f"{name!r}: story directories must be underscored (R3)" + assert "-" not in name, f"{name!r}: story directories must be underscored" assert cfg["era"] in _ERAS, f"{name!r}: era={cfg['era']!r} not in {_ERAS}" assert cfg["server_export"] in _SERVER_EXPORTS, f"{name!r}: server_export={cfg['server_export']!r}" assert set(cfg["transports"]) <= _TRANSPORTS, f"{name!r}: transports={cfg['transports']!r}" @@ -71,12 +64,11 @@ def test_manifest_schema_valid() -> None: assert cfg["lowlevel"] == ll.exists(), f"{name!r}: lowlevel={cfg['lowlevel']} vs server_lowlevel.py on disk" -def test_scenario_arity_matches_manifest() -> None: - """``scenario`` parameter count agrees with ``needs_connect``/``dual-in-body``/``needs_http``.""" - for name in STORIES: - cfg = story_cfg(name) - wants_connect = cfg["needs_connect"] or cfg["era"] == "dual-in-body" - expected = 1 + int(wants_connect) + int(cfg["needs_http"]) - mod = importlib.import_module(f"stories.{name}.client") - arity = len(inspect.signature(mod.scenario).parameters) - assert arity == expected, f"{name}: arity={arity} expected={expected}" +@pytest.mark.parametrize("name", sorted(STORIES)) +def test_main_signature_matches_manifest(name: str) -> None: + """``main``'s first parameter is ``target``/``targets`` per ``multi_connection``; ``http`` iff ``needs_http``.""" + cfg = story_cfg(name) + params = list(inspect.signature(importlib.import_module(f"stories.{name}.client").main).parameters) + first = "targets" if cfg["multi_connection"] else "target" + assert params[0] == first, f"{name}: first param is {params[0]!r}, expected {first!r}" + assert ("http" in params) == cfg["needs_http"], f"{name}: 'http' param vs needs_http={cfg['needs_http']}" diff --git a/tests/examples/test_stories_smoke.py b/tests/examples/test_stories_smoke.py index aa06cdc1d9..ecd6f48c87 100644 --- a/tests/examples/test_stories_smoke.py +++ b/tests/examples/test_stories_smoke.py @@ -45,7 +45,14 @@ def _free_port() -> int: # pragma: lax no cover async def _wait_listening(port: int) -> None: # pragma: lax no cover - """Poll ``127.0.0.1:port`` until it accepts; condition-based, not a fixed-duration wait.""" + """Connect-retry until ``127.0.0.1:port`` accepts. + + Deliberate exception to the no-``sleep`` rule: readiness lives in a uvicorn + *subprocess*, so there is no in-process ``anyio.Event`` to await — accepting a + TCP connect IS the readiness signal. Both callers bound this with + ``anyio.fail_after``, and the retry interval only paces the probe; it never + decides when the wait ends. + """ while True: try: stream = await anyio.connect_tcp("127.0.0.1", port) From 137f51cfb7b73f8c64b73b8b08e32f8ca4915b46 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:36:20 +0000 Subject: [PATCH 09/14] 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 "-example". - mrtr/ and subscriptions/ stubs note the lowlevel registration surface now exists upstream and they graduate once this branch's base has it. --- examples/stories/README.md | 39 ++++++ examples/stories/bearer_auth/client.py | 2 +- examples/stories/bearer_auth/server.py | 2 +- examples/stories/custom_methods/server.py | 2 +- examples/stories/dual_era/client.py | 2 +- examples/stories/json_response/client.py | 2 +- examples/stories/json_response/server.py | 2 +- examples/stories/legacy_elicitation/README.md | 2 + examples/stories/legacy_elicitation/server.py | 2 +- .../legacy_elicitation/server_lowlevel.py | 2 +- examples/stories/legacy_routing/client.py | 2 +- examples/stories/legacy_routing/server.py | 2 +- examples/stories/mrtr/README.md | 12 +- examples/stories/oauth/client.py | 2 +- examples/stories/oauth/server.py | 2 +- .../oauth_client_credentials/client.py | 2 +- .../oauth_client_credentials/server.py | 2 +- examples/stories/parallel_calls/client.py | 2 +- examples/stories/reconnect/client.py | 2 +- examples/stories/sse_polling/client.py | 2 +- examples/stories/sse_polling/server.py | 2 +- examples/stories/starlette_mount/client.py | 2 +- examples/stories/starlette_mount/server.py | 2 +- examples/stories/stateless_legacy/client.py | 2 +- examples/stories/stateless_legacy/server.py | 2 +- examples/stories/subscriptions/README.md | 8 +- tests/examples/test_story_shape.py | 122 ++++++++++++++++++ 27 files changed, 199 insertions(+), 28 deletions(-) create mode 100644 tests/examples/test_story_shape.py diff --git a/examples/stories/README.md b/examples/stories/README.md index 330f3fdbf7..a762ec147e 100644 --- a/examples/stories/README.md +++ b/examples/stories/README.md @@ -6,6 +6,45 @@ One feature per folder. Each story is a small, self-verifying program: a 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 diff --git a/examples/stories/bearer_auth/client.py b/examples/stories/bearer_auth/client.py index d1143ec22e..5c419a0716 100644 --- a/examples/stories/bearer_auth/client.py +++ b/examples/stories/bearer_auth/client.py @@ -1,4 +1,4 @@ -"""Call the bearer-gated server through an already-authed transport; assert the ``whoami`` principal.""" +"""Call the bearer-gated server through an already-authed (``build_auth``, HTTP-only) transport; assert ``whoami``.""" from collections.abc import Generator diff --git a/examples/stories/bearer_auth/server.py b/examples/stories/bearer_auth/server.py index daeb37b97e..45c9872c3a 100644 --- a/examples/stories/bearer_auth/server.py +++ b/examples/stories/bearer_auth/server.py @@ -1,4 +1,4 @@ -"""Resource-server-only bearer auth: ``TokenVerifier`` + ``AuthSettings`` → 401/PRM/principal.""" +"""Resource-server-only bearer auth: ``TokenVerifier``/``AuthSettings`` → 401/PRM/principal. Exports ``build_app()``.""" import time diff --git a/examples/stories/custom_methods/server.py b/examples/stories/custom_methods/server.py index 3184725027..a08e285ae5 100644 --- a/examples/stories/custom_methods/server.py +++ b/examples/stories/custom_methods/server.py @@ -24,7 +24,7 @@ class SearchResult(types.Result): def build_server() -> Server[Any]: - server = Server("acme-search") + server = Server("custom-methods-example") async def search(ctx: ServerRequestContext[Any], params: SearchParams) -> SearchResult: items = [f"{params.query}-{i}" for i in range(params.limit)] diff --git a/examples/stories/dual_era/client.py b/examples/stories/dual_era/client.py index a65b8b4914..ef2e8235a6 100644 --- a/examples/stories/dual_era/client.py +++ b/examples/stories/dual_era/client.py @@ -1,4 +1,4 @@ -"""Connect to the same server factory twice — once per era — and assert both are served.""" +"""Connect to the same server factory twice — once per era, so `main` takes `targets` — and assert both are served.""" from mcp import types from mcp.client import Client diff --git a/examples/stories/json_response/client.py b/examples/stories/json_response/client.py index 4ac0a2b45f..db271b0cab 100644 --- a/examples/stories/json_response/client.py +++ b/examples/stories/json_response/client.py @@ -1,4 +1,4 @@ -"""Regular ``Client`` against a JSON-only server; assert mid-call progress is dropped. +"""Plain ``Client`` against a JSON-only server: mid-call progress drops. HTTP-only — ``main`` also takes ``http``. ``RAW_ENVELOPE_BODY`` / ``MODERN_HEADERS`` are the exact wire shape a 2026-era client sends — this is the only story that shows it. ``main`` posts that body by hand and diff --git a/examples/stories/json_response/server.py b/examples/stories/json_response/server.py index 9ff767462f..c09aca78f3 100644 --- a/examples/stories/json_response/server.py +++ b/examples/stories/json_response/server.py @@ -1,4 +1,4 @@ -"""Serve over Streamable HTTP with JSON responses (no SSE stream). +"""Serve over Streamable HTTP with JSON responses (no SSE stream); HTTP-only, so this exports ``build_app()``. The 2026-07-28 path is stateless and JSON-only by construction today; the ``json_response=True`` flag also forces JSON for the legacy (2025-era) branch on diff --git a/examples/stories/legacy_elicitation/README.md b/examples/stories/legacy_elicitation/README.md index 540870b117..55c90b2fd2 100644 --- a/examples/stories/legacy_elicitation/README.md +++ b/examples/stories/legacy_elicitation/README.md @@ -6,6 +6,8 @@ > [`mrtr/`](../mrtr/) story. Elicitation itself is **not** deprecated. > TODO(maxisbey): unify once the MRTR runtime lands > ([#2898](https://github.com/modelcontextprotocol/python-sdk/issues/2898)). +> The TypeScript SDK ships a single dual-era `elicitation/` story; this +> directory re-merges back into `elicitation/` once MRTR lands. A tool pauses mid-call to ask the user for structured input. On the handshake-era protocol the server pushes an `elicitation/create` *request* to diff --git a/examples/stories/legacy_elicitation/server.py b/examples/stories/legacy_elicitation/server.py index a5be613bed..4999c545e0 100644 --- a/examples/stories/legacy_elicitation/server.py +++ b/examples/stories/legacy_elicitation/server.py @@ -13,7 +13,7 @@ class Registration(BaseModel): def build_server() -> MCPServer: - mcp = MCPServer("elicitation-example") + mcp = MCPServer("legacy-elicitation-example") @mcp.tool(description="Register a new account by asking the user for their details.") async def register_user(ctx: Context) -> str: diff --git a/examples/stories/legacy_elicitation/server_lowlevel.py b/examples/stories/legacy_elicitation/server_lowlevel.py index 82722a2b17..8dd81ec15b 100644 --- a/examples/stories/legacy_elicitation/server_lowlevel.py +++ b/examples/stories/legacy_elicitation/server_lowlevel.py @@ -58,7 +58,7 @@ async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolReques await ctx.session.send_elicit_complete(elicitation_id, related_request_id=ctx.request_id) return types.CallToolResult(content=[types.TextContent(text=f"linked {provider}")]) - return Server("elicitation-example", on_list_tools=list_tools, on_call_tool=call_tool) + return Server("legacy-elicitation-example", on_list_tools=list_tools, on_call_tool=call_tool) if __name__ == "__main__": diff --git a/examples/stories/legacy_routing/client.py b/examples/stories/legacy_routing/client.py index 73e2e9d01e..d41856234c 100644 --- a/examples/stories/legacy_routing/client.py +++ b/examples/stories/legacy_routing/client.py @@ -1,4 +1,4 @@ -"""Connect at both eras to one app; assert the built-in router and the predicate agree.""" +"""Connect at both eras to one app — so `main` takes `targets` — and assert the built-in router and predicate agree.""" from typing import Any diff --git a/examples/stories/legacy_routing/server.py b/examples/stories/legacy_routing/server.py index d7bf3bb555..6b295e1a1d 100644 --- a/examples/stories/legacy_routing/server.py +++ b/examples/stories/legacy_routing/server.py @@ -1,4 +1,4 @@ -"""Exported era classifier: the body-primary predicate, the built-in dual-era app, and CORS.""" +"""Exported era classifier: the body-primary predicate, the built-in dual-era app, and CORS — exports `build_app()`.""" from collections.abc import Mapping from typing import Any, Literal diff --git a/examples/stories/mrtr/README.md b/examples/stories/mrtr/README.md index 90179cf953..5cd4429dd1 100644 --- a/examples/stories/mrtr/README.md +++ b/examples/stories/mrtr/README.md @@ -7,9 +7,11 @@ the server resumes from the carried state. The story will show both the auto-fulfil helper and a manual resubmit loop. **Status: not yet implemented** ([#2898](https://github.com/modelcontextprotocol/python-sdk/issues/2898)). -The `InputRequiredResult` types exist, but `Client.call_tool` still validates -the response as a plain `CallToolResult` and rejects `input_required`. There is -no runnable round-trip until the runtime lands. +The lowlevel registration surface exists on `main` as of +[#2967](https://github.com/modelcontextprotocol/python-sdk/pull/2967) +(`ae13ede`), which widened the tool/prompt/resource handler return types to +include `InputRequiredResult`. This story graduates from a README stub to a +runnable example once this branch's base includes that commit. ## Spec @@ -23,4 +25,6 @@ The TypeScript SDK ships a runnable `mrtr` story: ## See also `legacy_elicitation/` and `sampling/` — the handshake-era push equivalents that -this mechanism replaces on the 2026 protocol. +this mechanism replaces on the 2026 protocol. The TypeScript SDK ships a single +dual-era `elicitation/` story covering both eras in one place; we re-merge +`legacy_elicitation/` back into `elicitation/` once MRTR lands. diff --git a/examples/stories/oauth/client.py b/examples/stories/oauth/client.py index 5ddd3df480..c55307f633 100644 --- a/examples/stories/oauth/client.py +++ b/examples/stories/oauth/client.py @@ -1,4 +1,4 @@ -"""OAuth authorization-code flow: 401 → PRM → AS metadata → DCR → PKCE authorize → token → retry.""" +"""HTTP-only OAuth authorization-code flow; `build_auth` supplies the provider, reconnecting needs `targets`.""" import httpx from pydantic import AnyUrl diff --git a/examples/stories/oauth/server.py b/examples/stories/oauth/server.py index 855cb5f951..6d4c706b00 100644 --- a/examples/stories/oauth/server.py +++ b/examples/stories/oauth/server.py @@ -1,4 +1,4 @@ -"""OAuth-protected MCP server: in-process AS + PRM + bearer-gated /mcp on one Starlette app.""" +"""OAuth-protected MCP server: in-process AS + PRM + bearer-gated /mcp on one Starlette app — exports `build_app()`.""" from pydantic import BaseModel from starlette.applications import Starlette diff --git a/examples/stories/oauth_client_credentials/client.py b/examples/stories/oauth_client_credentials/client.py index 9d972a464a..318523ee70 100644 --- a/examples/stories/oauth_client_credentials/client.py +++ b/examples/stories/oauth_client_credentials/client.py @@ -1,4 +1,4 @@ -"""Connect with ``ClientCredentialsOAuthProvider``; assert ``whoami`` round-trips client_id + scopes.""" +"""HTTP-only: ``build_auth`` returns a ``ClientCredentialsOAuthProvider``; ``whoami`` round-trips client_id + scopes.""" import httpx diff --git a/examples/stories/oauth_client_credentials/server.py b/examples/stories/oauth_client_credentials/server.py index 96be7e09a8..7e3d910e8f 100644 --- a/examples/stories/oauth_client_credentials/server.py +++ b/examples/stories/oauth_client_credentials/server.py @@ -1,4 +1,4 @@ -"""Bearer-gated MCP resource server + a minimal in-process ``client_credentials`` AS, one app.""" +"""Bearer-gated resource server + a minimal in-process ``client_credentials`` AS, one app; exports ``build_app()``.""" import base64 import secrets diff --git a/examples/stories/parallel_calls/client.py b/examples/stories/parallel_calls/client.py index 9d512a3c67..c940053dc8 100644 --- a/examples/stories/parallel_calls/client.py +++ b/examples/stories/parallel_calls/client.py @@ -1,4 +1,4 @@ -"""Two concurrent `Client`s against one server; the rendezvous tool proves concurrent dispatch.""" +"""Two concurrent `Client`s, so `main` takes `targets`; their rendezvous in one tool proves concurrent dispatch.""" import anyio diff --git a/examples/stories/reconnect/client.py b/examples/stories/reconnect/client.py index 3e163b17d0..67401aa732 100644 --- a/examples/stories/reconnect/client.py +++ b/examples/stories/reconnect/client.py @@ -1,4 +1,4 @@ -"""Probe server/discover once, persist the DiscoverResult, then reconnect with zero round-trips.""" +"""Probe server/discover once, persist the result, reconnect with zero round-trips — a fresh `Client` via `targets`.""" from mcp.client import Client from mcp.shared.version import LATEST_MODERN_VERSION diff --git a/examples/stories/sse_polling/client.py b/examples/stories/sse_polling/client.py index b77bf6ac83..39cec5dc93 100644 --- a/examples/stories/sse_polling/client.py +++ b/examples/stories/sse_polling/client.py @@ -1,4 +1,4 @@ -"""Call a tool whose SSE stream the server closes mid-flight; assert the call still completes.""" +"""Call a tool whose SSE stream the server closes mid-flight; the call still completes. HTTP-only — no SSE on stdio.""" import anyio diff --git a/examples/stories/sse_polling/server.py b/examples/stories/sse_polling/server.py index ff186f0e5a..1098ca6d56 100644 --- a/examples/stories/sse_polling/server.py +++ b/examples/stories/sse_polling/server.py @@ -1,4 +1,4 @@ -"""SEP-1699: a tool that closes its own SSE stream mid-call; the event store buffers the rest.""" +"""SEP-1699: a tool closes its own SSE stream mid-call; the event store buffers the rest. Exports `build_app()`.""" from starlette.applications import Starlette diff --git a/examples/stories/starlette_mount/client.py b/examples/stories/starlette_mount/client.py index 3c7233b6ab..c286577354 100644 --- a/examples/stories/starlette_mount/client.py +++ b/examples/stories/starlette_mount/client.py @@ -1,4 +1,4 @@ -"""Connect to the sub-mounted MCP endpoint at /api/, list tools and call greet.""" +"""Connect to the sub-mounted MCP endpoint at /api/, list tools and call greet. HTTP-only: the mount is the story.""" from mcp.client import Client from mcp.types import TextContent diff --git a/examples/stories/starlette_mount/server.py b/examples/stories/starlette_mount/server.py index 482de2c22b..858abc9203 100644 --- a/examples/stories/starlette_mount/server.py +++ b/examples/stories/starlette_mount/server.py @@ -1,4 +1,4 @@ -"""Mount an MCPServer inside an existing Starlette app at a sub-path, alongside non-MCP routes.""" +"""Mount an MCPServer in an existing Starlette app at a sub-path, alongside non-MCP routes; exports `build_app()`.""" import contextlib from collections.abc import AsyncIterator diff --git a/examples/stories/stateless_legacy/client.py b/examples/stories/stateless_legacy/client.py index 7c6e85c053..72d8ea4596 100644 --- a/examples/stories/stateless_legacy/client.py +++ b/examples/stories/stateless_legacy/client.py @@ -1,4 +1,4 @@ -"""Connect at each era; the same stateless app answers both with the same result.""" +"""Connect at each era — two connections, so `main` takes `targets`; the same stateless app answers both.""" from mcp.client import Client from mcp.shared.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION diff --git a/examples/stories/stateless_legacy/server.py b/examples/stories/stateless_legacy/server.py index d12a183d64..40c82ad34f 100644 --- a/examples/stories/stateless_legacy/server.py +++ b/examples/stories/stateless_legacy/server.py @@ -1,4 +1,4 @@ -"""The one-liner HTTP deploy: one stateless ASGI app serves both protocol eras.""" +"""The one-liner HTTP deploy: one stateless ASGI app serves both protocol eras, so it exports `build_app()`.""" from starlette.applications import Starlette diff --git a/examples/stories/subscriptions/README.md b/examples/stories/subscriptions/README.md index 8ea974bcec..a51bae979b 100644 --- a/examples/stories/subscriptions/README.md +++ b/examples/stories/subscriptions/README.md @@ -6,8 +6,12 @@ them. Replaces the handshake-era `resources/subscribe` + standalone-GET notification path. **Status: not yet implemented** ([#2901](https://github.com/modelcontextprotocol/python-sdk/issues/2901)). -Types exist; there is no `Client.listen()`, no `ServerEventBus`, and no -entry-handled `subscriptions/listen` route yet. +The lowlevel registration surface exists on `main` as of +[#2967](https://github.com/modelcontextprotocol/python-sdk/pull/2967) +(`ae13ede`), which added the lowlevel `on_subscriptions_listen` handler slot. +There is no `Client.listen()` or `ServerEventBus` yet; this story graduates +from a README stub to a runnable example once this branch's base includes that +commit. ## Spec diff --git a/tests/examples/test_story_shape.py b/tests/examples/test_story_shape.py new file mode 100644 index 0000000000..d5510923c9 --- /dev/null +++ b/tests/examples/test_story_shape.py @@ -0,0 +1,122 @@ +"""AST shape-check: stories keep the SDK construction visible and the harness contained. + +The python analogue of typescript-sdk's eslint import-allowlist over its examples, +strictly stronger: it also asserts each ``main`` constructs ``Client(...)`` itself — +the regression the harness inversion exists to prevent. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +from tests.examples.conftest import STORIES, STORIES_DIR, story_cfg + +_HARNESS_ALLOWLIST = frozenset({"run_client", "target_from_args", "Target", "TargetFactory"}) +"""The only ``stories._harness`` names a ``client.py`` may use. ``AuthBuilder`` is +additionally allowed in a ``client.py`` that defines ``build_auth`` (the auth seam +``run_client`` and the conftest both look up by name).""" + +_MCPSERVER_TIER = ("mcp.server.mcpserver", "mcp.server.MCPServer") +"""Both spellings of the high-level tier: the ``mcpserver`` module and its ``mcp.server`` re-export.""" + +_LOWLEVEL_STORIES = [name for name in sorted(STORIES) if story_cfg(name)["lowlevel"]] + + +def _parse(path: Path) -> ast.Module: + """Parse ``path`` into an AST module.""" + return ast.parse(path.read_text(), filename=str(path)) + + +def _resolve(node: ast.ImportFrom, package: str) -> str: + """The absolute module path ``node`` imports from, resolving a relative import against ``package``.""" + parents = package.split(".")[: -(node.level - 1) or None] if node.level else [] + return ".".join([*parents, *([node.module] if node.module else [])]) + + +def _module_paths(tree: ast.Module, package: str) -> set[str]: + """Every dotted module path the file (a module in ``package``) references — imports, with relative + ones resolved to absolute, plus attribute chains rooted at an import-bound name (``import mcp.shared`` + + ``mcp.shared._memory.f()``), so a reach-in is caught however it is spelled.""" + paths: set[str] = set() + bound: dict[str, str] = {} + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + paths.add(alias.name) + local = alias.asname or alias.name.partition(".")[0] + bound[local] = alias.name if alias.asname else local + elif isinstance(node, ast.ImportFrom): + module = _resolve(node, package) + for alias in node.names: + paths.add(f"{module}.{alias.name}") + bound[alias.asname or alias.name] = f"{module}.{alias.name}" + for node in ast.walk(tree): + attrs: list[str] = [] + expr: ast.AST = node + while isinstance(expr, ast.Attribute): + attrs.append(expr.attr) + expr = expr.value + if attrs and isinstance(expr, ast.Name) and expr.id in bound: + paths.add(".".join([bound[expr.id], *reversed(attrs)])) + return paths + + +def _is_private_mcp(path: str) -> bool: + """True when ``path`` crosses a ``_``-private segment inside the ``mcp`` package.""" + head, *rest = path.split(".") + return head == "mcp" and any(part.startswith("_") for part in rest) + + +def _is_story_module(path: str) -> bool: + """True for ``stories....`` — a story package, not a ``stories._*`` scaffold.""" + head, _, rest = path.partition(".") + return head == "stories" and bool(rest) and not rest.startswith("_") + + +@pytest.mark.parametrize("name", sorted(STORIES)) +def test_main_constructs_client_inline(name: str) -> None: + """``main``'s body contains a literal ``Client(...)`` call; the construction is never hidden in a helper.""" + tree = _parse(STORIES_DIR / name / "client.py") + mains = [n for n in tree.body if isinstance(n, ast.AsyncFunctionDef) and n.name == "main"] + assert mains, f"{name}/client.py defines no top-level async `main`" + calls = {n.func.id for n in ast.walk(mains[0]) if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)} + assert "Client" in calls, f"{name}/client.py: main() never calls Client(...) itself" + + +@pytest.mark.parametrize("name", sorted(STORIES)) +def test_client_harness_imports_within_allowlist(name: str) -> None: + """``client.py`` takes nothing from ``stories._harness`` beyond the allowlist, bounding the harness surface.""" + tree = _parse(STORIES_DIR / name / "client.py") + defines_build_auth = any(isinstance(n, ast.FunctionDef) and n.name == "build_auth" for n in tree.body) + allowed = _HARNESS_ALLOWLIST | {"AuthBuilder"} if defines_build_auth else _HARNESS_ALLOWLIST + paths = _module_paths(tree, package=f"stories.{name}") + used = {p.removeprefix("stories._harness.").partition(".")[0] for p in paths if p.startswith("stories._harness.")} + assert used <= allowed, f"{name}/client.py uses {sorted(used - allowed)} from stories._harness" + + +@pytest.mark.parametrize("name", sorted(STORIES)) +def test_story_files_import_no_private_mcp_module(name: str) -> None: + """No file in a story directory references a ``_``-private ``mcp.*`` module.""" + for path in sorted((STORIES_DIR / name).glob("*.py")): + private = sorted(p for p in _module_paths(_parse(path), package=f"stories.{name}") if _is_private_mcp(p)) + assert not private, f"{path.relative_to(STORIES_DIR)} reaches into private mcp module(s): {private}" + + +@pytest.mark.parametrize("name", _LOWLEVEL_STORIES) +def test_server_lowlevel_imports_no_mcpserver_tier(name: str) -> None: + """``server_lowlevel.py`` stays on the lowlevel tier; it never references ``MCPServer`` or its module.""" + paths = _module_paths(_parse(STORIES_DIR / name / "server_lowlevel.py"), package=f"stories.{name}") + high = sorted(p for p in paths if any(f"{p}.".startswith(f"{tier}.") for tier in _MCPSERVER_TIER)) + assert not high, f"{name}/server_lowlevel.py references the MCPServer tier: {high}" + + +@pytest.mark.parametrize("scaffold", ["_harness.py", "_hosting.py"]) +def test_scaffold_imports_no_story_module(scaffold: str) -> None: + """The dependency is one-way: ``_harness.py`` / ``_hosting.py`` import no ``stories.`` module.""" + story_refs = sorted( + p for p in _module_paths(_parse(STORIES_DIR / scaffold), package="stories") if _is_story_module(p) + ) + assert not story_refs, f"{scaffold} imports a story module: {story_refs}" From 5d8e097e145577982a9fdaab26019f68c1184817 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:48:43 +0000 Subject: [PATCH 10/14] Adapt the story suite to the merged 2026 client surface Rebased onto main: mode='auto' is now the Client default, so the harness drops the workaround prose; the mrtr/subscriptions stubs note their lowlevel surface is in this base. --- examples/stories/_harness.py | 9 +++++---- examples/stories/mrtr/README.md | 8 ++++---- examples/stories/subscriptions/README.md | 9 ++++----- tests/examples/conftest.py | 3 ++- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/examples/stories/_harness.py b/examples/stories/_harness.py index 39718b23ed..d7573cabdf 100644 --- a/examples/stories/_harness.py +++ b/examples/stories/_harness.py @@ -92,12 +92,13 @@ def run_client(main: Callable[..., Awaitable[None]]) -> None: targets = target_from_args(file) build_auth: AuthBuilder | None = globals_.get("build_auth") transport = "http" if "--http" in sys.argv else "stdio" - # Never rely on the SDK's mode= default — be explicit. stdio is legacy-only until - # the SDK's stdio entry can negotiate the era, so only --http gets a modern arm. + # The era is an axis of the story matrix, so ``mode=`` is always passed explicitly + # even though it often matches the ``Client`` default of "auto". stdio is legacy-only + # until the SDK's stdio entry can negotiate the era, so only --http gets a modern arm. era = "modern" if transport == "http" and "--legacy" not in sys.argv else "legacy" if cfg["era"] == "dual-in-body": - # The story pins its connection modes inside ``main`` itself, so hand it the - # real-user "auto" default and let those in-body pins decide. A hard version pin + # The story pins its connection modes inside ``main`` itself, so hand it "auto" + # (the ``Client`` default) and let those in-body pins decide. A hard version pin # here would skip the discover probe and leave ``server_info`` blank. era = "in-body" mode = {"modern": LATEST_MODERN_VERSION, "legacy": "legacy", "in-body": "auto"}[era] diff --git a/examples/stories/mrtr/README.md b/examples/stories/mrtr/README.md index 5cd4429dd1..6058e3a84d 100644 --- a/examples/stories/mrtr/README.md +++ b/examples/stories/mrtr/README.md @@ -7,11 +7,11 @@ the server resumes from the carried state. The story will show both the auto-fulfil helper and a manual resubmit loop. **Status: not yet implemented** ([#2898](https://github.com/modelcontextprotocol/python-sdk/issues/2898)). -The lowlevel registration surface exists on `main` as of +The lowlevel registration surface is in this base — [#2967](https://github.com/modelcontextprotocol/python-sdk/pull/2967) -(`ae13ede`), which widened the tool/prompt/resource handler return types to -include `InputRequiredResult`. This story graduates from a README stub to a -runnable example once this branch's base includes that commit. +(`ae13ede`) widened the tool/prompt/resource handler return types to include +`InputRequiredResult`. The runnable story is deliberately a follow-up PR to +keep this one reviewable. ## Spec diff --git a/examples/stories/subscriptions/README.md b/examples/stories/subscriptions/README.md index a51bae979b..d41d0f82ba 100644 --- a/examples/stories/subscriptions/README.md +++ b/examples/stories/subscriptions/README.md @@ -6,12 +6,11 @@ them. Replaces the handshake-era `resources/subscribe` + standalone-GET notification path. **Status: not yet implemented** ([#2901](https://github.com/modelcontextprotocol/python-sdk/issues/2901)). -The lowlevel registration surface exists on `main` as of +The lowlevel registration surface is in this base — [#2967](https://github.com/modelcontextprotocol/python-sdk/pull/2967) -(`ae13ede`), which added the lowlevel `on_subscriptions_listen` handler slot. -There is no `Client.listen()` or `ServerEventBus` yet; this story graduates -from a README stub to a runnable example once this branch's base includes that -commit. +(`ae13ede`) added the lowlevel `on_subscriptions_listen` handler slot — but +there is no `Client.listen()` or `ServerEventBus` yet. The runnable story is +deliberately a follow-up PR to keep this one reviewable. ## Spec diff --git a/tests/examples/conftest.py b/tests/examples/conftest.py index 9e18661399..d196a66919 100644 --- a/tests/examples/conftest.py +++ b/tests/examples/conftest.py @@ -45,7 +45,8 @@ _ERA_TO_MODE = {"modern": LATEST_MODERN_VERSION, "legacy": "legacy", "in-body": "auto"} """``Client`` rejects handshake-era version strings, so ``legacy`` resolves to ``mode='legacy'`` rather than ``LATEST_HANDSHAKE_VERSION``. ``in-body`` legs pin -their connection modes inside ``main`` themselves, so they get the real-user default.""" +their connection modes inside ``main`` themselves, so they get ``"auto"`` — the +``Client`` default; the era axis still passes every ``mode=`` explicitly.""" def story_cfg(name: str) -> dict[str, Any]: From 7447bffd4298772cf315b21f113cc17c4677ab84 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:41:02 +0000 Subject: [PATCH 11/14] 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. --- examples/README.md | 4 ++++ examples/pyproject.toml | 5 ++++- examples/stories/README.md | 2 +- examples/stories/_harness.py | 5 +++++ examples/stories/bearer_auth/README.md | 7 ++++--- examples/stories/dual_era/README.md | 3 ++- examples/stories/json_response/README.md | 5 +++-- examples/stories/legacy_elicitation/client.py | 5 +++-- examples/stories/legacy_elicitation/server.py | 3 ++- .../stories/legacy_elicitation/server_lowlevel.py | 8 ++++++-- examples/stories/legacy_routing/README.md | 13 ++++++++----- examples/stories/legacy_routing/server.py | 12 +++++++++++- examples/stories/legacy_routing/server_lowlevel.py | 12 +++++++++--- examples/stories/lifespan/README.md | 9 ++++++--- examples/stories/manifest.toml | 6 +----- examples/stories/middleware/README.md | 7 +++++-- examples/stories/middleware/client.py | 6 ++++-- examples/stories/oauth_client_credentials/README.md | 3 ++- examples/stories/reconnect/README.md | 3 ++- examples/stories/stateless_legacy/README.md | 9 +++++---- uv.lock | 6 +++++- 21 files changed, 92 insertions(+), 41 deletions(-) diff --git a/examples/README.md b/examples/README.md index 17be7cdbb0..0a283e1356 100644 --- a/examples/README.md +++ b/examples/README.md @@ -13,6 +13,10 @@ 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). diff --git a/examples/pyproject.toml b/examples/pyproject.toml index 237103cc8a..7b01095912 100644 --- a/examples/pyproject.toml +++ b/examples/pyproject.toml @@ -3,7 +3,10 @@ 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"] +dependencies = [ + "mcp", + "tomli>=2.0; python_version < '3.11'", +] [build-system] requires = ["hatchling"] diff --git a/examples/stories/README.md b/examples/stories/README.md index a762ec147e..46fd790458 100644 --- a/examples/stories/README.md +++ b/examples/stories/README.md @@ -120,7 +120,7 @@ opens with a banner saying what replaces it. | [`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()` default posture; the one-liner deploy | current | +| [`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 | diff --git a/examples/stories/_harness.py b/examples/stories/_harness.py index d7573cabdf..04cb912895 100644 --- a/examples/stories/_harness.py +++ b/examples/stories/_harness.py @@ -92,6 +92,11 @@ def run_client(main: Callable[..., Awaitable[None]]) -> None: targets = target_from_args(file) build_auth: AuthBuilder | None = globals_.get("build_auth") transport = "http" if "--http" in sys.argv else "stdio" + if cfg["server_export"] == "app" and transport != "http": + raise SystemExit( + f"{name} exports an ASGI app (no stdio entry point); start its server, then run:\n" + f" python -m stories.{name}.client --http http://127.0.0.1:8000{cfg['mcp_path']}" + ) # The era is an axis of the story matrix, so ``mode=`` is always passed explicitly # even though it often matches the ``Client`` default of "auto". stdio is legacy-only # until the SDK's stdio entry can negotiate the era, so only --http gets a modern arm. diff --git a/examples/stories/bearer_auth/README.md b/examples/stories/bearer_auth/README.md index 9849057363..f1cbace01a 100644 --- a/examples/stories/bearer_auth/README.md +++ b/examples/stories/bearer_auth/README.md @@ -19,9 +19,10 @@ uv run python -m stories.bearer_auth.server --port 8000 & # connect with the demo bearer token uv run python -m stories.bearer_auth.client --http http://127.0.0.1:8000/mcp -# lowlevel-API variant of the same app -uv run python -m stories.bearer_auth.server_lowlevel --port 8001 & -uv run python -m stories.bearer_auth.client --http http://127.0.0.1:8001/mcp +# lowlevel server variant — same port, so stop the first server +kill %1 +uv run python -m stories.bearer_auth.server_lowlevel --port 8000 & +uv run python -m stories.bearer_auth.client --http http://127.0.0.1:8000/mcp ``` `Client(url)` has no `auth=` passthrough, so a target built from a bare URL diff --git a/examples/stories/dual_era/README.md b/examples/stories/dual_era/README.md index 4ed87722cc..09965f0e05 100644 --- a/examples/stories/dual_era/README.md +++ b/examples/stories/dual_era/README.md @@ -14,7 +14,8 @@ stays era-agnostic. uv run python -m stories.dual_era.server --http --port 8000 & uv run python -m stories.dual_era.client --http http://127.0.0.1:8000/mcp -# lowlevel server variant +# lowlevel server variant — same port, so stop the first server +kill %1 uv run python -m stories.dual_era.server_lowlevel --http --port 8000 & uv run python -m stories.dual_era.client --http http://127.0.0.1:8000/mcp ``` diff --git a/examples/stories/json_response/README.md b/examples/stories/json_response/README.md index 1f1f630b14..7c64ece03a 100644 --- a/examples/stories/json_response/README.md +++ b/examples/stories/json_response/README.md @@ -59,5 +59,6 @@ curl -s http://127.0.0.1:8000/mcp \ ## See also -`stateless_legacy/` (the default posture), `legacy_routing/` (route by era at -the entry), `streaming/` (progress that *is* delivered — over stdio/SSE). +`stateless_legacy/` (the one-liner `stateless_http=True` deploy), +`legacy_routing/` (route by era at the entry), `streaming/` (progress that *is* +delivered — over stdio/SSE). diff --git a/examples/stories/legacy_elicitation/client.py b/examples/stories/legacy_elicitation/client.py index 23b1aef7a6..1d10ca6832 100644 --- a/examples/stories/legacy_elicitation/client.py +++ b/examples/stories/legacy_elicitation/client.py @@ -7,8 +7,9 @@ async def on_elicit(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult: if isinstance(params, types.ElicitRequestURLParams): - # A real client would open params.url in a browser, then wait for the matching - # notifications/elicitation/complete before resolving. + # A real client would ask consent and open params.url in a browser, returning + # `accept` right away; the server's notifications/elicitation/complete arrives + # afterward (once the out-of-band flow finishes) for the client to correlate. assert params.url.startswith("https://example.com/") return types.ElicitResult(action="accept") assert "username" in params.requested_schema["properties"] diff --git a/examples/stories/legacy_elicitation/server.py b/examples/stories/legacy_elicitation/server.py index 4999c545e0..d2a6e95a5e 100644 --- a/examples/stories/legacy_elicitation/server.py +++ b/examples/stories/legacy_elicitation/server.py @@ -24,7 +24,8 @@ async def register_user(ctx: Context) -> str: @mcp.tool(description="Link a third-party account by directing the user to a sign-in URL.") async def link_account(provider: str, ctx: Context) -> str: - elicitation_id = f"link-{provider}" + # elicitation_id must be unique per elicitation, not per provider — scope it to this request. + elicitation_id = f"link-{provider}-{ctx.request_context.request_id}" answer = await ctx.elicit_url( f"Sign in to {provider} to link your account", url=f"https://example.com/oauth/{provider}/authorize", diff --git a/examples/stories/legacy_elicitation/server_lowlevel.py b/examples/stories/legacy_elicitation/server_lowlevel.py index 8dd81ec15b..33559d89f2 100644 --- a/examples/stories/legacy_elicitation/server_lowlevel.py +++ b/examples/stories/legacy_elicitation/server_lowlevel.py @@ -39,7 +39,9 @@ async def list_tools( async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: if params.name == "register_user": - answer = await ctx.session.elicit_form("Please provide your registration details:", REGISTRATION_SCHEMA) + answer = await ctx.session.elicit_form( + "Please provide your registration details:", REGISTRATION_SCHEMA, related_request_id=ctx.request_id + ) if answer.action != "accept" or answer.content is None: return types.CallToolResult(content=[types.TextContent(text=f"registration {answer.action}")]) text = f"registered {answer.content['username']} (plan: {answer.content.get('plan') or 'free'})" @@ -47,11 +49,13 @@ async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolReques assert params.name == "link_account" and params.arguments is not None provider = params.arguments["provider"] - elicitation_id = f"link-{provider}" + # elicitation_id must be unique per elicitation, not per provider — scope it to this request. + elicitation_id = f"link-{provider}-{ctx.request_id}" answer = await ctx.session.elicit_url( f"Sign in to {provider} to link your account", url=f"https://example.com/oauth/{provider}/authorize", elicitation_id=elicitation_id, + related_request_id=ctx.request_id, ) if answer.action != "accept": return types.CallToolResult(content=[types.TextContent(text=f"link {answer.action}")]) diff --git a/examples/stories/legacy_routing/README.md b/examples/stories/legacy_routing/README.md index e9467605f4..5f28c955b5 100644 --- a/examples/stories/legacy_routing/README.md +++ b/examples/stories/legacy_routing/README.md @@ -9,7 +9,8 @@ ASGI/ingress layer. Unlike most SDKs, the Python SDK's built-in arms (per-era auth, separate ports, an existing v1 deployment to keep), not to make dual-era work at all. -Also shown: the CORS `expose_headers` recipe browser-based MCP clients need. +Also shown: the CORS recipe (methods, request headers, and `expose_headers`) +browser-based MCP clients need. ## Run it @@ -18,7 +19,8 @@ Also shown: the CORS `expose_headers` recipe browser-based MCP clients need. uv run python -m stories.legacy_routing.server --port 8000 & uv run python -m stories.legacy_routing.client --http http://127.0.0.1:8000/mcp -# lowlevel server variant +# lowlevel server variant — same port, so stop the first server +kill %1 uv run python -m stories.legacy_routing.server_lowlevel --port 8000 & uv run python -m stories.legacy_routing.client --http http://127.0.0.1:8000/mcp ``` @@ -43,9 +45,10 @@ uv run python -m stories.legacy_routing.client --http http://127.0.0.1:8000/mcp - `server.py` `build_app` — `streamable_http_app()` + `CORSMiddleware`. The `which_arm` tool reads `ctx.request_context.protocol_version` to prove which path the built-in router took. -- `server_lowlevel.py` — same `classify_era` and CORS recipe (re-used from - `server.py`); `build_app` wires `lowlevel.Server` instead of `MCPServer` and - reads `ctx.protocol_version` directly. +- `server_lowlevel.py` — the CORS recipe re-used from `server.py` (the + `MCP_*` header and method constants); `build_app` wires `lowlevel.Server` + instead of `MCPServer` and reads `ctx.protocol_version` directly. The + predicate is tier-agnostic, so `classify_era` lives only in `server.py`. ## User-land composition (when you need different backends) diff --git a/examples/stories/legacy_routing/server.py b/examples/stories/legacy_routing/server.py index 6b295e1a1d..4ee077adec 100644 --- a/examples/stories/legacy_routing/server.py +++ b/examples/stories/legacy_routing/server.py @@ -14,6 +14,10 @@ #: Response headers a browser-based MCP client must be able to read. MCP_EXPOSED_HEADERS = ["Mcp-Session-Id", "WWW-Authenticate", "Last-Event-Id", "Mcp-Protocol-Version"] +#: Request headers a browser-based MCP client must be allowed to send. +MCP_ALLOWED_HEADERS = ["Authorization", "Content-Type", "Mcp-Protocol-Version", "Mcp-Session-Id", "Last-Event-Id"] +#: Streamable HTTP verbs: POST requests, the standalone GET stream, DELETE session end. +MCP_ALLOWED_METHODS = ["GET", "POST", "DELETE"] def classify_era( @@ -47,7 +51,13 @@ async def which_arm(ctx: Context) -> str: app = mcp.streamable_http_app(transport_security=NO_DNS_REBIND) # CORS for browser-based clients. DEMO ONLY — restrict allow_origins in production. - app.add_middleware(CORSMiddleware, allow_origins=["*"], expose_headers=MCP_EXPOSED_HEADERS) + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=MCP_ALLOWED_METHODS, + allow_headers=MCP_ALLOWED_HEADERS, + expose_headers=MCP_EXPOSED_HEADERS, + ) return app diff --git a/examples/stories/legacy_routing/server_lowlevel.py b/examples/stories/legacy_routing/server_lowlevel.py index 02cfc741a9..c44da0f590 100644 --- a/examples/stories/legacy_routing/server_lowlevel.py +++ b/examples/stories/legacy_routing/server_lowlevel.py @@ -1,4 +1,4 @@ -"""Exported era classifier (lowlevel API): predicate + built-in dual-era app + CORS.""" +"""Exported era classifier (lowlevel API): the same dual-era app + CORS — the predicate stays in `server.py`.""" from typing import Any @@ -11,7 +11,7 @@ from mcp.shared.version import MODERN_PROTOCOL_VERSIONS from stories._hosting import NO_DNS_REBIND, run_app_from_args -from .server import MCP_EXPOSED_HEADERS +from .server import MCP_ALLOWED_HEADERS, MCP_ALLOWED_METHODS, MCP_EXPOSED_HEADERS WHICH_ARM = types.Tool( name="which_arm", @@ -34,7 +34,13 @@ async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolReques server = Server("legacy-routing-example", on_list_tools=list_tools, on_call_tool=call_tool) app = server.streamable_http_app(transport_security=NO_DNS_REBIND) - app.add_middleware(CORSMiddleware, allow_origins=["*"], expose_headers=MCP_EXPOSED_HEADERS) + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=MCP_ALLOWED_METHODS, + allow_headers=MCP_ALLOWED_HEADERS, + expose_headers=MCP_EXPOSED_HEADERS, + ) return app diff --git a/examples/stories/lifespan/README.md b/examples/stories/lifespan/README.md index 932ff2c946..6e13d7a77f 100644 --- a/examples/stories/lifespan/README.md +++ b/examples/stories/lifespan/README.md @@ -35,9 +35,12 @@ uv run python -m stories.lifespan.client --http http://127.0.0.1:8000/mcp - `ctx.request_context.lifespan_context` is the interim path; a later release will shorten this to `ctx.state.*`. The lowlevel `ctx.lifespan_context` path is unaffected. -- **v1 → v2 scope change** — in v1.x, `lifespan` was entered once *per - connection*; in v2 it is entered once *per process*. See `docs/migration.md` - ("lifespan now per-process"). +- **v1 → v2 scope change** — in v1.x, `lifespan` was entered once per + `Server.run()` call: once per *session* for stateful streamable HTTP and once + per *request* under `stateless_http=True` (stdio was already per-process). In + v2 it is entered once per process regardless of transport. See + `docs/migration.md` ("Streamable HTTP: lifespan now entered once at manager + startup"). ## Spec diff --git a/examples/stories/manifest.toml b/examples/stories/manifest.toml index 60557ec50f..2a428b77b6 100644 --- a/examples/stories/manifest.toml +++ b/examples/stories/manifest.toml @@ -1,7 +1,6 @@ # examples/stories/manifest.toml # -# Drives tests/examples/ axis expansion. test_manifest_matches_filesystem -# asserts [story.*] keys == story dirs with a client.py. +# test_manifest_matches_filesystem asserts [story.*] keys == story dirs with a client.py. [defaults] transports = ["in-memory", "http-asgi"] # in-memory = Client(server); http-asgi = StreamingASGITransport @@ -12,7 +11,6 @@ server_export = "factory" # "factory" -> build_server() | multi_connection = false # main(target, ...) vs main(targets, ...); targets() -> fresh target per call needs_http = false # main(..., http=) gets the raw httpx.AsyncClient (http-asgi only) timeout_s = 30 -smoke = false mcp_path = "/mcp" xfail = [] # [":", ...] -> strict xfail on that leg env = {} # env vars set for the leg via monkeypatch @@ -20,7 +18,6 @@ env = {} # env vars set for the leg via m # ───────────────────────────── start here ───────────────────────────── [story.tools] -smoke = true [story.prompts] @@ -80,7 +77,6 @@ transports = ["http-asgi"] server_export = "app" era = "dual-in-body" multi_connection = true -smoke = true [story.json_response] transports = ["http-asgi"] diff --git a/examples/stories/middleware/README.md b/examples/stories/middleware/README.md index 3b20e04a05..aad321f703 100644 --- a/examples/stories/middleware/README.md +++ b/examples/stories/middleware/README.md @@ -42,6 +42,9 @@ uv run python -m stories.middleware.client --http http://127.0.0.1:8000/mcp `mcp.server.context` (helper tier); not re-exported at `mcp.server.lowlevel`. - Do **not** `await ctx.session.send_request(...)` while wrapping `initialize` — `initialize` is dispatched inline and the outbound channel isn't open yet. +- To rewrite `ctx.method` / `ctx.params` before the handler runs, pass an + adjusted context through: `await call_next(dataclasses.replace(ctx, ...))`. + `docs/migration.md` shows the full recipe. ## Spec @@ -49,6 +52,6 @@ Middleware is SDK architecture, not an MCP spec feature. ## See also -`custom_methods/` (rewrite `ctx.method` / `ctx.params` via -`dataclasses.replace(ctx, ...)` before `call_next`), +`custom_methods/` (a vendor `acme/search` handler registered with +`add_request_handler` — middleware wraps it like any spec method), `src/mcp/server/_otel.py` (`OpenTelemetryMiddleware`, the SDK's own consumer). diff --git a/examples/stories/middleware/client.py b/examples/stories/middleware/client.py index 840d62cd15..60ebbbc305 100644 --- a/examples/stories/middleware/client.py +++ b/examples/stories/middleware/client.py @@ -17,8 +17,10 @@ async def main(target: Target, *, mode: str = "auto") -> None: # adds server/discover; modern in-memory adds nothing. Filter to the methods # this client drove. seen = [m for m in result.structured_content["result"] if m.startswith("tools/")] - # tools/call:done is absent — the handler ran inside the middleware frame. - assert seen == ["tools/list", "tools/list:done", "tools/call"], seen + # The tail ends at tools/call with no :done — the handler ran inside the + # middleware frame. Assert the tail (not the whole list) so a re-run against + # a long-lived server, whose log accumulates across clients, still passes. + assert seen[-3:] == ["tools/list", "tools/list:done", "tools/call"], seen if __name__ == "__main__": diff --git a/examples/stories/oauth_client_credentials/README.md b/examples/stories/oauth_client_credentials/README.md index 49cefb0fbc..f63a66ea14 100644 --- a/examples/stories/oauth_client_credentials/README.md +++ b/examples/stories/oauth_client_credentials/README.md @@ -13,7 +13,8 @@ discovery → token POST → Bearer attachment automatically. uv run python -m stories.oauth_client_credentials.server --port 8000 & uv run python -m stories.oauth_client_credentials.client --http http://127.0.0.1:8000/mcp -# lowlevel-API variant of the same app +# lowlevel server variant — same port, so stop the first server +kill %1 uv run python -m stories.oauth_client_credentials.server_lowlevel --port 8000 & uv run python -m stories.oauth_client_credentials.client --http http://127.0.0.1:8000/mcp ``` diff --git a/examples/stories/reconnect/README.md b/examples/stories/reconnect/README.md index 2b225a5880..f188dcb176 100644 --- a/examples/stories/reconnect/README.md +++ b/examples/stories/reconnect/README.md @@ -13,7 +13,8 @@ traffic and has `server_info` / `server_capabilities` available immediately. uv run python -m stories.reconnect.server --http --port 8000 & uv run python -m stories.reconnect.client --http http://127.0.0.1:8000/mcp -# lowlevel server variant +# lowlevel server variant — same port, so stop the first server +kill %1 uv run python -m stories.reconnect.server_lowlevel --http --port 8000 & uv run python -m stories.reconnect.client --http http://127.0.0.1:8000/mcp ``` diff --git a/examples/stories/stateless_legacy/README.md b/examples/stories/stateless_legacy/README.md index ae52235d0f..f3f59a6a50 100644 --- a/examples/stories/stateless_legacy/README.md +++ b/examples/stories/stateless_legacy/README.md @@ -17,9 +17,10 @@ uv run python -m stories.stateless_legacy.server --port 8000 & # connect once as a modern client and once as a legacy client uv run python -m stories.stateless_legacy.client --http http://127.0.0.1:8000/mcp -# lowlevel-API variant of the same app -uv run python -m stories.stateless_legacy.server_lowlevel --port 8001 & -uv run python -m stories.stateless_legacy.client --http http://127.0.0.1:8001/mcp +# lowlevel server variant — same port, so stop the first server +kill %1 +uv run python -m stories.stateless_legacy.server_lowlevel --port 8000 & +uv run python -m stories.stateless_legacy.client --http http://127.0.0.1:8000/mcp ``` ## What to look at @@ -52,6 +53,6 @@ uv run python -m stories.stateless_legacy.client --http http://127.0.0.1:8001/mc ## See also `dual_era/` (era branching inside a tool handler) · `legacy_routing/` -(`is_legacy_request()` for sessionful-2025 + modern on one mount) · +(`classify_inbound_request()` for sessionful-2025 + modern on one mount) · `starlette_mount/` (mounting under FastAPI/Starlette with parent lifespan) · `json_response/` (`json_response=True` and what it drops). diff --git a/uv.lock b/uv.lock index f0676c6816..a1e8a7e356 100644 --- a/uv.lock +++ b/uv.lock @@ -1067,10 +1067,14 @@ version = "0.0.0" source = { editable = "examples" } dependencies = [ { name = "mcp" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] [package.metadata] -requires-dist = [{ name = "mcp", editable = "." }] +requires-dist = [ + { name = "mcp", editable = "." }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" }, +] [[package]] name = "mcp-simple-auth" From 839932f3f5ca0b437ecfd72726eb4b0086bb11fc Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 25 Jun 2026 16:58:27 +0000 Subject: [PATCH 12/14] READMEs: capture the server PID instead of relying on job control `kill %1` assumes the server is the only background job; `SERVER_PID=$!` plus `kill "$SERVER_PID"` is the same one-line recipe but immune to whatever else the shell has running. --- examples/stories/bearer_auth/README.md | 3 ++- examples/stories/dual_era/README.md | 3 ++- examples/stories/legacy_routing/README.md | 3 ++- examples/stories/oauth_client_credentials/README.md | 3 ++- examples/stories/reconnect/README.md | 3 ++- examples/stories/stateless_legacy/README.md | 3 ++- 6 files changed, 12 insertions(+), 6 deletions(-) diff --git a/examples/stories/bearer_auth/README.md b/examples/stories/bearer_auth/README.md index f1cbace01a..b15544dc16 100644 --- a/examples/stories/bearer_auth/README.md +++ b/examples/stories/bearer_auth/README.md @@ -15,12 +15,13 @@ authorization server; see `../oauth/` for the full grant flow. ```bash # start the bearer-gated server (real uvicorn on :8000) uv run python -m stories.bearer_auth.server --port 8000 & +SERVER_PID=$! # connect with the demo bearer token uv run python -m stories.bearer_auth.client --http http://127.0.0.1:8000/mcp # lowlevel server variant — same port, so stop the first server -kill %1 +kill "$SERVER_PID" uv run python -m stories.bearer_auth.server_lowlevel --port 8000 & uv run python -m stories.bearer_auth.client --http http://127.0.0.1:8000/mcp ``` diff --git a/examples/stories/dual_era/README.md b/examples/stories/dual_era/README.md index 09965f0e05..1b89f50646 100644 --- a/examples/stories/dual_era/README.md +++ b/examples/stories/dual_era/README.md @@ -12,10 +12,11 @@ stays era-agnostic. ```bash # over HTTP — the same /mcp endpoint serves both eras uv run python -m stories.dual_era.server --http --port 8000 & +SERVER_PID=$! uv run python -m stories.dual_era.client --http http://127.0.0.1:8000/mcp # lowlevel server variant — same port, so stop the first server -kill %1 +kill "$SERVER_PID" uv run python -m stories.dual_era.server_lowlevel --http --port 8000 & uv run python -m stories.dual_era.client --http http://127.0.0.1:8000/mcp ``` diff --git a/examples/stories/legacy_routing/README.md b/examples/stories/legacy_routing/README.md index 5f28c955b5..a43aa6961a 100644 --- a/examples/stories/legacy_routing/README.md +++ b/examples/stories/legacy_routing/README.md @@ -17,10 +17,11 @@ browser-based MCP clients need. ```bash # HTTP only — the predicate is an HTTP-transport concern uv run python -m stories.legacy_routing.server --port 8000 & +SERVER_PID=$! uv run python -m stories.legacy_routing.client --http http://127.0.0.1:8000/mcp # lowlevel server variant — same port, so stop the first server -kill %1 +kill "$SERVER_PID" uv run python -m stories.legacy_routing.server_lowlevel --port 8000 & uv run python -m stories.legacy_routing.client --http http://127.0.0.1:8000/mcp ``` diff --git a/examples/stories/oauth_client_credentials/README.md b/examples/stories/oauth_client_credentials/README.md index f63a66ea14..80333bf347 100644 --- a/examples/stories/oauth_client_credentials/README.md +++ b/examples/stories/oauth_client_credentials/README.md @@ -11,10 +11,11 @@ discovery → token POST → Bearer attachment automatically. ```bash # start the server (real uvicorn on :8000 — auth is HTTP-only) uv run python -m stories.oauth_client_credentials.server --port 8000 & +SERVER_PID=$! uv run python -m stories.oauth_client_credentials.client --http http://127.0.0.1:8000/mcp # lowlevel server variant — same port, so stop the first server -kill %1 +kill "$SERVER_PID" uv run python -m stories.oauth_client_credentials.server_lowlevel --port 8000 & uv run python -m stories.oauth_client_credentials.client --http http://127.0.0.1:8000/mcp ``` diff --git a/examples/stories/reconnect/README.md b/examples/stories/reconnect/README.md index f188dcb176..06964b0e73 100644 --- a/examples/stories/reconnect/README.md +++ b/examples/stories/reconnect/README.md @@ -11,10 +11,11 @@ traffic and has `server_info` / `server_capabilities` available immediately. ```bash # over HTTP — Streamable HTTP only; in-memory has no "round-trip" to skip uv run python -m stories.reconnect.server --http --port 8000 & +SERVER_PID=$! uv run python -m stories.reconnect.client --http http://127.0.0.1:8000/mcp # lowlevel server variant — same port, so stop the first server -kill %1 +kill "$SERVER_PID" uv run python -m stories.reconnect.server_lowlevel --http --port 8000 & uv run python -m stories.reconnect.client --http http://127.0.0.1:8000/mcp ``` diff --git a/examples/stories/stateless_legacy/README.md b/examples/stories/stateless_legacy/README.md index f3f59a6a50..28e4413896 100644 --- a/examples/stories/stateless_legacy/README.md +++ b/examples/stories/stateless_legacy/README.md @@ -13,12 +13,13 @@ wiring, no era flag. The client connects once per era and asserts the same ```bash # start the server (real uvicorn on :8000) uv run python -m stories.stateless_legacy.server --port 8000 & +SERVER_PID=$! # connect once as a modern client and once as a legacy client uv run python -m stories.stateless_legacy.client --http http://127.0.0.1:8000/mcp # lowlevel server variant — same port, so stop the first server -kill %1 +kill "$SERVER_PID" uv run python -m stories.stateless_legacy.server_lowlevel --port 8000 & uv run python -m stories.stateless_legacy.client --http http://127.0.0.1:8000/mcp ``` From 51947905109a0d0a9c9cada2f179ec65b1e42619 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:43:22 +0000 Subject: [PATCH 13/14] Let client.py self-host its server for HTTP runs `python -m stories..client --http` with no URL now starts the sibling server on a port it owns, waits for it to listen, runs the scenario, and tears it down. The two-variant run recipe becomes two commands with nothing to background, kill, or collide; `--http ` still targets a server you run yourself. The smoke test reuses the same path instead of carrying its own spawn/poll copy, so it now exercises exactly the command the READMEs print. Auth stories keep their pinned :8000 via a manifest `fixed_port`. --- examples/stories/README.md | 28 ++++- examples/stories/_harness.py | 113 +++++++++++++----- examples/stories/bearer_auth/README.md | 21 ++-- examples/stories/custom_methods/README.md | 5 +- examples/stories/dual_era/README.md | 14 +-- examples/stories/error_handling/README.md | 7 +- examples/stories/json_response/README.md | 12 +- examples/stories/legacy_elicitation/README.md | 8 +- examples/stories/legacy_routing/README.md | 12 +- examples/stories/lifespan/README.md | 7 +- examples/stories/manifest.toml | 4 + examples/stories/middleware/README.md | 5 +- examples/stories/oauth/README.md | 21 ++-- .../oauth_client_credentials/README.md | 13 +- examples/stories/pagination/README.md | 9 +- examples/stories/parallel_calls/README.md | 10 +- examples/stories/prompts/README.md | 7 +- examples/stories/reconnect/README.md | 14 +-- examples/stories/resources/README.md | 10 +- examples/stories/roots/README.md | 7 +- examples/stories/sampling/README.md | 7 +- examples/stories/schema_validators/README.md | 7 +- examples/stories/sse_polling/README.md | 12 +- examples/stories/standalone_get/README.md | 11 +- examples/stories/starlette_mount/README.md | 7 ++ examples/stories/stateless_legacy/README.md | 14 +-- examples/stories/stickynotes/README.md | 7 +- examples/stories/streaming/README.md | 8 +- examples/stories/tools/README.md | 7 +- tests/examples/test_stories_smoke.py | 87 +++----------- 30 files changed, 284 insertions(+), 210 deletions(-) diff --git a/examples/stories/README.md b/examples/stories/README.md index 46fd790458..93f04a014b 100644 --- a/examples/stories/README.md +++ b/examples/stories/README.md @@ -64,11 +64,30 @@ From the repository root: # stdio (default — the client spawns the server as a subprocess) uv run python -m stories.tools.client -# against a running HTTP server -uv run python -m stories.tools.server --http --port 8000 & +# 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 +` 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 ` swaps in a sibling server module on stdio and on the +self-hosted `--http` run; with `--http ` 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: @@ -84,8 +103,9 @@ and variants; `tests/examples/` expands it. `_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 URL under -`--http`). They isolate the parts of the SDK's hosting surface +`target` that `main()` connects to (a stdio subprocess by default, a self-hosted +HTTP subprocess under bare `--http`, your URL under `--http `). 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. diff --git a/examples/stories/_harness.py b/examples/stories/_harness.py index 04cb912895..34e4a285f1 100644 --- a/examples/stories/_harness.py +++ b/examples/stories/_harness.py @@ -8,9 +8,11 @@ from __future__ import annotations +import socket import sys import traceback -from collections.abc import Awaitable, Callable +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import AsyncExitStack, asynccontextmanager from pathlib import Path from typing import Any, TypeAlias from urllib.parse import urlsplit @@ -50,15 +52,13 @@ def argv_after(flag: str, *, default: str | None = None) -> str: return default -def target_from_args(file: str) -> TargetFactory: - """Build a ``TargetFactory`` for the sibling server over the argv-selected transport. +def target_from_args(file: str, url: str | None) -> TargetFactory: + """Build a ``TargetFactory`` for the sibling server of the ``client.py`` at ``file``. - ``--http `` targets that streamable-HTTP URL; ``--stdio`` (the default) spawns - the sibling ``server.py`` as a fresh subprocess on each call. ``--server `` - selects ``.py`` (e.g. ``server_lowlevel``). ``file`` is the caller's ``__file__``. + ``url`` (already resolved by ``run_client``) targets that streamable-HTTP endpoint; ``None`` + spawns ``.py`` over stdio per call, ```` from ``--server`` (default ``server``). """ - if "--http" in sys.argv: - url = argv_after("--http") + if url is not None: return lambda: url # stdio is legacy-only until serve_stdio() lands; the modern arm is --http only for now. server = Path(file).parent / f"{argv_after('--server', default='server')}.py" @@ -66,6 +66,61 @@ def target_from_args(file: str) -> TargetFactory: return lambda: stdio_client(params) # becomes Client(params) once that overload lands +def _explicit_http_url() -> str | None: + """The URL token after ``--http``, or ``None`` when the flag stands alone (self-host).""" + rest = sys.argv[sys.argv.index("--http") + 1 :] + return rest[0] if rest and not rest[0].startswith("-") else None + + +def _free_port() -> int: + """An OS-assigned free TCP port, released for the server subprocess to re-bind.""" + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +async def _accepting(port: int) -> bool: + """Whether something accepts a TCP connect on ``127.0.0.1:port`` right now.""" + try: + stream = await anyio.connect_tcp("127.0.0.1", port) + except OSError: + return False + await stream.aclose() + return True + + +@asynccontextmanager +async def _self_hosted(name: str, cfg: dict[str, Any]) -> AsyncIterator[str]: + """Serve the story's sibling server from a subprocess on a port this process owns; yield its URL. + + Readiness is the first accepted TCP connect (bounded by ``run_client``'s + ``anyio.fail_after``); exiting terminates the subprocess. Nothing to background or kill. + A subprocess that dies before serving, or a ``fixed_port`` someone else already holds, + is a loud ``SystemExit`` rather than a hang or a run against the wrong server. + """ + port: int = cfg["fixed_port"] or _free_port() + if cfg["fixed_port"] and await _accepting(port): + # The readiness probe below can't tell our child from a server already on the + # story's pinned port, so a foreign listener would be tested in its place. + raise SystemExit( + f"{name} self-hosts on :{port} but something is already serving there; " + f"stop it, or connect to it with --http " + ) + module = f"stories.{name}.{argv_after('--server', default='server')}" + serve = ["--http"] if cfg["server_export"] == "factory" else [] + argv = [sys.executable, "-m", module, *serve, "--port", str(port)] + async with await anyio.open_process(argv, stdout=None, stderr=None) as server: + try: + while server.returncode is None and not await _accepting(port): + await anyio.sleep(0.05) + if server.returncode is not None: + raise SystemExit(f"{module} exited {server.returncode} before serving on :{port}") + yield f"http://127.0.0.1:{port}{cfg['mcp_path']}" + finally: + if server.returncode is None: + server.terminate() + + def _story_cfg(name: str) -> dict[str, Any]: """The manifest entry for the story ``name`` with ``[defaults]`` applied.""" manifest: dict[str, Any] = tomllib.loads((Path(__file__).parent / "manifest.toml").read_text()) @@ -80,23 +135,24 @@ def _authed_targets(url: str, http: httpx.AsyncClient) -> TargetFactory: def run_client(main: Callable[..., Awaitable[None]]) -> None: """Entry point for ``if __name__ == "__main__"`` in every ``client.py``. - Builds the argv-selected target(s) for the story that defines ``main``, picks the - era from argv, and calls ``main`` with an explicit ``mode=``. If the story module - exports ``build_auth``, the ``--http`` target is routed through an ``httpx.AsyncClient`` - that carries the returned ``httpx.Auth``. Prints ``OK:``/``FAIL:`` to stderr, exits 0/1. + Resolves the argv target — stdio (the default), ``--http `` for a server you run, or + bare ``--http`` to self-host the sibling server in a subprocess it owns — and calls ``main`` + with an explicit ``mode=``. A ``build_auth`` export auths the HTTP target. ``OK``/``FAIL``, exit 0/1. """ globals_ = getattr(main, "__globals__", {}) file = str(globals_.get("__file__", "")) name = Path(file).parent.name cfg = _story_cfg(name) - targets = target_from_args(file) build_auth: AuthBuilder | None = globals_.get("build_auth") transport = "http" if "--http" in sys.argv else "stdio" if cfg["server_export"] == "app" and transport != "http": raise SystemExit( - f"{name} exports an ASGI app (no stdio entry point); start its server, then run:\n" - f" python -m stories.{name}.client --http http://127.0.0.1:8000{cfg['mcp_path']}" + f"{name} exports an ASGI app (no stdio entry point); self-host it over HTTP:\n" + f" python -m stories.{name}.client --http" ) + if cfg["needs_http"] and transport != "http": + raise SystemExit(f"{name} asserts on raw HTTP responses; run it with --http") + explicit_url = _explicit_http_url() if transport == "http" else None # The era is an axis of the story matrix, so ``mode=`` is always passed explicitly # even though it often matches the ``Client`` default of "auto". stdio is legacy-only # until the SDK's stdio entry can negotiate the era, so only --http gets a modern arm. @@ -110,18 +166,21 @@ def run_client(main: Callable[..., Awaitable[None]]) -> None: async def _run() -> None: with anyio.fail_after(cfg["timeout_s"]): - if not cfg["needs_http"] and (build_auth is None or transport != "http"): - await main(targets if cfg["multi_connection"] else targets(), mode=mode) - return - # Auth and needs_http stories want the raw httpx client underneath the transport: - # build_auth threads an httpx.Auth onto it (Client(url, auth=...) doesn't exist - # yet), and needs_http stories assert on raw responses, so root the client at the - # server origin and relative paths like "/mcp" resolve. - if transport != "http": - raise SystemExit(f"{name} asserts on raw HTTP responses; run it with --http ") - url = argv_after("--http") - parts = urlsplit(url) - async with httpx.AsyncClient(base_url=f"{parts.scheme}://{parts.netloc}") as http: + async with AsyncExitStack() as stack: + url = explicit_url + if transport == "http" and url is None: + url = await stack.enter_async_context(_self_hosted(name, cfg)) + targets = target_from_args(file, url) + if url is None or (build_auth is None and not cfg["needs_http"]): + await main(targets if cfg["multi_connection"] else targets(), mode=mode) + return + # Auth and needs_http stories want the raw httpx client underneath the transport: + # build_auth threads an httpx.Auth onto it (Client(url, auth=...) doesn't exist + # yet), and needs_http stories assert on raw responses, so root the client at the + # server origin and relative paths like "/mcp" resolve. + parts = urlsplit(url) + base = f"{parts.scheme}://{parts.netloc}" + http = await stack.enter_async_context(httpx.AsyncClient(base_url=base)) make = targets if build_auth is not None: http.auth = build_auth(http) diff --git a/examples/stories/bearer_auth/README.md b/examples/stories/bearer_auth/README.md index b15544dc16..d1d556f94f 100644 --- a/examples/stories/bearer_auth/README.md +++ b/examples/stories/bearer_auth/README.md @@ -13,16 +13,18 @@ authorization server; see `../oauth/` for the full grant flow. ## Run it ```bash -# start the bearer-gated server (real uvicorn on :8000) +# HTTP — the client self-hosts the bearer-gated app, connects with the demo +# bearer token, then tears it down. Self-hosting uses this story's fixed :8000 +# (the issuer/PRM metadata pin it), so :8000 must be free. +uv run python -m stories.bearer_auth.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.bearer_auth.client --http --server server_lowlevel + +# against a server you run yourself (real uvicorn on :8000). The next section's +# curl probes use it too and `kill` it when done. While it is up it owns :8000, +# so the two self-host lines above refuse to run rather than test it by mistake. uv run python -m stories.bearer_auth.server --port 8000 & SERVER_PID=$! - -# connect with the demo bearer token -uv run python -m stories.bearer_auth.client --http http://127.0.0.1:8000/mcp - -# lowlevel server variant — same port, so stop the first server -kill "$SERVER_PID" -uv run python -m stories.bearer_auth.server_lowlevel --port 8000 & uv run python -m stories.bearer_auth.client --http http://127.0.0.1:8000/mcp ``` @@ -42,6 +44,9 @@ curl -i -X POST http://127.0.0.1:8000/mcp \ # the RFC 9728 protected-resource-metadata document curl -s http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp | jq + +# done with the server you started in "Run it" +kill "$SERVER_PID" ``` ## What to look at diff --git a/examples/stories/custom_methods/README.md b/examples/stories/custom_methods/README.md index 54fcb4785f..924ea0298d 100644 --- a/examples/stories/custom_methods/README.md +++ b/examples/stories/custom_methods/README.md @@ -12,9 +12,8 @@ it. # stdio (default — the client spawns the server as a subprocess) uv run python -m stories.custom_methods.client -# against a running HTTP server -uv run python -m stories.custom_methods.server --http --port 8000 & -uv run python -m stories.custom_methods.client --http http://127.0.0.1:8000/mcp +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.custom_methods.client --http ``` ## What to look at diff --git a/examples/stories/dual_era/README.md b/examples/stories/dual_era/README.md index 1b89f50646..f14f164027 100644 --- a/examples/stories/dual_era/README.md +++ b/examples/stories/dual_era/README.md @@ -10,15 +10,11 @@ stays era-agnostic. ## Run it ```bash -# over HTTP — the same /mcp endpoint serves both eras -uv run python -m stories.dual_era.server --http --port 8000 & -SERVER_PID=$! -uv run python -m stories.dual_era.client --http http://127.0.0.1:8000/mcp - -# lowlevel server variant — same port, so stop the first server -kill "$SERVER_PID" -uv run python -m stories.dual_era.server_lowlevel --http --port 8000 & -uv run python -m stories.dual_era.client --http http://127.0.0.1:8000/mcp +# over HTTP — the same /mcp endpoint serves both eras; the client self-hosts +# the server on a free port, runs, then tears it down +uv run python -m stories.dual_era.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.dual_era.client --http --server server_lowlevel ``` The bare stdio invocation (`uv run python -m stories.dual_era.client`) is diff --git a/examples/stories/error_handling/README.md b/examples/stories/error_handling/README.md index 81d2eabc8d..f550837385 100644 --- a/examples/stories/error_handling/README.md +++ b/examples/stories/error_handling/README.md @@ -14,9 +14,10 @@ client tells them apart. # stdio (default — the client spawns the server as a subprocess) uv run python -m stories.error_handling.client -# against a running HTTP server -uv run python -m stories.error_handling.server --http --port 8000 & -uv run python -m stories.error_handling.client --http http://127.0.0.1:8000/mcp +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.error_handling.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.error_handling.client --http --server server_lowlevel ``` ## What to look at diff --git a/examples/stories/json_response/README.md b/examples/stories/json_response/README.md index 7c64ece03a..36ad7300f4 100644 --- a/examples/stories/json_response/README.md +++ b/examples/stories/json_response/README.md @@ -9,10 +9,15 @@ same endpoint behave the same way. ## Run it ```bash -# start the server (real uvicorn) -uv run python -m stories.json_response.server --port 8000 & +# HTTP — the client self-hosts the app on a free port, runs the high-level +# Client + raw-envelope probe, then tears it down +uv run python -m stories.json_response.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.json_response.client --http --server server_lowlevel -# high-level Client + raw-envelope probe against it +# against a server you run yourself (real uvicorn on :8000) +uv run python -m stories.json_response.server --port 8000 & +SERVER_PID=$! uv run python -m stories.json_response.client --http http://127.0.0.1:8000/mcp # or POST the raw envelope yourself @@ -22,6 +27,7 @@ curl -s http://127.0.0.1:8000/mcp \ -H 'mcp-protocol-version: 2026-07-28' \ -H 'mcp-method: tools/list' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"0"},"io.modelcontextprotocol/clientCapabilities":{}}}}' +kill "$SERVER_PID" ``` ## What to look at diff --git a/examples/stories/legacy_elicitation/README.md b/examples/stories/legacy_elicitation/README.md index 55c90b2fd2..62f4379c3c 100644 --- a/examples/stories/legacy_elicitation/README.md +++ b/examples/stories/legacy_elicitation/README.md @@ -24,9 +24,11 @@ flow finishes). # stdio (default — the client spawns the server as a subprocess) uv run python -m stories.legacy_elicitation.client -# against a running HTTP server (--legacy: the push request needs the handshake era) -uv run python -m stories.legacy_elicitation.server --http --port 8000 & -uv run python -m stories.legacy_elicitation.client --http http://127.0.0.1:8000/mcp --legacy +# HTTP — the client self-hosts the server on a free port, runs, then tears it +# down (--legacy: the push request needs the handshake era) +uv run python -m stories.legacy_elicitation.client --http --legacy +# same, against the lowlevel-API server variant +uv run python -m stories.legacy_elicitation.client --http --legacy --server server_lowlevel ``` ## What to look at diff --git a/examples/stories/legacy_routing/README.md b/examples/stories/legacy_routing/README.md index a43aa6961a..2617f1470f 100644 --- a/examples/stories/legacy_routing/README.md +++ b/examples/stories/legacy_routing/README.md @@ -15,15 +15,17 @@ browser-based MCP clients need. ## Run it ```bash -# HTTP only — the predicate is an HTTP-transport concern +# HTTP only — the predicate is an HTTP-transport concern. The client +# self-hosts the app on a free port, runs, then tears it down. +uv run python -m stories.legacy_routing.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.legacy_routing.client --http --server server_lowlevel + +# against a server you run yourself (real uvicorn on :8000) uv run python -m stories.legacy_routing.server --port 8000 & SERVER_PID=$! uv run python -m stories.legacy_routing.client --http http://127.0.0.1:8000/mcp - -# lowlevel server variant — same port, so stop the first server kill "$SERVER_PID" -uv run python -m stories.legacy_routing.server_lowlevel --port 8000 & -uv run python -m stories.legacy_routing.client --http http://127.0.0.1:8000/mcp ``` ## What to look at diff --git a/examples/stories/lifespan/README.md b/examples/stories/lifespan/README.md index 6e13d7a77f..f2cb6c9d3e 100644 --- a/examples/stories/lifespan/README.md +++ b/examples/stories/lifespan/README.md @@ -11,9 +11,10 @@ the injected `Context` — no module-level globals. # stdio (default — the client spawns the server as a subprocess) uv run python -m stories.lifespan.client -# against a running HTTP server -uv run python -m stories.lifespan.server --http --port 8000 & -uv run python -m stories.lifespan.client --http http://127.0.0.1:8000/mcp +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.lifespan.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.lifespan.client --http --server server_lowlevel ``` ## What to look at diff --git a/examples/stories/manifest.toml b/examples/stories/manifest.toml index 2a428b77b6..7a1f079e8e 100644 --- a/examples/stories/manifest.toml +++ b/examples/stories/manifest.toml @@ -12,6 +12,7 @@ multi_connection = false # main(target, ...) vs main(targ needs_http = false # main(..., http=) gets the raw httpx.AsyncClient (http-asgi only) timeout_s = 30 mcp_path = "/mcp" +fixed_port = 0 # `client --http` self-host port; 0 = an OS-assigned free port xfail = [] # [":", ...] -> strict xfail on that leg env = {} # env vars set for the leg via monkeypatch @@ -119,16 +120,19 @@ multi_connection = true [story.bearer_auth] transports = ["http-asgi"] server_export = "app" +fixed_port = 8000 # issuer/PRM metadata bake in :8000 [story.oauth] transports = ["http-asgi"] server_export = "app" multi_connection = true +fixed_port = 8000 # issuer/PRM metadata bake in :8000 env = { OAUTH_DEMO_AUTO_CONSENT = "1" } [story.oauth_client_credentials] transports = ["http-asgi"] server_export = "app" +fixed_port = 8000 # issuer/PRM metadata bake in :8000 # ───────────────────────────── deferred ───────────────────────────── # README-only placeholders; no client.py, not expanded into legs. diff --git a/examples/stories/middleware/README.md b/examples/stories/middleware/README.md index aad321f703..599f890f80 100644 --- a/examples/stories/middleware/README.md +++ b/examples/stories/middleware/README.md @@ -13,9 +13,8 @@ outermost-first. # stdio (default — the client spawns the server as a subprocess) uv run python -m stories.middleware.client -# against a running HTTP server -uv run python -m stories.middleware.server --http --port 8000 & -uv run python -m stories.middleware.client --http http://127.0.0.1:8000/mcp +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.middleware.client --http ``` ## What to look at diff --git a/examples/stories/oauth/README.md b/examples/stories/oauth/README.md index 58e28275bb..a773a7851c 100644 --- a/examples/stories/oauth/README.md +++ b/examples/stories/oauth/README.md @@ -14,14 +14,19 @@ bearer retry — all inside the first awaited request, with no user-visible ## Run it ```bash -# terminal 1 — co-hosted AS + bearer-gated /mcp on :8000 -OAUTH_DEMO_AUTO_CONSENT=1 uv run python -m stories.oauth.server --port 8000 - -# terminal 2 — authorization-code flow (headless: redirect followed in-process) +# HTTP — the client self-hosts the co-hosted AS + bearer-gated /mcp, runs the +# authorization-code flow (headless: redirect followed in-process), then tears +# it down. Self-hosting uses this story's fixed :8000 (the AS metadata pins +# it), so :8000 must be free. +OAUTH_DEMO_AUTO_CONSENT=1 uv run python -m stories.oauth.client --http +# same, against the lowlevel-API server variant +OAUTH_DEMO_AUTO_CONSENT=1 uv run python -m stories.oauth.client --http --server server_lowlevel + +# against a server you run yourself (real uvicorn on :8000) +OAUTH_DEMO_AUTO_CONSENT=1 uv run python -m stories.oauth.server --port 8000 & +SERVER_PID=$! uv run python -m stories.oauth.client --http http://127.0.0.1:8000/mcp - -# lowlevel-API variant of the same app -OAUTH_DEMO_AUTO_CONSENT=1 uv run python -m stories.oauth.server_lowlevel --port 8000 +kill "$SERVER_PID" ``` The port must be **8000**: the demo AS metadata (`_shared/auth.py` `BASE_URL`) @@ -34,7 +39,7 @@ straight back with `?code=...`; without it the authorize step returns `Client(url)` has no `auth=` passthrough, so a target built from a bare URL can't carry the flow. Both runners close that gap the same way: `run_client` -(terminal 2) and the pytest harness build an authed `httpx.AsyncClient` from +(above) and the pytest harness build an authed `httpx.AsyncClient` from this module's `build_auth` export and hand `main` targets that are already routed through it. diff --git a/examples/stories/oauth_client_credentials/README.md b/examples/stories/oauth_client_credentials/README.md index 80333bf347..8cd5a5b82c 100644 --- a/examples/stories/oauth_client_credentials/README.md +++ b/examples/stories/oauth_client_credentials/README.md @@ -9,15 +9,18 @@ discovery → token POST → Bearer attachment automatically. ## Run it ```bash -# start the server (real uvicorn on :8000 — auth is HTTP-only) +# HTTP — the client self-hosts the server, runs the grant, then tears it down. +# Self-hosting uses this story's fixed :8000 (the AS metadata pins it), so +# :8000 must be free. +uv run python -m stories.oauth_client_credentials.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.oauth_client_credentials.client --http --server server_lowlevel + +# against a server you run yourself (real uvicorn on :8000 — auth is HTTP-only) uv run python -m stories.oauth_client_credentials.server --port 8000 & SERVER_PID=$! uv run python -m stories.oauth_client_credentials.client --http http://127.0.0.1:8000/mcp - -# lowlevel server variant — same port, so stop the first server kill "$SERVER_PID" -uv run python -m stories.oauth_client_credentials.server_lowlevel --port 8000 & -uv run python -m stories.oauth_client_credentials.client --http http://127.0.0.1:8000/mcp ``` OAuth is an HTTP-layer concern; stdio servers receive credentials via the diff --git a/examples/stories/pagination/README.md b/examples/stories/pagination/README.md index 27ea6cb9d8..f7113d4cc5 100644 --- a/examples/stories/pagination/README.md +++ b/examples/stories/pagination/README.md @@ -11,13 +11,12 @@ check (an empty string is a valid cursor under the spec). # stdio (default — the client spawns the server as a subprocess) uv run python -m stories.pagination.client --server server_lowlevel -# against a running HTTP server -uv run python -m stories.pagination.server_lowlevel --http --port 8000 & -uv run python -m stories.pagination.client --http http://127.0.0.1:8000/mcp +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.pagination.client --http --server server_lowlevel ``` -Swap `server_lowlevel` → `server` to run against the `MCPServer` variant -(single page). +Drop `--server server_lowlevel` (on either transport) to run against the +`MCPServer` variant (single page). ## What to look at diff --git a/examples/stories/parallel_calls/README.md b/examples/stories/parallel_calls/README.md index 67924e7f87..678a3642e4 100644 --- a/examples/stories/parallel_calls/README.md +++ b/examples/stories/parallel_calls/README.md @@ -10,14 +10,16 @@ demultiplexes by progress token, not by arrival order. ## Run it The tested legs run in-memory (`Client(server)`); the identical `main` body -works unchanged against an HTTP URL — both clients just reach the same running -server: +works unchanged over HTTP — both clients just reach the same server. Under +`--http` the client self-hosts that server on a free port, runs, then tears it +down: ```bash -uv run python -m stories.parallel_calls.server --http --port 8000 & # --legacy because handler-emitted progress is dropped on the modern # streamable-HTTP path today (see Caveats). -uv run python -m stories.parallel_calls.client --http http://127.0.0.1:8000/mcp --legacy +uv run python -m stories.parallel_calls.client --http --legacy +# same, against the lowlevel-API server variant +uv run python -m stories.parallel_calls.client --http --legacy --server server_lowlevel ``` There is no stdio run for this story: the stdio default spawns a fresh server diff --git a/examples/stories/prompts/README.md b/examples/stories/prompts/README.md index 36befc767d..3bce94b995 100644 --- a/examples/stories/prompts/README.md +++ b/examples/stories/prompts/README.md @@ -12,9 +12,10 @@ prompts. # stdio (default — the client spawns the server as a subprocess) uv run python -m stories.prompts.client -# against a running HTTP server -uv run python -m stories.prompts.server --http --port 8000 & -uv run python -m stories.prompts.client --http http://127.0.0.1:8000/mcp +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.prompts.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.prompts.client --http --server server_lowlevel ``` ## What to look at diff --git a/examples/stories/reconnect/README.md b/examples/stories/reconnect/README.md index 06964b0e73..78d281e7a9 100644 --- a/examples/stories/reconnect/README.md +++ b/examples/stories/reconnect/README.md @@ -9,15 +9,11 @@ traffic and has `server_info` / `server_capabilities` available immediately. ## Run it ```bash -# over HTTP — Streamable HTTP only; in-memory has no "round-trip" to skip -uv run python -m stories.reconnect.server --http --port 8000 & -SERVER_PID=$! -uv run python -m stories.reconnect.client --http http://127.0.0.1:8000/mcp - -# lowlevel server variant — same port, so stop the first server -kill "$SERVER_PID" -uv run python -m stories.reconnect.server_lowlevel --http --port 8000 & -uv run python -m stories.reconnect.client --http http://127.0.0.1:8000/mcp +# over HTTP — Streamable HTTP only; in-memory has no "round-trip" to skip. +# The client self-hosts the server on a free port, runs, then tears it down. +uv run python -m stories.reconnect.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.reconnect.client --http --server server_lowlevel ``` ## What to look at diff --git a/examples/stories/resources/README.md b/examples/stories/resources/README.md index 9ab7e7c4e7..10b210fe91 100644 --- a/examples/stories/resources/README.md +++ b/examples/stories/resources/README.md @@ -11,12 +11,10 @@ client lists resources, lists templates, then reads each. # stdio (default — the client spawns the server as a subprocess) uv run python -m stories.resources.client -# against a running HTTP server -uv run python -m stories.resources.server --http --port 8000 & -uv run python -m stories.resources.client --http http://127.0.0.1:8000/mcp - -# swap in the lowlevel server -uv run python -m stories.resources.client --server server_lowlevel +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.resources.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.resources.client --http --server server_lowlevel ``` ## What to look at diff --git a/examples/stories/roots/README.md b/examples/stories/roots/README.md index 0936519a58..9714616b62 100644 --- a/examples/stories/roots/README.md +++ b/examples/stories/roots/README.md @@ -16,9 +16,10 @@ client advertise the `roots` capability — there is no separate flag. # stdio (default — the client spawns the server as a subprocess) uv run python -m stories.roots.client -# against a running HTTP server -uv run python -m stories.roots.server --http --port 8000 & -uv run python -m stories.roots.client --http http://127.0.0.1:8000/mcp --legacy +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.roots.client --http --legacy +# same, against the lowlevel-API server variant +uv run python -m stories.roots.client --http --legacy --server server_lowlevel ``` ## What to look at diff --git a/examples/stories/sampling/README.md b/examples/stories/sampling/README.md index 1fb060fd65..73d021b09a 100644 --- a/examples/stories/sampling/README.md +++ b/examples/stories/sampling/README.md @@ -17,9 +17,10 @@ capability — there is no separate flag. # stdio (default — the client spawns the server as a subprocess) uv run python -m stories.sampling.client -# against a running HTTP server -uv run python -m stories.sampling.server --http --port 8000 & -uv run python -m stories.sampling.client --http http://127.0.0.1:8000/mcp --legacy +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.sampling.client --http --legacy +# same, against the lowlevel-API server variant +uv run python -m stories.sampling.client --http --legacy --server server_lowlevel ``` ## What to look at diff --git a/examples/stories/schema_validators/README.md b/examples/stories/schema_validators/README.md index 55de59f8b6..984f1595ba 100644 --- a/examples/stories/schema_validators/README.md +++ b/examples/stories/schema_validators/README.md @@ -11,9 +11,10 @@ client lists the tools, resolves each `who` schema, and round-trips a call. # stdio (default — the client spawns the server as a subprocess) uv run python -m stories.schema_validators.client -# against a running HTTP server -uv run python -m stories.schema_validators.server --http --port 8000 & -uv run python -m stories.schema_validators.client --http http://127.0.0.1:8000/mcp +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.schema_validators.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.schema_validators.client --http --server server_lowlevel ``` ## What to look at diff --git a/examples/stories/sse_polling/README.md b/examples/stories/sse_polling/README.md index c36b6f2eea..ddd1b61884 100644 --- a/examples/stories/sse_polling/README.md +++ b/examples/stories/sse_polling/README.md @@ -18,10 +18,16 @@ resolves as if the disconnect never happened. ## Run it ```bash -# in one terminal -uv run python -m stories.sse_polling.server --port 8000 -# in another +# HTTP — the client self-hosts the app on a free port, runs, then tears it down +uv run python -m stories.sse_polling.client --http --legacy +# same, against the lowlevel-API server variant +uv run python -m stories.sse_polling.client --http --legacy --server server_lowlevel + +# against a server you run yourself (real uvicorn on :8000) +uv run python -m stories.sse_polling.server --port 8000 & +SERVER_PID=$! uv run python -m stories.sse_polling.client --http http://127.0.0.1:8000/mcp --legacy +kill "$SERVER_PID" ``` ## What to look at diff --git a/examples/stories/standalone_get/README.md b/examples/stories/standalone_get/README.md index 086ea970c2..c460e14911 100644 --- a/examples/stories/standalone_get/README.md +++ b/examples/stories/standalone_get/README.md @@ -15,10 +15,17 @@ awaits it on an `anyio.Event`, then re-lists to observe the change. ## Run it ```bash -# server (HTTP-only — the standalone GET stream is a Streamable-HTTP feature) +# HTTP only — the standalone GET stream is a Streamable-HTTP feature. The +# client self-hosts the server on a free port, runs, then tears it down. +uv run python -m stories.standalone_get.client --http --legacy +# same, against the lowlevel-API server variant +uv run python -m stories.standalone_get.client --http --legacy --server server_lowlevel + +# against a server you run yourself uv run python -m stories.standalone_get.server --http --port 8000 & -# client +SERVER_PID=$! uv run python -m stories.standalone_get.client --http http://127.0.0.1:8000/mcp --legacy +kill "$SERVER_PID" ``` ## What to look at diff --git a/examples/stories/starlette_mount/README.md b/examples/stories/starlette_mount/README.md index ffdf86e622..4098a5fdeb 100644 --- a/examples/stories/starlette_mount/README.md +++ b/examples/stories/starlette_mount/README.md @@ -10,9 +10,16 @@ parent must enter `mcp.session_manager.run()`). ## Run it ```bash +# HTTP — the client self-hosts the mounted app on a free port at /api/, runs, +# then tears it down +uv run python -m stories.starlette_mount.client --http + +# against a server you run yourself (real uvicorn on :8000) uv run python -m stories.starlette_mount.server --port 8000 & +SERVER_PID=$! curl http://127.0.0.1:8000/health # → {"status":"ok"} uv run python -m stories.starlette_mount.client --http http://127.0.0.1:8000/api/ +kill "$SERVER_PID" ``` ## What to look at diff --git a/examples/stories/stateless_legacy/README.md b/examples/stories/stateless_legacy/README.md index 28e4413896..7fc1630ce9 100644 --- a/examples/stories/stateless_legacy/README.md +++ b/examples/stories/stateless_legacy/README.md @@ -11,17 +11,17 @@ wiring, no era flag. The client connects once per era and asserts the same ## Run it ```bash -# start the server (real uvicorn on :8000) +# HTTP — the client self-hosts the app on a free port, connects once as a +# modern client and once as a legacy client, then tears it down +uv run python -m stories.stateless_legacy.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.stateless_legacy.client --http --server server_lowlevel + +# against a server you run yourself (real uvicorn on :8000) uv run python -m stories.stateless_legacy.server --port 8000 & SERVER_PID=$! - -# connect once as a modern client and once as a legacy client uv run python -m stories.stateless_legacy.client --http http://127.0.0.1:8000/mcp - -# lowlevel server variant — same port, so stop the first server kill "$SERVER_PID" -uv run python -m stories.stateless_legacy.server_lowlevel --port 8000 & -uv run python -m stories.stateless_legacy.client --http http://127.0.0.1:8000/mcp ``` ## What to look at diff --git a/examples/stories/stickynotes/README.md b/examples/stories/stickynotes/README.md index d444d48ef3..b1d4543142 100644 --- a/examples/stories/stickynotes/README.md +++ b/examples/stories/stickynotes/README.md @@ -12,9 +12,10 @@ destructive clear. # stdio (default — the client spawns the server as a subprocess) uv run python -m stories.stickynotes.client -# against a running HTTP server -uv run python -m stories.stickynotes.server --http --port 8000 & -uv run python -m stories.stickynotes.client --http http://127.0.0.1:8000/mcp +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.stickynotes.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.stickynotes.client --http --server server_lowlevel ``` ## What to look at diff --git a/examples/stories/streaming/README.md b/examples/stories/streaming/README.md index 5363cbde19..86e2e74780 100644 --- a/examples/stories/streaming/README.md +++ b/examples/stories/streaming/README.md @@ -16,9 +16,11 @@ inside the progress callback (event-driven, no `sleep`). uv run python -m stories.streaming.client uv run python -m stories.streaming.client --server server_lowlevel -# against a running HTTP server (--legacy: see the note below) -uv run python -m stories.streaming.server --http --port 8000 & -uv run python -m stories.streaming.client --http http://127.0.0.1:8000/mcp --legacy +# HTTP — the client self-hosts the server on a free port, runs, then tears it +# down (--legacy: see the note below) +uv run python -m stories.streaming.client --http --legacy +# same, against the lowlevel-API server variant +uv run python -m stories.streaming.client --http --legacy --server server_lowlevel ``` The modern HTTP leg (drop `--legacy`) is `xfail` until the SSE wiring lands — diff --git a/examples/stories/tools/README.md b/examples/stories/tools/README.md index caa86e3916..996fa0c2b6 100644 --- a/examples/stories/tools/README.md +++ b/examples/stories/tools/README.md @@ -13,9 +13,10 @@ asserts structured output. # stdio (default — the client spawns the server as a subprocess) uv run python -m stories.tools.client -# against a running HTTP server -uv run python -m stories.tools.server --http --port 8000 & -uv run python -m stories.tools.client --http http://127.0.0.1:8000/mcp +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.tools.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.tools.client --http --server server_lowlevel ``` ## What to look at diff --git a/tests/examples/test_stories_smoke.py b/tests/examples/test_stories_smoke.py index ecd6f48c87..ed8a26ea44 100644 --- a/tests/examples/test_stories_smoke.py +++ b/tests/examples/test_stories_smoke.py @@ -3,20 +3,20 @@ The in-process matrix in ``test_stories.py`` never executes a story's ``if __name__ == "__main__"`` block, so ``run_client`` / ``run_server_from_args`` / ``run_app_from_args`` and the real stdio + uvicorn entries are unverified by -construction. This file proves that plumbing once over real subprocesses for two -stories (``tools`` over stdio, ``tools`` + ``bearer_auth`` over a real uvicorn -socket). +construction. This file proves that plumbing by running the literal commands the +story READMEs print: stdio (``run_client`` spawns the server over stdio) and bare +``--http`` (``run_client`` self-hosts the server on a real uvicorn socket on a +port it owns, then terminates it). lax no cover: gated on ``MCP_EXAMPLES_SMOKE=1``, which CI sets on exactly one matrix cell (ubuntu / 3.12 / locked — see ``shared.yml``). Every other cell -skips at collection, so the test bodies and the helpers they call are uncovered -there and the per-job 100% gate would otherwise fail. +skips at collection, so the test body is uncovered there and the per-job 100% +gate would otherwise fail. """ from __future__ import annotations import os -import socket import sys from pathlib import Path @@ -38,71 +38,20 @@ _ENV = {k: v for k, v in os.environ.items() if k not in _PROXY_VARS} -def _free_port() -> int: # pragma: lax no cover - with socket.socket() as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] - - -async def _wait_listening(port: int) -> None: # pragma: lax no cover - """Connect-retry until ``127.0.0.1:port`` accepts. - - Deliberate exception to the no-``sleep`` rule: readiness lives in a uvicorn - *subprocess*, so there is no in-process ``anyio.Event`` to await — accepting a - TCP connect IS the readiness signal. Both callers bound this with - ``anyio.fail_after``, and the retry interval only paces the probe; it never - decides when the wait ends. - """ - while True: - try: - stream = await anyio.connect_tcp("127.0.0.1", port) - except OSError: - await anyio.sleep(0.05) - else: - await stream.aclose() - return - - -async def _run_module(*argv: str) -> int: # pragma: lax no cover - async with await anyio.open_process( - [sys.executable, "-m", *argv], cwd=_REPO_ROOT, env=_ENV, stdout=None, stderr=None - ) as proc: - await proc.wait() - assert proc.returncode is not None - return proc.returncode - - -async def test_tools_stdio_main_runs_end_to_end() -> None: # pragma: lax no cover - """``python -m stories.tools.client`` spawns the sibling server over real stdio and exits 0.""" - with anyio.fail_after(30): - assert await _run_module("stories.tools.client") == 0 - - @pytest.mark.parametrize( - ("story", "server_argv"), + "argv", [ - ("tools", ("stories.tools.server", "--http")), - ("bearer_auth", ("stories.bearer_auth.server",)), + ("stories.tools.client",), + ("stories.tools.client", "--http"), + ("stories.bearer_auth.client", "--http"), ], - ids=["tools", "bearer_auth"], + ids=["tools-stdio", "tools-http", "bearer_auth-http"], ) -async def test_http_main_runs_end_to_end(story: str, server_argv: tuple[str, ...]) -> None: # pragma: lax no cover - """Spawn the story's server on a real uvicorn socket, drive its client at it, assert exit 0.""" - port = _free_port() - with anyio.fail_after(30): +async def test_story_main_runs_end_to_end(argv: tuple[str, ...]) -> None: # pragma: lax no cover + """``python -m .client [--http]`` (the README command) exits 0 over a real subprocess.""" + with anyio.fail_after(60): async with await anyio.open_process( - [sys.executable, "-m", *server_argv, "--port", str(port)], - cwd=_REPO_ROOT, - env=_ENV, - stdout=None, - stderr=None, - ) as server: - try: - await _wait_listening(port) - assert await _run_module(f"stories.{story}.client", "--http", f"http://127.0.0.1:{port}/mcp") == 0 - finally: - server.terminate() - with anyio.move_on_after(5): - await server.wait() - if server.returncode is None: - server.kill() + [sys.executable, "-m", *argv], cwd=_REPO_ROOT, env=_ENV, stdout=None, stderr=None + ) as proc: + await proc.wait() + assert proc.returncode == 0 From e362d8ff8e2106fe23905cadcdd35b7826a17f26 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:36:04 +0000 Subject: [PATCH 14/14] Adapt story suite to the mcp-types package split after rebase --- examples/stories/_harness.py | 2 +- examples/stories/bearer_auth/server_lowlevel.py | 2 +- examples/stories/custom_methods/client.py | 3 ++- examples/stories/custom_methods/server.py | 3 ++- examples/stories/dual_era/client.py | 5 +++-- examples/stories/dual_era/server.py | 3 ++- examples/stories/dual_era/server_lowlevel.py | 5 +++-- examples/stories/error_handling/client.py | 3 ++- examples/stories/error_handling/server.py | 3 ++- examples/stories/error_handling/server_lowlevel.py | 3 ++- examples/stories/json_response/client.py | 6 +++--- examples/stories/json_response/server_lowlevel.py | 2 +- examples/stories/legacy_elicitation/client.py | 3 ++- examples/stories/legacy_elicitation/server_lowlevel.py | 3 ++- examples/stories/legacy_routing/client.py | 7 ++++--- examples/stories/legacy_routing/server.py | 4 ++-- examples/stories/legacy_routing/server_lowlevel.py | 4 ++-- examples/stories/lifespan/client.py | 3 ++- examples/stories/lifespan/server_lowlevel.py | 3 ++- examples/stories/middleware/server.py | 3 ++- examples/stories/oauth/server_lowlevel.py | 2 +- .../stories/oauth_client_credentials/server_lowlevel.py | 2 +- examples/stories/pagination/server_lowlevel.py | 3 ++- examples/stories/parallel_calls/client.py | 2 +- examples/stories/parallel_calls/server_lowlevel.py | 2 +- examples/stories/prompts/client.py | 3 ++- examples/stories/prompts/server.py | 3 ++- examples/stories/prompts/server_lowlevel.py | 3 ++- examples/stories/reconnect/client.py | 5 +++-- examples/stories/reconnect/server_lowlevel.py | 3 ++- examples/stories/resources/client.py | 3 ++- examples/stories/resources/server_lowlevel.py | 5 +++-- examples/stories/roots/client.py | 2 +- examples/stories/roots/server_lowlevel.py | 3 ++- examples/stories/sampling/client.py | 3 ++- examples/stories/sampling/server.py | 3 ++- examples/stories/sampling/server_lowlevel.py | 3 ++- examples/stories/schema_validators/client.py | 3 ++- examples/stories/schema_validators/server_lowlevel.py | 3 ++- examples/stories/serve_one/client.py | 5 +++-- examples/stories/serve_one/server.py | 4 ++-- examples/stories/sse_polling/client.py | 2 +- examples/stories/sse_polling/event_store.py | 3 ++- examples/stories/sse_polling/server_lowlevel.py | 2 +- examples/stories/standalone_get/client.py | 2 +- examples/stories/standalone_get/server_lowlevel.py | 3 ++- examples/stories/starlette_mount/client.py | 3 ++- examples/stories/stateless_legacy/client.py | 5 +++-- examples/stories/stateless_legacy/server_lowlevel.py | 2 +- examples/stories/stickynotes/client.py | 4 ++-- examples/stories/stickynotes/server_lowlevel.py | 3 ++- examples/stories/streaming/client.py | 2 +- examples/stories/streaming/server.py | 2 +- examples/stories/streaming/server_lowlevel.py | 2 +- examples/stories/tools/client.py | 3 ++- examples/stories/tools/server.py | 2 +- examples/stories/tools/server_lowlevel.py | 3 ++- tests/examples/conftest.py | 2 +- 58 files changed, 108 insertions(+), 72 deletions(-) diff --git a/examples/stories/_harness.py b/examples/stories/_harness.py index 34e4a285f1..6af90a85c0 100644 --- a/examples/stories/_harness.py +++ b/examples/stories/_harness.py @@ -19,13 +19,13 @@ import anyio import httpx +from mcp_types.version import LATEST_MODERN_VERSION from mcp import StdioServerParameters, stdio_client from mcp.client import Transport from mcp.client.streamable_http import streamable_http_client from mcp.server import Server from mcp.server.mcpserver import MCPServer -from mcp.shared.version import LATEST_MODERN_VERSION if sys.version_info >= (3, 11): import tomllib diff --git a/examples/stories/bearer_auth/server_lowlevel.py b/examples/stories/bearer_auth/server_lowlevel.py index d01a63acea..f5abfc08c4 100644 --- a/examples/stories/bearer_auth/server_lowlevel.py +++ b/examples/stories/bearer_auth/server_lowlevel.py @@ -2,10 +2,10 @@ from typing import Any +import mcp_types as types from pydantic import AnyHttpUrl from starlette.applications import Starlette -from mcp import types from mcp.server.auth.middleware.auth_context import get_access_token from mcp.server.auth.settings import AuthSettings from mcp.server.context import ServerRequestContext diff --git a/examples/stories/custom_methods/client.py b/examples/stories/custom_methods/client.py index df9a4b89d9..4003885fa4 100644 --- a/examples/stories/custom_methods/client.py +++ b/examples/stories/custom_methods/client.py @@ -2,7 +2,8 @@ from typing import Literal, cast -from mcp import types +import mcp_types as types + from mcp.client import Client from stories._harness import Target, run_client diff --git a/examples/stories/custom_methods/server.py b/examples/stories/custom_methods/server.py index a08e285ae5..260aff787c 100644 --- a/examples/stories/custom_methods/server.py +++ b/examples/stories/custom_methods/server.py @@ -6,7 +6,8 @@ from typing import Any -from mcp import types +import mcp_types as types + from mcp.server.context import ServerRequestContext from mcp.server.lowlevel import Server from stories._hosting import run_server_from_args diff --git a/examples/stories/dual_era/client.py b/examples/stories/dual_era/client.py index ef2e8235a6..ba9acf5d99 100644 --- a/examples/stories/dual_era/client.py +++ b/examples/stories/dual_era/client.py @@ -1,8 +1,9 @@ """Connect to the same server factory twice — once per era, so `main` takes `targets` — and assert both are served.""" -from mcp import types +import mcp_types as types +from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION + from mcp.client import Client -from mcp.shared.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION from stories._harness import TargetFactory, run_client diff --git a/examples/stories/dual_era/server.py b/examples/stories/dual_era/server.py index 16486a4dce..3f70ee63c9 100644 --- a/examples/stories/dual_era/server.py +++ b/examples/stories/dual_era/server.py @@ -1,7 +1,8 @@ """One MCPServer factory that serves both the 2025 handshake era and the 2026 stateless era.""" +from mcp_types.version import MODERN_PROTOCOL_VERSIONS + from mcp.server.mcpserver import Context, MCPServer -from mcp.shared.version import MODERN_PROTOCOL_VERSIONS from stories._hosting import run_server_from_args diff --git a/examples/stories/dual_era/server_lowlevel.py b/examples/stories/dual_era/server_lowlevel.py index a5625139dc..b209135e6d 100644 --- a/examples/stories/dual_era/server_lowlevel.py +++ b/examples/stories/dual_era/server_lowlevel.py @@ -2,10 +2,11 @@ from typing import Any -from mcp import types +import mcp_types as types +from mcp_types.version import MODERN_PROTOCOL_VERSIONS + from mcp.server.context import ServerRequestContext from mcp.server.lowlevel import Server -from mcp.shared.version import MODERN_PROTOCOL_VERSIONS from stories._hosting import run_server_from_args GREET_INPUT_SCHEMA: dict[str, Any] = { diff --git a/examples/stories/error_handling/client.py b/examples/stories/error_handling/client.py index d4172a2d90..bd63c4fb13 100644 --- a/examples/stories/error_handling/client.py +++ b/examples/stories/error_handling/client.py @@ -1,8 +1,9 @@ """Prove the two error channels: is_error results return; MCPError raises.""" +from mcp_types import INVALID_PARAMS, TextContent + from mcp import MCPError from mcp.client import Client -from mcp.types import INVALID_PARAMS, TextContent from stories._harness import Target, run_client diff --git a/examples/stories/error_handling/server.py b/examples/stories/error_handling/server.py index 96667a5d0c..e4f3554433 100644 --- a/examples/stories/error_handling/server.py +++ b/examples/stories/error_handling/server.py @@ -1,9 +1,10 @@ """Two error channels: ToolError -> is_error result; MCPError -> JSON-RPC protocol error.""" +from mcp_types import INVALID_PARAMS + from mcp.server.mcpserver import MCPServer from mcp.server.mcpserver.exceptions import ToolError from mcp.shared.exceptions import MCPError -from mcp.types import INVALID_PARAMS from stories._hosting import run_server_from_args diff --git a/examples/stories/error_handling/server_lowlevel.py b/examples/stories/error_handling/server_lowlevel.py index 217762d85d..9bb9aef86a 100644 --- a/examples/stories/error_handling/server_lowlevel.py +++ b/examples/stories/error_handling/server_lowlevel.py @@ -2,7 +2,8 @@ from typing import Any -from mcp import types +import mcp_types as types + from mcp.server.context import ServerRequestContext from mcp.server.lowlevel import Server from mcp.shared.exceptions import MCPError diff --git a/examples/stories/json_response/client.py b/examples/stories/json_response/client.py index db271b0cab..08af5ef914 100644 --- a/examples/stories/json_response/client.py +++ b/examples/stories/json_response/client.py @@ -6,15 +6,15 @@ """ import httpx +from mcp_types import TextContent +from mcp_types.version import LATEST_MODERN_VERSION from mcp.client import Client -from mcp.shared.version import LATEST_MODERN_VERSION -from mcp.types import TextContent from stories._harness import Target, run_client # The raw 2026-07-28 POST envelope: per-request `_meta` replaces the initialize handshake. # The key/header strings are spelled out on purpose — this is the raw-wire story. In code -# use the named constants instead: `mcp.types.PROTOCOL_VERSION_META_KEY` / +# use the named constants instead: `mcp_types.PROTOCOL_VERSION_META_KEY` / # `CLIENT_INFO_META_KEY` / `CLIENT_CAPABILITIES_META_KEY` and # `mcp.shared.inbound.MCP_PROTOCOL_VERSION_HEADER` (`legacy_routing/` shows that form). RAW_ENVELOPE_BODY: dict[str, object] = { diff --git a/examples/stories/json_response/server_lowlevel.py b/examples/stories/json_response/server_lowlevel.py index 65ff815611..bcb14eb9ab 100644 --- a/examples/stories/json_response/server_lowlevel.py +++ b/examples/stories/json_response/server_lowlevel.py @@ -2,9 +2,9 @@ from typing import Any +import mcp_types as types from starlette.applications import Starlette -from mcp import types from mcp.server.context import ServerRequestContext from mcp.server.lowlevel import Server from stories._hosting import NO_DNS_REBIND, run_app_from_args diff --git a/examples/stories/legacy_elicitation/client.py b/examples/stories/legacy_elicitation/client.py index 1d10ca6832..52bb95e516 100644 --- a/examples/stories/legacy_elicitation/client.py +++ b/examples/stories/legacy_elicitation/client.py @@ -1,6 +1,7 @@ """Auto-answer form and URL elicitations and assert the tool result reflects them.""" -from mcp import types +import mcp_types as types + from mcp.client import Client, ClientRequestContext from stories._harness import Target, run_client diff --git a/examples/stories/legacy_elicitation/server_lowlevel.py b/examples/stories/legacy_elicitation/server_lowlevel.py index 33559d89f2..08c7c3a766 100644 --- a/examples/stories/legacy_elicitation/server_lowlevel.py +++ b/examples/stories/legacy_elicitation/server_lowlevel.py @@ -2,7 +2,8 @@ from typing import Any -from mcp import types +import mcp_types as types + from mcp.server.context import ServerRequestContext from mcp.server.lowlevel import Server from stories._hosting import run_server_from_args diff --git a/examples/stories/legacy_routing/client.py b/examples/stories/legacy_routing/client.py index d41856234c..d31d7b18f4 100644 --- a/examples/stories/legacy_routing/client.py +++ b/examples/stories/legacy_routing/client.py @@ -2,11 +2,12 @@ from typing import Any -from mcp import types +import mcp_types as types +from mcp_types import CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY +from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION + from mcp.client import Client from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER, InboundLadderRejection -from mcp.shared.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION -from mcp.types import CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY from stories._harness import TargetFactory, run_client from .server import classify_era diff --git a/examples/stories/legacy_routing/server.py b/examples/stories/legacy_routing/server.py index 4ee077adec..79cc2afa67 100644 --- a/examples/stories/legacy_routing/server.py +++ b/examples/stories/legacy_routing/server.py @@ -3,13 +3,13 @@ from collections.abc import Mapping from typing import Any, Literal +from mcp_types import INVALID_PARAMS +from mcp_types.version import MODERN_PROTOCOL_VERSIONS from starlette.applications import Starlette from starlette.middleware.cors import CORSMiddleware from mcp.server.mcpserver import Context, MCPServer from mcp.shared.inbound import InboundLadderRejection, InboundModernRoute, classify_inbound_request -from mcp.shared.version import MODERN_PROTOCOL_VERSIONS -from mcp.types import INVALID_PARAMS from stories._hosting import NO_DNS_REBIND, run_app_from_args #: Response headers a browser-based MCP client must be able to read. diff --git a/examples/stories/legacy_routing/server_lowlevel.py b/examples/stories/legacy_routing/server_lowlevel.py index c44da0f590..d2f763c8ec 100644 --- a/examples/stories/legacy_routing/server_lowlevel.py +++ b/examples/stories/legacy_routing/server_lowlevel.py @@ -2,13 +2,13 @@ from typing import Any +import mcp_types as types +from mcp_types.version import MODERN_PROTOCOL_VERSIONS from starlette.applications import Starlette from starlette.middleware.cors import CORSMiddleware -from mcp import types from mcp.server.context import ServerRequestContext from mcp.server.lowlevel import Server -from mcp.shared.version import MODERN_PROTOCOL_VERSIONS from stories._hosting import NO_DNS_REBIND, run_app_from_args from .server import MCP_ALLOWED_HEADERS, MCP_ALLOWED_METHODS, MCP_EXPOSED_HEADERS diff --git a/examples/stories/lifespan/client.py b/examples/stories/lifespan/client.py index f84895cd9d..51633177fa 100644 --- a/examples/stories/lifespan/client.py +++ b/examples/stories/lifespan/client.py @@ -1,7 +1,8 @@ """Prove the lifespan-yielded state is reachable from a tool call.""" +from mcp_types import TextContent + from mcp.client import Client -from mcp.types import TextContent from stories._harness import Target, run_client diff --git a/examples/stories/lifespan/server_lowlevel.py b/examples/stories/lifespan/server_lowlevel.py index 36d835c4cb..09945c12c3 100644 --- a/examples/stories/lifespan/server_lowlevel.py +++ b/examples/stories/lifespan/server_lowlevel.py @@ -5,7 +5,8 @@ from dataclasses import dataclass from typing import Any -from mcp import types +import mcp_types as types + from mcp.server.context import ServerRequestContext from mcp.server.lowlevel import Server from stories._hosting import run_server_from_args diff --git a/examples/stories/middleware/server.py b/examples/stories/middleware/server.py index f3bf8094e9..076120dccd 100644 --- a/examples/stories/middleware/server.py +++ b/examples/stories/middleware/server.py @@ -7,7 +7,8 @@ import json from typing import Any -from mcp import types +import mcp_types as types + from mcp.server.context import CallNext, HandlerResult, ServerRequestContext from mcp.server.lowlevel import Server from stories._hosting import run_server_from_args diff --git a/examples/stories/oauth/server_lowlevel.py b/examples/stories/oauth/server_lowlevel.py index 93d5afb209..0bc7799c1e 100644 --- a/examples/stories/oauth/server_lowlevel.py +++ b/examples/stories/oauth/server_lowlevel.py @@ -2,9 +2,9 @@ from typing import Any +import mcp_types as types from starlette.applications import Starlette -from mcp import types from mcp.server.auth.middleware.auth_context import get_access_token from mcp.server.auth.provider import ProviderTokenVerifier from mcp.server.context import ServerRequestContext diff --git a/examples/stories/oauth_client_credentials/server_lowlevel.py b/examples/stories/oauth_client_credentials/server_lowlevel.py index 147ab45948..ba2003dedf 100644 --- a/examples/stories/oauth_client_credentials/server_lowlevel.py +++ b/examples/stories/oauth_client_credentials/server_lowlevel.py @@ -5,13 +5,13 @@ import secrets from typing import Any +import mcp_types as types from pydantic import AnyHttpUrl from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import JSONResponse from starlette.routing import Route -from mcp import types from mcp.server.auth.middleware.auth_context import get_access_token from mcp.server.auth.provider import AccessToken from mcp.server.context import ServerRequestContext diff --git a/examples/stories/pagination/server_lowlevel.py b/examples/stories/pagination/server_lowlevel.py index 5be90b8c74..55958a9624 100644 --- a/examples/stories/pagination/server_lowlevel.py +++ b/examples/stories/pagination/server_lowlevel.py @@ -2,7 +2,8 @@ from typing import Any -from mcp import types +import mcp_types as types + from mcp.server.context import ServerRequestContext from mcp.server.lowlevel import Server from mcp.shared.exceptions import MCPError diff --git a/examples/stories/parallel_calls/client.py b/examples/stories/parallel_calls/client.py index c940053dc8..945e5410a6 100644 --- a/examples/stories/parallel_calls/client.py +++ b/examples/stories/parallel_calls/client.py @@ -1,9 +1,9 @@ """Two concurrent `Client`s, so `main` takes `targets`; their rendezvous in one tool proves concurrent dispatch.""" import anyio +from mcp_types import TextContent from mcp.client import Client -from mcp.types import TextContent from stories._harness import TargetFactory, run_client diff --git a/examples/stories/parallel_calls/server_lowlevel.py b/examples/stories/parallel_calls/server_lowlevel.py index fa0cf812cc..32807e1706 100644 --- a/examples/stories/parallel_calls/server_lowlevel.py +++ b/examples/stories/parallel_calls/server_lowlevel.py @@ -4,8 +4,8 @@ from typing import Any import anyio +import mcp_types as types -from mcp import types from mcp.server.context import ServerRequestContext from mcp.server.lowlevel import Server from stories._hosting import run_server_from_args diff --git a/examples/stories/prompts/client.py b/examples/stories/prompts/client.py index d683713204..22aae4af43 100644 --- a/examples/stories/prompts/client.py +++ b/examples/stories/prompts/client.py @@ -1,7 +1,8 @@ """List prompts, autocomplete an argument, then render both prompts.""" +from mcp_types import PromptReference, TextContent + from mcp.client import Client -from mcp.types import PromptReference, TextContent from stories._harness import Target, run_client diff --git a/examples/stories/prompts/server.py b/examples/stories/prompts/server.py index 9fe9788d22..2ef3fc3d83 100644 --- a/examples/stories/prompts/server.py +++ b/examples/stories/prompts/server.py @@ -1,8 +1,9 @@ """Prompts primitive: register templates, list, render, complete an argument.""" +from mcp_types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference + from mcp.server.mcpserver import MCPServer from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, UserMessage -from mcp.types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference from stories._hosting import run_server_from_args LANGUAGES = ["python", "pytorch", "rust", "go", "typescript"] diff --git a/examples/stories/prompts/server_lowlevel.py b/examples/stories/prompts/server_lowlevel.py index e2dff3aea6..2fb41de8bc 100644 --- a/examples/stories/prompts/server_lowlevel.py +++ b/examples/stories/prompts/server_lowlevel.py @@ -2,7 +2,8 @@ from typing import Any -from mcp import types +import mcp_types as types + from mcp.server.context import ServerRequestContext from mcp.server.lowlevel import Server from stories._hosting import run_server_from_args diff --git a/examples/stories/reconnect/client.py b/examples/stories/reconnect/client.py index 67401aa732..aab2312dc9 100644 --- a/examples/stories/reconnect/client.py +++ b/examples/stories/reconnect/client.py @@ -1,8 +1,9 @@ """Probe server/discover once, persist the result, reconnect with zero round-trips — a fresh `Client` via `targets`.""" +from mcp_types import DiscoverResult +from mcp_types.version import LATEST_MODERN_VERSION + from mcp.client import Client -from mcp.shared.version import LATEST_MODERN_VERSION -from mcp.types import DiscoverResult from stories._harness import TargetFactory, run_client diff --git a/examples/stories/reconnect/server_lowlevel.py b/examples/stories/reconnect/server_lowlevel.py index 926b7f3604..5c6a057d6e 100644 --- a/examples/stories/reconnect/server_lowlevel.py +++ b/examples/stories/reconnect/server_lowlevel.py @@ -2,7 +2,8 @@ from typing import Any -from mcp import types +import mcp_types as types + from mcp.server.context import ServerRequestContext from mcp.server.lowlevel import Server from stories._hosting import run_server_from_args diff --git a/examples/stories/resources/client.py b/examples/stories/resources/client.py index 9e12e51e7f..29f88d529a 100644 --- a/examples/stories/resources/client.py +++ b/examples/stories/resources/client.py @@ -1,7 +1,8 @@ """List resources and templates, then read both the static and templated URIs.""" +from mcp_types import TextResourceContents + from mcp.client import Client -from mcp.types import TextResourceContents from stories._harness import Target, run_client diff --git a/examples/stories/resources/server_lowlevel.py b/examples/stories/resources/server_lowlevel.py index eb935d9b9a..2161fecc9e 100644 --- a/examples/stories/resources/server_lowlevel.py +++ b/examples/stories/resources/server_lowlevel.py @@ -2,11 +2,12 @@ from typing import Any -from mcp import types +import mcp_types as types +from mcp_types.jsonrpc import INVALID_PARAMS + from mcp.server.context import ServerRequestContext from mcp.server.lowlevel import Server from mcp.shared.exceptions import MCPError -from mcp.types.jsonrpc import INVALID_PARAMS from stories._hosting import run_server_from_args diff --git a/examples/stories/roots/client.py b/examples/stories/roots/client.py index ce18cd10dc..9d8252991d 100644 --- a/examples/stories/roots/client.py +++ b/examples/stories/roots/client.py @@ -1,9 +1,9 @@ """Expose two filesystem roots and verify the server's tool can read them back.""" +from mcp_types import ListRootsResult, Root, TextContent from pydantic import FileUrl from mcp.client import Client, ClientRequestContext -from mcp.types import ListRootsResult, Root, TextContent from stories._harness import Target, run_client diff --git a/examples/stories/roots/server_lowlevel.py b/examples/stories/roots/server_lowlevel.py index 866e8c3e09..2696c946c5 100644 --- a/examples/stories/roots/server_lowlevel.py +++ b/examples/stories/roots/server_lowlevel.py @@ -2,7 +2,8 @@ from typing import Any -from mcp import types +import mcp_types as types + from mcp.server.context import ServerRequestContext from mcp.server.lowlevel import Server from stories._hosting import run_server_from_args diff --git a/examples/stories/sampling/client.py b/examples/stories/sampling/client.py index 93d3dddf1c..0ca88db996 100644 --- a/examples/stories/sampling/client.py +++ b/examples/stories/sampling/client.py @@ -1,7 +1,8 @@ """Supply a canned sampling_callback and assert its text round-trips through the tool.""" +from mcp_types import CreateMessageRequestParams, CreateMessageResult, TextContent + from mcp.client import Client, ClientRequestContext -from mcp.types import CreateMessageRequestParams, CreateMessageResult, TextContent from stories._harness import Target, run_client diff --git a/examples/stories/sampling/server.py b/examples/stories/sampling/server.py index 7481f2e36b..c97d8ab24f 100644 --- a/examples/stories/sampling/server.py +++ b/examples/stories/sampling/server.py @@ -1,7 +1,8 @@ """Sampling primitive: a tool asks the client's LLM for a completion mid-call.""" +from mcp_types import SamplingMessage, TextContent + from mcp.server.mcpserver import Context, MCPServer -from mcp.types import SamplingMessage, TextContent from stories._hosting import run_server_from_args diff --git a/examples/stories/sampling/server_lowlevel.py b/examples/stories/sampling/server_lowlevel.py index 0aa2368843..5bc2a19436 100644 --- a/examples/stories/sampling/server_lowlevel.py +++ b/examples/stories/sampling/server_lowlevel.py @@ -2,7 +2,8 @@ from typing import Any -from mcp import types +import mcp_types as types + from mcp.server.context import ServerRequestContext from mcp.server.lowlevel import Server from stories._hosting import run_server_from_args diff --git a/examples/stories/schema_validators/client.py b/examples/stories/schema_validators/client.py index 66e990bc61..8f6794eddc 100644 --- a/examples/stories/schema_validators/client.py +++ b/examples/stories/schema_validators/client.py @@ -1,7 +1,8 @@ """Asserts each variant publishes a `who` object schema and the call round-trips.""" +from mcp_types import TextContent + from mcp.client import Client -from mcp.types import TextContent from stories._harness import Target, run_client diff --git a/examples/stories/schema_validators/server_lowlevel.py b/examples/stories/schema_validators/server_lowlevel.py index 313f19ea99..02dca8d162 100644 --- a/examples/stories/schema_validators/server_lowlevel.py +++ b/examples/stories/schema_validators/server_lowlevel.py @@ -2,7 +2,8 @@ from typing import Any -from mcp import types +import mcp_types as types + from mcp.server.context import ServerRequestContext from mcp.server.lowlevel import Server from stories._hosting import run_server_from_args diff --git a/examples/stories/serve_one/client.py b/examples/stories/serve_one/client.py index da114f8519..73bd457e10 100644 --- a/examples/stories/serve_one/client.py +++ b/examples/stories/serve_one/client.py @@ -1,8 +1,9 @@ """Drive `handle_one` directly to assert the raw result-dict shape, then over the wire.""" -from mcp import types +import mcp_types as types +from mcp_types.version import LATEST_MODERN_VERSION + from mcp.client import Client -from mcp.shared.version import LATEST_MODERN_VERSION from stories._harness import Target, run_client from stories.serve_one.server import build_server, handle_one diff --git a/examples/stories/serve_one/server.py b/examples/stories/serve_one/server.py index f33a03fdaf..232ebf2bec 100644 --- a/examples/stories/serve_one/server.py +++ b/examples/stories/serve_one/server.py @@ -13,8 +13,9 @@ from typing import Any import anyio +import mcp_types as types +from mcp_types.version import LATEST_MODERN_VERSION -from mcp import types from mcp.server.connection import Connection # deep-path import; shorter re-export planned from mcp.server.context import ServerRequestContext from mcp.server.lowlevel import Server @@ -22,7 +23,6 @@ from mcp.server.stdio import stdio_server from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher from mcp.shared.transport_context import TransportContext -from mcp.shared.version import LATEST_MODERN_VERSION __all__ = ["SingleExchangeContext", "build_server", "handle_one"] diff --git a/examples/stories/sse_polling/client.py b/examples/stories/sse_polling/client.py index 39cec5dc93..d2f3918952 100644 --- a/examples/stories/sse_polling/client.py +++ b/examples/stories/sse_polling/client.py @@ -1,9 +1,9 @@ """Call a tool whose SSE stream the server closes mid-flight; the call still completes. HTTP-only — no SSE on stdio.""" import anyio +from mcp_types import TextContent from mcp.client import Client -from mcp.types import TextContent from stories._harness import Target, run_client diff --git a/examples/stories/sse_polling/event_store.py b/examples/stories/sse_polling/event_store.py index 1cd24827a7..95d2b8accf 100644 --- a/examples/stories/sse_polling/event_store.py +++ b/examples/stories/sse_polling/event_store.py @@ -4,8 +4,9 @@ this interface with persistent storage so replay survives a process restart. """ +from mcp_types import JSONRPCMessage + from mcp.server.streamable_http import EventCallback, EventId, EventMessage, EventStore, StreamId -from mcp.types import JSONRPCMessage class InMemoryEventStore(EventStore): diff --git a/examples/stories/sse_polling/server_lowlevel.py b/examples/stories/sse_polling/server_lowlevel.py index 9d9ce85bd3..fcf3199861 100644 --- a/examples/stories/sse_polling/server_lowlevel.py +++ b/examples/stories/sse_polling/server_lowlevel.py @@ -2,9 +2,9 @@ from typing import Any +import mcp_types as types from starlette.applications import Starlette -from mcp import types from mcp.server.context import ServerRequestContext from mcp.server.lowlevel import Server from stories._hosting import NO_DNS_REBIND, run_app_from_args diff --git a/examples/stories/standalone_get/client.py b/examples/stories/standalone_get/client.py index 738d4be92c..aaf870f0e7 100644 --- a/examples/stories/standalone_get/client.py +++ b/examples/stories/standalone_get/client.py @@ -1,8 +1,8 @@ """Receive `notifications/resources/list_changed` over the standalone GET stream, then re-list.""" import anyio +import mcp_types as types -from mcp import types from mcp.client import Client from stories._harness import Target, run_client diff --git a/examples/stories/standalone_get/server_lowlevel.py b/examples/stories/standalone_get/server_lowlevel.py index 09c8cbd84b..21ee8c1f1b 100644 --- a/examples/stories/standalone_get/server_lowlevel.py +++ b/examples/stories/standalone_get/server_lowlevel.py @@ -3,7 +3,8 @@ import itertools from typing import Any -from mcp import types +import mcp_types as types + from mcp.server.context import ServerRequestContext from mcp.server.lowlevel import Server from stories._hosting import run_server_from_args diff --git a/examples/stories/starlette_mount/client.py b/examples/stories/starlette_mount/client.py index c286577354..dcfc3495b3 100644 --- a/examples/stories/starlette_mount/client.py +++ b/examples/stories/starlette_mount/client.py @@ -1,7 +1,8 @@ """Connect to the sub-mounted MCP endpoint at /api/, list tools and call greet. HTTP-only: the mount is the story.""" +from mcp_types import TextContent + from mcp.client import Client -from mcp.types import TextContent from stories._harness import Target, run_client diff --git a/examples/stories/stateless_legacy/client.py b/examples/stories/stateless_legacy/client.py index 72d8ea4596..d21ff850cf 100644 --- a/examples/stories/stateless_legacy/client.py +++ b/examples/stories/stateless_legacy/client.py @@ -1,8 +1,9 @@ """Connect at each era — two connections, so `main` takes `targets`; the same stateless app answers both.""" +from mcp_types import TextContent +from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION + from mcp.client import Client -from mcp.shared.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION -from mcp.types import TextContent from stories._harness import TargetFactory, run_client diff --git a/examples/stories/stateless_legacy/server_lowlevel.py b/examples/stories/stateless_legacy/server_lowlevel.py index 4a4433696b..44943abd3d 100644 --- a/examples/stories/stateless_legacy/server_lowlevel.py +++ b/examples/stories/stateless_legacy/server_lowlevel.py @@ -2,9 +2,9 @@ from typing import Any +import mcp_types as types from starlette.applications import Starlette -from mcp import types from mcp.server.context import ServerRequestContext from mcp.server.lowlevel import Server from stories._hosting import NO_DNS_REBIND, run_app_from_args diff --git a/examples/stories/stickynotes/client.py b/examples/stories/stickynotes/client.py index 288ab4caae..56ca10f551 100644 --- a/examples/stories/stickynotes/client.py +++ b/examples/stories/stickynotes/client.py @@ -1,10 +1,10 @@ """Drive the sticky-notes board end to end and prove `remove_all` clears only on a confirmed elicitation.""" import anyio +import mcp_types as types +from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS -from mcp import types from mcp.client import Client, ClientRequestContext -from mcp.shared.version import HANDSHAKE_PROTOCOL_VERSIONS from stories._harness import Target, run_client diff --git a/examples/stories/stickynotes/server_lowlevel.py b/examples/stories/stickynotes/server_lowlevel.py index c4665cccd8..15a20a797d 100644 --- a/examples/stories/stickynotes/server_lowlevel.py +++ b/examples/stories/stickynotes/server_lowlevel.py @@ -5,7 +5,8 @@ from dataclasses import dataclass, field from typing import Any -from mcp import types +import mcp_types as types + from mcp.server.context import ServerRequestContext from mcp.server.lowlevel import Server from stories._hosting import run_server_from_args diff --git a/examples/stories/streaming/client.py b/examples/stories/streaming/client.py index 99398265ce..e584b4c1ef 100644 --- a/examples/stories/streaming/client.py +++ b/examples/stories/streaming/client.py @@ -1,9 +1,9 @@ """Asserts progress + log notifications arrive in order, then cancels a call mid-flight.""" import anyio +from mcp_types import LoggingMessageNotificationParams from mcp.client import Client -from mcp.types import LoggingMessageNotificationParams from stories._harness import Target, run_client diff --git a/examples/stories/streaming/server.py b/examples/stories/streaming/server.py index 0d917def60..ced59878d7 100644 --- a/examples/stories/streaming/server.py +++ b/examples/stories/streaming/server.py @@ -1,8 +1,8 @@ """Progress, in-flight logging, and cancellation from a single long-running tool.""" import anyio +import mcp_types as types -from mcp import types from mcp.server.mcpserver import Context, MCPServer from stories._hosting import run_server_from_args diff --git a/examples/stories/streaming/server_lowlevel.py b/examples/stories/streaming/server_lowlevel.py index 17ee17c15e..07daf641b4 100644 --- a/examples/stories/streaming/server_lowlevel.py +++ b/examples/stories/streaming/server_lowlevel.py @@ -3,8 +3,8 @@ from typing import Any import anyio +import mcp_types as types -from mcp import types from mcp.server.context import ServerRequestContext from mcp.server.lowlevel import Server from stories._hosting import run_server_from_args diff --git a/examples/stories/tools/client.py b/examples/stories/tools/client.py index 55c22e3b64..74e1ab4c0f 100644 --- a/examples/stories/tools/client.py +++ b/examples/stories/tools/client.py @@ -1,7 +1,8 @@ """List tools, inspect schemas + annotations, call both tools, assert structured output.""" +from mcp_types import TextContent + from mcp.client import Client -from mcp.types import TextContent from stories._harness import Target, run_client diff --git a/examples/stories/tools/server.py b/examples/stories/tools/server.py index 93e4398092..a1f035c26a 100644 --- a/examples/stories/tools/server.py +++ b/examples/stories/tools/server.py @@ -2,10 +2,10 @@ from typing import Literal +from mcp_types import ToolAnnotations from pydantic import BaseModel from mcp.server.mcpserver import MCPServer -from mcp.types import ToolAnnotations from stories._hosting import run_server_from_args diff --git a/examples/stories/tools/server_lowlevel.py b/examples/stories/tools/server_lowlevel.py index 8d670914d6..e6c4c05ef7 100644 --- a/examples/stories/tools/server_lowlevel.py +++ b/examples/stories/tools/server_lowlevel.py @@ -2,7 +2,8 @@ from typing import Any -from mcp import types +import mcp_types as types + from mcp.server.context import ServerRequestContext from mcp.server.lowlevel import Server from stories._hosting import run_server_from_args diff --git a/tests/examples/conftest.py b/tests/examples/conftest.py index d196a66919..ffe22caad8 100644 --- a/tests/examples/conftest.py +++ b/tests/examples/conftest.py @@ -22,12 +22,12 @@ import httpx import pytest import stories +from mcp_types.version import LATEST_MODERN_VERSION from starlette.applications import Starlette from stories._harness import AuthBuilder, TargetFactory from stories._hosting import asgi_from from mcp.client.streamable_http import streamable_http_client -from mcp.shared.version import LATEST_MODERN_VERSION from tests.interaction.transports._bridge import StreamingASGITransport if sys.version_info >= (3, 11): # pragma: lax no cover