diff --git a/.github/actions/conformance/client.py b/.github/actions/conformance/client.py index 2a7fd14681..4f3a93978b 100644 --- a/.github/actions/conformance/client.py +++ b/.github/actions/conformance/client.py @@ -17,6 +17,9 @@ initialize - Connect, initialize, list tools, close tools_call - Connect, call add_numbers(a=5, b=3), close sse-retry - Connect, call test_reconnection, close + json-schema-ref-no-deref - Connect, list tools (no $ref deref) + request-metadata - Connect with all callbacks; client stamps _meta + http-standard-headers - Connect, call a tool (Mcp-* headers checked) elicitation-sep1034-client-defaults - Elicitation with default accept callback auth/client-credentials-jwt - Client credentials with private_key_jwt auth/client-credentials-basic - Client credentials with client_secret_basic @@ -35,16 +38,18 @@ import httpx from pydantic import AnyUrl -from mcp import ClientSession, types +from mcp import types from mcp.client.auth import OAuthClientProvider, TokenStorage from mcp.client.auth.extensions.client_credentials import ( ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider, SignedJWTParameters, ) +from mcp.client.client import Client from mcp.client.context import ClientRequestContext from mcp.client.streamable_http import streamable_http_client from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata, OAuthToken +from mcp.shared.version import MODERN_PROTOCOL_VERSIONS # Set up logging to stderr (stdout is for conformance test output) logging.basicConfig( @@ -58,10 +63,24 @@ #: "2026-07-28"). The harness always sets this (when --spec-version is omitted #: it picks per-scenario: LATEST_SPEC_VERSION for active scenarios, #: DRAFT_PROTOCOL_VERSION for draft-only ones), so None means we were invoked -#: outside the harness. Handlers that need to take the stateless 2026 path will -#: branch on this once the SDK has one; today it is logged only. +#: outside the harness. PROTOCOL_VERSION: str | None = os.environ.get("MCP_CONFORMANCE_PROTOCOL_VERSION") + +def client_mode() -> str: + """Pick the Client(mode=) for the harness leg. + + On a modern leg (2026-07-28+) -> 'auto' so Client.discover() runs and the + _meta envelope + MCP-Protocol-Version header are stamped on every request. + On a handshake-era leg -> 'legacy' so the initialize handshake runs exactly + as before (no server/discover probe is sent against a mock that would 400 it). + Outside the harness -> 'auto' (probe + fallback). + """ + if PROTOCOL_VERSION is None or PROTOCOL_VERSION in MODERN_PROTOCOL_VERSIONS: + return "auto" + return "legacy" + + # Type for async scenario handler functions ScenarioHandler = Callable[[str], Coroutine[Any, None, None]] @@ -165,52 +184,22 @@ async def handle_callback(self) -> AuthorizationCodeResult: return result -# --- Scenario Handlers --- - - -@register("initialize") -async def run_initialize(server_url: str) -> None: - """Connect, initialize, list tools, close.""" - async with streamable_http_client(url=server_url) as (read_stream, write_stream): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - logger.debug("Initialized successfully") - await session.list_tools() - logger.debug("Listed tools successfully") - - -@register("json-schema-ref-no-deref") -async def run_json_schema_ref_no_deref(server_url: str) -> None: - """Initialize and list tools; the scenario fails only if the client fetches a network $ref. - - ClientSession never walks inputSchema or resolves $refs, so listing is enough (SEP-2106). - """ - async with streamable_http_client(url=server_url) as (read_stream, write_stream): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - await session.list_tools() +# --- Stub callbacks (declare capabilities in _meta without doing real work) --- -@register("tools_call") -async def run_tools_call(server_url: str) -> None: - """Connect, initialize, list tools, call add_numbers(a=5, b=3), close.""" - async with streamable_http_client(url=server_url) as (read_stream, write_stream): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - await session.list_tools() - result = await session.call_tool("add_numbers", {"a": 5, "b": 3}) - logger.debug(f"add_numbers result: {result}") +async def stub_sampling_callback( + context: ClientRequestContext, + params: types.CreateMessageRequestParams, +) -> types.CreateMessageResult | types.ErrorData: + return types.CreateMessageResult( + role="assistant", + content=types.TextContent(type="text", text=""), + model="conformance-stub", + ) -@register("sse-retry") -async def run_sse_retry(server_url: str) -> None: - """Connect, initialize, list tools, call test_reconnection, close.""" - async with streamable_http_client(url=server_url) as (read_stream, write_stream): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - await session.list_tools() - result = await session.call_tool("test_reconnection", {}) - logger.debug(f"test_reconnection result: {result}") +async def stub_list_roots_callback(context: ClientRequestContext) -> types.ListRootsResult | types.ErrorData: + return types.ListRootsResult(roots=[]) async def default_elicitation_callback( @@ -233,17 +222,87 @@ async def default_elicitation_callback( return types.ElicitResult(action="accept", content=content) +# --- Scenario Handlers --- + + +@register("initialize") +async def run_initialize(server_url: str) -> None: + """Connect, initialize, list tools, close.""" + async with Client(server_url, mode=client_mode()) as client: + logger.debug("Initialized successfully") + await client.list_tools() + logger.debug("Listed tools successfully") + + +@register("json-schema-ref-no-deref") +async def run_json_schema_ref_no_deref(server_url: str) -> None: + """Initialize and list tools; the scenario fails only if the client fetches a network $ref. + + The client never walks inputSchema or resolves $refs, so listing is enough (SEP-2106). + Pinned to mode='legacy': the harness reports PROTOCOL_VERSION=2026-07-28 for this + scenario but its mock server only speaks the handshake-era lifecycle and 400s a + modern-stamped tools/list. The check is lifecycle-agnostic so this is harmless. + """ + async with Client(server_url, mode="legacy") as client: + await client.list_tools() + + +@register("tools_call") +async def run_tools_call(server_url: str) -> None: + """Connect, list tools, call add_numbers(a=5, b=3), close.""" + async with Client(server_url, mode=client_mode()) as client: + await client.list_tools() + result = await client.call_tool("add_numbers", {"a": 5, "b": 3}) + logger.debug(f"add_numbers result: {result}") + + +@register("sse-retry") +async def run_sse_retry(server_url: str) -> None: + """Connect, list tools, call test_reconnection, close.""" + async with Client(server_url, mode=client_mode()) as client: + await client.list_tools() + result = await client.call_tool("test_reconnection", {}) + logger.debug(f"test_reconnection result: {result}") + + +@register("request-metadata") +async def run_request_metadata(server_url: str) -> None: + """Connect on the modern path with every client capability declared. + + The scenario inspects every request's `_meta` envelope (SEP-2575) for + protocolVersion / clientInfo / clientCapabilities, and the matching + MCP-Protocol-Version header. mode='auto' makes the SDK send + server/discover (covering the unsupported-version retry check), then adopt + and stamp the envelope on the follow-up requests. + """ + async with Client( + server_url, + mode=client_mode(), + sampling_callback=stub_sampling_callback, + list_roots_callback=stub_list_roots_callback, + elicitation_callback=default_elicitation_callback, + ) as client: + await client.list_tools() + result = await client.call_tool("add_numbers", {"a": 5, "b": 3}) + logger.debug(f"add_numbers result: {result}") + + +@register("http-standard-headers") +async def run_http_standard_headers(server_url: str) -> None: + """Connect on the modern path so Mcp-Method / Mcp-Name / MCP-Protocol-Version are sent (SEP-2243).""" + async with Client(server_url, mode=client_mode()) as client: + await client.list_tools() + result = await client.call_tool("add_numbers", {"a": 5, "b": 3}) + logger.debug(f"add_numbers result: {result}") + + @register("elicitation-sep1034-client-defaults") async def run_elicitation_defaults(server_url: str) -> None: """Connect with elicitation callback that applies schema defaults.""" - async with streamable_http_client(url=server_url) as (read_stream, write_stream): - async with ClientSession( - read_stream, write_stream, elicitation_callback=default_elicitation_callback - ) as session: - await session.initialize() - await session.list_tools() - result = await session.call_tool("test_client_elicitation_defaults", {}) - logger.debug(f"test_client_elicitation_defaults result: {result}") + async with Client(server_url, mode=client_mode(), elicitation_callback=default_elicitation_callback) as client: + await client.list_tools() + result = await client.call_tool("test_client_elicitation_defaults", {}) + logger.debug(f"test_client_elicitation_defaults result: {result}") @register("auth/client-credentials-jwt") @@ -343,25 +402,22 @@ async def run_auth_code_client(server_url: str) -> None: async def _run_auth_session(server_url: str, oauth_auth: OAuthClientProvider) -> None: """Common session logic for all OAuth flows.""" - client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0) - async with streamable_http_client(url=server_url, http_client=client) as (read_stream, write_stream): - async with ClientSession( - read_stream, write_stream, elicitation_callback=default_elicitation_callback - ) as session: - await session.initialize() - logger.debug("Initialized successfully") - - tools_result = await session.list_tools() - logger.debug(f"Listed tools: {[t.name for t in tools_result.tools]}") - - # Call the first available tool (different tests have different tools) - if tools_result.tools: - tool_name = tools_result.tools[0].name - try: - result = await session.call_tool(tool_name, {}) - logger.debug(f"Called {tool_name}, result: {result}") - except Exception as e: - logger.debug(f"Tool call result/error: {e}") + http_client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0) + transport = streamable_http_client(url=server_url, http_client=http_client) + async with Client(transport, mode=client_mode(), elicitation_callback=default_elicitation_callback) as client: + logger.debug("Initialized successfully") + + tools_result = await client.list_tools() + logger.debug(f"Listed tools: {[t.name for t in tools_result.tools]}") + + # Call the first available tool (different tests have different tools) + if tools_result.tools: + tool_name = tools_result.tools[0].name + try: + result = await client.call_tool(tool_name, {}) + logger.debug(f"Called {tool_name}, result: {result}") + except Exception as e: + logger.debug(f"Tool call result/error: {e}") logger.debug("Connection closed successfully") @@ -374,7 +430,7 @@ def main() -> None: server_url = sys.argv[1] scenario = os.environ.get("MCP_CONFORMANCE_SCENARIO") - logger.debug(f"Conformance protocol version: {PROTOCOL_VERSION!r}") + logger.debug(f"Conformance protocol version: {PROTOCOL_VERSION!r} -> mode={client_mode()!r}") if scenario: logger.debug(f"Running explicit scenario '{scenario}' against {server_url}") @@ -384,6 +440,9 @@ def main() -> None: elif scenario.startswith("auth/"): asyncio.run(run_auth_code_client(server_url)) else: + # Unhandled scenarios: + # - sep-2322-client-request-state (SEP-2322 / S6: MRTR client loop) + # - http-custom-headers, http-invalid-tool-headers (SEP-2243 / S8: Mcp-Param-* headers) print(f"Unknown scenario: {scenario}", file=sys.stderr) sys.exit(1) else: diff --git a/.github/actions/conformance/expected-failures.2026-07-28.yml b/.github/actions/conformance/expected-failures.2026-07-28.yml index b49626d0d6..529eb8babe 100644 --- a/.github/actions/conformance/expected-failures.2026-07-28.yml +++ b/.github/actions/conformance/expected-failures.2026-07-28.yml @@ -21,36 +21,13 @@ # milestone. client: - # --- No stateless client path on main yet --- - # client.py drives the 2025 stateful lifecycle (initialize handshake + - # session). The 2026-mode mock server is stateless, so the call sequence - # never reaches the assertion. Unblocks when client.py's is_modern_protocol() - # branch takes the per-request _meta path. - - tools_call - - # --- Auth scenarios cut short by the 2026 connection lifecycle --- - # The auth fixture flow drives the 2025 stateful lifecycle; the 2026-mode - # mock rejects the MCP POST before the scope-escalation behaviour these - # scenarios measure, so no authorization requests are observed. Unblocks - # when client.py's auth flow speaks the 2026 per-request lifecycle. - - auth/scope-step-up - - auth/scope-retry-limit - # --- Same gaps as the 2025 baseline (fail identically when forced to 2026-07-28) --- - # SEP-2575 (request metadata / _meta envelope): client does not populate the - # _meta envelope or the MCP-Protocol-Version header semantics yet. - - request-metadata # SEP-2322 (multi-round-trip requests): client does not echo requestState / # handle IncompleteResult yet. - sep-2322-client-request-state - # SEP-2243 (HTTP standardization): no fixture handler / client header support yet. + # SEP-2243 (HTTP standardization): no fixture handler / client Mcp-Param-* support yet. - http-custom-headers - http-invalid-tool-headers - # SEP-2352 (authorization server migration): the client re-registers and does not reuse the old - # AS credentials, but the 2026-mode mock rejects the MCP POST before the migration 401 fires - # (client.py drives the 2025 stateful lifecycle), so the re-register check is never reached. - # Unblocks with the 2026 stateless client lifecycle. - - auth/authorization-server-migration # auth/enterprise-managed-authorization (SEP-990) is in the 2025 baseline but # NOT here: the harness skips it as inapplicable at --spec-version 2026-07-28 # (it is an extension scenario not carried into the 2026 wire), so it is diff --git a/.github/actions/conformance/expected-failures.yml b/.github/actions/conformance/expected-failures.yml index 4234a6d4aa..2a411b4cde 100644 --- a/.github/actions/conformance/expected-failures.yml +++ b/.github/actions/conformance/expected-failures.yml @@ -12,20 +12,12 @@ client: # --- Draft-spec scenarios (in `--suite draft`, also part of `--suite all`) --- - # SEP-2575 (request metadata / _meta envelope): client does not populate the - # _meta envelope or the MCP-Protocol-Version header semantics yet. - - request-metadata # SEP-2322 (multi-round-trip requests): client does not echo requestState / # handle IncompleteResult yet. - sep-2322-client-request-state - # SEP-2243 (HTTP standardization): no fixture handler / client header support yet. + # SEP-2243 (HTTP standardization): no fixture handler / client Mcp-Param-* support yet. - http-custom-headers - http-invalid-tool-headers - # SEP-2352 (authorization server migration): the client re-registers and does not reuse the old - # AS credentials, but this 2026-introduced scenario runs at 2026-07-28, where client.py's 2025 - # stateful lifecycle is rejected (400 on initialize) before the migration 401 fires, so the - # re-register check is never reached. Unblocks with the 2026 stateless client lifecycle. - - auth/authorization-server-migration # --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 --- # SEP-990 (enterprise-managed authorization extension): no fixture handler / 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/docs/migration.md b/docs/migration.md index bf06690c45..f35721dcc9 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -159,6 +159,10 @@ The `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters have been re Note: `sse_client` retains its `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters — only the streamable HTTP transport changed. +### `StreamableHTTPTransport.protocol_version` attribute removed + +The transport no longer holds per-connection protocol state; era-dependent headers (e.g. `MCP-Protocol-Version`) are now supplied per-message by the session. If you were reading `transport.protocol_version` to learn the negotiated version, read `session.protocol_version` (or `client.protocol_version` on the high-level `Client`) instead. + ### `terminate_windows_process` removed The deprecated `mcp.os.win32.utilities.terminate_windows_process` function has been @@ -326,9 +330,9 @@ result = await session.list_resources(params=PaginatedRequestParams(cursor="next result = await session.list_tools(params=PaginatedRequestParams(cursor="next_page_token")) ``` -### `ClientSession.get_server_capabilities()` replaced by `initialize_result` property +### `ClientSession.get_server_capabilities()` replaced by era-neutral accessors -`ClientSession` now stores the full `InitializeResult` via an `initialize_result` property. This provides access to `server_info`, `capabilities`, `instructions`, and the negotiated `protocol_version` through a single property. The `get_server_capabilities()` method has been removed. +`ClientSession` now exposes the negotiated server metadata as properties: `server_capabilities`, `server_info`, `instructions`, and `protocol_version`. These are populated by whichever connection step ran (`initialize()` for ≤2025-11-25 servers, `discover()` for 2026-07-28+), and are `None` if none has — matching v1's `get_server_capabilities()`. The `get_server_capabilities()` method has been removed. **Before (v1):** @@ -340,15 +344,15 @@ capabilities = session.get_server_capabilities() **After (v2):** ```python -result = session.initialize_result -if result is not None: - capabilities = result.capabilities - server_info = result.server_info - instructions = result.instructions - version = result.protocol_version +capabilities = session.server_capabilities +server_info = session.server_info +instructions = session.instructions +version = session.protocol_version ``` -The high-level `Client.initialize_result` returns the same `InitializeResult` but is non-nullable — initialization is guaranteed inside the context manager, so no `None` check is needed. This replaces v1's `Client.server_capabilities`; use `client.initialize_result.capabilities` instead. +The raw handshake result is also retained: `session.initialize_result` is set after `initialize()` (≤2025-11-25 servers — including `stateless_http=True` servers, which still answer `initialize`); `session.discover_result` is set after `discover()` (2026-07-28+ servers). At most one is non-`None`. + +On the high-level `Client`, `client.server_capabilities`, `client.server_info`, and `client.protocol_version` are non-nullable inside the context manager. `client.instructions` remains `str | None` since the server may omit it. (The lowlevel `ClientSession` still lets you call methods before any handshake, as in v1; `Client` always handshakes on enter.) ### `McpError` renamed to `MCPError` @@ -764,6 +768,10 @@ async def my_tool(ctx: Context) -> str: ... async def my_tool(ctx: Context[MyLifespanState]) -> str: ... ``` +### Version constants + +`SUPPORTED_PROTOCOL_VERSIONS` is deprecated — it's now the union of `HANDSHAKE_PROTOCOL_VERSIONS` (initialize-handshake versions) and `MODERN_PROTOCOL_VERSIONS` (per-request-envelope versions). If you were using it to mean "versions the initialize handshake accepts", switch to `HANDSHAKE_PROTOCOL_VERSIONS`. Named scalars derived from these tuples are now exported alongside them — `LATEST_HANDSHAKE_VERSION`, `LATEST_MODERN_VERSION`, `OLDEST_SUPPORTED_VERSION` — so prefer those over indexing the tuples directly. + ### `ProgressContext` and `progress()` context manager removed The `mcp.shared.progress` module (`ProgressContext`, `Progress`, and the `progress()` context manager) has been removed. This module had no real-world adoption — all users send progress notifications via `Context.report_progress()` or `session.send_progress_notification()` directly. @@ -796,6 +804,12 @@ await session.send_progress_notification( ) ``` +### Handler progress reporting: prefer `ctx.report_progress()` over manual `progress_token` + +Reading `ctx.meta["progress_token"]` and calling `session.send_progress_notification(token, ...)` is specific to the JSON-RPC transport path. On the in-process modern path (`DirectDispatcher` / `Client(server)`), there is no wire token in `_meta`, so handlers that gate progress on the token's presence go silent. + +`ctx.report_progress(progress, total, message)` works on every dispatcher: it sends a progress notification when a token is present and routes the update through the dispatcher's progress channel otherwise, no-opping only when the caller did not request progress at all. `session.send_progress_notification(progress_token, ...)` is unchanged and still works on JSON-RPC transports for code that already holds a token. + ### `create_connected_server_and_client_session` removed The `create_connected_server_and_client_session` helper in `mcp.shared.memory` has been removed. Use `mcp.client.Client` instead — it accepts a `Server` or `MCPServer` instance directly and handles the in-memory transport and session setup for you. @@ -1289,6 +1303,12 @@ warnings.filterwarnings("ignore", category=MCPDeprecationWarning) No migration is required during the deprecation window. New code should avoid building on these features, since they may be removed in a future spec version. +### Client-to-server progress deprecated (2026-07-28) + +The 2026-07-28 spec restricts `notifications/progress` to the server-to-client direction only — `ProgressNotification` is no longer in `ClientNotification`. `Client.send_progress_notification()` and `ClientSession.send_progress_notification()` now carry `typing_extensions.deprecated` and emit `mcp.MCPDeprecationWarning` at runtime. They continue to work against servers negotiating 2025-11-25 or earlier. + +On the server side, prefer the new dispatcher-agnostic `ServerSession.report_progress(progress, total, message)` (and `Context.report_progress()` on `MCPServer`) over the raw `ServerSession.send_progress_notification(progress_token, …)`. `report_progress` encapsulates the "no-op when the caller did not request progress" rule and works on every dispatcher; the raw token-taking form remains for handlers that read `_meta.progressToken` directly. + ## Bug Fixes ### OAuth metadata URLs no longer gain a trailing slash 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..a762ec147e --- /dev/null +++ b/examples/stories/README.md @@ -0,0 +1,144 @@ +# Story examples + +One feature per folder. Each story is a small, self-verifying program: a +`server.py` (plus, where the wire contract is worth seeing by hand, a +`server_lowlevel.py`) and a `client.py` whose `main()` makes assertions and +exits non-zero on failure. The code you read here is the same code CI runs — +there is no separate test double. + +## Canonical shape + +Every `client.py` starts from this skeleton — copy it, then replace the body +with the story's assertions: + +```python +"""One line: what this client proves.""" + +from mcp.client import Client +from stories._harness import Target, run_client + + +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode) as client: + ... # the story's assertions + + +if __name__ == "__main__": + run_client(main) +``` + +There are exactly two `main` shapes. A story that opens **one** connection +takes `main(target: Target, ...)`. A story that opens **more than one** sets +`multi_connection = true` in [`manifest.toml`](manifest.toml), takes +`main(targets: TargetFactory, ...)`, and calls `targets()` once per fresh +connection — a `Client` cannot be re-entered after exit. Nothing else changes +shape. + +Story files import from `stories._harness` only these names: `run_client`, +`target_from_args`, `Target`, `TargetFactory` — plus `AuthBuilder` for the +auth stories. Everything else a story uses comes from public `mcp.*` modules. + +The repetition this produces across stories is deliberate, not a refactor +waiting to happen: each `client.py` is a standalone, compiled doc page, so +when a public API changes, N red example files flag N doc pages. Don't pull +the `Client(target, mode=mode)` line (or anything around it) into a shared +helper. A story that can't be the canonical shape says why in its module +docstring's first line. + +## How to read a story + +Start with the story's README, then `server.py`, then `client.py`. Every +`client.py` exports `async def main(target, *, mode="auto")` — or +`main(targets, ...)` for the stories that open more than one connection — and +constructs the `Client` itself, so the body opens with the one line a client +example exists to teach: `async with Client(target, mode=mode) as client:`. +The `run_client(main)` call in the `__main__` block is only argv plumbing +(stdio vs `--http`, which `mode` to pass); it never hides how the client +connects. + +## Running a story + +From the repository root: + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.tools.client + +# 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, status, +and variants; `tests/examples/` expands it. + +## Layout + +`_hosting.py` adapts a story's `build_server()` / `build_app()` to argv (stdio +vs `--http` serving); `_harness.py` is the client-side mirror — it picks the +`target` that `main()` connects to (a stdio subprocess by default, a 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 | current | +| [`prompts`](prompts/) | `@mcp.prompt()`, list/get, argument completion | current | +| [`resources`](resources/) | `@mcp.resource()`, list/read, URI templates | current | +| [`lifespan`](lifespan/) | startup/shutdown lifespan, per-request state injection | current | +| [`dual_era`](dual_era/) | one server factory serving both protocol eras; era-neutral accessors | current | +| **— feature stories —** | | | +| [`streaming`](streaming/) | progress notifications, in-flight logging, cancellation | current | +| [`legacy_elicitation`](legacy_elicitation/) | server pauses a tool to ask the user (form + url) via a push request | legacy | +| [`sampling`](sampling/) | server asks the client's LLM mid-tool (push request) | deprecated | +| [`stickynotes`](stickynotes/) | capstone: tools mutate state → resources + `list_changed` + elicit guard | current | +| [`custom_methods`](custom_methods/) | vendor-prefixed JSON-RPC via `add_request_handler` / `send_request` | current | +| [`schema_validators`](schema_validators/) | tool input schema from pydantic / TypedDict / dataclass / dict | current | +| [`middleware`](middleware/) | server-side request/response middleware | current | +| [`parallel_calls`](parallel_calls/) | two clients rendezvous in one tool; per-call progress attribution | current | +| [`roots`](roots/) | client-declared roots, server reads them via `ctx` | deprecated | +| [`pagination`](pagination/) | manual cursor loop over list endpoints | current | +| [`error_handling`](error_handling/) | `is_error` results vs `MCPError`; `ToolError` | current | +| [`serve_one`](serve_one/) | building a `Connection` by hand and calling `serve_one` directly | current | +| **— HTTP hosting —** | | | +| [`stateless_legacy`](stateless_legacy/) | `streamable_http_app()` 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) | +| [`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..6f4d6055a7 --- /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 ``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 new file mode 100644 index 0000000000..39718b23ed --- /dev/null +++ b/examples/stories/_harness.py @@ -0,0 +1,136 @@ +"""Client-side scaffold for story examples. + +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 Awaitable, Callable +from pathlib import Path +from typing import Any, TypeAlias +from urllib.parse import urlsplit + +import anyio +import httpx + +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 +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.""" + +TargetFactory = Callable[[], Target] +"""Yields a FRESH target against the same server/app on every call (``multi_connection`` stories).""" + +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: + """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 target_from_args(file: str) -> TargetFactory: + """Build a ``TargetFactory`` for the sibling server over the argv-selected transport. + + ``--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__``. + """ + if "--http" in sys.argv: + 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``. + + 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. + """ + 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" + 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 main(target, mode=mode) + + try: + anyio.run(_run) + 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..9849057363 --- /dev/null +++ b/examples/stories/bearer_auth/README.md @@ -0,0 +1,89 @@ +# 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 +``` + +`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 +# 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 + +- `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. +- `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`. + +## 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..5c419a0716 --- /dev/null +++ b/examples/stories/bearer_auth/client.py @@ -0,0 +1,48 @@ +"""Call the bearer-gated server through an already-authed (``build_auth``, HTTP-only) transport; assert ``whoami``.""" + +from collections.abc import Generator + +import httpx + +from mcp.client import Client +from stories._harness import Target, run_client + +from .server import DEMO_TOKEN, REQUIRED_SCOPE + + +class StaticBearerAuth(httpx.Auth): + """``httpx.Auth`` that attaches a fixed ``Authorization: Bearer `` to every request.""" + + def __init__(self, token: str) -> None: + self.token = token + + 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__": + run_client(main) diff --git a/examples/stories/bearer_auth/server.py b/examples/stories/bearer_auth/server.py new file mode 100644 index 0000000000..45c9872c3a --- /dev/null +++ b/examples/stories/bearer_auth/server.py @@ -0,0 +1,56 @@ +"""Resource-server-only bearer auth: ``TokenVerifier``/``AuthSettings`` → 401/PRM/principal. Exports ``build_app()``.""" + +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/custom_methods/README.md b/examples/stories/custom_methods/README.md new file mode 100644 index 0000000000..54fcb4785f --- /dev/null +++ b/examples/stories/custom_methods/README.md @@ -0,0 +1,54 @@ +# 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 + +- `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. +- `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..df9a4b89d9 --- /dev/null +++ b/examples/stories/custom_methods/client.py @@ -0,0 +1,38 @@ +"""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 Target, 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 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(main) diff --git a/examples/stories/custom_methods/server.py b/examples/stories/custom_methods/server.py new file mode 100644 index 0000000000..a08e285ae5 --- /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("custom-methods-example") + + 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/dual_era/README.md b/examples/stories/dual_era/README.md new file mode 100644 index 0000000000..4ed87722cc --- /dev/null +++ b/examples/stories/dual_era/README.md @@ -0,0 +1,60 @@ +# 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 +``` + +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 + +- `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. +- `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 + +- `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 + +`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..ef2e8235a6 --- /dev/null +++ b/examples/stories/dual_era/client.py @@ -0,0 +1,40 @@ +"""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 +from mcp.shared.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION +from stories._harness import TargetFactory, run_client + + +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 modern.list_tools() + assert [t.name for t in listed.tools] == ["greet"] + + 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 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 + + 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(main) diff --git a/examples/stories/dual_era/server.py b/examples/stories/dual_era/server.py new file mode 100644 index 0000000000..16486a4dce --- /dev/null +++ b/examples/stories/dual_era/server.py @@ -0,0 +1,24 @@ +"""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: + # 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() + 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..a5625139dc --- /dev/null +++ b/examples/stories/dual_era/server_lowlevel.py @@ -0,0 +1,49 @@ +"""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)]) + + # 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.", + 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..81d2eabc8d --- /dev/null +++ b/examples/stories/error_handling/README.md @@ -0,0 +1,53 @@ +# 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 + +- `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. + +## 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..d4172a2d90 --- /dev/null +++ b/examples/stories/error_handling/client.py @@ -0,0 +1,37 @@ +"""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 Target, run_client + + +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 + + # 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(main) 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..1f1f630b14 --- /dev/null +++ b/examples/stories/json_response/README.md @@ -0,0 +1,63 @@ +# 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/mcp + +# 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` `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. `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 + `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..db271b0cab --- /dev/null +++ b/examples/stories/json_response/client.py @@ -0,0 +1,69 @@ +"""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 +asserts the response is a single ``application/json`` body with no session id. +""" + +import httpx + +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` / +# `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, + "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 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] = [] + + 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) + 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}" + + # 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__": + run_client(main) diff --git a/examples/stories/json_response/server.py b/examples/stories/json_response/server.py new file mode 100644 index 0000000000..c09aca78f3 --- /dev/null +++ b/examples/stories/json_response/server.py @@ -0,0 +1,27 @@ +"""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 +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_elicitation/README.md b/examples/stories/legacy_elicitation/README.md new file mode 100644 index 0000000000..55c90b2fd2 --- /dev/null +++ b/examples/stories/legacy_elicitation/README.md @@ -0,0 +1,70 @@ +# 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)). +> 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 +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.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 +``` + +## 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, + 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. +- `server_lowlevel.py` — the same flow via `ctx.session.elicit_form` / + `ctx.session.elicit_url` and a hand-written `requestedSchema`. + +## Caveats + +- **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/legacy_elicitation/__init__.py b/examples/stories/legacy_elicitation/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/legacy_elicitation/client.py b/examples/stories/legacy_elicitation/client.py new file mode 100644 index 0000000000..23b1aef7a6 --- /dev/null +++ b/examples/stories/legacy_elicitation/client.py @@ -0,0 +1,30 @@ +"""Auto-answer form and URL elicitations and assert the tool result reflects them.""" + +from mcp import types +from mcp.client import Client, ClientRequestContext +from stories._harness import Target, 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"}) + + +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 + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/legacy_elicitation/server.py b/examples/stories/legacy_elicitation/server.py new file mode 100644 index 0000000000..4999c545e0 --- /dev/null +++ b/examples/stories/legacy_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("legacy-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/legacy_elicitation/server_lowlevel.py b/examples/stories/legacy_elicitation/server_lowlevel.py new file mode 100644 index 0000000000..8dd81ec15b --- /dev/null +++ b/examples/stories/legacy_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("legacy-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/legacy_routing/README.md b/examples/stories/legacy_routing/README.md new file mode 100644 index 0000000000..e9467605f4 --- /dev/null +++ b/examples/stories/legacy_routing/README.md @@ -0,0 +1,101 @@ +# 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 +``` + +## 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 + 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. +- `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..d41856234c --- /dev/null +++ b/examples/stories/legacy_routing/client.py @@ -0,0 +1,58 @@ +"""Connect at both eras to one app — so `main` takes `targets` — and assert the built-in router and 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 TargetFactory, 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 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 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" + + # ── 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(main) diff --git a/examples/stories/legacy_routing/server.py b/examples/stories/legacy_routing/server.py new file mode 100644 index 0000000000..6b295e1a1d --- /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 — exports `build_app()`.""" + +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..932ff2c946 --- /dev/null +++ b/examples/stories/lifespan/README.md @@ -0,0 +1,49 @@ +# 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 + +- `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. +- `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..f84895cd9d --- /dev/null +++ b/examples/stories/lifespan/client.py @@ -0,0 +1,21 @@ +"""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 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() + 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(main) 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..60557ec50f --- /dev/null +++ b/examples/stories/manifest.toml @@ -0,0 +1,148 @@ +# 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" +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 ───────────────────────────── + +[story.tools] +smoke = true + +[story.prompts] + +[story.resources] + +[story.lifespan] + +[story.dual_era] +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.legacy_elicitation] +era = "legacy" +status = "legacy" + +[story.sampling] +era = "legacy" +status = "deprecated" + +[story.stickynotes] + +[story.custom_methods] +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] +# 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" +status = "deprecated" + +[story.pagination] + +[story.error_handling] + +[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" +multi_connection = 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" +multi_connection = 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" +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"] +# 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" +multi_connection = 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..3b20e04a05 --- /dev/null +++ b/examples/stories/middleware/README.md @@ -0,0 +1,54 @@ +# 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 + +- `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`. +- `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. +- `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..840d62cd15 --- /dev/null +++ b/examples/stories/middleware/client.py @@ -0,0 +1,25 @@ +"""Prove the middleware wrapped both `tools/list` and the in-flight `tools/call`.""" + +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: + 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 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(main) diff --git a/examples/stories/middleware/server.py b/examples/stories/middleware/server.py new file mode 100644 index 0000000000..f3bf8094e9 --- /dev/null +++ b/examples/stories/middleware/server.py @@ -0,0 +1,53 @@ +"""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.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..5cd4429dd1 --- /dev/null +++ b/examples/stories/mrtr/README.md @@ -0,0 +1,30 @@ +# 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 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 + +[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 + +`legacy_elicitation/` and `sampling/` — the handshake-era push equivalents that +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/README.md b/examples/stories/oauth/README.md new file mode 100644 index 0000000000..58e28275bb --- /dev/null +++ b/examples/stories/oauth/README.md @@ -0,0 +1,87 @@ +# 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 the first awaited request, 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 +``` + +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 + 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. + +## 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 +multi-connection `targets()` consumer, no auth). 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..c55307f633 --- /dev/null +++ b/examples/stories/oauth/client.py @@ -0,0 +1,61 @@ +"""HTTP-only OAuth authorization-code flow; `build_auth` supplies the provider, reconnecting needs `targets`.""" + +import httpx +from pydantic import AnyUrl + +from mcp.client import Client +from mcp.client.auth import OAuthClientProvider +from mcp.shared.auth import OAuthClientMetadata +from stories._harness import TargetFactory, run_client + +# 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: + """An `OAuthClientProvider` over fresh storage, completing the authorize redirect headlessly. + + `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 + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/oauth/server.py b/examples/stories/oauth/server.py new file mode 100644 index 0000000000..6d4c706b00 --- /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 — exports `build_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..49cefb0fbc --- /dev/null +++ b/examples/stories/oauth_client_credentials/README.md @@ -0,0 +1,73 @@ +# 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. 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. +- `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, 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. +- `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..318523ee70 --- /dev/null +++ b/examples/stories/oauth_client_credentials/client.py @@ -0,0 +1,46 @@ +"""HTTP-only: ``build_auth`` returns a ``ClientCredentialsOAuthProvider``; ``whoami`` round-trips client_id + scopes.""" + +import httpx + +from mcp.client import Client +from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider +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 + + +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. ``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, + storage=InMemoryTokenStorage(), + client_id=DEMO_CLIENT_ID, + client_secret=DEMO_CLIENT_SECRET, + scopes=DEMO_SCOPE, + ) + + +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"] + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/oauth_client_credentials/server.py b/examples/stories/oauth_client_credentials/server.py new file mode 100644 index 0000000000..7e3d910e8f --- /dev/null +++ b/examples/stories/oauth_client_credentials/server.py @@ -0,0 +1,77 @@ +"""Bearer-gated resource server + a minimal in-process ``client_credentials`` AS, one app; exports ``build_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..27ea6cb9d8 --- /dev/null +++ b/examples/stories/pagination/README.md @@ -0,0 +1,53 @@ +# 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` `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 + 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..a952a32088 --- /dev/null +++ b/examples/stories/pagination/client.py @@ -0,0 +1,27 @@ +"""Walk every page of resources/list by hand until next_cursor is absent.""" + +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: + 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(main) 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..67924e7f87 --- /dev/null +++ b/examples/stories/parallel_calls/README.md @@ -0,0 +1,56 @@ +# parallel-calls + +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 + +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: + +```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 +``` + +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.** 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(...)`. + +## 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()`). + In-memory (both eras) and legacy-era HTTP deliver progress correctly — hence + the `--legacy` above. + +## Spec + +[Progress flow](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress) + +## See also + +`streaming/` (progress + cancellation on one call), `reconnect/` (the other +multi-connection client), `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..c940053dc8 --- /dev/null +++ b/examples/stories/parallel_calls/client.py @@ -0,0 +1,40 @@ +"""Two concurrent `Client`s, so `main` takes `targets`; their rendezvous in one tool proves concurrent dispatch.""" + +import anyio + +from mcp.client import Client +from mcp.types import TextContent +from stories._harness import TargetFactory, run_client + + +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} + + async def attend(tag: str) -> None: + async def on_progress(progress: float, total: float | None, message: str | None) -> None: + received[tag].append(message) + + # 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 + + # 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(attend, "a") + tg.start_soon(attend, "b") + + assert results == {"a": "a", "b": "b"}, results + # 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(main) 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..36befc767d --- /dev/null +++ b/examples/stories/prompts/README.md @@ -0,0 +1,49 @@ +# 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 + +- `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` + + `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..d683713204 --- /dev/null +++ b/examples/stories/prompts/client.py @@ -0,0 +1,38 @@ +"""List prompts, autocomplete an argument, then render both prompts.""" + +from mcp.client import Client +from mcp.types import PromptReference, TextContent +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(main) 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..2b225a5880 --- /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 +``` + +## What to look at + +- `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 + +- `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), `parallel_calls/` (the +other multi-connection client). 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..67401aa732 --- /dev/null +++ b/examples/stories/reconnect/client.py @@ -0,0 +1,43 @@ +"""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 +from mcp.types import DiscoverResult +from stories._harness import TargetFactory, run_client + + +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 + + # 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 + + # 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 + + +if __name__ == "__main__": + run_client(main) 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..9ab7e7c4e7 --- /dev/null +++ b/examples/stories/resources/README.md @@ -0,0 +1,52 @@ +# 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 + +- `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 + 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..9e12e51e7f --- /dev/null +++ b/examples/stories/resources/client.py @@ -0,0 +1,29 @@ +"""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 Target, run_client + + +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}"] + + 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(main) 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..0936519a58 --- /dev/null +++ b/examples/stories/roots/README.md @@ -0,0 +1,57 @@ +# roots + +> **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. + +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 + +```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` `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`. +- `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 + +`legacy_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..ce18cd10dc --- /dev/null +++ b/examples/stories/roots/client.py @@ -0,0 +1,31 @@ +"""Expose two filesystem roots and verify the server's tool can read them back.""" + +from pydantic import FileUrl + +from mcp.client import Client, ClientRequestContext +from mcp.types import ListRootsResult, Root, TextContent +from stories._harness import Target, 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")), + ] + ) + + +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", {}) + + 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(main) 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..1fb060fd65 --- /dev/null +++ b/examples/stories/sampling/README.md @@ -0,0 +1,61 @@ +# 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. + +## 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` `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. +- `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 + +`legacy_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..93d3dddf1c --- /dev/null +++ b/examples/stories/sampling/client.py @@ -0,0 +1,29 @@ +"""Supply a canned sampling_callback and assert its text round-trips through the tool.""" + +from mcp.client import Client, ClientRequestContext +from mcp.types import CreateMessageRequestParams, CreateMessageResult, TextContent +from stories._harness import Target, run_client + + +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( + role="assistant", + content=TextContent(text="[canned summary]"), + model="stub-model", + stop_reason="endTurn", + ) + + +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 + + +if __name__ == "__main__": + run_client(main) 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..55de59f8b6 --- /dev/null +++ b/examples/stories/schema_validators/README.md @@ -0,0 +1,51 @@ +# 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 + +- `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. +- `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..66e990bc61 --- /dev/null +++ b/examples/stories/schema_validators/client.py @@ -0,0 +1,37 @@ +"""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 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 friend" + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/schema_validators/server.py b/examples/stories/schema_validators/server.py new file mode 100644 index 0000000000..8648e211df --- /dev/null +++ b/examples/stories/schema_validators/server.py @@ -0,0 +1,59 @@ +"""Four ways to type a tool parameter so MCPServer derives and enforces inputSchema.""" + +from dataclasses import dataclass +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 + + +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..67f163e183 --- /dev/null +++ b/examples/stories/serve_one/README.md @@ -0,0 +1,60 @@ +# 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 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_connection loop) +uv run python -m stories.serve_one.client +``` + +## What to look at + +- `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.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`, 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. +- **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 + +`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..da114f8519 --- /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 Target, run_client +from stories.serve_one.server import build_server, handle_one + + +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_server() + 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. + 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 + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/serve_one/server.py b/examples/stories/serve_one/server.py new file mode 100644 index 0000000000..f33a03fdaf --- /dev/null +++ b/examples/stories/serve_one/server.py @@ -0,0 +1,109 @@ +"""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. 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 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..c36b6f2eea --- /dev/null +++ b/examples/stories/sse_polling/README.md @@ -0,0 +1,70 @@ +# 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 +`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. + +## 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 + +- **`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 + `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. + +## 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..39cec5dc93 --- /dev/null +++ b/examples/stories/sse_polling/client.py @@ -0,0 +1,32 @@ +"""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.client import Client +from mcp.types import TextContent +from stories._harness import Target, run_client + + +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) + + 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(main) 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..1098ca6d56 --- /dev/null +++ b/examples/stories/sse_polling/server.py @@ -0,0 +1,35 @@ +"""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 + +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..086ea970c2 --- /dev/null +++ b/examples/stories/standalone_get/README.md @@ -0,0 +1,60 @@ +# 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 +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 + +- **`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. + +## Caveats + +- 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..738d4be92c --- /dev/null +++ b/examples/stories/standalone_get/client.py @@ -0,0 +1,40 @@ +"""Receive `notifications/resources/list_changed` over the standalone GET stream, then re-list.""" + +import anyio + +from mcp import types +from mcp.client import Client +from stories._harness import Target, run_client + + +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 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 + + # 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"} + + +if __name__ == "__main__": + run_client(main) 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..ffdf86e622 --- /dev/null +++ b/examples/stories/starlette_mount/README.md @@ -0,0 +1,50 @@ +# 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 + +- `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 + 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..c286577354 --- /dev/null +++ b/examples/stories/starlette_mount/client.py @@ -0,0 +1,22 @@ +"""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 +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() + 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(main) diff --git a/examples/stories/starlette_mount/server.py b/examples/stories/starlette_mount/server.py new file mode 100644 index 0000000000..858abc9203 --- /dev/null +++ b/examples/stories/starlette_mount/server.py @@ -0,0 +1,47 @@ +"""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 + +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..ae52235d0f --- /dev/null +++ b/examples/stories/stateless_legacy/README.md @@ -0,0 +1,57 @@ +# 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 + +- `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. + +## 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..72d8ea4596 --- /dev/null +++ b/examples/stories/stateless_legacy/client.py @@ -0,0 +1,36 @@ +"""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 +from mcp.types import TextContent +from stories._harness import TargetFactory, run_client + + +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"] + + 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 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 Client(targets(), 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(main) diff --git a/examples/stories/stateless_legacy/server.py b/examples/stories/stateless_legacy/server.py new file mode 100644 index 0000000000..40c82ad34f --- /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, so it exports `build_app()`.""" + +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..d444d48ef3 --- /dev/null +++ b/examples/stories/stickynotes/README.md @@ -0,0 +1,61 @@ +# 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 + +- **`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 + 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`. `main` branches on + `client.protocol_version in HANDSHAKE_PROTOCOL_VERSIONS`. + +## 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/`, `legacy_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..288ab4caae --- /dev/null +++ b/examples/stories/stickynotes/client.py @@ -0,0 +1,81 @@ +"""Drive the sticky-notes board end to end and prove `remove_all` clears only on a confirmed elicitation.""" + +import anyio + +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 + + +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(main) diff --git a/examples/stories/stickynotes/server.py b/examples/stories/stickynotes/server.py new file mode 100644 index 0000000000..4c6c9d0a7e --- /dev/null +++ b/examples/stories/stickynotes/server.py @@ -0,0 +1,99 @@ +"""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) + + 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.""" + 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: + unregister_note(note_id) + 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): + unregister_note(nid) + 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..5363cbde19 --- /dev/null +++ b/examples/stories/streaming/README.md @@ -0,0 +1,78 @@ +# 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 (--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 +``` + +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 + `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** 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. +- 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. + +## 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..99398265ce --- /dev/null +++ b/examples/stories/streaming/client.py @@ -0,0 +1,54 @@ +"""Asserts progress + log notifications arrive in order, then cancels a call mid-flight.""" + +import anyio + +from mcp.client import Client +from mcp.types import LoggingMessageNotificationParams +from stories._harness import Target, run_client + + +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 on_log(params: LoggingMessageNotificationParams) -> None: + logs.append(params) + + 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)) + + 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(main) 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..a51bae979b --- /dev/null +++ b/examples/stories/subscriptions/README.md @@ -0,0 +1,28 @@ +# 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)). +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 + +[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..55c22e3b64 --- /dev/null +++ b/examples/stories/tools/client.py @@ -0,0 +1,31 @@ +"""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 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) == {"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(main) 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 07bfff740e..50a15aee10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,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", @@ -132,12 +134,16 @@ typeCheckingMode = "strict" include = [ "src/mcp", "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 @@ -146,8 +152,17 @@ venv = ".venv" executionEnvironments = [ { root = "tests", extraPaths = [ ".", + "examples", ], reportUnusedFunction = false, reportPrivateUsage = false }, - { root = "examples/servers", reportUnusedFunction = false }, + { root = "examples/stories", extraPaths = [ + "examples", + ], 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] @@ -191,10 +206,11 @@ max-returns = 13 # Default is 6 max-statements = 102 # Default is 50 [tool.uv.workspace] -members = ["examples/clients/*", "examples/servers/*", "examples/snippets"] +members = ["examples", "examples/clients/*", "examples/servers/*", "examples/snippets"] [tool.uv.sources] mcp = { workspace = true } +mcp-example-stories = { workspace = true } strict-no-cover = { git = "https://github.com/pydantic/strict-no-cover" } [tool.pytest.ini_options] @@ -217,6 +233,9 @@ filterwarnings = [ # them internally (e.g. `ctx.debug` -> `log` -> `send_log_message`), so the # advisory warning is silenced. Tests asserting it opt back in with pytest.warns. "ignore:.*is deprecated as of 2026-07-28 \\(SEP-2577\\).:mcp.MCPDeprecationWarning", + # 2026-07-28 restricts progress to server->client; the client send path is + # advisory-deprecated and a handful of tests still exercise it. + "ignore:Client-to-server progress is deprecated as of 2026-07-28.*:mcp.MCPDeprecationWarning", ] [tool.markdown.lint] diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index 39858cba44..f21231b924 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -40,7 +40,6 @@ validate_authorization_response_iss, validate_metadata_issuer, ) -from mcp.client.streamable_http import MCP_PROTOCOL_VERSION from mcp.shared.auth import ( AuthorizationCodeResult, OAuthClientInformationFull, @@ -54,6 +53,7 @@ check_resource_allowed, resource_url_from_server_url, ) +from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER from mcp.shared.version import is_version_at_least logger = logging.getLogger(__name__) @@ -534,7 +534,7 @@ async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx. await self._initialize() # Capture protocol version from request headers - self.context.protocol_version = request.headers.get(MCP_PROTOCOL_VERSION) + self.context.protocol_version = request.headers.get(MCP_PROTOCOL_VERSION_HEADER) if not self.context.is_token_valid() and self.context.can_refresh_token(): # Try to refresh token diff --git a/src/mcp/client/auth/utils.py b/src/mcp/client/auth/utils.py index f10264a330..16f711dd45 100644 --- a/src/mcp/client/auth/utils.py +++ b/src/mcp/client/auth/utils.py @@ -5,7 +5,6 @@ from pydantic import AnyUrl, ValidationError from mcp.client.auth import OAuthFlowError, OAuthRegistrationError, OAuthTokenError -from mcp.client.streamable_http import MCP_PROTOCOL_VERSION from mcp.shared.auth import ( OAuthClientInformationFull, OAuthClientMetadata, @@ -13,6 +12,7 @@ OAuthToken, ProtectedResourceMetadata, ) +from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER from mcp.types import LATEST_PROTOCOL_VERSION @@ -273,7 +273,7 @@ def validate_metadata_issuer(oauth_metadata: OAuthMetadata, expected_issuer: str def create_oauth_metadata_request(url: str) -> Request: - return Request("GET", url, headers={MCP_PROTOCOL_VERSION: LATEST_PROTOCOL_VERSION}) + return Request("GET", url, headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_PROTOCOL_VERSION}) def create_client_registration_request( diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index b69c3e5101..e6524a862a 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -2,27 +2,35 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable, Mapping from contextlib import AsyncExitStack from dataclasses import KW_ONLY, dataclass, field -from typing import Any +from typing import Any, Literal, TypeVar +import anyio from typing_extensions import deprecated +from mcp import types from mcp.client._memory import InMemoryTransport from mcp.client._transport import Transport from mcp.client.session import ClientSession, ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT from mcp.client.streamable_http import streamable_http_client from mcp.server import Server from mcp.server.mcpserver import MCPServer -from mcp.shared.dispatcher import ProgressFnT -from mcp.shared.exceptions import MCPDeprecationWarning +from mcp.server.runner import modern_on_request +from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair +from mcp.shared.dispatcher import Dispatcher, ProgressFnT +from mcp.shared.exceptions import MCPDeprecationWarning, MCPError +from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher +from mcp.shared.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS from mcp.types import ( + METHOD_NOT_FOUND, + REQUEST_TIMEOUT, CallToolResult, CompleteResult, EmptyResult, GetPromptResult, Implementation, - InitializeResult, ListPromptsResult, ListResourcesResult, ListResourceTemplatesResult, @@ -33,8 +41,87 @@ ReadResourceResult, RequestParamsMeta, ResourceTemplateReference, + ServerCapabilities, ) +ConnectMode = Literal["legacy", "auto"] | str +"""``mode=`` value: ``"legacy"`` (initialize handshake), ``"auto"`` (discover, fall back to +initialize), or a modern protocol-version string (adopt directly). The ``str`` arm is for +forward-compat; ``Client.__post_init__`` rejects anything outside that set at construction.""" + +_T = TypeVar("_T") + +_Connector = Callable[[AsyncExitStack, ConnectMode, bool], Awaitable["Dispatcher[Any]"]] +"""Resolved at ``__post_init__`` from the shape of ``server`` alone: enter whatever resources +are needed onto the exit stack and hand back the ``Dispatcher`` ``ClientSession`` will drive. +``mode`` and ``raise_exceptions`` are passed at call time so they're read at the same moment +``__aenter__`` reads them for the handshake step.""" + + +def _connect_transport(transport: Transport) -> _Connector: + """Connector for the stream-backed paths (URL, user-supplied ``Transport``).""" + + async def connect(exit_stack: AsyncExitStack, _mode: ConnectMode, _raise_exceptions: bool) -> Dispatcher[Any]: + read_stream, write_stream = await exit_stack.enter_async_context(transport) + return JSONRPCDispatcher(read_stream, write_stream) + + return connect + + +def _connect_inproc(server: Server[Any]) -> _Connector: + """Connector for an in-process ``Server``: legacy mode drives the stream loop via + ``InMemoryTransport``; any other mode drives the modern per-request path through a + ``DirectDispatcher`` peer pair (no streams, no JSON-RPC framing, no initialize handshake).""" + + async def connect(exit_stack: AsyncExitStack, mode: ConnectMode, raise_exceptions: bool) -> Dispatcher[Any]: + if mode == "legacy": + transport = InMemoryTransport(server, raise_exceptions=raise_exceptions) + read_stream, write_stream = await exit_stack.enter_async_context(transport) + return JSONRPCDispatcher(read_stream, write_stream) + lifespan_state = await exit_stack.enter_async_context(server.lifespan(server)) + client_disp, server_disp = create_direct_dispatcher_pair() + tg = await exit_stack.enter_async_context(anyio.create_task_group()) + exit_stack.callback(server_disp.close) + on_request = modern_on_request(server, lifespan_state, raise_exceptions=raise_exceptions) + await tg.start(server_disp.run, on_request, _no_inbound_client_notifications) + return client_disp + + return connect + + +def _connected(value: _T | None) -> _T: + """Narrow a post-handshake session attribute from ``T | None`` to ``T``. + + ``Client.__aenter__`` only assigns ``_session`` after the handshake succeeds, so inside + ``async with Client(...)`` these attributes are always populated; the ``.session`` gate + raises before this is reached otherwise. The guard exists for pyright, not runtime. + """ + if value is None: # pragma: no cover + raise RuntimeError("Client must be used within an async context manager") + return value + + +def _synthesize_discover(protocol_version: str) -> types.DiscoverResult: + return types.DiscoverResult( + supported_versions=[protocol_version], + capabilities=types.ServerCapabilities(), + server_info=types.Implementation(name="", version=""), + result_type="complete", + ttl_ms=0, + cache_scope="public", + ) + + +async def _no_inbound_client_notifications(_dctx: Any, _method: str, _params: Mapping[str, Any] | None) -> None: + """Server-side inbound ``OnNotify`` for the modern in-process path — receives nothing. + + At 2026-07-28 the spec defines no client→server notifications: ``initialized`` and + ``roots/list_changed`` are removed, and cancellation is structural (anyio scope cancel + through the direct await, not a notify). Server→client notifications (progress, log + messages) flow the other way via the per-request ``DispatchContext`` into the client's + callbacks, and are not seen here. + """ + @dataclass class Client: @@ -95,58 +182,87 @@ async def main(): client_info: Implementation | None = None """Client implementation info to send to server.""" - protocol_version: str | None = None - """Pin the protocol version instead of negotiating it. + # TODO(maxisbey): flip default to 'auto' once the in-proc test suite is era-decoupled + # and the probe-timeout fallback is transport-aware (stdio→fallback / HTTP→reject). + mode: ConnectMode = "legacy" + """'legacy' performs the initialize handshake. 'auto' probes server/discover and falls back to initialize() + on legacy servers. A modern protocol-version string (e.g. '2026-07-28') adopts that version directly without + a handshake — supply prior_discover to reuse a known DiscoverResult, or omit it to synthesize a minimal one.""" - Pinning to ``2026-07-28`` or later selects the stateless transport era: no initialize - handshake is sent on the wire (the session synthesizes its `InitializeResult` locally), - and for HTTP the ``MCP-Protocol-Version`` header is set from the first request. A modern - pin currently requires a URL or `Transport`; the in-memory `Server`/`MCPServer` path - does not yet have a modern entry point. - Leave as ``None`` to negotiate the version via the initialize handshake. - """ + prior_discover: types.DiscoverResult | None = None + """A previously-obtained DiscoverResult to install via .adopt() when mode is a version pin. + Ignored when mode='legacy'.""" elicitation_callback: ElicitationFnT | None = None """Callback for handling elicitation requests.""" + _entered: bool = field(init=False, default=False) _session: ClientSession | None = field(init=False, default=None) _exit_stack: AsyncExitStack | None = field(init=False, default=None) - _transport: Transport = field(init=False) + _connect: _Connector = field(init=False, repr=False, compare=False) def __post_init__(self) -> None: - if isinstance(self.server, Server | MCPServer): - self._transport = InMemoryTransport(self.server, raise_exceptions=self.raise_exceptions) - elif isinstance(self.server, str): - self._transport = streamable_http_client(self.server, protocol_version=self.protocol_version) + if self.mode not in ("legacy", "auto") and self.mode not in MODERN_PROTOCOL_VERSIONS: + hint = ( + f" ({self.mode!r} is a handshake-era version — use mode='legacy')" + if self.mode in HANDSHAKE_PROTOCOL_VERSIONS + else "" + ) + raise ValueError( + f"mode must be 'legacy', 'auto', or one of {list(MODERN_PROTOCOL_VERSIONS)}; got {self.mode!r}{hint}" + ) + + srv = self.server + if isinstance(srv, MCPServer): + srv = srv._lowlevel_server # pyright: ignore[reportPrivateUsage] + if isinstance(srv, Server): + self._connect = _connect_inproc(srv) + elif isinstance(srv, str): + self._connect = _connect_transport(streamable_http_client(srv)) else: - self._transport = self.server + self._connect = _connect_transport(srv) + + async def _build_session(self, exit_stack: AsyncExitStack) -> ClientSession: + """Enter the resolved connector and return an un-entered ClientSession.""" + dispatcher = await self._connect(exit_stack, self.mode, self.raise_exceptions) + return ClientSession( + dispatcher=dispatcher, + read_timeout_seconds=self.read_timeout_seconds, + sampling_callback=self.sampling_callback, + list_roots_callback=self.list_roots_callback, + logging_callback=self.logging_callback, + message_handler=self.message_handler, + client_info=self.client_info, + elicitation_callback=self.elicitation_callback, + ) async def __aenter__(self) -> Client: """Enter the async context manager.""" - if self._session is not None: + if self._entered: raise RuntimeError("Client is already entered; cannot reenter") + self._entered = True async with AsyncExitStack() as exit_stack: - read_stream, write_stream = await exit_stack.enter_async_context(self._transport) - - self._session = await exit_stack.enter_async_context( - ClientSession( - read_stream=read_stream, - write_stream=write_stream, - read_timeout_seconds=self.read_timeout_seconds, - sampling_callback=self.sampling_callback, - list_roots_callback=self.list_roots_callback, - logging_callback=self.logging_callback, - message_handler=self.message_handler, - client_info=self.client_info, - elicitation_callback=self.elicitation_callback, - protocol_version=self.protocol_version, - ) - ) - - await self._session.initialize() - - # Transfer ownership to self for __aexit__ to handle + session = await self._build_session(exit_stack) + session = await exit_stack.enter_async_context(session) + + if self.mode == "legacy": + await session.initialize() + elif self.mode == "auto": + try: + await session.discover() + except MCPError as e: + if e.code in (METHOD_NOT_FOUND, REQUEST_TIMEOUT): + await session.initialize() + else: + raise + else: + session.adopt(self.prior_discover or _synthesize_discover(self.mode)) + + # Only publish the session after the handshake succeeds, so `_session is not None` + # implies the protocol_version/server_info/server_capabilities are populated. If the + # handshake raised above, the local exit_stack unwinds the transport for us. + self._session = session self._exit_stack = exit_stack.pop_all() return self @@ -169,22 +285,38 @@ def session(self) -> ClientSession: raise RuntimeError("Client must be used within an async context manager") return self._session + # TODO(maxisbey): the by-construction shape is for __aenter__ to return a connected-view + # type whose protocol_version/server_info/server_capabilities are non-Optional fields, + # eliminating these guards (and the one in .session). Same family as resolving the + # transport/connector at __post_init__ so the Optional internal fields disappear. @property - def initialize_result(self) -> InitializeResult: - """The server's InitializeResult. + def protocol_version(self) -> str: + """Negotiated protocol version (set by initialize/discover/adopt during ``__aenter__``).""" + return _connected(self.session.protocol_version) - Contains server_info, capabilities, instructions, and the negotiated protocol_version. - Raises RuntimeError if accessed outside the context manager. - """ - result = self.session.initialize_result - if result is None: # pragma: no cover - raise RuntimeError("Client must be used within an async context manager") - return result + @property + def server_info(self) -> Implementation: + """Server name/version (set by initialize/discover/adopt during ``__aenter__``).""" + return _connected(self.session.server_info) + + @property + def server_capabilities(self) -> ServerCapabilities: + """Server capabilities (set by initialize/discover/adopt during ``__aenter__``).""" + return _connected(self.session.server_capabilities) + + @property + def instructions(self) -> str | None: + """Server-provided instructions text, if any.""" + return self.session.instructions async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> EmptyResult: """Send a ping request to the server.""" return await self.session.send_ping(meta=meta) + @deprecated( + "Client-to-server progress is deprecated as of 2026-07-28; progress is server-to-client only.", + category=MCPDeprecationWarning, + ) async def send_progress_notification( self, progress_token: str | int, @@ -193,7 +325,7 @@ async def send_progress_notification( message: str | None = None, ) -> None: """Send a progress notification to the server.""" - await self.session.send_progress_notification( + await self.session.send_progress_notification( # pyright: ignore[reportDeprecated] progress_token=progress_token, progress=progress, total=total, diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 4b24e98b1d..c38ab44304 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from collections.abc import Mapping +from collections.abc import Callable, Mapping from dataclasses import dataclass from types import TracebackType from typing import Any, Protocol, cast @@ -17,26 +17,72 @@ from mcp.shared._compat import resync_tracer from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, ProgressFnT from mcp.shared.exceptions import MCPDeprecationWarning, MCPError +from mcp.shared.inbound import ( + MCP_METHOD_HEADER, + MCP_NAME_HEADER, + MCP_PROTOCOL_VERSION_HEADER, + encode_header_value, +) from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher from mcp.shared.message import ClientMessageMetadata, SessionMessage from mcp.shared.session import RequestResponder from mcp.shared.transport_context import TransportContext -from mcp.shared.version import MODERN_PROTOCOL_VERSIONS, SUPPORTED_PROTOCOL_VERSIONS +from mcp.shared.version import ( + HANDSHAKE_PROTOCOL_VERSIONS, + LATEST_HANDSHAKE_VERSION, + LATEST_MODERN_VERSION, + MODERN_PROTOCOL_VERSIONS, +) from mcp.types import ( CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, INTERNAL_ERROR, METHOD_NOT_FOUND, PROTOCOL_VERSION_META_KEY, + UNSUPPORTED_PROTOCOL_VERSION, RequestId, RequestParamsMeta, ) from mcp.types import methods as _methods DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0") +DISCOVER_TIMEOUT_SECONDS = 10.0 logger = logging.getLogger("client") + +def _preconnect_stamp(data: dict[str, Any], opts: CallOptions) -> None: + # Only initialize/discover go out before connect; both forbid cancellation. + opts["cancel_on_abandon"] = False + + +def _make_handshake_stamp(protocol_version: str) -> Callable[[dict[str, Any], CallOptions], None]: + def stamp(data: dict[str, Any], opts: CallOptions) -> None: + opts.setdefault("headers", {})[MCP_PROTOCOL_VERSION_HEADER] = protocol_version + + return stamp + + +def _make_modern_stamp( + protocol_version: str, client_info: dict[str, Any], capabilities: dict[str, Any] +) -> Callable[[dict[str, Any], CallOptions], None]: + def stamp(data: dict[str, Any], opts: CallOptions) -> None: + params = data.setdefault("params", {}) + meta = params.setdefault("_meta", {}) + meta[PROTOCOL_VERSION_META_KEY] = protocol_version + meta[CLIENT_INFO_META_KEY] = client_info + meta[CLIENT_CAPABILITIES_META_KEY] = capabilities + opts["cancel_on_abandon"] = False + headers = opts.setdefault("headers", {}) + headers[MCP_PROTOCOL_VERSION_HEADER] = protocol_version + headers[MCP_METHOD_HEADER] = data["method"] + # TODO: also emit Mcp-Name for prompts/get (params.name) and resources/read (params.uri) + if data["method"] == "tools/call" and isinstance(name := params.get("name"), str): + headers[MCP_NAME_HEADER] = encode_header_value(name) + + return stamp + + ReceiveResultT = TypeVar("ReceiveResultT", bound=BaseModel) @@ -149,14 +195,11 @@ def __init__( message_handler: MessageHandlerFnT | None = None, client_info: types.Implementation | None = None, *, - protocol_version: str | None = None, sampling_capabilities: types.SamplingCapability | None = None, dispatcher: Dispatcher[Any] | None = None, ) -> None: self._session_read_timeout_seconds = read_timeout_seconds self._client_info = client_info or DEFAULT_CLIENT_INFO - self._pinned_version = protocol_version - self._stateless_pinned = protocol_version in MODERN_PROTOCOL_VERSIONS self._sampling_callback = sampling_callback or _default_sampling_callback self._sampling_capabilities = sampling_capabilities self._elicitation_callback = elicitation_callback or _default_elicitation_callback @@ -164,24 +207,23 @@ def __init__( self._logging_callback = logging_callback or _default_logging_callback self._message_handler = message_handler or _default_message_handler self._tool_output_schemas: dict[str, dict[str, Any] | None] = {} - self._initialize_result: types.InitializeResult | None - if self._stateless_pinned: - assert protocol_version is not None - # A stateless-pinned session is born initialized: there is no handshake - # at 2026-07-28+, so we synthesize the result locally. `server_info` is a - # placeholder until `server/discover` is implemented to populate it. - self._initialize_result = types.InitializeResult( - protocol_version=protocol_version, - capabilities=types.ServerCapabilities(), - server_info=types.Implementation(name="", version=""), - ) - else: - self._initialize_result = None + self._initialize_result: types.InitializeResult | None = None + self._discover_result: types.DiscoverResult | None = None + self._negotiated_version: str | None = None + self._stamp: Callable[[dict[str, Any], CallOptions], None] = _preconnect_stamp self._task_group: anyio.abc.TaskGroup | None = None if dispatcher is not None: if read_stream is not None or write_stream is not None: raise ValueError("pass read_stream/write_stream or dispatcher, not both") self._dispatcher: Dispatcher[Any] = dispatcher + if isinstance(dispatcher, JSONRPCDispatcher) and dispatcher.on_stream_exception is None: + # Route transport-level Exception items into message_handler — only + # stream-backed dispatchers carry these; DirectDispatcher has none. + # Don't clobber a caller-supplied hook. + # TODO(maxisbey): this leaves a bound-method ref on the dispatcher after + # the session exits (memory pin) and a second wrap of the same dispatcher + # would skip install. The Transport-as-Dispatcher rework removes this seam. + dispatcher.on_stream_exception = self._on_stream_exception else: if read_stream is None or write_stream is None: raise ValueError("read_stream and write_stream are required when no dispatcher is given") @@ -242,19 +284,7 @@ async def send_request( data = request.model_dump(by_alias=True, mode="json", exclude_none=True) method: str = data["method"] opts: CallOptions = {} - if self._stateless_pinned: - params = data.setdefault("params", {}) - envelope_meta = params.setdefault("_meta", {}) - envelope_meta[PROTOCOL_VERSION_META_KEY] = self._pinned_version - envelope_meta[CLIENT_INFO_META_KEY] = self._client_info.model_dump( - by_alias=True, mode="json", exclude_none=True - ) - envelope_meta[CLIENT_CAPABILITIES_META_KEY] = self._build_capabilities().model_dump( - by_alias=True, mode="json", exclude_none=True - ) - # Stateless pinned mode: disconnect-as-cancel is the spec mechanism, so the - # dispatcher must not emit notifications/cancelled when the caller abandons. - opts["cancel_on_abandon"] = False + self._stamp(data, opts) timeout = ( request_read_timeout_seconds if request_read_timeout_seconds is not None @@ -269,12 +299,9 @@ async def send_request( opts["resumption_token"] = metadata.resumption_token if metadata.on_resumption_token_update is not None: opts["on_resumption_token"] = metadata.on_resumption_token_update - if method == "initialize": - # The spec forbids cancelling initialize. - opts["cancel_on_abandon"] = False raw = await self._dispatcher.send_raw_request(method, data.get("params"), opts) # Literal fallback covers pre-handshake and stateless; matches runner.py. - version = self.protocol_version or "2025-11-25" + version = self._negotiated_version or "2025-11-25" try: _methods.validate_server_result(method, version, raw) except KeyError: @@ -288,7 +315,9 @@ async def send_notification(self, notification: types.ClientNotification) -> Non dropped with a debug log instead of raising. """ data = notification.model_dump(by_alias=True, mode="json", exclude_none=True) - await self._dispatcher.notify(data["method"], data.get("params")) + opts: CallOptions = {} + self._stamp(data, opts) + await self._dispatcher.notify(data["method"], data.get("params"), opts) def _build_capabilities(self) -> types.ClientCapabilities: sampling = ( @@ -314,56 +343,166 @@ def _build_capabilities(self) -> types.ClientCapabilities: async def initialize(self) -> types.InitializeResult: if self._initialize_result is not None: return self._initialize_result - capabilities = self._build_capabilities() result = await self.send_request( types.InitializeRequest( params=types.InitializeRequestParams( - protocol_version=self._pinned_version - if self._pinned_version is not None - else types.LATEST_PROTOCOL_VERSION, - capabilities=capabilities, + protocol_version=LATEST_HANDSHAKE_VERSION, + capabilities=self._build_capabilities(), client_info=self._client_info, ), ), types.InitializeResult, ) - if result.protocol_version not in SUPPORTED_PROTOCOL_VERSIONS: + if result.protocol_version not in HANDSHAKE_PROTOCOL_VERSIONS: raise RuntimeError(f"Unsupported protocol version from the server: {result.protocol_version}") - self._initialize_result = result + self.adopt(result) await self.send_notification(types.InitializedNotification()) return result + def adopt(self, result: types.InitializeResult | types.DiscoverResult) -> None: + """Install negotiated state from a result the caller already holds (no wire traffic). + + Clears the opposite slot, so at most one of `initialize_result` / + `discover_result` is ever non-None. + + Raises: + RuntimeError: `result` is a `DiscoverResult` whose `supported_versions` + shares nothing with this client's `MODERN_PROTOCOL_VERSIONS`. + """ + if isinstance(result, types.DiscoverResult): + # ordered oldest→newest via MODERN_PROTOCOL_VERSIONS + mutual = [v for v in MODERN_PROTOCOL_VERSIONS if v in result.supported_versions] + if not mutual: + raise RuntimeError( + f"No mutually supported modern protocol version " + f"(server: {result.supported_versions}, client: {list(MODERN_PROTOCOL_VERSIONS)})" + ) + client_info = self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True) + capabilities = self._build_capabilities().model_dump(by_alias=True, mode="json", exclude_none=True) + self._stamp = _make_modern_stamp(mutual[-1], client_info, capabilities) + self._discover_result = result + self._initialize_result = None + self._negotiated_version = mutual[-1] + else: + self._stamp = _make_handshake_stamp(result.protocol_version) + self._initialize_result = result + self._discover_result = None + self._negotiated_version = result.protocol_version + + async def discover(self) -> types.DiscoverResult: + """Probe `server/discover` and adopt the result. + + Sends a single `server/discover` proposing the newest modern protocol + version. On `UNSUPPORTED_PROTOCOL_VERSION` (-32022) the server's + `supported` list is intersected with `MODERN_PROTOCOL_VERSIONS` and the + probe is retried once at the highest mutual version. Any other error — + including `METHOD_NOT_FOUND` (-32601) and `REQUEST_TIMEOUT` (-32001) — + propagates; the legacy `initialize()` fallback is the caller's policy. + + Raises: + MCPError: The server rejected `server/discover`, the probe timed + out, or the -32022 retry found no mutual version / failed again. + RuntimeError: `adopt()` found no mutual version in the returned + `supported_versions`. + """ + if self._discover_result is not None: + return self._discover_result + + client_info = self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True) + capabilities = self._build_capabilities().model_dump(by_alias=True, mode="json", exclude_none=True) + + async def probe(version: str) -> dict[str, Any]: + params = { + "_meta": { + PROTOCOL_VERSION_META_KEY: version, + CLIENT_INFO_META_KEY: client_info, + CLIENT_CAPABILITIES_META_KEY: capabilities, + } + } + opts: CallOptions = { + "timeout": DISCOVER_TIMEOUT_SECONDS, + "cancel_on_abandon": False, + "headers": {MCP_PROTOCOL_VERSION_HEADER: version}, + } + return await self._dispatcher.send_raw_request("server/discover", params, opts) + + try: + raw = await probe(LATEST_MODERN_VERSION) + except MCPError as e: + if e.code != UNSUPPORTED_PROTOCOL_VERSION: + raise + try: + data = types.UnsupportedProtocolVersionErrorData.model_validate(e.error.data) + except ValidationError: + raise e from None + # ordered oldest→newest via MODERN_PROTOCOL_VERSIONS + mutual = [v for v in MODERN_PROTOCOL_VERSIONS if v in data.supported] + if not mutual: + raise + raw = await probe(mutual[-1]) + + result = types.DiscoverResult.model_validate(raw) + self.adopt(result) + return result + @property def initialize_result(self) -> types.InitializeResult | None: - """The server's InitializeResult. None until initialize() has been called. + """The server's InitializeResult. None unless `initialize()` ran (or was adopted).""" + return self._initialize_result - A stateless-pinned session (protocol_version >= 2026-07-28) is born - initialized: this property is populated at construction with a - synthesized result and `initialize()` returns it without touching the - wire. Contains server_info, capabilities, instructions, and the - negotiated protocol_version. + @property + def discover_result(self) -> types.DiscoverResult | None: + """The server's DiscoverResult. None unless `discover()` ran (or was adopted). + + Retained intact (supported_versions, ttl_ms, cache_scope) so callers + can round-trip it as ``prior_discover=``. """ - return self._initialize_result + return self._discover_result @property def protocol_version(self) -> str | None: - """Negotiated or pinned protocol version. None until initialize() unless pinned at construction. + """Negotiated protocol version. None until `initialize()`, `discover()`, or `adopt()`.""" + return self._negotiated_version - Once `initialize()` has completed, this is the version the server actually - negotiated (which can differ from a stateful pin); before that, the pin. - """ + @property + def server_info(self) -> types.Implementation | None: + """Server name/version. None until `initialize()`, `discover()`, or `adopt()`.""" + if self._discover_result is not None: + return self._discover_result.server_info if self._initialize_result is not None: - return self._initialize_result.protocol_version - return self._pinned_version + return self._initialize_result.server_info + return None + + @property + def server_capabilities(self) -> types.ServerCapabilities | None: + """Server capabilities. None until `initialize()`, `discover()`, or `adopt()`.""" + if self._discover_result is not None: + return self._discover_result.capabilities + if self._initialize_result is not None: + return self._initialize_result.capabilities + return None + + @property + def instructions(self) -> str | None: + """Server-provided instructions text, if any.""" + if self._discover_result is not None: + return self._discover_result.instructions + if self._initialize_result is not None: + return self._initialize_result.instructions + return None async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult: """Send a ping request.""" return await self.send_request(types.PingRequest(params=types.RequestParams(_meta=meta)), types.EmptyResult) + @deprecated( + "Client-to-server progress is deprecated as of 2026-07-28; progress is server-to-client only.", + category=MCPDeprecationWarning, + ) async def send_progress_notification( self, progress_token: str | int, @@ -563,7 +702,7 @@ async def _on_request( # Literal, not LATEST_PROTOCOL_VERSION: the fallback covers the initialize # handshake (which only exists at <=2025) and stateless until the header # is plumbed; its meaning is fixed regardless of LATEST bumps. - version = self.protocol_version or "2025-11-25" + version = self._negotiated_version or "2025-11-25" try: request = cast(types.ServerRequest, _methods.parse_server_request(method, version, params)) except KeyError: @@ -601,7 +740,7 @@ async def _on_notify( ) -> None: """Route a server notification: validate, run the typed callback, tee to message_handler.""" # Same fallback as `_on_request`: covers pre-handshake and stateless. - version = self.protocol_version or "2025-11-25" + version = self._negotiated_version or "2025-11-25" try: notification = cast(types.ServerNotification, _methods.parse_server_notification(method, version, params)) except KeyError: diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index a703a48afb..d4c4d3995c 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -2,10 +2,8 @@ from __future__ import annotations as _annotations -import base64 import contextlib import logging -import re from collections.abc import AsyncGenerator, Awaitable, Callable from contextlib import asynccontextmanager from dataclasses import dataclass @@ -20,14 +18,14 @@ from mcp.shared._compat import resync_tracer from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams from mcp.shared._httpx_utils import create_mcp_http_client +from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER from mcp.shared.message import ClientMessageMetadata, SessionMessage -from mcp.shared.version import MODERN_PROTOCOL_VERSIONS from mcp.types import ( INTERNAL_ERROR, INVALID_REQUEST, + METHOD_NOT_FOUND, PARSE_ERROR, ErrorData, - InitializeResult, JSONRPCError, JSONRPCMessage, JSONRPCNotification, @@ -46,25 +44,12 @@ StreamReader = ContextReceiveStream[SessionMessage] MCP_SESSION_ID = "mcp-session-id" -MCP_PROTOCOL_VERSION = "mcp-protocol-version" -MCP_METHOD = "mcp-method" -MCP_NAME = "mcp-name" LAST_EVENT_ID = "last-event-id" # Reconnection defaults DEFAULT_RECONNECTION_DELAY_MS = 1000 # 1 second fallback when server doesn't provide retry MAX_RECONNECTION_ATTEMPTS = 2 # Max retry attempts before giving up -_B64_SENTINEL = re.compile(r"^=\?base64\?.*\?=$") -# RFC 7230 token chars minus DEL; visible ASCII 0x20-0x7E is the practical bound for a header value. -_HEADER_SAFE = re.compile(r"^[\x20-\x7E]*$") - - -def _encode_header_value(value: str) -> str: - if _HEADER_SAFE.fullmatch(value) and value == value.strip() and not _B64_SENTINEL.fullmatch(value): - return value - return f"=?base64?{base64.b64encode(value.encode('utf-8')).decode('ascii')}?=" - class StreamableHTTPError(Exception): """Base exception for StreamableHTTP transport errors.""" @@ -88,57 +73,43 @@ class RequestContext: class StreamableHTTPTransport: """StreamableHTTP client transport implementation.""" - def __init__(self, url: str, protocol_version: str | None = None) -> None: + def __init__(self, url: str) -> None: """Initialize the StreamableHTTP transport. Args: url: The endpoint URL. - protocol_version: Pin the MCP-Protocol-Version header from the first request. - Only honoured for stateless 2026-07-28+ sessions that never send - initialize; for earlier (stateful) versions the header is populated - from the negotiated InitializeResult, so a pre-2026 value is ignored. """ self.url = url self.session_id: str | None = None - self.protocol_version: str | None = protocol_version if protocol_version in MODERN_PROTOCOL_VERSIONS else None - - def _per_message_headers(self, message: JSONRPCMessage) -> dict[str, str]: - """Per-POST routing headers (Mcp-Method, Mcp-Name) for 2026-07-28+ pinned transports. - - MCP-Protocol-Version is not emitted here — `_prepare_headers()` already adds it - from `self.protocol_version` for every request. - """ - if self.protocol_version not in MODERN_PROTOCOL_VERSIONS: - return {} - if not isinstance(message, JSONRPCRequest | JSONRPCNotification): - return {} - headers: dict[str, str] = {MCP_METHOD: message.method} - # TODO: Mcp-Name is also REQUIRED for prompts/get (params.name) and resources/read - # (params.uri); a method->param-key map replaces this gate when those land. - if ( - isinstance(message, JSONRPCRequest) - and message.method == "tools/call" - and message.params - and isinstance(name := message.params.get("name"), str) - ): - headers[MCP_NAME] = _encode_header_value(name) - return headers + # Captured from the first stamped POST's metadata; reused on transport-internal + # GET/DELETE that don't carry per-message metadata. + self._protocol_version_header: str | None = None - def _prepare_headers(self) -> dict[str, str]: - """Build MCP-specific request headers. + def _base_headers(self) -> dict[str, str]: + """Build MCP-specific request headers (accept / content-type / session-id). These headers will be merged with the httpx.AsyncClient's default headers, - with these MCP-specific headers taking precedence. + with these MCP-specific headers taking precedence. POSTs use this directly: + their protocol-version header arrives per-message via ``metadata.headers``, + so they must never read the cached value. """ headers: dict[str, str] = { "accept": "application/json, text/event-stream", "content-type": "application/json", } - # Add session headers if available if self.session_id: headers[MCP_SESSION_ID] = self.session_id - if self.protocol_version: - headers[MCP_PROTOCOL_VERSION] = self.protocol_version + return headers + + def _prepare_headers(self) -> dict[str, str]: + """Base headers plus the cached protocol-version header. + + Used by transport-internal GET/DELETE (listen stream, resumption, + reconnect, terminate) which don't carry per-message metadata. + """ + headers = self._base_headers() + if self._protocol_version_header: + headers[MCP_PROTOCOL_VERSION_HEADER] = self._protocol_version_header return headers def _is_initialization_request(self, message: JSONRPCMessage) -> bool: @@ -156,29 +127,12 @@ def _maybe_extract_session_id_from_response(self, response: httpx.Response) -> N self.session_id = new_session_id logger.info(f"Received session ID: {self.session_id}") - def _maybe_extract_protocol_version_from_message(self, message: JSONRPCMessage) -> None: - """Extract protocol version from initialization response message.""" - if self.protocol_version is not None: - # Only a modern constructor pin reaches here (pre-2026 values are dropped - # in __init__), and a modern pin never sends initialize. - return - if isinstance(message, JSONRPCResponse) and message.result: # pragma: no branch - try: - # Parse the result as InitializeResult for type safety - init_result = InitializeResult.model_validate(message.result, by_name=False) - self.protocol_version = init_result.protocol_version - logger.info(f"Negotiated protocol version: {self.protocol_version}") - except Exception: # pragma: no cover - logger.warning("Failed to parse initialization response as InitializeResult", exc_info=True) - logger.warning(f"Raw result: {message.result}") - async def _handle_sse_event( self, sse: ServerSentEvent, read_stream_writer: StreamWriter, original_request_id: RequestId | None = None, resumption_callback: Callable[[str], Awaitable[None]] | None = None, - is_initialization: bool = False, ) -> bool: """Handle an SSE event, returning True if the response is complete.""" if sse.event == "message": @@ -192,10 +146,6 @@ async def _handle_sse_event( message = jsonrpc_message_adapter.validate_json(sse.data, by_name=False) logger.debug(f"SSE message: {message}") - # Extract protocol version from initialization response - if is_initialization: - self._maybe_extract_protocol_version_from_message(message) - # If this is a response and we have original_request_id, replace it if original_request_id is not None and isinstance(message, JSONRPCResponse | JSONRPCError): message.id = original_request_id @@ -299,9 +249,12 @@ async def _handle_resumption_request(self, ctx: RequestContext) -> None: async def _handle_post_request(self, ctx: RequestContext) -> None: """Handle a POST request with response processing.""" - headers = self._prepare_headers() + headers = self._base_headers() message = ctx.session_message.message - headers.update(self._per_message_headers(message)) + if ctx.metadata is not None and ctx.metadata.headers is not None: + headers.update(ctx.metadata.headers) + if MCP_PROTOCOL_VERSION_HEADER in ctx.metadata.headers: + self._protocol_version_header = ctx.metadata.headers[MCP_PROTOCOL_VERSION_HEADER] is_initialization = self._is_initialization_request(message) async with ctx.client.stream( @@ -334,7 +287,13 @@ async def _handle_post_request(self, ctx: RequestContext) -> None: pass logger.debug("Non-2xx body was not a JSON-RPC error; using fallback") if response.status_code == 404: - error_data = ErrorData(code=INVALID_REQUEST, message="Session terminated") + if self.session_id is None: + # No session yet → 404 is the HTTP-level spelling of + # METHOD_NOT_FOUND (gateway / legacy server doesn't know + # this method); "Session terminated" would be a lie here. + error_data = ErrorData(code=METHOD_NOT_FOUND, message="Not Found") + else: + error_data = ErrorData(code=INVALID_REQUEST, message="Session terminated") else: error_data = ErrorData(code=INTERNAL_ERROR, message="Server returned an error response") session_message = SessionMessage(JSONRPCError(jsonrpc="2.0", id=message.id, error=error_data)) @@ -349,11 +308,9 @@ async def _handle_post_request(self, ctx: RequestContext) -> None: if isinstance(message, JSONRPCRequest): content_type = response.headers.get("content-type", "").lower() if content_type.startswith("application/json"): - await self._handle_json_response( - response, ctx.read_stream_writer, is_initialization, request_id=message.id - ) + await self._handle_json_response(response, ctx.read_stream_writer, request_id=message.id) elif content_type.startswith("text/event-stream"): - await self._handle_sse_response(response, ctx, is_initialization) + await self._handle_sse_response(response, ctx) else: logger.error(f"Unexpected content type: {content_type}") error_data = ErrorData(code=INVALID_REQUEST, message=f"Unexpected content type: {content_type}") @@ -364,7 +321,6 @@ async def _handle_json_response( self, response: httpx.Response, read_stream_writer: StreamWriter, - is_initialization: bool = False, *, request_id: RequestId, ) -> None: @@ -372,11 +328,6 @@ async def _handle_json_response( try: content = await response.aread() message = jsonrpc_message_adapter.validate_json(content, by_name=False) - - # Extract protocol version from initialization response - if is_initialization: - self._maybe_extract_protocol_version_from_message(message) - session_message = SessionMessage(message) await read_stream_writer.send(session_message) except (httpx.StreamError, ValidationError) as exc: @@ -389,7 +340,6 @@ async def _handle_sse_response( self, response: httpx.Response, ctx: RequestContext, - is_initialization: bool = False, ) -> None: """Handle SSE response from the server.""" last_event_id: str | None = None @@ -416,7 +366,6 @@ async def _handle_sse_response( ctx.read_stream_writer, original_request_id=original_request_id, resumption_callback=(ctx.metadata.on_resumption_token_update if ctx.metadata else None), - is_initialization=is_initialization, ) # If the SSE event indicates completion, like returning response/error # break the loop @@ -581,7 +530,6 @@ async def streamable_http_client( *, http_client: httpx.AsyncClient | None = None, terminate_on_close: bool = True, - protocol_version: str | None = None, ) -> AsyncGenerator[TransportStreams, None]: """Client transport for StreamableHTTP. @@ -591,8 +539,6 @@ async def streamable_http_client( client with recommended MCP timeouts will be created. To configure headers, authentication, or other HTTP settings, create an httpx.AsyncClient and pass it here. terminate_on_close: If True, send a DELETE request to terminate the session when the context exits. - protocol_version: Pin the MCP-Protocol-Version header for stateless 2026-07-28 sessions. - Tracer-bullet duplication — also pass to `ClientSession(protocol_version=...)`. Yields: Tuple containing: @@ -610,7 +556,7 @@ async def streamable_http_client( # Create default client with recommended MCP timeouts client = create_mcp_http_client() - transport = StreamableHTTPTransport(url, protocol_version=protocol_version) + transport = StreamableHTTPTransport(url) logger.debug(f"Connecting to StreamableHTTP endpoint: {url}") diff --git a/src/mcp/server/_streamable_http_modern.py b/src/mcp/server/_streamable_http_modern.py index 6a81786b3b..d151bc259c 100644 --- a/src/mcp/server/_streamable_http_modern.py +++ b/src/mcp/server/_streamable_http_modern.py @@ -14,7 +14,7 @@ import json import logging -from collections.abc import Mapping +from collections.abc import Awaitable, Mapping from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, TypeVar @@ -30,9 +30,11 @@ from mcp.shared.dispatcher import CallOptions from mcp.shared.exceptions import NoBackChannelError from mcp.shared.inbound import ERROR_CODE_HTTP_STATUS, InboundLadderRejection, classify_inbound_request +from mcp.shared.jsonrpc_dispatcher import handler_exception_to_error_data from mcp.shared.message import MessageMetadata, ServerMessageMetadata from mcp.shared.transport_context import TransportContext from mcp.types import ( + INTERNAL_ERROR, INVALID_REQUEST, PARSE_ERROR, ClientCapabilities, @@ -77,7 +79,7 @@ async def send_raw_request( ) -> dict[str, Any]: raise NoBackChannelError(method) - async def notify(self, method: str, params: Mapping[str, Any] | None) -> None: + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: # TODO(D-005a): buffer and stream as SSE once the JSON-vs-SSE response mode lands. return None @@ -99,6 +101,27 @@ def _typed(model: type[_ModelT], raw: Any) -> _ModelT | None: return None +async def _to_jsonrpc_response( + request_id: RequestId, coro: Awaitable[dict[str, Any]] +) -> JSONRPCResponse | JSONRPCError: + """Await ``coro`` and wrap its outcome as the JSON-RPC reply for ``request_id``. + + The exception-to-wire boundary for the modern HTTP entry, composed around + `serve_one`. `MCPError` and `ValidationError` map via the shared + `handler_exception_to_error_data` ladder; any other exception is logged and + surfaced as `INTERNAL_ERROR` so handler internals never reach the wire. + """ + try: + result = await coro + except Exception as exc: + error = handler_exception_to_error_data(exc) + if error is None: + logger.exception("request handler raised") + error = ErrorData(code=INTERNAL_ERROR, message="Internal server error") + return JSONRPCError(jsonrpc="2.0", id=request_id, error=error) + return JSONRPCResponse(jsonrpc="2.0", id=request_id, result=result) + + async def _write( msg: JSONRPCResponse | JSONRPCError, scope: Scope, @@ -193,5 +216,7 @@ async def handle_modern_request( request_id=req.id, message_metadata=ServerMessageMetadata(request_context=request), ) - msg = await serve_one(app, req, connection=connection, dctx=dctx, lifespan_state=lifespan_state) + msg = await _to_jsonrpc_response( + req.id, serve_one(app, dctx, req.method, req.params, connection=connection, lifespan_state=lifespan_state) + ) await _write(msg, scope, receive, send) diff --git a/src/mcp/server/auth/routes.py b/src/mcp/server/auth/routes.py index a72e819477..d88b6d1b13 100644 --- a/src/mcp/server/auth/routes.py +++ b/src/mcp/server/auth/routes.py @@ -17,8 +17,8 @@ from mcp.server.auth.middleware.client_auth import ClientAuthenticator from mcp.server.auth.provider import OAuthAuthorizationServerProvider from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions -from mcp.server.streamable_http import MCP_PROTOCOL_VERSION_HEADER from mcp.shared.auth import OAuthMetadata, ProtectedResourceMetadata +from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER def validate_issuer_url(url: AnyHttpUrl): diff --git a/src/mcp/server/connection.py b/src/mcp/server/connection.py index f5bfc18dfb..933bd5c6b6 100644 --- a/src/mcp/server/connection.py +++ b/src/mcp/server/connection.py @@ -31,8 +31,8 @@ from mcp.shared.dispatcher import CallOptions, Outbound from mcp.shared.exceptions import MCPDeprecationWarning, NoBackChannelError from mcp.shared.peer import Meta, dump_params +from mcp.shared.version import LATEST_HANDSHAKE_VERSION from mcp.types import ( - LATEST_PROTOCOL_VERSION, ClientCapabilities, CreateMessageRequest, CreateMessageResult, @@ -93,7 +93,7 @@ async def send_raw_request( ) -> dict[str, Any]: raise NoBackChannelError(method) - async def notify(self, method: str, params: Mapping[str, Any] | None) -> None: + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: logger.debug("dropped %s: no standalone channel", method) @@ -192,12 +192,12 @@ def for_loop( Not born-ready: `initialized` is set later by the kernel when `notifications/initialized` arrives. `protocol_version` is seeded from - the transport hint (or `LATEST_PROTOCOL_VERSION`) so it's never `None`; + the transport hint (or `LATEST_HANDSHAKE_VERSION`) so it's never `None`; the handshake overwrites it once negotiated. """ return cls( outbound, - protocol_version=protocol_version_hint if protocol_version_hint is not None else LATEST_PROTOCOL_VERSION, + protocol_version=protocol_version_hint if protocol_version_hint is not None else LATEST_HANDSHAKE_VERSION, session_id=session_id, ) @@ -275,14 +275,14 @@ async def send_request( cls = result_type if result_type is not None else _RESULT_FOR[type(req)] return cls.model_validate(raw, by_name=False) - async def notify(self, method: str, params: Mapping[str, Any] | None) -> None: + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: """Send a best-effort notification on the standalone stream. Never raises. If there's no standalone channel or the stream is broken, the notification is dropped and debug-logged. """ try: - await self.outbound.notify(method, params) + await self.outbound.notify(method, params, opts) except (anyio.BrokenResourceError, anyio.ClosedResourceError): logger.debug("dropped %s: standalone stream closed", method) diff --git a/src/mcp/server/elicitation.py b/src/mcp/server/elicitation.py index a34708d38d..aeb36ce573 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 7e79c94326..0979665c22 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -555,7 +555,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/context.py b/src/mcp/server/mcpserver/context.py index 0bf0b7ebfd..7856e32185 100644 --- a/src/mcp/server/mcpserver/context.py +++ b/src/mcp/server/mcpserver/context.py @@ -94,18 +94,7 @@ async def report_progress(self, progress: float, total: float | None = None, mes total: Optional total value (e.g., 100) message: Optional message (e.g., "Starting render...") """ - progress_token = self.request_context.meta.get("progress_token") if self.request_context.meta else None - - if progress_token is None: - return - - await self.request_context.session.send_progress_notification( - progress_token=progress_token, - progress=progress, - total=total, - message=message, - related_request_id=self.request_id, - ) + await self.request_context.session.report_progress(progress, total, message) async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]: """Read a resource by URI. diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 2064bd60cd..02bd5cace9 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.""" diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index bbc16abe9f..3fa8b3bc79 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -33,23 +33,21 @@ from mcp.shared._stream_protocols import ReadStream, WriteStream from mcp.shared.dispatcher import DispatchContext, Dispatcher, DispatchMiddleware, OnNotify, OnRequest from mcp.shared.exceptions import MCPError -from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher, handler_exception_to_error_data +from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher from mcp.shared.message import ServerMessageMetadata, SessionMessage from mcp.shared.transport_context import TransportContext -from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS +from mcp.shared.version import HANDSHAKE_PROTOCOL_VERSIONS, LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION from mcp.types import ( + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, INTERNAL_ERROR, INVALID_PARAMS, - LATEST_PROTOCOL_VERSION, METHOD_NOT_FOUND, + PROTOCOL_VERSION_META_KEY, ErrorData, Implementation, InitializeRequestParams, InitializeResult, - JSONRPCError, - JSONRPCRequest, - JSONRPCResponse, - RequestId, RequestParams, RequestParamsMeta, ) @@ -63,11 +61,11 @@ "ServerMiddleware", "ServerRunner", "aclose_shielded", + "modern_on_request", "otel_middleware", "serve_connection", "serve_loop", "serve_one", - "to_jsonrpc_response", ] logger = logging.getLogger(__name__) @@ -180,26 +178,6 @@ async def aclose_shielded(connection: Connection) -> None: ) -async def to_jsonrpc_response(request_id: RequestId, coro: Awaitable[dict[str, Any]]) -> JSONRPCResponse | JSONRPCError: - """Await ``coro`` and wrap its outcome as the JSON-RPC reply for ``request_id``. - - The exception-to-wire boundary for the request-per-call drivers - (`serve_one`, the modern HTTP entry). `MCPError` and `ValidationError` - map via the shared `handler_exception_to_error_data` ladder; any other - exception is logged and surfaced as `INTERNAL_ERROR` so handler internals - never reach the wire. - """ - try: - result = await coro - except Exception as exc: - error = handler_exception_to_error_data(exc) - if error is None: - logger.exception("request handler raised") - error = ErrorData(code=INTERNAL_ERROR, message="Internal server error") - return JSONRPCError(jsonrpc="2.0", id=request_id, error=error) - return JSONRPCResponse(jsonrpc="2.0", id=request_id, result=result) - - def _apply_middleware( middleware: ServerMiddleware[Any], call_next: CallNext, ctx: ServerRequestContext[Any, Any] ) -> Awaitable[HandlerResult]: @@ -414,7 +392,7 @@ def _negotiate_initialize(params: Mapping[str, Any] | None) -> tuple[InitializeR """Validate `initialize` params and pick the protocol version.""" init = InitializeRequestParams.model_validate(params or {}, by_name=False) requested = init.protocol_version - negotiated = requested if requested in SUPPORTED_PROTOCOL_VERSIONS else LATEST_PROTOCOL_VERSION + negotiated = requested if requested in HANDSHAKE_PROTOCOL_VERSIONS else LATEST_HANDSHAKE_VERSION return init, negotiated def _handle_initialize(self, params: Mapping[str, Any] | None) -> InitializeResult: @@ -494,22 +472,63 @@ async def serve_loop( async def serve_one( server: Server[LifespanT], - request: JSONRPCRequest, + dctx: DispatchContext[TransportContext], + method: str, + params: Mapping[str, Any] | None, *, connection: Connection, - dctx: DispatchContext[TransportContext], lifespan_state: LifespanT, -) -> JSONRPCResponse | JSONRPCError: - """Handle a single ``request`` and return its JSON-RPC reply. - - The single-exchange driver: builds the kernel, runs `on_request` once for - `request` under `dctx`, maps the outcome to a `JSONRPCResponse` / - `JSONRPCError` via `to_jsonrpc_response`, and tears down - `connection.exit_stack` (shielded) on the way out. The entry constructs - the (born-ready) `Connection` and the `dctx`; this only consumes them. +) -> dict[str, Any]: + """Handle a single request ``(method, params)`` and return its result dict. + + The single-exchange driver: builds the kernel, runs `on_request` once under + `dctx`, and tears down `connection.exit_stack` (shielded) on the way out. + The entry constructs the (born-ready) `Connection` and the `dctx`; this + only consumes them. + + Raises whatever the handler chain raises (`MCPError` / `ValidationError` / + unmapped); callers own the exception-to-wire mapping. """ runner = ServerRunner(server, connection, lifespan_state) try: - return await to_jsonrpc_response(request.id, runner.on_request(dctx, request.method, request.params)) + return await runner.on_request(dctx, method, params) finally: await aclose_shielded(connection) + + +def modern_on_request( + server: Server[LifespanT], lifespan_state: LifespanT, *, raise_exceptions: bool = False +) -> OnRequest: + """Return an `OnRequest` callback that serves each call via `serve_one` with a fresh per-request `Connection`. + + Wire this into the server side of a `DirectDispatcher` peer-pair to drive an + in-process server on the modern per-request-envelope path (each request + carries protocol version, client info, and capabilities in `params._meta`; + no `initialize` handshake). ``raise_exceptions`` lets unmapped handler + exceptions propagate to the caller for debuggable in-process testing; + otherwise they are sanitized to `MCPError(INTERNAL_ERROR)` so the in-process + path matches the wire path's leak guard. + """ + + async def handle( + dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None + ) -> dict[str, Any]: + meta = (params or {}).get("_meta", {}) + connection = Connection.from_envelope( + meta.get(PROTOCOL_VERSION_META_KEY, LATEST_MODERN_VERSION), + meta.get(CLIENT_INFO_META_KEY), + meta.get(CLIENT_CAPABILITIES_META_KEY), + ) + try: + return await serve_one(server, dctx, method, params, connection=connection, lifespan_state=lifespan_state) + except (MCPError, ValidationError): + # DirectDispatcher's ladder maps these onward; this layer only owns the raise_exceptions + # decision for unmapped exceptions, which DirectDispatcher would otherwise leak via str(exc). + raise + except Exception: + if raise_exceptions: + raise + logger.exception("request handler raised") + raise MCPError(code=INTERNAL_ERROR, message="Internal server error") from None + + return handle diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index be4c1805a9..aa84ad37b8 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -14,7 +14,7 @@ from mcp import types from mcp.server.connection import Connection from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages -from mcp.shared.dispatcher import CallOptions, Outbound, ProgressFnT +from mcp.shared.dispatcher import CallOptions, DispatchContext, ProgressFnT from mcp.shared.exceptions import MCPDeprecationWarning from mcp.shared.message import ServerMessageMetadata from mcp.types import methods as _methods @@ -36,7 +36,7 @@ class ServerSession: never crosses the `Outbound` Protocol. """ - def __init__(self, request_outbound: Outbound, connection: Connection) -> None: + def __init__(self, request_outbound: DispatchContext[Any], connection: Connection) -> None: self._request_outbound = request_outbound self._connection = connection @@ -353,6 +353,16 @@ async def send_ping(self) -> types.EmptyResult: types.EmptyResult, ) + async def report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: + """Report progress for the inbound request this session is scoped to. + + A no-op when the caller did not request progress. Dispatcher-agnostic: + on JSON-RPC the held `DispatchContext` emits ``notifications/progress`` + against the caller's token; on the in-process direct dispatcher it + invokes the caller's callback directly. + """ + await self._request_outbound.progress(progress, total, message) + async def send_progress_notification( self, progress_token: str | int, diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index f8aec6c9e2..aa682cbf2a 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -28,6 +28,7 @@ from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams from mcp.shared._stream_protocols import ReadStream, WriteStream +from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER from mcp.shared.message import ServerMessageMetadata, SessionMessage from mcp.shared.version import is_version_at_least from mcp.types import ( @@ -50,7 +51,6 @@ # Header names MCP_SESSION_ID_HEADER = "mcp-session-id" -MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version" LAST_EVENT_ID_HEADER = "last-event-id" # Content types @@ -818,7 +818,7 @@ async def _handle_unsupported_request(self, request: Request, send: Send) -> Non async def _validate_request_headers(self, request: Request, send: Send) -> bool: # Protocol-version validation lives in the manager's era-routing: only - # values in `SUPPORTED_PROTOCOL_VERSIONS` (or no header at all) reach + # values in `HANDSHAKE_PROTOCOL_VERSIONS` (or no header at all) reach # this transport, so the legacy version-gate is gone. return await self._validate_session(request, send) diff --git a/src/mcp/server/streamable_http_manager.py b/src/mcp/server/streamable_http_manager.py index 648dcc827f..f9329f8564 100644 --- a/src/mcp/server/streamable_http_manager.py +++ b/src/mcp/server/streamable_http_manager.py @@ -19,16 +19,16 @@ from mcp.server.connection import Connection from mcp.server.runner import serve_connection, serve_loop from mcp.server.streamable_http import ( - MCP_PROTOCOL_VERSION_HEADER, MCP_SESSION_ID_HEADER, EventStore, StreamableHTTPServerTransport, ) from mcp.server.transport_security import TransportSecuritySettings from mcp.shared._compat import resync_tracer +from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher from mcp.shared.transport_context import TransportContext -from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS +from mcp.shared.version import HANDSHAKE_PROTOCOL_VERSIONS from mcp.types import DEFAULT_NEGOTIATED_VERSION, INVALID_REQUEST, ErrorData, JSONRPCError if TYPE_CHECKING: @@ -169,7 +169,7 @@ async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> No # and return a structured rejection. 2025 paths below remain unchanged. header = MCP_PROTOCOL_VERSION_HEADER.encode("ascii") pv = next((v.decode("latin-1") for k, v in scope["headers"] if k == header), None) - if pv is not None and pv not in SUPPORTED_PROTOCOL_VERSIONS: + if pv is not None and pv not in HANDSHAKE_PROTOCOL_VERSIONS: await handle_modern_request(self.app, self.security_settings, self._lifespan_state, scope, receive, send) return diff --git a/src/mcp/shared/context.py b/src/mcp/shared/context.py index 11a0aae0a2..eef1fa3855 100644 --- a/src/mcp/shared/context.py +++ b/src/mcp/shared/context.py @@ -73,9 +73,9 @@ async def send_raw_request( """ return await self._dctx.send_raw_request(method, params, opts) - async def notify(self, method: str, params: Mapping[str, Any] | None) -> None: + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: """Send a notification to the peer on the back-channel.""" - await self._dctx.notify(method, params) + await self._dctx.notify(method, params, opts) async def report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: """Report progress for this request, if the peer supplied a progress token. diff --git a/src/mcp/shared/direct_dispatcher.py b/src/mcp/shared/direct_dispatcher.py index 4460be4e0d..d521840bef 100644 --- a/src/mcp/shared/direct_dispatcher.py +++ b/src/mcp/shared/direct_dispatcher.py @@ -63,7 +63,7 @@ class _DirectDispatchContext: def can_send_request(self) -> bool: return self.transport.can_send_request - async def notify(self, method: str, params: Mapping[str, Any] | None) -> None: + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: await self._back_notify(method, params) async def send_raw_request( @@ -133,12 +133,13 @@ async def send_raw_request( raise RuntimeError("DirectDispatcher.send_raw_request called before run()") return await self._peer._dispatch_request(method, params, opts) - async def notify(self, method: str, params: Mapping[str, Any] | None) -> None: + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: """Send a notification by invoking the peer's `on_notify` directly. Fire-and-forget: usable before `run()` (delivery waits for the peer to start), and after close it is silently dropped, matching - `JSONRPCDispatcher.notify`. + `JSONRPCDispatcher.notify`. `opts` is accepted for `Dispatcher` + conformance; there is no HTTP layer here so `headers` is ignored. """ if self._peer is None: raise RuntimeError("DirectDispatcher has no peer; use create_direct_dispatcher_pair()") diff --git a/src/mcp/shared/dispatcher.py b/src/mcp/shared/dispatcher.py index 888e55ba33..8b343f2555 100644 --- a/src/mcp/shared/dispatcher.py +++ b/src/mcp/shared/dispatcher.py @@ -85,6 +85,9 @@ class CallOptions(TypedDict, total=False): resumption is removed in the next protocol revision. """ + headers: dict[str, str] + """Transport-layer hint: HTTP transports merge these onto the outgoing request; non-HTTP transports ignore.""" + @runtime_checkable class Outbound(Protocol): @@ -111,7 +114,7 @@ async def send_raw_request( """ ... - async def notify(self, method: str, params: Mapping[str, Any] | None) -> None: + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: """Send a fire-and-forget notification.""" ... diff --git a/src/mcp/shared/inbound.py b/src/mcp/shared/inbound.py index 04aa93c141..fb4c765246 100644 --- a/src/mcp/shared/inbound.py +++ b/src/mcp/shared/inbound.py @@ -8,6 +8,8 @@ status. """ +import base64 +import re from collections.abc import Mapping, Sequence from dataclasses import dataclass from types import MappingProxyType @@ -34,13 +36,33 @@ "ERROR_CODE_HTTP_STATUS", "InboundLadderRejection", "InboundModernRoute", + "MCP_METHOD_HEADER", + "MCP_NAME_HEADER", "MCP_PROTOCOL_VERSION_HEADER", "classify_inbound_request", + "encode_header_value", ] MCP_PROTOCOL_VERSION_HEADER: Final = "mcp-protocol-version" """Canonical lowercase name of the HTTP header carrying the MCP protocol version.""" +MCP_METHOD_HEADER: Final = "mcp-method" +"""Canonical lowercase name of the HTTP header carrying the JSON-RPC method.""" + +MCP_NAME_HEADER: Final = "mcp-name" +"""Canonical lowercase name of the HTTP header carrying the resource name (tool/prompt/resource URI).""" + +_B64_SENTINEL = re.compile(r"^=\?base64\?.*\?=$") +# RFC 7230 token chars minus DEL; visible ASCII 0x20-0x7E is the practical bound for a header value. +_HEADER_SAFE = re.compile(r"^[\x20-\x7E]*$") + + +def encode_header_value(value: str) -> str: + if _HEADER_SAFE.fullmatch(value) and value == value.strip() and not _B64_SENTINEL.fullmatch(value): + return value + return f"=?base64?{base64.b64encode(value.encode('utf-8')).decode('ascii')}?=" + + # INTERNAL_ERROR is deliberately unmapped (→ HTTP 200): the spec assigns no status to # -32603, and whether handler-origin errors get 5xx is an open S4 question — see TODO(L66). ERROR_CODE_HTTP_STATUS: Final[Mapping[int, int]] = MappingProxyType( diff --git a/src/mcp/shared/jsonrpc_dispatcher.py b/src/mcp/shared/jsonrpc_dispatcher.py index 7fabafff65..24f1d3593a 100644 --- a/src/mcp/shared/jsonrpc_dispatcher.py +++ b/src/mcp/shared/jsonrpc_dispatcher.py @@ -75,7 +75,7 @@ def handler_exception_to_error_data(exc: BaseException) -> ErrorData | None: with empty ``data`` (no pydantic text on the wire). Returns ``None`` for any other exception so each caller applies its own catch-all - `JSONRPCDispatcher` currently pins ``code=0`` for v1 compat, - `to_jsonrpc_response` uses `INTERNAL_ERROR`. + the modern HTTP entry uses `INTERNAL_ERROR`. """ if isinstance(exc, MCPError): return exc.error @@ -132,11 +132,11 @@ def request_id(self) -> RequestId | None: def can_send_request(self) -> bool: return self.transport.can_send_request and not self._closed - async def notify(self, method: str, params: Mapping[str, Any] | None) -> None: + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: if self._closed: logger.debug("dropped %s: dispatch context closed", method) return - await self._dispatcher.notify(method, params, _related_request_id=self._request_id) + await self._dispatcher.notify(method, params, opts, _related_request_id=self._request_id) async def send_raw_request( self, @@ -209,6 +209,7 @@ def _plan_outbound(related_request_id: RequestId | None, opts: CallOptions | Non cancel_on_abandon = opts.get("cancel_on_abandon", True) token = opts.get("resumption_token") on_token = opts.get("on_resumption_token") + headers = opts.get("headers") if related_request_id is not None: if token is not None or on_token is not None: logger.debug( @@ -217,9 +218,11 @@ def _plan_outbound(related_request_id: RequestId | None, opts: CallOptions | Non return _OutboundPlan(ServerMessageMetadata(related_request_id=related_request_id), cancel_on_abandon) if token is not None or on_token is not None: return _OutboundPlan( - ClientMessageMetadata(resumption_token=token, on_resumption_token_update=on_token), + ClientMessageMetadata(resumption_token=token, on_resumption_token_update=on_token, headers=headers), cancel_on_abandon=False, ) + if headers: + return _OutboundPlan(ClientMessageMetadata(headers=headers), cancel_on_abandon) return _OutboundPlan(None, cancel_on_abandon) @@ -265,7 +268,10 @@ def __init__( self._peer_cancel_mode: PeerCancelMode = peer_cancel_mode self._raise_handler_exceptions = raise_handler_exceptions self._inline_methods = inline_methods - self._on_stream_exception = on_stream_exception + self.on_stream_exception = on_stream_exception + """Observer for ``Exception`` items on the read stream. Mutable so a session can + bind it after the dispatcher is built (e.g. ``ClientSession`` routing into + ``message_handler``); only consulted inside ``run()`` so pre-enter assignment is safe.""" self._next_id = 0 self._pending: dict[RequestId, _Pending] = {} @@ -395,6 +401,7 @@ async def notify( self, method: str, params: Mapping[str, Any] | None, + opts: CallOptions | None = None, *, _related_request_id: RequestId | None = None, ) -> None: @@ -414,7 +421,7 @@ async def notify( else: msg = JSONRPCNotification(jsonrpc="2.0", method=method) try: - await self._write(msg, _plan_outbound(_related_request_id, None).metadata) + await self._write(msg, _plan_outbound(_related_request_id, opts).metadata) except (anyio.BrokenResourceError, anyio.ClosedResourceError): # Transport tore down before run() noticed EOF. logger.debug("dropped %s: write stream closed", method) @@ -480,11 +487,11 @@ async def _dispatch( are awaited; any other `await` would head-of-line block the read loop. """ if isinstance(item, Exception): - if self._on_stream_exception is None: + if self.on_stream_exception is None: logger.debug("transport yielded exception: %r", item) return try: - await self._on_stream_exception(item) + await self.on_stream_exception(item) except Exception: logger.exception("on_stream_exception observer raised") return diff --git a/src/mcp/shared/message.py b/src/mcp/shared/message.py index dba263ad5a..a0b6561151 100644 --- a/src/mcp/shared/message.py +++ b/src/mcp/shared/message.py @@ -24,6 +24,8 @@ class ClientMessageMetadata: resumption_token: ResumptionToken | None = None on_resumption_token_update: Callable[[ResumptionToken], Awaitable[None]] | None = None + # Per-message HTTP headers (e.g. MCP-Protocol-Version, Mcp-Method) the transport should set. + headers: dict[str, str] | None = None @dataclass @@ -35,9 +37,6 @@ class ServerMessageMetadata: # transports, None for stdio). Typed as Any because the server layer is # transport-agnostic. request_context: Any = None - # Per-message protocol version observed by the transport (e.g. the - # validated MCP-Protocol-Version header). - protocol_version: str | None = None # Callback to close SSE stream for the current request without terminating close_sse_stream: CloseSSEStreamCallback | None = None # Callback to close the standalone GET SSE stream (for unsolicited notifications) diff --git a/src/mcp/shared/peer.py b/src/mcp/shared/peer.py index ddf5c1c8ce..2cf7b36823 100644 --- a/src/mcp/shared/peer.py +++ b/src/mcp/shared/peer.py @@ -81,8 +81,8 @@ async def send_raw_request( ) -> dict[str, Any]: return await self._outbound.send_raw_request(method, params, opts) - async def notify(self, method: str, params: Mapping[str, Any] | None) -> None: - await self._outbound.notify(method, params) + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: + await self._outbound.notify(method, params, opts) @overload @deprecated("The sampling capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) diff --git a/src/mcp/shared/version.py b/src/mcp/shared/version.py index 09aacb6956..c5c2233274 100644 --- a/src/mcp/shared/version.py +++ b/src/mcp/shared/version.py @@ -9,8 +9,6 @@ from typing import Final -from mcp.types import LATEST_PROTOCOL_VERSION - KNOWN_PROTOCOL_VERSIONS: Final[tuple[str, ...]] = ( "2024-11-05", "2025-03-26", @@ -20,11 +18,34 @@ ) """Every released protocol revision, oldest to newest.""" +HANDSHAKE_PROTOCOL_VERSIONS: Final[tuple[str, ...]] = ( + "2024-11-05", + "2025-03-26", + "2025-06-18", + "2025-11-25", +) +"""Protocol revisions reachable via the initialize handshake.""" + MODERN_PROTOCOL_VERSIONS: Final[tuple[str, ...]] = ("2026-07-28",) """Protocol revisions that use the stateless per-request envelope.""" -SUPPORTED_PROTOCOL_VERSIONS: list[str] = ["2024-11-05", "2025-03-26", "2025-06-18", LATEST_PROTOCOL_VERSION] -"""Protocol revisions this SDK can negotiate.""" +SUPPORTED_PROTOCOL_VERSIONS: tuple[str, ...] = (*HANDSHAKE_PROTOCOL_VERSIONS, *MODERN_PROTOCOL_VERSIONS) +"""Deprecated: prefer HANDSHAKE_PROTOCOL_VERSIONS or MODERN_PROTOCOL_VERSIONS. + +Kept as the union for v1.x compatibility. +""" + +LATEST_PROTOCOL_VERSION: Final[str] = KNOWN_PROTOCOL_VERSIONS[-1] +"""Newest protocol revision this SDK speaks (any era).""" + +LATEST_HANDSHAKE_VERSION: Final[str] = HANDSHAKE_PROTOCOL_VERSIONS[-1] +"""Newest revision reachable via the ``initialize`` handshake; the client's offer and server's counter-offer default.""" + +LATEST_MODERN_VERSION: Final[str] = MODERN_PROTOCOL_VERSIONS[-1] +"""Newest per-request-envelope revision; the ``server/discover`` probe default.""" + +OLDEST_SUPPORTED_VERSION: Final[str] = HANDSHAKE_PROTOCOL_VERSIONS[0] +"""Oldest revision this SDK still negotiates via the ``initialize`` handshake.""" def is_version_at_least(version: str, minimum: str) -> bool: diff --git a/src/mcp/types/__init__.py b/src/mcp/types/__init__.py index 992d584687..491047ae74 100644 --- a/src/mcp/types/__init__.py +++ b/src/mcp/types/__init__.py @@ -4,12 +4,13 @@ https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.json """ +from mcp.shared.version import LATEST_PROTOCOL_VERSION + # Re-export everything from _types for backward compatibility from mcp.types._types import ( CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, DEFAULT_NEGOTIATED_VERSION, - LATEST_PROTOCOL_VERSION, LOG_LEVEL_META_KEY, PROTOCOL_VERSION_META_KEY, Annotations, diff --git a/src/mcp/types/_types.py b/src/mcp/types/_types.py index 82b4a084d5..815803c34f 100644 --- a/src/mcp/types/_types.py +++ b/src/mcp/types/_types.py @@ -22,12 +22,6 @@ from mcp.types.jsonrpc import RequestId -LATEST_PROTOCOL_VERSION: Final[str] = "2025-11-25" -"""The newest protocol version this SDK can negotiate. - -See https://modelcontextprotocol.io/specification/latest. -""" - DEFAULT_NEGOTIATED_VERSION: Final[str] = "2025-03-26" """The default negotiated version of the Model Context Protocol when no version is specified. diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 3680639e0f..991cd1e5e1 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -3,8 +3,8 @@ from __future__ import annotations import contextvars -from collections.abc import Iterator -from contextlib import contextmanager +from collections.abc import AsyncIterator, Iterator +from contextlib import asynccontextmanager, contextmanager from unittest.mock import patch import anyio @@ -13,9 +13,14 @@ from mcp import MCPError, types from mcp.client._memory import InMemoryTransport +from mcp.client._transport import TransportStreams from mcp.client.client import Client +from mcp.client.streamable_http import streamable_http_client from mcp.server import Server, ServerRequestContext from mcp.server.mcpserver import MCPServer +from mcp.shared.memory import MessageStream, create_client_server_memory_streams +from mcp.shared.message import SessionMessage +from mcp.shared.version import LATEST_HANDSHAKE_VERSION from mcp.types import ( CallToolResult, EmptyResult, @@ -37,6 +42,7 @@ Tool, ToolsCapability, ) +from tests.interaction._connect import BASE_URL, mounted_app pytestmark = pytest.mark.anyio @@ -102,7 +108,7 @@ def greeting_prompt(name: str) -> str: async def test_client_is_initialized(app: MCPServer): """Test that the client is initialized after entering context.""" async with Client(app) as client: - assert client.initialize_result.capabilities == snapshot( + assert client.server_capabilities == snapshot( ServerCapabilities( experimental={}, prompts=PromptsCapability(list_changed=False), @@ -110,13 +116,13 @@ async def test_client_is_initialized(app: MCPServer): tools=ToolsCapability(list_changed=False), ) ) - assert client.initialize_result.server_info.name == "test" + assert client.server_info.name == "test" -async def test_client_initialize_result_exposes_negotiated_protocol_version(app: MCPServer): +async def test_client_exposes_negotiated_protocol_version(app: MCPServer): """The negotiated protocol version is readable after initialization.""" async with Client(app) as client: - assert client.initialize_result.protocol_version == types.LATEST_PROTOCOL_VERSION + assert client.protocol_version == LATEST_HANDSHAKE_VERSION async def test_client_with_simple_server(simple_server: Server): @@ -200,6 +206,40 @@ async def handle_read_resource( assert exc_info.value.error.code == 404 +async def test_raise_exceptions_propagates_handler_error_on_modern_inproc_path(): + """`raise_exceptions=True` on the modern in-process path: an unmapped handler + exception reaches the client with its original type chained, instead of being + sanitized to an opaque `INTERNAL_ERROR`.""" + + async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + raise ValueError("boom") + + server = Server("test", on_call_tool=handle_call_tool) + async with Client(server, mode="2026-07-28", raise_exceptions=True) as client: + with pytest.raises(MCPError) as exc_info: + await client.call_tool("explode", {}) + # The original exception is chained — not swallowed into a generic "Internal server error". + assert isinstance(exc_info.value.__cause__, ValueError) + assert str(exc_info.value.__cause__) == "boom" + + +async def test_raise_exceptions_false_sanitizes_handler_error_on_modern_inproc_path(): + """`raise_exceptions=False` (the default) on the modern in-process path: an + unmapped handler exception is sanitized to an opaque `INTERNAL_ERROR` so the + in-process path matches the wire path's leak guard.""" + + async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + raise ValueError("boom") + + server = Server("test", on_call_tool=handle_call_tool) + async with Client(server, mode="2026-07-28", raise_exceptions=False) as client: + with pytest.raises(MCPError) as exc_info: + await client.call_tool("explode", {}) + assert exc_info.value.error.code == types.INTERNAL_ERROR + assert exc_info.value.error.message == "Internal server error" + assert exc_info.value.__cause__ is None + + async def test_get_prompt(app: MCPServer): """Test getting a prompt.""" async with Client(app) as client: @@ -239,7 +279,7 @@ async def handle_progress(ctx: ServerRequestContext, params: types.ProgressNotif server = Server(name="test_server", on_progress=handle_progress) async with Client(server) as client: - await client.send_progress_notification(progress_token="token123", progress=50.0) + await client.send_progress_notification(progress_token="token123", progress=50.0) # pyright: ignore[reportDeprecated] await event.wait() assert received_from_client == snapshot({"progress_token": "token123", "progress": 50.0}) @@ -316,7 +356,7 @@ async def test_complete_with_prompt_reference(simple_server: Server): def test_client_with_url_initializes_streamable_http_transport(): with patch("mcp.client.client.streamable_http_client") as mock: _ = Client("http://localhost:8000/mcp") - mock.assert_called_once_with("http://localhost:8000/mcp", protocol_version=None) + mock.assert_called_once_with("http://localhost:8000/mcp") async def test_client_uses_transport_directly(app: MCPServer): @@ -359,3 +399,82 @@ async def check_context() -> str: assert result.content[0].text == "client_value", ( # type: ignore[union-attr] "Server handler did not see the sender's contextvars.Context" ) + + +async def test_client_auto_mode_probes_discover_then_adopts(simple_server: Server) -> None: + """`mode='auto'` over an in-process HTTP transport: the `server/discover` probe + reaches the modern entry and the negotiated protocol version is adopted without + an `initialize` handshake. Runs over HTTP because the in-memory runner gates + `server/discover` behind the init handshake.""" + with anyio.fail_after(5): + async with ( + mounted_app(simple_server) as (http, _), + Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto") as client, + ): + assert client.protocol_version == "2026-07-28" + assert (await client.list_resources()).resources[0].name == "Test Resource" + + +@pytest.mark.parametrize("code", [types.METHOD_NOT_FOUND, types.REQUEST_TIMEOUT]) +async def test_client_auto_mode_falls_back_to_initialize_on_legacy_signal(code: int) -> None: + """`mode='auto'`: when `server/discover` is rejected with -32601 or -32001, + `Client.__aenter__` runs the legacy `initialize()` handshake and lands at a + handshake-era protocol version. The session itself does not fall back — + that policy lives here. A real `Server` always implements `server/discover`, + so the server side is hand-played.""" + methods_seen: list[str] = [] + + async def scripted_server(streams: MessageStream) -> None: + server_read, server_write = streams + async for message in server_read: + assert isinstance(message, SessionMessage) + frame = message.message + assert isinstance(frame, types.JSONRPCRequest | types.JSONRPCNotification) + methods_seen.append(frame.method) + if isinstance(frame, types.JSONRPCNotification): + continue + if frame.method == "server/discover": + error = types.ErrorData(code=code, message="nope") + await server_write.send(SessionMessage(types.JSONRPCError(jsonrpc="2.0", id=frame.id, error=error))) + elif frame.method == "initialize": # pragma: no branch + result = types.InitializeResult( + protocol_version=LATEST_HANDSHAKE_VERSION, + capabilities=ServerCapabilities(), + server_info=types.Implementation(name="legacy-only", version="0.0.1"), + ) + await server_write.send( + SessionMessage( + types.JSONRPCResponse( + jsonrpc="2.0", + id=frame.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + + @asynccontextmanager + async def scripted_transport() -> AsyncIterator[TransportStreams]: + async with ( + create_client_server_memory_streams() as ((client_read, client_write), server_streams), + anyio.create_task_group() as tg, + ): + tg.start_soon(scripted_server, server_streams) + yield client_read, client_write + tg.cancel_scope.cancel() + + with anyio.fail_after(5): + async with Client(scripted_transport(), mode="auto") as client: + assert client.protocol_version == LATEST_HANDSHAKE_VERSION + assert client.server_info.name == "legacy-only" + assert methods_seen == ["server/discover", "initialize", "notifications/initialized"] + + +def test_client_rejects_handshake_era_mode_at_construction() -> None: + """A handshake-era protocol-version string passed as `mode=` is rejected by + `__post_init__` with a hint to use `mode='legacy'` — the version-pin path is + modern-only.""" + server = MCPServer("test") + with pytest.raises(ValueError, match=r"handshake-era version — use mode='legacy'"): + Client(server, mode="2025-06-18") + with pytest.raises(ValueError, match=r"mode must be 'legacy', 'auto', or one of"): + Client(server, mode="not-a-version") diff --git a/tests/client/test_notification_response.py b/tests/client/test_notification_response.py index 4dbd78dbbe..bd85cd074a 100644 --- a/tests/client/test_notification_response.py +++ b/tests/client/test_notification_response.py @@ -204,12 +204,19 @@ async def test_invalid_json_response_sends_jsonrpc_error() -> None: def _create_non_2xx_json_body_app(status: int, body: bytes) -> Starlette: - """Server that returns a fixed non-2xx status + ``application/json`` body for non-init requests.""" + """Server that returns a fixed non-2xx status + ``application/json`` body for non-init requests. + + The initialize response carries an ``mcp-session-id`` so the client treats subsequent + requests as part of an established session (needed for the 404 → session-terminated mapping). + """ async def handle_mcp_request(request: Request) -> Response: data = json.loads(await request.body()) if data.get("method") == "initialize": - return _init_json_response(data) + return JSONResponse( + {"jsonrpc": "2.0", "id": data["id"], "result": INIT_RESPONSE}, + headers={"mcp-session-id": "test-session"}, + ) if "id" not in data: return Response(status_code=202) return Response(content=body, status_code=status, media_type="application/json") diff --git a/tests/client/test_session.py b/tests/client/test_session.py index c171360de2..933171eabd 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -18,14 +18,15 @@ from mcp.shared.message import SessionMessage from mcp.shared.session import RequestResponder from mcp.shared.transport_context import TransportContext -from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS +from mcp.shared.version import HANDSHAKE_PROTOCOL_VERSIONS, LATEST_HANDSHAKE_VERSION from mcp.types import ( CONNECTION_CLOSED, INTERNAL_ERROR, INVALID_PARAMS, - LATEST_PROTOCOL_VERSION, METHOD_NOT_FOUND, + PROTOCOL_VERSION_META_KEY, REQUEST_TIMEOUT, + UNSUPPORTED_PROTOCOL_VERSION, CallToolResult, Implementation, InitializedNotification, @@ -86,7 +87,7 @@ async def mock_server(): assert isinstance(request, InitializeRequest) result = InitializeResult( - protocol_version=LATEST_PROTOCOL_VERSION, + protocol_version=LATEST_HANDSHAKE_VERSION, capabilities=ServerCapabilities( logging=None, resources=None, @@ -139,7 +140,7 @@ async def message_handler( # pragma: no cover # Assert the result assert isinstance(result, InitializeResult) - assert result.protocol_version == LATEST_PROTOCOL_VERSION + assert result.protocol_version == LATEST_HANDSHAKE_VERSION assert isinstance(result.capabilities, ServerCapabilities) assert result.server_info == Implementation(name="mock-server", version="0.1.0") assert result.instructions == "The server instructions." @@ -170,7 +171,7 @@ async def mock_server(): received_client_info = request.params.client_info result = InitializeResult( - protocol_version=LATEST_PROTOCOL_VERSION, + protocol_version=LATEST_HANDSHAKE_VERSION, capabilities=ServerCapabilities(), server_info=Implementation(name="mock-server", version="0.1.0"), ) @@ -227,7 +228,7 @@ async def mock_server(): received_client_info = request.params.client_info result = InitializeResult( - protocol_version=LATEST_PROTOCOL_VERSION, + protocol_version=LATEST_HANDSHAKE_VERSION, capabilities=ServerCapabilities(), server_info=Implementation(name="mock-server", version="0.1.0"), ) @@ -276,8 +277,8 @@ async def mock_server(): ) assert isinstance(request, InitializeRequest) - # Verify client sent the latest protocol version - assert request.params.protocol_version == LATEST_PROTOCOL_VERSION + # Verify client offers the newest handshake protocol version + assert request.params.protocol_version == LATEST_HANDSHAKE_VERSION # Server responds with a supported older version result = InitializeResult( @@ -313,7 +314,7 @@ async def mock_server(): # Assert the result with negotiated version assert isinstance(result, InitializeResult) assert result.protocol_version == "2024-11-05" - assert result.protocol_version in SUPPORTED_PROTOCOL_VERSIONS + assert result.protocol_version in HANDSHAKE_PROTOCOL_VERSIONS @pytest.mark.anyio @@ -385,7 +386,7 @@ async def mock_server(): received_capabilities = request.params.capabilities result = InitializeResult( - protocol_version=LATEST_PROTOCOL_VERSION, + protocol_version=LATEST_HANDSHAKE_VERSION, capabilities=ServerCapabilities(), server_info=Implementation(name="mock-server", version="0.1.0"), ) @@ -456,7 +457,7 @@ async def mock_server(): received_capabilities = request.params.capabilities result = InitializeResult( - protocol_version=LATEST_PROTOCOL_VERSION, + protocol_version=LATEST_HANDSHAKE_VERSION, capabilities=ServerCapabilities(), server_info=Implementation(name="mock-server", version="0.1.0"), ) @@ -535,7 +536,7 @@ async def mock_server(): received_capabilities = request.params.capabilities result = InitializeResult( - protocol_version=LATEST_PROTOCOL_VERSION, + protocol_version=LATEST_HANDSHAKE_VERSION, capabilities=ServerCapabilities(), server_info=Implementation(name="mock-server", version="0.1.0"), ) @@ -603,7 +604,7 @@ async def mock_server(): assert isinstance(request, InitializeRequest) result = InitializeResult( - protocol_version=LATEST_PROTOCOL_VERSION, + protocol_version=LATEST_HANDSHAKE_VERSION, capabilities=expected_capabilities, server_info=expected_server_info, instructions=expected_instructions, @@ -642,7 +643,12 @@ async def mock_server(): assert result.server_info == expected_server_info assert result.capabilities == expected_capabilities assert result.instructions == expected_instructions - assert result.protocol_version == LATEST_PROTOCOL_VERSION + assert result.protocol_version == LATEST_HANDSHAKE_VERSION + # Era-neutral accessors are populated from the InitializeResult. + assert session.server_info == expected_server_info + assert session.server_capabilities == expected_capabilities + assert session.instructions == expected_instructions + assert session.protocol_version == LATEST_HANDSHAKE_VERSION @pytest.mark.anyio @@ -665,7 +671,7 @@ async def mock_server(): assert isinstance(request, InitializeRequest) result = InitializeResult( - protocol_version=LATEST_PROTOCOL_VERSION, + protocol_version=LATEST_HANDSHAKE_VERSION, capabilities=ServerCapabilities(), server_info=Implementation(name="mock-server", version="0.1.0"), ) @@ -784,10 +790,12 @@ async def test_receive_loop_drops_unknown_notification_method_without_response() def _set_negotiated_version(session: ClientSession, version: str) -> None: """Force `session.protocol_version` without running the handshake.""" - session._initialize_result = InitializeResult( - protocol_version=version, - capabilities=ServerCapabilities(), - server_info=Implementation(name="mock-server", version="0.1.0"), + session.adopt( + InitializeResult( + protocol_version=version, + capabilities=ServerCapabilities(), + server_info=Implementation(name="mock-server", version="0.1.0"), + ) ) @@ -1300,6 +1308,25 @@ async def test_dispatcher_keyword_request_timeout_bounds_wait_for_never_run_peer assert exc.value.error.code == REQUEST_TIMEOUT +def test_adopt_raises_when_no_mutual_modern_version_is_supported() -> None: + """SDK-defined: ``adopt(DiscoverResult)`` picks the newest version both sides support; an + empty intersection is unrecoverable and raises rather than installing a stamp.""" + client_d, _ = create_direct_dispatcher_pair() + session = ClientSession(dispatcher=client_d) + with pytest.raises(RuntimeError, match="No mutually supported modern protocol version"): + session.adopt( + types.DiscoverResult( + supported_versions=["1999-01-01"], + capabilities=types.ServerCapabilities(), + server_info=types.Implementation(name="s", version="0"), + result_type="complete", + ttl_ms=0, + cache_scope="public", + ) + ) + assert session.protocol_version is None + + @pytest.mark.anyio async def test_initialize_opts_out_of_cancel_on_abandon_while_other_requests_leave_it_unset(): """`send_request` passes `cancel_on_abandon=False` for `initialize` — the spec forbids @@ -1327,13 +1354,13 @@ async def send_raw_request( self.calls.append((method, opts or {})) if method == "initialize": return InitializeResult( - protocol_version=LATEST_PROTOCOL_VERSION, + protocol_version=LATEST_HANDSHAKE_VERSION, capabilities=ServerCapabilities(), server_info=Implementation(name="mock-server", version="0.1.0"), ).model_dump(by_alias=True, mode="json", exclude_none=True) return {} - async def notify(self, method: str, params: Mapping[str, Any] | None) -> None: + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: pass dispatcher = RecordingDispatcher() @@ -1387,7 +1414,7 @@ async def send_raw_request( ) -> dict[str, Any]: raise NotImplementedError - async def notify(self, method: str, params: Mapping[str, Any] | None) -> None: + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: raise NotImplementedError session = ClientSession(dispatcher=NeverStartsDispatcher()) @@ -1400,66 +1427,6 @@ async def notify(self, method: str, params: Mapping[str, Any] | None) -> None: assert session._task_group is None -@pytest.mark.anyio -async def test_initialize_on_a_stateless_pinned_session_returns_the_synthesized_result_without_any_frame_sent(): - """A session pinned to the 2026-07-28 stateless protocol is born initialized. - - The 2026-07-28 lifecycle replaces the initialize handshake with a per-request ``_meta`` - envelope, so ``initialize()`` is idempotent and returns a locally-synthesized result - without ever touching the wire. - """ - async with raw_client_session(protocol_version="2026-07-28") as (session, _send, from_client): - result = await session.initialize() - assert result.protocol_version == "2026-07-28" - assert isinstance(result.capabilities, ServerCapabilities) - assert from_client.statistics().current_buffer_used == 0 - assert (await session.initialize()) is result - - -@pytest.mark.anyio -async def test_initialize_on_a_stateful_pin_requests_the_pinned_version(): - """A session pinned to a pre-2026 stateful version still runs the handshake, but the - outgoing ``initialize`` frame requests the pinned version rather than ``LATEST``.""" - async with raw_client_session(protocol_version="2025-06-18") as (session, to_client, from_client): - first: list[InitializeResult] = [] - - async def do_initialize() -> None: - first.append(await session.initialize()) - - async with anyio.create_task_group() as tg: - tg.start_soon(do_initialize) - out = await from_client.receive() - assert isinstance(out.message, JSONRPCRequest) - assert out.message.params is not None - assert out.message.params["protocolVersion"] == "2025-06-18" - assert session.protocol_version == "2025-06-18" - # Server negotiates a different (older) supported version than the pin requested. - result = InitializeResult( - protocol_version="2025-03-26", - capabilities=ServerCapabilities(), - server_info=Implementation(name="mock-server", version="0.1.0"), - ) - await to_client.send( - SessionMessage( - JSONRPCResponse( - jsonrpc="2.0", - id=out.message.id, - result=result.model_dump(by_alias=True, mode="json", exclude_none=True), - ) - ) - ) - # Drain the notifications/initialized frame so the buffer-used assertion below - # measures only what the second initialize() emits. - notif = await from_client.receive() - assert isinstance(notif.message, JSONRPCNotification) - # The property reports the negotiated version, not the pin, once the handshake is done. - assert session.protocol_version == "2025-03-26" - # A second call returns the cached result without a second handshake frame. - again = await session.initialize() - assert again is first[0] - assert from_client.statistics().current_buffer_used == 0 - - @pytest.mark.anyio async def test_send_notification_after_close_is_dropped_silently(): """Post-close `send_notification` is fire-and-forget: the notification is dropped, @@ -1476,3 +1443,199 @@ async def test_send_notification_after_close_is_dropped_silently(): finally: for s in (s2c_send, s2c_recv, c2s_send, c2s_recv): s.close() + + +# --- discover() ladder --- + + +class _ScriptedDispatcher: + """Records every `send_raw_request` and plays back scripted answers in order. + + A script entry that is an `Exception` is raised; a dict is returned.""" + + def __init__(self, *script: dict[str, Any] | Exception) -> None: + self.calls: list[tuple[str, Mapping[str, Any] | None]] = [] + self.notifies: list[str] = [] + self._script: list[dict[str, Any] | Exception] = list(script) + + async def run( + self, + on_request: OnRequest, + on_notify: OnNotify, + *, + task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, + ) -> None: + task_status.started() + await anyio.sleep_forever() + + async def send_raw_request( + self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None + ) -> dict[str, Any]: + self.calls.append((method, params)) + item = self._script.pop(0) + if isinstance(item, Exception): + raise item + return item + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: + self.notifies.append( + method + ) # pragma: no cover — recorded so a wrongly-sent notification fails the == [] assert + + +def _discover_result_dict() -> dict[str, Any]: + return types.DiscoverResult( + supported_versions=["2026-07-28"], + capabilities=ServerCapabilities(), + server_info=Implementation(name="stub", version="0"), + ).model_dump(by_alias=True, mode="json", exclude_none=True) + + +@pytest.mark.anyio +async def test_discover_adopts_the_returned_result_and_installs_the_modern_stamp() -> None: + """SDK-defined: a successful `server/discover` is adopted and subsequent requests + carry the modern `_meta` envelope (protocol version + client info + capabilities).""" + dispatcher = _ScriptedDispatcher(_discover_result_dict(), {}) + with anyio.fail_after(5): + async with ClientSession(dispatcher=dispatcher) as session: + result = await session.discover() + assert isinstance(result, types.DiscoverResult) + assert session.protocol_version == "2026-07-28" + await session.send_ping() + ping_method, ping_params = dispatcher.calls[-1] + assert ping_method == "ping" + assert ping_params is not None + assert ping_params["_meta"][PROTOCOL_VERSION_META_KEY] == "2026-07-28" + + +@pytest.mark.anyio +async def test_discover_retries_once_on_unsupported_version_then_adopts() -> None: + """Spec SHOULD: a -32022 reply that names a mutually-supported version + triggers exactly one retry at that version, and the retry's result is adopted.""" + dispatcher = _ScriptedDispatcher( + MCPError( + UNSUPPORTED_PROTOCOL_VERSION, + "unsupported", + data={"supported": ["2026-07-28"], "requested": "2026-07-28"}, + ), + _discover_result_dict(), + ) + with anyio.fail_after(5): + async with ClientSession(dispatcher=dispatcher) as session: + await session.discover() + assert session.protocol_version == "2026-07-28" + assert [m for m, _ in dispatcher.calls] == ["server/discover", "server/discover"] + + +@pytest.mark.anyio +async def test_discover_raises_when_retry_intersection_is_empty() -> None: + """Spec SHOULD: a -32022 reply whose `supported` list shares nothing with the + client's modern versions is unrecoverable — the original error is re-raised + without a second probe.""" + dispatcher = _ScriptedDispatcher( + MCPError( + UNSUPPORTED_PROTOCOL_VERSION, + "unsupported", + data={"supported": ["1999-01-01"], "requested": "2026-07-28"}, + ), + ) + with anyio.fail_after(5): + async with ClientSession(dispatcher=dispatcher) as session: + with pytest.raises(MCPError) as exc: + await session.discover() + assert exc.value.error.code == UNSUPPORTED_PROTOCOL_VERSION + assert [m for m, _ in dispatcher.calls] == ["server/discover"] + + +@pytest.mark.anyio +@pytest.mark.parametrize("code", [METHOD_NOT_FOUND, REQUEST_TIMEOUT, INTERNAL_ERROR]) +async def test_discover_reraises_non_retry_errors_without_falling_back(code: int) -> None: + """SDK-defined: any error outside the -32022 retry rung propagates verbatim + — `discover()` does not fall back to `initialize()` itself; that is the + caller's policy (`Client.__aenter__`).""" + dispatcher = _ScriptedDispatcher(MCPError(code, "nope")) + with anyio.fail_after(5): + async with ClientSession(dispatcher=dispatcher) as session: + with pytest.raises(MCPError) as exc: + await session.discover() + assert exc.value.error.code == code + assert session.protocol_version is None + assert [m for m, _ in dispatcher.calls] == ["server/discover"] + assert dispatcher.notifies == [] + + +@pytest.mark.anyio +async def test_discover_validates_the_response_shape_before_adopting() -> None: + """SDK-defined: the raw response is run through `DiscoverResult` validation + before any state is installed, so a malformed reply leaves the session + un-adopted rather than half-configured.""" + dispatcher = _ScriptedDispatcher({"supportedVersions": ["2026-07-28"]}) + session = ClientSession(dispatcher=dispatcher) + with anyio.fail_after(5): + async with session: + with pytest.raises(ValidationError): + await session.discover() + assert session.protocol_version is None + + +@pytest.mark.anyio +async def test_discover_is_idempotent_and_returns_the_cached_result() -> None: + """SDK-defined: a second `discover()` returns the already-adopted result without + re-probing — the script holds exactly one entry, so a second wire call would + `IndexError` on the empty script.""" + dispatcher = _ScriptedDispatcher(_discover_result_dict()) + with anyio.fail_after(5): + async with ClientSession(dispatcher=dispatcher) as session: + first = await session.discover() + assert isinstance(first, types.DiscoverResult) + assert await session.discover() is first + assert session.discover_result is first + assert [m for m, _ in dispatcher.calls] == ["server/discover"] + + +def test_era_neutral_properties_are_none_before_any_handshake() -> None: + """SDK-defined: the era-neutral accessors all read as None on a fresh session.""" + client_d, _ = create_direct_dispatcher_pair() + session = ClientSession(dispatcher=client_d) + assert session.protocol_version is None + assert session.server_info is None + assert session.server_capabilities is None + assert session.instructions is None + assert session.discover_result is None + assert session.initialize_result is None + + +@pytest.mark.anyio +async def test_era_neutral_properties_after_discover() -> None: + """SDK-defined: after `discover()` the era-neutral accessors read from the + DiscoverResult; `initialize_result` stays None.""" + raw = types.DiscoverResult( + supported_versions=["2026-07-28"], + capabilities=ServerCapabilities(tools=types.ToolsCapability(list_changed=True)), + server_info=Implementation(name="discovered", version="2.0"), + instructions="hello", + ).model_dump(by_alias=True, mode="json", exclude_none=True) + dispatcher = _ScriptedDispatcher(raw) + with anyio.fail_after(5): + async with ClientSession(dispatcher=dispatcher) as session: + await session.discover() + assert session.protocol_version == "2026-07-28" + assert session.server_info == Implementation(name="discovered", version="2.0") + assert session.server_capabilities == ServerCapabilities(tools=types.ToolsCapability(list_changed=True)) + assert session.instructions == "hello" + assert session.initialize_result is None + assert isinstance(session.discover_result, types.DiscoverResult) + + +@pytest.mark.anyio +async def test_discover_reraises_unsupported_version_with_malformed_error_data() -> None: + """SDK-defined: a -32022 reply whose `data` is not a valid + `UnsupportedProtocolVersionErrorData` payload is unrecoverable — the original + error is re-raised without a retry probe.""" + dispatcher = _ScriptedDispatcher(MCPError(UNSUPPORTED_PROTOCOL_VERSION, "unsupported", data="not-an-object")) + with anyio.fail_after(5): + async with ClientSession(dispatcher=dispatcher) as session: + with pytest.raises(MCPError) as exc: + await session.discover() + assert exc.value.error.code == UNSUPPORTED_PROTOCOL_VERSION + assert [m for m, _ in dispatcher.calls] == ["server/discover"] diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index bbe3e67fee..77b1fdc061 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -1,9 +1,9 @@ """Unit tests for the streamable-HTTP client transport. The full client<->server round trip is pinned by the interaction suite under -tests/interaction/transports/; these tests cover the transport's per-message header -derivation directly because the headers are an HTTP-seam observation the public client -never exposes. +tests/interaction/transports/; these tests cover the transport's header encoding and the +per-message metadata-headers merge directly because the headers are an HTTP-seam observation +the public client never exposes. """ import base64 @@ -14,56 +14,10 @@ import pytest from inline_snapshot import snapshot -from mcp.client import ClientSession -from mcp.client.streamable_http import ( - MCP_PROTOCOL_VERSION, - StreamableHTTPTransport, - _encode_header_value, - streamable_http_client, -) -from mcp.types import JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse - - -@pytest.mark.parametrize( - ("message", "expected"), - [ - ( - JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={"name": "add", "arguments": {}}), - snapshot({"mcp-method": "tools/call", "mcp-name": "add"}), - ), - ( - JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list", params={}), - snapshot({"mcp-method": "tools/list"}), - ), - ( - JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled"), - snapshot({"mcp-method": "notifications/cancelled"}), - ), - ( - JSONRPCResponse(jsonrpc="2.0", id=3, result={}), - snapshot({}), - ), - ], -) -def test_per_message_headers_for_pinned_transport_carry_method_and_name( - message: JSONRPCMessage, expected: dict[str, str] -) -> None: - """A 2026-07-28-pinned transport derives ``Mcp-Method`` (and ``Mcp-Name`` for tools/call) from the body. - - ``MCP-Protocol-Version`` is not in the per-message set: ``_prepare_headers()`` adds it from the - pin for every request, so only the method/name advisory headers vary per POST. Responses yield - nothing because the spec only defines the headers for requests and notifications. - """ - transport = StreamableHTTPTransport("http://test/mcp", protocol_version="2026-07-28") - assert transport._per_message_headers(message) == expected # pyright: ignore[reportPrivateUsage] - - -@pytest.mark.parametrize("protocol_version", [None, "2025-11-25"]) -def test_per_message_headers_are_empty_for_legacy_or_unpinned_transport(protocol_version: str | None) -> None: - """An unpinned or 2025-era transport emits no per-message headers, keeping the wire byte-identical to v1.""" - transport = StreamableHTTPTransport("http://test/mcp", protocol_version=protocol_version) - message = JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={"name": "add", "arguments": {}}) - assert transport._per_message_headers(message) == {} # pyright: ignore[reportPrivateUsage] +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER, encode_header_value +from mcp.shared.message import ClientMessageMetadata, SessionMessage +from mcp.types import METHOD_NOT_FOUND, JSONRPCError, JSONRPCRequest @pytest.mark.parametrize( @@ -89,7 +43,7 @@ def test_mcp_name_header_values_are_base64_wrapped_when_unsafe_for_an_http_field or trailing space is wrapped because RFC 7230 forbids it in field-values (h11 rejects on real transports); an empty value is allowed and passes verbatim. """ - encoded = _encode_header_value(raw) + encoded = encode_header_value(raw) assert encoded == expected if wrapped: assert encoded.startswith("=?base64?") and encoded.endswith("?=") @@ -99,77 +53,86 @@ def test_mcp_name_header_values_are_base64_wrapped_when_unsafe_for_an_http_field @pytest.mark.anyio -async def test_pinned_transport_ignores_returned_session_id_and_never_opens_get_or_delete() -> None: - """A server-issued ``Mcp-Session-Id`` never reaches a pinned client's wire: only POSTs are sent. - - The session-id capture, the standalone GET listening stream, and the DELETE-on-close are all - gated implicitly: a pinned ``ClientSession`` never sends ``initialize`` (no InitializeResult to - capture an id from) and never sends ``notifications/initialized`` (which is what triggers the - standalone GET), so even when a misbehaving peer volunteers a session id on every response the - recorded log stays POST-only and no request echoes the id back. The successful ``tools/call`` - triggers the client's implicit ``tools/list`` output-schema fetch so there is a second POST - after the id was offered. +async def test_post_request_merges_per_message_metadata_headers() -> None: + """`ClientMessageMetadata.headers` on a `SessionMessage` are merged into the outgoing POST headers + (SDK-defined: the headers sidecar is the path the session uses to reach the transport).""" + recorded: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + recorded.append(request) + body = json.loads(request.content) + return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}}) + + with anyio.fail_after(5): + async with ( + httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http, + streamable_http_client("http://test/mcp", http_client=http) as (read, write), + ): + await write.send( + SessionMessage( + message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/list", params={}), + metadata=ClientMessageMetadata(headers={"x-test": "v"}), + ) + ) + reply = await read.receive() + assert isinstance(reply, SessionMessage) + assert [r.method for r in recorded] == ["POST"] + assert recorded[0].headers["x-test"] == "v" + + +@pytest.mark.anyio +async def test_pre_session_bare_404_maps_to_method_not_found() -> None: + """A bare HTTP 404 (no JSON-RPC body) before any session-id is held maps to METHOD_NOT_FOUND. + + Gateways and legacy servers 404 at the HTTP layer for unknown methods; with no session yet, + "Session terminated" is meaningless, and the discover→initialize fallback ladder keys on -32601. + """ + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(404) + + with anyio.fail_after(5): + async with ( + httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http, + streamable_http_client("http://test/mcp", http_client=http) as (read, write), + ): + await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="server/discover", params={}))) + reply = await read.receive() + assert isinstance(reply, SessionMessage) + assert isinstance(reply.message, JSONRPCError) + assert reply.message.error.code == METHOD_NOT_FOUND + + +@pytest.mark.anyio +async def test_post_does_not_read_cached_protocol_version_header() -> None: + """A POST's protocol-version header comes only from its own ``metadata.headers``. + + The first POST carries (and caches) a pv header; the second POST sends no metadata + and must therefore carry no pv header — a stale cached value would poison the + fallback ``initialize`` after a failed discover probe. The cache exists for + transport-internal GET/DELETE only. """ recorded: list[httpx.Request] = [] def handler(request: httpx.Request) -> httpx.Response: recorded.append(request) body = json.loads(request.content) - if body["method"] == "tools/list": - result: dict[str, object] = { - "tools": [{"name": "add", "inputSchema": {"type": "object"}}], - "resultType": "complete", - "ttlMs": 0, - "cacheScope": "public", - } - else: - result = {"content": [{"type": "text", "text": "5"}], "isError": False, "resultType": "complete"} - return httpx.Response( - 200, json={"jsonrpc": "2.0", "id": body["id"], "result": result}, headers={"mcp-session-id": "srv-123"} - ) + return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}}) with anyio.fail_after(5): async with ( httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http, - streamable_http_client("http://test/mcp", http_client=http, protocol_version="2026-07-28") as (read, write), - ClientSession(read, write, protocol_version="2026-07-28") as session, + streamable_http_client("http://test/mcp", http_client=http) as (read, write), ): - await session.call_tool("add", {"a": 2, "b": 3}) - - assert [r.method for r in recorded] == snapshot(["POST", "POST"]) - assert all("mcp-session-id" not in r.headers for r in recorded) - - -def test_modern_constructor_pin_is_not_overwritten_by_an_initialize_result() -> None: - """A 2026-07-28+ pin wins over the InitializeResult snoop (no initialize is ever sent).""" - transport = StreamableHTTPTransport("http://test/mcp", protocol_version="2026-07-28") - init = JSONRPCResponse( - jsonrpc="2.0", - id=1, - result={ - "protocolVersion": "2025-11-25", - "capabilities": {}, - "serverInfo": {"name": "s", "version": "0"}, - }, - ) - transport._maybe_extract_protocol_version_from_message(init) # pyright: ignore[reportPrivateUsage] - assert transport.protocol_version == "2026-07-28" - - -def test_stateful_constructor_pin_is_ignored_and_the_negotiated_version_wins() -> None: - """A pre-2026 pin is a session-layer concern; the transport must not stamp it on the - initialize request and must adopt the server's negotiated version for later headers.""" - transport = StreamableHTTPTransport("http://test/mcp", protocol_version="2025-06-18") - assert MCP_PROTOCOL_VERSION not in transport._prepare_headers() # pyright: ignore[reportPrivateUsage] - init = JSONRPCResponse( - jsonrpc="2.0", - id=1, - result={ - "protocolVersion": "2025-03-26", - "capabilities": {}, - "serverInfo": {"name": "s", "version": "0"}, - }, - ) - transport._maybe_extract_protocol_version_from_message(init) # pyright: ignore[reportPrivateUsage] - assert transport.protocol_version == "2025-03-26" - assert transport._prepare_headers()[MCP_PROTOCOL_VERSION] == "2025-03-26" # pyright: ignore[reportPrivateUsage] + await write.send( + SessionMessage( + message=JSONRPCRequest(jsonrpc="2.0", id=1, method="server/discover", params={}), + metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: "2026-07-28"}), + ) + ) + await read.receive() + await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=2, method="initialize", params={}))) + await read.receive() + assert [r.method for r in recorded] == ["POST", "POST"] + assert recorded[0].headers[MCP_PROTOCOL_VERSION_HEADER] == "2026-07-28" + assert MCP_PROTOCOL_VERSION_HEADER not in recorded[1].headers diff --git a/tests/client/transports/test_memory.py b/tests/client/transports/test_memory.py index 8baee128b5..51a026c138 100644 --- a/tests/client/transports/test_memory.py +++ b/tests/client/transports/test_memory.py @@ -76,7 +76,7 @@ async def test_with_mcpserver(mcpserver_server: MCPServer): async def test_server_is_running(mcpserver_server: MCPServer): """Test that the server is running and responding to requests.""" async with Client(mcpserver_server) as client: - assert client.initialize_result.capabilities.tools is not None + assert client.server_capabilities.tools is not None async def test_list_tools(mcpserver_server: MCPServer): 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..9e18661399 --- /dev/null +++ b/tests/examples/conftest.py @@ -0,0 +1,169 @@ +"""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 +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, 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 + 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", "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]: + 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)) + + @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]]] = [] + 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") + + +@dataclass +class Hosted: + """One server/app instance hosted for the leg's whole duration. + + ``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. + """ + + targets: TargetFactory + 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 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) + path = cfg["mcp_path"] + + if leg.transport == "in-memory": + server = server_module.build_server() + yield Hosted(lambda: server, 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, 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) + 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) as http_client, + ): + if build_auth is not None: + http_client.auth = build_auth(http_client) + 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 new file mode 100644 index 0000000000..f56106a797 --- /dev/null +++ b/tests/examples/test_stories.py @@ -0,0 +1,74 @@ +"""Run every story's ``main`` 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) -> None: + kwargs: dict[str, Any] = {"mode": leg.mode} + if cfg["needs_http"]: + kwargs["http"] = hosted.http + with anyio.fail_after(cfg["timeout_s"]): + 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: + """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" + 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" + + +@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 new file mode 100644 index 0000000000..ecd6f48c87 --- /dev/null +++ b/tests/examples/test_stories_smoke.py @@ -0,0 +1,108 @@ +"""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 + """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"), + [ + ("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/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}" diff --git a/tests/interaction/_connect.py b/tests/interaction/_connect.py index 575a742632..a9383837d2 100644 --- a/tests/interaction/_connect.py +++ b/tests/interaction/_connect.py @@ -31,8 +31,8 @@ from mcp.server.streamable_http import EventStore from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from mcp.server.transport_security import TransportSecuritySettings +from mcp.shared.version import LATEST_HANDSHAKE_VERSION, MODERN_PROTOCOL_VERSIONS from mcp.types import ( - LATEST_PROTOCOL_VERSION, ClientCapabilities, Implementation, InitializeRequestParams, @@ -70,7 +70,7 @@ def __call__( message_handler: MessageHandlerFnT | None = None, client_info: Implementation | None = None, elicitation_callback: ElicitationFnT | None = None, - protocol_version: str = LATEST_PROTOCOL_VERSION, + spec_version: str = LATEST_HANDSHAKE_VERSION, ) -> AbstractAsyncContextManager[Client]: ... @@ -85,11 +85,17 @@ async def connect_in_memory( message_handler: MessageHandlerFnT | None = None, client_info: Implementation | None = None, elicitation_callback: ElicitationFnT | None = None, - protocol_version: str = LATEST_PROTOCOL_VERSION, + spec_version: str = LATEST_HANDSHAKE_VERSION, ) -> AsyncIterator[Client]: - """Yield a Client connected to the server over the in-memory transport.""" + """Yield a Client connected to the server over the in-memory transport. + + When `spec_version` is a modern (2026-07-28+) revision the Client is opened with + `mode=`, which drives the server through the DirectDispatcher peer-pair + (per-request `serve_one`, no initialize handshake) instead of the legacy stream pair. + """ async with Client( server, + mode=spec_version if spec_version in MODERN_PROTOCOL_VERSIONS else "legacy", read_timeout_seconds=read_timeout_seconds, sampling_callback=sampling_callback, list_roots_callback=list_roots_callback, @@ -97,7 +103,6 @@ async def connect_in_memory( message_handler=message_handler, client_info=client_info, elicitation_callback=elicitation_callback, - protocol_version=protocol_version, ) as client: yield client @@ -117,7 +122,7 @@ async def connect_over_streamable_http( message_handler: MessageHandlerFnT | None = None, client_info: Implementation | None = None, elicitation_callback: ElicitationFnT | None = None, - protocol_version: str = LATEST_PROTOCOL_VERSION, + spec_version: str = LATEST_HANDSHAKE_VERSION, ) -> AsyncIterator[Client]: """Yield a Client connected to the server's streamable HTTP app, entirely in process. @@ -126,6 +131,10 @@ async def connect_over_streamable_http( transport-specific tests pass `json_response` to select the other server mode, and the resumability tests pass an `event_store` (with `retry_interval=0` so the client's reconnection wait is a no-op). + + When `spec_version` is a modern (2026-07-28+) revision the Client is opened with + `mode=`, which adopts a synthesized DiscoverResult instead of running the legacy + initialize handshake. """ app = server.streamable_http_app( stateless_http=stateless_http, @@ -138,7 +147,8 @@ async def connect_over_streamable_http( server.session_manager.run(), httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http_client, Client( - streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client, protocol_version=protocol_version), + streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client), + mode=spec_version if spec_version in MODERN_PROTOCOL_VERSIONS else "legacy", read_timeout_seconds=read_timeout_seconds, sampling_callback=sampling_callback, list_roots_callback=list_roots_callback, @@ -146,7 +156,6 @@ async def connect_over_streamable_http( message_handler=message_handler, client_info=client_info, elicitation_callback=elicitation_callback, - protocol_version=protocol_version, ) as client, ): yield client @@ -260,14 +269,14 @@ def base_headers(*, session_id: str | None = None) -> dict[str, str]: """Standard request headers for raw-httpx streamable-HTTP tests. Every well-formed request carries these (Accept covering both response representations, - Content-Type for POST bodies, MCP-Protocol-Version at the latest revision, and the session + Content-Type for POST bodies, MCP-Protocol-Version at the newest handshake revision, and the session ID once one exists), so a test that wants to assert a specific rejection only varies the one header under test. """ headers = { "accept": "application/json, text/event-stream", "content-type": "application/json", - "mcp-protocol-version": LATEST_PROTOCOL_VERSION, + "mcp-protocol-version": LATEST_HANDSHAKE_VERSION, } if session_id is not None: headers["mcp-session-id"] = session_id @@ -277,7 +286,7 @@ def base_headers(*, session_id: str | None = None) -> dict[str, str]: def initialize_body(request_id: int = 1) -> dict[str, object]: """A wire-level initialize JSON-RPC request body, exactly as an SDK client would send it.""" params = InitializeRequestParams( - protocol_version=LATEST_PROTOCOL_VERSION, + protocol_version=LATEST_HANDSHAKE_VERSION, capabilities=ClientCapabilities(), client_info=Implementation(name="raw", version="0.0.0"), ) @@ -345,7 +354,7 @@ async def connect_over_sse( message_handler: MessageHandlerFnT | None = None, client_info: Implementation | None = None, elicitation_callback: ElicitationFnT | None = None, - protocol_version: str = LATEST_PROTOCOL_VERSION, + spec_version: str = LATEST_HANDSHAKE_VERSION, ) -> AsyncIterator[Client]: """Yield a Client connected to the server's legacy SSE transport, entirely in process.""" app, _ = build_sse_app(server) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 9aee73b29b..8714977824 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -63,9 +63,7 @@ TRANSPORT_SPEC_VERSIONS: dict[Transport, tuple[SpecVersion, ...]] = { "sse": ("2025-11-25",), - # Temporary lock: the in-memory transport has no modern entry point yet, so it cannot - # negotiate the newer revision. Remove once an in-memory factory for the modern path lands. - "in-memory": ("2025-11-25",), + "in-memory": ("2025-11-25", "2026-07-28"), # At the newer revision the protocol-version header check runs before the stateless branch is # taken, so a stateless connection at that revision behaves identically to the stateful one. # Locked to avoid a redundant matrix column; revisit if the header/stateless ordering changes. @@ -252,6 +250,7 @@ def __post_init__(self) -> None: source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization", behavior="The client's name, version, and title are visible to server handlers after initialization.", removed_in="2026-07-28", + superseded_by="lifecycle:envelope:stamped-on-every-request", note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.", arm_exclusions=(ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),), ), @@ -262,6 +261,7 @@ def __post_init__(self) -> None: "(sampling, elicitation, roots)." ), removed_in="2026-07-28", + superseded_by="lifecycle:envelope:stamped-on-every-request", note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.", arm_exclusions=(ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),), ), @@ -395,6 +395,80 @@ def __post_init__(self) -> None: "hosting:http:legacy-no-modern-vocabulary covers the same vocabulary set" ), ), + "lifecycle:envelope:stamped-on-every-request": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic#_meta", + behavior=( + "Every client→server request on a modern-negotiated session carries " + "_meta.{protocolVersion,clientInfo,clientCapabilities}; notifications do not." + ), + added_in="2026-07-28", + supersedes=("lifecycle:initialize:client-info", "lifecycle:initialize:client-capabilities"), + ), + "lifecycle:envelope:header-matches-meta": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#headers", + behavior="On HTTP, the MCP-Protocol-Version header on every POST matches _meta.protocolVersion in the body.", + transports=("streamable-http", "streamable-http-stateless"), + added_in="2026-07-28", + note="HTTP-only: the header is a streamable-http transport concern; stdio and in-memory carry no headers.", + ), + "lifecycle:discover:basic": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#discover", + behavior=( + "Calling discover() sends server/discover with no params and returns a typed DiscoverResult " + "carrying protocolVersion, capabilities, serverInfo and the cache hint fields." + ), + added_in="2026-07-28", + ), + "lifecycle:discover:retry-on-32022": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#version-errors", + behavior=( + "When server/discover returns -32022 UnsupportedProtocolVersion, the client retries once with " + "the intersection of error.data.supported and its own modern versions; an empty intersection raises." + ), + added_in="2026-07-28", + ), + "lifecycle:discover:fallback-method-not-found": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/stdio#backward-compatibility", + behavior=( + "When server/discover returns -32601 (or HTTP 404), an auto-negotiating client falls back to " + "the legacy initialize handshake and the connection succeeds at a handshake-era version." + ), + added_in="2026-07-28", + ), + "lifecycle:discover:network-error-raises": Requirement( + source="sdk", + behavior=( + "An HTTP timeout, connection error, or non-404 4xx/5xx during server/discover raises to the " + "caller without falling back to initialize." + ), + transports=("streamable-http", "streamable-http-stateless"), + added_in="2026-07-28", + note="HTTP-only: distinguishes transport-level failures from the -32601 fallback signal.", + ), + "lifecycle:mode:legacy-never-probes": Requirement( + source="sdk", + behavior=( + "A Client constructed with mode='legacy' sends initialize as its first request " + "and never sends server/discover." + ), + added_in="2026-07-28", + ), + "lifecycle:mode:pin-never-handshakes": Requirement( + source="sdk", + behavior=( + "A Client constructed with mode='2026-07-28' sends no initialize and no server/discover; its " + "first wire request is the caller's first call, carrying the full _meta envelope." + ), + added_in="2026-07-28", + ), + "lifecycle:mode:prior-discover-zero-rtt": Requirement( + source="sdk", + behavior=( + "A Client constructed with prior_discover= sends no negotiation traffic; " + "server_info and capabilities are populated from the prior result." + ), + added_in="2026-07-28", + ), # ═══════════════════════════════════════════════════════════════════════════ # Protocol primitives: cancellation, timeout, progress, errors, _meta # ═══════════════════════════════════════════════════════════════════════════ @@ -581,7 +655,9 @@ def __post_init__(self) -> None: "Progress notifications emitted by a handler during a request are delivered to the caller's " "progress callback, in order, with their progress, total, and message." ), - known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),), + known_failures=( + KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None), + ), ), "protocol:progress:token-injected": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow", @@ -594,7 +670,14 @@ def __post_init__(self) -> None: "protocol:progress:token-unique": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow", behavior=("Concurrent in-flight requests that each supply a progress callback carry distinct progress tokens."), - known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),), + note=( + "Tested as the consequence: each callback receives only its own request's progress under " + "interleaved emission. Token distinctness is the JSON-RPC mechanism for that; the in-process " + "direct dispatcher carries the callback per-request without a wire-level token." + ), + known_failures=( + KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None), + ), ), "protocol:progress:monotonic": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow", @@ -607,7 +690,9 @@ def __post_init__(self) -> None: "handler that emits non-increasing values has them forwarded to the callback unchanged." ), ), - known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),), + known_failures=( + KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None), + ), ), "protocol:progress:stops-after-completion": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/progress#behavior-requirements", @@ -745,7 +830,9 @@ def __post_init__(self) -> None: "Log notifications emitted by a tool handler during execution reach the client's logging " "callback before the tool result returns." ), - known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),), + known_failures=( + KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None), + ), ), "tools:call:progress": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow", @@ -753,7 +840,9 @@ def __post_init__(self) -> None: "Progress notifications emitted by a tool handler reach the caller's progress callback before " "the tool result returns." ), - known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),), + known_failures=( + KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None), + ), ), "tools:call:sampling-roundtrip": Requirement( source=f"{SPEC_BASE_URL}/client/sampling#creating-messages", @@ -974,14 +1063,18 @@ def __post_init__(self) -> None: "The Context logging helpers (debug/info/warning/error) send log message notifications at the " "corresponding severity." ), - known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),), + known_failures=( + KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None), + ), ), "mcpserver:context:progress": Requirement( source="sdk", behavior=( "Context.report_progress sends a progress notification against the requesting client's progress token." ), - known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),), + known_failures=( + KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None), + ), ), "mcpserver:context:elicit": Requirement( source="sdk", @@ -1339,7 +1432,9 @@ def __post_init__(self) -> None: "logging:message:all-levels": Requirement( source=f"{SPEC_BASE_URL}/server/utilities/logging#log-levels", behavior="All eight RFC 5424 severity levels are deliverable as log message notifications.", - known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),), + known_failures=( + KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None), + ), ), "logging:message:fields": Requirement( source=f"{SPEC_BASE_URL}/server/utilities/logging#log-message-notifications", @@ -1347,7 +1442,9 @@ def __post_init__(self) -> None: "A log message sent by a server handler is delivered to the client's logging callback with its " "severity level, logger name, and data." ), - known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),), + known_failures=( + KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None), + ), ), "logging:message:filtered": Requirement( source=f"{SPEC_BASE_URL}/server/utilities/logging#setting-log-level", @@ -1970,6 +2067,7 @@ def __post_init__(self) -> None: known_failures=( KnownFailure( spec_version="2026-07-28", + transport="streamable-http", note=( "List-mutation assertions hold; only the sentinel ctx.info() never reaches the client. " + _MODERN_NOTIFY_DROP diff --git a/tests/interaction/auth/test_flow.py b/tests/interaction/auth/test_flow.py index ab96185796..96903ef910 100644 --- a/tests/interaction/auth/test_flow.py +++ b/tests/interaction/auth/test_flow.py @@ -104,7 +104,7 @@ async def test_an_unauthenticated_request_is_challenged_then_the_full_oauth_flow # The first PRM discovery GET carries the protocol-version header (an SDK behaviour, not a # spec requirement on discovery requests). prm_get = next(r for r in requests if r.url.path == "/.well-known/oauth-protected-resource/mcp") - assert prm_get.headers.get("mcp-protocol-version") == snapshot("2025-11-25") + assert prm_get.headers.get("mcp-protocol-version") == snapshot("2026-07-28") authorize = parse_qs(urlsplit(headless.authorize_url).query) assert authorize["response_type"] == ["code"] diff --git a/tests/interaction/conftest.py b/tests/interaction/conftest.py index cc1ae5ee7a..b918daf008 100644 --- a/tests/interaction/conftest.py +++ b/tests/interaction/conftest.py @@ -45,4 +45,4 @@ def connect(request: pytest.FixtureRequest) -> Connect: transport, spec_version = request.param assert isinstance(transport, str) assert isinstance(spec_version, str) - return partial(_FACTORIES[transport], protocol_version=spec_version) + return partial(_FACTORIES[transport], spec_version=spec_version) diff --git a/tests/interaction/lowlevel/test_client_connect.py b/tests/interaction/lowlevel/test_client_connect.py new file mode 100644 index 0000000000..f508dfc90c --- /dev/null +++ b/tests/interaction/lowlevel/test_client_connect.py @@ -0,0 +1,360 @@ +"""Client connect-time negotiation: mode selection, server/discover, and the per-request envelope. + +These tests pin what `Client(..., mode=...)` puts on the wire BEFORE the caller's first call -- +the legacy initialize handshake, the modern `server/discover` probe, or nothing at all -- and +that a modern-negotiated session stamps the three-key `io.modelcontextprotocol/*` `_meta` +envelope on every subsequent request. Each test drives the highest public surface (`Client`) +and observes traffic at a recording seam: `RecordingTransport` for the legacy stream pair, and +`mounted_app`'s httpx event hook for the in-process streamable-HTTP transport. + +The fallback test alone hand-plays the server's side of the wire, because no real `Server` +answers `server/discover` with -32601. +""" + +import json +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import asynccontextmanager + +import anyio +import httpx +import pytest + +from mcp import MCPError, types +from mcp.client._memory import InMemoryTransport +from mcp.client._transport import TransportStreams +from mcp.client.client import Client +from mcp.client.streamable_http import streamable_http_client +from mcp.server import Server, ServerRequestContext +from mcp.shared.memory import MessageStream, create_client_server_memory_streams +from mcp.shared.message import SessionMessage +from mcp.shared.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS +from mcp.types import ( + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + INTERNAL_ERROR, + METHOD_NOT_FOUND, + PROTOCOL_VERSION_META_KEY, + UNSUPPORTED_PROTOCOL_VERSION, + DiscoverResult, + Implementation, + InitializeResult, + JSONRPCError, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, + ServerCapabilities, + ToolsCapability, +) +from tests.interaction._connect import BASE_URL, Connect, mounted_app +from tests.interaction._helpers import RecordingTransport +from tests.interaction._requirements import requirement + +pytestmark = pytest.mark.anyio + + +def _tools_server(name: str = "negotiator") -> Server: + """A low-level server with one list-tools handler, so a feature request has something to reach.""" + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="noop", input_schema={"type": "object"})]) + + return Server(name, on_list_tools=list_tools) + + +def _request_recorder() -> tuple[list[httpx.Request], Callable[[httpx.Request], Awaitable[None]]]: + """Return a list and an `on_request` hook that appends each outgoing httpx request to it.""" + captured: list[httpx.Request] = [] + + async def on_request(request: httpx.Request) -> None: + captured.append(request) + + return captured, on_request + + +@requirement("lifecycle:mode:legacy-never-probes") +async def test_legacy_mode_sends_initialize_and_never_probes_discover() -> None: + """`Client(server, mode='legacy')` opens with `initialize` and never sends `server/discover`. + + Requirement `lifecycle:mode:legacy-never-probes` (sdk-defined): ``mode='legacy'`` must remain + byte-identical to the pre-2026 client so a 2025-era server never observes modern vocabulary. + """ + recording = RecordingTransport(InMemoryTransport(_tools_server())) + + with anyio.fail_after(5): + async with Client(recording, mode="legacy") as client: + await client.list_tools() + + sent = [m.message for m in recording.sent] + methods = [m.method for m in sent if isinstance(m, JSONRPCRequest | JSONRPCNotification)] + assert methods[0] == "initialize" + assert "server/discover" not in methods + assert "notifications/initialized" in methods + + +@requirement("lifecycle:mode:pin-never-handshakes") +async def test_pinned_mode_sends_no_connect_time_traffic() -> None: + """`Client(..., mode='2026-07-28')` sends nothing on entry; the caller's first call is the first wire request. + + Requirement `lifecycle:mode:pin-never-handshakes` (sdk-defined): a version pin adopts a + synthesized DiscoverResult locally, so no `initialize` and no `server/discover` ever cross + the wire. Asserted at the in-process streamable-HTTP seam via the httpx event hook. + """ + requests, on_request = _request_recorder() + + with anyio.fail_after(5): + async with ( + mounted_app(_tools_server(), on_request=on_request) as (http, _), + Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode=LATEST_MODERN_VERSION) as client, + ): + assert requests == [] # entering the Client produced zero HTTP traffic + result = await client.list_tools() + + bodies = [json.loads(r.content) for r in requests] + assert [b["method"] for b in bodies] == ["tools/list"] + assert PROTOCOL_VERSION_META_KEY in bodies[0]["params"]["_meta"] + assert [t.name for t in result.tools] == ["noop"] + + +@requirement("lifecycle:mode:prior-discover-zero-rtt") +async def test_prior_discover_populates_state_with_zero_connect_time_traffic() -> None: + """`Client(..., mode=, prior_discover=...)` sends nothing on entry and exposes the prior server_info. + + Requirement `lifecycle:mode:prior-discover-zero-rtt` (sdk-defined): a previously-obtained + DiscoverResult is installed via `adopt()` so server_info and capabilities are available + immediately with zero round trips. + """ + prior = DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(tools=ToolsCapability(list_changed=False)), + server_info=Implementation(name="cached-server", version="9.9.9"), + ) + requests, on_request = _request_recorder() + + with anyio.fail_after(5): + async with ( + mounted_app(_tools_server(), on_request=on_request) as (http, _), + Client( + streamable_http_client(f"{BASE_URL}/mcp", http_client=http), + mode=LATEST_MODERN_VERSION, + prior_discover=prior, + ) as client, + ): + assert requests == [] + assert client.server_info == Implementation(name="cached-server", version="9.9.9") + assert client.server_capabilities.tools == ToolsCapability(list_changed=False) + await client.list_tools() + + assert [json.loads(r.content)["method"] for r in requests] == ["tools/list"] + + +@requirement("lifecycle:discover:basic") +async def test_auto_mode_probes_server_discover_and_adopts_the_result() -> None: + """`Client(..., mode='auto')` sends `server/discover` first and adopts the returned version and server_info. + + Requirement `lifecycle:discover:basic` (spec basic/lifecycle#discover): the probe is a + single `server/discover` request whose result carries supported versions, capabilities, + server_info and the cache-hint fields, after which the session is modern-negotiated. + """ + requests, on_request = _request_recorder() + server = _tools_server("discoverable") + + with anyio.fail_after(5): + async with ( + mounted_app(server, on_request=on_request) as (http, _), + Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto") as client, + ): + assert client.protocol_version == LATEST_MODERN_VERSION + assert client.server_info.name == "discoverable" + await client.list_tools() + + bodies = [json.loads(r.content) for r in requests] + assert bodies[0]["method"] == "server/discover" + assert "initialize" not in [b["method"] for b in bodies] + + +@requirement("lifecycle:discover:retry-on-32022") +async def test_auto_mode_retries_discover_once_on_unsupported_protocol_version() -> None: + """A -32022 from `server/discover` triggers exactly one retry at the highest mutual modern version. + + Requirement `lifecycle:discover:retry-on-32022` (spec basic/lifecycle#version-errors): the + client intersects `error.data.supported` with its own modern versions and re-probes once; + the second success is adopted. The server's `server/discover` handler is overridden to fail + the first call and succeed on the second. + """ + calls: list[str | None] = [] + + async def discover(ctx: ServerRequestContext, params: types.RequestParams | None) -> DiscoverResult: + proposed = ctx.meta.get(PROTOCOL_VERSION_META_KEY) if ctx.meta else None + calls.append(proposed) + if len(calls) == 1: + raise MCPError( + code=UNSUPPORTED_PROTOCOL_VERSION, + message="unsupported protocol version", + data={"supported": list(MODERN_PROTOCOL_VERSIONS), "requested": proposed}, + ) + return DiscoverResult( + supported_versions=list(MODERN_PROTOCOL_VERSIONS), + capabilities=ServerCapabilities(), + server_info=Implementation(name="picky", version="1.0.0"), + ) + + server = _tools_server("picky") + server.add_request_handler("server/discover", types.RequestParams, discover) + requests, on_request = _request_recorder() + + with anyio.fail_after(5): + async with ( + mounted_app(server, on_request=on_request) as (http, _), + Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto") as client, + ): + assert client.protocol_version == LATEST_MODERN_VERSION + + assert calls == [LATEST_MODERN_VERSION, LATEST_MODERN_VERSION] + assert [json.loads(r.content)["method"] for r in requests][:2] == ["server/discover", "server/discover"] + + +@requirement("lifecycle:discover:network-error-raises") +async def test_auto_mode_reraises_a_non_fallback_discover_error_without_initializing() -> None: + """A `server/discover` failure outside the {-32601, -32001, -32022} ladder raises without falling back. + + Requirement `lifecycle:discover:network-error-raises` (sdk-defined): a 5xx-class error from + the probe is surfaced to the caller; the client never sends `initialize`. Exercised here as + the JSON-RPC INTERNAL_ERROR branch (which the modern HTTP entry maps to a 5xx). The error + reaches the test wrapped in the streamable-http transport's task-group teardown, so + `pytest.RaisesGroup` flattens before matching. + """ + + async def discover(ctx: ServerRequestContext, params: types.RequestParams | None) -> DiscoverResult: + raise MCPError(code=INTERNAL_ERROR, message="storage unavailable") + + server = _tools_server() + server.add_request_handler("server/discover", types.RequestParams, discover) + requests, on_request = _request_recorder() + + def is_internal_error(exc: MCPError) -> bool: + return exc.code == INTERNAL_ERROR + + with anyio.fail_after(5): + async with mounted_app(server, on_request=on_request) as (http, _): + with pytest.RaisesGroup( + pytest.RaisesExc(MCPError, check=is_internal_error), flatten_subgroups=True + ): # pragma: no branch + async with Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto"): + raise NotImplementedError("entering the Client should have raised") # pragma: no cover + + assert [json.loads(r.content)["method"] for r in requests] == ["server/discover"] + + +@requirement("lifecycle:discover:fallback-method-not-found") +async def test_auto_mode_falls_back_to_initialize_when_discover_is_method_not_found() -> None: + """A -32601 from `server/discover` makes an auto-negotiating client run the legacy `initialize` handshake. + + Requirement `lifecycle:discover:fallback-method-not-found` (spec stdio#backward-compatibility): + a legacy-era server that does not implement `server/discover` is connected to via the + handshake, and the session lands at a handshake-era protocol version. A real `Server` always + implements `server/discover`, so this test plays the server's side of the wire by hand. + Reserve this pattern for behaviour no real server can be made to produce. + """ + methods_seen: list[str] = [] + + async def scripted_server(streams: MessageStream) -> None: + server_read, server_write = streams + async for message in server_read: + assert isinstance(message, SessionMessage) + frame = message.message + assert isinstance(frame, JSONRPCRequest | JSONRPCNotification) + methods_seen.append(frame.method) + if isinstance(frame, JSONRPCRequest) and frame.method == "server/discover": + error = types.ErrorData(code=METHOD_NOT_FOUND, message="Method not found") + await server_write.send(SessionMessage(JSONRPCError(jsonrpc="2.0", id=frame.id, error=error))) + elif isinstance(frame, JSONRPCRequest) and frame.method == "initialize": + result = InitializeResult( + protocol_version=LATEST_HANDSHAKE_VERSION, + capabilities=ServerCapabilities(), + server_info=Implementation(name="legacy-only", version="0.0.1"), + ) + await server_write.send( + SessionMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=frame.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + # notifications/initialized (and anything else) is observed and ignored. + + @asynccontextmanager + async def scripted_transport() -> AsyncIterator[TransportStreams]: + async with ( + create_client_server_memory_streams() as ((client_read, client_write), server_streams), + anyio.create_task_group() as tg, + ): + tg.start_soon(scripted_server, server_streams) + yield client_read, client_write + tg.cancel_scope.cancel() + + with anyio.fail_after(5): + async with Client(scripted_transport(), mode="auto") as client: + assert client.protocol_version == LATEST_HANDSHAKE_VERSION + assert client.server_info.name == "legacy-only" + + assert methods_seen == ["server/discover", "initialize", "notifications/initialized"] + + +@requirement("lifecycle:envelope:stamped-on-every-request") +async def test_every_request_on_a_modern_session_carries_the_three_key_meta_envelope(connect: Connect) -> None: + """Each modern-session request's `params._meta` carries protocolVersion, clientInfo and clientCapabilities. + + Requirement `lifecycle:envelope:stamped-on-every-request` (spec basic#_meta): the per-request + envelope replaces the initialize handshake's once-per-session exchange. Asserted server-side + by capturing `ctx.meta` inside the handler. + """ + observed: list[dict[str, object]] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + assert ctx.meta is not None + observed.append(dict(ctx.meta)) + return types.ListToolsResult(tools=[]) + + server = Server("stamped", on_list_tools=list_tools) + + with anyio.fail_after(5): + async with connect(server, client_info=Implementation(name="enveloper", version="1.2.3")) as client: + await client.list_tools() + await client.list_tools() + + assert len(observed) == 2 + for meta in observed: + assert meta[PROTOCOL_VERSION_META_KEY] == LATEST_MODERN_VERSION + assert meta[CLIENT_INFO_META_KEY] == {"name": "enveloper", "version": "1.2.3"} + assert CLIENT_CAPABILITIES_META_KEY in meta + + +@requirement("lifecycle:envelope:header-matches-meta") +async def test_http_protocol_version_header_matches_meta_protocol_version_on_every_post() -> None: + """On streamable-HTTP, the `MCP-Protocol-Version` header on each POST equals `_meta.protocolVersion` in its body. + + Requirement `lifecycle:envelope:header-matches-meta` (spec streamable-http#headers): the + body-derived header and the envelope's protocol version are kept in lockstep so the server's + header-based routing and body-based validation never disagree. + """ + requests, on_request = _request_recorder() + + with anyio.fail_after(5): + async with ( + mounted_app(_tools_server(), on_request=on_request) as (http, _), + Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode=LATEST_MODERN_VERSION) as client, + ): + await client.list_tools() + await client.list_tools() + + assert requests, "no HTTP traffic recorded" + for request in requests: + body = json.loads(request.content) + assert request.headers["mcp-protocol-version"] == body["params"]["_meta"][PROTOCOL_VERSION_META_KEY] + assert request.headers["mcp-protocol-version"] == LATEST_MODERN_VERSION diff --git a/tests/interaction/lowlevel/test_completion.py b/tests/interaction/lowlevel/test_completion.py index f12671d935..8478831f31 100644 --- a/tests/interaction/lowlevel/test_completion.py +++ b/tests/interaction/lowlevel/test_completion.py @@ -123,7 +123,7 @@ async def test_complete_without_handler_is_method_not_found(connect: Connect) -> server = Server("incomplete") async with connect(server) as client: - assert client.initialize_result.capabilities.completions is None + assert client.server_capabilities.completions is None with pytest.raises(MCPError) as exc_info: await client.complete(PromptReference(name="anything"), argument={"name": "topic", "value": ""}) diff --git a/tests/interaction/lowlevel/test_initialize.py b/tests/interaction/lowlevel/test_initialize.py index 91adbf5611..d1f79c0cb7 100644 --- a/tests/interaction/lowlevel/test_initialize.py +++ b/tests/interaction/lowlevel/test_initialize.py @@ -60,7 +60,7 @@ async def test_initialize_returns_server_info(connect: Connect) -> None: ) async with connect(server) as client: - server_info = client.initialize_result.server_info + server_info = client.server_info assert server_info == snapshot( Implementation( @@ -78,10 +78,10 @@ async def test_initialize_returns_server_info(connect: Connect) -> None: async def test_initialize_returns_instructions(connect: Connect) -> None: """Instructions are returned when the server declares them and omitted when it does not.""" async with connect(Server("guided", instructions="Call the add tool.")) as client: - assert client.initialize_result.instructions == snapshot("Call the add tool.") + assert client.instructions == snapshot("Call the add tool.") async with connect(Server("unguided")) as client: - assert client.initialize_result.instructions is None + assert client.instructions is None @requirement("lifecycle:initialize:capabilities:from-handlers") @@ -137,7 +137,7 @@ async def completion(ctx: ServerRequestContext, params: types.CompleteRequestPar ) async with connect(server) as client: - capabilities = client.initialize_result.capabilities + capabilities = client.server_capabilities assert capabilities == snapshot( ServerCapabilities( @@ -155,7 +155,7 @@ async def completion(ctx: ServerRequestContext, params: types.CompleteRequestPar async def test_initialize_minimal_server_advertises_no_capabilities(connect: Connect) -> None: """A server with no feature handlers advertises no feature capabilities.""" async with connect(Server("bare")) as client: - capabilities = client.initialize_result.capabilities + capabilities = client.server_capabilities assert capabilities == snapshot(ServerCapabilities(experimental={})) diff --git a/tests/interaction/lowlevel/test_progress.py b/tests/interaction/lowlevel/test_progress.py index a89039b99e..4fb2c7c224 100644 --- a/tests/interaction/lowlevel/test_progress.py +++ b/tests/interaction/lowlevel/test_progress.py @@ -41,18 +41,9 @@ async def list_tools( async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: assert params.name == "download" - assert ctx.meta is not None - token = ctx.meta.get("progress_token") - assert token is not None - await ctx.session.send_progress_notification( - token, 1.0, total=3.0, message="first chunk", related_request_id=str(ctx.request_id) - ) - await ctx.session.send_progress_notification( - token, 2.0, total=3.0, message="second chunk", related_request_id=str(ctx.request_id) - ) - await ctx.session.send_progress_notification( - token, 3.0, total=3.0, message="done", related_request_id=str(ctx.request_id) - ) + await ctx.session.report_progress(1.0, total=3.0, message="first chunk") + await ctx.session.report_progress(2.0, total=3.0, message="second chunk") + await ctx.session.report_progress(3.0, total=3.0, message="done") return CallToolResult(content=[TextContent(text="downloaded")]) server = Server("downloader", on_list_tools=list_tools, on_call_tool=call_tool) @@ -130,7 +121,7 @@ async def on_progress(ctx: ServerRequestContext, params: ProgressNotificationPar server = Server("observer", on_progress=on_progress) async with connect(server) as client: - await client.send_progress_notification("upload-1", 0.5, total=1.0, message="halfway") + await client.send_progress_notification("upload-1", 0.5, total=1.0, message="halfway") # pyright: ignore[reportDeprecated] with anyio.fail_after(5): await delivered.wait() @@ -147,12 +138,11 @@ async def test_concurrent_requests_carry_distinct_progress_tokens(connect: Conne token would be live at a time and the demultiplexing would never be exercised. The handlers each block until both have started and then hand control back and forth so the four progress notifications are emitted in strict a, b, a, b order on the wire. The two handlers send different - progress values so a stream swap (token A delivered to callback B and vice versa) would fail: each - callback receiving exactly its own values proves notifications are routed by token, not by arrival - order or by chance. + progress values so a stream swap (request A's progress delivered to callback B and vice versa) + would fail: each callback receiving exactly its own values proves notifications are routed + per-request, not by arrival order or by chance. """ progress_values = {"a": (1.0, 2.0), "b": (10.0, 20.0)} - tokens: dict[str, ProgressToken] = {} entered = {"a": anyio.Event(), "b": anyio.Event()} # turns[n] is set to release the nth emission; each emission releases the next. turns = [anyio.Event() for _ in range(4)] @@ -165,23 +155,15 @@ async def list_tools( async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: assert params.name == "report" assert params.arguments is not None - assert ctx.meta is not None - token = ctx.meta.get("progress_token") - assert token is not None label = params.arguments["label"] - tokens[label] = token entered[label].set() # The two handlers interleave by waiting on alternating turns: a takes 0 and 2, b takes 1 and 3. first, second = (0, 2) if label == "a" else (1, 3) await turns[first].wait() - await ctx.session.send_progress_notification( - token, progress_values[label][0], related_request_id=str(ctx.request_id) - ) + await ctx.session.report_progress(progress_values[label][0]) turns[first + 1].set() await turns[second].wait() - await ctx.session.send_progress_notification( - token, progress_values[label][1], related_request_id=str(ctx.request_id) - ) + await ctx.session.report_progress(progress_values[label][1]) if second + 1 < len(turns): turns[second + 1].set() return CallToolResult(content=[TextContent(text="done")]) @@ -210,7 +192,6 @@ async def call(label: str, collect: ProgressFnT) -> None: await entered["b"].wait() turns[0].set() - assert tokens["a"] != tokens["b"] assert received_a == [1.0, 2.0] assert received_b == [10.0, 20.0] @@ -285,12 +266,9 @@ async def list_tools( async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: assert params.name == "zigzag" - assert ctx.meta is not None - token = ctx.meta.get("progress_token") - assert token is not None - await ctx.session.send_progress_notification(token, 0.5, related_request_id=str(ctx.request_id)) - await ctx.session.send_progress_notification(token, 0.3, related_request_id=str(ctx.request_id)) - await ctx.session.send_progress_notification(token, 0.9, related_request_id=str(ctx.request_id)) + await ctx.session.report_progress(0.5) + await ctx.session.report_progress(0.3) + await ctx.session.report_progress(0.9) return CallToolResult(content=[TextContent(text="done")]) server = Server("zigzagger", on_list_tools=list_tools, on_call_tool=call_tool) diff --git a/tests/interaction/mcpserver/test_completion.py b/tests/interaction/mcpserver/test_completion.py index 7761066e94..30ff9613e3 100644 --- a/tests/interaction/mcpserver/test_completion.py +++ b/tests/interaction/mcpserver/test_completion.py @@ -32,7 +32,7 @@ async def complete( raise NotImplementedError async with connect(with_handler) as client: - assert client.initialize_result.capabilities.completions == CompletionsCapability() + assert client.server_capabilities.completions == CompletionsCapability() async with connect(MCPServer("plain")) as client: - assert client.initialize_result.capabilities.completions is None + assert client.server_capabilities.completions is None diff --git a/tests/interaction/mcpserver/test_context.py b/tests/interaction/mcpserver/test_context.py index f3ee3f52e4..edbbc94467 100644 --- a/tests/interaction/mcpserver/test_context.py +++ b/tests/interaction/mcpserver/test_context.py @@ -52,7 +52,7 @@ async def collect(params: LoggingMessageNotificationParams) -> None: async with connect(mcp, logging_callback=collect) as client: result = await client.call_tool("narrate", {}) - advertised_logging = client.initialize_result.capabilities.logging + advertised_logging = client.server_capabilities.logging assert result == snapshot(CallToolResult(content=[TextContent(text="done")], structured_content={"result": "done"})) assert received == snapshot( diff --git a/tests/interaction/test_coverage.py b/tests/interaction/test_coverage.py index 2c7e486ab3..26e697c3ba 100644 --- a/tests/interaction/test_coverage.py +++ b/tests/interaction/test_coverage.py @@ -301,6 +301,7 @@ def test_compute_cells_drops_era_locked_transport_outside_its_versions() -> None "sse-2025-11-25", "streamable-http-2025-11-25", "streamable-http-stateless-2025-11-25", + "in-memory-2026-07-28", "streamable-http-2026-07-28", ] diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py index f943f9e89e..52b20629e6 100644 --- a/tests/interaction/transports/test_hosting_http_modern.py +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -20,6 +20,7 @@ from mcp.client.session import ClientSession from mcp.client.streamable_http import streamable_http_client from mcp.server import Server, ServerRequestContext +from mcp.shared.version import LATEST_MODERN_VERSION from mcp.types import ( CLIENT_CAPABILITIES_META_KEY, INTERNAL_ERROR, @@ -28,6 +29,7 @@ MISSING_REQUIRED_CLIENT_CAPABILITY, CallToolRequestParams, CallToolResult, + DiscoverResult, EmptyResult, Implementation, JSONRPCError, @@ -35,6 +37,7 @@ ListToolsResult, PaginatedRequestParams, RequestParams, + ServerCapabilities, TextContent, Tool, ) @@ -43,8 +46,6 @@ pytestmark = pytest.mark.anyio -MODERN_VERSION = "2026-07-28" - def _modern_headers(*, method: str, name: str | None = None) -> dict[str, str]: """Request headers for a 2026-07-28 POST. @@ -52,7 +53,7 @@ def _modern_headers(*, method: str, name: str | None = None) -> dict[str, str]: The Accept/Content-Type baseline plus the ``MCP-Protocol-Version`` routing header and the ``Mcp-Method`` / ``Mcp-Name`` advisory headers a 2026-era client always sends. """ - headers = base_headers() | {"mcp-protocol-version": MODERN_VERSION, "mcp-method": method} + headers = base_headers() | {"mcp-protocol-version": LATEST_MODERN_VERSION, "mcp-method": method} if name is not None: headers["mcp-name"] = name return headers @@ -65,7 +66,7 @@ def _meta_envelope() -> dict[str, object]: capabilities travel on each request instead of once per session. """ return { - "io.modelcontextprotocol/protocolVersion": MODERN_VERSION, + "io.modelcontextprotocol/protocolVersion": LATEST_MODERN_VERSION, "io.modelcontextprotocol/clientInfo": {"name": "raw", "version": "0.0.0"}, "io.modelcontextprotocol/clientCapabilities": {}, } @@ -328,12 +329,16 @@ async def on_response(response: httpx.Response) -> None: with anyio.fail_after(5): async with ( mounted_app(server, on_request=on_request, on_response=on_response) as (http, _), - streamable_http_client(f"{BASE_URL}/mcp", http_client=http, protocol_version=MODERN_VERSION) as ( - read, - write, - ), - ClientSession(read, write, client_info=client_info, protocol_version=MODERN_VERSION) as session, + streamable_http_client(f"{BASE_URL}/mcp", http_client=http) as (read, write), + ClientSession(read, write, client_info=client_info) as session, ): + session.adopt( + DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + server_info=Implementation(name="srv", version="0"), + ) + ) result = await session.call_tool( "add", {"a": 2, "b": 3}, diff --git a/tests/interaction/transports/test_hosting_resume.py b/tests/interaction/transports/test_hosting_resume.py index f88521dbb0..77ef087cd6 100644 --- a/tests/interaction/transports/test_hosting_resume.py +++ b/tests/interaction/transports/test_hosting_resume.py @@ -21,8 +21,8 @@ from mcp.client.streamable_http import streamable_http_client from mcp.server.mcpserver import Context, MCPServer from mcp.shared.message import ClientMessageMetadata +from mcp.shared.version import LATEST_HANDSHAKE_VERSION from mcp.types import ( - LATEST_PROTOCOL_VERSION, CallToolRequest, CallToolRequestParams, CallToolResult, @@ -431,7 +431,7 @@ async def collect(params: LoggingMessageNotificationParams) -> None: # The session id is only observable via the manager (the client transport does not expose it). (session_id,) = manager._server_instances http.headers["mcp-session-id"] = session_id - http.headers["mcp-protocol-version"] = LATEST_PROTOCOL_VERSION + http.headers["mcp-protocol-version"] = LATEST_HANDSHAKE_VERSION tg.cancel_scope.cancel() with anyio.fail_after(5): # pragma: no branch diff --git a/tests/interaction/transports/test_stdio.py b/tests/interaction/transports/test_stdio.py index 8aac551c67..60a9b93981 100644 --- a/tests/interaction/transports/test_stdio.py +++ b/tests/interaction/transports/test_stdio.py @@ -90,7 +90,7 @@ async def collect(params: LoggingMessageNotificationParams) -> None: # Must exceed session time plus the patched PROCESS_TERMINATION_TIMEOUT (20s). with anyio.fail_after(30): async with Client(transport, logging_callback=collect) as client: - assert client.initialize_result.server_info.name == "stdio-echo" + assert client.server_info.name == "stdio-echo" result = await client.call_tool("echo", {"text": "across\nprocesses"}) errlog.seek(0) diff --git a/tests/interaction/transports/test_streamable_http.py b/tests/interaction/transports/test_streamable_http.py index cb63e389ca..79aace2639 100644 --- a/tests/interaction/transports/test_streamable_http.py +++ b/tests/interaction/transports/test_streamable_http.py @@ -69,7 +69,7 @@ async def announce(ctx: Context) -> str: async def test_tool_call_over_streamable_http_with_json_responses() -> None: """The round trip works when the server answers with a single JSON body instead of an SSE stream.""" async with connect_over_streamable_http(_smoke_server(), json_response=True) as client: - assert client.initialize_result.server_info.name == "smoke" + assert client.server_info.name == "smoke" result = await client.call_tool("echo", {"text": "as json"}) assert result == snapshot( diff --git a/tests/issues/test_176_progress_token.py b/tests/issues/test_176_progress_token.py index 1ba2c8e118..5e62e9c692 100644 --- a/tests/issues/test_176_progress_token.py +++ b/tests/issues/test_176_progress_token.py @@ -9,13 +9,17 @@ async def test_progress_token_zero_first_call(): - """Test that progress notifications work when progress_token is 0 on first call.""" - - # Create mock session with progress notification tracking + """Regression: progress reporting must not be gated on a falsy token. + + Issue #176: the original Context.report_progress treated token 0 as "no token" and + silently dropped progress. Context now delegates unconditionally to + ServerSession.report_progress (which calls DispatchContext.progress, whose JSONRPC + implementation gates on `is None`, not truthiness), so a request whose meta carries + a 0-valued token still emits all three reports. + """ mock_session = AsyncMock() - mock_session.send_progress_notification = AsyncMock() + mock_session.report_progress = AsyncMock() - # Create request context with progress token 0 request_context = ServerRequestContext( request_id="test-request", session=mock_session, @@ -25,22 +29,14 @@ async def test_progress_token_zero_first_call(): protocol_version="2025-11-25", ) - # Create context with our mocks ctx = Context(request_context=request_context, mcp_server=MagicMock()) - # Test progress reporting - await ctx.report_progress(0, 10) # First call with 0 - await ctx.report_progress(5, 10) # Middle progress - await ctx.report_progress(10, 10) # Complete + await ctx.report_progress(0, 10) + await ctx.report_progress(5, 10) + await ctx.report_progress(10, 10) - # Verify progress notifications - assert mock_session.send_progress_notification.call_count == 3, "All progress notifications should be sent" - mock_session.send_progress_notification.assert_any_call( - progress_token=0, progress=0.0, total=10.0, message=None, related_request_id="test-request" - ) - mock_session.send_progress_notification.assert_any_call( - progress_token=0, progress=5.0, total=10.0, message=None, related_request_id="test-request" - ) - mock_session.send_progress_notification.assert_any_call( - progress_token=0, progress=10.0, total=10.0, message=None, related_request_id="test-request" - ) + assert mock_session.report_progress.await_args_list == [ + ((0, 10, None),), + ((5, 10, None),), + ((10, 10, None),), + ] diff --git a/tests/issues/test_192_request_id.py b/tests/issues/test_192_request_id.py index de96dbe23a..9f935ae088 100644 --- a/tests/issues/test_192_request_id.py +++ b/tests/issues/test_192_request_id.py @@ -4,8 +4,8 @@ from mcp.server.lowlevel import NotificationOptions, Server from mcp.server.models import InitializationOptions from mcp.shared.message import SessionMessage +from mcp.shared.version import LATEST_HANDSHAKE_VERSION from mcp.types import ( - LATEST_PROTOCOL_VERSION, ClientCapabilities, Implementation, InitializeRequestParams, @@ -59,7 +59,7 @@ async def run_server(): id="init-1", method="initialize", params=InitializeRequestParams( - protocol_version=LATEST_PROTOCOL_VERSION, + protocol_version=LATEST_HANDSHAKE_VERSION, capabilities=ClientCapabilities(), client_info=Implementation(name="test-client", version="1.0.0"), ).model_dump(by_alias=True, exclude_none=True), diff --git a/tests/issues/test_552_windows_hang.py b/tests/issues/test_552_windows_hang.py index 371d033c2b..82d4074e91 100644 --- a/tests/issues/test_552_windows_hang.py +++ b/tests/issues/test_552_windows_hang.py @@ -9,7 +9,8 @@ from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client -from mcp.types import LATEST_PROTOCOL_VERSION, InitializeResult +from mcp.shared.version import LATEST_HANDSHAKE_VERSION +from mcp.types import InitializeResult @pytest.mark.skipif(sys.platform != "win32", reason="Windows-specific test") # pragma: no cover @@ -32,7 +33,7 @@ async def test_initialize_succeeds_and_shutdown_returns_after_the_server_exits_m "jsonrpc": "2.0", "id": request["id"], "result": {{ - "protocolVersion": {json.dumps(LATEST_PROTOCOL_VERSION)}, + "protocolVersion": {json.dumps(LATEST_HANDSHAKE_VERSION)}, "capabilities": {{}}, "serverInfo": {{"name": "test-server", "version": "1.0"}} }} diff --git a/tests/server/mcpserver/test_integration.py b/tests/server/mcpserver/test_integration.py index 5bac39dfee..a5388b17a4 100644 --- a/tests/server/mcpserver/test_integration.py +++ b/tests/server/mcpserver/test_integration.py @@ -105,7 +105,7 @@ async def elicitation_callback(context: ClientRequestContext, params: ElicitRequ async def test_basic_tools() -> None: """Test basic tool functionality.""" async with Client(basic_tool.mcp) as client: - assert client.initialize_result.capabilities.tools is not None + assert client.server_capabilities.tools is not None # Test sum tool tool_result = await client.call_tool("sum", {"a": 5, "b": 3}) @@ -123,7 +123,7 @@ async def test_basic_tools() -> None: async def test_basic_resources() -> None: """Test basic resource functionality.""" async with Client(basic_resource.mcp) as client: - assert client.initialize_result.capabilities.resources is not None + assert client.server_capabilities.resources is not None # Test document resource doc_content = await client.read_resource("file://documents/readme") @@ -145,7 +145,7 @@ async def test_basic_resources() -> None: async def test_basic_prompts() -> None: """Test basic prompt functionality.""" async with Client(basic_prompt.mcp) as client: - assert client.initialize_result.capabilities.prompts is not None + assert client.server_capabilities.prompts is not None # Test review_code prompt prompts = await client.list_prompts() @@ -216,7 +216,7 @@ async def progress_callback(progress: float, total: float | None, message: str | async def test_sampling() -> None: """Test sampling (LLM interaction) functionality.""" async with Client(sampling.mcp, sampling_callback=sampling_callback) as client: - assert client.initialize_result.capabilities.tools is not None + assert client.server_capabilities.tools is not None # Test sampling tool sampling_result = await client.call_tool("generate_poem", {"topic": "nature"}) @@ -286,8 +286,8 @@ async def message_handler(message: RequestResponder[ServerRequest, ClientResult] async def test_completion() -> None: """Test completion (autocomplete) functionality.""" async with Client(completion.mcp) as client: - assert client.initialize_result.capabilities.resources is not None - assert client.initialize_result.capabilities.prompts is not None + assert client.server_capabilities.resources is not None + assert client.server_capabilities.prompts is not None # Test resource completion completion_result = await client.complete( diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 554fe50215..a48bd7ae47 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -720,7 +720,7 @@ def get_text() -> str: mcp = MCPServer(resources=[resource]) async with Client(mcp) as client: - assert client.initialize_result.capabilities.resources is not None + assert client.server_capabilities.resources is not None resources = await client.list_resources() assert len(resources.resources) == 1 @@ -1515,21 +1515,21 @@ def test_streamable_http_no_redirect() -> None: assert streamable_routes[0].path == "/mcp", "Streamable route path should be /mcp" -async def test_report_progress_passes_related_request_id(): - """Test that report_progress passes the request_id as related_request_id. +async def test_report_progress_delegates_to_session_report_progress(): + """Context.report_progress delegates to ServerSession.report_progress unconditionally. - Without related_request_id, the streamable HTTP transport cannot route - progress notifications to the correct SSE stream, causing them to be - silently dropped. See #953 and #2001. + Stream routing (related_request_id, progress-token gating) is encapsulated in the + per-request DispatchContext that ServerSession holds, so Context never inspects + request metadata itself. See #953 and #2001 for the original streamable-HTTP routing bug. """ mock_session = AsyncMock() - mock_session.send_progress_notification = AsyncMock() + mock_session.report_progress = AsyncMock() request_context = ServerRequestContext( request_id="req-abc-123", session=mock_session, method="tools/call", - meta={"progress_token": "tok-1"}, + meta=None, lifespan_context=None, protocol_version="2025-11-25", ) @@ -1538,13 +1538,7 @@ async def test_report_progress_passes_related_request_id(): await ctx.report_progress(50, 100, message="halfway") - mock_session.send_progress_notification.assert_awaited_once_with( - progress_token="tok-1", - progress=50, - total=100, - message="halfway", - related_request_id="req-abc-123", - ) + mock_session.report_progress.assert_awaited_once_with(50, 100, "halfway") async def test_read_resource_template_error(): diff --git a/tests/server/test_cancel_handling.py b/tests/server/test_cancel_handling.py index 0744e63022..cc157247c9 100644 --- a/tests/server/test_cancel_handling.py +++ b/tests/server/test_cancel_handling.py @@ -7,8 +7,8 @@ from mcp.server import Server, ServerRequestContext from mcp.shared.exceptions import MCPError from mcp.shared.message import SessionMessage +from mcp.shared.version import LATEST_HANDSHAKE_VERSION from mcp.types import ( - LATEST_PROTOCOL_VERSION, CallToolRequest, CallToolRequestParams, CallToolResult, @@ -138,7 +138,7 @@ async def run_server(): id=1, method="initialize", params=InitializeRequestParams( - protocol_version=LATEST_PROTOCOL_VERSION, + protocol_version=LATEST_HANDSHAKE_VERSION, capabilities=ClientCapabilities(), client_info=Implementation(name="test", version="1.0"), ).model_dump(by_alias=True, mode="json", exclude_none=True), @@ -212,7 +212,7 @@ async def run_server(): id=1, method="initialize", params=InitializeRequestParams( - protocol_version=LATEST_PROTOCOL_VERSION, + protocol_version=LATEST_HANDSHAKE_VERSION, capabilities=ClientCapabilities(), client_info=Implementation(name="test", version="1.0"), ).model_dump(by_alias=True, mode="json", exclude_none=True), diff --git a/tests/server/test_connection.py b/tests/server/test_connection.py index 8ca1ae8a7a..3a09aa15d7 100644 --- a/tests/server/test_connection.py +++ b/tests/server/test_connection.py @@ -19,7 +19,7 @@ from mcp.server.connection import Connection from mcp.shared.dispatcher import CallOptions from mcp.shared.exceptions import NoBackChannelError -from mcp.shared.version import MODERN_PROTOCOL_VERSIONS +from mcp.shared.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION from mcp.types import ( LATEST_PROTOCOL_VERSION, ClientCapabilities, @@ -40,7 +40,6 @@ ) _CLIENT_INFO = Implementation(name="t", version="0") -_MODERN = MODERN_PROTOCOL_VERSIONS[0] class StubOutbound: @@ -58,7 +57,7 @@ async def send_raw_request( self.requests.append((method, params)) return self._result - async def notify(self, method: str, params: Mapping[str, Any] | None) -> None: + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: if self._raise_on_send is not None: raise self._raise_on_send() self.notifications.append((method, params)) @@ -70,8 +69,8 @@ async def notify(self, method: str, params: Mapping[str, Any] | None) -> None: def test_from_envelope_is_born_ready_with_no_back_channel(): """SDK-defined: `from_envelope` populates `protocol_version`, sets `initialized`, and holds the no-channel sentinel so `has_standalone_channel` derives False.""" - conn = Connection.from_envelope(_MODERN, None, None) - assert conn.protocol_version == _MODERN + conn = Connection.from_envelope(LATEST_MODERN_VERSION, None, None) + assert conn.protocol_version == LATEST_MODERN_VERSION assert conn.initialized.is_set() assert conn.initialize_accepted is True assert conn.has_standalone_channel is False @@ -83,11 +82,11 @@ def test_from_envelope_records_client_params_when_both_info_and_caps_supplied(): """SDK-defined: when both client info and capabilities are supplied, `from_envelope` synthesizes `client_params` so capability checks can run.""" caps = ClientCapabilities(sampling=SamplingCapability()) - conn = Connection.from_envelope(_MODERN, _CLIENT_INFO, caps) + conn = Connection.from_envelope(LATEST_MODERN_VERSION, _CLIENT_INFO, caps) assert conn.client_params is not None assert conn.client_params.client_info.name == "t" assert conn.client_params.capabilities.sampling is not None - assert conn.client_params.protocol_version == _MODERN + assert conn.client_params.protocol_version == LATEST_MODERN_VERSION @pytest.mark.parametrize( @@ -99,7 +98,7 @@ def test_from_envelope_leaves_client_params_none_when_either_is_missing( ): """SDK-defined: `client_params` is only synthesized when both info and caps are present; either missing leaves it `None`.""" - conn = Connection.from_envelope(_MODERN, info, caps) + conn = Connection.from_envelope(LATEST_MODERN_VERSION, info, caps) assert conn.client_params is None @@ -107,7 +106,7 @@ def test_from_envelope_with_explicit_outbound_has_standalone_channel(): """SDK-defined: duplex modern transports pass an outbound; `has_standalone_channel` derives True since the held outbound is not the no-channel sentinel.""" out = StubOutbound() - conn = Connection.from_envelope(_MODERN, None, None, outbound=out) + conn = Connection.from_envelope(LATEST_MODERN_VERSION, None, None, outbound=out) assert conn.has_standalone_channel is True assert conn.outbound is out assert conn.initialized.is_set() @@ -115,17 +114,17 @@ def test_from_envelope_with_explicit_outbound_has_standalone_channel(): def test_for_loop_seeds_version_from_hint_or_latest_and_is_not_born_ready(): """SDK-defined: `for_loop` seeds `protocol_version` from the hint when given, - else `LATEST_PROTOCOL_VERSION`; the connection awaits the initialize handshake.""" + else `LATEST_HANDSHAKE_VERSION`; the connection awaits the initialize handshake.""" out = StubOutbound() conn = Connection.for_loop(out) - assert conn.protocol_version == LATEST_PROTOCOL_VERSION + assert conn.protocol_version == LATEST_HANDSHAKE_VERSION assert conn.has_standalone_channel is True assert not conn.initialized.is_set() assert conn.initialize_accepted is False assert conn.client_params is None - hinted = Connection.for_loop(out, protocol_version_hint=_MODERN) - assert hinted.protocol_version == _MODERN + hinted = Connection.for_loop(out, protocol_version_hint=LATEST_MODERN_VERSION) + assert hinted.protocol_version == LATEST_MODERN_VERSION def test_for_loop_records_session_id_when_supplied(): @@ -229,7 +228,7 @@ async def test_send_request_validates_the_client_result_against_the_surface_sche async def test_send_request_passes_a_spec_valid_client_result(): """A spec-valid client result passes the surface gate and parses to the typed model.""" conn = Connection.for_loop(StubOutbound(result={"roots": [{"uri": "file:///ws"}]})) - assert conn.protocol_version == LATEST_PROTOCOL_VERSION + assert conn.protocol_version == LATEST_HANDSHAKE_VERSION result = await conn.send_request(ListRootsRequest()) assert isinstance(result, ListRootsResult) assert str(result.roots[0].uri) == "file:///ws" @@ -248,7 +247,7 @@ class _CustomResult(BaseModel): async def test_send_request_skips_the_surface_gate_when_method_absent_at_version(): """Surface row absent for the negotiated version: gate is bypassed and only the inferred result type validates.""" - conn = Connection.for_loop(StubOutbound(result={}), protocol_version_hint=_MODERN) + conn = Connection.for_loop(StubOutbound(result={}), protocol_version_hint=LATEST_MODERN_VERSION) result = await conn.send_request(PingRequest()) assert isinstance(result, EmptyResult) @@ -328,7 +327,7 @@ def test_connection_check_capability_false_when_no_client_params_recorded(): conn = Connection.for_loop(StubOutbound()) assert conn.check_capability(ClientCapabilities(sampling=SamplingCapability())) is False # Same for a born-ready connection that supplied neither info nor caps. - assert Connection.from_envelope(_MODERN, None, None).check_capability(ClientCapabilities()) is False + assert Connection.from_envelope(LATEST_MODERN_VERSION, None, None).check_capability(ClientCapabilities()) is False @pytest.mark.parametrize( diff --git a/tests/server/test_runner.py b/tests/server/test_runner.py index c5a99ae07a..30c611066c 100644 --- a/tests/server/test_runner.py +++ b/tests/server/test_runner.py @@ -4,7 +4,7 @@ `Server` as the registry. The `connected_runner` helper starts both sides and (by default) performs the initialize handshake, so each test exercises only the behaviour under test. Driver tests (`serve_connection`, `serve_one`, -`to_jsonrpc_response`, `aclose_shielded`) follow at the bottom. +`aclose_shielded`) follow at the bottom. """ from collections.abc import AsyncIterator, Mapping @@ -30,7 +30,6 @@ otel_middleware, serve_connection, serve_one, - to_jsonrpc_response, ) from mcp.server.session import ServerSession from mcp.shared.dispatcher import CallOptions, DispatchContext, DispatchMiddleware, OnRequest @@ -39,7 +38,7 @@ from mcp.shared.message import MessageMetadata from mcp.shared.peer import dump_params from mcp.shared.transport_context import TransportContext -from mcp.shared.version import MODERN_PROTOCOL_VERSIONS, SUPPORTED_PROTOCOL_VERSIONS +from mcp.shared.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION, OLDEST_SUPPORTED_VERSION from mcp.types import ( INTERNAL_ERROR, INVALID_PARAMS, @@ -50,9 +49,6 @@ ErrorData, Implementation, InitializeRequestParams, - JSONRPCError, - JSONRPCRequest, - JSONRPCResponse, ListToolsResult, NotificationParams, PaginatedRequestParams, @@ -71,7 +67,7 @@ def _initialize_params() -> dict[str, Any]: return InitializeRequestParams( - protocol_version=LATEST_PROTOCOL_VERSION, + protocol_version=LATEST_HANDSHAKE_VERSION, capabilities=ClientCapabilities(), client_info=Implementation(name="test-client", version="1.0"), ).model_dump(by_alias=True, exclude_none=True) @@ -168,7 +164,7 @@ async def test_runner_handles_initialize_and_populates_connection(server: SrvT): assert "tools" in result["capabilities"] assert runner.connection.client_params is not None assert runner.connection.client_params.client_info.name == "test-client" - assert runner.connection.protocol_version == LATEST_PROTOCOL_VERSION + assert runner.connection.protocol_version == LATEST_HANDSHAKE_VERSION assert runner.connection.initialize_accepted is True @@ -245,7 +241,7 @@ async def test_runner_routes_to_handler_and_builds_context(server: SrvT): assert isinstance(ctx.session, ServerSession) assert ctx.session.protocol_version == runner.connection.protocol_version assert ctx.request_id is not None - assert ctx.protocol_version == LATEST_PROTOCOL_VERSION + assert ctx.protocol_version == LATEST_HANDSHAKE_VERSION @pytest.mark.anyio @@ -290,7 +286,7 @@ async def test_runner_rejects_snake_case_initialize_params(server: SrvT): """Inbound wire payloads validate alias-only; Python field names are not accepted (`protocol_version` must arrive as `protocolVersion`).""" snake = { - "protocol_version": LATEST_PROTOCOL_VERSION, + "protocol_version": LATEST_HANDSHAKE_VERSION, "capabilities": {}, "client_info": {"name": "c", "version": "0"}, } @@ -815,7 +811,7 @@ async def test_runner_with_born_ready_connection_skips_init_gate(server: SrvT): """A `Connection.from_envelope` connection is born ready: the kernel's init-gate is open without any handshake. The kernel is mode-agnostic - the same `on_request` reads `connection.initialize_accepted` as a fact.""" - born_ready = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None) + born_ready = Connection.from_envelope(LATEST_HANDSHAKE_VERSION, None, None) async with connected_runner(server, initialized=False, connection=born_ready) as (client, runner): assert runner.connection.initialize_accepted is True assert runner.connection.initialized.is_set() @@ -848,7 +844,7 @@ async def greet(ctx: Ctx, params: GreetParams) -> dict[str, Any]: @pytest.mark.anyio async def test_runner_spec_method_with_invalid_params_is_invalid_params_at_the_negotiated_version(server: SrvT): async with connected_runner(server) as (client, runner): - assert runner.connection.protocol_version == LATEST_PROTOCOL_VERSION + assert runner.connection.protocol_version == LATEST_HANDSHAKE_VERSION with pytest.raises(MCPError) as exc: await client.send_raw_request("tools/call", {"name": 42}) assert exc.value.error.code == INVALID_PARAMS @@ -927,9 +923,9 @@ async def discover(ctx: Ctx, params: RequestParams) -> Any: async def test_on_request_rejects_initialize_at_modern_version_with_method_not_found(server: SrvT): """Spec-mandated: `initialize` has no `CLIENT_REQUESTS` row at the modern version; kernel dispatch (not the inbound classifier) rejects it.""" - born_ready = Connection.from_envelope(MODERN_PROTOCOL_VERSIONS[0], None, None) + born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None) async with connected_runner(server, initialized=False, connection=born_ready) as (client, runner): - assert runner.connection.protocol_version == MODERN_PROTOCOL_VERSIONS[0] + assert runner.connection.protocol_version == LATEST_MODERN_VERSION with pytest.raises(MCPError) as exc: await client.send_raw_request("initialize", _initialize_params()) assert exc.value.error.code == METHOD_NOT_FOUND @@ -944,7 +940,7 @@ async def echo(ctx: Ctx, params: RequestParams) -> dict[str, Any]: return {"echoed": True} server.add_request_handler("myorg/echo", RequestParams, echo) - born_ready = Connection.from_envelope(MODERN_PROTOCOL_VERSIONS[0], None, None) + born_ready = Connection.from_envelope(LATEST_MODERN_VERSION, None, None) async with connected_runner(server, initialized=False, connection=born_ready) as (client, _): result = await client.send_raw_request("myorg/echo", None) assert result == {"echoed": True} @@ -998,7 +994,7 @@ async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToo @pytest.mark.anyio async def test_runner_initialize_echoes_supported_version_and_falls_back_to_latest(server: SrvT): - oldest = SUPPORTED_PROTOCOL_VERSIONS[0] + oldest = OLDEST_SUPPORTED_VERSION async with connected_runner(server, initialized=False) as (client, _): params = {**_initialize_params(), "protocolVersion": oldest} result = await client.send_raw_request("initialize", params) @@ -1006,7 +1002,7 @@ async def test_runner_initialize_echoes_supported_version_and_falls_back_to_late async with connected_runner(server, initialized=False) as (client, _): params = {**_initialize_params(), "protocolVersion": "1999-01-01"} result = await client.send_raw_request("initialize", params) - assert result["protocolVersion"] == LATEST_PROTOCOL_VERSION + assert result["protocolVersion"] == LATEST_HANDSHAKE_VERSION @pytest.mark.anyio @@ -1202,71 +1198,6 @@ async def _append(i: int) -> None: assert "abandoning remaining callbacks" not in caplog.text -# --- to_jsonrpc_response ------------------------------------------------------- - - -@pytest.mark.anyio -async def test_to_jsonrpc_response_wraps_success_as_jsonrpc_response(): - """SDK-defined: a handler coroutine resolving to a result dict is wrapped as a - `JSONRPCResponse` carrying the supplied id and the dict verbatim as `result`.""" - - async def ok() -> dict[str, Any]: - return {"k": "v"} - - reply = await to_jsonrpc_response(7, ok()) - assert isinstance(reply, JSONRPCResponse) - assert reply.id == 7 - assert reply.result == {"k": "v"} - - -@pytest.mark.anyio -async def test_to_jsonrpc_response_maps_mcp_error_to_jsonrpc_error(): - """SDK-defined: an `MCPError` raised by the handler coroutine is wrapped as a - `JSONRPCError` whose `error` carries the same code, message, and data.""" - - async def fail() -> dict[str, Any]: - raise MCPError(code=METHOD_NOT_FOUND, message="nope", data="x") - - reply = await to_jsonrpc_response("rid", fail()) - assert isinstance(reply, JSONRPCError) - assert reply.id == "rid" - assert reply.error == ErrorData(code=METHOD_NOT_FOUND, message="nope", data="x") - - -@pytest.mark.anyio -async def test_to_jsonrpc_response_maps_validation_error_to_invalid_params(): - """SDK-defined: a pydantic `ValidationError` escaping the handler coroutine is - mapped to `INVALID_PARAMS` with a generic message (validator detail does not - reach the wire).""" - - async def fail() -> dict[str, Any]: - Tool.model_validate({"name": 123}) # raises ValidationError - raise NotImplementedError - - reply = await to_jsonrpc_response(1, fail()) - assert isinstance(reply, JSONRPCError) - assert reply.error == ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="") - - -@pytest.mark.anyio -async def test_to_jsonrpc_response_maps_unmapped_exception_to_internal_error_and_logs( - caplog: pytest.LogCaptureFixture, -): - """SDK-defined: an unmapped exception is logged server-side and surfaced as - `INTERNAL_ERROR` with a generic message; the exception text never reaches the - wire.""" - - async def fail() -> dict[str, Any]: - raise RuntimeError("boom") - - reply = await to_jsonrpc_response(1, fail()) - assert isinstance(reply, JSONRPCError) - assert reply.error.code == INTERNAL_ERROR - # Handler internals never reach the wire. - assert "boom" not in reply.error.message - assert "request handler raised" in caplog.text - - # --- aclose_shielded ----------------------------------------------------------- @@ -1310,7 +1241,7 @@ async def send_raw_request( ) -> dict[str, Any]: raise NotImplementedError - async def notify(self, method: str, params: Mapping[str, Any] | None) -> None: + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: raise NotImplementedError async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: @@ -1325,34 +1256,34 @@ async def _append_async(dst: list[int], v: int) -> None: @pytest.mark.anyio -async def test_serve_one_runs_handler_and_returns_jsonrpc_response(server: SrvT): +async def test_serve_one_runs_handler_and_returns_result_dict(server: SrvT): """The single-exchange driver: builds the kernel, runs `on_request` once, - wraps via `to_jsonrpc_response`, and tears down `connection.exit_stack`.""" - conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None) + returns the agnostic result dict, and tears down `connection.exit_stack`.""" + conn = Connection.from_envelope(LATEST_HANDSHAKE_VERSION, None, None) cleaned: list[int] = [] conn.exit_stack.push_async_callback(_append_async, cleaned, 1) - request = JSONRPCRequest(jsonrpc="2.0", id=9, method="tools/list", params=None) - reply = await serve_one(server, request, connection=conn, dctx=_StubDispatchContext(9), lifespan_state=_LIFESPAN) - assert isinstance(reply, JSONRPCResponse) - assert reply.id == 9 - assert reply.result["tools"][0]["name"] == "t" + result = await serve_one( + server, _StubDispatchContext(9), "tools/list", None, connection=conn, lifespan_state=_LIFESPAN + ) + assert result["tools"][0]["name"] == "t" assert cleaned == [1] ctx = _seen_ctx[0] - assert ctx.protocol_version == LATEST_PROTOCOL_VERSION + assert ctx.protocol_version == LATEST_HANDSHAKE_VERSION @pytest.mark.anyio -async def test_serve_one_maps_error_to_jsonrpc_error_and_still_closes_exit_stack(server: SrvT): +async def test_serve_one_propagates_error_and_still_closes_exit_stack(server: SrvT): """SDK-defined: a kernel-produced error (here `METHOD_NOT_FOUND` for an - unregistered method) is wrapped as a `JSONRPCError`, and the per-request - exit stack is closed on the error path too.""" - conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None) + unregistered method) propagates as `MCPError`, and the per-request exit + stack is closed on the error path too.""" + conn = Connection.from_envelope(LATEST_HANDSHAKE_VERSION, None, None) cleaned: list[int] = [] conn.exit_stack.push_async_callback(_append_async, cleaned, 1) - request = JSONRPCRequest(jsonrpc="2.0", id=2, method="resources/list", params=None) - reply = await serve_one(server, request, connection=conn, dctx=_StubDispatchContext(2), lifespan_state=_LIFESPAN) - assert isinstance(reply, JSONRPCError) - assert reply.error.code == METHOD_NOT_FOUND + with pytest.raises(MCPError) as exc_info: + await serve_one( + server, _StubDispatchContext(2), "resources/list", None, connection=conn, lifespan_state=_LIFESPAN + ) + assert exc_info.value.error.code == METHOD_NOT_FOUND assert cleaned == [1] @@ -1361,11 +1292,17 @@ async def test_serve_one_reads_connection_protocol_version_as_a_fact(server: Srv """`serve_one` builds the kernel over the entry's `Connection`; the kernel reads `connection.protocol_version` for the version gate. A `from_envelope` connection at a modern version rejects a method absent there.""" - conn = Connection.from_envelope(MODERN_PROTOCOL_VERSIONS[0], None, None) - request = JSONRPCRequest(jsonrpc="2.0", id=1, method="logging/setLevel", params={"level": "info"}) - reply = await serve_one(server, request, connection=conn, dctx=_StubDispatchContext(1), lifespan_state=_LIFESPAN) - assert isinstance(reply, JSONRPCError) - assert reply.error.code == METHOD_NOT_FOUND + conn = Connection.from_envelope(LATEST_MODERN_VERSION, None, None) + with pytest.raises(MCPError) as exc_info: + await serve_one( + server, + _StubDispatchContext(1), + "logging/setLevel", + {"level": "info"}, + connection=conn, + lifespan_state=_LIFESPAN, + ) + assert exc_info.value.error.code == METHOD_NOT_FOUND @pytest.mark.anyio @@ -1389,5 +1326,5 @@ async def test_serve_connection_drives_dispatcher_loop_and_tears_down(server: Sr assert cleaned == [] close() assert cleaned == [1] - assert conn.protocol_version == LATEST_PROTOCOL_VERSION + assert conn.protocol_version == LATEST_HANDSHAKE_VERSION assert conn.client_params is not None diff --git a/tests/server/test_session.py b/tests/server/test_session.py index 84d5e3aa93..ea57441dcc 100644 --- a/tests/server/test_session.py +++ b/tests/server/test_session.py @@ -15,11 +15,10 @@ from mcp import types from mcp.server.connection import Connection from mcp.server.session import ServerSession -from mcp.shared.dispatcher import CallOptions, Outbound +from mcp.shared.dispatcher import CallOptions from mcp.shared.message import ServerMessageMetadata -from mcp.shared.version import MODERN_PROTOCOL_VERSIONS +from mcp.shared.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION from mcp.types import ( - LATEST_PROTOCOL_VERSION, ClientCapabilities, Implementation, SamplingCapability, @@ -28,11 +27,21 @@ class StubOutbound: - """Records `send_raw_request` / `notify` calls and returns a canned result.""" + """Records `send_raw_request` / `notify` / `progress` calls and returns a canned result. + + Structurally a `DispatchContext[Any]` so it can stand in for the per-request channel. + """ + + transport: Any = None + can_send_request: bool = True + request_id: Any = None + message_metadata: Any = None + cancel_requested: Any = None def __init__(self, result: dict[str, Any] | None = None) -> None: self.requests: list[tuple[str, Mapping[str, Any] | None, CallOptions | None]] = [] self.notifications: list[tuple[str, Mapping[str, Any] | None]] = [] + self.progress_calls: list[tuple[float, float | None, str | None]] = [] self.result = result if result is not None else {} async def send_raw_request( @@ -44,15 +53,18 @@ async def send_raw_request( self.requests.append((method, params, opts)) return self.result - async def notify(self, method: str, params: Mapping[str, Any] | None) -> None: + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: self.notifications.append((method, params)) + async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: + self.progress_calls.append((progress, total, message)) + def _make_session( outbound: StubOutbound, *, capabilities: ClientCapabilities | None = None, - protocol_version: str = LATEST_PROTOCOL_VERSION, + protocol_version: str = LATEST_HANDSHAKE_VERSION, ) -> ServerSession: """Single-channel session: the stub is both request and standalone outbound.""" client_info = Implementation(name="c", version="0") if capabilities is not None else None @@ -60,9 +72,9 @@ def _make_session( return ServerSession(outbound, conn) -def _two_channel_session(request_ch: Outbound, standalone_ch: Outbound) -> ServerSession: +def _two_channel_session(request_ch: StubOutbound, standalone_ch: StubOutbound) -> ServerSession: """Distinct request/standalone outbounds so routing assertions can tell the channels apart.""" - conn = Connection.from_envelope(LATEST_PROTOCOL_VERSION, None, None, outbound=standalone_ch) + conn = Connection.from_envelope(LATEST_HANDSHAKE_VERSION, None, None, outbound=standalone_ch) return ServerSession(request_ch, conn) @@ -145,6 +157,19 @@ async def test_send_notification_routes_by_related_request_id(): assert [m for m, _ in request_ch.notifications] == ["notifications/progress"] +@pytest.mark.anyio +async def test_report_progress_delegates_to_the_request_dispatch_context(): + """`report_progress` calls the per-request `DispatchContext.progress` seam, never the + standalone channel: token gating and routing live in the dispatcher, not here.""" + request_ch = StubOutbound() + standalone_ch = StubOutbound() + session = _two_channel_session(request_ch, standalone_ch) + await session.report_progress(0.5, total=1.0, message="halfway") + assert request_ch.progress_calls == [(0.5, 1.0, "halfway")] + assert standalone_ch.progress_calls == [] + assert request_ch.notifications == [] + + @pytest.mark.anyio async def test_send_request_validates_the_client_result_against_the_surface_schema(): """A spec-method result that fails the per-version surface schema raises @@ -167,7 +192,7 @@ async def test_send_request_passes_a_spec_valid_client_result(): async def test_send_request_skips_the_surface_gate_when_method_absent_at_version(): """Surface row absent for the connection's version: gate is bypassed and only `result_type` validates.""" - session = _make_session(StubOutbound(result={}), protocol_version=MODERN_PROTOCOL_VERSIONS[0]) + session = _make_session(StubOutbound(result={}), protocol_version=LATEST_MODERN_VERSION) result = await session.send_request(types.PingRequest(), types.EmptyResult) assert isinstance(result, types.EmptyResult) diff --git a/tests/server/test_stateless_mode.py b/tests/server/test_stateless_mode.py index 91d344253a..7b252ef536 100644 --- a/tests/server/test_stateless_mode.py +++ b/tests/server/test_stateless_mode.py @@ -21,7 +21,16 @@ class StubOutbound: - """Records `send_raw_request` / `notify` calls and returns a canned result.""" + """Records `send_raw_request` / `notify` calls and returns a canned result. + + Structurally a `DispatchContext[Any]` so it can stand in for the per-request channel. + """ + + transport: Any = None + can_send_request: bool = True + request_id: Any = None + message_metadata: Any = None + cancel_requested: Any = None def __init__(self, result: dict[str, Any] | None = None) -> None: self.requests: list[tuple[str, Mapping[str, Any] | None, CallOptions | None]] = [] @@ -37,9 +46,12 @@ async def send_raw_request( self.requests.append((method, params, opts)) return self.result - async def notify(self, method: str, params: Mapping[str, Any] | None) -> None: + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: self.notifications.append((method, params)) + async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: + raise NotImplementedError # pragma: no cover + def _no_channel_session(request_ch: StubOutbound | None = None) -> tuple[ServerSession, StubOutbound]: """A session whose standalone channel is the connection's no-channel diff --git a/tests/server/test_streamable_http_modern.py b/tests/server/test_streamable_http_modern.py index 35ee17f3d6..7b655bdd6f 100644 --- a/tests/server/test_streamable_http_modern.py +++ b/tests/server/test_streamable_http_modern.py @@ -15,20 +15,31 @@ from starlette.types import Receive, Scope, Send from mcp.server import Server, ServerRequestContext, runner -from mcp.server._streamable_http_modern import _SingleExchangeDispatchContext, handle_modern_request +from mcp.server._streamable_http_modern import ( + _SingleExchangeDispatchContext, + _to_jsonrpc_response, + handle_modern_request, +) from mcp.server.transport_security import TransportSecuritySettings -from mcp.shared.exceptions import NoBackChannelError +from mcp.shared.exceptions import MCPError, NoBackChannelError from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER from mcp.shared.transport_context import TransportContext -from mcp.shared.version import MODERN_PROTOCOL_VERSIONS +from mcp.shared.version import LATEST_MODERN_VERSION from mcp.types import ( CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, + INTERNAL_ERROR, + INVALID_PARAMS, INVALID_REQUEST, + METHOD_NOT_FOUND, PARSE_ERROR, PROTOCOL_VERSION_META_KEY, + ErrorData, + JSONRPCError, + JSONRPCResponse, ListToolsResult, PaginatedRequestParams, + Tool, ) pytestmark = pytest.mark.anyio @@ -56,7 +67,7 @@ async def app(scope: Scope, receive: Receive, send: Send) -> None: return httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://testserver", - headers={MCP_PROTOCOL_VERSION_HEADER: MODERN_PROTOCOL_VERSIONS[0]}, + headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}, ) @@ -114,7 +125,7 @@ async def test_handle_modern_request_returns_transport_security_error_response() def _list_tools_body() -> dict[str, Any]: """A minimal valid 2026-07-28 ``tools/list`` request body, including the required ``_meta`` envelope.""" meta = { - PROTOCOL_VERSION_META_KEY: MODERN_PROTOCOL_VERSIONS[0], + PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION, CLIENT_INFO_META_KEY: {"name": "raw", "version": "0.0.0"}, CLIENT_CAPABILITIES_META_KEY: {}, } @@ -198,3 +209,64 @@ async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | assert response.status_code == 200 # pragma: lax no cover assert response.json()["result"]["tools"] == [] # pragma: lax no cover assert "abandoning remaining callbacks" in caplog.text # pragma: lax no cover + + +# --- _to_jsonrpc_response ------------------------------------------------------ + + +async def test_to_jsonrpc_response_wraps_success_as_jsonrpc_response() -> None: + """SDK-defined: a handler coroutine resolving to a result dict is wrapped as a + `JSONRPCResponse` carrying the supplied id and the dict verbatim as `result`.""" + + async def ok() -> dict[str, Any]: + return {"k": "v"} + + reply = await _to_jsonrpc_response(7, ok()) + assert isinstance(reply, JSONRPCResponse) + assert reply.id == 7 + assert reply.result == {"k": "v"} + + +async def test_to_jsonrpc_response_maps_mcp_error_to_jsonrpc_error() -> None: + """SDK-defined: an `MCPError` raised by the handler coroutine is wrapped as a + `JSONRPCError` whose `error` carries the same code, message, and data.""" + + async def fail() -> dict[str, Any]: + raise MCPError(code=METHOD_NOT_FOUND, message="nope", data="x") + + reply = await _to_jsonrpc_response("rid", fail()) + assert isinstance(reply, JSONRPCError) + assert reply.id == "rid" + assert reply.error == ErrorData(code=METHOD_NOT_FOUND, message="nope", data="x") + + +async def test_to_jsonrpc_response_maps_validation_error_to_invalid_params() -> None: + """SDK-defined: a pydantic `ValidationError` escaping the handler coroutine is + mapped to `INVALID_PARAMS` with a generic message (validator detail does not + reach the wire).""" + + async def fail() -> dict[str, Any]: + Tool.model_validate({"name": 123}) # raises ValidationError + raise NotImplementedError + + reply = await _to_jsonrpc_response(1, fail()) + assert isinstance(reply, JSONRPCError) + assert reply.error == ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="") + + +async def test_to_jsonrpc_response_maps_unmapped_exception_to_internal_error_and_logs( + caplog: pytest.LogCaptureFixture, +) -> None: + """SDK-defined: an unmapped exception is logged server-side and surfaced as + `INTERNAL_ERROR` with a generic message; the exception text never reaches the + wire.""" + + async def fail() -> dict[str, Any]: + raise RuntimeError("boom") + + reply = await _to_jsonrpc_response(1, fail()) + assert isinstance(reply, JSONRPCError) + assert reply.error.code == INTERNAL_ERROR + # Handler internals never reach the wire. + assert "boom" not in reply.error.message + assert "request handler raised" in caplog.text diff --git a/tests/shared/test_inbound.py b/tests/shared/test_inbound.py index 75ed93e99a..a8e275a4fa 100644 --- a/tests/shared/test_inbound.py +++ b/tests/shared/test_inbound.py @@ -17,11 +17,10 @@ InboundModernRoute, classify_inbound_request, ) -from mcp.shared.version import MODERN_PROTOCOL_VERSIONS +from mcp.shared.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS from mcp.types import ( CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, - LATEST_PROTOCOL_VERSION, PROTOCOL_VERSION_META_KEY, ) from mcp.types.jsonrpc import ( @@ -34,9 +33,6 @@ UNSUPPORTED_PROTOCOL_VERSION, ) -MODERN = MODERN_PROTOCOL_VERSIONS[0] -"""The modern protocol-version string, read from the registry — never inlined here.""" - CLIENT_INFO = {"name": "t", "version": "0"} CLIENT_CAPS: dict[str, Any] = {} @@ -44,7 +40,7 @@ def envelope( method: str = "tools/list", *, - version: str = MODERN, + version: str = LATEST_MODERN_VERSION, drop: frozenset[str] = frozenset(), ) -> dict[str, Any]: """Build a JSON-RPC body carrying a complete modern ``_meta`` envelope. @@ -108,23 +104,23 @@ def test_envelope_rung_rejects_non_mapping_shapes(body: dict[str, Any]) -> None: def test_version_rung_rejects_unsupported_with_data_shape() -> None: """Spec-mandated: an envelope version outside the modern set rejects with the ``supported``/``requested`` data.""" rejection = assert_rejected( - classify_inbound_request(envelope(version=LATEST_PROTOCOL_VERSION)), + classify_inbound_request(envelope(version=LATEST_HANDSHAKE_VERSION)), UNSUPPORTED_PROTOCOL_VERSION, ) assert rejection.data == { "supported": list(MODERN_PROTOCOL_VERSIONS), - "requested": LATEST_PROTOCOL_VERSION, + "requested": LATEST_HANDSHAKE_VERSION, } def test_version_rung_data_reflects_supplied_supported_list() -> None: """SDK-defined: the caller-supplied ``supported_modern_versions`` is what rejection ``data.supported`` echoes.""" - custom = (LATEST_PROTOCOL_VERSION,) + custom = (LATEST_HANDSHAKE_VERSION,) rejection = assert_rejected( classify_inbound_request(envelope(), supported_modern_versions=custom), UNSUPPORTED_PROTOCOL_VERSION, ) - assert rejection.data == {"supported": list(custom), "requested": MODERN} + assert rejection.data == {"supported": list(custom), "requested": LATEST_MODERN_VERSION} # --- rung 3: header ↔ envelope agreement --------------------------------------- @@ -138,14 +134,14 @@ def test_header_rung_does_not_reject_when_headers_arg_is_none() -> None: def test_header_rung_passes_when_header_matches_envelope() -> None: """Spec-mandated: an HTTP version header equal to the envelope version passes rung 3.""" - result = classify_inbound_request(envelope(), headers={MCP_PROTOCOL_VERSION_HEADER: MODERN}) + result = classify_inbound_request(envelope(), headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}) assert isinstance(result, InboundModernRoute) @pytest.mark.parametrize( "headers", [ - pytest.param({MCP_PROTOCOL_VERSION_HEADER: LATEST_PROTOCOL_VERSION}, id="mismatch"), + pytest.param({MCP_PROTOCOL_VERSION_HEADER: LATEST_HANDSHAKE_VERSION}, id="mismatch"), pytest.param({}, id="header-absent"), ], ) @@ -159,9 +155,9 @@ def test_header_rung_rejects_on_disagreement(headers: dict[str, str]) -> None: def test_all_rungs_pass_yields_route() -> None: """Spec-mandated: a complete envelope at a supported version with agreeing header routes, surfacing the envelope.""" - result = classify_inbound_request(envelope(), headers={MCP_PROTOCOL_VERSION_HEADER: MODERN}) + result = classify_inbound_request(envelope(), headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}) assert isinstance(result, InboundModernRoute) - assert result.protocol_version == MODERN + assert result.protocol_version == LATEST_MODERN_VERSION assert result.client_info == CLIENT_INFO assert result.client_capabilities == CLIENT_CAPS @@ -169,7 +165,7 @@ def test_all_rungs_pass_yields_route() -> None: @pytest.mark.parametrize("method", ["initialize", "myorg/custom", "does/not/exist"]) def test_classifier_passes_unknown_method_through_to_route(method: str) -> None: """SDK-defined: the classifier does not gate on method — kernel dispatch is the single owner of that decision.""" - result = classify_inbound_request(envelope(method), headers={MCP_PROTOCOL_VERSION_HEADER: MODERN}) + result = classify_inbound_request(envelope(method), headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}) assert isinstance(result, InboundModernRoute) @@ -177,8 +173,8 @@ def test_ladder_first_failure_wins() -> None: """Spec-mandated: rungs evaluate in order — header-mismatch and version-unsupported would both fail; the header rung fires first so an inconsistent client is told it disagrees with itself rather than that its body version is unsupported.""" - body = envelope(version=LATEST_PROTOCOL_VERSION) - result = classify_inbound_request(body, headers={MCP_PROTOCOL_VERSION_HEADER: MODERN}) + body = envelope(version=LATEST_HANDSHAKE_VERSION) + result = classify_inbound_request(body, headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}) assert_rejected(result, HEADER_MISMATCH) diff --git a/tests/shared/test_jsonrpc_dispatcher.py b/tests/shared/test_jsonrpc_dispatcher.py index 588c1dcc21..660b5cb3af 100644 --- a/tests/shared/test_jsonrpc_dispatcher.py +++ b/tests/shared/test_jsonrpc_dispatcher.py @@ -1755,6 +1755,39 @@ def test_plan_outbound_with_resumption_token_returns_client_metadata_and_suppres assert _plan_outbound(None, {}) == _OutboundPlan(metadata=None, cancel_on_abandon=True) +@pytest.mark.anyio +async def test_send_raw_request_projects_opts_headers_onto_message_metadata(): + """`opts["headers"]` alone yields `ClientMessageMetadata(headers=...)` on the outbound `SessionMessage` + (SDK-defined: the headers sidecar is the path the session uses to reach the transport).""" + c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) + s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) + client: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(s2c_recv, c2s_send) + on_request, on_notify = echo_handlers(Recorder()) + + try: + async with anyio.create_task_group() as tg: + await tg.start(client.run, on_request, on_notify) + + async def caller() -> None: + await client.send_raw_request("tools/list", None, {"headers": {"x-test": "v"}}) + + tg.start_soon(caller) + with anyio.fail_after(5): + outbound = await c2s_recv.receive() + assert isinstance(outbound, SessionMessage) + assert isinstance(outbound.message, JSONRPCRequest) + assert isinstance(outbound.metadata, ClientMessageMetadata) + assert outbound.metadata.headers == {"x-test": "v"} + assert outbound.metadata.resumption_token is None + await s2c_send.send( + SessionMessage(message=JSONRPCResponse(jsonrpc="2.0", id=outbound.message.id, result={})) + ) + tg.cancel_scope.cancel() + finally: + for s in (c2s_send, c2s_recv, s2c_send, s2c_recv): + s.close() + + @pytest.mark.anyio async def test_response_with_string_id_correlates_to_int_keyed_pending_request(): """A peer that echoes the request ID as a JSON string still resolves the waiter.""" diff --git a/tests/shared/test_peer.py b/tests/shared/test_peer.py index d17af88520..89e931b3b1 100644 --- a/tests/shared/test_peer.py +++ b/tests/shared/test_peer.py @@ -185,7 +185,7 @@ async def send_raw_request( ) -> dict[str, Any]: raise NotImplementedError - async def notify(self, method: str, params: Mapping[str, Any] | None) -> None: + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: Any = None) -> None: sent.append((method, params)) await ClientPeer(_Out()).notify("n", {"x": 1}) diff --git a/tests/shared/test_version.py b/tests/shared/test_version.py index baffa032fe..595bb03bc0 100644 --- a/tests/shared/test_version.py +++ b/tests/shared/test_version.py @@ -3,8 +3,9 @@ import pytest from mcp.shared.version import ( + HANDSHAKE_PROTOCOL_VERSIONS, KNOWN_PROTOCOL_VERSIONS, - SUPPORTED_PROTOCOL_VERSIONS, + MODERN_PROTOCOL_VERSIONS, is_version_at_least, ) @@ -51,7 +52,8 @@ def test_is_version_at_least_matches_lexicographic_for_known_versions(version: s def test_supported_versions_are_known() -> None: """Every negotiable revision must be in the ordering registry.""" - assert set(SUPPORTED_PROTOCOL_VERSIONS) <= set(KNOWN_PROTOCOL_VERSIONS) + assert set(HANDSHAKE_PROTOCOL_VERSIONS) <= set(KNOWN_PROTOCOL_VERSIONS) + assert set(MODERN_PROTOCOL_VERSIONS) <= set(KNOWN_PROTOCOL_VERSIONS) def test_known_versions_are_strictly_ordered() -> None: diff --git a/uv.lock b/uv.lock index 7970d1cc2d..e0dddba350 100644 --- a/uv.lock +++ b/uv.lock @@ -10,6 +10,7 @@ resolution-markers = [ members = [ "mcp", "mcp-everything-server", + "mcp-example-stories", "mcp-simple-auth", "mcp-simple-auth-client", "mcp-simple-chatbot", @@ -941,6 +942,7 @@ dev = [ { name = "inline-snapshot" }, { name = "logfire" }, { name = "mcp", extra = ["cli"] }, + { name = "mcp-example-stories" }, { name = "opentelemetry-sdk" }, { name = "pillow" }, { name = "pyright" }, @@ -951,6 +953,7 @@ dev = [ { name = "pytest-xdist" }, { name = "ruff" }, { name = "strict-no-cover" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "trio" }, ] docs = [ @@ -995,6 +998,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" }, @@ -1005,6 +1009,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 = [ @@ -1041,7 +1046,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, + { name = "mcp" }, { name = "starlette" }, { name = "uvicorn" }, ] @@ -1053,6 +1058,17 @@ dev = [ { name = "ruff", specifier = ">=0.6.9" }, ] +[[package]] +name = "mcp-example-stories" +version = "0.0.0" +source = { editable = "examples" } +dependencies = [ + { name = "mcp" }, +] + +[package.metadata] +requires-dist = [{ name = "mcp", editable = "." }] + [[package]] name = "mcp-simple-auth" version = "0.1.0" @@ -1080,7 +1096,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, + { name = "mcp" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pydantic-settings", specifier = ">=2.5.2" }, { name = "sse-starlette", specifier = ">=1.6.1" }, @@ -1113,7 +1129,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.2.0" }, - { name = "mcp", editable = "." }, + { name = "mcp" }, ] [package.metadata.requires-dev] @@ -1142,7 +1158,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "mcp", editable = "." }, + { name = "mcp" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "uvicorn", specifier = ">=0.32.1" }, ] @@ -1177,7 +1193,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, + { name = "mcp" }, ] [package.metadata.requires-dev] @@ -1210,7 +1226,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, + { name = "mcp" }, ] [package.metadata.requires-dev] @@ -1243,7 +1259,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, + { name = "mcp" }, ] [package.metadata.requires-dev] @@ -1278,7 +1294,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, + { name = "mcp" }, { name = "starlette" }, { name = "uvicorn" }, ] @@ -1315,7 +1331,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, + { name = "mcp" }, { name = "starlette" }, { name = "uvicorn" }, ] @@ -1350,7 +1366,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, + { name = "mcp" }, ] [package.metadata.requires-dev] @@ -1369,7 +1385,7 @@ dependencies = [ ] [package.metadata] -requires-dist = [{ name = "mcp", editable = "." }] +requires-dist = [{ name = "mcp" }] [[package]] name = "mcp-sse-polling-client" @@ -1390,7 +1406,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.2.0" }, - { name = "mcp", editable = "." }, + { name = "mcp" }, ] [package.metadata.requires-dev] @@ -1425,7 +1441,7 @@ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "click", specifier = ">=8.2.0" }, { name = "httpx", specifier = ">=0.27" }, - { name = "mcp", editable = "." }, + { name = "mcp" }, { name = "starlette" }, { name = "uvicorn" }, ] @@ -1446,7 +1462,7 @@ dependencies = [ ] [package.metadata] -requires-dist = [{ name = "mcp", editable = "." }] +requires-dist = [{ name = "mcp" }] [[package]] name = "mdurl"