From 587340279efd05d067ac7abf109d30adcbbde241 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:51:59 +0200 Subject: [PATCH 001/100] =?UTF-8?q?Conformance=20burn-down:=20server-side?= =?UTF-8?q?=20InputRequiredResult,=20Mcp-Method/Name=20validation,=20x-mcp?= =?UTF-8?q?-header=20filter=20(14=20scenarios=20=E2=86=92=20green)=20(#297?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/actions/conformance/client.py | 89 +++++- .../expected-failures.2026-07-28.yml | 37 +-- .../actions/conformance/expected-failures.yml | 28 +- .github/workflows/conformance.yml | 36 +-- docs/migration.md | 3 +- .../mcp_everything_server/server.py | 241 ++++++++++++++- src/mcp/client/session.py | 18 +- src/mcp/server/_streamable_http_modern.py | 6 +- src/mcp/server/mcpserver/context.py | 32 +- src/mcp/server/mcpserver/server.py | 11 +- .../mcpserver/utilities/func_metadata.py | 45 ++- src/mcp/shared/inbound.py | 205 +++++++++++-- tests/client/test_client.py | 32 ++ .../transports/test_hosting_http.py | 2 +- tests/server/mcpserver/test_func_metadata.py | 101 ++++++- tests/server/mcpserver/test_server.py | 102 +++++++ tests/server/test_streamable_http_modern.py | 41 ++- tests/shared/test_inbound.py | 284 +++++++++++++++++- 18 files changed, 1163 insertions(+), 150 deletions(-) diff --git a/.github/actions/conformance/client.py b/.github/actions/conformance/client.py index 2d2acf9304..4a57d5aeee 100644 --- a/.github/actions/conformance/client.py +++ b/.github/actions/conformance/client.py @@ -20,7 +20,9 @@ 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) + http-invalid-tool-headers - List tools, call every surfaced tool (x-mcp-header filter) elicitation-sep1034-client-defaults - Elicitation with default accept callback + sep-2322-client-request-state - Drive the manual MRTR retry surface auth/client-credentials-jwt - Client credentials with private_key_jwt auth/client-credentials-basic - Client credentials with client_secret_basic auth/* - Authorization code flow (default for auth scenarios) @@ -296,6 +298,43 @@ async def run_http_standard_headers(server_url: str) -> None: logger.debug(f"add_numbers result: {result}") +def _stub_required_args(input_schema: dict[str, Any]) -> dict[str, Any]: + """Minimal arguments satisfying a tool inputSchema's required list.""" + by_type: dict[str, Any] = { + "string": "x", + "integer": 0, + "number": 0, + "boolean": False, + "object": {}, + "array": [], + "null": None, + } + properties = input_schema.get("properties", {}) + return {name: by_type.get(properties.get(name, {}).get("type"), "x") for name in input_schema.get("required", [])} + + +@register("http-invalid-tool-headers") +async def run_http_invalid_tool_headers(server_url: str) -> None: + """List tools, then call every tool the SDK surfaces (SEP-2243). + + The harness mock advertises one valid tool plus several with malformed + x-mcp-header annotations (empty, non-primitive type, duplicate, invalid + chars). The scenario passes if valid_tool is called and the malformed + ones are not -- so a conforming client filters them out of the list_tools + result and the loop below never sees them. The scenario sets + allowClientError, so a per-call failure is logged and skipped rather + than aborting the whole run. + """ + async with Client(server_url, mode=client_mode()) as client: + listed = await client.list_tools() + logger.debug(f"Surfaced tools: {[t.name for t in listed.tools]}") + for tool in listed.tools: + try: + await client.call_tool(tool.name, _stub_required_args(tool.input_schema)) + except Exception: + logger.exception(f"call_tool({tool.name!r}) failed") + + @register("elicitation-sep1034-client-defaults") async def run_elicitation_defaults(server_url: str) -> None: """Connect with elicitation callback that applies schema defaults.""" @@ -305,6 +344,53 @@ async def run_elicitation_defaults(server_url: str) -> None: logger.debug(f"test_client_elicitation_defaults result: {result}") +@register("sep-2322-client-request-state") +async def run_mrtr_client(server_url: str) -> None: + """Drive the manual MRTR retry surface against the SEP-2322 client mock. + + The mock speaks the modern lifecycle (server/discover, no initialize) and + inspects the wire params of each tools/call round, so this exercises the + explicit allow_input_required=True path rather than an auto-loop: round 1 + receives an InputRequiredResult, the fixture fulfils the elicitation + locally, then round 2 retries with input_responses + the echoed + request_state. Passing request_state straight off the typed result -- a + str when the server sent one, None when it didn't -- lets the + serializer's exclude_none drop the key in the no-state case without a + branch here. The unrelated call between rounds proves MRTR params don't + leak across tools, and the no-result-type call must parse as a complete + CallToolResult with no retry. + """ + async with Client(server_url, mode=client_mode()) as client: + await client.list_tools() + confirm = {"confirm": types.ElicitResult(action="accept", content={"confirmed": True})} + + r1 = await client.call_tool("test_mrtr_echo_state", {}, allow_input_required=True) + assert isinstance(r1, types.InputRequiredResult) + + await client.call_tool("test_mrtr_unrelated", {}) + + await client.call_tool( + "test_mrtr_echo_state", + {}, + input_responses=confirm, + request_state=r1.request_state, + allow_input_required=True, + ) + + r2 = await client.call_tool("test_mrtr_no_state", {}, allow_input_required=True) + assert isinstance(r2, types.InputRequiredResult) + await client.call_tool( + "test_mrtr_no_state", + {}, + input_responses=confirm, + request_state=r2.request_state, + allow_input_required=True, + ) + + result = await client.call_tool("test_mrtr_no_result_type", {}) + assert isinstance(result, types.CallToolResult) + + @register("auth/client-credentials-jwt") async def run_client_credentials_jwt(server_url: str) -> None: """Client credentials flow with private_key_jwt authentication.""" @@ -441,8 +527,7 @@ def main() -> None: 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) + # - http-custom-headers (SEP-2243 / S8: Mcp-Param-* emission) 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 529eb8babe..a4b4f44806 100644 --- a/.github/actions/conformance/expected-failures.2026-07-28.yml +++ b/.github/actions/conformance/expected-failures.2026-07-28.yml @@ -21,48 +21,19 @@ # milestone. client: - # --- Same gaps as the 2025 baseline (fail identically when forced to 2026-07-28) --- - # 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 Mcp-Param-* support yet. + # SEP-2243 (HTTP standardization): no client Mcp-Param-* support yet — needs the + # tool-schema-cache vs per-call tool_definition design (S8). - http-custom-headers - - http-invalid-tool-headers # 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 # neither run nor evaluated on this leg. server: - # --- Carried-forward 2025-era scenarios still failing on the 2026 wire --- # The stateless 2026 path now reaches handlers for plain request/response # scenarios; tools-call-with-progress still fails because the stateless # server has no channel for server→client progress notifications. - tools-call-with-progress - # SEP-2106 (JSON Schema 2020-12 in tool inputSchema): the fixture tool's - # schema has none of the 2020-12 keywords the scenario checks. The scenario - # is in `--suite all` but not `--suite active`, so this is the only leg that - # runs it; it fails identically at 2025-11-25 (not a 2026-path regression). - - json-schema-2020-12 - - # --- Draft scenarios (same failures and reasons as the `--suite draft` leg) --- - # SEP-2322 (multi-round-trip requests / IncompleteResult): not implemented. - - input-required-result-basic-elicitation - - input-required-result-basic-sampling - - input-required-result-basic-list-roots - - input-required-result-request-state - - input-required-result-multiple-input-requests - - input-required-result-multi-round + # SEP-2322 (multi-round-trip requests / IncompleteResult): the prompt pipeline + # cannot return InputRequiredResult from MCPServer yet (tools/call can). - input-required-result-non-tool-request - - input-required-result-result-type - - input-required-result-tampered-state - - input-required-result-capability-check - # SEP-2243 (HTTP header standardization): Mcp-Method / Mcp-Name cross-check - # against the request body is not implemented. - - http-header-validation - # WARNING-only entries: these scenarios emit no FAILURE checks but the - # expected-failures evaluator counts WARNINGs as failures (the summary line - # only shows passed/failed, not warnings, so a local re-probe can mis-read - # these as stale). - - input-required-result-missing-input-response - - input-required-result-validate-input diff --git a/.github/actions/conformance/expected-failures.yml b/.github/actions/conformance/expected-failures.yml index 2a411b4cde..cb59dba029 100644 --- a/.github/actions/conformance/expected-failures.yml +++ b/.github/actions/conformance/expected-failures.yml @@ -12,12 +12,9 @@ client: # --- Draft-spec scenarios (in `--suite draft`, also part of `--suite all`) --- - # 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 Mcp-Param-* support yet. + # SEP-2243 (HTTP standardization): no client Mcp-Param-* support yet — needs the + # tool-schema-cache vs per-call tool_definition design (S8). - http-custom-headers - - http-invalid-tool-headers # --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 --- # SEP-990 (enterprise-managed authorization extension): no fixture handler / @@ -26,23 +23,6 @@ client: server: # --- Draft-spec scenarios (in `--suite draft`; the `active` suite is green) --- - # SEP-2322 (multi-round-trip requests / IncompleteResult): not implemented. - - input-required-result-basic-elicitation - - input-required-result-basic-sampling - - input-required-result-basic-list-roots - - input-required-result-request-state - - input-required-result-multiple-input-requests - - input-required-result-multi-round + # SEP-2322 (multi-round-trip requests / IncompleteResult): the prompt pipeline + # cannot return InputRequiredResult from MCPServer yet (tools/call can). - input-required-result-non-tool-request - - input-required-result-result-type - - input-required-result-tampered-state - - input-required-result-capability-check - # SEP-2243 (HTTP header standardization): Mcp-Method / Mcp-Name cross-check - # against the request body is not implemented. - - http-header-validation - # WARNING-only entries: these scenarios emit no FAILURE checks but the - # expected-failures evaluator counts WARNINGs as failures (the summary line - # only shows passed/failed, not warnings, so a local re-probe can mis-read - # these as stale). - - input-required-result-missing-input-response - - input-required-result-validate-input diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index e985a52f6b..9f5ce489fe 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -15,15 +15,10 @@ permissions: env: # Pinned conformance harness package spec (passed verbatim to `npx --yes`). - # Use a published version, e.g. @modelcontextprotocol/conformance@0.2.0-alpha.5. + # Use a published version, e.g. @modelcontextprotocol/conformance@0.2.0-alpha.7. # Bump deliberately and reconcile both # .github/actions/conformance/expected-failures*.yml files in the same change. - # - # TODO: replace with @modelcontextprotocol/conformance@0.2.0-alpha.5 once - # https://github.com/modelcontextprotocol/conformance/pull/357 publishes, and - # drop CONFORMANCE_PKG_SHA256 plus the fetch-and-verify step below. - CONFORMANCE_PKG: "https://pkg.pr.new/@modelcontextprotocol/conformance@65fcd39" - CONFORMANCE_PKG_SHA256: "9a381d7083f8be2fe7ae44efeca54530f18c61425805ddaf9cd88915efcc1574" + CONFORMANCE_PKG: "@modelcontextprotocol/conformance@0.2.0-alpha.7" jobs: server-conformance: @@ -39,19 +34,6 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 - - name: Fetch and verify conformance harness - # Only when CONFORMANCE_PKG is a URL: download, check the recorded - # sha256, and re-point CONFORMANCE_PKG at the verified local tarball. - # When CONFORMANCE_PKG is a registry spec, this step is a no-op (npm's - # own integrity check applies). - run: | - case "$CONFORMANCE_PKG" in - https://*) - curl -fsSL "$CONFORMANCE_PKG" -o /tmp/conformance.tgz - echo "$CONFORMANCE_PKG_SHA256 /tmp/conformance.tgz" | sha256sum -c - - echo "CONFORMANCE_PKG=file:/tmp/conformance.tgz" >> "$GITHUB_ENV" - ;; - esac - run: uv sync --frozen --all-extras --package mcp-everything-server - name: Run server conformance (active suite) run: >- @@ -83,26 +65,22 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 - - name: Fetch and verify conformance harness - run: | - case "$CONFORMANCE_PKG" in - https://*) - curl -fsSL "$CONFORMANCE_PKG" -o /tmp/conformance.tgz - echo "$CONFORMANCE_PKG_SHA256 /tmp/conformance.tgz" | sha256sum -c - - echo "CONFORMANCE_PKG=file:/tmp/conformance.tgz" >> "$GITHUB_ENV" - ;; - esac - run: uv sync --frozen --all-extras --package mcp - name: Run client conformance (all suite) + # The harness runs all scenarios via unbounded Promise.all; with 40 + # scenarios on a 2-core runner the slowest one (sse-retry, which has a + # real-time SSE reconnect wait) needs more than the 30s default budget. run: >- npx --yes "$CONFORMANCE_PKG" client --command 'uv run --frozen python .github/actions/conformance/client.py' --suite all + --timeout 60000 --expected-failures ./.github/actions/conformance/expected-failures.yml - name: Run client conformance (2026-07-28 wire, all suite) run: >- npx --yes "$CONFORMANCE_PKG" client --command 'uv run --frozen python .github/actions/conformance/client.py' --suite all + --timeout 60000 --spec-version 2026-07-28 --expected-failures ./.github/actions/conformance/expected-failures.2026-07-28.yml diff --git a/docs/migration.md b/docs/migration.md index e977ce4a21..7598b52022 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -10,7 +10,8 @@ Version 2 of the MCP Python SDK introduces several breaking changes to improve t ### `MCPServer.call_tool()` returns `CallToolResult` -`MCPServer.call_tool()` now always returns a `CallToolResult`. It previously +`MCPServer.call_tool()` now returns a `CallToolResult` (or an +`InputRequiredResult` when a multi-round tool requests further input). It previously advertised `Sequence[ContentBlock] | dict[str, Any]` and leaked the internal conversion shapes (a bare content sequence or a `(content, structured_content)` tuple), forcing callers to re-assemble a `CallToolResult` themselves. diff --git a/examples/servers/everything-server/mcp_everything_server/server.py b/examples/servers/everything-server/mcp_everything_server/server.py index c43b6735c3..f622aac7a3 100644 --- a/examples/servers/everything-server/mcp_everything_server/server.py +++ b/examples/servers/everything-server/mcp_everything_server/server.py @@ -6,8 +6,12 @@ import asyncio import base64 +import binascii +import hashlib +import hmac import json import logging +from typing import Any import click from mcp.server import ServerRequestContext @@ -20,10 +24,20 @@ Completion, CompletionArgument, CompletionContext, + CreateMessageRequest, + CreateMessageRequestParams, + CreateMessageResult, + ElicitRequest, + ElicitRequestFormParams, + ElicitResult, EmbeddedResource, EmptyResult, ImageContent, + InputRequest, + InputRequiredResult, JSONRPCMessage, + ListRootsRequest, + ListRootsResult, PromptReference, ResourceTemplateReference, SamplingMessage, @@ -33,7 +47,7 @@ TextResourceContents, UnsubscribeRequestParams, ) -from mcp_types.jsonrpc import MISSING_REQUIRED_CLIENT_CAPABILITY +from mcp_types.jsonrpc import INVALID_PARAMS, MISSING_REQUIRED_CLIENT_CAPABILITY from pydantic import BaseModel, Field logger = logging.getLogger(__name__) @@ -333,6 +347,231 @@ async def test_missing_capability(ctx: Context) -> str: return "Client declared sampling capability; proceeding." +# SEP-2322 InputRequiredResult fixtures (multi-round-trip / ephemeral workflow) + +NAME_SCHEMA = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]} + + +def _name_elicitation(message: str = "What is your name?") -> ElicitRequest: + return ElicitRequest(params=ElicitRequestFormParams(message=message, requested_schema=NAME_SCHEMA)) + + +@mcp.tool() +async def test_input_required_result_elicitation(ctx: Context) -> str | InputRequiredResult: + """Tests InputRequiredResult with a single elicitation request""" + responses = ctx.input_responses + if responses and "user_name" in responses: + answer = responses["user_name"] + name = answer.content.get("name", "stranger") if isinstance(answer, ElicitResult) and answer.content else "?" + return f"Hello, {name}!" + return InputRequiredResult(input_requests={"user_name": _name_elicitation()}) + + +@mcp.tool() +async def test_input_required_result_sampling(ctx: Context) -> str | InputRequiredResult: + """Tests InputRequiredResult with a single sampling request""" + responses = ctx.input_responses + if responses and "capital_question" in responses: + answer = responses["capital_question"] + text = answer.content.text if isinstance(answer, CreateMessageResult) and answer.content.type == "text" else "?" + return f"Model said: {text}" + return InputRequiredResult( + input_requests={ + "capital_question": CreateMessageRequest( + params=CreateMessageRequestParams( + messages=[ + SamplingMessage( + role="user", content=TextContent(type="text", text="What is the capital of France?") + ) + ], + max_tokens=100, + ) + ) + } + ) + + +@mcp.tool() +async def test_input_required_result_list_roots(ctx: Context) -> str | InputRequiredResult: + """Tests InputRequiredResult with a single roots/list request""" + responses = ctx.input_responses + if responses and "client_roots" in responses: + answer = responses["client_roots"] + count = len(answer.roots) if isinstance(answer, ListRootsResult) else 0 + return f"Client exposed {count} root(s)." + return InputRequiredResult(input_requests={"client_roots": ListRootsRequest()}) + + +@mcp.tool() +async def test_input_required_result_request_state(ctx: Context) -> str | InputRequiredResult: + """Tests requestState round-tripping in the InputRequiredResult flow""" + responses = ctx.input_responses + if responses and "confirm" in responses and ctx.request_state == "request-state-nonce": + return "state-ok: confirmation received" + confirm = ElicitRequest( + params=ElicitRequestFormParams( + message="Please confirm", + requested_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}, "required": ["ok"]}, + ) + ) + return InputRequiredResult(input_requests={"confirm": confirm}, request_state="request-state-nonce") + + +@mcp.tool() +async def test_input_required_result_multiple_inputs(ctx: Context) -> str | InputRequiredResult: + """Tests InputRequiredResult carrying elicitation, sampling and roots requests together""" + responses = ctx.input_responses + if responses and {"user_name", "greeting", "client_roots"} <= responses.keys(): + return "All inputs received." + return InputRequiredResult( + input_requests={ + "user_name": _name_elicitation(), + "greeting": CreateMessageRequest( + params=CreateMessageRequestParams( + messages=[ + SamplingMessage(role="user", content=TextContent(type="text", text="Generate a greeting")) + ], + max_tokens=50, + ) + ), + "client_roots": ListRootsRequest(), + }, + request_state="multiple-inputs", + ) + + +@mcp.tool() +async def test_input_required_result_multi_round(ctx: Context) -> str | InputRequiredResult: + """Tests a three-round InputRequiredResult flow with evolving requestState""" + state = json.loads(ctx.request_state) if ctx.request_state else {"round": 0} + responses = ctx.input_responses or {} + + if state["round"] == 0: + return InputRequiredResult( + input_requests={"step1": _name_elicitation("Step 1: What is your name?")}, + request_state=json.dumps({"round": 1}), + ) + + if state["round"] == 1 and "step1" in responses: + step1 = responses["step1"] + name = step1.content.get("name") if isinstance(step1, ElicitResult) and step1.content else None + color_schema = {"type": "object", "properties": {"color": {"type": "string"}}, "required": ["color"]} + return InputRequiredResult( + input_requests={ + "step2": ElicitRequest( + params=ElicitRequestFormParams( + message="Step 2: What is your favorite color?", requested_schema=color_schema + ) + ) + }, + request_state=json.dumps({"round": 2, "name": name}), + ) + + if state["round"] == 2 and "step2" in responses: + step2 = responses["step2"] + color = step2.content.get("color") if isinstance(step2, ElicitResult) and step2.content else None + return f"{state.get('name')} likes {color}." + + # Missing or out-of-order response: re-request from the start. + return InputRequiredResult( + input_requests={"step1": _name_elicitation("Step 1: What is your name?")}, + request_state=json.dumps({"round": 1}), + ) + + +# Fixed key for the conformance fixture; a real server would derive or rotate this. +_STATE_HMAC_KEY = b"everything-server-fixture-key" + + +def _seal_state(payload: str) -> str: + encoded = base64.urlsafe_b64encode(payload.encode()).decode() + sig = hmac.new(_STATE_HMAC_KEY, encoded.encode(), hashlib.sha256).hexdigest() + return f"{encoded}.{sig}" + + +def _unseal_state(state: str) -> str: + encoded, _, sig = state.partition(".") + expected = hmac.new(_STATE_HMAC_KEY, encoded.encode(), hashlib.sha256).hexdigest() + if not sig or not hmac.compare_digest(sig, expected): + raise MCPError(code=INVALID_PARAMS, message="requestState failed integrity verification") + try: + return base64.urlsafe_b64decode(encoded).decode() + except (binascii.Error, UnicodeDecodeError) as e: + raise MCPError(code=INVALID_PARAMS, message="requestState failed integrity verification") from e + + +@mcp.tool() +async def test_input_required_result_tampered_state(ctx: Context) -> str | InputRequiredResult: + """Tests that the server rejects a requestState that fails HMAC verification""" + if ctx.request_state is None: + confirm = ElicitRequest( + params=ElicitRequestFormParams( + message="Please confirm", + requested_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}, "required": ["ok"]}, + ) + ) + return InputRequiredResult(input_requests={"confirm": confirm}, request_state=_seal_state("round-1")) + payload = _unseal_state(ctx.request_state) + return f"state-ok: {payload}" + + +@mcp.tool() +async def test_input_required_result_capabilities(ctx: Context) -> InputRequiredResult: + """Tests that inputRequests only include methods the client declared support for""" + caps = ctx.client_capabilities + requests: dict[str, InputRequest] = {} + if caps is None or caps.sampling is not None: + requests["sample"] = CreateMessageRequest( + params=CreateMessageRequestParams( + messages=[SamplingMessage(role="user", content=TextContent(type="text", text="Say hello"))], + max_tokens=50, + ) + ) + if caps is None or caps.elicitation is not None: + requests["ask"] = _name_elicitation() + return InputRequiredResult(input_requests=requests, request_state="capability-gated") + + +# SEP-1613 / SEP-2106 JSON Schema 2020-12 fixture: a tool whose inputSchema carries +# the full set of 2020-12 keywords the conformance scenario asserts on. + +JSON_SCHEMA_2020_12_INPUT_SCHEMA: dict[str, Any] = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "$defs": { + "address": { + "$anchor": "addressDef", + "type": "object", + "properties": {"street": {"type": "string"}, "city": {"type": "string"}}, + } + }, + "properties": { + "name": {"type": "string"}, + "address": {"$ref": "#/$defs/address"}, + "contactMethod": {"type": "string", "enum": ["phone", "email"]}, + "phone": {"type": "string"}, + "email": {"type": "string"}, + }, + "allOf": [{"anyOf": [{"required": ["phone"]}, {"required": ["email"]}]}], + "if": {"properties": {"contactMethod": {"const": "phone"}}, "required": ["contactMethod"]}, + "then": {"required": ["phone"]}, + "else": {"required": ["email"]}, + "additionalProperties": False, +} + + +@mcp.tool(name="json_schema_2020_12_tool") +def json_schema_2020_12_tool() -> str: + """Tests JSON Schema 2020-12 keyword preservation in tools/list (inputSchema installed below).""" + return "json_schema_2020_12_tool" + + +# TODO(felix): replace with a public input_schema= override once MCPServer.tool() grows one. +mcp._tool_manager._tools["json_schema_2020_12_tool"].parameters = ( # pyright: ignore[reportPrivateUsage] + JSON_SCHEMA_2020_12_INPUT_SCHEMA +) + + @mcp.tool() async def test_reconnection(ctx: Context) -> str: """Tests SSE polling by closing stream mid-call (SEP-1699)""" diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 591223250d..0c6e0270c1 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -38,7 +38,9 @@ MCP_METHOD_HEADER, MCP_NAME_HEADER, MCP_PROTOCOL_VERSION_HEADER, + NAME_BEARING_METHODS, encode_header_value, + find_invalid_x_mcp_header, ) from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher from mcp.shared.message import ClientMessageMetadata, SessionMessage @@ -78,8 +80,8 @@ def stamp(data: dict[str, Any], opts: CallOptions) -> None: 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): + name_key = NAME_BEARING_METHODS.get(data["method"]) + if name_key is not None and isinstance(name := params.get(name_key), str): headers[MCP_NAME_HEADER] = encode_header_value(name) return stamp @@ -429,7 +431,7 @@ async def send_discover(self, version: str) -> dict[str, Any]: opts: CallOptions = { "timeout": DISCOVER_TIMEOUT_SECONDS, "cancel_on_abandon": False, - "headers": {MCP_PROTOCOL_VERSION_HEADER: version}, + "headers": {MCP_PROTOCOL_VERSION_HEADER: version, MCP_METHOD_HEADER: data["method"]}, } return await self._dispatcher.send_raw_request(data["method"], data.get("params"), opts) @@ -759,6 +761,16 @@ async def list_tools(self, *, params: types.PaginatedRequestParams | None = None types.ListToolsResult, ) + if self._negotiated_version in MODERN_PROTOCOL_VERSIONS: + # 2026-07-28: clients MUST drop tools whose x-mcp-header annotations are invalid. + kept: list[types.Tool] = [] + for tool in result.tools: + if (reason := find_invalid_x_mcp_header(tool.input_schema)) is not None: + logger.warning("dropping tool %r: invalid x-mcp-header (%s)", tool.name, reason) + continue + kept.append(tool) + result.tools = kept + # Cache tool output schemas for future validation # Note: don't clear the cache, as we may be using a cursor for tool in result.tools: diff --git a/src/mcp/server/_streamable_http_modern.py b/src/mcp/server/_streamable_http_modern.py index 9a42d64dda..cecf21f08e 100644 --- a/src/mcp/server/_streamable_http_modern.py +++ b/src/mcp/server/_streamable_http_modern.py @@ -41,7 +41,11 @@ from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings 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.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 diff --git a/src/mcp/server/mcpserver/context.py b/src/mcp/server/mcpserver/context.py index f703e760fb..aeb91fdfe4 100644 --- a/src/mcp/server/mcpserver/context.py +++ b/src/mcp/server/mcpserver/context.py @@ -3,7 +3,7 @@ from collections.abc import Iterable from typing import TYPE_CHECKING, Any, Generic -from mcp_types import LoggingLevel +from mcp_types import ClientCapabilities, InputResponseRequestParams, InputResponses, LoggingLevel from pydantic import AnyUrl, BaseModel from typing_extensions import deprecated @@ -58,6 +58,7 @@ async def my_tool(x: int, ctx: Context) -> str: _request_context: ServerRequestContext[LifespanContextT, RequestT] | None _mcp_server: MCPServer | None + _input_params: InputResponseRequestParams | None # TODO(maxisbey): Consider making request_context/mcp_server required, or refactor Context entirely. def __init__( @@ -65,12 +66,14 @@ def __init__( *, request_context: ServerRequestContext[LifespanContextT, RequestT] | None = None, mcp_server: MCPServer | None = None, + input_params: InputResponseRequestParams | None = None, # TODO(Marcelo): We should drop this kwargs parameter. **kwargs: Any, ): super().__init__(**kwargs) self._request_context = request_context self._mcp_server = mcp_server + self._input_params = input_params @property def mcp_server(self) -> MCPServer: @@ -219,6 +222,33 @@ def request_id(self) -> str: """Get the unique ID for this request.""" return str(self.request_context.request_id) + @property + def input_responses(self) -> InputResponses | None: + """Client responses to a prior `InputRequiredResult.input_requests`. + + `None` on the initial round, or when the client retried without + responses. + """ + return self._input_params.input_responses if self._input_params else None + + @property + def request_state(self) -> str | None: + """Opaque state echoed from a prior `InputRequiredResult.request_state`. + + `None` on the initial round. + """ + return self._input_params.request_state if self._input_params else None + + @property + def client_capabilities(self) -> ClientCapabilities | None: + """The client's declared capabilities for this connection. + + `None` when the client supplied no client info (e.g. an anonymous + stateless request without the reserved `_meta` keys). + """ + client_params = self.request_context.session.client_params + return client_params.capabilities if client_params else None + @property def session(self): """Access to the underlying session for advanced usage.""" diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 15308eefd7..67c81c18a6 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -24,6 +24,7 @@ GetPromptRequestParams, GetPromptResult, Icon, + InputRequiredResult, ListPromptsResult, ListResourcesResult, ListResourceTemplatesResult, @@ -306,8 +307,8 @@ async def _handle_list_tools( async def _handle_call_tool( self, ctx: ServerRequestContext[LifespanResultT], params: CallToolRequestParams - ) -> CallToolResult: - context = Context(request_context=ctx, mcp_server=self) + ) -> CallToolResult | InputRequiredResult: + context = Context(request_context=ctx, mcp_server=self, input_params=params) try: return await self.call_tool(params.name, params.arguments or {}, context) except MCPError: @@ -323,7 +324,7 @@ async def _handle_list_resources( async def _handle_read_resource( self, ctx: ServerRequestContext[LifespanResultT], params: ReadResourceRequestParams ) -> ReadResourceResult: - context = Context(request_context=ctx, mcp_server=self) + context = Context(request_context=ctx, mcp_server=self, input_params=params) try: results = await self.read_resource(params.uri, context) except ResourceNotFoundError as err: @@ -365,7 +366,7 @@ async def _handle_list_prompts( async def _handle_get_prompt( self, ctx: ServerRequestContext[LifespanResultT], params: GetPromptRequestParams ) -> GetPromptResult: - context = Context(request_context=ctx, mcp_server=self) + context = Context(request_context=ctx, mcp_server=self, input_params=params) return await self.get_prompt(params.name, params.arguments, context) async def list_tools(self) -> list[MCPTool]: @@ -387,7 +388,7 @@ async def list_tools(self) -> list[MCPTool]: async def call_tool( self, name: str, arguments: dict[str, Any], context: Context[LifespanResultT, Any] | None = None - ) -> CallToolResult: + ) -> CallToolResult | InputRequiredResult: """Call a tool by name with arguments.""" if context is None: context = Context(mcp_server=self) diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index d0c679c053..97eb3909ed 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -4,12 +4,12 @@ from collections.abc import Awaitable, Callable, Sequence from itertools import chain from types import GenericAlias -from typing import Annotated, Any, cast, get_args, get_origin, get_type_hints +from typing import Annotated, Any, Union, cast, get_args, get_origin, get_type_hints import anyio import anyio.to_thread import pydantic_core -from mcp_types import CallToolResult, ContentBlock, TextContent +from mcp_types import CallToolResult, ContentBlock, InputRequiredResult, TextContent from pydantic import BaseModel, ConfigDict, Field, PydanticUserError, WithJsonSchema, create_model from pydantic.fields import FieldInfo from pydantic.json_schema import GenerateJsonSchema, JsonSchemaWarningKind @@ -29,6 +29,10 @@ logger = get_logger(__name__) +def _is_input_required_type(obj: Any) -> bool: + return isinstance(obj, type) and issubclass(obj, InputRequiredResult) + + class StrictJsonSchema(GenerateJsonSchema): """A JSON schema generator that raises exceptions instead of emitting warnings. @@ -88,9 +92,13 @@ async def call_fn_with_arg_validation( else: return await anyio.to_thread.run_sync(functools.partial(fn, **arguments_parsed_dict)) - def convert_result(self, result: Any) -> CallToolResult: + def convert_result(self, result: Any) -> CallToolResult | InputRequiredResult: """Convert a function call result into a `CallToolResult`. + An `InputRequiredResult` is passed through unchanged so the multi-round + flow surfaces on the wire as `resultType: "input_required"` rather than + being JSON-dumped into a text block. + Note: we build unstructured content here **even though the lowlevel server tool call handler provides generic backwards compatibility serialization of structured content**. This is for MCPServer backwards compatibility: we need to @@ -98,6 +106,8 @@ def convert_result(self, result: Any) -> CallToolResult: from function return values, whereas the lowlevel server simply serializes the structured output. """ + if isinstance(result, InputRequiredResult): + return result if isinstance(result, CallToolResult): if self.output_schema is not None: assert self.output_model is not None, "Output model must be set if output schema is defined" @@ -266,10 +276,33 @@ def func_metadata( # unknown (i.e. a bare `Final`). assert return_type_expr is not UNKNOWN + if _is_input_required_type(return_type_expr): + # A tool annotated to return only InputRequiredResult never produces structured content. + return FuncMetadata(arg_model=arguments_model) + + # The annotation fed to schema derivation. Starts as the raw return annotation (preserving any + # Annotated[...] wrapper) and is narrowed below if InputRequiredResult arms are stripped. + effective_annotation: Any = sig.return_annotation + if is_union_origin(get_origin(return_type_expr)): args = get_args(return_type_expr) - # Check if CallToolResult appears in the union (excluding None for Optional check) - if any(isinstance(arg, type) and issubclass(arg, CallToolResult) for arg in args if arg is not type(None)): + # InputRequiredResult is a control-flow signal, not data: strip it so the residual arms + # drive schema derivation. convert_result short-circuits on an InputRequiredResult instance + # before output validation, so the schema only ever sees the data arms at runtime. + residual = tuple(a for a in args if not _is_input_required_type(a)) + if not residual: + return FuncMetadata(arg_model=arguments_model) + if len(residual) != len(args): + # PEP 604 has no syntax for "union of a runtime tuple"; Union[...] is the only spelling. + effective_annotation = residual[0] if len(residual) == 1 else Union[residual] # noqa: UP007 + # Re-normalize so the residual is processed exactly as if it had been the declared + # return annotation: unwraps a top-level Annotated[...] arm and re-derives metadata, + # so the CallToolResult/BaseModel/TypedDict dispatch below sees the bare type. + inspected_return_ann = inspect_annotation(effective_annotation, annotation_source=AnnotationSource.FUNCTION) + return_type_expr = inspected_return_ann.type + if len(residual) > 1 and any( + isinstance(a, type) and issubclass(a, CallToolResult) for a in residual if a is not type(None) + ): raise InvalidSignature( f"Function {func.__name__}: CallToolResult cannot be used in Union or Optional types. " "To return empty results, use: CallToolResult(content=[])" @@ -295,7 +328,7 @@ def func_metadata( else: return FuncMetadata(arg_model=arguments_model) else: - original_annotation = sig.return_annotation + original_annotation = effective_annotation output_model, output_schema, wrap_output = _try_create_model_and_schema( original_annotation, return_type_expr, func.__name__ diff --git a/src/mcp/shared/inbound.py b/src/mcp/shared/inbound.py index f54f125e7a..1c70e3d926 100644 --- a/src/mcp/shared/inbound.py +++ b/src/mcp/shared/inbound.py @@ -1,19 +1,23 @@ """Inbound request classification for the modern per-request-envelope path. -Pure module: no I/O, no transport, no ``mcp.server`` imports. Runs the +Pure module: no I/O, no transport, no `mcp.server` imports. Runs the validation ladder against a decoded JSON-RPC body and returns either an :class:`InboundModernRoute` (every rung passed) or an :class:`InboundLadderRejection` (the first rung that failed). Callers map a -rejection's ``code`` through :data:`ERROR_CODE_HTTP_STATUS` to pick the HTTP +rejection's `code` through :data:`ERROR_CODE_HTTP_STATUS` to pick the HTTP status. + +Also hosts the shared header-value codec and the `x-mcp-header` schema +validator so client emit and server validate read the same source of truth. """ import base64 +import binascii import re -from collections.abc import Mapping, Sequence +from collections.abc import Iterator, Mapping, Sequence from dataclasses import dataclass from types import MappingProxyType -from typing import Any, Final +from typing import Any, Final, cast from mcp_types import ( CLIENT_CAPABILITIES_META_KEY, @@ -39,8 +43,12 @@ "MCP_METHOD_HEADER", "MCP_NAME_HEADER", "MCP_PROTOCOL_VERSION_HEADER", + "NAME_BEARING_METHODS", + "X_MCP_HEADER_KEY", "classify_inbound_request", + "decode_header_value", "encode_header_value", + "find_invalid_x_mcp_header", ] MCP_PROTOCOL_VERSION_HEADER: Final = "mcp-protocol-version" @@ -52,17 +60,152 @@ 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\?.*\?=$") +X_MCP_HEADER_KEY: Final = "x-mcp-header" +"""JSON-Schema property annotation that designates an `Mcp-Param-*` HTTP header.""" + +NAME_BEARING_METHODS: Final[Mapping[str, str]] = MappingProxyType( + { + "tools/call": "name", + "prompts/get": "name", + "resources/read": "uri", + } +) +"""Method → params key whose value is mirrored as the `Mcp-Name` HTTP header. + +Shared by client emit (which header to send) and server validate (which body +field to compare against), so both ends agree on the field by construction. +""" + +_B64_SENTINEL = re.compile(r"^=\?base64\?(?P.*)\?=$") # 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]*$") +# RFC 9110 §5.6.2 token: the only characters permitted in an HTTP field name. +_RFC9110_TOKEN = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") +# JSON-Schema types the spec permits to carry `x-mcp-header` (transports.mdx +# §Custom Headers). `number` is explicitly forbidden — float→str is not +# portable across implementations. +_X_MCP_HEADER_PRIMITIVE_TYPES: Final = frozenset({"string", "integer", "boolean"}) + +# JSON Schema 2020-12 applicator keywords whose values are themselves schema +# positions, grouped by value shape. `properties` is handled separately as the +# only keyword that preserves the statically-reachable chain; every keyword +# here drops the chain to None. Instance-data keywords (`default`, `examples`, +# `const`, `enum`) and `$ref`/`$dynamicRef` are deliberately absent so the +# walk never mistakes data for an annotation and never dereferences. +_SUBSCHEMA_SINGLE: Final = frozenset( + { + "items", + "contains", + "unevaluatedItems", + "additionalProperties", + "propertyNames", + "unevaluatedProperties", + "not", + "if", + "then", + "else", + "contentSchema", + } +) +_SUBSCHEMA_LIST: Final = frozenset({"allOf", "anyOf", "oneOf", "prefixItems"}) +_SUBSCHEMA_MAP: Final = frozenset({"patternProperties", "dependentSchemas", "$defs", "definitions"}) + + +def _walk_schema_positions(root: Any) -> Iterator[tuple[tuple[str, ...] | None, dict[str, Any]]]: + """Yield `(properties_path, schema)` for every schema position in `root`. + + `properties_path` is the chain of `properties` keys from the root to the + position, or `None` once any other applicator keyword has been crossed. + The root itself yields `()`. Only the JSON Schema 2020-12 applicators + listed above are entered; instance-data keywords are not, and `$ref` is + not dereferenced, so the walk terminates on any finite JSON value. An + explicit stack keeps the function total even on pathologically deep input. + """ + stack: list[tuple[tuple[str, ...] | None, Any]] = [((), root)] + while stack: + path, node = stack.pop() + if not isinstance(node, dict): + continue + schema = cast(dict[str, Any], node) + yield path, schema + for kw, val in schema.items(): + if kw == "properties" and isinstance(val, dict): + for name, sub in cast(dict[str, Any], val).items(): + stack.append(((*path, name) if path is not None else None, sub)) + elif kw in _SUBSCHEMA_SINGLE: + stack.append((None, val)) + elif kw in _SUBSCHEMA_LIST and isinstance(val, list): + stack.extend((None, sub) for sub in cast(list[Any], val)) + elif kw in _SUBSCHEMA_MAP and isinstance(val, dict): + stack.extend((None, sub) for sub in cast(dict[str, Any], val).values()) def encode_header_value(value: str) -> str: + """Wrap `value` in the `=?base64?...?=` sentinel when it would not survive an HTTP field round-trip. + + Plain printable ASCII without leading/trailing whitespace passes verbatim; + anything else (control chars, non-ASCII, edge whitespace, or a value that + already looks like the sentinel) is base64-wrapped so the receiver can + recover the exact bytes. + """ 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')}?=" +def decode_header_value(value: str | None) -> str | None: + """Inverse of :func:`encode_header_value`. + + Returns the value verbatim unless it carries the `=?base64?...?=` sentinel, + in which case the payload is decoded as UTF-8. A malformed sentinel (bad + base64 or bad UTF-8) yields `None` so a corrupt header never matches a body + value by accident. `None` in → `None` out so callers can pass + `headers.get(...)` directly. + """ + if value is None: + return None + m = _B64_SENTINEL.fullmatch(value) + if m is None: + return value + try: + return base64.b64decode(m.group("payload"), validate=True).decode("utf-8") + except (binascii.Error, UnicodeDecodeError): + return None + + +def find_invalid_x_mcp_header(input_schema: Any) -> str | None: + """Return a reason string if any `x-mcp-header` annotation in `input_schema` is invalid; else `None`. + + Walks every JSON Schema 2020-12 schema position. An annotation is valid + only when it sits on a property statically reachable from the root via a + chain of pure `properties` keys, names a non-empty RFC 9110 token, is on + an integer/string/boolean property, and is case-insensitively unique + across the whole schema. A `None` / non-mapping schema has no schema + positions and returns `None`. + """ + seen: dict[str, str] = {} + for path, schema in _walk_schema_positions(input_schema): + if X_MCP_HEADER_KEY not in schema: + continue + if not path: # None (off the pure-properties chain) or () (the root itself) + return f"{X_MCP_HEADER_KEY} found at a schema position not reachable via a pure `properties` chain" + where = ".".join(path) + header = schema[X_MCP_HEADER_KEY] + if not isinstance(header, str) or not _RFC9110_TOKEN.fullmatch(header): + return f"property {where!r}: {X_MCP_HEADER_KEY} {header!r} is not an RFC 9110 token" + prop_type = schema.get("type") + if not isinstance(prop_type, str) or prop_type not in _X_MCP_HEADER_PRIMITIVE_TYPES: + return ( + f"property {where!r}: {X_MCP_HEADER_KEY} is only permitted on " + f"integer/string/boolean properties (got {prop_type!r})" + ) + lower = header.lower() + if lower in seen: + return f"{X_MCP_HEADER_KEY} {header!r} on property {where!r} duplicates property {seen[lower]!r}" + seen[lower] = where + return None + + # 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( @@ -76,7 +219,7 @@ def encode_header_value(value: str) -> str: METHOD_NOT_FOUND: 404, } ) -"""HTTP status to send for a JSON-RPC ``error.code``. +"""HTTP status to send for a JSON-RPC `error.code`. Consulted for classifier-origin *and* handler-origin errors, so one table decides the wire status regardless of where the error was produced. Unmapped @@ -88,7 +231,7 @@ def encode_header_value(value: str) -> str: class InboundModernRoute: """A modern-protocol request whose envelope passed every ladder rung. - ``client_info`` and ``client_capabilities`` are the raw envelope values; + `client_info` and `client_capabilities` are the raw envelope values; the classifier checks presence only, not shape. Method existence is not a ladder rung — kernel dispatch is the single source of truth for that. """ @@ -117,25 +260,26 @@ def classify_inbound_request( Rungs, in order — first failure wins: - 1. ``params._meta`` is a mapping carrying every reserved envelope key + 1. `params._meta` is a mapping carrying every reserved envelope key (protocol version, client info, client capabilities) → else :data:`~mcp_types.jsonrpc.INVALID_PARAMS`. - 2. When ``headers`` is given, its ``MCP-Protocol-Version`` entry equals - the envelope's protocol version → else - :data:`~mcp_types.jsonrpc.HEADER_MISMATCH`. Runs before the - supported-version rung so a client that disagrees with itself is told - so, rather than told the body's version is unsupported. - 3. The envelope's protocol version is in ``supported_modern_versions`` → + 2. When `headers` is given, `MCP-Protocol-Version` equals the envelope's + protocol version, `Mcp-Method` equals `body.method`, and — for the + methods in :data:`NAME_BEARING_METHODS` — `Mcp-Name` equals the named + body param → else :data:`~mcp_types.jsonrpc.HEADER_MISMATCH`. Runs + before the supported-version rung so a client that disagrees with itself + is told so, rather than told the body's version is unsupported. + 3. The envelope's protocol version is in `supported_modern_versions` → else :data:`~mcp_types.jsonrpc.UNSUPPORTED_PROTOCOL_VERSION` with - ``data = {"supported": [...], "requested": }``. + `data = {"supported": [...], "requested": }`. Method existence is *not* a rung: kernel dispatch owns that decision so custom-registered methods route and the answer lives in one place. Args: body: The decoded JSON-RPC request mapping. Envelope shape - (``jsonrpc`` / ``id``) is not checked here. - headers: Transport headers keyed by lowercase name, or ``None`` to + (`jsonrpc` / `id`) is not checked here. + headers: Transport headers keyed by lowercase name, or `None` to skip the header rung (non-HTTP callers). supported_modern_versions: Modern protocol revisions this server accepts on the per-request-envelope path. @@ -152,12 +296,27 @@ def classify_inbound_request( "client-capabilities envelope keys", ) - # TODO(L59): also validate Mcp-Method / Mcp-Name per SEP-2243 §Server Validation - if headers is not None and headers.get(MCP_PROTOCOL_VERSION_HEADER) != protocol_version: - return InboundLadderRejection( - code=HEADER_MISMATCH, - message=f"{MCP_PROTOCOL_VERSION_HEADER} header does not match the request envelope's protocol version", - ) + if headers is not None: + if headers.get(MCP_PROTOCOL_VERSION_HEADER) != protocol_version: + return InboundLadderRejection( + code=HEADER_MISMATCH, + message=f"{MCP_PROTOCOL_VERSION_HEADER} header does not match the request envelope's protocol version", + ) + method: Any = body.get("method") + if headers.get(MCP_METHOD_HEADER) != method: + return InboundLadderRejection( + code=HEADER_MISMATCH, + message=f"{MCP_METHOD_HEADER} header does not match the request body's method", + ) + name_key = NAME_BEARING_METHODS.get(method) + if name_key is not None: + # Rung 1 already proved body["params"] is a mapping. + body_value = body["params"].get(name_key) + if body_value is not None and decode_header_value(headers.get(MCP_NAME_HEADER)) != body_value: + return InboundLadderRejection( + code=HEADER_MISMATCH, + message=f"{MCP_NAME_HEADER} header does not match the request body's {name_key!r} parameter", + ) if protocol_version not in supported_modern_versions: return InboundLadderRejection( diff --git a/tests/client/test_client.py b/tests/client/test_client.py index cc3ff4d968..f869d1f1bc 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -472,6 +472,38 @@ async def scripted_transport() -> AsyncIterator[TransportStreams]: assert methods_seen == ["server/discover", "initialize", "notifications/initialized"] +@pytest.mark.anyio +async def test_modern_list_tools_drops_tools_with_invalid_x_mcp_header_but_legacy_does_not() -> None: + """At 2026-07-28 the spec requires clients to exclude tools whose `x-mcp-header` + annotation is malformed; handshake-era sessions surface them unchanged. Two + tools are advertised — one valid, one with a non-RFC-9110-token header name — + and the modern client sees only the valid one.""" + valid = types.Tool( + name="ok", + input_schema={"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "Region"}}}, + ) + bad = types.Tool( + name="dropme", + input_schema={"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "bad name"}}}, + ) + + async def on_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[valid, bad]) + + server = Server("test", on_list_tools=on_list_tools) + + with anyio.fail_after(5): + async with Client(server) as client: + result = await client.list_tools() + assert [t.name for t in result.tools] == ["ok"] + + async with Client(server, mode="legacy") as client: + result = await client.list_tools() + assert [t.name for t in result.tools] == ["ok", "dropme"] + + 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 diff --git a/tests/interaction/transports/test_hosting_http.py b/tests/interaction/transports/test_hosting_http.py index e17f2f18f8..9c83e213c6 100644 --- a/tests/interaction/transports/test_hosting_http.py +++ b/tests/interaction/transports/test_hosting_http.py @@ -206,7 +206,7 @@ async def test_unsupported_protocol_version_rejection_body_contains_the_sniffed_ response = await http.post( "/mcp", json={"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {"_meta": meta}}, - headers=base_headers() | {"mcp-protocol-version": bad}, + headers=base_headers() | {"mcp-protocol-version": bad, "mcp-method": "tools/list"}, ) assert response.status_code == 400 diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index 0329f68365..edc3decbd4 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -10,7 +10,7 @@ import annotated_types import pytest from dirty_equals import IsPartialDict -from mcp_types import CallToolResult +from mcp_types import CallToolResult, InputRequiredResult from pydantic import BaseModel, Field from mcp.server.mcpserver.exceptions import InvalidSignature @@ -862,6 +862,29 @@ def func_returning_annotated_tool_call_result() -> Annotated[CallToolResult, Per assert isinstance(meta.convert_result(func_returning_annotated_tool_call_result()), CallToolResult) +def test_tool_call_result_annotated_unioned_with_input_required_result_is_equivalent_to_the_bare_annotated_form(): + """Stripping `InputRequiredResult` makes the residual behave exactly as if it were the + declared return annotation, including the `Annotated[CallToolResult, Model]` special case + — the schema derives from `Model` and `convert_result` validates `structured_content` + against it instead of wrapping the whole `CallToolResult`.""" + + class PersonClass(BaseModel): + name: str + + def fn_bare() -> Annotated[CallToolResult, PersonClass]: + return CallToolResult(content=[], structured_content={"name": "Brandon"}) + + def fn_iir() -> Annotated[CallToolResult, PersonClass] | InputRequiredResult: + return CallToolResult(content=[], structured_content={"name": "Brandon"}) + + bare = func_metadata(fn_bare) + iir = func_metadata(fn_iir) + assert iir.output_schema == bare.output_schema + assert iir.wrap_output == bare.wrap_output + assert isinstance(bare.convert_result(fn_bare()), CallToolResult) + assert isinstance(iir.convert_result(fn_iir()), CallToolResult) + + def test_tool_call_result_annotated_is_structured_and_invalid(): class PersonClass(BaseModel): name: str @@ -1038,7 +1061,9 @@ def func_with_aliases() -> ModelWithAliases: # pragma: no cover # Check that the actual output uses aliases too result = ModelWithAliases(**{"first": "hello", "second": "world"}) - structured_content = meta.convert_result(result).structured_content + converted = meta.convert_result(result) + assert isinstance(converted, CallToolResult) + structured_content = converted.structured_content assert structured_content is not None # The structured content should use aliases to match the schema @@ -1051,7 +1076,9 @@ def func_with_aliases() -> ModelWithAliases: # pragma: no cover # Also test the case where we have a model with defaults to ensure aliases work in all cases result_with_defaults = ModelWithAliases() # Uses default None values - structured_content_defaults = meta.convert_result(result_with_defaults).structured_content + converted_defaults = meta.convert_result(result_with_defaults) + assert isinstance(converted_defaults, CallToolResult) + structured_content_defaults = converted_defaults.structured_content assert structured_content_defaults is not None # Even with defaults, should use aliases in output @@ -1191,3 +1218,71 @@ def func_with_metadata() -> Annotated[int, Field(gt=1)]: ... # pragma: no branc assert meta.output_schema is not None assert meta.output_schema["properties"]["result"] == {"exclusiveMinimum": 1, "title": "Result", "type": "integer"} + + +def test_convert_result_passes_input_required_result_through_unchanged(): + def fn() -> str | InputRequiredResult: ... # pragma: no branch + + meta = func_metadata(fn) + irr = InputRequiredResult(request_state="opaque") + assert meta.convert_result(irr) is irr + + +def test_input_required_result_return_annotation_yields_no_output_schema(): + def fn() -> InputRequiredResult: ... # pragma: no branch + + meta = func_metadata(fn) + assert meta.output_schema is None + assert meta.output_model is None + + +def test_union_with_input_required_result_derives_schema_from_residual_arm(): + def fn() -> str | InputRequiredResult: ... # pragma: no branch + + meta = func_metadata(fn) + assert meta.output_schema is not None + assert meta.output_schema["properties"]["result"]["type"] == "string" + converted = meta.convert_result("hello") + assert isinstance(converted, CallToolResult) + assert converted.structured_content == {"result": "hello"} + irr = InputRequiredResult(request_state="opaque") + assert meta.convert_result(irr) is irr + + +def test_call_tool_result_unioned_with_input_required_result_is_accepted(): + def fn() -> CallToolResult | InputRequiredResult: ... # pragma: no branch + + meta = func_metadata(fn) + assert meta.output_schema is None + + +def test_basemodel_union_input_required_result_derives_model_schema(): + class Payload(BaseModel): + x: int + + def fn() -> Payload | InputRequiredResult: ... # pragma: no branch + + meta = func_metadata(fn) + assert meta.output_model is Payload + assert meta.wrap_output is False + assert meta.output_schema == Payload.model_json_schema() + + +def test_call_tool_result_in_union_with_input_required_result_is_still_rejected(): + def fn() -> CallToolResult | str | InputRequiredResult: ... # pragma: no branch + + with pytest.raises(InvalidSignature, match="CallToolResult cannot be used in Union"): + func_metadata(fn) + + +def test_union_of_only_input_required_subclasses_yields_no_output_schema(): + class StepA(InputRequiredResult): + pass + + class StepB(InputRequiredResult): + pass + + def fn() -> StepA | StepB: ... # pragma: no branch + + meta = func_metadata(fn) + assert meta.output_schema is None diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 4ea867480e..47f3384a87 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -3,6 +3,7 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch +import anyio import pytest from inline_snapshot import snapshot from mcp_types import ( @@ -11,15 +12,21 @@ AudioContent, BlobResourceContents, CallToolResult, + ClientCapabilities, Completion, CompletionArgument, CompletionContext, ContentBlock, + ElicitRequest, + ElicitRequestFormParams, + ElicitResult, EmbeddedResource, GetPromptResult, Icon, ImageContent, + InputRequiredResult, ListPromptsResult, + ListRootsRequest, Prompt, PromptArgument, PromptMessage, @@ -1570,3 +1577,98 @@ def get_user(user_id: str) -> str: assert exc_info.value.error.code == INVALID_PARAMS assert exc_info.value.error.data == {"uri": "resource://users/999"} + + +async def test_tool_returning_input_required_result_reaches_client_unchanged(): + mcp = MCPServer() + + @mcp.tool() + async def ask(ctx: Context) -> str | InputRequiredResult: + return InputRequiredResult(input_requests={"roots": ListRootsRequest()}, request_state="round-1") + + with anyio.fail_after(5): + async with Client(mcp, mode="2026-07-28") as client: + result = await client.call_tool("ask", allow_input_required=True) + + assert isinstance(result, InputRequiredResult) + assert result.request_state == "round-1" + assert result.input_requests is not None + assert result.input_requests["roots"].method == "roots/list" + + +async def test_tool_reads_input_responses_and_request_state_from_context_on_retry(): + mcp = MCPServer() + + @mcp.tool() + async def greet(ctx: Context) -> str | InputRequiredResult: + responses = ctx.input_responses + if responses and "who" in responses: + who = responses["who"] + assert isinstance(who, ElicitResult) and who.content is not None + return f"Hello, {who.content['name']}! (state={ctx.request_state})" + return InputRequiredResult( + input_requests={ + "who": ElicitRequest( + params=ElicitRequestFormParams( + message="What is your name?", + requested_schema={ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + }, + ) + ) + }, + request_state="r1", + ) + + with anyio.fail_after(5): + async with Client(mcp, mode="2026-07-28") as client: + r1 = await client.call_tool("greet", allow_input_required=True) + assert isinstance(r1, InputRequiredResult) + assert r1.input_requests is not None and "who" in r1.input_requests + + r2 = await client.call_tool( + "greet", + input_responses={"who": ElicitResult(action="accept", content={"name": "Alice"})}, + request_state=r1.request_state, + allow_input_required=True, + ) + assert isinstance(r2, CallToolResult) + block = r2.content[0] + assert isinstance(block, TextContent) + assert block.text == "Hello, Alice! (state=r1)" + + +async def test_context_exposes_client_capabilities_from_connection(): + mcp = MCPServer() + seen: list[ClientCapabilities | None] = [] + + @mcp.tool() + async def probe(ctx: Context) -> str: + seen.append(ctx.client_capabilities) + return "ok" + + with anyio.fail_after(5): + async with Client(mcp, mode="2026-07-28") as client: + await client.call_tool("probe") + + assert len(seen) == 1 + assert isinstance(seen[0], ClientCapabilities) + + +async def test_context_input_responses_and_request_state_are_none_on_initial_round(): + mcp = MCPServer() + captured: dict[str, Any] = {} + + @mcp.tool() + async def probe(ctx: Context) -> str: + captured["responses"] = ctx.input_responses + captured["state"] = ctx.request_state + return "ok" + + with anyio.fail_after(5): + async with Client(mcp, mode="2026-07-28") as client: + await client.call_tool("probe") + + assert captured == {"responses": None, "state": None} diff --git a/tests/server/test_streamable_http_modern.py b/tests/server/test_streamable_http_modern.py index 08b9401078..0ba61cf391 100644 --- a/tests/server/test_streamable_http_modern.py +++ b/tests/server/test_streamable_http_modern.py @@ -15,6 +15,7 @@ from mcp_types import ( CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, + HEADER_MISMATCH, INTERNAL_ERROR, INVALID_PARAMS, INVALID_REQUEST, @@ -39,7 +40,7 @@ ) from mcp.server.transport_security import TransportSecuritySettings from mcp.shared.exceptions import MCPError, NoBackChannelError -from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER +from mcp.shared.inbound import MCP_METHOD_HEADER, MCP_NAME_HEADER, MCP_PROTOCOL_VERSION_HEADER from mcp.shared.transport_context import TransportContext pytestmark = pytest.mark.anyio @@ -67,7 +68,10 @@ 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: LATEST_MODERN_VERSION}, + headers={ + MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION, + "content-type": "application/json", + }, ) @@ -150,7 +154,7 @@ async def greet(ctx: ServerRequestContext, params: PaginatedRequestParams) -> di body["method"] = "custom/greet" body["params"]["_meta"][CLIENT_INFO_META_KEY] = "not-an-object" async with _asgi_client(server) as http: - response = await http.post("/mcp", json=body, headers={"content-type": "application/json"}) + response = await http.post("/mcp", json=body, headers={MCP_METHOD_HEADER: "custom/greet"}) assert response.status_code == 200 assert response.json()["result"] == {"ok": True} assert seen == [None] @@ -175,7 +179,7 @@ async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | with caplog.at_level(logging.ERROR, logger=runner.__name__): async with _asgi_client(Server("test", on_list_tools=list_tools)) as http: - response = await http.post("/mcp", json=_list_tools_body(), headers={"content-type": "application/json"}) + response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "tools/list"}) assert response.status_code == 200 assert response.json()["result"]["tools"] == [] @@ -203,7 +207,7 @@ async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | with anyio.fail_after(5), caplog.at_level(logging.WARNING, logger=runner.__name__): async with _asgi_client(Server("test", on_list_tools=list_tools)) as http: - response = await http.post("/mcp", json=_list_tools_body(), headers={"content-type": "application/json"}) + response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "tools/list"}) # coverage.py on Python 3.11 misreports the lines below as unhit (the test passes there); # the shielded-cancel path inside the request task disrupts the tracer in this frame. assert response.status_code == 200 # pragma: lax no cover @@ -270,3 +274,30 @@ async def fail() -> dict[str, Any]: # Handler internals never reach the wire. assert "boom" not in reply.error.message assert "request handler raised" in caplog.text + + +# --- header cross-check at the wire -------------------------------------------- + + +async def test_handle_modern_request_rejects_mismatched_method_header_with_400_and_header_mismatch() -> None: + """Spec-mandated: an `Mcp-Method` header that disagrees with `body.method` is rejected at the + boundary as HTTP 400 with JSON-RPC error code HEADER_MISMATCH; the handler never runs.""" + async with _asgi_client(Server("test")) as http: + response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "prompts/list"}) + assert response.status_code == 400 + assert response.json()["error"]["code"] == HEADER_MISMATCH + + +async def test_handle_modern_request_rejects_mismatched_name_header_with_400_and_header_mismatch() -> None: + """Spec-mandated: for a name-bearing method, an `Mcp-Name` header that disagrees with the body's + named param is rejected as HTTP 400 with JSON-RPC error code HEADER_MISMATCH.""" + body = _list_tools_body() + body["method"] = "tools/call" + body["params"]["name"] = "real" + body["params"]["arguments"] = {} + async with _asgi_client(Server("test")) as http: + response = await http.post( + "/mcp", json=body, headers={MCP_METHOD_HEADER: "tools/call", MCP_NAME_HEADER: "wrong"} + ) + assert response.status_code == 400 + assert response.json()["error"]["code"] == HEADER_MISMATCH diff --git a/tests/shared/test_inbound.py b/tests/shared/test_inbound.py index 150cea6c2b..93ab6ecc22 100644 --- a/tests/shared/test_inbound.py +++ b/tests/shared/test_inbound.py @@ -1,7 +1,7 @@ """Pure-function tests of :mod:`mcp.shared.inbound`. Independent verifier of the classifier: every ladder rung is exercised -pass+fail with no ``mcp.server`` / transport imports and no inlined error-code +pass+fail with no `mcp.server` / transport imports and no inlined error-code or protocol-version literals — all facts are imported from their one source. """ @@ -27,10 +27,16 @@ from mcp.shared.inbound import ( ERROR_CODE_HTTP_STATUS, + MCP_METHOD_HEADER, + MCP_NAME_HEADER, MCP_PROTOCOL_VERSION_HEADER, + NAME_BEARING_METHODS, InboundLadderRejection, InboundModernRoute, classify_inbound_request, + decode_header_value, + encode_header_value, + find_invalid_x_mcp_header, ) CLIENT_INFO = {"name": "t", "version": "0"} @@ -42,10 +48,11 @@ def envelope( *, version: str = LATEST_MODERN_VERSION, drop: frozenset[str] = frozenset(), + extra_params: dict[str, Any] | None = None, ) -> dict[str, Any]: - """Build a JSON-RPC body carrying a complete modern ``_meta`` envelope. + """Build a JSON-RPC body carrying a complete modern `_meta` envelope. - ``drop`` removes named envelope keys so rung-1 failures are driven from one + `drop` removes named envelope keys so rung-1 failures are driven from one table instead of repeating reserved-key constants per call site. """ meta: dict[str, Any] = { @@ -55,7 +62,22 @@ def envelope( } for key in drop: del meta[key] - return {"jsonrpc": "2.0", "id": 1, "method": method, "params": {"_meta": meta}} + params: dict[str, Any] = {"_meta": meta} + if extra_params: + params.update(extra_params) + return {"jsonrpc": "2.0", "id": 1, "method": method, "params": params} + + +def matching_headers(body: dict[str, Any]) -> dict[str, str]: + """The minimal lowercase HTTP header set that agrees with `body` for rung 2.""" + headers = { + MCP_PROTOCOL_VERSION_HEADER: body["params"]["_meta"][PROTOCOL_VERSION_META_KEY], + MCP_METHOD_HEADER: body["method"], + } + name_key = NAME_BEARING_METHODS.get(body["method"]) + if name_key is not None and name_key in body["params"]: + headers[MCP_NAME_HEADER] = encode_header_value(body["params"][name_key]) + return headers def assert_rejected(result: object, code: int) -> InboundLadderRejection: @@ -78,7 +100,7 @@ def assert_rejected(result: object, code: int) -> InboundLadderRejection: ], ) def test_envelope_rung_rejects_missing_keys(body: dict[str, Any]) -> None: - """Spec-mandated: a modern request lacking any of the three reserved ``_meta`` keys is rejected INVALID_PARAMS.""" + """Spec-mandated: a modern request lacking any of the three reserved `_meta` keys is rejected INVALID_PARAMS.""" rejection = assert_rejected(classify_inbound_request(body), INVALID_PARAMS) assert rejection.data is None @@ -94,7 +116,7 @@ def test_envelope_rung_rejects_missing_keys(body: dict[str, Any]) -> None: ], ) def test_envelope_rung_rejects_non_mapping_shapes(body: dict[str, Any]) -> None: - """Spec-mandated: non-mapping ``params`` / ``_meta`` cannot carry the envelope and reject INVALID_PARAMS.""" + """Spec-mandated: non-mapping `params` / `_meta` cannot carry the envelope and reject INVALID_PARAMS.""" assert_rejected(classify_inbound_request(body), INVALID_PARAMS) @@ -102,7 +124,7 @@ 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.""" + """Spec-mandated: an envelope version outside the modern set rejects with the `supported`/`requested` data.""" rejection = assert_rejected( classify_inbound_request(envelope(version=LATEST_HANDSHAKE_VERSION)), UNSUPPORTED_PROTOCOL_VERSION, @@ -114,7 +136,7 @@ def test_version_rung_rejects_unsupported_with_data_shape() -> None: def test_version_rung_data_reflects_supplied_supported_list() -> None: - """SDK-defined: the caller-supplied ``supported_modern_versions`` is what rejection ``data.supported`` echoes.""" + """SDK-defined: the caller-supplied `supported_modern_versions` is what rejection `data.supported` echoes.""" custom = (LATEST_HANDSHAKE_VERSION,) rejection = assert_rejected( classify_inbound_request(envelope(), supported_modern_versions=custom), @@ -127,14 +149,15 @@ def test_version_rung_data_reflects_supplied_supported_list() -> None: def test_header_rung_does_not_reject_when_headers_arg_is_none() -> None: - """SDK-defined: ``headers=None`` (non-HTTP transports) means rung 3 has nothing to check and the ladder proceeds.""" + """SDK-defined: `headers=None` (non-HTTP transports) means rung 3 has nothing to check and the ladder proceeds.""" result = classify_inbound_request(envelope(), headers=None) assert isinstance(result, InboundModernRoute) 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: LATEST_MODERN_VERSION}) + body = envelope() + result = classify_inbound_request(body, headers=matching_headers(body)) assert isinstance(result, InboundModernRoute) @@ -150,12 +173,78 @@ def test_header_rung_rejects_on_disagreement(headers: dict[str, str]) -> None: assert_rejected(classify_inbound_request(envelope(), headers=headers), HEADER_MISMATCH) +@pytest.mark.parametrize( + "override", + [ + pytest.param({MCP_METHOD_HEADER: "prompts/list"}, id="method-mismatch"), + pytest.param({MCP_METHOD_HEADER: "TOOLS/LIST"}, id="method-case-mismatch"), + ], +) +def test_header_rung_rejects_method_header_disagreement(override: dict[str, str]) -> None: + """Spec-mandated: `Mcp-Method` must equal `body.method` exactly (case-sensitive) → else HEADER_MISMATCH.""" + body = envelope() + rejection = assert_rejected( + classify_inbound_request(body, headers=matching_headers(body) | override), HEADER_MISMATCH + ) + assert MCP_METHOD_HEADER in rejection.message + + +def test_header_rung_rejects_missing_method_header() -> None: + """Spec-mandated: an HTTP request on the modern path without `Mcp-Method` is HEADER_MISMATCH.""" + body = envelope() + headers = matching_headers(body) + del headers[MCP_METHOD_HEADER] + assert_rejected(classify_inbound_request(body, headers=headers), HEADER_MISMATCH) + + +@pytest.mark.parametrize( + ("method", "name_key"), + [(m, k) for m, k in NAME_BEARING_METHODS.items()], +) +def test_header_rung_rejects_missing_or_mismatched_name_header_for_name_bearing_methods( + method: str, name_key: str +) -> None: + """Spec-mandated: when the body carries the named param, `Mcp-Name` must be present and equal it.""" + body = envelope(method, extra_params={name_key: "expected"}) + headers = matching_headers(body) + # Mismatch + assert_rejected(classify_inbound_request(body, headers=headers | {MCP_NAME_HEADER: "wrong"}), HEADER_MISMATCH) + # Absent + del headers[MCP_NAME_HEADER] + assert_rejected(classify_inbound_request(body, headers=headers), HEADER_MISMATCH) + + +def test_header_rung_decodes_base64_sentinel_before_comparing_name() -> None: + """Spec-mandated: servers MUST decode the `=?base64?...?=` sentinel before comparing `Mcp-Name`.""" + body = envelope("tools/call", extra_params={"name": "résumé"}) + headers = matching_headers(body) + assert headers[MCP_NAME_HEADER].startswith("=?base64?") + result = classify_inbound_request(body, headers=headers) + assert isinstance(result, InboundModernRoute) + + +def test_header_rung_does_not_require_name_header_for_non_name_bearing_method() -> None: + """SDK-defined: a method outside `NAME_BEARING_METHODS` ignores `Mcp-Name` entirely.""" + body = envelope("tools/list") + result = classify_inbound_request(body, headers=matching_headers(body) | {MCP_NAME_HEADER: "anything"}) + assert isinstance(result, InboundModernRoute) + + +def test_header_rung_does_not_require_name_header_when_body_omits_the_named_param() -> None: + """SDK-defined: a name-bearing method whose body lacks the named param skips the `Mcp-Name` + check — the param's absence is INVALID_PARAMS later, not HEADER_MISMATCH here.""" + body = envelope("tools/call") + result = classify_inbound_request(body, headers=matching_headers(body)) + assert isinstance(result, InboundModernRoute) + + # --- all rungs pass ------------------------------------------------------------ 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: LATEST_MODERN_VERSION}) + body = envelope() + result = classify_inbound_request(body, headers=matching_headers(body)) assert isinstance(result, InboundModernRoute) assert result.protocol_version == LATEST_MODERN_VERSION assert result.client_info == CLIENT_INFO @@ -165,7 +254,8 @@ 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: LATEST_MODERN_VERSION}) + body = envelope(method) + result = classify_inbound_request(body, headers=matching_headers(body)) assert isinstance(result, InboundModernRoute) @@ -215,3 +305,173 @@ def test_verdict_dataclasses_are_frozen() -> None: for verdict in (route, rejection): with pytest.raises(dataclasses.FrozenInstanceError): setattr(verdict, "message", "mutated") + + +# --- header-value codec -------------------------------------------------------- + + +@pytest.mark.parametrize( + "raw", + ["plain", "with internal space", "", " edge-ws ", "résumé", "a\r\nb", "=?base64?Zm9v?="], +) +def test_decode_header_value_round_trips_encode(raw: str) -> None: + """SDK-defined: `decode_header_value` is the exact inverse of `encode_header_value` over the full input domain.""" + assert decode_header_value(encode_header_value(raw)) == raw + + +def test_decode_header_value_passes_none_and_plain_through() -> None: + """SDK-defined: `None` in → `None` out so callers can pass `headers.get(...)` directly; plain stays verbatim.""" + assert decode_header_value(None) is None + assert decode_header_value("plain") == "plain" + + +@pytest.mark.parametrize("bad", ["=?base64?not base64!?=", "=?base64?gA==?="]) +def test_decode_header_value_returns_none_for_malformed_sentinel(bad: str) -> None: + """SDK-defined: a sentinel with bad base64 or bad UTF-8 decodes to `None`, so it can never match a body value.""" + assert decode_header_value(bad) is None + + +# --- NAME_BEARING_METHODS ------------------------------------------------------ + + +def test_name_bearing_methods_table_matches_spec() -> None: + """Spec-mandated: pins the method → name-param table the client emit and server validate share.""" + assert NAME_BEARING_METHODS == {"tools/call": "name", "prompts/get": "name", "resources/read": "uri"} + + +# --- find_invalid_x_mcp_header ------------------------------------------------- + + +def _schema(**props: Any) -> dict[str, Any]: + return {"type": "object", "properties": props} + + +@pytest.mark.parametrize( + "input_schema", + [ + pytest.param(None, id="none"), + pytest.param("not-a-mapping", id="non-mapping"), + pytest.param({"type": "object"}, id="no-properties"), + pytest.param({"type": "object", "properties": "not-a-mapping"}, id="properties-non-mapping"), + pytest.param(_schema(a={"type": "string"}), id="no-annotation"), + pytest.param(_schema(a={"type": "string", "x-mcp-header": "Region"}), id="valid-string"), + pytest.param(_schema(a={"type": "integer", "x-mcp-header": "Count"}), id="valid-integer"), + pytest.param(_schema(a={"type": "boolean", "x-mcp-header": "Flag"}), id="valid-boolean"), + pytest.param( + _schema(a={"type": "string", "x-mcp-header": "A"}, b={"type": "string", "x-mcp-header": "B"}), + id="two-distinct", + ), + pytest.param(_schema(a="not-a-mapping", b={"type": "string", "x-mcp-header": "B"}), id="non-mapping-prop"), + pytest.param( + _schema(outer={"type": "object", "properties": {"r": {"type": "string", "x-mcp-header": "R"}}}), + id="nested-on-properties-chain", + ), + pytest.param( + _schema(a={"type": "string", "default": {"x-mcp-header": "ignored"}}), + id="annotation-lookalike-in-default-is-data", + ), + pytest.param( + _schema(a={"type": "string", "examples": [{"x-mcp-header": "ignored"}]}), + id="annotation-lookalike-in-examples-is-data", + ), + pytest.param( + _schema(a={"type": "string", "const": {"x-mcp-header": "ignored"}}), + id="annotation-lookalike-in-const-is-data", + ), + pytest.param( + {"properties": {"a": {"type": "string", "x-mcp-header": "R"}}, "$ref": "#/$defs/loop"}, + id="ref-is-not-dereferenced", + ), + pytest.param( + {"type": "object", "allOf": 0, "anyOf": [], "$defs": 0, "patternProperties": {}}, + id="malformed-or-empty-applicators-ignored", + ), + ], +) +def test_find_invalid_x_mcp_header_accepts_valid_or_absent_annotations(input_schema: Any) -> None: + """Spec-mandated: a schema without annotations, or with annotations that are RFC 9110 tokens on + integer/string/boolean properties reachable via a pure `properties` chain and case-insensitively + unique across the whole schema, is valid.""" + assert find_invalid_x_mcp_header(input_schema) is None + + +@pytest.mark.parametrize( + "input_schema", + [ + pytest.param(_schema(a={"type": "string", "x-mcp-header": ""}), id="empty"), + pytest.param(_schema(a={"type": "string", "x-mcp-header": "My Region"}), id="space"), + pytest.param(_schema(a={"type": "string", "x-mcp-header": "Region:Primary"}), id="colon"), + pytest.param(_schema(a={"type": "string", "x-mcp-header": "Région"}), id="non-ascii"), + pytest.param(_schema(a={"type": "string", "x-mcp-header": "Region\t1"}), id="control-char"), + pytest.param(_schema(a={"type": "string", "x-mcp-header": 42}), id="non-string"), + pytest.param(_schema(a={"type": "object", "x-mcp-header": "Data"}), id="on-object"), + pytest.param(_schema(a={"type": "array", "x-mcp-header": "Items"}), id="on-array"), + pytest.param(_schema(a={"type": "null", "x-mcp-header": "Nil"}), id="on-null"), + pytest.param(_schema(a={"type": "number", "x-mcp-header": "Ratio"}), id="on-number"), + pytest.param(_schema(a={"type": ["string", "null"], "x-mcp-header": "Maybe"}), id="array-type"), + pytest.param(_schema(a={"type": {"not": "valid"}, "x-mcp-header": "Bad"}), id="dict-type"), + pytest.param(_schema(a={"x-mcp-header": "NoType"}), id="missing-type"), + pytest.param( + _schema(a={"type": "string", "x-mcp-header": "Region"}, b={"type": "string", "x-mcp-header": "Region"}), + id="duplicate-same-case", + ), + pytest.param( + _schema(a={"type": "string", "x-mcp-header": "MyField"}, b={"type": "string", "x-mcp-header": "myfield"}), + id="duplicate-diff-case", + ), + pytest.param( + _schema(a={"type": "array", "items": {"type": "string", "x-mcp-header": "X"}}), + id="under-items", + ), + pytest.param( + {"allOf": [{"properties": {"a": {"type": "string", "x-mcp-header": "X"}}}]}, + id="under-allOf", + ), + pytest.param( + {"oneOf": [{"type": "string", "x-mcp-header": "X"}]}, + id="under-oneOf", + ), + pytest.param( + _schema(a={"if": {"type": "string", "x-mcp-header": "X"}}), + id="under-if", + ), + pytest.param( + {"$defs": {"T": {"type": "string", "x-mcp-header": "X"}}, "properties": {}}, + id="under-defs", + ), + pytest.param( + {"patternProperties": {"^a": {"type": "string", "x-mcp-header": "X"}}}, + id="under-patternProperties", + ), + pytest.param( + {"type": "string", "x-mcp-header": "X"}, + id="on-root-schema", + ), + pytest.param( + _schema( + a={"type": "string", "x-mcp-header": "Region"}, + o={"type": "object", "properties": {"b": {"type": "string", "x-mcp-header": "region"}}}, + ), + id="duplicate-across-nesting-levels", + ), + pytest.param( + _schema(outer={"type": "object", "properties": {"r": {"type": "string", "x-mcp-header": "bad name"}}}), + id="nested-bad-token", + ), + pytest.param( + _schema(outer={"type": "object", "properties": {"r": {"type": "object", "x-mcp-header": "R"}}}), + id="nested-non-primitive", + ), + ], +) +def test_find_invalid_x_mcp_header_rejects_malformed_annotations(input_schema: dict[str, Any]) -> None: + """Spec-mandated: empty / non-token / non-primitive / off-chain / duplicate `x-mcp-header` + annotations yield a reason string.""" + assert isinstance(find_invalid_x_mcp_header(input_schema), str) + + +def test_find_invalid_x_mcp_header_reports_dotted_path_for_nested_property() -> None: + """SDK-defined: the reason string names the nested property by its dotted `properties` path.""" + schema = _schema(outer={"type": "object", "properties": {"r": {"type": "object", "x-mcp-header": "R"}}}) + reason = find_invalid_x_mcp_header(schema) + assert reason is not None and "'outer.r'" in reason From e9cd1692b050b0986b82e5f9b4489796025a20c9 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 26 Jun 2026 11:00:51 +0200 Subject: [PATCH 002/100] Remove comment-on-release workflow (#2985) --- .github/workflows/comment-on-release.yml | 229 ----------------------- 1 file changed, 229 deletions(-) delete mode 100644 .github/workflows/comment-on-release.yml diff --git a/.github/workflows/comment-on-release.yml b/.github/workflows/comment-on-release.yml deleted file mode 100644 index f49a4e32c5..0000000000 --- a/.github/workflows/comment-on-release.yml +++ /dev/null @@ -1,229 +0,0 @@ -name: Comment on PRs in Release - -on: - release: - types: [published] - -permissions: - pull-requests: write - contents: read - -jobs: - comment-on-prs: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Get previous release - id: previous_release - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - CURRENT_TAG: ${{ github.event.release.tag_name }} - with: - script: | - const currentTag = process.env.CURRENT_TAG; - - // Paginate: with two release lines publishing interleaved, the - // previous release on this line can sit far down the list. - const releases = await github.paginate(github.rest.repos.listReleases, { - owner: context.repo.owner, - repo: context.repo.repo, - per_page: 100 - }); - - if (!releases.some(r => r.tag_name === currentTag)) { - console.log('Current release not found in list'); - return null; - } - - const major = tag => (tag.match(/^v?(\d+)/) || [])[1]; - - if (major(currentTag) === undefined) { - console.log(`Cannot parse a major version from ${currentTag}; skipping comments`); - return null; - } - - // The list is ordered by release creation date, which does not - // reliably reflect tag topology (for example, a release published - // from a long-lived draft keeps its draft creation date). Instead - // of trusting list order, compare every same-major release and - // pick the nearest ancestor of the current tag: the one the - // smallest number of commits behind it. The major check runs - // first so cross-line candidates cost no API calls; per_page=1 - // because only status/ahead_by are needed here (the commits are - // fetched in the next step). For the first release of a new major - // line there is no same-line predecessor, and we skip commenting - // rather than compare across the entire new line's history. - let best = null; - for (const candidate of releases) { - if (candidate.tag_name === currentTag || candidate.draft) continue; - if (major(candidate.tag_name) !== major(currentTag)) continue; - - let comparison; - try { - ({ data: comparison } = await github.rest.repos.compareCommits({ - owner: context.repo.owner, - repo: context.repo.repo, - base: candidate.tag_name, - head: currentTag, - per_page: 1 - })); - } catch (error) { - // Tolerate only candidates whose tag no longer resolves; - // anything else (rate limits, server errors) must fail the - // job rather than silently produce a wrong comparison base. - if (error.status === 404) { - console.log(`Skipping ${candidate.tag_name}: tag does not resolve`); - continue; - } - throw error; - } - - // 'identical' covers a release re-cut on the same commit; it - // yields an empty commit range downstream, hence no comments. - if (comparison.status !== 'ahead' && comparison.status !== 'identical') { - console.log(`Skipping ${candidate.tag_name}: not an ancestor of ${currentTag} (status: ${comparison.status})`); - continue; - } - - if (best === null || comparison.ahead_by < best.aheadBy) { - best = { tagName: candidate.tag_name, aheadBy: comparison.ahead_by }; - } - } - - if (best === null) { - console.log(`No previous release found for ${currentTag} on its major line (it may be the first); skipping comments`); - return null; - } - - console.log(`Found previous release: ${best.tagName} (${best.aheadBy} commits behind ${currentTag})`); - return best.tagName; - - - name: Get merged PRs between releases - id: get_prs - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - CURRENT_TAG: ${{ github.event.release.tag_name }} - PREVIOUS_TAG_JSON: ${{ steps.previous_release.outputs.result }} - with: - script: | - const currentTag = process.env.CURRENT_TAG; - const previousTag = JSON.parse(process.env.PREVIOUS_TAG_JSON); - - if (!previousTag) { - console.log('No previous release found, skipping'); - return []; - } - - console.log(`Finding PRs between ${previousTag} and ${currentTag}`); - - // Get commits between previous and current release. A single - // compare response caps the commit list, so paginate — but bound - // the total: a range this large means a mis-selected base, and - // commenting on hundreds of PRs is worse than commenting on none. - const MAX_COMMITS = 250; - const commits = []; - for (let page = 1; ; page++) { - const { data: comparison } = await github.rest.repos.compareCommits({ - owner: context.repo.owner, - repo: context.repo.repo, - base: previousTag, - head: currentTag, - per_page: 100, - page - }); - commits.push(...comparison.commits); - if (commits.length > MAX_COMMITS) { - console.log(`Range ${previousTag}...${currentTag} exceeds ${MAX_COMMITS} commits; skipping comments`); - return []; - } - if (comparison.commits.length < 100) break; - } - console.log(`Found ${commits.length} commits`); - - // Get PRs associated with each commit using GitHub API - const prNumbers = new Set(); - - for (const commit of commits) { - try { - const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ - owner: context.repo.owner, - repo: context.repo.repo, - commit_sha: commit.sha - }); - - for (const pr of prs) { - if (pr.merged_at) { - prNumbers.add(pr.number); - console.log(`Found merged PR: #${pr.number}`); - } - } - } catch (error) { - console.log(`Failed to get PRs for commit ${commit.sha}: ${error.message}`); - } - } - - console.log(`Found ${prNumbers.size} merged PRs`); - return Array.from(prNumbers); - - - name: Comment on PRs - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - PR_NUMBERS_JSON: ${{ steps.get_prs.outputs.result }} - RELEASE_TAG: ${{ github.event.release.tag_name }} - RELEASE_URL: ${{ github.event.release.html_url }} - RELEASE_IS_PRERELEASE: ${{ github.event.release.prerelease }} - with: - script: | - const prNumbers = JSON.parse(process.env.PR_NUMBERS_JSON); - const releaseTag = process.env.RELEASE_TAG; - const releaseUrl = process.env.RELEASE_URL; - // Trust the tag as well as the flag, in case the release manager - // forgets to tick the pre-release checkbox. - const isPrerelease = process.env.RELEASE_IS_PRERELEASE === 'true' || /\d(a|b|rc)\d/.test(releaseTag); - const releaseKind = isPrerelease ? 'pre-release' : 'release'; - - const comment = `This pull request is included in ${releaseKind} [${releaseTag}](${releaseUrl})`; - - let commentedCount = 0; - - for (const prNumber of prNumbers) { - try { - // Check if we've already commented on this PR for this - // release. Paginate: comments are returned oldest-first, so - // on a busy PR an earlier bot comment is exactly what would - // fall off a single page. - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - per_page: 100 - }); - - const alreadyCommented = comments.some(c => - c.user.type === 'Bot' && c.body.includes(`[${releaseTag}]`) - ); - - if (alreadyCommented) { - console.log(`Skipping PR #${prNumber} - already commented for ${releaseTag}`); - continue; - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: comment - }); - commentedCount++; - console.log(`Successfully commented on PR #${prNumber}`); - } catch (error) { - console.error(`Failed to comment on PR #${prNumber}:`, error.message); - } - } - - console.log(`Commented on ${commentedCount} of ${prNumbers.length} PRs`); From f41a5193f36443f40956d92895df2c758deafe40 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 26 Jun 2026 11:41:41 +0200 Subject: [PATCH 003/100] Preserve empty issuer/resource paths on AuthSettings (#2987) --- docs/migration.md | 8 ++++++++ src/mcp/server/auth/settings.py | 8 +++++++- tests/server/auth/test_routes.py | 27 ++++++++++++++++++++++++++- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 7598b52022..0ea24991fa 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1373,6 +1373,14 @@ match redirect URIs by exact string comparison, so if you registered such a URI release (with the trailing slash) and the registration is persisted in `TokenStorage`, re-register the client so the stored value matches what the SDK now transmits. +`AuthSettings` now sets `url_preserve_empty_path=True` for the same reason: a path-less +`issuer_url` (or `resource_server_url`) passed as a string keeps its empty path, so the authorization +server advertises `issuer` as `https://as.example.com` rather than `https://as.example.com/` in its +metadata. Previously the trailing slash was added before the model saw the value, leaving the served +issuer inconsistent with what clients compare against under RFC 8414 / RFC 9207. Passing an +already-built `AnyHttpUrl` object still normalizes at construction; pass a string to get the +preserved form. + ### Lowlevel `Server`: `subscribe` capability now correctly reported Previously, the lowlevel `Server` hardcoded `subscribe=False` in resource capabilities even when a `subscribe_resource()` handler was registered. The `subscribe` capability is now dynamically set to `True` when an `on_subscribe_resource` handler is provided. Clients that previously didn't see `subscribe: true` in capabilities will now see it when a handler is registered, which may change client behavior. diff --git a/src/mcp/server/auth/settings.py b/src/mcp/server/auth/settings.py index 1649826db2..f88dc147dc 100644 --- a/src/mcp/server/auth/settings.py +++ b/src/mcp/server/auth/settings.py @@ -1,4 +1,4 @@ -from pydantic import AnyHttpUrl, BaseModel, Field +from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field class ClientRegistrationOptions(BaseModel): @@ -13,6 +13,12 @@ class RevocationOptions(BaseModel): class AuthSettings(BaseModel): + # Preserve empty URL paths so a path-less issuer/resource passed as a string keeps its + # canonical form (no trailing slash). RFC 8414/9207 issuer comparison is exact string + # comparison, so a spurious trailing slash would break it. See PR #2925 for the metadata + # models; this applies the same to the server's own configured URLs. + model_config = ConfigDict(url_preserve_empty_path=True) + issuer_url: AnyHttpUrl = Field( ..., description="OAuth authorization server URL that issues tokens for this resource server.", diff --git a/tests/server/auth/test_routes.py b/tests/server/auth/test_routes.py index 3d13b5ba53..58685c64c7 100644 --- a/tests/server/auth/test_routes.py +++ b/tests/server/auth/test_routes.py @@ -1,7 +1,8 @@ import pytest from pydantic import AnyHttpUrl -from mcp.server.auth.routes import validate_issuer_url +from mcp.server.auth.routes import build_metadata, validate_issuer_url +from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions def test_validate_issuer_url_https_allowed(): @@ -45,3 +46,27 @@ def test_validate_issuer_url_fragment_rejected(): def test_validate_issuer_url_query_rejected(): with pytest.raises(ValueError, match="query"): validate_issuer_url(AnyHttpUrl("https://example.com/path?q=1")) + + +def test_auth_settings_preserves_path_less_issuer(): + """A path-less issuer passed as a string keeps its canonical form (no trailing slash).""" + settings = AuthSettings( + issuer_url="https://as.example.com", # type: ignore[arg-type] + resource_server_url="https://rs.example.com", # type: ignore[arg-type] + ) + assert str(settings.issuer_url) == "https://as.example.com" + assert str(settings.resource_server_url) == "https://rs.example.com" + + +def test_build_metadata_serves_issuer_without_trailing_slash(): + """The served issuer matches the configured one exactly (RFC 8414/9207 string comparison).""" + settings = AuthSettings( + issuer_url="https://as.example.com", # type: ignore[arg-type] + resource_server_url="https://rs.example.com", # type: ignore[arg-type] + ) + metadata = build_metadata(settings.issuer_url, None, ClientRegistrationOptions(), RevocationOptions()) + + served = metadata.model_dump(mode="json", exclude_none=True) + assert served["issuer"] == "https://as.example.com" + assert served["authorization_endpoint"] == "https://as.example.com/authorize" + assert served["token_endpoint"] == "https://as.example.com/token" From 9dc8c5f02dfbb809a2321c382fe57a0913679373 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:54:28 +0200 Subject: [PATCH 004/100] find_invalid_x_mcp_header: never repr a non-string annotation value (#2989) Co-authored-by: Marcelo Trylesinski --- src/mcp/shared/inbound.py | 14 ++++++- tests/shared/test_inbound.py | 74 +++++++++++++++++++++++++----------- 2 files changed, 64 insertions(+), 24 deletions(-) diff --git a/src/mcp/shared/inbound.py b/src/mcp/shared/inbound.py index 1c70e3d926..a2a2a9c271 100644 --- a/src/mcp/shared/inbound.py +++ b/src/mcp/shared/inbound.py @@ -191,10 +191,20 @@ def find_invalid_x_mcp_header(input_schema: Any) -> str | None: return f"{X_MCP_HEADER_KEY} found at a schema position not reachable via a pure `properties` chain" where = ".".join(path) header = schema[X_MCP_HEADER_KEY] - if not isinstance(header, str) or not _RFC9110_TOKEN.fullmatch(header): + # Wrong type and malformed value are distinct failures with distinct messages: the + # non-str arm returns before any interpolation, because `repr` of an arbitrary + # schema value is not total (a large `int` exceeds `sys.get_int_max_str_digits`). + if not isinstance(header, str): + return f"property {where!r}: {X_MCP_HEADER_KEY} must be a string, not {type(header).__name__}" + if not _RFC9110_TOKEN.fullmatch(header): return f"property {where!r}: {X_MCP_HEADER_KEY} {header!r} is not an RFC 9110 token" prop_type = schema.get("type") - if not isinstance(prop_type, str) or prop_type not in _X_MCP_HEADER_PRIMITIVE_TYPES: + if not isinstance(prop_type, str): + return ( + f"property {where!r}: {X_MCP_HEADER_KEY} is only permitted on " + f"integer/string/boolean properties (the type keyword is {type(prop_type).__name__}, not a string)" + ) + if prop_type not in _X_MCP_HEADER_PRIMITIVE_TYPES: return ( f"property {where!r}: {X_MCP_HEADER_KEY} is only permitted on " f"integer/string/boolean properties (got {prop_type!r})" diff --git a/tests/shared/test_inbound.py b/tests/shared/test_inbound.py index 93ab6ecc22..8478c37339 100644 --- a/tests/shared/test_inbound.py +++ b/tests/shared/test_inbound.py @@ -26,6 +26,9 @@ from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS from mcp.shared.inbound import ( + _SUBSCHEMA_LIST, + _SUBSCHEMA_MAP, + _SUBSCHEMA_SINGLE, ERROR_CODE_HTTP_STATUS, MCP_METHOD_HEADER, MCP_NAME_HEADER, @@ -378,6 +381,10 @@ def _schema(**props: Any) -> dict[str, Any]: _schema(a={"type": "string", "const": {"x-mcp-header": "ignored"}}), id="annotation-lookalike-in-const-is-data", ), + pytest.param( + _schema(a={"type": "string", "enum": [{"x-mcp-header": "ignored"}]}), + id="annotation-lookalike-in-enum-is-data", + ), pytest.param( {"properties": {"a": {"type": "string", "x-mcp-header": "R"}}, "$ref": "#/$defs/loop"}, id="ref-is-not-dereferenced", @@ -404,12 +411,14 @@ def test_find_invalid_x_mcp_header_accepts_valid_or_absent_annotations(input_sch pytest.param(_schema(a={"type": "string", "x-mcp-header": "Région"}), id="non-ascii"), pytest.param(_schema(a={"type": "string", "x-mcp-header": "Region\t1"}), id="control-char"), pytest.param(_schema(a={"type": "string", "x-mcp-header": 42}), id="non-string"), + pytest.param(_schema(a={"type": "string", "x-mcp-header": 10**5000}), id="oversized-int-header"), pytest.param(_schema(a={"type": "object", "x-mcp-header": "Data"}), id="on-object"), pytest.param(_schema(a={"type": "array", "x-mcp-header": "Items"}), id="on-array"), pytest.param(_schema(a={"type": "null", "x-mcp-header": "Nil"}), id="on-null"), pytest.param(_schema(a={"type": "number", "x-mcp-header": "Ratio"}), id="on-number"), pytest.param(_schema(a={"type": ["string", "null"], "x-mcp-header": "Maybe"}), id="array-type"), pytest.param(_schema(a={"type": {"not": "valid"}, "x-mcp-header": "Bad"}), id="dict-type"), + pytest.param(_schema(a={"type": 10**5000, "x-mcp-header": "Big"}), id="oversized-int-type"), pytest.param(_schema(a={"x-mcp-header": "NoType"}), id="missing-type"), pytest.param( _schema(a={"type": "string", "x-mcp-header": "Region"}, b={"type": "string", "x-mcp-header": "Region"}), @@ -420,28 +429,8 @@ def test_find_invalid_x_mcp_header_accepts_valid_or_absent_annotations(input_sch id="duplicate-diff-case", ), pytest.param( - _schema(a={"type": "array", "items": {"type": "string", "x-mcp-header": "X"}}), - id="under-items", - ), - pytest.param( - {"allOf": [{"properties": {"a": {"type": "string", "x-mcp-header": "X"}}}]}, - id="under-allOf", - ), - pytest.param( - {"oneOf": [{"type": "string", "x-mcp-header": "X"}]}, - id="under-oneOf", - ), - pytest.param( - _schema(a={"if": {"type": "string", "x-mcp-header": "X"}}), - id="under-if", - ), - pytest.param( - {"$defs": {"T": {"type": "string", "x-mcp-header": "X"}}, "properties": {}}, - id="under-defs", - ), - pytest.param( - {"patternProperties": {"^a": {"type": "string", "x-mcp-header": "X"}}}, - id="under-patternProperties", + {"allOf": [{"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "X"}}}]}, + id="properties-chain-not-restored-below-an-applicator", ), pytest.param( {"type": "string", "x-mcp-header": "X"}, @@ -470,6 +459,47 @@ def test_find_invalid_x_mcp_header_rejects_malformed_annotations(input_schema: d assert isinstance(find_invalid_x_mcp_header(input_schema), str) +# Keyword → a value of that keyword's own JSON Schema shape carrying an annotated subschema. +# Deliberately a literal table, independent of the `_SUBSCHEMA_*` sets in `inbound.py`: +# dropping a keyword from the walk must FAIL its case here, not shrink the parametrization. +_ANNOTATED = {"type": "string", "x-mcp-header": "Region"} +_APPLICATOR_CASES: dict[str, Any] = { + "$defs": {"T": _ANNOTATED}, + "additionalProperties": _ANNOTATED, + "allOf": [_ANNOTATED], + "anyOf": [_ANNOTATED], + "contains": _ANNOTATED, + "contentSchema": _ANNOTATED, + "definitions": {"T": _ANNOTATED}, + "dependentSchemas": {"k": _ANNOTATED}, + "else": _ANNOTATED, + "if": _ANNOTATED, + "items": _ANNOTATED, + "not": _ANNOTATED, + "oneOf": [_ANNOTATED], + "patternProperties": {"^a": _ANNOTATED}, + "prefixItems": [_ANNOTATED], + "propertyNames": _ANNOTATED, + "then": _ANNOTATED, + "unevaluatedItems": _ANNOTATED, + "unevaluatedProperties": _ANNOTATED, +} + + +@pytest.mark.parametrize("keyword", sorted(_APPLICATOR_CASES)) +def test_find_invalid_x_mcp_header_rejects_annotations_under_every_non_properties_applicator(keyword: str) -> None: + """Spec-mandated: a property reached through any applicator other than `properties` is not + statically reachable, so its annotation invalidates the whole tool definition.""" + schema = _schema(ok={"type": "string"}) | {keyword: _APPLICATOR_CASES[keyword]} + assert isinstance(find_invalid_x_mcp_header(schema), str) + + +def test_schema_walk_applicator_keywords_match_the_pinned_reject_cases() -> None: + """SDK-defined: a keyword added to the walk must gain a literal reject case above (a removed + keyword already fails its case there).""" + assert _SUBSCHEMA_LIST | _SUBSCHEMA_MAP | _SUBSCHEMA_SINGLE == set(_APPLICATOR_CASES) + + def test_find_invalid_x_mcp_header_reports_dotted_path_for_nested_property() -> None: """SDK-defined: the reason string names the nested property by its dotted `properties` path.""" schema = _schema(outer={"type": "object", "properties": {"r": {"type": "object", "x-mcp-header": "R"}}}) From 4caa41f6d5c68bbbc141ed65157189e4262c3c3b Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:02:27 +0200 Subject: [PATCH 005/100] Add story-style examples suite (27 stories + harness + CI) (#2957) --- .github/workflows/shared.yml | 4 + examples/README.md | 25 ++- examples/pyproject.toml | 16 ++ examples/stories/README.md | 164 ++++++++++++++ examples/stories/__init__.py | 6 + examples/stories/_harness.py | 203 ++++++++++++++++++ examples/stories/_hosting.py | 87 ++++++++ examples/stories/_shared/__init__.py | 1 + examples/stories/_shared/auth.py | 159 ++++++++++++++ examples/stories/apps/README.md | 14 ++ examples/stories/bearer_auth/README.md | 96 +++++++++ examples/stories/bearer_auth/__init__.py | 0 examples/stories/bearer_auth/client.py | 48 +++++ examples/stories/bearer_auth/server.py | 56 +++++ .../stories/bearer_auth/server_lowlevel.py | 56 +++++ examples/stories/caching/README.md | 20 ++ examples/stories/custom_methods/README.md | 53 +++++ examples/stories/custom_methods/__init__.py | 0 examples/stories/custom_methods/client.py | 39 ++++ examples/stories/custom_methods/server.py | 39 ++++ examples/stories/dual_era/README.md | 58 +++++ examples/stories/dual_era/__init__.py | 0 examples/stories/dual_era/client.py | 41 ++++ examples/stories/dual_era/server.py | 25 +++ examples/stories/dual_era/server_lowlevel.py | 50 +++++ examples/stories/error_handling/README.md | 52 +++++ examples/stories/error_handling/__init__.py | 0 examples/stories/error_handling/client.py | 38 ++++ examples/stories/error_handling/server.py | 35 +++ .../stories/error_handling/server_lowlevel.py | 45 ++++ examples/stories/events/README.md | 21 ++ examples/stories/json_response/README.md | 70 ++++++ examples/stories/json_response/__init__.py | 0 examples/stories/json_response/client.py | 69 ++++++ examples/stories/json_response/server.py | 27 +++ .../stories/json_response/server_lowlevel.py | 44 ++++ examples/stories/legacy_elicitation/README.md | 72 +++++++ .../stories/legacy_elicitation/__init__.py | 0 examples/stories/legacy_elicitation/client.py | 32 +++ examples/stories/legacy_elicitation/server.py | 47 ++++ .../legacy_elicitation/server_lowlevel.py | 70 ++++++ examples/stories/legacy_routing/README.md | 109 ++++++++++ examples/stories/legacy_routing/__init__.py | 0 examples/stories/legacy_routing/client.py | 62 ++++++ examples/stories/legacy_routing/server.py | 65 ++++++ .../stories/legacy_routing/server_lowlevel.py | 48 +++++ examples/stories/lifespan/README.md | 53 +++++ examples/stories/lifespan/__init__.py | 0 examples/stories/lifespan/client.py | 22 ++ examples/stories/lifespan/server.py | 39 ++++ examples/stories/lifespan/server_lowlevel.py | 66 ++++++ examples/stories/manifest.toml | 148 +++++++++++++ examples/stories/middleware/README.md | 56 +++++ examples/stories/middleware/__init__.py | 0 examples/stories/middleware/client.py | 27 +++ examples/stories/middleware/server.py | 54 +++++ examples/stories/mrtr/README.md | 30 +++ examples/stories/oauth/README.md | 92 ++++++++ examples/stories/oauth/__init__.py | 0 examples/stories/oauth/client.py | 61 ++++++ examples/stories/oauth/server.py | 40 ++++ examples/stories/oauth/server_lowlevel.py | 58 +++++ .../oauth_client_credentials/README.md | 78 +++++++ .../oauth_client_credentials/__init__.py | 0 .../oauth_client_credentials/client.py | 46 ++++ .../oauth_client_credentials/server.py | 77 +++++++ .../server_lowlevel.py | 82 +++++++ examples/stories/pagination/README.md | 52 +++++ examples/stories/pagination/__init__.py | 0 examples/stories/pagination/client.py | 27 +++ examples/stories/pagination/server.py | 24 +++ .../stories/pagination/server_lowlevel.py | 36 ++++ examples/stories/parallel_calls/README.md | 60 ++++++ examples/stories/parallel_calls/__init__.py | 0 examples/stories/parallel_calls/client.py | 40 ++++ examples/stories/parallel_calls/server.py | 31 +++ .../stories/parallel_calls/server_lowlevel.py | 48 +++++ examples/stories/prompts/README.md | 50 +++++ examples/stories/prompts/__init__.py | 0 examples/stories/prompts/client.py | 39 ++++ examples/stories/prompts/server.py | 43 ++++ examples/stories/prompts/server_lowlevel.py | 87 ++++++++ examples/stories/reconnect/README.md | 56 +++++ examples/stories/reconnect/__init__.py | 0 examples/stories/reconnect/client.py | 44 ++++ examples/stories/reconnect/server.py | 23 ++ examples/stories/reconnect/server_lowlevel.py | 48 +++++ examples/stories/resources/README.md | 50 +++++ examples/stories/resources/__init__.py | 0 examples/stories/resources/client.py | 30 +++ examples/stories/resources/server.py | 24 +++ examples/stories/resources/server_lowlevel.py | 65 ++++++ examples/stories/roots/README.md | 58 +++++ examples/stories/roots/__init__.py | 0 examples/stories/roots/client.py | 31 +++ examples/stories/roots/server.py | 19 ++ examples/stories/roots/server_lowlevel.py | 36 ++++ examples/stories/sampling/README.md | 62 ++++++ examples/stories/sampling/__init__.py | 0 examples/stories/sampling/client.py | 30 +++ examples/stories/sampling/server.py | 25 +++ examples/stories/sampling/server_lowlevel.py | 45 ++++ examples/stories/schema_validators/README.md | 52 +++++ .../stories/schema_validators/__init__.py | 0 examples/stories/schema_validators/client.py | 38 ++++ examples/stories/schema_validators/server.py | 59 +++++ .../schema_validators/server_lowlevel.py | 55 +++++ examples/stories/serve_one/README.md | 60 ++++++ examples/stories/serve_one/__init__.py | 0 examples/stories/serve_one/client.py | 39 ++++ examples/stories/serve_one/server.py | 110 ++++++++++ examples/stories/skills/README.md | 14 ++ examples/stories/sse_polling/README.md | 76 +++++++ examples/stories/sse_polling/__init__.py | 0 examples/stories/sse_polling/client.py | 32 +++ examples/stories/sse_polling/event_store.py | 34 +++ examples/stories/sse_polling/server.py | 35 +++ .../stories/sse_polling/server_lowlevel.py | 45 ++++ examples/stories/standalone_get/README.md | 67 ++++++ examples/stories/standalone_get/__init__.py | 0 examples/stories/standalone_get/client.py | 40 ++++ examples/stories/standalone_get/server.py | 30 +++ .../stories/standalone_get/server_lowlevel.py | 49 +++++ examples/stories/starlette_mount/README.md | 58 +++++ examples/stories/starlette_mount/__init__.py | 0 examples/stories/starlette_mount/client.py | 23 ++ examples/stories/starlette_mount/server.py | 47 ++++ examples/stories/stateless_legacy/README.md | 59 +++++ examples/stories/stateless_legacy/__init__.py | 0 examples/stories/stateless_legacy/client.py | 37 ++++ examples/stories/stateless_legacy/server.py | 22 ++ .../stateless_legacy/server_lowlevel.py | 38 ++++ examples/stories/stickynotes/README.md | 62 ++++++ examples/stories/stickynotes/__init__.py | 0 examples/stories/stickynotes/client.py | 81 +++++++ examples/stories/stickynotes/server.py | 99 +++++++++ .../stories/stickynotes/server_lowlevel.py | 119 ++++++++++ examples/stories/streaming/README.md | 80 +++++++ examples/stories/streaming/__init__.py | 0 examples/stories/streaming/client.py | 54 +++++ examples/stories/streaming/server.py | 40 ++++ examples/stories/streaming/server_lowlevel.py | 69 ++++++ examples/stories/subscriptions/README.md | 27 +++ examples/stories/tasks/README.md | 16 ++ examples/stories/tools/README.md | 39 ++++ examples/stories/tools/__init__.py | 0 examples/stories/tools/client.py | 32 +++ examples/stories/tools/server.py | 37 ++++ examples/stories/tools/server_lowlevel.py | 72 +++++++ pyproject.toml | 20 +- src/mcp/server/elicitation.py | 2 +- src/mcp/server/lowlevel/server.py | 2 +- src/mcp/server/mcpserver/server.py | 6 +- src/mcp/shared/exceptions.py | 2 +- tests/examples/__init__.py | 0 tests/examples/conftest.py | 170 +++++++++++++++ tests/examples/test_stories.py | 74 +++++++ tests/examples/test_stories_smoke.py | 57 +++++ tests/examples/test_story_shape.py | 122 +++++++++++ uv.lock | 48 +++-- 160 files changed, 6801 insertions(+), 26 deletions(-) create mode 100644 examples/pyproject.toml create mode 100644 examples/stories/README.md create mode 100644 examples/stories/__init__.py create mode 100644 examples/stories/_harness.py create mode 100644 examples/stories/_hosting.py create mode 100644 examples/stories/_shared/__init__.py create mode 100644 examples/stories/_shared/auth.py create mode 100644 examples/stories/apps/README.md create mode 100644 examples/stories/bearer_auth/README.md create mode 100644 examples/stories/bearer_auth/__init__.py create mode 100644 examples/stories/bearer_auth/client.py create mode 100644 examples/stories/bearer_auth/server.py create mode 100644 examples/stories/bearer_auth/server_lowlevel.py create mode 100644 examples/stories/caching/README.md create mode 100644 examples/stories/custom_methods/README.md create mode 100644 examples/stories/custom_methods/__init__.py create mode 100644 examples/stories/custom_methods/client.py create mode 100644 examples/stories/custom_methods/server.py create mode 100644 examples/stories/dual_era/README.md create mode 100644 examples/stories/dual_era/__init__.py create mode 100644 examples/stories/dual_era/client.py create mode 100644 examples/stories/dual_era/server.py create mode 100644 examples/stories/dual_era/server_lowlevel.py create mode 100644 examples/stories/error_handling/README.md create mode 100644 examples/stories/error_handling/__init__.py create mode 100644 examples/stories/error_handling/client.py create mode 100644 examples/stories/error_handling/server.py create mode 100644 examples/stories/error_handling/server_lowlevel.py create mode 100644 examples/stories/events/README.md create mode 100644 examples/stories/json_response/README.md create mode 100644 examples/stories/json_response/__init__.py create mode 100644 examples/stories/json_response/client.py create mode 100644 examples/stories/json_response/server.py create mode 100644 examples/stories/json_response/server_lowlevel.py create mode 100644 examples/stories/legacy_elicitation/README.md create mode 100644 examples/stories/legacy_elicitation/__init__.py create mode 100644 examples/stories/legacy_elicitation/client.py create mode 100644 examples/stories/legacy_elicitation/server.py create mode 100644 examples/stories/legacy_elicitation/server_lowlevel.py create mode 100644 examples/stories/legacy_routing/README.md create mode 100644 examples/stories/legacy_routing/__init__.py create mode 100644 examples/stories/legacy_routing/client.py create mode 100644 examples/stories/legacy_routing/server.py create mode 100644 examples/stories/legacy_routing/server_lowlevel.py create mode 100644 examples/stories/lifespan/README.md create mode 100644 examples/stories/lifespan/__init__.py create mode 100644 examples/stories/lifespan/client.py create mode 100644 examples/stories/lifespan/server.py create mode 100644 examples/stories/lifespan/server_lowlevel.py create mode 100644 examples/stories/manifest.toml create mode 100644 examples/stories/middleware/README.md create mode 100644 examples/stories/middleware/__init__.py create mode 100644 examples/stories/middleware/client.py create mode 100644 examples/stories/middleware/server.py create mode 100644 examples/stories/mrtr/README.md create mode 100644 examples/stories/oauth/README.md create mode 100644 examples/stories/oauth/__init__.py create mode 100644 examples/stories/oauth/client.py create mode 100644 examples/stories/oauth/server.py create mode 100644 examples/stories/oauth/server_lowlevel.py create mode 100644 examples/stories/oauth_client_credentials/README.md create mode 100644 examples/stories/oauth_client_credentials/__init__.py create mode 100644 examples/stories/oauth_client_credentials/client.py create mode 100644 examples/stories/oauth_client_credentials/server.py create mode 100644 examples/stories/oauth_client_credentials/server_lowlevel.py create mode 100644 examples/stories/pagination/README.md create mode 100644 examples/stories/pagination/__init__.py create mode 100644 examples/stories/pagination/client.py create mode 100644 examples/stories/pagination/server.py create mode 100644 examples/stories/pagination/server_lowlevel.py create mode 100644 examples/stories/parallel_calls/README.md create mode 100644 examples/stories/parallel_calls/__init__.py create mode 100644 examples/stories/parallel_calls/client.py create mode 100644 examples/stories/parallel_calls/server.py create mode 100644 examples/stories/parallel_calls/server_lowlevel.py create mode 100644 examples/stories/prompts/README.md create mode 100644 examples/stories/prompts/__init__.py create mode 100644 examples/stories/prompts/client.py create mode 100644 examples/stories/prompts/server.py create mode 100644 examples/stories/prompts/server_lowlevel.py create mode 100644 examples/stories/reconnect/README.md create mode 100644 examples/stories/reconnect/__init__.py create mode 100644 examples/stories/reconnect/client.py create mode 100644 examples/stories/reconnect/server.py create mode 100644 examples/stories/reconnect/server_lowlevel.py create mode 100644 examples/stories/resources/README.md create mode 100644 examples/stories/resources/__init__.py create mode 100644 examples/stories/resources/client.py create mode 100644 examples/stories/resources/server.py create mode 100644 examples/stories/resources/server_lowlevel.py create mode 100644 examples/stories/roots/README.md create mode 100644 examples/stories/roots/__init__.py create mode 100644 examples/stories/roots/client.py create mode 100644 examples/stories/roots/server.py create mode 100644 examples/stories/roots/server_lowlevel.py create mode 100644 examples/stories/sampling/README.md create mode 100644 examples/stories/sampling/__init__.py create mode 100644 examples/stories/sampling/client.py create mode 100644 examples/stories/sampling/server.py create mode 100644 examples/stories/sampling/server_lowlevel.py create mode 100644 examples/stories/schema_validators/README.md create mode 100644 examples/stories/schema_validators/__init__.py create mode 100644 examples/stories/schema_validators/client.py create mode 100644 examples/stories/schema_validators/server.py create mode 100644 examples/stories/schema_validators/server_lowlevel.py create mode 100644 examples/stories/serve_one/README.md create mode 100644 examples/stories/serve_one/__init__.py create mode 100644 examples/stories/serve_one/client.py create mode 100644 examples/stories/serve_one/server.py create mode 100644 examples/stories/skills/README.md create mode 100644 examples/stories/sse_polling/README.md create mode 100644 examples/stories/sse_polling/__init__.py create mode 100644 examples/stories/sse_polling/client.py create mode 100644 examples/stories/sse_polling/event_store.py create mode 100644 examples/stories/sse_polling/server.py create mode 100644 examples/stories/sse_polling/server_lowlevel.py create mode 100644 examples/stories/standalone_get/README.md create mode 100644 examples/stories/standalone_get/__init__.py create mode 100644 examples/stories/standalone_get/client.py create mode 100644 examples/stories/standalone_get/server.py create mode 100644 examples/stories/standalone_get/server_lowlevel.py create mode 100644 examples/stories/starlette_mount/README.md create mode 100644 examples/stories/starlette_mount/__init__.py create mode 100644 examples/stories/starlette_mount/client.py create mode 100644 examples/stories/starlette_mount/server.py create mode 100644 examples/stories/stateless_legacy/README.md create mode 100644 examples/stories/stateless_legacy/__init__.py create mode 100644 examples/stories/stateless_legacy/client.py create mode 100644 examples/stories/stateless_legacy/server.py create mode 100644 examples/stories/stateless_legacy/server_lowlevel.py create mode 100644 examples/stories/stickynotes/README.md create mode 100644 examples/stories/stickynotes/__init__.py create mode 100644 examples/stories/stickynotes/client.py create mode 100644 examples/stories/stickynotes/server.py create mode 100644 examples/stories/stickynotes/server_lowlevel.py create mode 100644 examples/stories/streaming/README.md create mode 100644 examples/stories/streaming/__init__.py create mode 100644 examples/stories/streaming/client.py create mode 100644 examples/stories/streaming/server.py create mode 100644 examples/stories/streaming/server_lowlevel.py create mode 100644 examples/stories/subscriptions/README.md create mode 100644 examples/stories/tasks/README.md create mode 100644 examples/stories/tools/README.md create mode 100644 examples/stories/tools/__init__.py create mode 100644 examples/stories/tools/client.py create mode 100644 examples/stories/tools/server.py create mode 100644 examples/stories/tools/server_lowlevel.py create mode 100644 tests/examples/__init__.py create mode 100644 tests/examples/conftest.py create mode 100644 tests/examples/test_stories.py create mode 100644 tests/examples/test_stories_smoke.py create mode 100644 tests/examples/test_story_shape.py diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index cdf2037332..21a70f46ef 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -79,6 +79,10 @@ jobs: - name: Run pytest with coverage shell: bash + env: + # tests/examples/test_stories_smoke.py is gated on this var; it spawns real + # stdio + uvicorn subprocesses, so run it on exactly one matrix cell. + MCP_EXAMPLES_SMOKE: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' && matrix.dep-resolution.name == 'locked' && '1' || '' }} run: | uv run --frozen --no-sync coverage erase uv run --frozen --no-sync coverage run -m pytest -n auto diff --git a/examples/README.md b/examples/README.md index 5ed4dd55f5..0a283e1356 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,5 +1,22 @@ -# 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. +- [`clients/`](clients/) and the remaining [`servers/`](servers/) directories + (`simple-*`, `sse-polling-demo`, `structured-output-lowlevel`) — standalone + v1-era projects still linked from `README.v2.md`; retained pending + consolidation into `stories/`. + +For real-world servers see the +[servers repository](https://github.com/modelcontextprotocol/servers). diff --git a/examples/pyproject.toml b/examples/pyproject.toml new file mode 100644 index 0000000000..7b01095912 --- /dev/null +++ b/examples/pyproject.toml @@ -0,0 +1,16 @@ +[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", + "tomli>=2.0; python_version < '3.11'", +] + +[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..93f04a014b --- /dev/null +++ b/examples/stories/README.md @@ -0,0 +1,164 @@ +# 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 + +# HTTP, self-hosted — the client spawns the server on a real uvicorn socket on a +# port it owns, waits for it, runs, then terminates it. Nothing to background or kill. +uv run python -m stories.tools.client --http + +# the same self-hosted run against the story's lowlevel-API server variant +uv run python -m stories.tools.client --http --server server_lowlevel + +# HTTP against a server you run yourself +uv run python -m stories.tools.server --http --port 8000 # separate terminal +uv run python -m stories.tools.client --http http://127.0.0.1:8000/mcp +``` + +`--http` takes two forms. Bare `--http` is the canonical HTTP run — it is +complete on its own, and it is what every per-story README shows. `--http +` connects to a server you started yourself; the per-story READMEs spell +that out only where hosting is the lesson (the HTTP-hosting and auth stories). +`--server ` swaps in a sibling server module on stdio and on the +self-hosted `--http` run; with `--http ` you already picked the server +when you started it. The auth stories (`bearer_auth/`, `oauth/`, +`oauth_client_credentials/`) self-host on their fixed `:8000` instead of a +free port because their issuer/PRM metadata bake it in — `:8000` must be +free, and the run refuses to start (rather than silently testing whatever is +there) if it is not. + +The full matrix (every story × transport × era × server-variant) runs under +pytest: + +```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 self-hosted +HTTP subprocess under bare `--http`, your URL under `--http `). They +isolate the parts of the SDK's hosting surface +that are still moving — **don't copy them into your own project**; copy the +`server.py` / `client.py` bodies instead. `_shared/` holds an in-process OAuth +authorization server reused by the auth stories. + +## 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(stateless_http=True)`; the one-liner deploy | current | +| [`json_response`](json_response/) | `json_response=True` mode; raw 2026 POST envelope on the wire | current | +| [`legacy_routing`](legacy_routing/) | `classify_inbound_request()` era routing in front of a sessionful 1.x deploy | current | +| [`starlette_mount`](starlette_mount/) | mounting `streamable_http_app()` under a Starlette/FastAPI sub-path | current | +| [`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..c7036acd68 --- /dev/null +++ b/examples/stories/_harness.py @@ -0,0 +1,203 @@ +"""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 socket +import sys +import traceback +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import AsyncExitStack, asynccontextmanager +from pathlib import Path +from typing import Any, TypeAlias +from urllib.parse import urlsplit + +import anyio +import httpx +from mcp_types.version import LATEST_MODERN_VERSION + +from mcp import StdioServerParameters, stdio_client +from mcp.client import Transport +from mcp.client.streamable_http import streamable_http_client +from mcp.server import Server +from mcp.server.mcpserver import MCPServer + +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, url: str | None) -> TargetFactory: + """Build a ``TargetFactory`` for the sibling server of the ``client.py`` at ``file``. + + ``url`` (already resolved by ``run_client``) targets that streamable-HTTP endpoint; ``None`` + spawns ``.py`` over stdio per call, ```` from ``--server`` (default ``server``). + """ + if url is not None: + return lambda: url + # stdio is legacy-only until serve_stdio() lands; the modern arm is --http only for now. + server = Path(file).parent / f"{argv_after('--server', default='server')}.py" + params = StdioServerParameters(command=sys.executable, args=[str(server)]) + return lambda: stdio_client(params) # becomes Client(params) once that overload lands + + +def _explicit_http_url() -> str | None: + """The URL token after ``--http``, or ``None`` when the flag stands alone (self-host).""" + rest = sys.argv[sys.argv.index("--http") + 1 :] + return rest[0] if rest and not rest[0].startswith("-") else None + + +def _free_port() -> int: + """An OS-assigned free TCP port, released for the server subprocess to re-bind.""" + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +async def _accepting(port: int) -> bool: + """Whether something accepts a TCP connect on ``127.0.0.1:port`` right now.""" + try: + stream = await anyio.connect_tcp("127.0.0.1", port) + except OSError: + return False + await stream.aclose() + return True + + +@asynccontextmanager +async def _self_hosted(name: str, cfg: dict[str, Any]) -> AsyncIterator[str]: + """Serve the story's sibling server from a subprocess on a port this process owns; yield its URL. + + Readiness is the first accepted TCP connect (bounded by ``run_client``'s + ``anyio.fail_after``); exiting terminates the subprocess. Nothing to background or kill. + A subprocess that dies before serving, or a ``fixed_port`` someone else already holds, + is a loud ``SystemExit`` rather than a hang or a run against the wrong server. + """ + port: int = cfg["fixed_port"] or _free_port() + if cfg["fixed_port"] and await _accepting(port): + # The readiness probe below can't tell our child from a server already on the + # story's pinned port, so a foreign listener would be tested in its place. + raise SystemExit( + f"{name} self-hosts on :{port} but something is already serving there; " + f"stop it, or connect to it with --http " + ) + module = f"stories.{name}.{argv_after('--server', default='server')}" + serve = ["--http"] if cfg["server_export"] == "factory" else [] + argv = [sys.executable, "-m", module, *serve, "--port", str(port)] + async with await anyio.open_process(argv, stdout=None, stderr=None) as server: + try: + while server.returncode is None and not await _accepting(port): + await anyio.sleep(0.05) + if server.returncode is not None: + raise SystemExit(f"{module} exited {server.returncode} before serving on :{port}") + yield f"http://127.0.0.1:{port}{cfg['mcp_path']}" + finally: + if server.returncode is None: + server.terminate() + + +def _story_cfg(name: str) -> dict[str, Any]: + """The manifest entry for the story ``name`` with ``[defaults]`` applied.""" + manifest: dict[str, Any] = tomllib.loads((Path(__file__).parent / "manifest.toml").read_text()) + 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``. + + Resolves the argv target — stdio (the default), ``--http `` for a server you run, or + bare ``--http`` to self-host the sibling server in a subprocess it owns — and calls ``main`` + with an explicit ``mode=``. A ``build_auth`` export auths the HTTP target. ``OK``/``FAIL``, exit 0/1. + """ + globals_ = getattr(main, "__globals__", {}) + file = str(globals_.get("__file__", "")) + name = Path(file).parent.name + cfg = _story_cfg(name) + build_auth: AuthBuilder | None = globals_.get("build_auth") + transport = "http" if "--http" in sys.argv else "stdio" + if cfg["server_export"] == "app" and transport != "http": + raise SystemExit( + f"{name} exports an ASGI app (no stdio entry point); self-host it over HTTP:\n" + f" python -m stories.{name}.client --http" + ) + if cfg["needs_http"] and transport != "http": + raise SystemExit(f"{name} asserts on raw HTTP responses; run it with --http") + explicit_url = _explicit_http_url() if transport == "http" else None + # The era is an axis of the story matrix, so ``mode=`` is always passed explicitly + # even though it often matches the ``Client`` default of "auto". stdio is legacy-only + # until the SDK's stdio entry can negotiate the era, so only --http gets a modern arm. + era = "modern" if transport == "http" and "--legacy" not in sys.argv else "legacy" + if cfg["era"] in ("legacy", "modern"): + era = cfg["era"] + if cfg["era"] == "dual-in-body": + # The story pins its connection modes inside ``main`` itself, so hand it "auto" + # (the ``Client`` default) and let those in-body pins decide. A hard version pin + # here would skip the discover probe and leave ``server_info`` blank. + era = "in-body" + mode = {"modern": LATEST_MODERN_VERSION, "legacy": "legacy", "in-body": "auto"}[era] + + async def _run() -> None: + with anyio.fail_after(cfg["timeout_s"]): + async with AsyncExitStack() as stack: + url = explicit_url + if transport == "http" and url is None: + url = await stack.enter_async_context(_self_hosted(name, cfg)) + targets = target_from_args(file, url) + if url is None or (build_auth is None and not cfg["needs_http"]): + await main(targets if cfg["multi_connection"] else targets(), mode=mode) + return + # Auth and needs_http stories want the raw httpx client underneath the transport: + # build_auth threads an httpx.Auth onto it (Client(url, auth=...) doesn't exist + # yet), and needs_http stories assert on raw responses, so root the client at the + # server origin and relative paths like "/mcp" resolve. + parts = urlsplit(url) + base = f"{parts.scheme}://{parts.netloc}" + http = await stack.enter_async_context(httpx.AsyncClient(base_url=base)) + make = targets + if build_auth is not None: + http.auth = build_auth(http) + 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..d1d556f94f --- /dev/null +++ b/examples/stories/bearer_auth/README.md @@ -0,0 +1,96 @@ +# 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 +# HTTP — the client self-hosts the bearer-gated app, connects with the demo +# bearer token, then tears it down. Self-hosting uses this story's fixed :8000 +# (the issuer/PRM metadata pin it), so :8000 must be free. +uv run python -m stories.bearer_auth.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.bearer_auth.client --http --server server_lowlevel + +# against a server you run yourself (real uvicorn on :8000). The next section's +# curl probes use it too and `kill` it when done. While it is up it owns :8000, +# so the two self-host lines above refuse to run rather than test it by mistake. +uv run python -m stories.bearer_auth.server --port 8000 & +SERVER_PID=$! +uv run python -m stories.bearer_auth.client --http http://127.0.0.1:8000/mcp +``` + +`Client(url)` has no `auth=` passthrough, so a target built from a bare URL +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 + +# done with the server you started in "Run it" +kill "$SERVER_PID" +``` + +## 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..f5abfc08c4 --- /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 + +import mcp_types as types +from pydantic import AnyHttpUrl +from starlette.applications import Starlette + +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..924ea0298d --- /dev/null +++ b/examples/stories/custom_methods/README.md @@ -0,0 +1,53 @@ +# 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 + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.custom_methods.client --http +``` + +## What to look at + +- `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..4003885fa4 --- /dev/null +++ b/examples/stories/custom_methods/client.py @@ -0,0 +1,39 @@ +"""Send a vendor-prefixed request via the `client.session` escape hatch.""" + +from typing import Literal, cast + +import mcp_types as 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..260aff787c --- /dev/null +++ b/examples/stories/custom_methods/server.py @@ -0,0 +1,39 @@ +"""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 + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + + +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..f14f164027 --- /dev/null +++ b/examples/stories/dual_era/README.md @@ -0,0 +1,58 @@ +# 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; the client self-hosts +# the server on a free port, runs, then tears it down +uv run python -m stories.dual_era.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.dual_era.client --http --server server_lowlevel +``` + +The bare stdio invocation (`uv run python -m stories.dual_era.client`) is +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..ba9acf5d99 --- /dev/null +++ b/examples/stories/dual_era/client.py @@ -0,0 +1,41 @@ +"""Connect to the same server factory twice — once per era, so `main` takes `targets` — and assert both are served.""" + +import mcp_types as types +from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION + +from mcp.client import Client +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..3f70ee63c9 --- /dev/null +++ b/examples/stories/dual_era/server.py @@ -0,0 +1,25 @@ +"""One MCPServer factory that serves both the 2025 handshake era and the 2026 stateless era.""" + +from mcp_types.version import MODERN_PROTOCOL_VERSIONS + +from mcp.server.mcpserver import Context, MCPServer +from 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..b209135e6d --- /dev/null +++ b/examples/stories/dual_era/server_lowlevel.py @@ -0,0 +1,50 @@ +"""One lowlevel Server factory that serves both the 2025 handshake era and the 2026 stateless era.""" + +from typing import Any + +import mcp_types as types +from mcp_types.version import MODERN_PROTOCOL_VERSIONS + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from 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..475a2a0b29 --- /dev/null +++ b/examples/stories/error_handling/README.md @@ -0,0 +1,52 @@ +# 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 + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.error_handling.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.error_handling.client --http --server server_lowlevel +``` + +## What to look at + +- `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. + +## 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..872ec7fe31 --- /dev/null +++ b/examples/stories/error_handling/client.py @@ -0,0 +1,38 @@ +"""Prove the two error channels: is_error results return; MCPError raises.""" + +from mcp_types import INVALID_PARAMS, TextContent + +from mcp import MCPError +from mcp.client import Client +from 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.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..e4f3554433 --- /dev/null +++ b/examples/stories/error_handling/server.py @@ -0,0 +1,35 @@ +"""Two error channels: ToolError -> is_error result; MCPError -> JSON-RPC protocol error.""" + +from mcp_types import INVALID_PARAMS + +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.exceptions import ToolError +from mcp.shared.exceptions import MCPError +from 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..9bb9aef86a --- /dev/null +++ b/examples/stories/error_handling/server_lowlevel.py @@ -0,0 +1,45 @@ +"""Two error channels on lowlevel.Server: return is_error=True yourself, or raise MCPError.""" + +from typing import Any + +import mcp_types as 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..e7dff00f8c --- /dev/null +++ b/examples/stories/json_response/README.md @@ -0,0 +1,70 @@ +# 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 +# HTTP — the client self-hosts the app on a free port, runs the high-level +# Client + raw-envelope probe, then tears it down +uv run python -m stories.json_response.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.json_response.client --http --server server_lowlevel + +# against a server you run yourself (real uvicorn on :8000) +uv run python -m stories.json_response.server --port 8000 & +SERVER_PID=$! +uv run python -m stories.json_response.client --http http://127.0.0.1:8000/mcp + +# or POST the raw envelope yourself +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":{}}}}' +kill "$SERVER_PID" +``` + +## 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/draft/basic/transports/streamable-http) +· [SEP-2243 standard headers](https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#standard-request-headers) + +## See also + +`stateless_legacy/` (the one-liner `stateless_http=True` deploy), +`legacy_routing/` (route by era at the entry), `streaming/` (progress that *is* +delivered — over stdio/SSE). diff --git a/examples/stories/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..08af5ef914 --- /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_types import TextContent +from mcp_types.version import LATEST_MODERN_VERSION + +from mcp.client import Client +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..bcb14eb9ab --- /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 + +import mcp_types as types +from starlette.applications import Starlette + +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..62f4379c3c --- /dev/null +++ b/examples/stories/legacy_elicitation/README.md @@ -0,0 +1,72 @@ +# 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 + +# HTTP — the client self-hosts the server on a free port, runs, then tears it +# down (--legacy: the push request needs the handshake era) +uv run python -m stories.legacy_elicitation.client --http --legacy +# same, against the lowlevel-API server variant +uv run python -m stories.legacy_elicitation.client --http --legacy --server server_lowlevel +``` + +## What to look at + +- `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..52bb95e516 --- /dev/null +++ b/examples/stories/legacy_elicitation/client.py @@ -0,0 +1,32 @@ +"""Auto-answer form and URL elicitations and assert the tool result reflects them.""" + +import mcp_types as 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 ask consent and open params.url in a browser, returning + # `accept` right away; the server's notifications/elicitation/complete arrives + # afterward (once the out-of-band flow finishes) for the client to correlate. + assert params.url.startswith("https://example.com/") + return types.ElicitResult(action="accept") + assert "username" in params.requested_schema["properties"] + 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..d2a6e95a5e --- /dev/null +++ b/examples/stories/legacy_elicitation/server.py @@ -0,0 +1,47 @@ +"""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 must be unique per elicitation, not per provider — scope it to this request. + elicitation_id = f"link-{provider}-{ctx.request_context.request_id}" + answer = await ctx.elicit_url( + f"Sign in to {provider} to link your account", + url=f"https://example.com/oauth/{provider}/authorize", + 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..08c7c3a766 --- /dev/null +++ b/examples/stories/legacy_elicitation/server_lowlevel.py @@ -0,0 +1,70 @@ +"""Elicitation (handshake-era push style) against the low-level Server.""" + +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + +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, related_request_id=ctx.request_id + ) + if answer.action != "accept" or answer.content is None: + return types.CallToolResult(content=[types.TextContent(text=f"registration {answer.action}")]) + text = f"registered {answer.content['username']} (plan: {answer.content.get('plan') or 'free'})" + 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 must be unique per elicitation, not per provider — scope it to this request. + elicitation_id = f"link-{provider}-{ctx.request_id}" + answer = await ctx.session.elicit_url( + f"Sign in to {provider} to link your account", + url=f"https://example.com/oauth/{provider}/authorize", + elicitation_id=elicitation_id, + related_request_id=ctx.request_id, + ) + if answer.action != "accept": + return types.CallToolResult(content=[types.TextContent(text=f"link {answer.action}")]) + 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..66f839c0a2 --- /dev/null +++ b/examples/stories/legacy_routing/README.md @@ -0,0 +1,109 @@ +# 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 recipe (methods, request headers, and `expose_headers`) +browser-based MCP clients need. + +## Run it + +```bash +# HTTP only — the predicate is an HTTP-transport concern. The client +# self-hosts the app on a free port, runs, then tears it down. +uv run python -m stories.legacy_routing.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.legacy_routing.client --http --server server_lowlevel + +# against a server you run yourself (real uvicorn on :8000) +uv run python -m stories.legacy_routing.server --port 8000 & +SERVER_PID=$! +uv run python -m stories.legacy_routing.client --http http://127.0.0.1:8000/mcp +kill "$SERVER_PID" +``` + +## 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` is a malformed-modern request to **reject**, + not route to legacy. When headers are supplied, both `Mcp-Protocol-Version` + and `Mcp-Method` must mirror the body — a disagreement (or an unsupported + version) is what produces that third arm; `client.py` shows both. +- `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` — the CORS recipe re-used from `server.py` (the + `MCP_*` header and method constants); `build_app` wires `lowlevel.Server` + instead of `MCPServer` and reads `ctx.protocol_version` directly. The + predicate is tier-agnostic, so `classify_era` lives only in `server.py`. + +## User-land composition (when you need different backends) + +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..b9b401a2d3 --- /dev/null +++ b/examples/stories/legacy_routing/client.py @@ -0,0 +1,62 @@ +"""Connect at both eras to one app — so `main` takes `targets` — and assert the built-in router and predicate agree.""" + +from typing import Any + +import mcp_types as types +from mcp_types import CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY +from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION + +from mcp.client import Client +from mcp.shared.inbound import MCP_METHOD_HEADER, MCP_PROTOCOL_VERSION_HEADER, InboundLadderRejection +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 2026 _meta envelope whose + # `Mcp-Protocol-Version`/`Mcp-Method` headers mirror it is modern; a bare + # initialize body is legacy; a header that 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: {}, + } + }, + } + modern_headers = {MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION, MCP_METHOD_HEADER: "tools/list"} + assert classify_era(modern_body, headers=modern_headers) == "modern" + + legacy_body: dict[str, Any] = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}} + assert classify_era(legacy_body, headers={}) == "legacy" + + # The SAME complete header set, with only the protocol version disagreeing with the body. + mismatched_headers = modern_headers | {MCP_PROTOCOL_VERSION_HEADER: LATEST_HANDSHAKE_VERSION} + mismatched = classify_era(modern_body, headers=mismatched_headers) + 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..79cc2afa67 --- /dev/null +++ b/examples/stories/legacy_routing/server.py @@ -0,0 +1,65 @@ +"""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 mcp_types import INVALID_PARAMS +from mcp_types.version import MODERN_PROTOCOL_VERSIONS +from starlette.applications import Starlette +from starlette.middleware.cors import CORSMiddleware + +from mcp.server.mcpserver import Context, MCPServer +from mcp.shared.inbound import InboundLadderRejection, InboundModernRoute, classify_inbound_request +from 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"] +#: Request headers a browser-based MCP client must be allowed to send. +MCP_ALLOWED_HEADERS = ["Authorization", "Content-Type", "Mcp-Protocol-Version", "Mcp-Session-Id", "Last-Event-Id"] +#: Streamable HTTP verbs: POST requests, the standalone GET stream, DELETE session end. +MCP_ALLOWED_METHODS = ["GET", "POST", "DELETE"] + + +def classify_era( + 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=["*"], + allow_methods=MCP_ALLOWED_METHODS, + allow_headers=MCP_ALLOWED_HEADERS, + 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..d2f763c8ec --- /dev/null +++ b/examples/stories/legacy_routing/server_lowlevel.py @@ -0,0 +1,48 @@ +"""Exported era classifier (lowlevel API): the same dual-era app + CORS — the predicate stays in `server.py`.""" + +from typing import Any + +import mcp_types as types +from mcp_types.version import MODERN_PROTOCOL_VERSIONS +from starlette.applications import Starlette +from starlette.middleware.cors import CORSMiddleware + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import NO_DNS_REBIND, run_app_from_args + +from .server import MCP_ALLOWED_HEADERS, MCP_ALLOWED_METHODS, 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=["*"], + allow_methods=MCP_ALLOWED_METHODS, + allow_headers=MCP_ALLOWED_HEADERS, + 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..f2cb6c9d3e --- /dev/null +++ b/examples/stories/lifespan/README.md @@ -0,0 +1,53 @@ +# 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 + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.lifespan.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.lifespan.client --http --server server_lowlevel +``` + +## What to look at + +- `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 + `Server.run()` call: once per *session* for stateful streamable HTTP and once + per *request* under `stateless_http=True` (stdio was already per-process). In + v2 it is entered once per process regardless of transport. See + `docs/migration.md` ("Streamable HTTP: lifespan now entered once at manager + startup"). + +## Spec + +[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..51633177fa --- /dev/null +++ b/examples/stories/lifespan/client.py @@ -0,0 +1,22 @@ +"""Prove the lifespan-yielded state is reachable from a tool call.""" + +from mcp_types import TextContent + +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] == ["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..09945c12c3 --- /dev/null +++ b/examples/stories/lifespan/server_lowlevel.py @@ -0,0 +1,66 @@ +"""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 + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + + +@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..7a1f079e8e --- /dev/null +++ b/examples/stories/manifest.toml @@ -0,0 +1,148 @@ +# examples/stories/manifest.toml +# +# 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 +mcp_path = "/mcp" +fixed_port = 0 # `client --http` self-host port; 0 = an OS-assigned free port +xfail = [] # [":", ...] -> strict xfail on that leg +env = {} # env vars set for the leg via monkeypatch + +# ───────────────────────────── start here ───────────────────────────── + +[story.tools] + +[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 + +[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" +fixed_port = 8000 # issuer/PRM metadata bake in :8000 + +[story.oauth] +transports = ["http-asgi"] +server_export = "app" +multi_connection = true +fixed_port = 8000 # issuer/PRM metadata bake in :8000 +env = { OAUTH_DEMO_AUTO_CONSENT = "1" } + +[story.oauth_client_credentials] +transports = ["http-asgi"] +server_export = "app" +fixed_port = 8000 # issuer/PRM metadata bake in :8000 + +# ───────────────────────────── deferred ───────────────────────────── +# README-only placeholders; no client.py, not expanded into legs. +# 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..599f890f80 --- /dev/null +++ b/examples/stories/middleware/README.md @@ -0,0 +1,56 @@ +# 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 + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.middleware.client --http +``` + +## What to look at + +- `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. +- To rewrite `ctx.method` / `ctx.params` before the handler runs, pass an + adjusted context through: `await call_next(dataclasses.replace(ctx, ...))`. + `docs/migration.md` shows the full recipe. + +## Spec + +Middleware is SDK architecture, not an MCP spec feature. + +## See also + +`custom_methods/` (a vendor `acme/search` handler registered with +`add_request_handler` — middleware wraps it like any spec method), +`src/mcp/server/_otel.py` (`OpenTelemetryMiddleware`, the SDK's own consumer). diff --git a/examples/stories/middleware/__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..60ebbbc305 --- /dev/null +++ b/examples/stories/middleware/client.py @@ -0,0 +1,27 @@ +"""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/")] + # The tail ends at tools/call with no :done — the handler ran inside the + # middleware frame. Assert the tail (not the whole list) so a re-run against + # a long-lived server, whose log accumulates across clients, still passes. + assert seen[-3:] == ["tools/list", "tools/list:done", "tools/call"], seen + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/middleware/server.py b/examples/stories/middleware/server.py new file mode 100644 index 0000000000..076120dccd --- /dev/null +++ b/examples/stories/middleware/server.py @@ -0,0 +1,54 @@ +"""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 + +import mcp_types as types + +from mcp.server.context import CallNext, HandlerResult, ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + + +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..6058e3a84d --- /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 is in this base — +[#2967](https://github.com/modelcontextprotocol/python-sdk/pull/2967) +(`ae13ede`) widened the tool/prompt/resource handler return types to include +`InputRequiredResult`. The runnable story is deliberately a follow-up PR to +keep this one reviewable. + +## Spec + +[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..a773a7851c --- /dev/null +++ b/examples/stories/oauth/README.md @@ -0,0 +1,92 @@ +# 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 +# HTTP — the client self-hosts the co-hosted AS + bearer-gated /mcp, runs the +# authorization-code flow (headless: redirect followed in-process), then tears +# it down. Self-hosting uses this story's fixed :8000 (the AS metadata pins +# it), so :8000 must be free. +OAUTH_DEMO_AUTO_CONSENT=1 uv run python -m stories.oauth.client --http +# same, against the lowlevel-API server variant +OAUTH_DEMO_AUTO_CONSENT=1 uv run python -m stories.oauth.client --http --server server_lowlevel + +# against a server you run yourself (real uvicorn on :8000) +OAUTH_DEMO_AUTO_CONSENT=1 uv run python -m stories.oauth.server --port 8000 & +SERVER_PID=$! +uv run python -m stories.oauth.client --http http://127.0.0.1:8000/mcp +kill "$SERVER_PID" +``` + +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` +(above) and the pytest harness build an authed `httpx.AsyncClient` from +this module's `build_auth` export and hand `main` targets that are already +routed through it. + +## 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..0bc7799c1e --- /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 + +import mcp_types as types +from starlette.applications import Starlette + +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..8cd5a5b82c --- /dev/null +++ b/examples/stories/oauth_client_credentials/README.md @@ -0,0 +1,78 @@ +# 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 +# HTTP — the client self-hosts the server, runs the grant, then tears it down. +# Self-hosting uses this story's fixed :8000 (the AS metadata pins it), so +# :8000 must be free. +uv run python -m stories.oauth_client_credentials.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.oauth_client_credentials.client --http --server server_lowlevel + +# against a server you run yourself (real uvicorn on :8000 — auth is HTTP-only) +uv run python -m stories.oauth_client_credentials.server --port 8000 & +SERVER_PID=$! +uv run python -m stories.oauth_client_credentials.client --http http://127.0.0.1:8000/mcp +kill "$SERVER_PID" +``` + +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..ba2003dedf --- /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 + +import mcp_types as types +from pydantic import AnyHttpUrl +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Route + +from mcp.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..f7113d4cc5 --- /dev/null +++ b/examples/stories/pagination/README.md @@ -0,0 +1,52 @@ +# 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 + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.pagination.client --http --server server_lowlevel +``` + +Drop `--server server_lowlevel` (on either transport) 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..55958a9624 --- /dev/null +++ b/examples/stories/pagination/server_lowlevel.py @@ -0,0 +1,36 @@ +"""Paginated resources/list (lowlevel API): pages of two via an opaque integer-offset cursor.""" + +from typing import Any + +import mcp_types as 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..e3b54da287 --- /dev/null +++ b/examples/stories/parallel_calls/README.md @@ -0,0 +1,60 @@ +# 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 — each +`Client` is a separate connection, so there's no shared wire for them to cross +on. + +## Run it + +The tested legs run in-memory (`Client(server)`); the identical `main` body +works unchanged over HTTP — both clients just reach the same server. Under +`--http` the client self-hosts that server on a free port, runs, then tears it +down: + +```bash +# --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 --legacy +# same, against the lowlevel-API server variant +uv run python -m stories.parallel_calls.client --http --legacy --server server_lowlevel +``` + +There is no stdio run for this story: the stdio default spawns a fresh server +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"]}` shows each connection + delivered its own progress, and — combined with the rendezvous — that both + calls were genuinely in flight at once. +- **`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..945e5410a6 --- /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_types import TextContent + +from mcp.client import Client +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..32807e1706 --- /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 +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + +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..3bce94b995 --- /dev/null +++ b/examples/stories/prompts/README.md @@ -0,0 +1,50 @@ +# 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 + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.prompts.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.prompts.client --http --server server_lowlevel +``` + +## What to look at + +- `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..22aae4af43 --- /dev/null +++ b/examples/stories/prompts/client.py @@ -0,0 +1,39 @@ +"""List prompts, autocomplete an argument, then render both prompts.""" + +from mcp_types import PromptReference, TextContent + +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_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..2ef3fc3d83 --- /dev/null +++ b/examples/stories/prompts/server.py @@ -0,0 +1,43 @@ +"""Prompts primitive: register templates, list, render, complete an argument.""" + +from mcp_types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference + +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, UserMessage +from 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..2fb41de8bc --- /dev/null +++ b/examples/stories/prompts/server_lowlevel.py @@ -0,0 +1,87 @@ +"""Prompts primitive (lowlevel API): hand-built Prompt descriptors, GetPromptResult, completion.""" + +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + +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..78d281e7a9 --- /dev/null +++ b/examples/stories/reconnect/README.md @@ -0,0 +1,56 @@ +# 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. +# The client self-hosts the server on a free port, runs, then tears it down. +uv run python -m stories.reconnect.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.reconnect.client --http --server server_lowlevel +``` + +## What to look at + +- `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..aab2312dc9 --- /dev/null +++ b/examples/stories/reconnect/client.py @@ -0,0 +1,44 @@ +"""Probe server/discover once, persist the result, reconnect with zero round-trips — a fresh `Client` via `targets`.""" + +from mcp_types import DiscoverResult +from mcp_types.version import LATEST_MODERN_VERSION + +from mcp.client import Client +from 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..5c6a057d6e --- /dev/null +++ b/examples/stories/reconnect/server_lowlevel.py @@ -0,0 +1,48 @@ +"""A small modern server whose DiscoverResult a client persists for zero-RTT reconnect (lowlevel API).""" + +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + +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..10b210fe91 --- /dev/null +++ b/examples/stories/resources/README.md @@ -0,0 +1,50 @@ +# 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 + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.resources.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.resources.client --http --server server_lowlevel +``` + +## What to look at + +- `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..29f88d529a --- /dev/null +++ b/examples/stories/resources/client.py @@ -0,0 +1,30 @@ +"""List resources and templates, then read both the static and templated URIs.""" + +from mcp_types import TextResourceContents + +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_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..2161fecc9e --- /dev/null +++ b/examples/stories/resources/server_lowlevel.py @@ -0,0 +1,65 @@ +"""Resources primitive (lowlevel API): hand-built list/templates/read handlers.""" + +from typing import Any + +import mcp_types as types +from mcp_types.jsonrpc import INVALID_PARAMS + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from mcp.shared.exceptions import MCPError +from 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..d11bf8845e --- /dev/null +++ b/examples/stories/roots/README.md @@ -0,0 +1,58 @@ +# 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 + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.roots.client --http --legacy +# same, against the lowlevel-API server variant +uv run python -m stories.roots.client --http --legacy --server server_lowlevel +``` + +## What to look at + +- `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, 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. The non-deprecated + replacement is to accept directory paths as ordinary tool parameters (see the + banner above) — there is no successor server→client call. +- `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 stories that exercise the same +legacy server→client request shape. 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..9d8252991d --- /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 mcp_types import ListRootsResult, Root, TextContent +from pydantic import FileUrl + +from mcp.client import Client, ClientRequestContext +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..2696c946c5 --- /dev/null +++ b/examples/stories/roots/server_lowlevel.py @@ -0,0 +1,36 @@ +"""Roots primitive (lowlevel API): the same server→client round-trip, hand-built.""" + +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + + +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..1c4a9bf794 --- /dev/null +++ b/examples/stories/sampling/README.md @@ -0,0 +1,62 @@ +# 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 + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.sampling.client --http --legacy +# same, against the lowlevel-API server variant +uv run python -m stories.sampling.client --http --legacy --server server_lowlevel +``` + +## What to look at + +- `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, 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. The non-deprecated + replacement is to call your LLM provider directly from the server (see the + banner above) — there is no successor server→client call. +- `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 stories that exercise the same legacy +server→client request shape. 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..0ca88db996 --- /dev/null +++ b/examples/stories/sampling/client.py @@ -0,0 +1,30 @@ +"""Supply a canned sampling_callback and assert its text round-trips through the tool.""" + +from mcp_types import CreateMessageRequestParams, CreateMessageResult, TextContent + +from mcp.client import Client, ClientRequestContext +from 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..c97d8ab24f --- /dev/null +++ b/examples/stories/sampling/server.py @@ -0,0 +1,25 @@ +"""Sampling primitive: a tool asks the client's LLM for a completion mid-call.""" + +from mcp_types import SamplingMessage, TextContent + +from mcp.server.mcpserver import Context, MCPServer +from 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..5bc2a19436 --- /dev/null +++ b/examples/stories/sampling/server_lowlevel.py @@ -0,0 +1,45 @@ +"""Sampling primitive (lowlevel API): the same server→client round-trip, hand-built.""" + +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + + +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..984f1595ba --- /dev/null +++ b/examples/stories/schema_validators/README.md @@ -0,0 +1,52 @@ +# 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 + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.schema_validators.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.schema_validators.client --http --server server_lowlevel +``` + +## What to look at + +- `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..8f6794eddc --- /dev/null +++ b/examples/stories/schema_validators/client.py @@ -0,0 +1,38 @@ +"""Asserts each variant publishes a `who` object schema and the call round-trips.""" + +from mcp_types import TextContent + +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() + 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..02dca8d162 --- /dev/null +++ b/examples/stories/schema_validators/server_lowlevel.py @@ -0,0 +1,55 @@ +"""Same four tools via lowlevel.Server — inputSchema is hand-written JSON Schema.""" + +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + +# 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..dd75486164 --- /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/draft/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..73bd457e10 --- /dev/null +++ b/examples/stories/serve_one/client.py @@ -0,0 +1,39 @@ +"""Drive `handle_one` directly to assert the raw result-dict shape, then over the wire.""" + +import mcp_types as types +from mcp_types.version import LATEST_MODERN_VERSION + +from mcp.client import Client +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..447e4a82b8 --- /dev/null +++ b/examples/stories/serve_one/server.py @@ -0,0 +1,110 @@ +"""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 +import mcp_types as types +from mcp_types.version import LATEST_MODERN_VERSION + +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.exceptions import NoBackChannelError +from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher +from mcp.shared.transport_context import TransportContext + +__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 NoBackChannelError(method) + + 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..ddd1b61884 --- /dev/null +++ b/examples/stories/sse_polling/README.md @@ -0,0 +1,76 @@ +# 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 +# HTTP — the client self-hosts the app on a free port, runs, then tears it down +uv run python -m stories.sse_polling.client --http --legacy +# same, against the lowlevel-API server variant +uv run python -m stories.sse_polling.client --http --legacy --server server_lowlevel + +# against a server you run yourself (real uvicorn on :8000) +uv run python -m stories.sse_polling.server --port 8000 & +SERVER_PID=$! +uv run python -m stories.sse_polling.client --http http://127.0.0.1:8000/mcp --legacy +kill "$SERVER_PID" +``` + +## What to look at + +- **`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..d2f3918952 --- /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_types import TextContent + +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: + 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..95d2b8accf --- /dev/null +++ b/examples/stories/sse_polling/event_store.py @@ -0,0 +1,34 @@ +"""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_types import JSONRPCMessage + +from mcp.server.streamable_http import EventCallback, EventId, EventMessage, EventStore, StreamId + + +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..fcf3199861 --- /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 + +import mcp_types as types +from starlette.applications import Starlette + +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..c460e14911 --- /dev/null +++ b/examples/stories/standalone_get/README.md @@ -0,0 +1,67 @@ +# 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 +# HTTP only — the standalone GET stream is a Streamable-HTTP feature. The +# client self-hosts the server on a free port, runs, then tears it down. +uv run python -m stories.standalone_get.client --http --legacy +# same, against the lowlevel-API server variant +uv run python -m stories.standalone_get.client --http --legacy --server server_lowlevel + +# against a server you run yourself +uv run python -m stories.standalone_get.server --http --port 8000 & +SERVER_PID=$! +uv run python -m stories.standalone_get.client --http http://127.0.0.1:8000/mcp --legacy +kill "$SERVER_PID" +``` + +## What to look at + +- **`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..aaf870f0e7 --- /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 +import mcp_types as 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..21ee8c1f1b --- /dev/null +++ b/examples/stories/standalone_get/server_lowlevel.py @@ -0,0 +1,49 @@ +"""Sessionful Streamable HTTP (lowlevel `Server`): tool-triggered `list_changed` over the standalone GET stream.""" + +import itertools +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + +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..97b3a84bbe --- /dev/null +++ b/examples/stories/starlette_mount/README.md @@ -0,0 +1,58 @@ +# 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 +# HTTP — the client self-hosts the mounted app on a free port at /api/, runs, +# then tears it down +uv run python -m stories.starlette_mount.client --http + +# against a server you run yourself (real uvicorn on :8000) +uv run python -m stories.starlette_mount.server --port 8000 & +SERVER_PID=$! +curl http://127.0.0.1:8000/health # → {"status":"ok"} +uv run python -m stories.starlette_mount.client --http http://127.0.0.1:8000/api/ +kill "$SERVER_PID" +``` + +## What to look at + +- `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 fails immediately with a 500 + (`RuntimeError: Task group is not initialized. Make sure to use run().`) — + 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..dcfc3495b3 --- /dev/null +++ b/examples/stories/starlette_mount/client.py @@ -0,0 +1,23 @@ +"""Connect to the sub-mounted MCP endpoint at /api/, list tools and call greet. HTTP-only: the mount is the story.""" + +from mcp_types import TextContent + +from mcp.client import Client +from 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..7fc1630ce9 --- /dev/null +++ b/examples/stories/stateless_legacy/README.md @@ -0,0 +1,59 @@ +# 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 +# HTTP — the client self-hosts the app on a free port, connects once as a +# modern client and once as a legacy client, then tears it down +uv run python -m stories.stateless_legacy.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.stateless_legacy.client --http --server server_lowlevel + +# against a server you run yourself (real uvicorn on :8000) +uv run python -m stories.stateless_legacy.server --port 8000 & +SERVER_PID=$! +uv run python -m stories.stateless_legacy.client --http http://127.0.0.1:8000/mcp +kill "$SERVER_PID" +``` + +## 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/` +(`classify_inbound_request()` for sessionful-2025 + modern on one mount) · +`starlette_mount/` (mounting under FastAPI/Starlette with parent lifespan) · +`json_response/` (`json_response=True` and what it drops). diff --git a/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..d21ff850cf --- /dev/null +++ b/examples/stories/stateless_legacy/client.py @@ -0,0 +1,37 @@ +"""Connect at each era — two connections, so `main` takes `targets`; the same stateless app answers both.""" + +from mcp_types import TextContent +from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION + +from mcp.client import Client +from 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..44943abd3d --- /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 + +import mcp_types as types +from starlette.applications import Starlette + +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..b1d4543142 --- /dev/null +++ b/examples/stories/stickynotes/README.md @@ -0,0 +1,62 @@ +# 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 + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.stickynotes.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.stickynotes.client --http --server server_lowlevel +``` + +## What to look at + +- **`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..56ca10f551 --- /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 +import mcp_types as types +from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS + +from mcp.client import Client, ClientRequestContext +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..15a20a797d --- /dev/null +++ b/examples/stories/stickynotes/server_lowlevel.py @@ -0,0 +1,119 @@ +"""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 + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + + +@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..86e2e74780 --- /dev/null +++ b/examples/stories/streaming/README.md @@ -0,0 +1,80 @@ +# 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 + +# HTTP — the client self-hosts the server on a free port, runs, then tears it +# down (--legacy: see the note below) +uv run python -m stories.streaming.client --http --legacy +# same, against the lowlevel-API server variant +uv run python -m stories.streaming.client --http --legacy --server server_lowlevel +``` + +The modern HTTP leg (drop `--legacy`) is `xfail` until the SSE wiring lands — +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..e584b4c1ef --- /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_types import LoggingMessageNotificationParams + +from mcp.client import Client +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..ced59878d7 --- /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 +import mcp_types as 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..07daf641b4 --- /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 +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + +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..d41d0f82ba --- /dev/null +++ b/examples/stories/subscriptions/README.md @@ -0,0 +1,27 @@ +# 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 is in this base — +[#2967](https://github.com/modelcontextprotocol/python-sdk/pull/2967) +(`ae13ede`) added the lowlevel `on_subscriptions_listen` handler slot — but +there is no `Client.listen()` or `ServerEventBus` yet. The runnable story is +deliberately a follow-up PR to keep this one reviewable. + +## Spec + +[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..996fa0c2b6 --- /dev/null +++ b/examples/stories/tools/README.md @@ -0,0 +1,39 @@ +# 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 + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.tools.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.tools.client --http --server server_lowlevel +``` + +## What to look at + +- `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..74e1ab4c0f --- /dev/null +++ b/examples/stories/tools/client.py @@ -0,0 +1,32 @@ +"""List tools, inspect schemas + annotations, call both tools, assert structured output.""" + +from mcp_types import TextContent + +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() + 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..a1f035c26a --- /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 mcp_types import ToolAnnotations +from pydantic import BaseModel + +from mcp.server.mcpserver import MCPServer +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..e6c4c05ef7 --- /dev/null +++ b/examples/stories/tools/server_lowlevel.py @@ -0,0 +1,72 @@ +"""Tools primitive (lowlevel API): hand-built Tool descriptors and CallToolResult.""" + +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + +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 ecac707505..e7ef057f3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,8 @@ build-constraint-dependencies = [ dev = [ # We add mcp[cli] so `uv sync` considers the extras. "mcp[cli]", + "mcp-example-stories", + "tomli>=2.0; python_version < '3.11'", "pyright>=1.1.400", "pytest>=8.4.0", "ruff>=0.8.5", @@ -135,12 +137,16 @@ include = [ "src/mcp", "src/mcp-types/mcp_types", "tests", + "examples/stories", "examples/servers", "examples/snippets", "examples/clients", ] venvPath = "." venv = ".venv" +# `stories` is a workspace package rooted at examples/; the IDE language server +# does not always pick up the editable-install .pth, so resolve it statically. +extraPaths = ["examples"] # The FastAPI style of using decorators in tests gives a `reportUnusedFunction` error. # See https://github.com/microsoft/pyright/issues/7771 for more details. # TODO(Marcelo): We should remove `reportPrivateUsage = false`. The idea is that we should test the workflow that uses @@ -149,8 +155,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] @@ -194,10 +209,11 @@ max-returns = 13 # Default is 6 max-statements = 102 # Default is 50 [tool.uv.workspace] -members = ["src/mcp-types", "examples/clients/*", "examples/servers/*", "examples/snippets"] +members = ["src/mcp-types", "examples", "examples/clients/*", "examples/servers/*", "examples/snippets"] [tool.uv.sources] mcp = { workspace = true } +mcp-example-stories = { workspace = true } mcp-types = { workspace = true } strict-no-cover = { git = "https://github.com/pydantic/strict-no-cover" } diff --git a/src/mcp/server/elicitation.py b/src/mcp/server/elicitation.py index 066d33a1be..dc0e669c8b 100644 --- a/src/mcp/server/elicitation.py +++ b/src/mcp/server/elicitation.py @@ -113,7 +113,7 @@ async def elicit_with_validation( return AcceptedElicitation(data=validated_data) elif result.action == "decline": return DeclinedElicitation() - elif result.action == "cancel": # pragma: no cover + elif result.action == "cancel": return CancelledElicitation() else: # pragma: no cover # This should never happen, but handle it just in case diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 8ad945b6a8..a4fbc10057 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -561,7 +561,7 @@ def streamable_http_app( ) ) - if custom_starlette_routes: # pragma: no cover + if custom_starlette_routes: routes.extend(custom_starlette_routes) return Starlette( diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 67c81c18a6..60b8b8473d 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -200,7 +200,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] = [] @@ -822,7 +822,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( @@ -830,7 +830,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/shared/exceptions.py b/src/mcp/shared/exceptions.py index d3acb12714..2f8a539dab 100644 --- a/src/mcp/shared/exceptions.py +++ b/src/mcp/shared/exceptions.py @@ -38,7 +38,7 @@ def message(self) -> str: @property def data(self) -> Any: - return self.error.data # pragma: no cover + return self.error.data @classmethod def from_jsonrpc_error(cls, error: JSONRPCError) -> MCPError: 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..ffe22caad8 --- /dev/null +++ b/tests/examples/conftest.py @@ -0,0 +1,170 @@ +"""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 mcp_types.version import LATEST_MODERN_VERSION +from starlette.applications import Starlette +from stories._harness import AuthBuilder, TargetFactory +from stories._hosting import asgi_from + +from mcp.client.streamable_http import streamable_http_client +from 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 ``"auto"`` — the +``Client`` default; the era axis still passes every ``mode=`` explicitly.""" + + +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..ed8a26ea44 --- /dev/null +++ b/tests/examples/test_stories_smoke.py @@ -0,0 +1,57 @@ +"""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 by running the literal commands the +story READMEs print: stdio (``run_client`` spawns the server over stdio) and bare +``--http`` (``run_client`` self-hosts the server on a real uvicorn socket on a +port it owns, then terminates it). + +lax no cover: gated on ``MCP_EXAMPLES_SMOKE=1``, which CI sets on exactly one +matrix cell (ubuntu / 3.12 / locked — see ``shared.yml``). Every other cell +skips at collection, so the test body is uncovered there and the per-job 100% +gate would otherwise fail. +""" + +from __future__ import annotations + +import os +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} + + +@pytest.mark.parametrize( + "argv", + [ + ("stories.tools.client",), + ("stories.tools.client", "--http"), + ("stories.bearer_auth.client", "--http"), + ], + ids=["tools-stdio", "tools-http", "bearer_auth-http"], +) +async def test_story_main_runs_end_to_end(argv: tuple[str, ...]) -> None: # pragma: lax no cover + """``python -m .client [--http]`` (the README command) exits 0 over a real subprocess.""" + with anyio.fail_after(60): + async with await anyio.open_process( + [sys.executable, "-m", *argv], cwd=_REPO_ROOT, env=_ENV, stdout=None, stderr=None + ) as proc: + await proc.wait() + assert proc.returncode == 0 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/uv.lock b/uv.lock index b6dbdd9e0a..a1e8a7e356 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", @@ -943,6 +944,7 @@ dev = [ { name = "inline-snapshot" }, { name = "logfire" }, { name = "mcp", extra = ["cli"] }, + { name = "mcp-example-stories" }, { name = "opentelemetry-sdk" }, { name = "pillow" }, { name = "pyright" }, @@ -953,6 +955,7 @@ dev = [ { name = "pytest-xdist" }, { name = "ruff" }, { name = "strict-no-cover" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "trio" }, ] docs = [ @@ -998,6 +1001,7 @@ dev = [ { name = "inline-snapshot", specifier = ">=0.23.0" }, { name = "logfire", specifier = ">=3.0.0" }, { name = "mcp", extras = ["cli"], editable = "." }, + { name = "mcp-example-stories", editable = "examples" }, { name = "opentelemetry-sdk", specifier = ">=1.39.1" }, { name = "pillow", specifier = ">=12.0" }, { name = "pyright", specifier = ">=1.1.400" }, @@ -1008,6 +1012,7 @@ dev = [ { name = "pytest-xdist", specifier = ">=3.6.1" }, { name = "ruff", specifier = ">=0.8.5" }, { name = "strict-no-cover", git = "https://github.com/pydantic/strict-no-cover" }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" }, { name = "trio", specifier = ">=0.26.2" }, ] docs = [ @@ -1044,7 +1049,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" }, ] @@ -1056,6 +1061,21 @@ dev = [ { name = "ruff", specifier = ">=0.6.9" }, ] +[[package]] +name = "mcp-example-stories" +version = "0.0.0" +source = { editable = "examples" } +dependencies = [ + { name = "mcp" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] + +[package.metadata] +requires-dist = [ + { name = "mcp", editable = "." }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0" }, +] + [[package]] name = "mcp-simple-auth" version = "0.1.0" @@ -1083,7 +1103,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" }, @@ -1116,7 +1136,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.2.0" }, - { name = "mcp", editable = "." }, + { name = "mcp" }, ] [package.metadata.requires-dev] @@ -1145,7 +1165,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "mcp", editable = "." }, + { name = "mcp" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "uvicorn", specifier = ">=0.32.1" }, ] @@ -1180,7 +1200,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] @@ -1213,7 +1233,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] @@ -1246,7 +1266,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] @@ -1281,7 +1301,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" }, ] @@ -1318,7 +1338,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" }, ] @@ -1353,7 +1373,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] @@ -1372,7 +1392,7 @@ dependencies = [ ] [package.metadata] -requires-dist = [{ name = "mcp", editable = "." }] +requires-dist = [{ name = "mcp" }] [[package]] name = "mcp-sse-polling-client" @@ -1393,7 +1413,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.2.0" }, - { name = "mcp", editable = "." }, + { name = "mcp" }, ] [package.metadata.requires-dev] @@ -1428,7 +1448,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" }, ] @@ -1449,7 +1469,7 @@ dependencies = [ ] [package.metadata] -requires-dist = [{ name = "mcp", editable = "." }] +requires-dist = [{ name = "mcp" }] [[package]] name = "mcp-types" From 411a6d398028123a3d2a3ec95327b2e1fca2efce Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:49:19 +0200 Subject: [PATCH 006/100] Rebuild the docs around tested examples; shrink README.v2.md to a pitch (#2978) --- .github/workflows/deploy-docs.yml | 3 + .github/workflows/shared.yml | 23 + .pre-commit-config.yaml | 2 +- README.v2.md | 2506 +------------------ docs/advanced/authorization.md | 121 + docs/advanced/deprecated.md | 91 + docs/advanced/low-level-server.md | 198 ++ docs/advanced/middleware.md | 130 + docs/advanced/multi-round-trip.md | 96 + docs/advanced/oauth-clients.md | 137 + docs/advanced/pagination.md | 80 + docs/advanced/session-groups.md | 82 + docs/authorization.md | 5 - docs/client/callbacks.md | 143 ++ docs/client/index.md | 212 ++ docs/client/protocol-versions.md | 127 + docs/client/transports.md | 115 + docs/concepts.md | 13 - docs/index.md | 101 +- docs/installation.md | 54 +- docs/low-level-server.md | 5 - docs/run/asgi.md | 171 ++ docs/run/index.md | 146 ++ docs/testing.md | 82 - docs/tutorial/completions.md | 125 + docs/tutorial/context.md | 126 + docs/tutorial/elicitation.md | 153 ++ docs/tutorial/first-steps.md | 139 + docs/tutorial/handling-errors.md | 132 + docs/tutorial/index.md | 51 + docs/tutorial/lifespan.md | 102 + docs/tutorial/logging.md | 78 + docs/tutorial/media.md | 108 + docs/tutorial/progress.md | 117 + docs/tutorial/prompts.md | 150 ++ docs/tutorial/resources.md | 139 + docs/tutorial/structured-output.md | 245 ++ docs/tutorial/testing.md | 106 + docs/tutorial/tools.md | 172 ++ docs_src/__init__.py | 7 + docs_src/asgi/__init__.py | 0 docs_src/asgi/tutorial001.py | 12 + docs_src/asgi/tutorial002.py | 27 + docs_src/asgi/tutorial003.py | 39 + docs_src/asgi/tutorial004.py | 27 + docs_src/asgi/tutorial005.py | 52 + docs_src/asgi/tutorial006.py | 20 + docs_src/authorization/__init__.py | 0 docs_src/authorization/tutorial001.py | 31 + docs_src/authorization/tutorial002.py | 35 + docs_src/client/__init__.py | 0 docs_src/client/tutorial001.py | 18 + docs_src/client/tutorial002.py | 20 + docs_src/client/tutorial003.py | 33 + docs_src/client/tutorial004.py | 32 + docs_src/client/tutorial005.py | 20 + docs_src/client/tutorial006.py | 32 + docs_src/client/tutorial007.py | 31 + docs_src/client_callbacks/__init__.py | 0 docs_src/client_callbacks/tutorial001.py | 19 + docs_src/client_callbacks/tutorial002.py | 21 + docs_src/client_callbacks/tutorial003.py | 31 + docs_src/client_callbacks/tutorial004.py | 19 + docs_src/client_transports/__init__.py | 0 docs_src/client_transports/tutorial001.py | 16 + docs_src/client_transports/tutorial002.py | 7 + docs_src/client_transports/tutorial003.py | 16 + docs_src/client_transports/tutorial004.py | 14 + docs_src/completions/__init__.py | 0 docs_src/completions/tutorial001.py | 15 + docs_src/completions/tutorial002.py | 30 + docs_src/completions/tutorial003.py | 40 + docs_src/context/__init__.py | 0 docs_src/context/tutorial001.py | 10 + docs_src/context/tutorial002.py | 17 + docs_src/context/tutorial003.py | 17 + docs_src/elicitation/__init__.py | 0 docs_src/elicitation/tutorial001.py | 26 + docs_src/elicitation/tutorial002.py | 24 + docs_src/elicitation/tutorial003.py | 22 + docs_src/first_steps/__init__.py | 0 docs_src/first_steps/tutorial001.py | 21 + docs_src/handling_errors/__init__.py | 0 docs_src/handling_errors/tutorial001.py | 13 + docs_src/handling_errors/tutorial002.py | 16 + docs_src/handling_errors/tutorial003.py | 14 + docs_src/index/__init__.py | 0 docs_src/index/tutorial001.py | 15 + docs_src/lifespan/__init__.py | 0 docs_src/lifespan/tutorial001.py | 41 + docs_src/lifespan/tutorial002.py | 44 + docs_src/logging/__init__.py | 0 docs_src/logging/tutorial001.py | 14 + docs_src/lowlevel/__init__.py | 0 docs_src/lowlevel/tutorial001.py | 33 + docs_src/lowlevel/tutorial002.py | 48 + docs_src/lowlevel/tutorial003.py | 41 + docs_src/lowlevel/tutorial004.py | 42 + docs_src/lowlevel/tutorial005.py | 51 + docs_src/lowlevel/tutorial006.py | 48 + docs_src/media/__init__.py | 0 docs_src/media/tutorial001.py | 16 + docs_src/media/tutorial002.py | 24 + docs_src/media/tutorial003.py | 20 + docs_src/middleware/__init__.py | 0 docs_src/middleware/tutorial001.py | 50 + docs_src/mrtr/__init__.py | 0 docs_src/mrtr/tutorial001.py | 53 + docs_src/mrtr/tutorial002.py | 23 + docs_src/oauth_clients/__init__.py | 0 docs_src/oauth_clients/tutorial001.py | 62 + docs_src/oauth_clients/tutorial002.py | 41 + docs_src/pagination/__init__.py | 0 docs_src/pagination/tutorial001.py | 20 + docs_src/pagination/tutorial002.py | 34 + docs_src/progress/__init__.py | 0 docs_src/progress/tutorial001.py | 12 + docs_src/progress/tutorial002.py | 21 + docs_src/prompts/__init__.py | 0 docs_src/prompts/tutorial001.py | 9 + docs_src/prompts/tutorial002.py | 20 + docs_src/prompts/tutorial003.py | 16 + docs_src/protocol_versions/__init__.py | 0 docs_src/protocol_versions/tutorial001.py | 15 + docs_src/protocol_versions/tutorial002.py | 15 + docs_src/protocol_versions/tutorial003.py | 15 + docs_src/protocol_versions/tutorial004.py | 19 + docs_src/resources/__init__.py | 0 docs_src/resources/tutorial001.py | 9 + docs_src/resources/tutorial002.py | 15 + docs_src/resources/tutorial003.py | 23 + docs_src/run/__init__.py | 0 docs_src/run/tutorial001.py | 13 + docs_src/run/tutorial002.py | 13 + docs_src/run/tutorial003.py | 13 + docs_src/session_groups/__init__.py | 0 docs_src/session_groups/tutorial001.py | 15 + docs_src/session_groups/tutorial002.py | 9 + docs_src/session_groups/tutorial003.py | 19 + docs_src/session_groups/tutorial004.py | 26 + docs_src/structured_output/__init__.py | 0 docs_src/structured_output/tutorial001.py | 11 + docs_src/structured_output/tutorial002.py | 17 + docs_src/structured_output/tutorial003.py | 17 + docs_src/structured_output/tutorial004.py | 18 + docs_src/structured_output/tutorial005.py | 17 + docs_src/structured_output/tutorial006.py | 11 + docs_src/structured_output/tutorial007.py | 21 + docs_src/structured_output/tutorial008.py | 9 + docs_src/structured_output/tutorial009.py | 15 + docs_src/testing/__init__.py | 0 docs_src/testing/tutorial001.py | 9 + docs_src/tools/__init__.py | 0 docs_src/tools/tutorial001.py | 9 + docs_src/tools/tutorial002.py | 9 + docs_src/tools/tutorial003.py | 18 + docs_src/tools/tutorial004.py | 17 + docs_src/tools/tutorial005.py | 14 + mkdocs.yml | 53 +- pyproject.toml | 4 + scripts/update_readme_snippets.py | 10 +- src/mcp/client/client.py | 2 +- src/mcp/server/mcpserver/context.py | 2 +- src/mcp/server/mcpserver/server.py | 2 +- src/mcp/server/mcpserver/utilities/types.py | 12 +- tests/client/test_client.py | 2 +- tests/docs_src/__init__.py | 0 tests/docs_src/test_asgi.py | 213 ++ tests/docs_src/test_authorization.py | 98 + tests/docs_src/test_client.py | 182 ++ tests/docs_src/test_client_callbacks.py | 129 + tests/docs_src/test_client_transports.py | 58 + tests/docs_src/test_completions.py | 116 + tests/docs_src/test_context.py | 88 + tests/docs_src/test_deprecated.py | 144 ++ tests/docs_src/test_elicitation.py | 248 ++ tests/docs_src/test_first_steps.py | 98 + tests/docs_src/test_handling_errors.py | 86 + tests/docs_src/test_index.py | 31 + tests/docs_src/test_lifespan.py | 113 + tests/docs_src/test_logging.py | 62 + tests/docs_src/test_lowlevel.py | 143 ++ tests/docs_src/test_media.py | 62 + tests/docs_src/test_middleware.py | 116 + tests/docs_src/test_mrtr.py | 103 + tests/docs_src/test_oauth_clients.py | 131 + tests/docs_src/test_pagination.py | 80 + tests/docs_src/test_progress.py | 102 + tests/docs_src/test_prompts.py | 101 + tests/docs_src/test_protocol_versions.py | 94 + tests/docs_src/test_resources.py | 119 + tests/docs_src/test_run.py | 52 + tests/docs_src/test_session_groups.py | 98 + tests/docs_src/test_shape.py | 193 ++ tests/docs_src/test_structured_output.py | 192 ++ tests/docs_src/test_testing.py | 23 + tests/docs_src/test_tools.py | 107 + tests/test_examples.py | 21 +- 198 files changed, 9510 insertions(+), 2639 deletions(-) create mode 100644 docs/advanced/authorization.md create mode 100644 docs/advanced/deprecated.md create mode 100644 docs/advanced/low-level-server.md create mode 100644 docs/advanced/middleware.md create mode 100644 docs/advanced/multi-round-trip.md create mode 100644 docs/advanced/oauth-clients.md create mode 100644 docs/advanced/pagination.md create mode 100644 docs/advanced/session-groups.md delete mode 100644 docs/authorization.md create mode 100644 docs/client/callbacks.md create mode 100644 docs/client/index.md create mode 100644 docs/client/protocol-versions.md create mode 100644 docs/client/transports.md delete mode 100644 docs/concepts.md delete mode 100644 docs/low-level-server.md create mode 100644 docs/run/asgi.md create mode 100644 docs/run/index.md delete mode 100644 docs/testing.md create mode 100644 docs/tutorial/completions.md create mode 100644 docs/tutorial/context.md create mode 100644 docs/tutorial/elicitation.md create mode 100644 docs/tutorial/first-steps.md create mode 100644 docs/tutorial/handling-errors.md create mode 100644 docs/tutorial/index.md create mode 100644 docs/tutorial/lifespan.md create mode 100644 docs/tutorial/logging.md create mode 100644 docs/tutorial/media.md create mode 100644 docs/tutorial/progress.md create mode 100644 docs/tutorial/prompts.md create mode 100644 docs/tutorial/resources.md create mode 100644 docs/tutorial/structured-output.md create mode 100644 docs/tutorial/testing.md create mode 100644 docs/tutorial/tools.md create mode 100644 docs_src/__init__.py create mode 100644 docs_src/asgi/__init__.py create mode 100644 docs_src/asgi/tutorial001.py create mode 100644 docs_src/asgi/tutorial002.py create mode 100644 docs_src/asgi/tutorial003.py create mode 100644 docs_src/asgi/tutorial004.py create mode 100644 docs_src/asgi/tutorial005.py create mode 100644 docs_src/asgi/tutorial006.py create mode 100644 docs_src/authorization/__init__.py create mode 100644 docs_src/authorization/tutorial001.py create mode 100644 docs_src/authorization/tutorial002.py create mode 100644 docs_src/client/__init__.py create mode 100644 docs_src/client/tutorial001.py create mode 100644 docs_src/client/tutorial002.py create mode 100644 docs_src/client/tutorial003.py create mode 100644 docs_src/client/tutorial004.py create mode 100644 docs_src/client/tutorial005.py create mode 100644 docs_src/client/tutorial006.py create mode 100644 docs_src/client/tutorial007.py create mode 100644 docs_src/client_callbacks/__init__.py create mode 100644 docs_src/client_callbacks/tutorial001.py create mode 100644 docs_src/client_callbacks/tutorial002.py create mode 100644 docs_src/client_callbacks/tutorial003.py create mode 100644 docs_src/client_callbacks/tutorial004.py create mode 100644 docs_src/client_transports/__init__.py create mode 100644 docs_src/client_transports/tutorial001.py create mode 100644 docs_src/client_transports/tutorial002.py create mode 100644 docs_src/client_transports/tutorial003.py create mode 100644 docs_src/client_transports/tutorial004.py create mode 100644 docs_src/completions/__init__.py create mode 100644 docs_src/completions/tutorial001.py create mode 100644 docs_src/completions/tutorial002.py create mode 100644 docs_src/completions/tutorial003.py create mode 100644 docs_src/context/__init__.py create mode 100644 docs_src/context/tutorial001.py create mode 100644 docs_src/context/tutorial002.py create mode 100644 docs_src/context/tutorial003.py create mode 100644 docs_src/elicitation/__init__.py create mode 100644 docs_src/elicitation/tutorial001.py create mode 100644 docs_src/elicitation/tutorial002.py create mode 100644 docs_src/elicitation/tutorial003.py create mode 100644 docs_src/first_steps/__init__.py create mode 100644 docs_src/first_steps/tutorial001.py create mode 100644 docs_src/handling_errors/__init__.py create mode 100644 docs_src/handling_errors/tutorial001.py create mode 100644 docs_src/handling_errors/tutorial002.py create mode 100644 docs_src/handling_errors/tutorial003.py create mode 100644 docs_src/index/__init__.py create mode 100644 docs_src/index/tutorial001.py create mode 100644 docs_src/lifespan/__init__.py create mode 100644 docs_src/lifespan/tutorial001.py create mode 100644 docs_src/lifespan/tutorial002.py create mode 100644 docs_src/logging/__init__.py create mode 100644 docs_src/logging/tutorial001.py create mode 100644 docs_src/lowlevel/__init__.py create mode 100644 docs_src/lowlevel/tutorial001.py create mode 100644 docs_src/lowlevel/tutorial002.py create mode 100644 docs_src/lowlevel/tutorial003.py create mode 100644 docs_src/lowlevel/tutorial004.py create mode 100644 docs_src/lowlevel/tutorial005.py create mode 100644 docs_src/lowlevel/tutorial006.py create mode 100644 docs_src/media/__init__.py create mode 100644 docs_src/media/tutorial001.py create mode 100644 docs_src/media/tutorial002.py create mode 100644 docs_src/media/tutorial003.py create mode 100644 docs_src/middleware/__init__.py create mode 100644 docs_src/middleware/tutorial001.py create mode 100644 docs_src/mrtr/__init__.py create mode 100644 docs_src/mrtr/tutorial001.py create mode 100644 docs_src/mrtr/tutorial002.py create mode 100644 docs_src/oauth_clients/__init__.py create mode 100644 docs_src/oauth_clients/tutorial001.py create mode 100644 docs_src/oauth_clients/tutorial002.py create mode 100644 docs_src/pagination/__init__.py create mode 100644 docs_src/pagination/tutorial001.py create mode 100644 docs_src/pagination/tutorial002.py create mode 100644 docs_src/progress/__init__.py create mode 100644 docs_src/progress/tutorial001.py create mode 100644 docs_src/progress/tutorial002.py create mode 100644 docs_src/prompts/__init__.py create mode 100644 docs_src/prompts/tutorial001.py create mode 100644 docs_src/prompts/tutorial002.py create mode 100644 docs_src/prompts/tutorial003.py create mode 100644 docs_src/protocol_versions/__init__.py create mode 100644 docs_src/protocol_versions/tutorial001.py create mode 100644 docs_src/protocol_versions/tutorial002.py create mode 100644 docs_src/protocol_versions/tutorial003.py create mode 100644 docs_src/protocol_versions/tutorial004.py create mode 100644 docs_src/resources/__init__.py create mode 100644 docs_src/resources/tutorial001.py create mode 100644 docs_src/resources/tutorial002.py create mode 100644 docs_src/resources/tutorial003.py create mode 100644 docs_src/run/__init__.py create mode 100644 docs_src/run/tutorial001.py create mode 100644 docs_src/run/tutorial002.py create mode 100644 docs_src/run/tutorial003.py create mode 100644 docs_src/session_groups/__init__.py create mode 100644 docs_src/session_groups/tutorial001.py create mode 100644 docs_src/session_groups/tutorial002.py create mode 100644 docs_src/session_groups/tutorial003.py create mode 100644 docs_src/session_groups/tutorial004.py create mode 100644 docs_src/structured_output/__init__.py create mode 100644 docs_src/structured_output/tutorial001.py create mode 100644 docs_src/structured_output/tutorial002.py create mode 100644 docs_src/structured_output/tutorial003.py create mode 100644 docs_src/structured_output/tutorial004.py create mode 100644 docs_src/structured_output/tutorial005.py create mode 100644 docs_src/structured_output/tutorial006.py create mode 100644 docs_src/structured_output/tutorial007.py create mode 100644 docs_src/structured_output/tutorial008.py create mode 100644 docs_src/structured_output/tutorial009.py create mode 100644 docs_src/testing/__init__.py create mode 100644 docs_src/testing/tutorial001.py create mode 100644 docs_src/tools/__init__.py create mode 100644 docs_src/tools/tutorial001.py create mode 100644 docs_src/tools/tutorial002.py create mode 100644 docs_src/tools/tutorial003.py create mode 100644 docs_src/tools/tutorial004.py create mode 100644 docs_src/tools/tutorial005.py create mode 100644 tests/docs_src/__init__.py create mode 100644 tests/docs_src/test_asgi.py create mode 100644 tests/docs_src/test_authorization.py create mode 100644 tests/docs_src/test_client.py create mode 100644 tests/docs_src/test_client_callbacks.py create mode 100644 tests/docs_src/test_client_transports.py create mode 100644 tests/docs_src/test_completions.py create mode 100644 tests/docs_src/test_context.py create mode 100644 tests/docs_src/test_deprecated.py create mode 100644 tests/docs_src/test_elicitation.py create mode 100644 tests/docs_src/test_first_steps.py create mode 100644 tests/docs_src/test_handling_errors.py create mode 100644 tests/docs_src/test_index.py create mode 100644 tests/docs_src/test_lifespan.py create mode 100644 tests/docs_src/test_logging.py create mode 100644 tests/docs_src/test_lowlevel.py create mode 100644 tests/docs_src/test_media.py create mode 100644 tests/docs_src/test_middleware.py create mode 100644 tests/docs_src/test_mrtr.py create mode 100644 tests/docs_src/test_oauth_clients.py create mode 100644 tests/docs_src/test_pagination.py create mode 100644 tests/docs_src/test_progress.py create mode 100644 tests/docs_src/test_prompts.py create mode 100644 tests/docs_src/test_protocol_versions.py create mode 100644 tests/docs_src/test_resources.py create mode 100644 tests/docs_src/test_run.py create mode 100644 tests/docs_src/test_session_groups.py create mode 100644 tests/docs_src/test_shape.py create mode 100644 tests/docs_src/test_structured_output.py create mode 100644 tests/docs_src/test_testing.py create mode 100644 tests/docs_src/test_tools.py diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 7aea8e63b6..d28d3721f2 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -7,6 +7,9 @@ on: - v1.x paths: - docs/** + # docs pages include their code blocks from these files via `--8<--`, so a + # change here changes the rendered site even when no .md file moves. + - docs_src/** - mkdocs.yml - src/mcp/** - scripts/build-docs.sh diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index 21a70f46ef..8989639b51 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -110,3 +110,26 @@ jobs: - name: Check README snippets are up to date run: uv run --frozen scripts/update_readme_snippets.py --check --readme README.v2.md + + # `mkdocs.yml` sets `strict: true` and `pymdownx.snippets: check_paths: true`, + # but until this job existed the docs were only ever built post-merge by + # `deploy-docs.yml`, so a broken link, a missing nav target, or a deleted + # `docs_src/` include went green on the PR and broke the next deploy of main. + # This is the check path; `deploy-docs.yml` stays the deploy path. + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + version: 0.9.5 + + - name: Install dependencies + run: uv sync --frozen --all-extras --python 3.10 + + - name: Build the docs in strict mode + run: uv run --frozen --no-sync mkdocs build --strict diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 42c12fdedd..f88f229ed5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -65,5 +65,5 @@ repos: name: Check README snippets are up to date entry: uv run --frozen python scripts/update_readme_snippets.py --check language: system - files: ^(README\.v2\.md|examples/.*\.py|scripts/update_readme_snippets\.py)$ + files: ^(README\.v2\.md|docs_src/.*\.py|examples/.*\.py|scripts/update_readme_snippets\.py)$ pass_filenames: false diff --git a/README.v2.md b/README.v2.md index b9896d9412..9b9971ec32 100644 --- a/README.v2.md +++ b/README.v2.md @@ -17,2512 +17,116 @@ > **Important: this documents v2 of the SDK, which is in alpha.** Pre-releases are published to PyPI as `2.0.0aN`, and each alpha may contain breaking changes from the previous one. > -> v2 is a major rework of the SDK, both to support the [2026-07-28 MCP specification release](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) and to fix long-standing architectural issues. See the [migration guide](https://github.com/modelcontextprotocol/python-sdk/blob/main/docs/migration.md) for what's changed. We're targeting a beta on 2026-06-30 and a stable v2 on 2026-07-27, alongside the spec release. Before stable, we plan to add a significant set of backwards compatibility shims so the final upgrade is much smaller than today's diff. +> v2 is a major rework of the SDK, both to support the [2026-07-28 MCP specification release](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) and to fix long-standing architectural issues. See the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/) for what's changed. We're targeting a beta on 2026-06-30 and a stable v2 on 2026-07-27, alongside the spec release. Before stable, we plan to add a significant set of backwards compatibility shims so the final upgrade is much smaller than today's diff. > -> **v1.x is the only stable release line and remains recommended for production.** It is in maintenance mode and continues to receive critical bug fixes and security patches. Installers never select a pre-release unless you opt in (for example `pip install mcp==2.0.0aN`), so existing installs are unaffected. **If your package depends on `mcp`, add a `<2` upper bound to your version constraint (for example `mcp>=1.27,<2`) before the stable release lands.** +> **v1.x is the only stable release line and remains recommended for production.** It is in maintenance mode and continues to receive critical bug fixes and security patches. Installers never select a pre-release unless you opt in (for example `pip install mcp==2.0.0a3`), so existing installs are unaffected. **If your package depends on `mcp`, add a `<2` upper bound to your version constraint (for example `mcp>=1.27,<2`) before the stable release lands.** > > Try the alpha and tell us what breaks: [#python-sdk-dev on the MCP Contributors Discord](https://discord.gg/6CSzBmMkjX). For v1 documentation, see [the v1.x README](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/README.md). - -## Table of Contents - -- [MCP Python SDK](#mcp-python-sdk) - - [Overview](#overview) - - [Installation](#installation) - - [Adding MCP to your python project](#adding-mcp-to-your-python-project) - - [Running the standalone MCP development tools](#running-the-standalone-mcp-development-tools) - - [Quickstart](#quickstart) - - [What is MCP?](#what-is-mcp) - - [Core Concepts](#core-concepts) - - [Server](#server) - - [Resources](#resources) - - [Tools](#tools) - - [Structured Output](#structured-output) - - [Prompts](#prompts) - - [Images](#images) - - [Context](#context) - - [Getting Context in Functions](#getting-context-in-functions) - - [Context Properties and Methods](#context-properties-and-methods) - - [Completions](#completions) - - [Elicitation](#elicitation) - - [Sampling](#sampling) - - [Logging and Notifications](#logging-and-notifications) - - [Authentication](#authentication) - - [MCPServer Properties](#mcpserver-properties) - - [Session Properties and Methods](#session-properties-and-methods) - - [Request Context Properties](#request-context-properties) - - [Running Your Server](#running-your-server) - - [Development Mode](#development-mode) - - [Claude Desktop Integration](#claude-desktop-integration) - - [Direct Execution](#direct-execution) - - [Streamable HTTP Transport](#streamable-http-transport) - - [CORS Configuration for Browser-Based Clients](#cors-configuration-for-browser-based-clients) - - [Mounting to an Existing ASGI Server](#mounting-to-an-existing-asgi-server) - - [StreamableHTTP servers](#streamablehttp-servers) - - [Basic mounting](#basic-mounting) - - [Host-based routing](#host-based-routing) - - [Multiple servers with path configuration](#multiple-servers-with-path-configuration) - - [Path configuration at initialization](#path-configuration-at-initialization) - - [SSE servers](#sse-servers) - - [Advanced Usage](#advanced-usage) - - [Low-Level Server](#low-level-server) - - [Structured Output Support](#structured-output-support) - - [Pagination (Advanced)](#pagination-advanced) - - [Writing MCP Clients](#writing-mcp-clients) - - [Client Display Utilities](#client-display-utilities) - - [OAuth Authentication for Clients](#oauth-authentication-for-clients) - - [Parsing Tool Results](#parsing-tool-results) - - [MCP Primitives](#mcp-primitives) - - [Server Capabilities](#server-capabilities) - - [Documentation](#documentation) - - [Contributing](#contributing) - - [License](#license) - -[pypi-badge]: https://img.shields.io/pypi/v/mcp.svg -[pypi-url]: https://pypi.org/project/mcp/ -[mit-badge]: https://img.shields.io/pypi/l/mcp.svg -[mit-url]: https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE -[python-badge]: https://img.shields.io/pypi/pyversions/mcp.svg -[python-url]: https://www.python.org/downloads/ -[docs-badge]: https://img.shields.io/badge/docs-python--sdk-blue.svg -[docs-url]: https://py.sdk.modelcontextprotocol.io/v2/ -[protocol-badge]: https://img.shields.io/badge/protocol-modelcontextprotocol.io-blue.svg -[protocol-url]: https://modelcontextprotocol.io -[spec-badge]: https://img.shields.io/badge/spec-spec.modelcontextprotocol.io-blue.svg -[spec-url]: https://modelcontextprotocol.io/specification/latest - -## Overview - -The Model Context Protocol allows applications to provide context for LLMs in a standardized way, separating the concerns of providing context from the actual LLM interaction. This Python SDK implements the full MCP specification, making it easy to: - -- Build MCP clients that can connect to any MCP server -- Create MCP servers that expose resources, prompts and tools -- Use standard transports like stdio, SSE, and Streamable HTTP -- Handle all MCP protocol messages and lifecycle events +## Documentation -## Installation +**The documentation lives at .** -### Adding MCP to your python project +It has the full [tutorial](https://py.sdk.modelcontextprotocol.io/v2/tutorial/), the [API reference](https://py.sdk.modelcontextprotocol.io/v2/api/mcp/), and the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/). -We recommend using [uv](https://docs.astral.sh/uv/) to manage your Python projects. +## What is MCP? -If you haven't created a uv-managed project yet, create one: +The [Model Context Protocol](https://modelcontextprotocol.io) lets you build servers that expose data and functionality to LLM applications in a secure, standardized way. Think of it like a web API, but designed for LLM interactions. With this SDK you can: - ```bash - uv init mcp-server-demo - cd mcp-server-demo - ``` +- **Build MCP servers** that expose tools, resources, and prompts to any MCP host +- **Build MCP clients** that connect to any MCP server +- Speak every standard transport: stdio, Streamable HTTP, and SSE - Then add MCP to your project dependencies: +## Requirements - ```bash - uv add "mcp[cli]==2.0.0a1" - ``` +Python 3.10+. -Alternatively, for projects using pip for dependencies: +## Installation ```bash -pip install "mcp[cli]==2.0.0a1" +uv add "mcp[cli]==2.0.0a3" # or: pip install "mcp[cli]==2.0.0a3" ``` -> While v2 is in pre-release, you must pin the version explicitly: unpinned installs resolve to the latest stable v1.x release, which these docs do not describe. Check the [release history](https://pypi.org/project/mcp/#history) for the newest pre-release. The same applies to ad-hoc commands: use `uv run --with "mcp==2.0.0a1"` rather than `uv run --with mcp`. - -### Running the standalone MCP development tools - -To run the mcp command with uv: - -```bash -uv run mcp -``` +The pin matters while v2 is in pre-release: an unpinned install resolves to the latest stable v1.x, which this README does not describe. Check [PyPI](https://pypi.org/project/mcp/#history) for the newest pre-release, and use `uv run --with "mcp==2.0.0a3"` for one-off commands. -## Quickstart +## A server in 15 lines -Let's create a simple MCP server that exposes a calculator tool and some data: +Create a `server.py`: - + ```python -"""MCPServer quickstart example. - -Run from the repository root: - uv run examples/snippets/servers/mcpserver_quickstart.py -""" - -from mcp.server.mcpserver import MCPServer +from mcp.server import MCPServer -# Create an MCP server mcp = MCPServer("Demo") -# Add an addition tool @mcp.tool() def add(a: int, b: int) -> int: - """Add two numbers""" + """Add two numbers.""" return a + b -# Add a dynamic greeting resource @mcp.resource("greeting://{name}") -def get_greeting(name: str) -> str: - """Get a personalized greeting""" +def greeting(name: str) -> str: + """Greet someone by name.""" return f"Hello, {name}!" - - -# Add a prompt -@mcp.prompt() -def greet_user(name: str, style: str = "friendly") -> str: - """Generate a greeting prompt""" - styles = { - "friendly": "Please write a warm, friendly greeting", - "formal": "Please write a formal, professional greeting", - "casual": "Please write a casual, relaxed greeting", - } - - return f"{styles.get(style, styles['friendly'])} for someone named {name}." - - -# Run with streamable HTTP transport -if __name__ == "__main__": - mcp.run(transport="streamable-http", json_response=True) ``` -_Full example: [examples/snippets/servers/mcpserver_quickstart.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/mcpserver_quickstart.py)_ +_Full example: [docs_src/index/tutorial001.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/docs_src/index/tutorial001.py)_ -You can install this server in [Claude Code](https://docs.claude.com/en/docs/claude-code/mcp) and interact with it right away. First, run the server: - -```bash -uv run --with "mcp==2.0.0a1" examples/snippets/servers/mcpserver_quickstart.py -``` - -Then add it to Claude Code: - -```bash -claude mcp add --transport http my-server http://localhost:8000/mcp -``` - -Alternatively, you can test it with the MCP Inspector. Start the server as above, then in a separate terminal: +That's a complete MCP server: one tool, one templated resource. Open it in the [MCP Inspector](https://github.com/modelcontextprotocol/inspector): ```bash -npx -y @modelcontextprotocol/inspector -``` - -In the inspector UI, connect to `http://localhost:8000/mcp`. - -## What is MCP? - -The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) lets you build servers that expose data and functionality to LLM applications in a secure, standardized way. Think of it like a web API, but specifically designed for LLM interactions. - -MCP follows a **client-server model**, where LLM applications act as clients and connect to MCP servers to access capabilities such as data retrieval and tool execution in a consistent format. - -MCP servers can: - -- Expose data through **Resources** (think of these sort of like GET endpoints; they are used to load information into the LLM's context) -- Provide functionality through **Tools** (sort of like POST endpoints; they are used to execute code or otherwise produce a side effect) -- Define interaction patterns through **Prompts** (reusable templates for LLM interactions) -- And more! - -## Core Concepts - -### Server - -The MCPServer server is your core interface to the MCP protocol. It handles connection management, protocol compliance, and message routing: - - -```python -"""Example showing lifespan support for startup/shutdown with strong typing.""" - -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from dataclasses import dataclass - -from mcp.server.mcpserver import Context, MCPServer - - -# Mock database class for example -class Database: - """Mock database class for example.""" - - @classmethod - async def connect(cls) -> "Database": - """Connect to database.""" - return cls() - - async def disconnect(self) -> None: - """Disconnect from database.""" - pass - - def query(self) -> str: - """Execute a query.""" - return "Query result" - - -@dataclass -class AppContext: - """Application context with typed dependencies.""" - - db: Database - - -@asynccontextmanager -async def app_lifespan(server: MCPServer) -> AsyncIterator[AppContext]: - """Manage application lifecycle with type-safe context.""" - # Initialize on startup - db = await Database.connect() - try: - yield AppContext(db=db) - finally: - # Cleanup on shutdown - await db.disconnect() - - -# Pass lifespan to server -mcp = MCPServer("My App", lifespan=app_lifespan) - - -# Access type-safe lifespan context in tools -@mcp.tool() -def query_db(ctx: Context[AppContext]) -> str: - """Tool that uses initialized resources.""" - db = ctx.request_context.lifespan_context.db - return db.query() -``` - -_Full example: [examples/snippets/servers/lifespan_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lifespan_example.py)_ - - -### Resources - -Resources are how you expose data to LLMs. They're similar to GET endpoints in a REST API - they provide data but shouldn't perform significant computation or have side effects: - - -```python -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer(name="Resource Example") - - -@mcp.resource("file://documents/{name}") -def read_document(name: str) -> str: - """Read a document by name.""" - # This would normally read from disk - return f"Content of {name}" - - -@mcp.resource("config://settings") -def get_settings() -> str: - """Get application settings.""" - return """{ - "theme": "dark", - "language": "en", - "debug": false -}""" -``` - -_Full example: [examples/snippets/servers/basic_resource.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_resource.py)_ - - -### Tools - -Tools let LLMs take actions through your server. Unlike resources, tools are expected to perform computation and have side effects: - - -```python -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer(name="Tool Example") - - -@mcp.tool() -def sum(a: int, b: int) -> int: - """Add two numbers together.""" - return a + b - - -@mcp.tool() -def get_weather(city: str, unit: str = "celsius") -> str: - """Get weather for a city.""" - # This would normally call a weather API - return f"Weather in {city}: 22degrees{unit[0].upper()}" -``` - -_Full example: [examples/snippets/servers/basic_tool.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_tool.py)_ - - -Tools can optionally receive a Context object by including a parameter with the `Context` type annotation. This context is automatically injected by the MCPServer framework and provides access to MCP capabilities: - - -```python -from mcp.server.mcpserver import Context, MCPServer - -mcp = MCPServer(name="Progress Example") - - -@mcp.tool() -async def long_running_task(task_name: str, ctx: Context, steps: int = 5) -> str: - """Execute a task with progress updates.""" - await ctx.info(f"Starting: {task_name}") # pyright: ignore[reportDeprecated] - - for i in range(steps): - progress = (i + 1) / steps - await ctx.report_progress( - progress=progress, - total=1.0, - message=f"Step {i + 1}/{steps}", - ) - await ctx.debug(f"Completed step {i + 1}") # pyright: ignore[reportDeprecated] - - return f"Task '{task_name}' completed" -``` - -_Full example: [examples/snippets/servers/tool_progress.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/tool_progress.py)_ - - -#### Structured Output - -Tools will return structured results by default, if their return type -annotation is compatible. Otherwise, they will return unstructured results. - -Structured output supports these return types: - -- Pydantic models (BaseModel subclasses) -- TypedDicts -- Dataclasses and other classes with type hints -- `dict[str, T]` (where T is any JSON-serializable type) -- Primitive types (str, int, float, bool, bytes, None) - wrapped in `{"result": value}` -- Generic types (list, tuple, Union, Optional, etc.) - wrapped in `{"result": value}` - -Classes without type hints cannot be serialized for structured output. Only -classes with properly annotated attributes will be converted to Pydantic models -for schema generation and validation. - -Structured results are automatically validated against the output schema -generated from the annotation. This ensures the tool returns well-typed, -validated data that clients can easily process. - -**Note:** For backward compatibility, unstructured results are also -returned. Unstructured results are provided for backward compatibility -with previous versions of the MCP specification, and are quirks-compatible -with previous versions of MCPServer in the current version of the SDK. - -**Note:** In cases where a tool function's return type annotation -causes the tool to be classified as structured _and this is undesirable_, -the classification can be suppressed by passing `structured_output=False` -to the `@tool` decorator. - -##### Advanced: Direct CallToolResult - -For full control over tool responses including the `_meta` field (for passing data to client applications without exposing it to the model), you can return `CallToolResult` directly: - - -```python -"""Example showing direct CallToolResult return for advanced control.""" - -from typing import Annotated - -from mcp_types import CallToolResult, TextContent -from pydantic import BaseModel - -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer("CallToolResult Example") - - -class ValidationModel(BaseModel): - """Model for validating structured output.""" - - status: str - data: dict[str, int] - - -@mcp.tool() -def advanced_tool() -> CallToolResult: - """Return CallToolResult directly for full control including _meta field.""" - return CallToolResult( - content=[TextContent(type="text", text="Response visible to the model")], - _meta={"hidden": "data for client applications only"}, - ) - - -@mcp.tool() -def validated_tool() -> Annotated[CallToolResult, ValidationModel]: - """Return CallToolResult with structured output validation.""" - return CallToolResult( - content=[TextContent(type="text", text="Validated response")], - structured_content={"status": "success", "data": {"result": 42}}, - _meta={"internal": "metadata"}, - ) - - -@mcp.tool() -def empty_result_tool() -> CallToolResult: - """For empty results, return CallToolResult with empty content.""" - return CallToolResult(content=[]) -``` - -_Full example: [examples/snippets/servers/direct_call_tool_result.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/direct_call_tool_result.py)_ - - -**Important:** `CallToolResult` must always be returned (no `Optional` or `Union`). For empty results, use `CallToolResult(content=[])`. For optional simple types, use `str | None` without `CallToolResult`. - - -```python -"""Example showing structured output with tools.""" - -from typing import TypedDict - -from pydantic import BaseModel, Field - -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer("Structured Output Example") - - -# Using Pydantic models for rich structured data -class WeatherData(BaseModel): - """Weather information structure.""" - - temperature: float = Field(description="Temperature in Celsius") - humidity: float = Field(description="Humidity percentage") - condition: str - wind_speed: float - - -@mcp.tool() -def get_weather(city: str) -> WeatherData: - """Get weather for a city - returns structured data.""" - # Simulated weather data - return WeatherData( - temperature=22.5, - humidity=45.0, - condition="sunny", - wind_speed=5.2, - ) - - -# Using TypedDict for simpler structures -class LocationInfo(TypedDict): - latitude: float - longitude: float - name: str - - -@mcp.tool() -def get_location(address: str) -> LocationInfo: - """Get location coordinates""" - return LocationInfo(latitude=51.5074, longitude=-0.1278, name="London, UK") - - -# Using dict[str, Any] for flexible schemas -@mcp.tool() -def get_statistics(data_type: str) -> dict[str, float]: - """Get various statistics""" - return {"mean": 42.5, "median": 40.0, "std_dev": 5.2} - - -# Ordinary classes with type hints work for structured output -class UserProfile: - name: str - age: int - email: str | None = None - - def __init__(self, name: str, age: int, email: str | None = None): - self.name = name - self.age = age - self.email = email - - -@mcp.tool() -def get_user(user_id: str) -> UserProfile: - """Get user profile - returns structured data""" - return UserProfile(name="Alice", age=30, email="alice@example.com") - - -# Classes WITHOUT type hints cannot be used for structured output -class UntypedConfig: - def __init__(self, setting1, setting2): # type: ignore[reportMissingParameterType] - self.setting1 = setting1 - self.setting2 = setting2 - - -@mcp.tool() -def get_config() -> UntypedConfig: - """This returns unstructured output - no schema generated""" - return UntypedConfig("value1", "value2") - - -# Lists and other types are wrapped automatically -@mcp.tool() -def list_cities() -> list[str]: - """Get a list of cities""" - return ["London", "Paris", "Tokyo"] - # Returns: {"result": ["London", "Paris", "Tokyo"]} - - -@mcp.tool() -def get_temperature(city: str) -> float: - """Get temperature as a simple float""" - return 22.5 - # Returns: {"result": 22.5} -``` - -_Full example: [examples/snippets/servers/structured_output.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/structured_output.py)_ - - -### Prompts - -Prompts are reusable templates that help LLMs interact with your server effectively: - - -```python -from mcp.server.mcpserver import MCPServer -from mcp.server.mcpserver.prompts import base - -mcp = MCPServer(name="Prompt Example") - - -@mcp.prompt(title="Code Review") -def review_code(code: str) -> str: - return f"Please review this code:\n\n{code}" - - -@mcp.prompt(title="Debug Assistant") -def debug_error(error: str) -> list[base.Message]: - return [ - base.UserMessage("I'm seeing this error:"), - base.UserMessage(error), - base.AssistantMessage("I'll help debug that. What have you tried so far?"), - ] -``` - -_Full example: [examples/snippets/servers/basic_prompt.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_prompt.py)_ - - -### Icons - -MCP servers can provide icons for UI display. Icons can be added to the server implementation, tools, resources, and prompts: - -```python -from mcp.server.mcpserver import MCPServer, Icon - -# Create an icon from a file path or URL -icon = Icon( - src="icon.png", - mime_type="image/png", - sizes=["64x64"] -) - -# Add icons to server -mcp = MCPServer( - "My Server", - website_url="https://example.com", - icons=[icon] -) - -# Add icons to tools, resources, and prompts -@mcp.tool(icons=[icon]) -def my_tool(): - """Tool with an icon.""" - return "result" - -@mcp.resource("demo://resource", icons=[icon]) -def my_resource(): - """Resource with an icon.""" - return "content" -``` - -_Full example: [examples/mcpserver/icons_demo.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/mcpserver/icons_demo.py)_ - -### Images - -MCPServer provides an `Image` class that automatically handles image data: - - -```python -"""Example showing image handling with MCPServer.""" - -from PIL import Image as PILImage - -from mcp.server.mcpserver import Image, MCPServer - -mcp = MCPServer("Image Example") - - -@mcp.tool() -def create_thumbnail(image_path: str) -> Image: - """Create a thumbnail from an image""" - img = PILImage.open(image_path) - img.thumbnail((100, 100)) - return Image(data=img.tobytes(), format="png") -``` - -_Full example: [examples/snippets/servers/images.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/images.py)_ - - -### Context - -The Context object is automatically injected into tool and resource functions that request it via type hints. It provides access to MCP capabilities like logging, progress reporting, resource reading, user interaction, and request metadata. - -#### Getting Context in Functions - -To use context in a tool or resource function, add a parameter with the `Context` type annotation: - -```python -from mcp.server.mcpserver import Context, MCPServer - -mcp = MCPServer(name="Context Example") - - -@mcp.tool() -async def my_tool(x: int, ctx: Context) -> str: - """Tool that uses context capabilities.""" - # The context parameter can have any name as long as it's type-annotated - return await process_with_context(x, ctx) +uv run mcp dev server.py ``` -#### Context Properties and Methods - -The Context object provides the following capabilities: - -- `ctx.request_id` - Unique ID for the current request -- `ctx.client_id` - Client ID if available -- `ctx.mcp_server` - Access to the MCPServer server instance (see [MCPServer Properties](#mcpserver-properties)) -- `ctx.session` - Access to the underlying session for advanced communication (see [Session Properties and Methods](#session-properties-and-methods)) -- `ctx.request_context` - Access to request-specific data and lifespan resources (see [Request Context Properties](#request-context-properties)) -- `await ctx.debug(data)` - Send debug log message -- `await ctx.info(data)` - Send info log message -- `await ctx.warning(data)` - Send warning log message -- `await ctx.error(data)` - Send error log message -- `await ctx.log(level, data, logger_name=None)` - Send log with custom level -- `await ctx.report_progress(progress, total=None, message=None)` - Report operation progress -- `await ctx.read_resource(uri)` - Read a resource by URI -- `await ctx.elicit(message, schema)` - Request additional information from user with validation - - -```python -from mcp.server.mcpserver import Context, MCPServer - -mcp = MCPServer(name="Progress Example") - - -@mcp.tool() -async def long_running_task(task_name: str, ctx: Context, steps: int = 5) -> str: - """Execute a task with progress updates.""" - await ctx.info(f"Starting: {task_name}") # pyright: ignore[reportDeprecated] - - for i in range(steps): - progress = (i + 1) / steps - await ctx.report_progress( - progress=progress, - total=1.0, - message=f"Step {i + 1}/{steps}", - ) - await ctx.debug(f"Completed step {i + 1}") # pyright: ignore[reportDeprecated] - - return f"Task '{task_name}' completed" -``` +Call `add` with `a=1`, `b=2` and you get `3` back. -_Full example: [examples/snippets/servers/tool_progress.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/tool_progress.py)_ - +Notice what you did **not** write: no JSON Schema (`a: int, b: int` _is_ the schema), no request parsing, no validation code, no protocol handling. Two type-hinted Python functions and a docstring. -### Completions +[The tutorial](https://py.sdk.modelcontextprotocol.io/v2/tutorial/) takes it from here. -MCP supports providing completion suggestions for prompt arguments and resource template parameters. With the context parameter, servers can provide completions based on previously resolved values: +## A client in 10 lines -Client usage: +The same package is a full MCP **client**. `Client` connects to a URL, a stdio subprocess, a custom transport, or (for tests) straight to a server object in memory with no transport at all: - ```python -"""cd to the `examples/snippets` directory and run: -uv run completion-client -""" - import asyncio -import os - -from mcp_types import PromptReference, ResourceTemplateReference - -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client - -# Create server parameters for stdio connection -server_params = StdioServerParameters( - command="uv", # Using uv to run the server - args=["run", "server", "completion", "stdio"], # Server with completion support - env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, -) +from mcp import Client -async def run(): - """Run the completion client example.""" - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - # Initialize the connection - await session.initialize() +from server import mcp - # List available resource templates - templates = await session.list_resource_templates() - print("Available resource templates:") - for template in templates.resource_templates: - print(f" - {template.uri_template}") - # List available prompts - prompts = await session.list_prompts() - print("\nAvailable prompts:") - for prompt in prompts.prompts: - print(f" - {prompt.name}") +async def main() -> None: + async with Client(mcp) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + print(result.structured_content) # {'result': 3} - # Complete resource template arguments - if templates.resource_templates: - template = templates.resource_templates[0] - print(f"\nCompleting arguments for resource template: {template.uri_template}") - # Complete without context - result = await session.complete( - ref=ResourceTemplateReference(type="ref/resource", uri=template.uri_template), - argument={"name": "owner", "value": "model"}, - ) - print(f"Completions for 'owner' starting with 'model': {result.completion.values}") - - # Complete with context - repo suggestions based on owner - result = await session.complete( - ref=ResourceTemplateReference(type="ref/resource", uri=template.uri_template), - argument={"name": "repo", "value": ""}, - context_arguments={"owner": "modelcontextprotocol"}, - ) - print(f"Completions for 'repo' with owner='modelcontextprotocol': {result.completion.values}") - - # Complete prompt arguments - if prompts.prompts: - prompt_name = prompts.prompts[0].name - print(f"\nCompleting arguments for prompt: {prompt_name}") +asyncio.run(main()) +``` - result = await session.complete( - ref=PromptReference(type="ref/prompt", name=prompt_name), - argument={"name": "style", "value": ""}, - ) - print(f"Completions for 'style' argument: {result.completion.values}") +Swap `mcp` for `"http://localhost:8000/mcp"` and the exact same code talks to a remote server. +## Contributing -def main(): - """Entry point for the completion client.""" - asyncio.run(run()) +We are passionate about supporting contributors of all levels of experience and would love to see you get involved in the project. See the [contributing guide](https://github.com/modelcontextprotocol/python-sdk/blob/main/CONTRIBUTING.md) to get started. +## License -if __name__ == "__main__": - main() -``` +This project is licensed under the MIT License. See the [LICENSE](https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE) file for details. -_Full example: [examples/snippets/clients/completion_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/completion_client.py)_ - -### Elicitation - -Request additional information from users. This example shows an Elicitation during a Tool Call: - - -```python -"""Elicitation examples demonstrating form and URL mode elicitation. - -Form mode elicitation collects structured, non-sensitive data through a schema. -URL mode elicitation directs users to external URLs for sensitive operations -like OAuth flows, credential collection, or payment processing. -""" - -import uuid - -from mcp_types import ElicitRequestURLParams -from pydantic import BaseModel, Field - -from mcp.server.mcpserver import Context, MCPServer -from mcp.shared.exceptions import UrlElicitationRequiredError - -mcp = MCPServer(name="Elicitation Example") - - -class BookingPreferences(BaseModel): - """Schema for collecting user preferences.""" - - checkAlternative: bool = Field(description="Would you like to check another date?") - alternativeDate: str = Field( - default="2024-12-26", - description="Alternative date (YYYY-MM-DD)", - ) - - -@mcp.tool() -async def book_table(date: str, time: str, party_size: int, ctx: Context) -> str: - """Book a table with date availability check. - - This demonstrates form mode elicitation for collecting non-sensitive user input. - """ - # Check if date is available - if date == "2024-12-25": - # Date unavailable - ask user for alternative - result = await ctx.elicit( - message=(f"No tables available for {party_size} on {date}. Would you like to try another date?"), - schema=BookingPreferences, - ) - - if result.action == "accept" and result.data: - if result.data.checkAlternative: - return f"[SUCCESS] Booked for {result.data.alternativeDate}" - return "[CANCELLED] No booking made" - return "[CANCELLED] Booking cancelled" - - # Date available - return f"[SUCCESS] Booked for {date} at {time}" - - -@mcp.tool() -async def secure_payment(amount: float, ctx: Context) -> str: - """Process a secure payment requiring URL confirmation. - - This demonstrates URL mode elicitation using ctx.elicit_url() for - operations that require out-of-band user interaction. - """ - elicitation_id = str(uuid.uuid4()) - - result = await ctx.elicit_url( - message=f"Please confirm payment of ${amount:.2f}", - url=f"https://payments.example.com/confirm?amount={amount}&id={elicitation_id}", - elicitation_id=elicitation_id, - ) - - if result.action == "accept": - # In a real app, the payment confirmation would happen out-of-band - # and you'd verify the payment status from your backend - return f"Payment of ${amount:.2f} initiated - check your browser to complete" - elif result.action == "decline": - return "Payment declined by user" - return "Payment cancelled" - - -@mcp.tool() -async def connect_service(service_name: str, ctx: Context) -> str: - """Connect to a third-party service requiring OAuth authorization. - - This demonstrates the "throw error" pattern using UrlElicitationRequiredError. - Use this pattern when the tool cannot proceed without user authorization. - """ - elicitation_id = str(uuid.uuid4()) - - # Raise UrlElicitationRequiredError to signal that the client must complete - # a URL elicitation before this request can be processed. - # The MCP framework will convert this to a -32042 error response. - raise UrlElicitationRequiredError( - [ - ElicitRequestURLParams( - mode="url", - message=f"Authorization required to connect to {service_name}", - url=f"https://{service_name}.example.com/oauth/authorize?elicit={elicitation_id}", - elicitation_id=elicitation_id, - ) - ] - ) -``` - -_Full example: [examples/snippets/servers/elicitation.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/elicitation.py)_ - - -Elicitation schemas support default values for all field types. Default values are automatically included in the JSON schema sent to clients, allowing them to pre-populate forms. - -The `elicit()` method returns an `ElicitationResult` with: - -- `action`: "accept", "decline", or "cancel" -- `data`: The validated response (only when accepted) - -If the client returns data that doesn't match the schema, `elicit()` raises a `pydantic.ValidationError`. - -### Sampling - -Tools can interact with LLMs through sampling (generating text): - - -```python -from mcp_types import SamplingMessage, TextContent - -from mcp.server.mcpserver import Context, MCPServer - -mcp = MCPServer(name="Sampling Example") - - -@mcp.tool() -async def generate_poem(topic: str, ctx: Context) -> str: - """Generate a poem using LLM sampling.""" - prompt = f"Write a short poem about {topic}" - - result = await ctx.session.create_message( # pyright: ignore[reportDeprecated] - messages=[ - SamplingMessage( - role="user", - content=TextContent(type="text", text=prompt), - ) - ], - max_tokens=100, - ) - - # Since we're not passing tools param, result.content is single content - if result.content.type == "text": - return result.content.text - return str(result.content) -``` - -_Full example: [examples/snippets/servers/sampling.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/sampling.py)_ - - -### Logging and Notifications - -Tools can send logs and notifications through the context: - - -```python -from mcp.server.mcpserver import Context, MCPServer - -mcp = MCPServer(name="Notifications Example") - - -@mcp.tool() -async def process_data(data: str, ctx: Context) -> str: - """Process data with logging.""" - # Different log levels - await ctx.debug(f"Debug: Processing '{data}'") # pyright: ignore[reportDeprecated] - await ctx.info("Info: Starting processing") # pyright: ignore[reportDeprecated] - await ctx.warning("Warning: This is experimental") # pyright: ignore[reportDeprecated] - await ctx.error("Error: (This is just a demo)") # pyright: ignore[reportDeprecated] - - # Notify about resource changes - await ctx.session.send_resource_list_changed() - - return f"Processed: {data}" -``` - -_Full example: [examples/snippets/servers/notifications.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/notifications.py)_ - - -### Authentication - -Authentication can be used by servers that want to expose tools accessing protected resources. - -`mcp.server.auth` implements OAuth 2.1 resource server functionality, where MCP servers act as Resource Servers (RS) that validate tokens issued by separate Authorization Servers (AS). This follows the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) and implements RFC 9728 (Protected Resource Metadata) for AS discovery. - -MCP servers can use authentication by providing an implementation of the `TokenVerifier` protocol: - - -```python -"""Run from the repository root: -uv run examples/snippets/servers/oauth_server.py -""" - -from pydantic import AnyHttpUrl - -from mcp.server.auth.provider import AccessToken, TokenVerifier -from mcp.server.auth.settings import AuthSettings -from mcp.server.mcpserver import MCPServer - - -class SimpleTokenVerifier(TokenVerifier): - """Simple token verifier for demonstration.""" - - async def verify_token(self, token: str) -> AccessToken | None: - pass # This is where you would implement actual token validation - - -# Create MCPServer instance as a Resource Server -mcp = MCPServer( - "Weather Service", - # Token verifier for authentication - token_verifier=SimpleTokenVerifier(), - # Auth settings for RFC 9728 Protected Resource Metadata - auth=AuthSettings( - issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL - resource_server_url=AnyHttpUrl("http://localhost:3001"), # This server's URL - required_scopes=["user"], - ), -) - - -@mcp.tool() -async def get_weather(city: str = "London") -> dict[str, str]: - """Get weather data for a city""" - return { - "city": city, - "temperature": "22", - "condition": "Partly cloudy", - "humidity": "65%", - } - - -if __name__ == "__main__": - mcp.run(transport="streamable-http", json_response=True) -``` - -_Full example: [examples/snippets/servers/oauth_server.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/oauth_server.py)_ - - -For a complete example with separate Authorization Server and Resource Server implementations, see [`examples/servers/simple-auth/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/servers/simple-auth/). - -**Architecture:** - -- **Authorization Server (AS)**: Handles OAuth flows, user authentication, and token issuance -- **Resource Server (RS)**: Your MCP server that validates tokens and serves protected resources -- **Client**: Discovers AS through RFC 9728, obtains tokens, and uses them with the MCP server - -See [TokenVerifier](https://github.com/modelcontextprotocol/python-sdk/blob/main/src/mcp/server/auth/provider.py) for more details on implementing token validation. - -### MCPServer Properties - -The MCPServer server instance accessible via `ctx.mcp_server` provides access to server configuration and metadata: - -- `ctx.mcp_server.name` - The server's name as defined during initialization -- `ctx.mcp_server.instructions` - Server instructions/description provided to clients -- `ctx.mcp_server.website_url` - Optional website URL for the server -- `ctx.mcp_server.icons` - Optional list of icons for UI display -- `ctx.mcp_server.settings` - Complete server configuration object containing: - - `debug` - Debug mode flag - - `log_level` - Current logging level - - `host` and `port` - Server network configuration - - `sse_path`, `streamable_http_path` - Transport paths - - `stateless_http` - Whether the server operates in stateless mode - - And other configuration options - -```python -@mcp.tool() -def server_info(ctx: Context) -> dict: - """Get information about the current server.""" - return { - "name": ctx.mcp_server.name, - "instructions": ctx.mcp_server.instructions, - "debug_mode": ctx.mcp_server.settings.debug, - "log_level": ctx.mcp_server.settings.log_level, - "host": ctx.mcp_server.settings.host, - "port": ctx.mcp_server.settings.port, - } -``` - -### Session Properties and Methods - -The session object accessible via `ctx.session` provides advanced control over client communication: - -- `ctx.session.client_params` - Client initialization parameters and declared capabilities -- `await ctx.session.send_log_message(level, data, logger)` - Send log messages with full control -- `await ctx.session.create_message(messages, max_tokens=...)` - Request LLM sampling/completion (`max_tokens` is keyword-only) -- `await ctx.session.send_progress_notification(token, progress, total, message)` - Direct progress updates -- `await ctx.session.send_resource_updated(uri)` - Notify clients that a specific resource changed -- `await ctx.session.send_resource_list_changed()` - Notify clients that the resource list changed -- `await ctx.session.send_tool_list_changed()` - Notify clients that the tool list changed -- `await ctx.session.send_prompt_list_changed()` - Notify clients that the prompt list changed - -```python -@mcp.tool() -async def notify_data_update(resource_uri: str, ctx: Context) -> str: - """Update data and notify clients of the change.""" - # Perform data update logic here - - # Notify clients that this specific resource changed - await ctx.session.send_resource_updated(AnyUrl(resource_uri)) - - # If this affects the overall resource list, notify about that too - await ctx.session.send_resource_list_changed() - - return f"Updated {resource_uri} and notified clients" -``` - -### Request Context Properties - -The request context accessible via `ctx.request_context` contains request-specific information and resources: - -- `ctx.request_context.lifespan_context` - Access to resources initialized during server startup - - Database connections, configuration objects, shared services - - Type-safe access to resources defined in your server's lifespan function -- `ctx.request_context.meta` - Request metadata from the client including: - - `progress_token` - Token for progress notifications - - Other client-provided metadata -- `ctx.request_context.request` - Data the transport attached to this message (for example the HTTP request object on HTTP transports; `None` on stdio) -- `ctx.request_context.request_id` - Unique identifier for this request - -```python -# Example with typed lifespan context -@dataclass -class AppContext: - db: Database - config: AppConfig - -@mcp.tool() -def query_with_config(query: str, ctx: Context) -> str: - """Execute a query using shared database and configuration.""" - # Access typed lifespan context - app_ctx: AppContext = ctx.request_context.lifespan_context - - # Use shared resources - connection = app_ctx.db - settings = app_ctx.config - - # Execute query with configuration - result = connection.execute(query, timeout=settings.query_timeout) - return str(result) -``` - -_Full lifespan example: [examples/snippets/servers/lifespan_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lifespan_example.py)_ - -## Running Your Server - -### Development Mode - -The fastest way to test and debug your server is with the MCP Inspector: - -```bash -uv run mcp dev server.py - -# Add dependencies -uv run mcp dev server.py --with pandas --with numpy - -# Mount local code -uv run mcp dev server.py --with-editable . -``` - -### Claude Desktop Integration - -Once your server is ready, install it in Claude Desktop: - -```bash -uv run mcp install server.py - -# Custom name -uv run mcp install server.py --name "My Analytics Server" - -# Environment variables -uv run mcp install server.py -v API_KEY=abc123 -v DB_URL=postgres://... -uv run mcp install server.py -f .env -``` - -### Direct Execution - -For advanced scenarios like custom deployments: - - -```python -"""Example showing direct execution of an MCP server. - -This is the simplest way to run an MCP server directly. -cd to the `examples/snippets` directory and run: - uv run direct-execution-server - or - python servers/direct_execution.py -""" - -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer("My App") - - -@mcp.tool() -def hello(name: str = "World") -> str: - """Say hello to someone.""" - return f"Hello, {name}!" - - -def main(): - """Entry point for the direct execution server.""" - mcp.run() - - -if __name__ == "__main__": - main() -``` - -_Full example: [examples/snippets/servers/direct_execution.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/direct_execution.py)_ - - -Run it with: - -```bash -python servers/direct_execution.py -# or -uv run mcp run servers/direct_execution.py -``` - -Note that `uv run mcp run` or `uv run mcp dev` only supports server using MCPServer and not the low-level server variant. - -### Streamable HTTP Transport - -> **Note**: Streamable HTTP transport is the recommended transport for production deployments. Use `stateless_http=True` and `json_response=True` for optimal scalability. - - -```python -"""Run from the repository root: -uv run examples/snippets/servers/streamable_config.py -""" - -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer("StatelessServer") - - -# Add a simple tool to demonstrate the server -@mcp.tool() -def greet(name: str = "World") -> str: - """Greet someone by name.""" - return f"Hello, {name}!" - - -# Run server with streamable_http transport -# Transport-specific options (stateless_http, json_response) are passed to run() -if __name__ == "__main__": - # Stateless server with JSON responses (recommended) - mcp.run(transport="streamable-http", stateless_http=True, json_response=True) - - # Other configuration options: - # Stateless server with SSE streaming responses - # mcp.run(transport="streamable-http", stateless_http=True) - - # Stateful server with session persistence - # mcp.run(transport="streamable-http") -``` - -_Full example: [examples/snippets/servers/streamable_config.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_config.py)_ - - -You can mount multiple MCPServer servers in a Starlette application: - - -```python -"""Run from the repository root: -uvicorn examples.snippets.servers.streamable_starlette_mount:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.mcpserver import MCPServer - -# Create the Echo server -echo_mcp = MCPServer(name="EchoServer") - - -@echo_mcp.tool() -def echo(message: str) -> str: - """A simple echo tool""" - return f"Echo: {message}" - - -# Create the Math server -math_mcp = MCPServer(name="MathServer") - - -@math_mcp.tool() -def add_two(n: int) -> int: - """Tool to add two to the input""" - return n + 2 - - -# Create a combined lifespan to manage both session managers -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with contextlib.AsyncExitStack() as stack: - await stack.enter_async_context(echo_mcp.session_manager.run()) - await stack.enter_async_context(math_mcp.session_manager.run()) - yield - - -# Create the Starlette app and mount the MCP servers -app = Starlette( - routes=[ - Mount("/echo", echo_mcp.streamable_http_app(stateless_http=True, json_response=True)), - Mount("/math", math_mcp.streamable_http_app(stateless_http=True, json_response=True)), - ], - lifespan=lifespan, -) - -# Note: Clients connect to http://localhost:8000/echo/mcp and http://localhost:8000/math/mcp -# To mount at the root of each path (e.g., /echo instead of /echo/mcp): -# echo_mcp.streamable_http_app(streamable_http_path="/", stateless_http=True, json_response=True) -# math_mcp.streamable_http_app(streamable_http_path="/", stateless_http=True, json_response=True) -``` - -_Full example: [examples/snippets/servers/streamable_starlette_mount.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_starlette_mount.py)_ - - -For low level server with Streamable HTTP implementations, see: - -- Stateful server: [`examples/servers/simple-streamablehttp/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/servers/simple-streamablehttp/) -- Stateless server: [`examples/servers/simple-streamablehttp-stateless/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/servers/simple-streamablehttp-stateless/) - -The streamable HTTP transport supports: - -- Stateful and stateless operation modes -- Resumability with event stores -- JSON or SSE response formats -- Better scalability for multi-node deployments - -#### CORS Configuration for Browser-Based Clients - -If you'd like your server to be accessible by browser-based MCP clients, you'll need to configure CORS headers. The `Mcp-Session-Id` header must be exposed for browser clients to access it: - -```python -from starlette.applications import Starlette -from starlette.middleware.cors import CORSMiddleware - -# Create your Starlette app first -starlette_app = Starlette(routes=[...]) - -# Then wrap it with CORS middleware -starlette_app = CORSMiddleware( - starlette_app, - allow_origins=["*"], # Configure appropriately for production - allow_methods=["GET", "POST", "DELETE"], # MCP streamable HTTP methods - expose_headers=["Mcp-Session-Id"], -) -``` - -This configuration is necessary because: - -- The MCP streamable HTTP transport uses the `Mcp-Session-Id` header for session management -- Browsers restrict access to response headers unless explicitly exposed via CORS -- Without this configuration, browser-based clients won't be able to read the session ID from initialization responses - -### Mounting to an Existing ASGI Server - -By default, SSE servers are mounted at `/sse` and Streamable HTTP servers are mounted at `/mcp`. You can customize these paths using the methods described below. - -For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes). - -#### StreamableHTTP servers - -You can mount the StreamableHTTP server to an existing ASGI server using the `streamable_http_app` method. This allows you to integrate the StreamableHTTP server with other ASGI applications. - -##### Basic mounting - - -```python -"""Basic example showing how to mount StreamableHTTP server in Starlette. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_basic_mounting:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.mcpserver import MCPServer - -# Create MCP server -mcp = MCPServer("My App") - - -@mcp.tool() -def hello() -> str: - """A simple hello tool""" - return "Hello from MCP!" - - -# Create a lifespan context manager to run the session manager -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with mcp.session_manager.run(): - yield - - -# Mount the StreamableHTTP server to the existing ASGI server -# Transport-specific options are passed to streamable_http_app() -app = Starlette( - routes=[ - Mount("/", app=mcp.streamable_http_app(json_response=True)), - ], - lifespan=lifespan, -) -``` - -_Full example: [examples/snippets/servers/streamable_http_basic_mounting.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_basic_mounting.py)_ - - -##### Host-based routing - - -```python -"""Example showing how to mount StreamableHTTP server using Host-based routing. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_host_mounting:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Host - -from mcp.server.mcpserver import MCPServer - -# Create MCP server -mcp = MCPServer("MCP Host App") - - -@mcp.tool() -def domain_info() -> str: - """Get domain-specific information""" - return "This is served from mcp.acme.corp" - - -# Create a lifespan context manager to run the session manager -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with mcp.session_manager.run(): - yield - - -# Mount using Host-based routing -# Transport-specific options are passed to streamable_http_app() -app = Starlette( - routes=[ - Host("mcp.acme.corp", app=mcp.streamable_http_app(json_response=True)), - ], - lifespan=lifespan, -) -``` - -_Full example: [examples/snippets/servers/streamable_http_host_mounting.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_host_mounting.py)_ - - -##### Multiple servers with path configuration - - -```python -"""Example showing how to mount multiple StreamableHTTP servers with path configuration. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_multiple_servers:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.mcpserver import MCPServer - -# Create multiple MCP servers -api_mcp = MCPServer("API Server") -chat_mcp = MCPServer("Chat Server") - - -@api_mcp.tool() -def api_status() -> str: - """Get API status""" - return "API is running" - - -@chat_mcp.tool() -def send_message(message: str) -> str: - """Send a chat message""" - return f"Message sent: {message}" - - -# Create a combined lifespan to manage both session managers -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with contextlib.AsyncExitStack() as stack: - await stack.enter_async_context(api_mcp.session_manager.run()) - await stack.enter_async_context(chat_mcp.session_manager.run()) - yield - - -# Mount the servers with transport-specific options passed to streamable_http_app() -# streamable_http_path="/" means endpoints will be at /api and /chat instead of /api/mcp and /chat/mcp -app = Starlette( - routes=[ - Mount("/api", app=api_mcp.streamable_http_app(json_response=True, streamable_http_path="/")), - Mount("/chat", app=chat_mcp.streamable_http_app(json_response=True, streamable_http_path="/")), - ], - lifespan=lifespan, -) -``` - -_Full example: [examples/snippets/servers/streamable_http_multiple_servers.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_multiple_servers.py)_ - - -##### Path configuration at initialization - - -```python -"""Example showing path configuration when mounting MCPServer. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_path_config:app --reload -""" - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.mcpserver import MCPServer - -# Create a simple MCPServer server -mcp_at_root = MCPServer("My Server") - - -@mcp_at_root.tool() -def process_data(data: str) -> str: - """Process some data""" - return f"Processed: {data}" - - -# Mount at /process with streamable_http_path="/" so the endpoint is /process (not /process/mcp) -# Transport-specific options like json_response are passed to streamable_http_app() -app = Starlette( - routes=[ - Mount( - "/process", - app=mcp_at_root.streamable_http_app(json_response=True, streamable_http_path="/"), - ), - ] -) -``` - -_Full example: [examples/snippets/servers/streamable_http_path_config.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_path_config.py)_ - - -#### SSE servers - -> **Note**: SSE transport is being superseded by [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http). - -You can mount the SSE server to an existing ASGI server using the `sse_app` method. This allows you to integrate the SSE server with other ASGI applications. - -```python -from starlette.applications import Starlette -from starlette.routing import Mount, Host -from mcp.server.mcpserver import MCPServer - - -mcp = MCPServer("My App") - -# Mount the SSE server to the existing ASGI server -app = Starlette( - routes=[ - Mount('/', app=mcp.sse_app()), - ] -) - -# or dynamically mount as host -app.router.routes.append(Host('mcp.acme.corp', app=mcp.sse_app())) -``` - -You can also mount multiple MCP servers at different sub-paths. The SSE transport automatically detects the mount path via ASGI's `root_path` mechanism, so message endpoints are correctly routed: - -```python -from starlette.applications import Starlette -from starlette.routing import Mount -from mcp.server.mcpserver import MCPServer - -# Create multiple MCP servers -github_mcp = MCPServer("GitHub API") -browser_mcp = MCPServer("Browser") -search_mcp = MCPServer("Search") - -# Mount each server at its own sub-path -# The SSE transport automatically uses ASGI's root_path to construct -# the correct message endpoint (e.g., /github/messages/, /browser/messages/) -app = Starlette( - routes=[ - Mount("/github", app=github_mcp.sse_app()), - Mount("/browser", app=browser_mcp.sse_app()), - Mount("/search", app=search_mcp.sse_app()), - ] -) -``` - -For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes). - -## Advanced Usage - -### Low-Level Server - -For more control, you can use the low-level server implementation directly. This gives you full access to the protocol and allows you to customize every aspect of your server, including lifecycle management through the lifespan API: - - -```python -"""Run from the repository root: -uv run examples/snippets/servers/lowlevel/lifespan.py -""" - -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from typing import TypedDict - -import mcp_types as types - -import mcp.server.stdio -from mcp.server import Server, ServerRequestContext - - -# Mock database class for example -class Database: - """Mock database class for example.""" - - @classmethod - async def connect(cls) -> "Database": - """Connect to database.""" - print("Database connected") - return cls() - - async def disconnect(self) -> None: - """Disconnect from database.""" - print("Database disconnected") - - async def query(self, query_str: str) -> list[dict[str, str]]: - """Execute a query.""" - # Simulate database query - return [{"id": "1", "name": "Example", "query": query_str}] - - -class AppContext(TypedDict): - db: Database - - -@asynccontextmanager -async def server_lifespan(_server: Server[AppContext]) -> AsyncIterator[AppContext]: - """Manage server startup and shutdown lifecycle.""" - db = await Database.connect() - try: - yield {"db": db} - finally: - await db.disconnect() - - -async def handle_list_tools( - ctx: ServerRequestContext[AppContext], params: types.PaginatedRequestParams | None -) -> types.ListToolsResult: - """List available tools.""" - return types.ListToolsResult( - tools=[ - types.Tool( - name="query_db", - description="Query the database", - input_schema={ - "type": "object", - "properties": {"query": {"type": "string", "description": "SQL query to execute"}}, - "required": ["query"], - }, - ) - ] - ) - - -async def handle_call_tool( - ctx: ServerRequestContext[AppContext], params: types.CallToolRequestParams -) -> types.CallToolResult: - """Handle database query tool call.""" - if params.name != "query_db": - raise ValueError(f"Unknown tool: {params.name}") - - db = ctx.lifespan_context["db"] - results = await db.query((params.arguments or {})["query"]) - - return types.CallToolResult(content=[types.TextContent(type="text", text=f"Query results: {results}")]) - - -server = Server( - "example-server", - lifespan=server_lifespan, - on_list_tools=handle_list_tools, - on_call_tool=handle_call_tool, -) - - -async def run(): - """Run the server with lifespan management.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options(), - ) - - -if __name__ == "__main__": - import asyncio - - asyncio.run(run()) -``` - -_Full example: [examples/snippets/servers/lowlevel/lifespan.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/lifespan.py)_ - - -The lifespan API provides: - -- A way to initialize resources when the server starts and clean them up when it stops -- Access to initialized resources through the request context in handlers -- Type-safe context passing between lifespan and request handlers - - -```python -"""Run from the repository root: -uv run examples/snippets/servers/lowlevel/basic.py -""" - -import asyncio - -import mcp_types as types - -import mcp.server.stdio -from mcp.server import Server, ServerRequestContext - - -async def handle_list_prompts( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListPromptsResult: - """List available prompts.""" - return types.ListPromptsResult( - prompts=[ - types.Prompt( - name="example-prompt", - description="An example prompt template", - arguments=[types.PromptArgument(name="arg1", description="Example argument", required=True)], - ) - ] - ) - - -async def handle_get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> types.GetPromptResult: - """Get a specific prompt by name.""" - if params.name != "example-prompt": - raise ValueError(f"Unknown prompt: {params.name}") - - arg1_value = (params.arguments or {}).get("arg1", "default") - - return types.GetPromptResult( - description="Example prompt", - messages=[ - types.PromptMessage( - role="user", - content=types.TextContent(type="text", text=f"Example prompt text with argument: {arg1_value}"), - ) - ], - ) - - -server = Server( - "example-server", - on_list_prompts=handle_list_prompts, - on_get_prompt=handle_get_prompt, -) - - -async def run(): - """Run the basic low-level server.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options(), - ) - - -if __name__ == "__main__": - asyncio.run(run()) -``` - -_Full example: [examples/snippets/servers/lowlevel/basic.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/basic.py)_ - - -Caution: The `uv run mcp run` and `uv run mcp dev` tool doesn't support low-level server. - -#### Structured Output Support - -The low-level server supports structured output for tools, allowing you to return both human-readable content and machine-readable structured data. Tools can define an `outputSchema` to validate their structured output: - - -```python -"""Run from the repository root: -uv run examples/snippets/servers/lowlevel/structured_output.py -""" - -import asyncio -import json - -import mcp_types as types - -import mcp.server.stdio -from mcp.server import Server, ServerRequestContext - - -async def handle_list_tools( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListToolsResult: - """List available tools with structured output schemas.""" - return types.ListToolsResult( - tools=[ - types.Tool( - name="get_weather", - description="Get current weather for a city", - input_schema={ - "type": "object", - "properties": {"city": {"type": "string", "description": "City name"}}, - "required": ["city"], - }, - output_schema={ - "type": "object", - "properties": { - "temperature": {"type": "number", "description": "Temperature in Celsius"}, - "condition": {"type": "string", "description": "Weather condition"}, - "humidity": {"type": "number", "description": "Humidity percentage"}, - "city": {"type": "string", "description": "City name"}, - }, - "required": ["temperature", "condition", "humidity", "city"], - }, - ) - ] - ) - - -async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: - """Handle tool calls with structured output.""" - if params.name == "get_weather": - city = (params.arguments or {})["city"] - - weather_data = { - "temperature": 22.5, - "condition": "partly cloudy", - "humidity": 65, - "city": city, - } - - return types.CallToolResult( - content=[types.TextContent(type="text", text=json.dumps(weather_data, indent=2))], - structured_content=weather_data, - ) - - raise ValueError(f"Unknown tool: {params.name}") - - -server = Server( - "example-server", - on_list_tools=handle_list_tools, - on_call_tool=handle_call_tool, -) - - -async def run(): - """Run the structured output server.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options(), - ) - - -if __name__ == "__main__": - asyncio.run(run()) -``` - -_Full example: [examples/snippets/servers/lowlevel/structured_output.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/structured_output.py)_ - - -With the low-level server, handlers always return `CallToolResult` directly. You construct both the human-readable `content` and the machine-readable `structured_content` yourself, giving you full control over the response. - -##### Returning CallToolResult with `_meta` - -For passing data to client applications without exposing it to the model, use the `_meta` field on `CallToolResult`: - - -```python -"""Run from the repository root: -uv run examples/snippets/servers/lowlevel/direct_call_tool_result.py -""" - -import asyncio - -import mcp_types as types - -import mcp.server.stdio -from mcp.server import Server, ServerRequestContext - - -async def handle_list_tools( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListToolsResult: - """List available tools.""" - return types.ListToolsResult( - tools=[ - types.Tool( - name="advanced_tool", - description="Tool with full control including _meta field", - input_schema={ - "type": "object", - "properties": {"message": {"type": "string"}}, - "required": ["message"], - }, - ) - ] - ) - - -async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: - """Handle tool calls by returning CallToolResult directly.""" - if params.name == "advanced_tool": - message = (params.arguments or {}).get("message", "") - return types.CallToolResult( - content=[types.TextContent(type="text", text=f"Processed: {message}")], - structured_content={"result": "success", "message": message}, - _meta={"hidden": "data for client applications only"}, - ) - - raise ValueError(f"Unknown tool: {params.name}") - - -server = Server( - "example-server", - on_list_tools=handle_list_tools, - on_call_tool=handle_call_tool, -) - - -async def run(): - """Run the server.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options(), - ) - - -if __name__ == "__main__": - asyncio.run(run()) -``` - -_Full example: [examples/snippets/servers/lowlevel/direct_call_tool_result.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/direct_call_tool_result.py)_ - - -### Pagination (Advanced) - -For servers that need to handle large datasets, the low-level server provides paginated versions of list operations. This is an optional optimization - most servers won't need pagination unless they're dealing with hundreds or thousands of items. - -#### Server-side Implementation - - -```python -"""Example of implementing pagination with the low-level MCP server.""" - -import mcp_types as types - -from mcp.server import Server, ServerRequestContext - -# Sample data to paginate -ITEMS = [f"Item {i}" for i in range(1, 101)] # 100 items - - -async def handle_list_resources( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListResourcesResult: - """List resources with pagination support.""" - page_size = 10 - - # Extract cursor from request params - cursor = params.cursor if params is not None else None - - # Parse cursor to get offset - start = 0 if cursor is None else int(cursor) - end = start + page_size - - # Get page of resources - page_items = [ - types.Resource(uri=f"resource://items/{item}", name=item, description=f"Description for {item}") - for item in ITEMS[start:end] - ] - - # Determine next cursor - next_cursor = str(end) if end < len(ITEMS) else None - - return types.ListResourcesResult(resources=page_items, next_cursor=next_cursor) - - -server = Server("paginated-server", on_list_resources=handle_list_resources) -``` - -_Full example: [examples/snippets/servers/pagination_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/pagination_example.py)_ - - -#### Client-side Consumption - - -```python -"""Example of consuming paginated MCP endpoints from a client.""" - -import asyncio - -from mcp_types import PaginatedRequestParams, Resource - -from mcp.client.session import ClientSession -from mcp.client.stdio import StdioServerParameters, stdio_client - - -async def list_all_resources() -> None: - """Fetch all resources using pagination.""" - async with stdio_client(StdioServerParameters(command="uv", args=["run", "mcp-simple-pagination"])) as ( - read, - write, - ): - async with ClientSession(read, write) as session: - await session.initialize() - - all_resources: list[Resource] = [] - cursor = None - - while True: - # Fetch a page of resources - result = await session.list_resources(params=PaginatedRequestParams(cursor=cursor)) - all_resources.extend(result.resources) - - print(f"Fetched {len(result.resources)} resources") - - # Check if there are more pages - if result.next_cursor: - cursor = result.next_cursor - else: - break - - print(f"Total resources: {len(all_resources)}") - - -if __name__ == "__main__": - asyncio.run(list_all_resources()) -``` - -_Full example: [examples/snippets/clients/pagination_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/pagination_client.py)_ - - -#### Key Points - -- **Cursors are opaque strings** - the server defines the format (numeric offsets, timestamps, etc.) -- **Return `nextCursor=None`** when there are no more pages -- **Backward compatible** - clients that don't support pagination will still work (they'll just get the first page) -- **Flexible page sizes** - Each endpoint can define its own page size based on data characteristics - -See the [simple-pagination example](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/servers/simple-pagination) for a complete implementation. - -### Writing MCP Clients - -The SDK provides a high-level client interface for connecting to MCP servers using various [transports](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports): - - -```python -"""cd to the `examples/snippets/clients` directory and run: -uv run client -""" - -import asyncio -import os - -import mcp_types as types - -from mcp import ClientSession, StdioServerParameters -from mcp.client.context import ClientRequestContext -from mcp.client.stdio import stdio_client - -# Create server parameters for stdio connection -server_params = StdioServerParameters( - command="uv", # Using uv to run the server - args=["run", "server", "mcpserver_quickstart", "stdio"], # We're already in snippets dir - env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, -) - - -# Optional: create a sampling callback -async def handle_sampling_message( - context: ClientRequestContext, params: types.CreateMessageRequestParams -) -> types.CreateMessageResult: - print(f"Sampling request: {params.messages}") - return types.CreateMessageResult( - role="assistant", - content=types.TextContent( - type="text", - text="Hello, world! from model", - ), - model="gpt-3.5-turbo", - stop_reason="endTurn", - ) - - -async def run(): - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write, sampling_callback=handle_sampling_message) as session: - # Initialize the connection - await session.initialize() - - # List available prompts - prompts = await session.list_prompts() - print(f"Available prompts: {[p.name for p in prompts.prompts]}") - - # Get a prompt (greet_user prompt from mcpserver_quickstart) - if prompts.prompts: - prompt = await session.get_prompt("greet_user", arguments={"name": "Alice", "style": "friendly"}) - print(f"Prompt result: {prompt.messages[0].content}") - - # List available resources - resources = await session.list_resources() - print(f"Available resources: {[r.uri for r in resources.resources]}") - - # List available tools - tools = await session.list_tools() - print(f"Available tools: {[t.name for t in tools.tools]}") - - # Read a resource (greeting resource from mcpserver_quickstart) - resource_content = await session.read_resource("greeting://World") - content_block = resource_content.contents[0] - if isinstance(content_block, types.TextResourceContents): - print(f"Resource content: {content_block.text}") - - # Call a tool (add tool from mcpserver_quickstart) - result = await session.call_tool("add", arguments={"a": 5, "b": 3}) - result_unstructured = result.content[0] - if isinstance(result_unstructured, types.TextContent): - print(f"Tool result: {result_unstructured.text}") - result_structured = result.structured_content - print(f"Structured tool result: {result_structured}") - - -def main(): - """Entry point for the client script.""" - asyncio.run(run()) - - -if __name__ == "__main__": - main() -``` - -_Full example: [examples/snippets/clients/stdio_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/stdio_client.py)_ - - -Clients can also connect using [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http): - - -```python -"""Run from the repository root: -uv run examples/snippets/clients/streamable_basic.py -""" - -import asyncio - -from mcp import ClientSession -from mcp.client.streamable_http import streamable_http_client - - -async def main(): - # Connect to a streamable HTTP server - async with streamable_http_client("http://localhost:8000/mcp") as (read_stream, write_stream): - # Create a session using the client streams - async with ClientSession(read_stream, write_stream) as session: - # Initialize the connection - await session.initialize() - # List available tools - tools = await session.list_tools() - print(f"Available tools: {[tool.name for tool in tools.tools]}") - - -if __name__ == "__main__": - asyncio.run(main()) -``` - -_Full example: [examples/snippets/clients/streamable_basic.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/streamable_basic.py)_ - - -### Client Display Utilities - -When building MCP clients, the SDK provides utilities to help display human-readable names for tools, resources, and prompts: - - -```python -"""cd to the `examples/snippets` directory and run: -uv run display-utilities-client -""" - -import asyncio -import os - -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client -from mcp.shared.metadata_utils import get_display_name - -# Create server parameters for stdio connection -server_params = StdioServerParameters( - command="uv", # Using uv to run the server - args=["run", "server", "mcpserver_quickstart", "stdio"], - env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, -) - - -async def display_tools(session: ClientSession): - """Display available tools with human-readable names""" - tools_response = await session.list_tools() - - for tool in tools_response.tools: - # get_display_name() returns the title if available, otherwise the name - display_name = get_display_name(tool) - print(f"Tool: {display_name}") - if tool.description: - print(f" {tool.description}") - - -async def display_resources(session: ClientSession): - """Display available resources with human-readable names""" - resources_response = await session.list_resources() - - for resource in resources_response.resources: - display_name = get_display_name(resource) - print(f"Resource: {display_name} ({resource.uri})") - - templates_response = await session.list_resource_templates() - for template in templates_response.resource_templates: - display_name = get_display_name(template) - print(f"Resource Template: {display_name}") - - -async def run(): - """Run the display utilities example.""" - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - # Initialize the connection - await session.initialize() - - print("=== Available Tools ===") - await display_tools(session) - - print("\n=== Available Resources ===") - await display_resources(session) - - -def main(): - """Entry point for the display utilities client.""" - asyncio.run(run()) - - -if __name__ == "__main__": - main() -``` - -_Full example: [examples/snippets/clients/display_utilities.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/display_utilities.py)_ - - -The `get_display_name()` function implements the proper precedence rules for displaying names: - -- For tools: `title` > `annotations.title` > `name` -- For other objects: `title` > `name` - -This ensures your client UI shows the most user-friendly names that servers provide. - -### OAuth Authentication for Clients - -The SDK includes [authorization support](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) for connecting to protected MCP servers: - - -```python -"""Before running, specify running MCP RS server URL. -To spin up RS server locally, see - examples/servers/simple-auth/README.md - -cd to the `examples/snippets` directory and run: - uv run oauth-client -""" - -import asyncio -from urllib.parse import parse_qs, urlparse - -import httpx -from pydantic import AnyUrl - -from mcp import ClientSession -from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider, TokenStorage -from mcp.client.streamable_http import streamable_http_client -from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken - - -class InMemoryTokenStorage(TokenStorage): - """Demo In-memory token storage implementation.""" - - def __init__(self): - self.tokens: OAuthToken | None = None - self.client_info: OAuthClientInformationFull | None = None - - async def get_tokens(self) -> OAuthToken | None: - """Get stored tokens.""" - return self.tokens - - async def set_tokens(self, tokens: OAuthToken) -> None: - """Store tokens.""" - self.tokens = tokens - - async def get_client_info(self) -> OAuthClientInformationFull | None: - """Get stored client information.""" - return self.client_info - - async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: - """Store client information.""" - self.client_info = client_info - - -async def handle_redirect(auth_url: str) -> None: - print(f"Visit: {auth_url}") - - -async def handle_callback() -> AuthorizationCodeResult: - callback_url = input("Paste callback URL: ") - params = parse_qs(urlparse(callback_url).query) - return AuthorizationCodeResult( - code=params["code"][0], - state=params.get("state", [None])[0], - iss=params.get("iss", [None])[0], - ) - - -async def main(): - """Run the OAuth client example.""" - oauth_auth = OAuthClientProvider( - server_url="http://localhost:8001", - client_metadata=OAuthClientMetadata( - client_name="Example MCP Client", - redirect_uris=[AnyUrl("http://localhost:3000/callback")], - grant_types=["authorization_code", "refresh_token"], - response_types=["code"], - scope="user", - ), - storage=InMemoryTokenStorage(), - redirect_handler=handle_redirect, - callback_handler=handle_callback, - ) - - async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: - async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - tools = await session.list_tools() - print(f"Available tools: {[tool.name for tool in tools.tools]}") - - resources = await session.list_resources() - print(f"Available resources: {[r.uri for r in resources.resources]}") - - -def run(): - asyncio.run(main()) - - -if __name__ == "__main__": - run() -``` - -_Full example: [examples/snippets/clients/oauth_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/oauth_client.py)_ - - -For a complete working example, see [`examples/clients/simple-auth-client/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples/clients/simple-auth-client/). - -### Parsing Tool Results - -When calling tools through MCP, the `CallToolResult` object contains the tool's response in a structured format. Understanding how to parse this result is essential for properly handling tool outputs. - - -```python -"""examples/snippets/clients/parsing_tool_results.py""" - -import asyncio - -import mcp_types as types - -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client - - -async def parse_tool_results(): - """Demonstrates how to parse different types of content in CallToolResult.""" - server_params = StdioServerParameters(command="python", args=["path/to/mcp_server.py"]) - - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - # Example 1: Parsing text content - result = await session.call_tool("get_data", {"format": "text"}) - for content in result.content: - if isinstance(content, types.TextContent): - print(f"Text: {content.text}") - - # Example 2: Parsing structured content from JSON tools - result = await session.call_tool("get_user", {"id": "123"}) - if hasattr(result, "structured_content") and result.structured_content: - # Access structured data directly - user_data = result.structured_content - print(f"User: {user_data.get('name')}, Age: {user_data.get('age')}") - - # Example 3: Parsing embedded resources - result = await session.call_tool("read_config", {}) - for content in result.content: - if isinstance(content, types.EmbeddedResource): - resource = content.resource - if isinstance(resource, types.TextResourceContents): - print(f"Config from {resource.uri}: {resource.text}") - else: - print(f"Binary data from {resource.uri}") - - # Example 4: Parsing image content - result = await session.call_tool("generate_chart", {"data": [1, 2, 3]}) - for content in result.content: - if isinstance(content, types.ImageContent): - print(f"Image ({content.mime_type}): {len(content.data)} bytes") - - # Example 5: Handling errors - result = await session.call_tool("failing_tool", {}) - if result.is_error: - print("Tool execution failed!") - for content in result.content: - if isinstance(content, types.TextContent): - print(f"Error: {content.text}") - - -async def main(): - await parse_tool_results() - - -if __name__ == "__main__": - asyncio.run(main()) -``` - -_Full example: [examples/snippets/clients/parsing_tool_results.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/parsing_tool_results.py)_ - - -### MCP Primitives - -The MCP protocol defines three core primitives that servers can implement: - -| Primitive | Control | Description | Example Use | -|-----------|-----------------------|-----------------------------------------------------|------------------------------| -| Prompts | User-controlled | Interactive templates invoked by user choice | Slash commands, menu options | -| Resources | Application-controlled| Contextual data managed by the client application | File contents, API responses | -| Tools | Model-controlled | Functions exposed to the LLM to take actions | API calls, data updates | - -### Server Capabilities - -MCP servers declare capabilities during initialization: - -| Capability | Feature Flag | Description | -|--------------|------------------------------|------------------------------------| -| `prompts` | `listChanged` | Prompt template management | -| `resources` | `subscribe`
`listChanged`| Resource exposure and updates | -| `tools` | `listChanged` | Tool discovery and execution | -| `logging` | - | Server logging configuration | -| `completions`| - | Argument completion suggestions | - -## Documentation - -- [API Reference](https://py.sdk.modelcontextprotocol.io/v2/api/mcp/) -- [Model Context Protocol documentation](https://modelcontextprotocol.io) -- [Model Context Protocol specification](https://modelcontextprotocol.io/specification/latest) -- [Officially supported servers](https://github.com/modelcontextprotocol/servers) - -## Contributing - -We are passionate about supporting contributors of all levels of experience and would love to see you get involved in the project. See the [contributing guide](https://github.com/modelcontextprotocol/python-sdk/blob/main/CONTRIBUTING.md) to get started. - -## License - -This project is licensed under the MIT License - see the LICENSE file for details. +[pypi-badge]: https://img.shields.io/pypi/v/mcp.svg +[pypi-url]: https://pypi.org/project/mcp/ +[mit-badge]: https://img.shields.io/pypi/l/mcp.svg +[mit-url]: https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE +[python-badge]: https://img.shields.io/pypi/pyversions/mcp.svg +[python-url]: https://www.python.org/downloads/ +[docs-badge]: https://img.shields.io/badge/docs-python--sdk-blue.svg +[docs-url]: https://py.sdk.modelcontextprotocol.io/v2/ +[protocol-badge]: https://img.shields.io/badge/protocol-modelcontextprotocol.io-blue.svg +[protocol-url]: https://modelcontextprotocol.io +[spec-badge]: https://img.shields.io/badge/spec-spec.modelcontextprotocol.io-blue.svg +[spec-url]: https://modelcontextprotocol.io/specification/latest diff --git a/docs/advanced/authorization.md b/docs/advanced/authorization.md new file mode 100644 index 0000000000..5f96571f4b --- /dev/null +++ b/docs/advanced/authorization.md @@ -0,0 +1,121 @@ +# Authorization + +Over Streamable HTTP your MCP server is an ordinary web service, and you protect it the way you protect any web service: with OAuth 2.1 bearer tokens. + +In OAuth terms, your server is a **resource server**. It never signs anyone in and it never issues a token. It does one thing: look at the `Authorization` header on each request and decide whether the token in it is good. + +## The three parties + +* The **authorization server** signs people in and issues access tokens. You don't write this. It's your identity provider (Auth0, Keycloak, Entra, your own). +* The **resource server** is your MCP server. It verifies the token on every request. +* The **client** discovers which authorization server you trust, gets a token from it, and sends it back to you as `Authorization: Bearer `. + +That's the whole triangle. Everything on this page is the middle bullet. + +## A token verifier + +The SDK has no opinion about what a valid token looks like. You tell it, by implementing **`TokenVerifier`**: + +```python title="server.py" hl_lines="12-14 19-24" +--8<-- "docs_src/authorization/tutorial001.py" +``` + +* `TokenVerifier` is a protocol with one async method. `verify_token` gets the raw token from the `Authorization` header and returns an **`AccessToken`** if it's valid, `None` if it isn't. There is nothing else to implement. +* This one looks the token up in a table. A real one verifies a JWT signature or calls the authorization server's token-introspection endpoint. That code is yours; the SDK only calls it. +* `token_verifier=` and `auth=` always travel together. Pass one without the other and `MCPServer(...)` raises a `ValueError` before it ever serves a request. + +`AuthSettings` is the public face of your resource server: + +* `issuer_url`: the authorization server that issues your tokens. +* `resource_server_url`: the public URL of this MCP endpoint. It names *which* resource a token is for, and it's where the discovery document lives. +* `required_scopes`: every token must carry all of them. + +!!! tip + `examples/servers/simple-auth/` in the SDK repository has an `IntrospectionTokenVerifier` that calls + a real authorization server's RFC 7662 endpoint. It's the shape most production verifiers take. + +## What you get over HTTP + +Authorization lives in HTTP headers, so it exists only on the HTTP transports. Run it on the one you deploy: `mcp.run(transport="streamable-http")` puts it on `http://127.0.0.1:8000/mcp`, and **Running your server** has the rest. The app now has two routes: + +```text +/mcp +/.well-known/oauth-protected-resource/mcp +``` + +You registered one tool. The second route is the SDK's. + +### Discovery + +`GET` that well-known path and you get **RFC 9728 Protected Resource Metadata**, built straight from your `AuthSettings`: + +```json +{ + "resource": "http://127.0.0.1:8000/mcp", + "authorization_servers": ["https://auth.example.com/"], + "scopes_supported": ["notes:read"], + "bearer_methods_supported": ["header"] +} +``` + +This document is how a client that has never heard of your server finds its way in: it reads `authorization_servers` and goes there for a token. You wrote none of it. + +!!! check + Call `/mcp` with no token (or with one your verifier returned `None` for) and the request is + stopped at the door: + + ```text + HTTP/1.1 401 Unauthorized + WWW-Authenticate: Bearer error="invalid_token", error_description="Authentication required", resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp" + + {"error": "invalid_token", "error_description": "Authentication required"} + ``` + + Nothing was parsed and no tool ran. And that `resource_metadata` pointer in `WWW-Authenticate` is + what makes discovery automatic: 401 -> metadata document -> authorization server -> token -> retry. + +!!! warning + None of this protects `stdio`. A pipe has no `Authorization` header, so `token_verifier` is never + consulted there. A `stdio` server's security boundary is the process that launched it. The same + goes for the in-memory `Client(mcp)` you use in tests: it connects straight to the server object + and skips the HTTP layer, authorization included. + +## The caller's identity + +Inside any handler, **`get_access_token()`** is the `AccessToken` your verifier returned for the current request: + +```python title="server.py" hl_lines="4 32-35" +--8<-- "docs_src/authorization/tutorial002.py" +``` + +* It works in tools, resources, and prompts, and there is nothing to pass around: the auth middleware stores it in a context variable per request. +* You get back the **same object your verifier built**: `client_id`, `scopes`, `subject`, `expires_at`, and any extra `claims` you attached. That's the hook for per-tool rules: read the scopes and refuse. +* Outside an authenticated HTTP request it returns `None`. In-memory and over `stdio` it is always `None`. + +Call `whoami` with `Authorization: Bearer alice-token` and the model reads: + +```text +alice (scopes: notes:read) +``` + +## The half the SDK doesn't do + +The SDK gives you the resource-server half: verify, advertise, refuse. It does not give you a login page, a consent screen, or a token. + +To watch all three parties move, run `examples/servers/simple-auth/` from the SDK repository (a small authorization server and a resource server set up exactly like this page) and then point `examples/clients/simple-auth-client/` at it for the full discovery-and-token dance. + +!!! info + There is a second constructor argument, `auth_server_provider=`, that embeds a full authorization + server inside your MCP server. It predates the AS/RS separation that the MCP authorization spec + is built around. New servers should not reach for it. + +## Recap + +* Over Streamable HTTP your server is an OAuth 2.1 **resource server**: it verifies tokens, it never issues them. +* `TokenVerifier` is the whole integration surface: one async method, token in, `AccessToken | None` out. +* `token_verifier=` and `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` always travel together. +* The SDK publishes RFC 9728 Protected Resource Metadata at `/.well-known/oauth-protected-resource/...` and answers unauthenticated requests with a 401 whose `WWW-Authenticate` header points at it. That is the entire discovery story. +* `get_access_token()` in any handler is who's calling. +* Authorization is an HTTP concern. `stdio` and the in-memory client never see it. + +The other side of the handshake, a client that discovers your authorization server and fetches the token for you, is **OAuth clients**. diff --git a/docs/advanced/deprecated.md b/docs/advanced/deprecated.md new file mode 100644 index 0000000000..4a3d4f831a --- /dev/null +++ b/docs/advanced/deprecated.md @@ -0,0 +1,91 @@ +# Deprecated features + +The 2026-07-28 spec retires five things. The SDK still implements every one of them, and every one of them now carries a **deprecation warning**. + +The table below names each deprecated feature, why it is going away, and the replacement to build on. + +## What is deprecated + +| Deprecated | Why | What you do instead | +|---|---|---| +| **Roots**: `ctx.session.list_roots()`, `client.send_roots_list_changed()`, the `list_roots_callback=` you pass to `Client(...)` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) retires the capability. | Take the paths that matter as ordinary tool arguments or resource URIs. | +| **Server-initiated sampling**: `ctx.session.create_message()`, the `sampling_callback=` you pass to `Client(...)` | SEP-2577 retires the capability. | Return `InputRequiredResult` and let the client retry the call (see **Multi-round-trip requests**). | +| **Protocol logging**: `ctx.log()`, `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`, `ctx.session.send_log_message()`, `client.set_logging_level()` | SEP-2577 retires the capability. Nothing in-protocol replaces it. | Ordinary `import logging` to stderr (see **Logging**). | +| **`ping`**: `client.send_ping()` | **Removed** from the protocol, not merely deprecated. There is no `ping` method in 2026-07-28. | Nothing. It only works against a `mode="legacy"` connection. | +| **Client->server progress**: `client.send_progress_notification()` | 2026-07-28 makes progress server->client only. | Nothing to send. Your *server* reports progress with `ctx.report_progress()` (see **Progress**). | + +Three things fall out of that table: + +* Roots, sampling, and logging go together. One proposal, **SEP-2577**, deprecates all three capabilities at once. +* Sampling and roots share a deeper problem: they are the two places a **server** sends a **request** to the **client**. That whole direction is what 2026-07-28 replaces with **Multi-round-trip requests**. +* `ping` is the odd one out. The protocol does not deprecate it, it removes it. The SDK method still warns (its message says *removed*, not *deprecated*) and calling it on a modern connection answers with *"Method not found"*. + +## Deprecated is advisory + +Nothing breaks today. + +Every method above keeps working against any session that negotiated **2025-11-25 or earlier**. Pin `mode="legacy"` on the client and you get exactly the pre-2026 behaviour. There are no wire changes and capability negotiation is unchanged. + +What changes is that you get a visible warning the first time each one runs: + +```text +MCPDeprecationWarning: The logging capability is deprecated as of 2026-07-28 (SEP-2577). +``` + +`MCPDeprecationWarning` subclasses `UserWarning`, **not** `DeprecationWarning`. That is deliberate: Python's default filter only shows `DeprecationWarning` in code run directly as `__main__`, which is how libraries deprecate things and nobody notices for two years. This one shows up everywhere, with no `-W` flag. + +!!! warning + "Advisory" stops at the wire. Sampling and roots are server-to-client *requests*, and a + 2026-07-28 session has no channel to carry one. Call `ctx.session.create_message()` + inside a tool on a modern connection and the warning still fires, and then the send + fails with an error: + + ```text + Cannot send 'sampling/createMessage': this transport context has no back-channel + for server-initiated requests. + ``` + + Two signals, in that order. The `MCPDeprecationWarning` fires the moment you call the + method, on any connection. The error is what comes back when the SDK then tries to + send. These two only work end-to-end on a `mode="legacy"` connection whose client + registered the matching callback. + +## Silencing the warning + +Don't, in new code. + +But a server you maintain that genuinely serves pre-2026 clients has every right to a quiet log. Filter the category before the first deprecated call runs: + +```python +import warnings + +from mcp import MCPDeprecationWarning + +warnings.filterwarnings("ignore", category=MCPDeprecationWarning) +``` + +That is the whole API. There is no per-method switch, and you don't want one: the point of one category is that one line silences it and one line brings it back. + +!!! check + Run the filter the other way and you get a free regression test. Add + `"error::mcp.MCPDeprecationWarning"` to the `filterwarnings` setting in your pytest + configuration and the deprecated call **raises** instead of warning. A tool named + `old_log` that still calls `ctx.info()` stops passing and starts reporting: + + ```text + Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577). + ``` + + One line of pytest configuration, and a deprecated call can never sneak back into your + codebase without failing a test. + +## Recap + +* The 2026-07-28 spec deprecates **roots**, server-initiated **sampling**, and protocol **logging** (all SEP-2577), restricts **progress** to server-to-client, and removes **`ping`**. +* The replacement column points you onward: **Multi-round-trip requests** for sampling, **Logging** for logging, **Progress** for progress. Roots needs no chapter (pass the paths as arguments) and `ping` needs nothing at all. +* Deprecated is advisory: no wire changes, everything keeps working against pre-2026 sessions, and you get a visible `MCPDeprecationWarning` (a `UserWarning`, so it is on by default). +* Sampling and roots additionally need a back-channel that a 2026-07-28 session does not have. On a modern connection they warn and then they raise. +* `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` silences the whole category; `"error::mcp.MCPDeprecationWarning"` in pytest turns it into a test failure. +* New code should not be built on any of these. + +Every other page in these docs teaches the current API. diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md new file mode 100644 index 0000000000..8473495ce0 --- /dev/null +++ b/docs/advanced/low-level-server.md @@ -0,0 +1,198 @@ +# The low-level Server + +`@mcp.tool()` is a layer. Underneath it is a second server class, `Server`, that speaks raw MCP: you hand it the protocol objects and it puts them on the wire, unchanged. + +`MCPServer` is built on top of it. You drop down when the convenience layer is in the way: + +* You need to emit an **exact** schema (loaded from a file, generated from a database), not one derived from a Python signature. +* You need full control of the result: `_meta`, `is_error`, every key of `structured_content`. +* You need to handle a method MCP doesn't define. + +For everything else, stay on `MCPServer`. + +## The same tool, by hand + +This is `search_books` from **Tools** (the nine-line `@mcp.tool()` file) with the sugar removed: + +```python title="server.py" hl_lines="23 27 33" +--8<-- "docs_src/lowlevel/tutorial001.py" +``` + +Three things changed, and they are the whole low-level API: + +* **Handlers are constructor parameters.** `on_list_tools=` and `on_call_tool=` go into `Server(...)`. There are no decorators down here, and every handler has the same shape: `async (ctx, params) -> result`. +* **You write the input schema.** `Tool.input_schema` is a plain JSON Schema `dict`. Nobody derives it from type hints, because there are no type hints to derive it from. +* **You build the result.** `CallToolResult(content=[TextContent(...)])`, by hand. Nothing is wrapped, converted, or inferred from a return annotation. + +`params` is the parsed request: `CallToolRequestParams` gives you `.name` and `.arguments`. `ctx` is a `ServerRequestContext`: `ctx.session` for talking back to the client, `ctx.lifespan_context`, `ctx.request_id`, and `ctx.meta`, the request's inbound `_meta`. + +!!! info + If you've used FastAPI, you already know this relationship. `MCPServer` is the decorators-and-type-hints layer; `Server` is the Starlette underneath. They are not rivals: `MCPServer` constructs a `Server` and registers handlers exactly like these on it. + +### Try it + +There is no Inspector for this one: `mcp dev` and `mcp run` only accept an `MCPServer`. The in-memory `Client` doesn't care; it takes a low-level `Server` exactly like it takes an `MCPServer`: + +```python title="main.py" +import asyncio + +from mcp import Client + +from server import server + + +async def main() -> None: + async with Client(server) as client: + result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) + print(result.content) + + +asyncio.run(main()) +``` + +```text +[TextContent(type='text', text="Found 3 books matching 'dune' (showing up to 5).", annotations=None, meta=None)] +``` + +The same text the `@mcp.tool()` version produced. Two honest differences: + +* `result.structured_content` is `None`. The high-level server wrapped your `-> str` into `{"result": ...}`; here nobody builds what you didn't build. +* `list_tools` returns the schema **you** typed, character for character. The high-level version had `"title": "Query"` on every property and a `"title": "search_booksArguments"` at the root: Pydantic artifacts. Down here, if it's on the wire, you put it there. + +## Nothing is checked for you + +In **Tools** you saw a bad argument get rejected before your function ran. That was `MCPServer` validating the call against the schema it generated. + +`Server` does not do that. Your `input_schema` is *advertised* to the client; it is never *applied* to `params.arguments`. + +!!! check + Call `search_books` without `limit` and your `args["limit"]` raises `KeyError`. The client sees: + + ```text + MCPError: Internal server error + ``` + + A JSON-RPC error, code `-32603`, with a deliberately generic message: the SDK won't leak your traceback to a remote caller. The model never finds out what it did wrong, so it can't retry. (In a test, `raise_exceptions=True` surfaces the real exception instead; see **Testing**.) + +That generalises. An exception raised from a low-level handler is **always** a protocol error, never an `is_error=True` tool result. If you want the model to read the failure and recover, validate `params.arguments` yourself and return `CallToolResult(content=[TextContent(...)], is_error=True)`. The two kinds of failure are the subject of **Handling errors**. + +## Two tools, one handler + +`on_call_tool` is the single entry point for every tool on the server. You route on `params.name`: + +```python title="server.py" hl_lines="39-44" +--8<-- "docs_src/lowlevel/tutorial002.py" +``` + +* `list_tools` advertises both. `call_tool` dispatches on the name. +* The `else` branch matters: `Server` will happily forward a `tools/call` for a name you never listed straight into your handler. Raising there turns the call into the same `-32603` as above. + +## Structured output, by hand + +Declare `output_schema` on the `Tool` and put `structured_content` on the result. Both are yours: + +```python title="server.py" hl_lines="20-24 37" +--8<-- "docs_src/lowlevel/tutorial003.py" +``` + +Call it and the result carries both representations: + +```json +{ + "content": [{"type": "text", "text": "Found 3 books matching 'dune'."}], + "structuredContent": {"matches": 3, "query": "dune"}, + "isError": false, + "resultType": "complete" +} +``` + +The server never compares the two fields. This SDK's `Client` does: return `structured_content` that doesn't satisfy the `output_schema` you declared and `call_tool` raises a `RuntimeError` that starts with `Invalid structured content returned by tool search_books` and goes on to quote the `jsonschema` failure. Promising a schema is cheap; keeping it is on you. The whole ladder of return types and schemas is in **Structured Output**. + +## `_meta`: for the application, not the model + +`content` is the part of the answer the model reads. `structured_content` is the same answer as typed data. `_meta` is the third channel: data that rides along with the result for the **client application**, without being part of the answer at all. + +Use it for record IDs, trace IDs, anything your UI needs and your prompt doesn't: + +```python title="server.py" hl_lines="38" +--8<-- "docs_src/lowlevel/tutorial004.py" +``` + +* You construct it as `_meta=`, the wire name. The client reads it back as `result.meta`. +* Namespace your keys (`bookshop/record_ids`). The `io.modelcontextprotocol/*` keys are reserved by the protocol. + +!!! warning + `_meta` is a convention between you and the client application, not a guarantee about what reaches + the model. The host decides what it renders. Never put a secret in any part of a tool result. + +## Capabilities follow your handlers + +A `Server` advertises exactly the method families you gave it handlers for. The `Bookshop` above passes `on_list_tools` and `on_call_tool` and nothing else, so a client connecting to it sees: + +```json +{"tools": {"listChanged": false}} +``` + +No `resources`, no `prompts`: there is nothing to back them. Pass `on_list_prompts` and `prompts` appears; pass `on_completion` and `completions` appears. + +`MCPServer` always advertises tools, resources and prompts, whether you registered any or not, because its managers always exist. Down here the declaration *is* the constructor call. + +## The lifespan generic + +`Server` is generic in the type its lifespan yields. Annotate it once and the object is typed everywhere it surfaces: + +```python title="server.py" hl_lines="25-27 45-46 51" +--8<-- "docs_src/lowlevel/tutorial005.py" +``` + +* The lifespan is a `Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]]`; `@asynccontextmanager` on an `async` generator gives you exactly that. +* Whatever it `yield`s becomes `ctx.lifespan_context`, and because the handlers are annotated `ServerRequestContext[Catalog]`, `.search(...)` autocompletes and type-checks. +* It is entered once when the server starts and exited once when it stops. Startup, teardown, and `MCPServer`'s version of the same idea are in **Lifespan**. + +Without a `lifespan=`, `ctx.lifespan_context` is an empty `dict`. + +## A method of your own + +The constructor covers the methods MCP defines. `add_request_handler` covers everything else: + +```python title="server.py" hl_lines="35-36 39-40 43-44 48" +--8<-- "docs_src/lowlevel/tutorial006.py" +``` + +* The first argument is the method string. Notifications have a twin, `add_notification_handler`. +* `params_type` is the model the incoming `params` are validated against **before** your handler runs, so custom methods *do* get the validation tools don't. Subclass `RequestParams` so the `_meta` field parses like every other method's. +* The handler returns a `BaseModel`, a `dict`, or `None`. The SDK serialises it into the JSON-RPC result. + +One honest caveat: the high-level `Client` only has verbs for the methods MCP defines, so there is no `client.reindex()`. A vendor method is for a peer that already knows it exists: a client you also ship, or another service of yours speaking JSON-RPC. + +One method you cannot claim: + +```text +ValueError: 'initialize' is handled by the server runner and cannot be overridden; +use Server.middleware to observe or wrap initialization +``` + +The handshake belongs to the runner. `server/discover`, `ping`, and every other built-in are yours to replace. + +!!! tip + `Server.middleware`, mentioned in that error, wraps **every** inbound message, including `initialize`. If what you want is to observe or rewrite traffic rather than answer a new method, start at **Middleware**. + +## The other handlers + +Each of these is one idea you now have the vocabulary for; each has its own chapter. + +* `on_call_tool` may return an `InputRequiredResult` instead of a `CallToolResult` to pause the call and ask the client for input; see **Multi-round-trip requests**. +* `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` are the same `(ctx, params) -> result` shape for the other primitives. +* `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **Running your server** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story. + +## Recap + +* The low-level `Server` takes its handlers as `on_*` **constructor parameters**; every handler is `async (ctx, params) -> result`. +* You write the `input_schema` dict and you build the `CallToolResult`. Nothing is derived, wrapped, or validated for you. +* An exception in a handler is a `-32603` protocol error. A tool error the model can read is a `CallToolResult` with `is_error=True` that **you** return. +* `_meta` on the result is addressed to the client application, not the model. +* `Server[T]` is generic in what its lifespan yields; `ctx.lifespan_context` is a typed `T`. +* `add_request_handler(method, params_type, handler)` serves any method. `initialize` is reserved. +* The capabilities a `Server` advertises are derived from which handlers you registered. + +`Client(server)` treated both servers identically because they *are* the same protocol, which is the whole point. The next layer down isn't a class at all: it's **Middleware**. diff --git a/docs/advanced/middleware.md b/docs/advanced/middleware.md new file mode 100644 index 0000000000..6fdf9d4a19 --- /dev/null +++ b/docs/advanced/middleware.md @@ -0,0 +1,130 @@ +# Middleware + +A **middleware** is one async function that wraps every message your server receives. + +You write it as `async (ctx, call_next)` and append it to `server.middleware`. That is the whole API. + +!!! warning + `Server.middleware` is marked **provisional** in the source. The signature and semantics are + expected to change before v2 is final. Use it to *observe*: timing, logging, tracing. + Do not make it the foundation your server stands on. + +This is a **low-level `Server`** feature. `MCPServer` does not expose a middleware list. +If `Server(name, on_call_tool=...)` is new to you, read **The low-level Server** first. + +## A timing middleware + +One server, one tool, one middleware that logs how long each message took: + +```python title="server.py" hl_lines="40-46 50" +--8<-- "docs_src/middleware/tutorial001.py" +``` + +* `ctx` is the same `ServerRequestContext` your handlers receive. `ctx.method` is the raw + method string; `ctx.params` are the raw params, **before** any validation. +* `call_next(ctx)` runs the rest of the chain: validation, the handler lookup, your handler. + Return what it returned and the response is untouched. +* The `try`/`finally` is deliberate: a handler that raises is still timed, because the failure + reaches your middleware as the exception out of `call_next`. +* `server.middleware.append(...)` registers it. The list runs outermost-first, so + `middleware[0]` is the one closest to the wire. + +### Try it + +Connect a client, list the tools, call one. Your log has **three** lines: + +```text +server/discover took 18.3 ms +tools/list took 0.1 ms +tools/call took 0.1 ms +``` + +You made two calls and got three lines. The first is `server/discover`: the request the +client sent to set the connection up, before you asked for anything. + +That is the point. Middleware wraps **every** inbound message: + +* The connection setup: `server/discover`, or `initialize` and `notifications/initialized` + on a legacy session. +* Every request and every notification. For a notification, `ctx.request_id is None`, + `call_next(ctx)` returns `None`, and whatever you return is discarded. +* Even a method the server has no handler for: `call_next` raises the + `MCPError(-32601, "Method not found")` *through* your middleware on its way to the client. + +## What you can do inside one + +In increasing order of how much you should hesitate: + +* **Observe.** Time it, count it, log it. The example above. +* **Refuse.** Raise an `MCPError` *instead of* calling `call_next(ctx)` and that one message is + answered with a JSON-RPC error. The connection stays up; the next message goes through. +* **Rewrite.** `ctx` is a dataclass: `await call_next(dataclasses.replace(ctx, params=...))` + hands the rest of the chain different params than the client sent. Never do this to + `initialize`: the result the client gets back is built from your rewritten params, but the + server commits its connection state from the original wire params. The two sides can finish + the handshake disagreeing about what they negotiated. + +!!! check + `initialize` is one of the things middleware wraps, and it is the *only* hook you get + for it. Try to take it over with `add_request_handler` and the SDK refuses: + + ```text + ValueError: 'initialize' is handled by the server runner and cannot be overridden; + use Server.middleware to observe or wrap initialization + ``` + +!!! warning + `initialize` is handled inline: the server reads no further inbound messages until your + middleware chain returns. Awaiting a server-to-client request (`ctx.session.send_request(...)`, + an elicitation) while handling `initialize` therefore **deadlocks the connection**: the + response you are waiting for can never be read. Fire-and-forget notifications are fine. + +## `OpenTelemetryMiddleware` + +The SDK ships one middleware: `OpenTelemetryMiddleware`. Construct it and append it +(`server.middleware.append(OpenTelemetryMiddleware())`), exactly the line you already wrote +for `log_timing`. + +Every inbound message becomes a `SERVER` span named after the method and its target, so a +`tools/call` for `search_books` is the span `tools/call search_books`. + +* Every span carries `mcp.method.name` and `mcp.protocol.version`; a request's span also + carries its JSON-RPC request id (a notification has none). +* A `tools/call` span gets OpenTelemetry's GenAI semantic conventions, + `gen_ai.operation.name` (`"execute_tool"`) and `gen_ai.tool.name`, so a tracing UI groups + your tool calls the way it groups any other agent's. A `prompts/get` span gets + `gen_ai.prompt.name`. The list methods carry no `gen_ai.*` keys. +* A handler that raises sets the span's status to error. So does a tool result with + `is_error=True`. + +!!! tip + The SDK depends only on `opentelemetry-api`. With no exporter installed those spans are + no-ops, so appending this middleware costs you nothing. Install `opentelemetry-sdk` plus an + exporter and everything lights up, with no server change. + +The import is the catch. The class lives at `from mcp.server._otel import OpenTelemetryMiddleware` +today, and the leading underscore is not an accident: it is the same provisional flag this whole +page opened with. The SDK has not given it a public spelling yet, so the import path is the one +line here you should expect to change. + +!!! info + If you have written ASGI middleware, you already know this shape. Starlette's + `(scope, receive, send)` became `(ctx, call_next)`, and it runs *after* the transport, on + the decoded message instead of the raw HTTP request. The two compose: Starlette middleware + on `streamable_http_app()` sees HTTP; this sees MCP. + +## Recap + +* A middleware is `async (ctx, call_next) -> result`, appended to `server.middleware` on the + low-level `Server`. +* It wraps **every** inbound message (`server/discover`, `initialize`, requests, notifications, + unknown methods) and runs outermost-first. +* `ctx.request_id is None` is how you tell a notification from a request. +* Raise instead of calling `call_next` to refuse one message; the connection survives. +* `OpenTelemetryMiddleware` turns each message into a span (with GenAI attributes on tool + calls and prompt gets) for the price of one `append`, and costs nothing until you install + an exporter. +* The whole surface is provisional. Observe with it; don't build on it. + +That is everything that wraps a request. **Authorization** is what decides whether the request +gets to run at all. diff --git a/docs/advanced/multi-round-trip.md b/docs/advanced/multi-round-trip.md new file mode 100644 index 0000000000..2b02cabffc --- /dev/null +++ b/docs/advanced/multi-round-trip.md @@ -0,0 +1,96 @@ +# Multi-round-trip requests + +Sometimes a tool can't finish in one round trip. It needs something only the user has: a choice, a confirmation, a credential. + +Before 2026-07-28 the server got it by calling **back**: opening its own request to the client (an elicitation, a sampling call) in the middle of handling the original one. The 2026-07-28 spec retires that back-channel. + +Instead, the server **returns**. + +## Return, don't call back + +The server answers `tools/call` with an **`InputRequiredResult`** instead of a `CallToolResult`. Two of its fields do the work: + +* **`input_requests`**: what the server still needs, as a dict keyed by names the server chose. Each value is an `ElicitRequest`, a `CreateMessageRequest`, or a `ListRootsRequest`. +* **`request_state`**: an opaque token. The client echoes it back verbatim on the retry. Your server is the only thing that reads it. + +The client fulfils each request, then calls the **same tool again**, carrying its answers in `input_responses` and the token in `request_state`. The server now has what it was missing and returns a normal `CallToolResult`. + +That's the whole protocol. Every leg is an ordinary request from the client to the server. Nothing ever flows the other way. + +## The server side + +The high-level `@mcp.tool()` decorator has no sugar for this yet. Today you write it on the **low-level** `Server`, whose `on_call_tool` handler is allowed to return either result type: + +```python title="server.py" hl_lines="44-47" +--8<-- "docs_src/mrtr/tutorial001.py" +``` + +* `on_call_tool` is typed `-> CallToolResult | InputRequiredResult`. Returning the second one is the entire server-side API. +* On the first call `params.input_responses` is `None`, so the guard fires and the handler asks instead of answering. +* On the retry, the `ElicitResult` the client sent is sitting under the **same key** (`"region"`) that the server used in `input_requests`. + +Everything else in that file (the explicit `input_schema`, the hand-built `CallToolResult`) is the ordinary low-level `Server`, covered in **The low-level Server**. This page only adds the second return type. + +## The client side + +`call_tool` will not hand you an `InputRequiredResult` unless you opt in. + +!!! check + Call a tool that needs input without opting in and `call_tool` raises: + + ```text + Server returned InputRequiredResult; pass allow_input_required=True to receive it and retry call_tool(..., input_responses=..., request_state=result.request_state). + ``` + + That is deliberate. Most call sites expect a result or an exception, not a third thing in the + middle of the happy path, and pyright agrees: without the flag, `call_tool` is typed to return + a plain `CallToolResult`. + +Pass `allow_input_required=True` and the result reaches you intact: + +```python +result.result_type # 'input_required' +result.request_state # 'provision-v1' +result.input_requests # {'region': ElicitRequest(method='elicitation/create', params=ElicitRequestFormParams(...))} +``` + +### The retry loop + +Now you own the loop. There is no automatic driver yet; `while isinstance(result, InputRequiredResult)` **is** the API: + +```python title="client.py" hl_lines="13-15 17-20" +--8<-- "docs_src/mrtr/tutorial002.py" +``` + +* `allow_input_required=True` widens the return type to `CallToolResult | InputRequiredResult`. That union is exactly what the `isinstance` is narrowing. +* For every entry in `input_requests` you put an `InputResponse` under the **same key** in `input_responses`. `fulfil` is where your UI goes; this one hard-codes the answer. +* Same tool name, same `arguments`, every leg. The retry is the original call carried out again, not a new method. +* `request_state=result.request_state`: copy it across. Never inspect it, never invent it. +* When the server has everything it needs it returns a `CallToolResult` and the loop exits. + +## A 2026-07-28 result + +`InputRequiredResult` only exists at protocol version **2026-07-28**. The in-memory `Client(server)` negotiates it for you; over the wire, `mode="auto"` discovers it. After connecting, `client.protocol_version` tells you what you got. + +!!! warning + A pre-2026 session has nowhere to put an `InputRequiredResult`. Return one from your handler on a + `mode="legacy"` connection and the runner cannot serialize it into the negotiated version; the + client gets back a `-32603` *"Handler returned an invalid result"* error. A server that serves + both eras must check `ctx.protocol_version` before reaching for it. + +!!! info + **URL-mode elicitation** rides this exact mechanism on a 2026 connection. The entry in + `input_requests` is an `ElicitRequest` whose params are `ElicitRequestURLParams`; the user + finishes the out-of-band flow and your client retries the call. Same loop, no new API. The + high-level server half is in **Elicitation**. + +## Recap + +* At 2026-07-28 a server that needs input mid-call **returns** an `InputRequiredResult`. It never opens a request to the client. +* `input_requests` is what it needs. `request_state` is an opaque resume token only the server reads. +* The client answers by calling the **same tool again** with `input_responses=` and `request_state=`. +* By default `call_tool` raises on an `InputRequiredResult`; `allow_input_required=True` opts in and widens the return type. +* The manual `while isinstance(result, InputRequiredResult)` loop is the whole client API; there is no auto-retry driver yet. +* The server side is the **low-level** `Server` only; `@mcp.tool()` has no sugar for this yet. + +This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **Deprecated features**. diff --git a/docs/advanced/oauth-clients.md b/docs/advanced/oauth-clients.md new file mode 100644 index 0000000000..5acbd92a7b --- /dev/null +++ b/docs/advanced/oauth-clients.md @@ -0,0 +1,137 @@ +# OAuth clients + +Some MCP servers are protected. Send them a request without a token and they answer `401 Unauthorized`. + +**`OAuthClientProvider`** is how you get the token. It is not an MCP object at all. It is an `httpx.Auth`, the standard httpx hook for "do something to every request". You attach it to an `httpx.AsyncClient`, hand that client to the Streamable HTTP transport, and stop thinking about it. + +This chapter is the client side. Making your own server demand a token is **Authorization**. + +## The provider + +```python title="client.py" hl_lines="44-54" +--8<-- "docs_src/oauth_clients/tutorial001.py" +``` + +You give it four things: + +* `server_url`: the MCP endpoint you are connecting to. The provider discovers everything else from it. +* `client_metadata`: what you would type into an authorization server's "register an application" form. +* `storage`: where tokens live between runs. +* `redirect_handler` and `callback_handler`: the two moments a human is involved. + +Nothing else in the file mentions OAuth. `main()` never sees a token. + +### Client metadata + +`OAuthClientMetadata` is the real RFC 7591 registration document, as a Pydantic model. + +You set three fields. The defaults fill in the rest: `grant_types` is already `["authorization_code", "refresh_token"]` and `response_types` is already `["code"]`, which is exactly the flow this provider runs. + +!!! check + Because it is a Pydantic model, it validates **before a single byte goes over the network**. + Leave out `redirect_uris` and construction fails on the spot with a `ValidationError` that + names the field: + + ```text + redirect_uris + Field required [type=missing, input_value={'client_name': 'Bookshop Agent'}, input_type=dict] + ``` + + No browser opened, no half-finished registration left behind on the authorization server. + +### Token storage + +**`TokenStorage`** is a `Protocol` with four async methods. You don't inherit from anything; write the methods and any class is a token store: + +* `get_tokens` / `set_tokens` hold the `OAuthToken`: access token, refresh token, expiry, scope. +* `get_client_info` / `set_client_info` hold the `OAuthClientInformationFull` the authorization server issued when the provider registered you, including your `client_id`. + +The in-memory version above works. It also forgets everything when the process exits, so the next run does the whole dance again. Persist it to a file or your platform's keyring and the next run is silent. + +!!! tip + Store `client_info`, not only the tokens. The provider registers dynamically the first time it + finds no stored `client_info`. Throw it away and you mint a fresh registration on every run. + +### The two handlers + +The authorization code flow needs a human exactly once: someone has to sign in and click "allow". + +* **`redirect_handler`** is awaited with the fully-built authorization URL. The `client_id`, the `redirect_uri`, the `state` and the PKCE challenge are already in it. Your only job is to get a browser there. A desktop app calls `webbrowser.open`; this file prints it. +* **`callback_handler`** is awaited next. It waits until the user lands back on your `redirect_uri` and returns that redirect's query parameters as an `AuthorizationCodeResult`. + +A real client runs a small local HTTP server on the redirect URI instead of calling `input()`. The shape is identical: get redirected, hand back `code`, `state`, and `iss`. + +!!! warning + Pass `state` and `iss` through exactly as they arrived. The provider compares `state` to the one + it generated and `iss` to the issuer it discovered, and refuses a mismatch. They are the CSRF + and server-mix-up defences. + +### Into the `Client` + +Look at `main()`. The provider goes on the **httpx client**, the httpx client goes into `streamable_http_client(url, http_client=...)`, and that transport goes into `Client`. + +`streamable_http_client` has no `auth=` keyword. Anything HTTP-level (auth, headers, timeouts, proxies) belongs on the `httpx.AsyncClient` you bring. That layering is **Client transports**. + +## What the provider does for you + +The first time `Client` sends a request, the server answers `401`. The provider takes over: + +1. **Discovery.** It reads the `WWW-Authenticate` header, fetches the server's Protected Resource Metadata from `/.well-known/oauth-protected-resource`, learns which authorization server protects this resource, and fetches *that* server's metadata. +2. **Registration.** Nothing in storage? It registers you dynamically with your `OAuthClientMetadata` and stores the result. +3. **Authorization.** It generates the PKCE pair and a `state`, builds the authorization URL, awaits your `redirect_handler`, then awaits your `callback_handler` for the code. +4. **Exchange.** It trades the code for an `OAuthToken`, stores it, and replays your original request with `Authorization: Bearer ...`. + +After that it is quiet. Tokens come out of storage, an expired access token is refreshed with the refresh token, and only when none of that works does it run the flow again. + +You wrote none of it. Three keyword arguments remain (`timeout`, `client_metadata_url` and `validate_resource_url`), and this file needs none of them. + +### Try it + +Everything else in these docs you have checked with an in-memory `Client(server)`. Not this: the whole point of the flow is an HTTP `401`, and there is no HTTP between an in-memory client and its server. + +The repository ships the live version. `examples/servers/simple-auth/` runs a standalone authorization server and a protected MCP server; `examples/clients/simple-auth-client/` is this chapter's client grown into a small CLI. Its README has the two commands: start the servers, run the client against them, and you watch the four steps go by. + +## Machine to machine + +A nightly job, a CI step, another service. There is no browser and nobody to click "allow". That is the **client credentials** grant: you already hold a `client_id` and a `client_secret`, and the token endpoint is the whole flow. + +`ClientCredentialsOAuthProvider` is the same `httpx.Auth`, minus the human: + +```python title="client.py" hl_lines="4 27-33" +--8<-- "docs_src/oauth_clients/tutorial002.py" +``` + +What changed: + +* No `OAuthClientMetadata`, no handlers. You pass `client_id` and `client_secret`; the provider builds a minimal `client_credentials` registration around them and skips dynamic registration entirely. +* `scopes` is a space-separated string, the OAuth wire format. +* Everything downstream is identical: the same `TokenStorage`, the same `httpx.AsyncClient(auth=...)`, the same `streamable_http_client`. + +By default the secret travels as HTTP Basic auth on the token request (`client_secret_basic`). Pass `token_endpoint_auth_method="client_secret_post"` to put it in the form body instead. Some authorization servers only accept one of the two. + +!!! tip + Read `client_secret` from the environment or a secret manager, never from source control. + +!!! info + One more provider lives in `mcp.client.auth.extensions.client_credentials`: + **`PrivateKeyJWTOAuthProvider`**, for clients that authenticate with a JWT instead of a + shared secret (`private_key_jwt`, the key-pair and workload-identity flavour). It follows + the same pattern: construct one, put it on `auth=`. The same module ships + `SignedJWTParameters` and `static_assertion_provider`, two helpers that build its assertion. + +## When it fails + +When the OAuth flow goes wrong, the provider raises an `OAuthFlowError` from `mcp.client.auth`. It has two subclasses. `OAuthRegistrationError` means the authorization server refused to register you. `OAuthTokenError` means the token endpoint said no. One `except OAuthFlowError:` covers discovery, registration, authorization, and exchange. + +Not everything is a flow error. The network can still fail; those are ordinary `httpx` exceptions and pass through untouched. + +## Recap + +* `OAuthClientProvider` is an `httpx.Auth`. Put it on an `httpx.AsyncClient`, pass that to `streamable_http_client(url, http_client=...)`, and `Client` never knows OAuth happened. +* You supply four things: the server URL, an `OAuthClientMetadata`, a `TokenStorage`, and the redirect/callback handler pair. +* `TokenStorage` is a `Protocol`: four async methods, no base class. Persist `client_info` as well as the tokens. +* Discovery, dynamic registration, PKCE, the `state` and `iss` checks, and token refresh are the provider's job, not yours. +* `ClientCredentialsOAuthProvider` is the no-human version: `client_id` + `client_secret`, no handlers, no browser. +* Every OAuth failure is an `OAuthFlowError`; `OAuthRegistrationError` and `OAuthTokenError` are its subclasses. + +The other half of this handshake, making your *server* demand the token, is **Authorization**. diff --git a/docs/advanced/pagination.md b/docs/advanced/pagination.md new file mode 100644 index 0000000000..ef33fa0c32 --- /dev/null +++ b/docs/advanced/pagination.md @@ -0,0 +1,80 @@ +# Pagination + +Most servers never need this. + +`MCPServer` answers every `list_*` request with everything it has, in one page, `next_cursor=None`. For a few dozen tools, resources or prompts that is the right answer and there is nothing to configure. + +Pagination is for the server whose resource list is really a database: thousands of rows it refuses to serialize in one response. The protocol's answer is a **cursor**: the server returns a page plus an opaque token, and the client sends that token back to get the next page. + +`@mcp.resource()` has no hook for any of that. To page, you write the list handler yourself, on the **low-level Server**. + +## A server that pages + +```python title="server.py" hl_lines="13 16-17" +--8<-- "docs_src/pagination/tutorial001.py" +``` + +* On a low-level `Server`, handlers are constructor arguments, not decorators. `on_list_resources` answers every `resources/list` request; that's the whole hookup. +* Every paged handler is typed `params: PaginatedRequestParams | None`, and the example accepts both. Over a connection, though, the SDK never hands you `None` (a request with no `params` member reaches the handler as the model with its defaults), so the signal that matters is `params.cursor is None`: **start from the top**. +* You decide what a cursor *is*. Here it's an offset rendered as a string. A timestamp, a primary key, a base64 blob: anything you can mint on the way out and recognise on the way back in. +* `next_cursor=None` is how you say "that was the last page". There is no count, no total, no `has_more`. `None` is the entire signal. + +!!! tip + A `PAGE_SIZE` of 10 makes the example readable. Pick yours per endpoint: a list of + one-line resources can afford a page of 500; a list of fat prompt templates cannot. + The client has no say in it, and that is by design. + +### Try it + +`Client(server)` connects to a low-level `Server` in memory exactly as it connects to an `MCPServer`. + +Call `list_resources()` with no arguments. You get ten resources, `book-1` through `book-10`, and `next_cursor` is the string `"10"`. + +Hand it back with `list_resources(cursor="10")` and the first resource is `book-11`, the new `next_cursor` is `"20"`. + +The tenth page comes back with `next_cursor` set to `None`. Done. + +## The client loop + +Every `list_*` method on `Client` (`list_tools`, `list_resources`, `list_resource_templates`, `list_prompts`) takes a `cursor=` keyword. Draining a paged list is one `while True`: + +```python title="client.py" hl_lines="27-33" +--8<-- "docs_src/pagination/tutorial002.py" +``` + +* `cursor` starts as `None`, so the first request carries no cursor. +* Extend **before** you look at `next_cursor`: the last page has resources too. +* `next_cursor is None` is the exit. Anything else goes straight back into `cursor=`, untouched. + +Run its `main()` and it prints `100 resources`: ten pages of ten, stitched together by a loop that never knew there were ten pages. + +This is the same loop **The Client** chapter showed you, and it costs nothing against a server that doesn't page: `next_cursor` is `None` on the first response and the loop runs once. + +## The three rules + +**Cursors are opaque.** A client must never parse, build, or guess one. The only legal source of a cursor is the previous page's `next_cursor`, verbatim. + +**The server picks the page size.** There is no `limit=` in the protocol. If you need a different page size, you change the server. + +**A client that ignores paging still works.** It calls `list_resources()` once, gets the first ten, and never notices the `next_cursor` it threw away. Nothing breaks; it sees less. + +!!! check + Opaque means opaque. Invent a cursor (`list_resources(cursor="page-2")`) and there is + nothing the protocol can do for you. This server tries `int("page-2")`, the handler raises, + and what comes back to the client is: + + ```text + MCPError(-32603, 'Internal server error', None) + ``` + + A cursor you didn't get from the server is a bug, not a feature request. + +## Recap + +* `MCPServer` returns everything in one page. Pagination is opt-in, and you opt in on the low-level `Server`. +* `on_list_resources` (and `on_list_tools`, `on_list_prompts`, `on_list_resource_templates`) receives `PaginatedRequestParams | None`; `params.cursor` is `None` for the first page. +* You return a page plus `next_cursor`: any string you'll recognise later, or `None` when there is nothing left. +* The client loop: pass `cursor=`, accumulate, repeat until `next_cursor is None`. +* Cursors are opaque, the server owns the page size, and a non-paging client still gets page one. + +The rest of the hand-written `Server` API (`on_call_tool`, `input_schema` dicts, `_meta`) is **The low-level Server**. diff --git a/docs/advanced/session-groups.md b/docs/advanced/session-groups.md new file mode 100644 index 0000000000..e33004c47d --- /dev/null +++ b/docs/advanced/session-groups.md @@ -0,0 +1,82 @@ +# Session groups + +A `Client` connects to one server. Real applications often want several (a search server, a database server, an internal API) and end up juggling a connection and a tool list for each. + +**`ClientSessionGroup`** is one object that holds many connections and merges everything they expose into a single view. + +## Two servers + +Start with two ordinary servers. They have nothing to do with each other, so both naturally called their tool `search`: + +```python title="library_server.py" hl_lines="7" +--8<-- "docs_src/session_groups/tutorial001.py" +``` + +```python title="web_server.py" hl_lines="7" +--8<-- "docs_src/session_groups/tutorial002.py" +``` + +## One group + +Create a `ClientSessionGroup` and call **`connect_to_server`** once per server: + +```python title="client.py" hl_lines="10-12" +--8<-- "docs_src/session_groups/tutorial003.py" +``` + +* `connect_to_server` takes transport parameters, not a server object: `StdioServerParameters` (from `mcp`) to launch a subprocess, or `StreamableHttpParameters` / `SseServerParameters` (from `mcp.client.session_group`) for a server already listening on a URL. +* `group.tools` is a `dict[str, Tool]` of every connected server's tools. `group.resources` and `group.prompts` are the same shape. +* `group.call_tool(name, arguments)` looks the name up, finds the session that owns it, and forwards the call. You never say which server. + +!!! check + Put `client.py` next to the two servers and run it. The second `connect_to_server` refuses: + + ```text + mcp.shared.exceptions.MCPError: {'search'} already exist in group tools. + ``` + + That is an `MCPError`, raised before anything from the second server is registered. A name must + be unique across the **whole** group, and two servers you don't control will collide eventually. + +## `component_name_hook` + +You fix this at the group, not at the servers. Pass a function of `(name, server_info)` and the group runs it on every name it registers: + +```python title="client.py" hl_lines="8-9 16" +--8<-- "docs_src/session_groups/tutorial004.py" +``` + +Run it again. `print(sorted(group.tools))` now shows both: + +```text +['Library.search', 'Web.search'] +``` + +* The **key** is yours. `by_server` built it from `server_info.name`, the name each `MCPServer(...)` was constructed with. +* The `Tool` inside is untouched: `group.tools["Web.search"].name` is still `"search"`, and that is the name `call_tool` puts on the wire. The prefix never leaves your process. +* It is not only tools. The library's `hours` resource is registered as `Library.hours`. + +!!! tip + The hook runs on **every** name from **every** server, not only on conflicts: there is no + prefix-on-collision mode. Pick one scheme and let it apply everywhere. + +## Adding and removing servers + +`connect_to_server` returns the `ClientSession` it opened. Keep it if you ever want that server gone: `await group.disconnect_from_server(session)` removes its tools, resources, and prompts from the group. + +If you already hold a connected `ClientSession` (`Client.session` is one), hand it to `await group.connect_with_session(server_info, session)` instead of opening a new transport. It aggregates the same way. The group never closes a session it didn't open. + +## The classic handshake + +`ClientSessionGroup` is built on `ClientSession`, not on `Client`. Each `connect_to_server` runs the classic `initialize` handshake. It never sends the `server/discover` probe described in **Protocol versions**. Every MCP server understands that handshake, so this costs you compatibility with nothing; it only means a group takes the older, slower path to a server that could do better. + +## Recap + +* `ClientSessionGroup` holds many server connections and merges their tools, resources, and prompts into one `dict` each. +* `connect_to_server(params)` per server. It takes transport parameters, never the server object or URL a `Client` takes. +* `group.call_tool(name, arguments)` routes to the owning server for you. +* Names must be unique across the whole group; two servers with a `search` tool cannot coexist on their own. +* `component_name_hook=` rewrites every registered name. The dict key changes, the wire name does not. +* `connect_with_session` adds a session you already hold; `disconnect_from_server` removes one. + +The handshake a group speaks (and the faster one a `Client` prefers) is the subject of **Protocol versions**. diff --git a/docs/authorization.md b/docs/authorization.md deleted file mode 100644 index 4b6208bdfc..0000000000 --- a/docs/authorization.md +++ /dev/null @@ -1,5 +0,0 @@ -# Authorization - -!!! warning "Under Construction" - - This page is currently being written. Check back soon for complete documentation. diff --git a/docs/client/callbacks.md b/docs/client/callbacks.md new file mode 100644 index 0000000000..3c2ca5f096 --- /dev/null +++ b/docs/client/callbacks.md @@ -0,0 +1,143 @@ +# Client callbacks + +So far every request has gone one way: client to server. + +A server can also ask the **client** for things: to put a question to the user, to sample the user's model, to list the user's workspace folders. You answer those requests by passing **callbacks** to `Client(...)`. + +## A server that asks + +Here is a server whose tool can't finish on its own: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/client_callbacks/tutorial001.py" +``` + +* `ctx.elicit(...)` sends an `elicitation/create` request **to the client** and waits. +* The tool doesn't return until somebody (a person in a form, or your code) supplies a `name`. + +That is the server half, and the **Elicitation** chapter owns it. This chapter is the other end of the wire. + +## The elicitation callback + +```python title="client.py" hl_lines="7-11 17-18" +--8<-- "docs_src/client_callbacks/tutorial002.py" +``` + +* An elicitation callback is `async (context, params) -> ElicitResult`. +* `params.message` is the question. `params.requested_schema` is the JSON Schema of the answer the server wants. A real client renders a form from it; this one auto-fills. +* You return `ElicitResult(action="accept", content={...})`, or `action="decline"`, or `action="cancel"`. The only other option is `ErrorData(...)`, which refuses the request and fails the whole call. +* `context` is a `ClientRequestContext`: the live `session`, the server's `request_id`, and any `meta` it attached. + +!!! tip + `params` is a union of the two elicitation modes. Here `params.mode` is `"form"`; a `"url"` request + carries `params.url` instead of a schema. One callback handles both; branch on `params.mode`. + **Elicitation** shows the full pattern. + +### Try it + +Call `issue_card` and watch both ends. + +Your callback receives the server's question, already parsed: + +```python +params.mode # 'form' +params.message # 'What name should go on the card?' +params.requested_schema # {'properties': {'name': {'title': 'Name', 'type': 'string'}}, + # 'required': ['name'], 'title': 'CardHolder', 'type': 'object'} +``` + +It answers, `ctx.elicit(...)` resumes inside the tool, and the tool finishes: + +```python +result.content # [TextContent(type='text', text='Card issued to Ada Lovelace.')] +``` + +One `tools/call` from you, one `elicitation/create` back from the server, answered by your function, all inside a single tool call. + +!!! info + `mode="legacy"` on line 17 is doing real work. By default `Client(...)` negotiates the modern + protocol path, and that path has no back-channel for server-to-client requests: `ctx.elicit` + fails before your callback ever runs. The transport doesn't decide that; the negotiated + protocol does, in-memory and over a URL alike. Pin `mode="legacy"` whenever your client has + to answer one; every test behind this page does. **Protocol versions** has the whole story. + +## A callback is a capability + +You never told the server that your client can answer elicitation requests. The SDK did. + +When a client connects it declares its `capabilities`, the mirror image of the server's. You don't write that object. **Registering a callback is the declaration.** + +| you pass | the client declares | +| --- | --- | +| `elicitation_callback=` | `"elicitation": {"form": {}, "url": {}}` | +| `sampling_callback=` | `"sampling": {}` | +| `list_roots_callback=` | `"roots": {"listChanged": true}` | +| none of them | `{}` | + +`logging_callback` and `message_handler` are not in the table. They handle notifications, and notifications need no capability. + +The server reads the declaration back with `ctx.session.check_client_capability(...)`. Add a tool that does: + +```python title="server.py" hl_lines="23-31" +--8<-- "docs_src/client_callbacks/tutorial003.py" +``` + +Connect with only `elicitation_callback` and call it: + +```python +result.structured_content # {'result': ['elicitation']} +``` + +Pass all three callbacks and you get `['elicitation', 'sampling', 'roots']`. Pass none and you get `[]`. + +!!! check + Now do the wrong thing: connect **without** `elicitation_callback` and call `issue_card` anyway. + + The server's `elicitation/create` request still reaches your client, and the SDK answers it for + you, with an error, because you never said you could handle it. That error sinks the whole call. + `call_tool` doesn't return an `is_error` result; it raises: + + ```text + MCPError: Elicitation not supported + ``` + + That is a protocol error (`-32600`, *invalid request*), not a tool error: there is nothing for + the model to read and retry. It's why `client_features` is worth having: a well-behaved server + checks before it asks. + +## The deprecated pair + +`sampling_callback` answers `sampling/createMessage`: the server asking *your* model to complete something. `list_roots_callback` answers `roots/list`: the server asking which directories it may work in. + +Both work. Both follow the rule above. And both serve features the **2026-07-28 spec deprecates**: a modern server doesn't call back into your model mid-request, it hands the request back to you as part of the tool result (**Multi-round-trip requests**), and roots give way to plain tool arguments and resource URIs. The whole list is in **Deprecated features**. + +You still need the callbacks to talk to servers that haven't moved. The signatures: + +```python title="client.py" +--8<-- "docs_src/client_callbacks/tutorial004.py" +``` + +* A sampling callback receives the full `CreateMessageRequestParams` (`messages`, `model_preferences`, `max_tokens`) and returns a `CreateMessageResult`. *You* run the model, however you like; the SDK only carries the request. +* A roots callback takes no params at all and returns a `ListRootsResult`. +* Either one may return `ErrorData(...)` instead, to refuse. + +Pass them to `Client(...)` exactly like `elicitation_callback`. + +## The notification callbacks + +Two more. Neither declares anything. + +`logging_callback` receives every `notifications/message` a server sends, as `LoggingMessageNotificationParams` (`level`, `logger`, `data`). Protocol logging is itself deprecated by the 2026-07-28 spec (**Logging** has what to do instead), so this callback exists for the servers that still emit it. + +`message_handler` is the catch-all: every server notification reaches it (as well as its specific callback), and on a stream-backed transport so does every transport-level `Exception`. The one pattern worth knowing is `if isinstance(message, Exception): raise message`, so a broken connection fails loudly instead of vanishing. + +## Recap + +* A server can send requests to the client. You answer them with callbacks passed to `Client(...)`. +* The elicitation callback is the current one: `async (context, params) -> ElicitResult`, one function for both form and URL mode. +* **Registering a callback is declaring the capability.** Without it, the SDK refuses the server's request on your behalf and the whole call fails with `MCPError`. +* A server finds out before asking with `ctx.session.check_client_capability(...)`. +* `sampling_callback` and `list_roots_callback` work the same way but serve deprecated features; modern servers use multi-round-trip requests instead. +* `logging_callback` and `message_handler` receive notifications. They declare nothing. + +Next: the first argument you've been passing to `Client(...)` all along, **Client transports**. diff --git a/docs/client/index.md b/docs/client/index.md new file mode 100644 index 0000000000..38efa72b69 --- /dev/null +++ b/docs/client/index.md @@ -0,0 +1,212 @@ +# The Client + +A **`Client`** is how a Python program talks to an MCP server. + +It is one object with one lifecycle: construct it, enter `async with`, call methods. Every protocol verb (list the tools, call one, read a resource, render a prompt) is an `async` method on it that returns a typed result. + +## Your first client + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +The server at the top is only there so you have something to connect to. The client is the five highlighted lines. + +* `Client(mcp)` is given the **server object itself**. That is the in-memory transport: no subprocess, no port, no HTTP. It is how every example in this chapter, and every test you write, connects. +* `async with` is the **lifecycle**. Entering it connects and negotiates; leaving it disconnects. There is no `connect()` / `close()` pair, and a `Client` cannot be reused after the block ends. +* Inside the block the connection facts are already there as plain properties. + +### What you can pass to `Client` + +`Client` takes one positional argument and resolves the transport from its type: + +* An `MCPServer` (or low-level `Server`) instance: connected **in-process**. +* A URL string (`Client("http://localhost:8000/mcp")`): Streamable HTTP, the production path. +* A **transport**: anything you can `async with ... as (read, write)`, such as `stdio_client(...)` wrapping a subprocess. + +Everything else on this page is identical across all three. Headers, subprocesses, timeouts, and the `Transport` protocol get their own chapter: **Client transports**. + +### What's on a connected client + +Four read-only properties, populated the moment you enter the block: + +* `client.server_info`: the server's identity. `server_info.name` here is `"Bookshop"`, `server_info.version` is whatever the server reports. +* `client.server_capabilities`: what the server can do (`tools`, `resources`, `prompts`, `completions`, ...). A capability the server doesn't have is `None`. +* `client.protocol_version`: the protocol version the two sides agreed on. Here it is `"2026-07-28"`. +* `client.instructions`: the server's `instructions=` string, or `None` if it didn't set one. + +You never picked a protocol version. By default the `Client` probes the server and falls back to the classic handshake on older ones, so one client works against any era of server. When you need to control that, **Protocol versions** has the whole story. + +!!! tip + `client.session` is the underlying `ClientSession`, the low-level escape hatch. + You won't need it for anything on this page. + +## Listing tools + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial002.py" +``` + +`list_tools()` returns a `ListToolsResult`; the tools are in `.tools`. Each one is the complete definition a host would hand to a model: + +```python +tool.name # 'search_books' +tool.title # 'Search the catalog' +tool.description # 'Search the catalog by title or author.' +``` + +and `tool.input_schema` is the JSON Schema the server derived from the function's type hints: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +That schema is everything a UI needs to render an argument form, and everything a model needs to produce valid arguments. + +!!! tip + `title` is optional, so a UI showing tools to a human has to pick: the `title` if there is one, + the `name` if not. `from mcp.shared.metadata_utils import get_display_name` does exactly that, + for tools, resources, resource templates and prompts. + +## Calling a tool + +`call_tool(name, arguments)` runs the tool and gives you back a `CallToolResult`. + +```python title="client.py" hl_lines="26-33" +--8<-- "docs_src/client/tutorial003.py" +``` + +The server's `lookup_book` returns a Pydantic `Book`. Here is what the client sees: + +```python +result.content # [TextContent(type='text', text='{\n "title": "Dune",\n "author": "Frank Herbert",\n "year": 1965\n}')] +result.structured_content # {'title': 'Dune', 'author': 'Frank Herbert', 'year': 1965} +result.is_error # False +``` + +One return value, three things to read. Each has a different consumer. + +### `content`: what the model reads + +`content` is a `list` of **content blocks**, and a content block is a union: `TextContent`, `ImageContent`, `AudioContent`, `ResourceLink`, or `EmbeddedResource`. A tool can return several, of different kinds. + +That is why `main` narrows with `isinstance(block, TextContent)` before touching `block.text`. Notice there is no `.text` outside the `isinstance`: the type checker won't allow it, because `ImageContent` has `.data`, not `.text`. The union is honest about what a tool is allowed to send you; your code should be too. + +### `structured_content`: what your application reads + +`structured_content` is the tool's return value as JSON, matching the tool's declared `output_schema`. No string parsing, no guessing. + +When both are present they say the same thing twice on purpose: `content` is for a model, `structured_content` is for code. Where the structured half comes from, and how to control it, is the **Structured Output** chapter. + +### `is_error`: whether the tool failed + +A tool that raises does **not** raise in your client. It comes back as an ordinary result with `is_error=True`. + +!!! check + Ask `lookup_book` for `"Solaris"` (a title that isn't in the catalog) and the function raises + `ValueError`. The call still returns normally: + + ```python + result.is_error # True + result.content # [TextContent(type='text', text="Error executing tool lookup_book: No book titled 'Solaris' in the catalog.")] + result.structured_content # None + ``` + + The exception's message landed in `content`, where the **model** can read it and try again. That + is deliberate: a tool error is part of the conversation, not a crash. Always look at `is_error` + before you trust `structured_content`. + +!!! warning + `is_error=True` covers more than your own `raise`. Ask for a tool the server doesn't even have + (`call_tool("does_not_exist", {})`) and nothing raises. You get the same shape back, + `is_error=True` with `Unknown tool: does_not_exist` in `content`. A `Client` method raises + `MCPError` only when the server answers with a JSON-RPC **error** instead of a result, and + **Handling errors** covers when a server produces which. + +## Resources + +The resource verbs come in pairs: two ways to list, one way to read. + +```python title="client.py" hl_lines="23-32" +--8<-- "docs_src/client/tutorial004.py" +``` + +* `list_resources()` returns the **concrete** resources, the ones with a fixed URI. Here: `['catalog://genres']`. +* `list_resource_templates()` returns the **parameterised** ones. Here: `['catalog://genres/{genre}']`. They are two different lists because a template isn't readable until you fill it in. +* `read_resource(uri)` takes a plain `str` URI and works on both: pass `"catalog://genres/poetry"` and the server matches it to the template. + +`read_resource` returns `contents`, a list of `TextResourceContents` or `BlobResourceContents`. Same idea as tool content: narrow with `isinstance`, then read `.text` (or `.blob`). + +A client can also **subscribe** to a resource and be told when it changes: `subscribe_resource(uri)` and `unsubscribe_resource(uri)`, same shape as everything else here. `MCPServer` doesn't implement that half. It says so up front (`server_capabilities.resources.subscribe` is `False`) and answers the request with an `MCPError`: `-32601`, *Method not found*. A server that does support subscriptions is built on the low-level `Server` (**The low-level Server**). + +## Prompts + +```python title="client.py" hl_lines="15-20" +--8<-- "docs_src/client/tutorial005.py" +``` + +`list_prompts()` tells you what the server offers and what each prompt needs: + +```python +prompt.name # 'recommend' +prompt.title # 'Recommend a book' +prompt.arguments # [PromptArgument(name='genre', required=True)] +``` + +`get_prompt(name, arguments)` renders it. The arguments dict is `str -> str`: prompt arguments are always strings. The result is `messages`, a list of `PromptMessage`, each with a `role` and a `content` block: + +```python +message.role # 'user' +message.content # TextContent(type='text', text='Recommend one poetry book from the catalog and say why.') +``` + +A host hands those messages straight to the model. That is the whole feature. + +## Completions + +A server with a completion handler can autocomplete prompt and resource-template arguments as the user types. + +```python title="client.py" hl_lines="28-32" +--8<-- "docs_src/client/tutorial006.py" +``` + +* `ref` says *which* prompt or template you're filling in: a `PromptReference` or a `ResourceTemplateReference`. +* `argument` is `{"name": ..., "value": ...}`: the argument and what the user has typed so far. + +The answer is in `result.completion.values`. Type `"p"` and the server comes back with `['poetry']`. The server side, and how a handler uses the *other* already-filled arguments to narrow its suggestions, is the **Completions** chapter. + +## Pagination + +Every `list_*` method takes a `cursor=` keyword and every result carries a `next_cursor`. When `next_cursor` is `None`, you have everything. + +```python title="client.py" hl_lines="23-31" +--8<-- "docs_src/client/tutorial007.py" +``` + +This loop is correct against every server. `MCPServer` returns everything in one page, so `next_cursor` is `None` and the loop runs once, which is why most code never writes it. Servers that genuinely page, and the rules cursors obey, are in **Pagination**. + +## In tests + +`Client(mcp)` with no process and no port is already a test harness for your server. + +There is one constructor flag built for that: `Client(mcp, raise_exceptions=True)`. It only has an effect on in-memory connections, and **Testing** is the chapter that explains it and builds the whole pattern around it. + +## Recap + +* `Client(x)` connects in-memory to a server object, over Streamable HTTP to a URL string, and over anything else via a transport. +* `async with` is the whole lifecycle. Inside it, `server_info`, `server_capabilities`, `protocol_version` and `instructions` are already populated. +* `list_tools()` gives you each tool's `name`, `title`, `description` and `input_schema`. +* `call_tool()` returns `content` for the model, `structured_content` for your code, and `is_error`. A raising tool is a result, not an exception. +* `content` is a union of block types; narrow with `isinstance` before reading. +* `list_resources` / `list_resource_templates` / `read_resource`, `list_prompts` / `get_prompt`, and `complete` round out the verbs. +* Every `list_*` takes `cursor=`; loop until `next_cursor` is `None`. + +Next: the things a server can ask the *client* for, and how you answer, in **Client callbacks**. diff --git a/docs/client/protocol-versions.md b/docs/client/protocol-versions.md new file mode 100644 index 0000000000..323cc9cd48 --- /dev/null +++ b/docs/client/protocol-versions.md @@ -0,0 +1,127 @@ +# Protocol versions + +MCP has two eras. + +Servers released before 2026-07-28 open every connection with the **`initialize` handshake**: the client proposes a version, the server counters, the client acknowledges, all before the first useful request. Servers at **2026-07-28** drop the handshake. The client sends one **`server/discover`** probe and the server answers it with everything in a single result. + +You haven't had to care, because `Client` negotiates for you. This chapter is about the one constructor argument that controls it, `mode=`, and the three times you change it. + +## `mode="auto"` + +```python title="client.py" hl_lines="14-15" +--8<-- "docs_src/protocol_versions/tutorial001.py" +``` + +You didn't pass `mode`, so you got the default: `"auto"`. Entering `async with` sends a single `server/discover` probe at the newest version this SDK speaks. Then: + +* A **modern server** answers it. The client adopts the result. One round trip, done. +* An **older server** has never heard of `server/discover` and returns an error. The client falls back to the classic `initialize` handshake and takes whatever that negotiates. + +Either way you come out connected, and `client.protocol_version` tells you which it was: + +```text +2026-07-28 +``` + +That is the whole feature. One `Client`, any era of server, no branching in your code. + +!!! info + `MCPServer` answers `server/discover`, so against your own in-memory server `auto` always lands + on `2026-07-28`. The fallback only ever fires against a real pre-2026 server, which is exactly + when you want it to. + +## `mode="legacy"` + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial002.py" +``` + +`mode="legacy"` never probes. It runs the `initialize` handshake, the same connection a pre-2026 client opens. + +```text +2025-11-25 +``` + +Same server. It speaks `2026-07-28` perfectly well; you told the client not to ask. + +You want this for the **push-style** features. + +A server-initiated request is the server calling *you*: `ctx.elicit(...)` putting a form in front of your user, sampling asking your model for a completion mid-tool-call. That channel only exists on a handshake-era session. + +At 2026-07-28 it is gone. The server *returns* its questions and you retry the call with the answers (**Multi-round-trip requests**). + +`mode="auto"` only gives you a handshake when the server is too old for anything else. `mode="legacy"` guarantees one. Reach for it whenever you hand `Client(...)` a `sampling_callback`, an `elicitation_callback` you want driven as a request, or a `message_handler`. **Client callbacks** goes through each. + +## Pinning a version + +`mode` also accepts a modern protocol version string. Today that set is exactly `["2026-07-28"]`. + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/protocol_versions/tutorial003.py" +``` + +A pin sends **nothing**. No probe, no handshake. The client adopts `2026-07-28` locally and the connection is live the instant `async with` returns. + +A pin is a promise *you* make: you already know the server speaks that version. The client doesn't check. + +!!! check + A pin is not a discovery. Print `client.server_info` and the price is right there: + + ```text + name='' title=None version='' description=None website_url=None icons=None + ``` + + The client never asked the server who it is, so `server_info` is a blank. `client.server_capabilities` + is the same story: every capability is `None`. Tool calls still work (the protocol needs none of it); + code that reads `server_capabilities` to decide what to offer does not. + + The next section is the fix. + +Only modern versions are pinnable. A handshake-era string is rejected at construction, before any I/O, and the error tells you what to write instead: + +```text +ValueError: mode must be 'legacy', 'auto', or one of ['2026-07-28']; got '2025-06-18' ('2025-06-18' is a handshake-era version; use mode='legacy') +``` + +## Reconnecting with `prior_discover` + +The probe is cheap, but it is still a round trip you pay on every reconnect, and the answer almost never changes. + +So keep it. After an `auto` connection, `client.session.discover_result` holds the exact `DiscoverResult` the server sent: its `supported_versions`, its `capabilities`, its `server_info`, its `instructions`. Hand it back as `prior_discover=` the next time: + +```python title="client.py" hl_lines="15 17" +--8<-- "docs_src/protocol_versions/tutorial004.py" +``` + +```text +2026-07-28 +Bookshop +``` + +The second connection made **zero** negotiation round trips and still knows exactly who it is talking to. That is the pinned mode done properly: `mode=` names the version, `prior_discover=` supplies the identity. ✨ + +`DiscoverResult` is a Pydantic model. `saved.model_dump_json()` goes into a file or a cache; `DiscoverResult.model_validate_json(...)` brings it back in the next process. + +!!! tip + `prior_discover=` only does anything when `mode` is a version pin. Under `"auto"` the client + probes the server anyway, and under `"legacy"` it is ignored. + +## The four modes + +| You write | Negotiation traffic | You get | +| --- | --- | --- | +| `Client(target)` | one `server/discover` probe; the `initialize` handshake if it fails | the newest version both sides speak, whichever era | +| `Client(target, mode="legacy")` | the `initialize` handshake | a handshake-era version; server-initiated requests work | +| `Client(target, mode="2026-07-28")` | none | that version, pinned, with a blank `server_info` | +| `Client(target, mode="2026-07-28", prior_discover=saved)` | none | that version, pinned, *and* the identity you saved last time | + +## Recap + +* MCP has a handshake era (up to `2025-11-25`, the `initialize` handshake) and a modern era (`2026-07-28`, `server/discover`). `Client` bridges them. +* `mode="auto"` is the default: probe, fall back. Leave it alone unless one of the other three rows describes you. +* `client.protocol_version` is always the answer to "what did I get?". +* `mode="legacy"` forces the handshake. It is what you need for server-initiated requests: sampling, push elicitation, `message_handler`. +* A version pin (`mode="2026-07-28"`) sends no negotiation traffic at all, at the cost of a blank `server_info`. +* `prior_discover=` pays that cost back: save `client.session.discover_result`, reconnect with it, get both. + +A modern connection has no push channel, so how does a 2026 server ask you a question mid-call? It returns it: **Multi-round-trip requests**. diff --git a/docs/client/transports.md b/docs/client/transports.md new file mode 100644 index 0000000000..c47669267a --- /dev/null +++ b/docs/client/transports.md @@ -0,0 +1,115 @@ +# Client transports + +Every `Client` talks to its server over a **transport**: the thing that actually carries the messages. + +You never configure one separately. `Client` takes a single positional argument and works the transport out from its type. + +The *server* side of each (what `mcp.run()` does and what you deploy) is **Running your server**. + +## In memory + +Pass the server object itself: + +```python title="client.py" hl_lines="14" +--8<-- "docs_src/client_transports/tutorial001.py" +``` + +No subprocess, no port, no bytes on a wire. The client and the server are two objects in the same process, and the call still goes through the real protocol layer: `search_books` is listed, validated and invoked exactly as it would be over HTTP. + +That makes it two things at once: + +* **A test harness.** Every example in this documentation is exercised this way, and the **Testing** chapter builds the whole pattern around it. +* **An embedding API.** An application that constructs the server doesn't need a network hop to call its tools. + +## Streamable HTTP + +Pass a URL string and you get **Streamable HTTP**, the transport you deploy behind: + +```python title="client.py" hl_lines="5" +--8<-- "docs_src/client_transports/tutorial002.py" +``` + +That is the whole production client. `Client` wraps the URL in `streamable_http_client(...)` for you, on top of an `httpx.AsyncClient` configured the way MCP needs: `follow_redirects=True`, a 30-second timeout for connect/write/pool, and a 300-second read timeout because the server may hold a response stream open. + +!!! check + A `Client` you have constructed is **not** connected. Construction only picks the transport; + `async with` is what opens it. Reach for the connection before entering and the SDK tells you so: + + ```text + RuntimeError: Client must be used within an async context manager + ``` + + Nothing was resolved, fetched or spawned when you wrote `Client("http://...")`. That line is free. + +### Bring your own `httpx.AsyncClient` + +The moment you need an `Authorization` header, a cookie, a proxy, mTLS, or a different timeout, build the `httpx.AsyncClient` yourself and hand it to `streamable_http_client`: + +```python title="client.py" hl_lines="8-14" +--8<-- "docs_src/client_transports/tutorial003.py" +``` + +Two things to notice: + +* You own the `httpx.AsyncClient`, so **you** enter and exit it. The SDK never closes a client it didn't create. +* `streamable_http_client(url, http_client=...)` returns a transport, and `Client(transport)` accepts it like anything else. + +!!! warning + `streamable_http_client` used to take `headers=` and `timeout=` directly. It does not any more: + its only parameters are `url`, `http_client` and `terminate_on_close`. Reach for `headers=` out + of habit and you get: + + ```text + TypeError: streamable_http_client() got an unexpected keyword argument 'headers' + ``` + + Everything HTTP-shaped now lives on the one `httpx.AsyncClient` you pass in. + +!!! info + If you know `httpx`, you already know how to do auth, proxies, event hooks, retries and connection + limits here. The SDK adds nothing on top and takes nothing away. It is also where OAuth plugs in: + `httpx.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **OAuth clients**. + +## stdio + +A **stdio** server is a subprocess. The client launches it, writes JSON-RPC to its stdin and reads JSON-RPC from its stdout. It is how a desktop host runs a server on your machine. + +Describe the process with `StdioServerParameters`, turn it into a transport with `stdio_client`, and hand *that* to `Client`: + +```python title="client.py" hl_lines="4-8 12" +--8<-- "docs_src/client_transports/tutorial004.py" +``` + +`Client` does not accept the parameters object on its own. `StdioServerParameters` is configuration; `stdio_client(server)` is the transport that knows how to spawn a process from it. Always wrap. + +Leaving the `async with` block also shuts the subprocess down: close stdin, wait, kill if it lingers. You never clean it up yourself. + +!!! warning + The child does **not** inherit your environment. It gets a minimal allow-list (`HOME`, `LOGNAME`, + `PATH`, `SHELL`, `TERM` and `USER` on POSIX) so nothing sensitive leaks into a process you may + not have written. + + A server that needs an API key won't find it there. Pass it explicitly with `env=`; those + variables are merged on top of the allow-list. That is what `BOOKSHOP_API_KEY` is doing above. + +## SSE + +`sse_client(url)`, from `mcp.client.sse`, is the HTTP transport that Streamable HTTP superseded. Wrap it the same way, `Client(sse_client("http://localhost:8000/sse"))`, to talk to a server that still speaks it, and don't build anything new on it. + +## The `Transport` protocol + +To `Client`, all of the above are the same thing. + +A **transport** is any async context manager that yields a `(read, write)` pair of message streams: formally, the `Transport` protocol in `mcp.client`. `Client` resolves its argument by type: a server object connects in-process, a `str` becomes `streamable_http_client(url)`, and anything else is entered as a transport directly. That last rule is why `stdio_client(...)`, `streamable_http_client(...)` and `sse_client(...)` all drop into the same slot, and why you can write your own. + +## Recap + +* `Client(mcp)` (the server object) connects in memory. Use it for tests and for embedding. +* `Client("http://.../mcp")` (a URL) connects over Streamable HTTP, the production transport. +* Headers, auth, proxies and timeouts belong on an `httpx.AsyncClient` you pass to `streamable_http_client(url, http_client=...)`. There is no `headers=` keyword. +* stdio is `Client(stdio_client(StdioServerParameters(...)))`, never the parameters object alone. +* The subprocess gets an allow-listed environment, not yours; `env=` adds to it. +* A transport is anything you can `async with x as (read, write)`. `Client` hands anything that isn't a server object or a URL straight to that protocol. +* Constructing a `Client` picks the transport. `async with` opens it. + +Once the transport is open the two sides have to agree on a protocol version. You normally never think about it; when you do, **Protocol versions** is the page. diff --git a/docs/concepts.md b/docs/concepts.md deleted file mode 100644 index a2d6eb8d3a..0000000000 --- a/docs/concepts.md +++ /dev/null @@ -1,13 +0,0 @@ -# Concepts - -!!! warning "Under Construction" - - This page is currently being written. Check back soon for complete documentation. - - diff --git a/docs/index.md b/docs/index.md index 6a937da67f..48c22e03f5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,68 +3,91 @@ !!! info "You are viewing the in-development v2 documentation" For the current stable release, see the [v1.x documentation](https://py.sdk.modelcontextprotocol.io/). -The **Model Context Protocol (MCP)** allows applications to provide context for LLMs in a standardized way, separating the concerns of providing context from the actual LLM interaction. +The **Model Context Protocol (MCP)** lets applications provide context to LLMs in a standardized way, separating the concern of *providing* context from the LLM interaction itself. -This Python SDK implements the full MCP specification, making it easy to: +This is the official Python SDK for it. With it you can: -- **Build MCP servers** that expose resources, prompts, and tools -- **Create MCP clients** that can connect to any MCP server -- **Use standard transports** like stdio, SSE, and Streamable HTTP +* **Build MCP servers** that expose tools, resources, and prompts to any MCP host. +* **Build MCP clients** that connect to any MCP server. +* Speak every standard transport: stdio, Streamable HTTP, and SSE. -If you want to read more about the specification, please visit the [MCP documentation](https://modelcontextprotocol.io). +## Requirements -## Quick Example +Python 3.10+. -Here's a simple MCP server that exposes a tool, resource, and prompt: +## Installation -```python title="server.py" -from mcp.server.mcpserver import MCPServer +=== "uv" -mcp = MCPServer("Test Server", json_response=True) + ```bash + uv add "mcp[cli]==2.0.0a3" + ``` +=== "pip" -@mcp.tool() -def add(a: int, b: int) -> int: - """Add two numbers""" - return a + b + ```bash + pip install "mcp[cli]==2.0.0a3" + ``` +The `[cli]` extra gives you the `mcp` command; you'll want it for development. -@mcp.resource("greeting://{name}") -def get_greeting(name: str) -> str: - """Get a personalized greeting""" - return f"Hello, {name}!" +!!! warning "Pin the version while v2 is in alpha" + Installers never select a pre-release unless you name one, so an unpinned `uv add "mcp[cli]"` + gives you the latest **v1.x** release, which this documentation does not describe. Check + [PyPI](https://pypi.org/project/mcp/#history) for the newest alpha before you copy the line + above. See [Installation](installation.md) for the details. +## Example -@mcp.prompt() -def greet_user(name: str, style: str = "friendly") -> str: - """Generate a greeting prompt""" - return f"Write a {style} greeting for someone named {name}." +### Create it +Create a file `server.py`: -if __name__ == "__main__": - mcp.run(transport="streamable-http") +```python title="server.py" +--8<-- "docs_src/index/tutorial001.py" ``` -Run the server: +That's a complete MCP server. + +It exposes one **tool**, `add`, and one templated **resource**, `greeting://{name}`. -```bash -uv run --with mcp server.py +### Run it + +```console +uv run mcp dev server.py ``` -Then open the [MCP Inspector](https://github.com/modelcontextprotocol/inspector) and connect to `http://localhost:8000/mcp`: +This starts your server and opens the [MCP Inspector](https://github.com/modelcontextprotocol/inspector), an interactive UI for poking at it. Open the URL it prints. + +!!! note + The Inspector is a Node.js app, so `mcp dev` needs `npx` on your `PATH`. + +### Try it -```bash -npx -y @modelcontextprotocol/inspector +In the Inspector, go to **Tools** and call `add` with `a=1`, `b=2`. + +You get `3` back. ✨ + +The Inspector built that form (a required integer field for `a`, another for `b`) from your type hints. So will Claude, and every other MCP host. + +Now go to **Resources** and read `greeting://World`: + +```text +Hello, World! ``` -## Getting Started +### Recap + +Look again at what you did **not** write: + +* No JSON Schema. `a: int, b: int` *is* the schema. +* No request parsing, no serialization, no validation code. +* No protocol handling at all. - -1. **[Install](installation.md)** the MCP SDK -2. **[Learn concepts](concepts.md)** - understand the three primitives and architecture -3. **[Explore authorization](authorization.md)** - add security to your servers -4. **[Use low-level APIs](low-level-server.md)** - for advanced customization +You wrote two Python functions with type hints and a docstring. The SDK does the rest. -## API Reference +## Where to go next -Full API documentation is available in the [API Reference](api/mcp/index.md). +* The **[Tutorial](tutorial/index.md)** walks through everything a server can do, one small step at a time. +* Migrating from v1? Start with the **[Migration Guide](migration.md)**. +* Hunting for an exact signature? The **[API Reference](api/mcp/index.md)** is generated from the source. diff --git a/docs/installation.md b/docs/installation.md index f398462353..13f56feecb 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,31 +1,49 @@ # Installation -The Python SDK is available on PyPI as [`mcp`](https://pypi.org/project/mcp/) so installation is as simple as: +The Python SDK is on PyPI as [`mcp`](https://pypi.org/project/mcp/). It requires **Python 3.10+**. -=== "pip" +These docs describe **v2**, which is in alpha, so the version pin is not optional yet: + +=== "uv" ```bash - pip install mcp + uv add "mcp[cli]==2.0.0a3" ``` -=== "uv" + +=== "pip" ```bash - uv add mcp + pip install "mcp[cli]==2.0.0a3" ``` -The following dependencies are automatically installed: +!!! warning "Why the pin" + Installers never select a pre-release unless you name one, so an unpinned `uv add "mcp[cli]"` + gives you the latest **v1.x** release, which these docs do not describe. Check the + [release history](https://pypi.org/project/mcp/#history) for the newest alpha before you copy + the line above. + + The same applies to one-off commands: `uv run --with "mcp==2.0.0a3" ...`, not `uv run --with mcp ...`. + + If your *package* depends on `mcp`, add a `<2` upper bound (for example `mcp>=1.27,<2`) before + the stable v2 lands so the major version bump doesn't surprise you. + +## What gets installed + +You don't need to know any of this to use the SDK, but if you're wondering what each dependency is for: -- [`httpx`](https://pypi.org/project/httpx/): HTTP client to handle HTTP Streamable and SSE transports. -- [`httpx-sse`](https://pypi.org/project/httpx-sse/): HTTP client to handle SSE transport. -- [`pydantic`](https://pypi.org/project/pydantic/): Types, JSON schema generation, data validation, and [more](https://docs.pydantic.dev/latest/). -- [`starlette`](https://pypi.org/project/starlette/): Web framework used to build the HTTP transport endpoints. -- [`python-multipart`](https://pypi.org/project/python-multipart/): Handle HTTP body parsing. -- [`sse-starlette`](https://pypi.org/project/sse-starlette/): Server-Sent Events for Starlette, used to build the SSE transport endpoint. -- [`pydantic-settings`](https://pypi.org/project/pydantic-settings/): Settings management used in MCPServer. -- [`uvicorn`](https://pypi.org/project/uvicorn/): ASGI server used to run the HTTP transport endpoints. -- [`jsonschema`](https://pypi.org/project/jsonschema/): JSON schema validation. -- [`pywin32`](https://pypi.org/project/pywin32/): Windows specific dependencies for the CLI tools. +* `mcp-types`: every protocol type (requests, results, content blocks) as its own package, versioned in lockstep with the SDK. Every `from mcp_types import ...` in these docs is this package. +* [`anyio`](https://anyio.readthedocs.io/): the async runtime. The whole SDK is written against anyio, so it runs on either `asyncio` or `trio`. +* [`pydantic`](https://docs.pydantic.dev/): what every `mcp_types` model is built on, plus all schema generation and validation. +* [`pydantic-settings`](https://docs.pydantic.dev/latest/concepts/pydantic_settings/): server configuration via `MCP_*` environment variables and `.env` files. +* [`httpx`](https://www.python-httpx.org/) and [`httpx-sse`](https://pypi.org/project/httpx-sse/): the HTTP client behind the Streamable HTTP and SSE *client* transports. +* [`starlette`](https://www.starlette.io/), [`uvicorn`](https://www.uvicorn.org/), [`sse-starlette`](https://pypi.org/project/sse-starlette/), and [`python-multipart`](https://pypi.org/project/python-multipart/): the HTTP *server* transports. +* [`jsonschema`](https://pypi.org/project/jsonschema/): validates a tool's structured output against its declared output schema. +* [`pyjwt[crypto]`](https://pyjwt.readthedocs.io/): OAuth token handling for authorization. +* [`opentelemetry-api`](https://opentelemetry-python.readthedocs.io/): just the lightweight API, so the SDK's tracing middleware costs nothing unless you install an OpenTelemetry SDK and exporter yourself. +* [`typing-extensions`](https://typing-extensions.readthedocs.io/) and `typing-inspection`: modern typing features on Python 3.10. +* `pywin32`: Windows only, used for `stdio` subprocess management. -This package has the following optional groups: +## Optional extras -- `cli`: Installs `typer` and `python-dotenv` for the MCP CLI tools. +* `mcp[cli]` adds [`typer`](https://typer.tiangolo.com/) and `python-dotenv` for the `mcp` command-line tool (`mcp dev`, `mcp run`, `mcp install`). You'll want this during development; you may not need it in a deployed server. +* `mcp[rich]` adds [`rich`](https://rich.readthedocs.io/) for nicer server logs. diff --git a/docs/low-level-server.md b/docs/low-level-server.md deleted file mode 100644 index a5b4f3df33..0000000000 --- a/docs/low-level-server.md +++ /dev/null @@ -1,5 +0,0 @@ -# Low-Level Server - -!!! warning "Under Construction" - - This page is currently being written. Check back soon for complete documentation. diff --git a/docs/run/asgi.md b/docs/run/asgi.md new file mode 100644 index 0000000000..2a21489a16 --- /dev/null +++ b/docs/run/asgi.md @@ -0,0 +1,171 @@ +# ASGI + +`mcp.run("streamable-http")` starts a web server for you. Sometimes you don't want that: your MCP server is one piece of a larger web application, or you already have an ASGI deployment. + +For that, `mcp.streamable_http_app()` returns a **Starlette application**. + +A Starlette app is an ASGI app, so anything that hosts ASGI (uvicorn, Hypercorn, another Starlette, FastAPI) can host your MCP server. + +## The app + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/asgi/tutorial001.py" +``` + +`app` is an ordinary ASGI application. Hand it to any ASGI server: + +```console +uvicorn server:app +``` + +The MCP endpoint is at `/mcp`, so a client connects to `http://127.0.0.1:8000/mcp`. + +The app already carries two things: + +* One route, `/mcp`: the Streamable HTTP endpoint. +* A **lifespan** that starts `mcp.session_manager`, the object that owns every live session's background work. + +Run the app on its own (`uvicorn server:app`) and you never think about either. + +!!! tip + `streamable_http_app()` takes the same keyword arguments as `mcp.run("streamable-http", ...)`, + minus `port`: the port belongs to whatever serves the app. `host` is still accepted but binds + nothing here; the next section is what it actually controls. **Running your server** covers the + options themselves. + +`mcp.sse_app()` does the same for the superseded SSE transport. + +## Localhost only, until you say otherwise + +`streamable_http_app()` cannot know which hostname it will be served behind, so it assumes the +safest answer: localhost. With no `transport_security=`, the app switches on **DNS-rebinding +protection** and accepts a request only if its `Host` header is `127.0.0.1:`, +`localhost:`, or `[::1]:`, and only if its `Origin` header, when there is one, is the +`http://` form of the same. For `uvicorn server:app` on your machine that is exactly what you want: +it stops a malicious web page from driving your local server through a DNS name it rebound to +`127.0.0.1`. + +It also means that **deployed behind a real hostname, the app rejects every request until you +configure it**. The check runs before MCP does, the client sees only a generic transport error, and +the reason is a single warning in the *server's* log: + +```text +421 Misdirected Request Invalid Host header the Host is not in the allowlist +403 Forbidden Invalid Origin header the Origin is not in the allowlist +``` + +`transport_security=` is how you configure it. Allowlist what you actually serve: + +```python +from mcp.server.transport_security import TransportSecuritySettings + +security = TransportSecuritySettings( + allowed_hosts=["mcp.example.com", "mcp.example.com:*"], + allowed_origins=["https://app.example.com"], +) +app = mcp.streamable_http_app(transport_security=security) +``` + +* `allowed_hosts` entries are exact strings: `"mcp.example.com"` matches a bare `Host` header and + `"mcp.example.com:*"` matches any port. List both. +* `allowed_origins` only matters for browsers (nothing else sends `Origin`). It is the server-side + twin of the CORS configuration below. +* Behind a reverse proxy that already controls the `Host` header, switching the check off is the + honest configuration: `TransportSecuritySettings(enable_dns_rebinding_protection=False)`. +* Passing a non-localhost `host=` (for example `host="mcp.example.com"`) does **not** allowlist that + hostname. It only stops the localhost default from arming the protection, which leaves every Host + and Origin accepted. Say what you mean with `transport_security=` instead. + +## Mounting it + +The moment the MCP server is *part* of a bigger application, you put the app inside a `Mount`. And the moment you do that, the lifespan becomes your problem: + +```python title="server.py" hl_lines="18-21 25-26" +--8<-- "docs_src/asgi/tutorial002.py" +``` + +* `Mount("/", ...)` plus the default `/mcp` path keeps the endpoint at `/mcp`. Starlette tries routes in order and `Mount("/")` matches **every** path, so your own routes go *before* it in the list. Anything after it is unreachable. +* The `lifespan` function enters `mcp.session_manager.run()` for the lifetime of the **host** app. This is the line everyone forgets. +* `mcp.session_manager` only exists *after* `streamable_http_app()` has been called. That is why the routes are built at module level and the manager is only touched inside the lifespan. + +Starlette's `Host` route works the same way: swap `Mount("/", ...)` for `Host("mcp.example.com", ...)` to route by hostname instead of by path. The lifespan rule does not change, and neither does the transport-security one. A `Host("mcp.example.com", ...)` route only ever receives requests addressed to that hostname, so without `allowed_hosts=["mcp.example.com", "mcp.example.com:*"]` it answers every one of them with a `421`. + +!!! warning "The host app owns the lifespan" + `streamable_http_app()` wires `session_manager.run()` into the lifespan of the Starlette it + returns, but **a mounted sub-application's lifespan never runs**. Mount the app and that + built-in lifespan is dead code. Whichever app sits at the top of your ASGI stack must enter + `mcp.session_manager.run()` in its own lifespan. + +!!! check + Delete the `lifespan=lifespan` line and start the server. It starts. The route resolves. + Then the first request to `/mcp` fails with: + + ```text + RuntimeError: Task group is not initialized. Make sure to use run(). + ``` + + Nothing starts the session manager except its `run()`. + +## Two servers, one app + +Each `MCPServer` is its own app with its own session manager. Mount as many as you like; enter every manager from the one host lifespan: + +```python title="server.py" hl_lines="27-30 35-36" +--8<-- "docs_src/asgi/tutorial003.py" +``` + +* `AsyncExitStack` enters both managers; they start together and shut down in reverse order. +* The endpoints are `/notes/mcp` and `/tasks/mcp`: the mount prefix plus the default path. + +## Changing the path + +That trailing `/mcp` is `streamable_http_path`. Set it to `"/"` and the mount prefix becomes the whole public path: + +```python title="server.py" hl_lines="25" +--8<-- "docs_src/asgi/tutorial004.py" +``` + +Now clients connect to `/notes`, not `/notes/mcp`. + +## CORS for browser clients + +A browser-based client needs two permissions from you: to **send** its MCP request headers, and to **read** the one MCP sends back. Both are CORS configuration on the host app, and the transport-security allowlist above has to agree with it: + +```python title="server.py" hl_lines="27-30 33 35-49" +--8<-- "docs_src/asgi/tutorial005.py" +``` + +* `allow_headers` is the half everyone forgets. A browser **preflights** every MCP request, because `Content-Type: application/json` and the `Mcp-*` request headers are not on the CORS safelist, and a header the preflight doesn't grant is a request the browser never sends. (`allow_headers=["*"]` also works: Starlette answers a preflight with whatever it asked for.) +* `expose_headers=["Mcp-Session-Id"]` is the read half. Streamable HTTP returns the session ID in that response header, and browsers hide response headers from JavaScript unless CORS exposes them by name. Without it the client can never make its second request. +* `allow_origins` is your decision, not MCP's. Be precise, and mirror it in `allowed_origins=` above: the browser enforces CORS, but the server checks `Origin` itself, and an origin the transport doesn't trust gets a `403` even after a clean preflight. +* `allow_methods` lists the three methods Streamable HTTP uses: `POST` to send messages, `GET` to open the server-to-client stream, `DELETE` to end the session. + +## Custom routes + +`@mcp.custom_route()` registers a plain HTTP endpoint on the same app, for the things every deployed service needs that have nothing to do with MCP: a health check, an OAuth callback. + +```python title="server.py" hl_lines="15-17" +--8<-- "docs_src/asgi/tutorial006.py" +``` + +* The handler is plain Starlette: an `async` function from `Request` to `Response`. +* `streamable_http_app()` picks up every custom route. `app.routes` is now `/mcp` and `/health`. +* `GET /health` answers `{"status": "ok"}` with no MCP in sight: no session, no handshake. + +!!! warning + Custom routes are **never authenticated**, even when the rest of the server is. That is + deliberate: health checks and OAuth callbacks have to be reachable before any token exists. + Don't put anything private behind one. + +## Recap + +* `mcp.streamable_http_app()` returns a Starlette app with one route, `/mcp`. Any ASGI server can run it. +* Out of the box the app answers only requests addressed to localhost. Deploying behind a real hostname means passing `transport_security=TransportSecuritySettings(...)`. +* `Mount` (or `Host`) puts it inside a bigger Starlette or FastAPI app. +* **Mounting disables the built-in lifespan.** The host app's lifespan must enter `mcp.session_manager.run()`, or the first request fails. +* Several servers in one app means several mounts and one lifespan that enters every session manager. +* `streamable_http_path="/"` moves the endpoint to the mount prefix itself. +* Browser clients need CORS: `allow_headers` for the `Mcp-*` request headers, `expose_headers=["Mcp-Session-Id"]` for the response. +* `@mcp.custom_route()` adds plain, unauthenticated HTTP endpoints next to `/mcp`. + +Once the server is reachable at a real URL, **The Client** connects to it with that URL instead of a server object. diff --git a/docs/run/index.md b/docs/run/index.md new file mode 100644 index 0000000000..da6bb2bfd1 --- /dev/null +++ b/docs/run/index.md @@ -0,0 +1,146 @@ +# Running your server + +`mcp.run()` starts the server. + +The only decision you make is the **transport**: how the bytes between your server and its client actually move. + +## Pick a transport + +| Transport | What it is | When | +|---|---|---| +| `stdio` | The host launches your file as a subprocess and speaks over its stdin and stdout. | Local servers. The default. | +| `streamable-http` | A real HTTP server listening on a port. | Anything you deploy. | +| `sse` | The older HTTP transport. | You don't. | + +!!! warning + SSE was superseded by Streamable HTTP in the 2025-03-26 protocol revision. + `mcp.run(transport="sse")` still works, with its own `sse_path=` and `message_path=` + options, but it exists for clients that haven't moved. Don't build anything new on it. + +## `mcp.run()` + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/run/tutorial001.py" +``` + +* `run()` is synchronous. It blocks for the life of the server. +* With no argument, the transport is `stdio`. +* It sits under `if __name__ == "__main__":` because everything that loads your server (`mcp dev`, `mcp run`, `mcp install`, your tests) **imports** this file. The guard keeps an import from turning into a running server. + +### stdio + +There is nothing to configure. The host starts your file as a child process, writes requests to its stdin, and reads responses from its stdout. + +Run it yourself and you see the consequence: + +```console +python server.py +``` + +Nothing prints, and it doesn't return. It is waiting on stdin for a host to speak first. + +That also means stdout **is the wire**. A stray `print()` corrupts the stream; the `logging` module writes to stderr and is the right tool. That story is in **Logging**. + +### Try it + +```console +uv run mcp dev server.py +``` + +The Inspector does exactly what a real host does: it launches `server.py` as a subprocess and connects to it over stdio. + +You never gave it a port. There isn't one. + +## Streamable HTTP + +To put the same server on a port instead, name the transport (and its options) in `run()`: + +```python title="server.py" hl_lines="13" +--8<-- "docs_src/run/tutorial002.py" +``` + +That one line builds a Starlette app and serves it with uvicorn. Clients connect to `http://127.0.0.1:3001/mcp`. + +Each transport has its own keyword arguments, all on `run()`: + +* `host` / `port`: where to listen. Defaults `127.0.0.1` and `8000`. +* `streamable_http_path`: where the MCP endpoint lives. Default `/mcp`. +* `json_response=True`: answer with plain JSON instead of an SSE stream. +* `stateless_http=True`: a fresh transport per request, no session tracking. +* `event_store`, `retry_interval`, `transport_security`: resumability and DNS-rebinding protection. They can wait, until you deploy somewhere other than localhost; **ASGI** covers `transport_security`. + +!!! warning + Transport options go to `run()`, **not** to `MCPServer(...)`. The constructor describes what + your server *is*: name, version, instructions. `run()` describes how it is served. Get it + backwards and Python answers before MCP is even involved: + + ```text + TypeError: MCPServer.__init__() got an unexpected keyword argument 'port' + ``` + +`run()` is the short road. The moment you need more (your server mounted inside an existing app, two servers in one process, CORS for browser clients), you build the ASGI app yourself and hand it to any ASGI host. That is **ASGI**. + +## Server settings + +A couple of things about running are not about the transport. They are constructor arguments: + +```python title="server.py" hl_lines="3" +--8<-- "docs_src/run/tutorial003.py" +``` + +* `log_level`: handed to `logging.basicConfig()` the moment `MCPServer(...)` is constructed. That configures the **root** logger, so it sets the level for your own loggers too, not just the SDK's. Default `"INFO"`. +* `debug`: forwarded to the Starlette app that the HTTP transports build. Default `False`. + +Both land on `mcp.settings`, which you can read back at runtime. + +## The `mcp` command + +The `[cli]` extra installs a small command-line tool around all of this. + +`mcp dev` runs your server under the **MCP Inspector**: + +```console +uv run mcp dev server.py +uv run mcp dev server.py --with pandas --with numpy +uv run mcp dev server.py --with-editable . +``` + +`--with` adds packages to the environment it builds; `--with-editable` installs your own package into it. It needs `npx` on your `PATH`: the Inspector is a Node.js app. + +`mcp run` imports the file, finds the server object (a module-level `mcp`, `server`, or `app`), and calls `run()` on it: + +```console +uv run mcp run server.py +uv run mcp run server.py:bookshop +``` + +The `:` suffix names the object when it isn't called `mcp`, `server`, or `app`. + +Your `if __name__ == "__main__":` block never executes here: `mcp run` calls `run()` itself, and the only option it forwards is `--transport`. + +`mcp install` registers the server with **Claude Desktop**, so the app launches it for you: + +```console +uv run mcp install server.py --name "Bookshop" +uv run mcp install server.py -v API_KEY=abc123 -f .env +``` + +`-v KEY=VALUE` and `-f .env` record environment variables in that entry. Claude Desktop starts your server in its own process. Your shell's environment is not there. + +`mcp version` prints the installed SDK version. + +!!! tip + `mcp dev` and `mcp run` only understand `MCPServer`. If you build with the low-level `Server`, + you run it yourself. See **The low-level Server**. + +## Recap + +* A **transport** is how bytes reach your server: `stdio` for a local subprocess, `streamable-http` for a port. SSE is superseded. +* `mcp.run()` picks the transport. With no argument it is `stdio`, and it blocks. +* Every transport option (`host`, `port`, `streamable_http_path`, ...) is an argument to `run()`, never to `MCPServer(...)`. +* Keep `run()` under `if __name__ == "__main__":`. Everything that loads your server imports the file first. +* `log_level=` and `debug=` are constructor arguments; they land on `mcp.settings`. +* `mcp dev` for the Inspector, `mcp run` to execute a file, `mcp install` for Claude Desktop, `mcp version` for the version. +* The transport never changes what your server *is*: all three files on this page expose the identical tool. + +When `run()` itself is the limit (your server inside an app that already exists), the next step is **ASGI**. diff --git a/docs/testing.md b/docs/testing.md deleted file mode 100644 index fcbc3a8553..0000000000 --- a/docs/testing.md +++ /dev/null @@ -1,82 +0,0 @@ -# Testing MCP Servers - -The Python SDK provides a `Client` class for testing MCP servers with an in-memory transport. -This makes it easy to write tests without network overhead. - -## Basic Usage - -Let's assume you have a simple server with a single tool: - -```python title="server.py" -from mcp.server import MCPServer - -app = MCPServer("Calculator") - -@app.tool() -def add(a: int, b: int) -> int: - """Add two numbers.""" # (1)! - return a + b -``` - -1. The docstring is automatically added as the description of the tool. - -To run the below test, you'll need to install the following dependencies: - -=== "pip" - ```bash - pip install inline-snapshot pytest - ``` - -=== "uv" - ```bash - uv add inline-snapshot pytest - ``` - -!!! info - I think [`pytest`](https://docs.pytest.org/en/stable/) is a pretty standard testing framework, - so I won't go into details here. - - The [`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) is a library that allows - you to take snapshots of the output of your tests. Which makes it easier to create tests for your - server - you don't need to use it, but we are spreading the word for best practices. - -```python title="test_server.py" -import pytest -from inline_snapshot import snapshot -from mcp import Client -from mcp_types import CallToolResult, TextContent - -from server import app - - -@pytest.fixture -def anyio_backend(): # (1)! - return "asyncio" - - -@pytest.fixture -async def client(): # (2)! - async with Client(app, raise_exceptions=True) as c: - yield c - - -@pytest.mark.anyio -async def test_call_add_tool(client: Client): - result = await client.call_tool("add", {"a": 1, "b": 2}) - assert result == snapshot( - CallToolResult( - content=[TextContent(type="text", text="3")], - structuredContent={"result": 3}, - ) - ) -``` - -1. If you are using `trio`, you should set `"trio"` as the `anyio_backend`. Check more information in the [anyio documentation](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on). -2. The `client` fixture creates a connected client that can be reused across multiple tests. - -!!! note - `Client(app)` connects in-process and is era-neutral by default — it probes the server and picks the - appropriate protocol path. Pin `mode='legacy'` if your test exercises legacy-specific semantics - (sampling/elicitation push, `message_handler`). - -There you go! You can now extend your tests to cover more scenarios. diff --git a/docs/tutorial/completions.md b/docs/tutorial/completions.md new file mode 100644 index 0000000000..e1d1815a13 --- /dev/null +++ b/docs/tutorial/completions.md @@ -0,0 +1,125 @@ +# Completions + +A client building a UI on top of your server wants to autocomplete argument values as the user types: language names, repository names, file paths. + +**Completions** are how your server supplies those suggestions. + +## Something worth completing + +Completions apply to exactly two things: the arguments of a **prompt** and the parameters of a **resource template**. So start with a server that has one of each: + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/completions/tutorial001.py" +``` + +Nothing here is about completions yet. + +* `review_code` takes a `language`. A user shouldn't have to guess which spellings you accept. +* `github_repo` takes an `owner` and a `repo`. Free-text boxes for both make a bad form. + +## The completion handler + +Add **one** function decorated with `@mcp.completion()`: + +```python title="server.py" hl_lines="22-30" +--8<-- "docs_src/completions/tutorial002.py" +``` + +* There is one handler per server. Every completion request lands here, and you branch on what's being completed. +* It must be `async def`: the SDK awaits it. +* It receives three arguments: + * `ref`: *which* prompt or resource template, as a `PromptReference` or a `ResourceTemplateReference`. `isinstance` is how you tell them apart. + * `argument`: `argument.name` is the argument being completed, `argument.value` is what the user has typed so far. + * `context`: the arguments already resolved. Ignore it for now. +* You return a `Completion(values=[...])`, or `None` when you have nothing to offer. + +!!! tip + `argument.value` is the prefix the user has typed. The SDK does **not** filter for you: whatever + you put in `values` is what the UI shows. The `startswith` is yours to write. + +### Try it + +Drive it with the in-memory `Client`, the same one you use in **Testing**. Call +`client.complete()` with `ref=PromptReference(name="review_code")` and +`argument={"name": "language", "value": "py"}`: + +```python +result.completion.values # ['python'] +``` + +* `ref` is the same reference type your handler receives. +* `argument` is a plain dict with exactly two keys, `name` and `value`. + +Send an empty `value` and you get the whole list back. `lang.startswith("")` is true for every language: + +```python +result.completion.values # ['go', 'javascript', 'python', 'rust', 'typescript'] +``` + +Ask about `code` (an argument your handler doesn't recognise) and it returns `None`, which the SDK turns into an empty list: + +```python +result.completion.values # [] +``` + +`None` means *"no suggestions"*, never an error. A UI falls back to a plain text box. + +## A capability you never declared + +Registering the handler is the declaration. Connect a client and look: + +```python +client.server_capabilities.completions # CompletionsCapability() +``` + +You didn't list `completions` anywhere. The SDK saw the handler and advertised it during the handshake. Every *optional* capability works this way: the handler is the declaration. (The three primitives are not optional: `MCPServer` always declares those, handlers or not.) + +!!! check + Go back to the first `server.py` (the one with no handler) and ask it anyway. The call fails + with a JSON-RPC error: + + ```text + Method not found + ``` + + And `client.server_capabilities.completions` is `None`. That's the point of the capability: a + well-behaved client checks it and never sends the request you can't answer. + +## Dependent arguments + +`github://repos/{owner}/{repo}` has two parameters, and the useful values for `repo` depend on which `owner` was picked first. + +That's what `context` is for. It carries the arguments the user has **already resolved**: + +```python title="server.py" hl_lines="9-12 35-39" +--8<-- "docs_src/completions/tutorial003.py" +``` + +* The new branch fires for the template's `repo` parameter. +* `context.arguments` is a `dict[str, str] | None` of the values picked so far (here, `owner`). +* No `owner` yet means no sensible suggestions, so the handler returns `None`. + +The client sends those resolved values with `context_arguments=`. This time `ref` is a +`ResourceTemplateReference(uri="github://repos/{owner}/{repo}")`. Ask for `repo` with an +empty `value` and pass `context_arguments={"owner": "modelcontextprotocol"}`: + +```python +result.completion.values # ['python-sdk', 'typescript-sdk', 'inspector'] +``` + +Drop `context_arguments=` and the same call returns `[]`. The handler can't know which repos to offer until it knows the owner. + +!!! info + `Completion` also takes `total=` and `has_more=`. Set them when `values` is a slice of a longer + list, so a UI can show *"and 200 more"*. Most handlers never need them. + +## Recap + +* Completions are suggestions for **prompt arguments** and **resource template parameters**. Nothing else. +* `@mcp.completion()` registers the one handler. It's `async def (ref, argument, context) -> Completion | None`. +* Branch on `isinstance(ref, ...)` and on `argument.name`. Filter by `argument.value` yourself. +* `None` becomes an empty list. It is never an error. +* `context.arguments` holds the already-resolved values; the client supplies them as `context_arguments=`. +* The `completions` capability appears the moment you register the handler. Without it, the request is `Method not found`. + +Suggestions help *before* a tool runs. To ask the user a question in the *middle* of one, you want **Elicitation**. diff --git a/docs/tutorial/context.md b/docs/tutorial/context.md new file mode 100644 index 0000000000..3a15e8fc82 --- /dev/null +++ b/docs/tutorial/context.md @@ -0,0 +1,126 @@ +# The Context + +A tool's arguments come from the model. Everything else (the request you are serving, the server you live in, a way to talk back to the client) comes from one object: the **`Context`**. + +You don't construct it and you don't configure it. You ask for it. + +## Ask for it + +Add a parameter annotated with `Context` to any tool: + +```python title="server.py" hl_lines="2 8" +--8<-- "docs_src/context/tutorial001.py" +``` + +* The SDK builds a fresh `Context` for every request and passes it in. +* The parameter **name doesn't matter**. `ctx`, `context`, `c`: the SDK finds it by its annotation. +* Resources and prompts can declare one too, the same way. +* `ctx.request_id` is the id of the request your function is serving right now. + +!!! info + If you've used FastAPI, you've seen this move: declare a parameter with the framework's own type + (`Request` there, `Context` here) and the framework supplies it. Nothing to register, nothing to + configure: the type annotation is the whole mechanism. + +### Invisible to the model + +This is the part to internalise. Here is the input schema `tools/list` reports for `search_books`: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +One property. `ctx` is not an argument: it never appears in the schema, the model is never told about it, and no client can fill it in. It's a contract between you and the SDK, invisible on the wire. + +### Try it + +Run the server with the MCP Inspector: + +```console +uv run mcp dev server.py +``` + +The form for `search_books` has a single `query` field. Call it with `dune`: + +```text +[request 3] Found 3 books matching 'dune'. +``` + +The number is whichever request this happened to be. Call the tool again and it changes: every request gets its own `Context`. + +## What it gives you + +The injected object is small. Besides `request_id`: + +* `await ctx.read_resource(uri)`: read one of the server's **own** resources from inside a tool. The next section. +* `await ctx.report_progress(progress, total, message)`: stream progress back to the caller during a long call. The whole story is in **Progress**. +* `await ctx.elicit(message, schema)` and `await ctx.elicit_url(...)`: pause the tool and ask the user a question. That's **Elicitation**. +* `ctx.session`: the server's side of the conversation with this client. Notifications you send to the client live here; the last section uses it. +* `ctx.request_context`: the raw per-request record. The field you'll reach for is `lifespan_context`, the object your startup code yielded (see **Lifespan**). + +Logging is deliberately not on that list. A server logs with Python's `logging` module, like any other Python program. **Logging** is the short chapter on why. + +!!! tip + Injection only happens for the function you registered. A helper that your tool calls doesn't get + its own `Context`; pass `ctx` down as an ordinary argument. There is no ambient + "current context" to fetch from somewhere else. + +## Read your own resources + +A server's resources aren't only for clients. A tool can read them too: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/context/tutorial002.py" +``` + +`ctx.read_resource` resolves the URI through the same registry that serves `resources/read`, so a tool gets what a client would get: an iterable of `ReadResourceContents`, one per content block. For this URI there is one: + +```python +contents.content # 'fiction, non-fiction, poetry' +contents.mime_type # 'text/plain' +``` + +* `content` is exactly what `genres()` returned. One source of truth: the client browses the resource, your tools consume it, nobody copies the string. +* `describe_catalog`'s only parameter is the `Context`, so its input schema has **no properties at all**. The model calls it with `{}`. + +## Tell the client the list changed + +What a server offers is not fixed at import time. Register a tool at runtime, then tell the client: + +```python title="server.py" hl_lines="15-16" +--8<-- "docs_src/context/tutorial003.py" +``` + +* `mcp.add_tool(recommend_book)` registers a plain function as a tool: name, description and schema derived exactly as `@mcp.tool()` would have. +* `await ctx.session.send_tool_list_changed()` sends `notifications/tools/list_changed`. A client that receives it calls `tools/list` again and sees `recommend_book`. + +The siblings are `send_resource_list_changed()`, `send_prompt_list_changed()`, and `send_resource_updated(uri)` for a change to one specific resource. + +!!! check + Before anyone runs `enable_recommendations`, the tool you are promising does not exist. Call it + anyway and the result is an error the model can read: + + ```text + Unknown tool: recommend_book + ``` + + Run `enable_recommendations`, and the very same call succeeds. The tool list is genuinely + dynamic: `tools/list` reflects whatever is registered *right now*. + +## Recap + +* Annotate a parameter with `Context` (in a tool, a resource, or a prompt) and the SDK injects it. The name is yours. +* It is invisible to the model: the input schema only ever contains your real arguments. +* `ctx.request_id` identifies the request; `ctx.request_context.lifespan_context` is what your startup yielded. +* `await ctx.read_resource(uri)` lets a tool read the server's own resources. +* `ctx.session` is the channel back to the client: `send_tool_list_changed()` and its siblings tell it to re-fetch a list you changed. +* Progress reporting and elicitation also start at `Context`; each has its own chapter. + +Next: what happens when your tool fails, and how to choose who finds out, in **Handling errors**. diff --git a/docs/tutorial/elicitation.md b/docs/tutorial/elicitation.md new file mode 100644 index 0000000000..ef8d5911b5 --- /dev/null +++ b/docs/tutorial/elicitation.md @@ -0,0 +1,153 @@ +# Elicitation + +A tool that is halfway through its job and missing one answer doesn't have to fail. + +**Elicitation** lets it ask. In the middle of a tool call the server sends the client a question, the client puts it to the user, and the answer comes back into the same function call. + +There are two modes: + +* **Form mode**: you need a value (a confirmation, a date, a quantity). You describe the fields, the client renders the form. +* **URL mode**: you need the user to go somewhere else (an OAuth consent screen, a payment page). Nothing they do there passes through the protocol. + +## Ask with a form + +`ctx.elicit()` takes a message and a Pydantic model: + +```python title="server.py" hl_lines="9-11 20-23 25" +--8<-- "docs_src/elicitation/tutorial001.py" +``` + +* The **`Context`** parameter is what gives you `ctx.elicit`; any tool can take one. That object has its own chapter: **The Context**. +* `AlternativeDate` is the **schema** of the answer you want. +* The tool is `async def`. It has to be: it stops in the middle and waits for a person. +* On any other date the tool returns straight away. It only asks when it has to. +* The date the user accepts goes back through `book_table` itself. An answer is input like any other: an alternative that is also fully booked gets asked about again, not confirmed blind. + +### What the client receives + +The client gets your message and, next to it, a JSON Schema generated from the model: + +```json +{ + "properties": { + "accept_alternative": { + "description": "Try another date?", + "title": "Accept Alternative", + "type": "boolean" + }, + "date": { + "default": "2025-12-26", + "description": "Alternative date (YYYY-MM-DD)", + "title": "Date", + "type": "string" + } + }, + "required": ["accept_alternative"], + "title": "AlternativeDate", + "type": "object" +} +``` + +That schema is the form. `Field(description=...)` is the label; a default pre-fills the input and makes the field optional. It's the same Pydantic-to-JSON-Schema machinery you already used for a tool's arguments in **Tools**. + +!!! warning + An elicitation schema is not as expressive as a tool's input schema. Flat, primitive fields + only: `str`, `int`, `float`, `bool`, or a `Literal` of strings (it becomes an `enum`). + Put a model inside the model and `ctx.elicit` raises before anything is sent to the client: + + ```text + TypeError: Elicitation schema field 'address' rendered as {'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition + ``` + + You are interrupting a person mid-task. If the answer needs nesting, it should have been an + argument to the tool. + +### The three answers + +`result.action` tells you what the user did, and there are exactly three possibilities: + +* `"accept"`: they submitted the form. `result.data` is an `AlternativeDate` instance, already validated. +* `"decline"`: they said no. +* `"cancel"`: they dismissed the question without choosing. + +`result.data` only exists on `"accept"`, which is why the example checks `result.action` first. Your type checker enforces the order: after `result.action == "accept"`, `result.data` is an `AlternativeDate`; before it, there is no `.data` at all. + +A refusal is not an error. The tool decides what declining means (here, no booking) and answers the model normally. + +!!! tip + The answer is validated against your model before your code sees it. A client that sends + `"maybe"` for a `bool` doesn't corrupt your booking: the call fails with the + `ValidationError`, your `if` never runs. + +## Send the user to a URL + +Some things must not go through the model or the client: credentials, card numbers, OAuth consent. For those you don't ask for data; you ask the user to go somewhere: + +```python title="server.py" hl_lines="10-14 23" +--8<-- "docs_src/elicitation/tutorial002.py" +``` + +* `ctx.elicit_url()` takes the message, the **URL** to visit, and an `elicitation_id` you choose: any string that identifies this elicitation within your server. +* The result has an action and nothing else. `"accept"` means the user agreed to open the URL, **not** that they finished what's on the other side. +* The payment happens out of band, between the user's browser and your payment provider. No content ever comes back through MCP. + +Look at the second tool. When your server learns the out-of-band flow finished (a webhook, a poll; here it's modelled as a second tool), `ctx.session.send_elicit_complete(...)` sends `notifications/elicitation/complete` with the same `elicitation_id`. That is how the client knows it can stop showing *"waiting for payment..."*. Without it, the client can only guess. + +## The client side + +Servers ask. Clients answer by passing an **`elicitation_callback`** to `Client(...)`: + +```python title="client.py" hl_lines="7-8 19" +--8<-- "docs_src/elicitation/tutorial003.py" +``` + +* One callback handles both modes. `params` is a union of `ElicitRequestFormParams` and `ElicitRequestURLParams`; `isinstance` is the branch. +* For a URL, you show `params.url` to the user and return the action they chose. Never any `content`. +* For a form, a real application renders `params.requested_schema` and returns the user's input as `content`. This one always says yes with a canned answer, which is exactly the callback you want in a test. +* Passing the callback is also the **capability declaration**: it's how the server learns this client can be asked. The other things a client can answer for a server live in **Client callbacks**. + +!!! info + Elicitation is a request from the *server* to the *client*, and those only exist on a + classic-handshake session, which is why this client passes `mode="legacy"`. + On a **2026-07-28** connection a tool asks by *returning* the question from the call + instead; that flow is **Multi-round-trip requests**. + +### Try it + +Start the form-mode `server.py` (the first one on this page) on Streamable HTTP (**Running your server** has the one-liner), then run the client's `main()` and ask `book_table` for Christmas day. + +The callback prints the question it was sent: + +```text +No tables for 2 on 2025-12-25. Would you like to try another date? +``` + +It answers with `{"accept_alternative": True, "date": "2025-12-27"}`, and the tool, which has been waiting inside `await ctx.elicit(...)` this whole time, finishes the booking: + +```text +Booked a table for 2 on 2025-12-27. +``` + +Now swap in the URL-mode `server.py` and point the same `main()` at `pay_deposit`: the same callback takes the other branch, prints the payment link, and the tool comes back with *"Complete the payment in your browser."* One round trip, mid-call, in both directions. + +!!! check + Now remove `elicitation_callback=` from the `Client` and call `book_table` for Christmas day + again. The whole call fails with a protocol error: + + ```text + Elicitation not supported + ``` + + A client that registered no callback never declared the `elicitation` capability, so there is + nobody to ask. Your tool didn't get a `"decline"`; it got an exception. Design for it: every + elicitation needs a sensible answer to "what if I can't ask?". + +## Recap + +* `await ctx.elicit(message, schema=Model)` asks mid-call; your tool resumes with the answer. +* The schema is a flat Pydantic model: primitive fields only, validated on the way back. +* `result.action` is `"accept"`, `"decline"` or `"cancel"`; `result.data` exists only on accept. +* `await ctx.elicit_url(message, url, elicitation_id)` is for everything that must not pass through the model; `ctx.session.send_elicit_complete(elicitation_id)` says the out-of-band part is done. +* The client answers with one `elicitation_callback`, branching on the params type; registering it is what declares the capability. + +A tool that can ask is good. A tool that says how far along it is (**Progress**) is next. diff --git a/docs/tutorial/first-steps.md b/docs/tutorial/first-steps.md new file mode 100644 index 0000000000..ccf1a32b50 --- /dev/null +++ b/docs/tutorial/first-steps.md @@ -0,0 +1,139 @@ +# First steps + +On the landing page you wrote a server, ran it, and called a tool. + +Now do it again, slowly, with all three things a server can expose, and the names for everything you just saw. + +## Host, client, and server + +Three words you'll see on every page from here on: + +* A **host** is the LLM application: Claude, an IDE, an agent runtime. It's the thing the user is talking to. +* A **client** lives inside the host and speaks MCP. The host runs one client per server it's connected to. +* A **server** is what you build with this SDK. It exposes things to clients. It never talks to the model directly. + +You write the server. Hosts are someone else's product. The SDK also gives you a `Client`. You'll use it to test your servers, and it shows up later in this chapter. + +## The three primitives + +A server exposes exactly three kinds of thing. What separates them is **who decides to use them**: + +| Primitive | Controlled by | What it is | Example | +|---------------|-----------------|-----------------------------------------------------|------------------------------------| +| **Tools** | The model | A function the model calls to take an action | An API call, a database write | +| **Resources** | The application | Data the host loads into the model's context | A file's contents, an API response | +| **Prompts** | The user | A reusable message template the user invokes by name | A slash command, a menu entry | + +"Controlled by" is the whole point of the split. A tool runs because the **model** decided to call it. A resource is attached because the **application** decided the model needed it. A prompt runs because the **user** picked it. + +!!! info + If you've built a web API you already have most of the intuition: a **resource** is a `GET` + (it loads data and changes nothing) and a **tool** is a `POST` (it does work and may have + side effects). A **prompt** has no HTTP analogue; it's closer to a saved query the user runs + by name. + +## One server, all three + +```python title="server.py" hl_lines="6 12 18" +--8<-- "docs_src/first_steps/tutorial001.py" +``` + +Three plain functions, three decorators. Each decorator is the entire registration: + +* `@mcp.tool()` makes `add` a **tool**. +* `@mcp.resource("greeting://{name}")` makes `greeting` a **resource template**: the `{name}` in the URI is the function's parameter. +* `@mcp.prompt()` makes `summarize` a **prompt**. The string it returns becomes a user message. + +Everything else (the name, the description, the argument schema) the SDK reads from the function itself: its name, its docstring, its type hints. You never declared any of it separately. + +!!! tip + The two halves of the SDK have two import paths: `from mcp import Client` and + `from mcp.server import MCPServer`. There is no `from mcp import MCPServer`. + +### Try it + +Run it with the MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Open the URL it prints. The Inspector has one tab per primitive; walk through them in order. + +**Tools.** One entry: `add`, described as *Add two numbers.* The form has a required integer field for `a` and another for `b`. Fill them in, call it, and the result is `3`. The Inspector built that form from `a: int, b: int`. So does every other client. + +**Resources.** The *Resources* list is empty. `greeting` is under **Resource Templates**, because `greeting://{name}` has a parameter: there is no single resource to list until someone supplies a `name`. Give it `World` and read it: + +```text +Hello, World! +``` + +**Prompts.** One entry: `summarize`, with a single required `text` argument. Get it with some text and you receive one message with `role: user` and your rendered string as the content. That's all a prompt is: a function that builds messages. + +The Inspector ran your server over **stdio**, one of the transports an MCP server can speak. You don't pick one yet; **Running your server** is the chapter for that. + +## Capabilities + +You saw three tabs in the Inspector. How did it know there were three? + +When a client connects, the server declares its **capabilities**: which families of requests it will answer. The client uses that declaration to decide what to even ask for. You never wrote it; `MCPServer` declares it for you. + +Look at it yourself. The SDK's `Client` accepts the server object directly and connects to it **in memory** (no subprocess, no port): + +```python +import asyncio + +from mcp import Client + +from server import mcp + + +async def main() -> None: + async with Client(mcp) as client: + print(client.server_capabilities.model_dump(exclude_none=True)) + + +asyncio.run(main()) +``` + +```text +{'prompts': {'list_changed': False}, 'resources': {'subscribe': False, 'list_changed': False}, 'tools': {'list_changed': False}} +``` + +That dictionary is the server's half of the handshake: + +| Capability | The client may now call | +|-------------|------------------------------------------------------------| +| `tools` | `tools/list`, `tools/call` | +| `resources` | `resources/list`, `resources/templates/list`, `resources/read` | +| `prompts` | `prompts/list`, `prompts/get` | + +`MCPServer` serves all three primitives, so all three are always declared. + +Notice what isn't there. `completions` (argument autocomplete for resource templates and prompts) needs a handler you write, this server doesn't have one, so the capability is absent and a well-behaved client won't ask. That's the rule for everything optional: register the thing and the capability appears; **Completions** proves it. + +!!! info + `Client(mcp)` is the same in-memory client every example in this tutorial is tested with, and + it's how you'll test yours. It gets a whole chapter: **Testing**. + +## What you did not write + +Look back over this page. You wrote three small Python functions. You did **not** write: + +* A JSON Schema. `a: int, b: int` *is* the schema for `add`. +* A request handler. `tools/list`, `resources/read`, `prompts/get`: all served for you. +* A capability declaration. `MCPServer` made it for you. +* A line of protocol. The handshake, the version negotiation, the JSON-RPC framing: all of it happened inside `mcp dev` and `Client(mcp)`, and you never saw it. + +That ratio is the whole point of the SDK. + +## Recap + +* A **host** is the LLM app, a **client** is its MCP-speaking half, a **server** is what you build. +* Tools are **model**-controlled, resources are **application**-controlled, prompts are **user**-controlled. +* One decorator per primitive: `@mcp.tool()`, `@mcp.resource(uri)`, `@mcp.prompt()`. Name, description, and schema come from the function. +* A URI with a `{param}` makes a resource **template**, listed separately from concrete resources. +* The server's **capabilities** are declared for you, and a client only asks for what a server declares. +* `Client(mcp)` connects to the server object in memory: your test harness from day one. + +Each primitive now gets its own chapter, starting with the one the model drives: **Tools**. diff --git a/docs/tutorial/handling-errors.md b/docs/tutorial/handling-errors.md new file mode 100644 index 0000000000..9ee6dd9817 --- /dev/null +++ b/docs/tutorial/handling-errors.md @@ -0,0 +1,132 @@ +# Handling errors + +A tool can fail in two ways, and the SDK treats them very differently. + +Raise an ordinary exception and the **model** sees it. Raise `MCPError` and the **protocol** sees it. + +This chapter is about choosing. + +## An error the model can fix + +Take a tool that looks something up, and let the lookup miss: + +```python title="server.py" hl_lines="11-12" +--8<-- "docs_src/handling_errors/tutorial001.py" +``` + +There is nothing MCP about those two lines. `get_author` raises a plain `ValueError`, the way any Python function would. + +Call it with a title that isn't in the catalog and look at the result: + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool get_author: No book titled 'Nothing' in the catalog.")] +result.structured_content # None +``` + +* The request **succeeded**. There is a result; nothing was raised at the caller. +* `is_error` is `True`, and your exception's message (prefixed with the tool name) is in `content`, exactly where the model reads. +* `structured_content` is `None`. A failed call has no return value to structure. + +This is a **tool error**, and it is the default for *any* exception your tool raises. It is also almost always what you want. + +The model is the one calling your tool. It picked the arguments. So a tool error is a turn in the conversation: the model reads *"No book titled 'Nothing' in the catalog."*, realises it guessed the title wrong, and calls again with a better one. You wrote one `raise` and got a self-correcting agent. + +!!! tip + Never `return` an error message from a tool. A returned string has `is_error=False`, so to the + model (and to every client UI) it looks like the tool worked and that string was the answer. + `raise`. The flag is the signal. + +## An error the model cannot fix + +Now swap `ValueError` for `MCPError`. + +```python title="server.py" hl_lines="1 3 15" +--8<-- "docs_src/handling_errors/tutorial002.py" +``` + +`MCPError` is the SDK's **protocol error**. It is the one exception the tool wrapper does *not* catch: it propagates, and the whole `tools/call` request fails with a JSON-RPC error instead of a result. + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog." +} +``` + +* There is **no result**. No `content`, no `is_error`: nothing for the model to read. +* The **host** application gets the error instead, the same way it would if the tool didn't exist at all. +* `code`, `message`, and `data` arrive intact. `INVALID_PARAMS` is `-32602`; `mcp_types` exports it and the other JSON-RPC error codes (`INVALID_REQUEST`, `INTERNAL_ERROR`, ...) as constants so you never type a magic number. + +!!! check + Same lookup, same miss, but now the call *raises* on the client side instead of returning: + + ```text + mcp.shared.exceptions.MCPError: No book titled 'Nothing' in the catalog. + ``` + + The first version handed the model a sentence it could react to. This one hands it nothing. + For `get_author` that is strictly worse, which is the point of the next section. + +## Which one to raise + +The two paths answer two different questions. + +* **Raise any exception** for a failure of *execution*: the thing your tool tried to do didn't work. The model chose the call, so the model should see the consequence and get a chance to recover. A misspelled title, an upstream API that timed out, a row that doesn't exist: all tool errors. +* **Raise `MCPError`** when the *request itself* should be rejected: the client is missing a capability your tool depends on, the server isn't in a state to serve anyone, the caller skipped a required step. No retry from the model fixes any of those, so there is nothing to gain from handing it the message. + +One question decides it: **could a smarter model have avoided this?** Yes -> ordinary exception. No -> `MCPError`. + +By that test, the second version of `get_author` made the wrong choice: a better title fixes it, so the model deserved to see the message. It's there to show you the mechanism, not to recommend it. + +!!! info + `MCPError` lives at `from mcp import MCPError` and takes `code`, `message`, and an optional + `data` payload. Whatever you put in them is what the client receives: the SDK forwards a raised + `MCPError` verbatim instead of sanitising it. + +## A resource that doesn't exist + +Resources draw the same line, and ship one named exception for the common case. + +```python title="server.py" hl_lines="2 13" +--8<-- "docs_src/handling_errors/tutorial003.py" +``` + +`books://{title}` is a **template**. It matches *any* title, so "the URI is well-formed" and "the book exists" are two different questions, and only your function can answer the second one. + +When it can't, raise `ResourceNotFoundError`. The SDK turns it into the protocol error the spec assigns to a missing resource: `-32602` with the requested URI in `data`, so the client knows *which* read failed. + +```json +{ + "code": -32602, + "message": "No book titled 'Nothing' in the catalog.", + "data": {"uri": "books://Nothing"} +} +``` + +Notice there is no `is_error=True` half-result here. A resource read either returns contents or fails: resources have only the protocol path. Templates and everything else about resources live in **Resources**. + +## Errors you never raise + +A bad argument never reaches your function. + +Send `get_author` a `title` that isn't a string and the SDK rejects it against the input schema **before** calling you, as the same kind of `is_error=True` tool error the model can read and correct. You saw this in **Tools** with `Field(le=50)`. + +It means a whole class of `raise` statements you don't write: don't re-validate your own type hints. + +!!! info + Everything on this page is what a **client** sees, and the in-memory `Client` you'll write + tests with sees exactly the same thing. Even `raise_exceptions=True` doesn't turn a tool error + back into a traceback: by the time that flag could act, your exception is already the + `is_error=True` result. Assert on the result. **Testing** covers the pattern. + +## Recap + +* Raise **any exception** in a tool -> the call returns `is_error=True` with your message in `content`. The model reads it and can retry. This is the default. +* Raise **`MCPError`** -> the call itself fails with a JSON-RPC error. The model sees nothing; the host deals with it. `code`, `message`, and `data` survive intact. +* The deciding question: *could a smarter model have avoided this?* Yes -> exception. No -> `MCPError`. +* `ResourceNotFoundError` from a resource handler -> the protocol's `-32602`, with the URI in `data`. +* Bad arguments are rejected against the schema before your function runs; you don't `raise` for those. +* `from mcp import MCPError`; the error-code constants come from `mcp_types`. + +Errors handled. Next: the things your server sets up once, before the first call ever arrives, the **Lifespan**. diff --git a/docs/tutorial/index.md b/docs/tutorial/index.md new file mode 100644 index 0000000000..e7c7ba799e --- /dev/null +++ b/docs/tutorial/index.md @@ -0,0 +1,51 @@ +# Tutorial - User Guide + +This tutorial shows you how to use the MCP Python SDK, step by step. + +Each section gradually builds on the previous ones, but it's written so you can go straight to any specific section to solve a specific problem. It also works as a future reference: you can come back to exactly the part you need. + +## Run the code + +All the code blocks can be copied and used directly: they are complete, working files. + +To follow along, paste a block into a `server.py` and open it in the MCP Inspector: + +```console +uv run mcp dev server.py +``` + +It is **HIGHLY encouraged** that you write (or copy) the code, edit it, and run it locally. Using it in your own editor is what really shows you the point: how little you write, the autocompletion, the type checks catching mistakes before you run anything. + +## You will not be guessing + +Every example in this tutorial is a complete file under [`docs_src/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/docs_src) in the SDK's own repository, and every one of them is exercised by the SDK's test suite through an **in-memory client**: + +```python +import pytest +from mcp import Client + +from server import mcp + + +@pytest.mark.anyio +async def test_add() -> None: + async with Client(mcp) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + assert result.structured_content == {"result": 3} +``` + +No subprocess, no port, no transport. `Client(mcp)` connects to the server object directly. + +If a change to the SDK breaks an example on one of these pages, CI goes red before the page does. The code you read here is the code that runs. + +You'll use this yourself in the [Testing](testing.md) chapter; it's how you test your own servers, too. + +## Install the SDK + +If you haven't yet, [install the SDK](../installation.md) first. + +## Advanced User Guide + +There is also an **Advanced User Guide** you can read after this one. + +It builds on this tutorial, uses the same concepts, and teaches you the extra things: the low-level `Server`, middleware, authorization, the 2026-07-28 protocol negotiation. But you should read this first: everything in the Advanced guide assumes you know the basics. diff --git a/docs/tutorial/lifespan.md b/docs/tutorial/lifespan.md new file mode 100644 index 0000000000..97ea2d0964 --- /dev/null +++ b/docs/tutorial/lifespan.md @@ -0,0 +1,102 @@ +# Lifespan + +Most real servers hold something for their whole life: a database pool, an HTTP client, a loaded model. + +You don't want to build it on every call, and you do want to close it cleanly. That's what the **lifespan** is for. + +## A typed lifespan + +A lifespan is an `@asynccontextmanager` that receives the server and `yield`s **one object**. Whatever you yield is available to every handler for as long as the server runs. + +```python title="server.py" hl_lines="25-31 34 38 40" +--8<-- "docs_src/lifespan/tutorial001.py" +``` + +Read it bottom-up: + +* `app_lifespan` connects the `Database` **before** the `yield` and disconnects it **after**, in a `finally`. That's startup and shutdown. +* It yields an `AppContext`, a plain dataclass holding the things you set up. One field today, ten tomorrow. +* `MCPServer("Bookshop", lifespan=app_lifespan)` is the whole wiring. +* Inside the tool, the yielded object is `ctx.request_context.lifespan_context`. + +The lifespan runs **once**. It is entered when the server starts (before the first request) and exited when the server stops. Every request in between shares the same `AppContext`. + +!!! info + If you've written a FastAPI `lifespan`, you already know this. Same decorator, same `yield`, same `finally`. + +### What the model sees + +Nothing new. `ctx` is a **Context** parameter, so the SDK injects it and it never reaches the input schema: + +```json +{ + "type": "object", + "properties": { + "genre": {"title": "Genre", "type": "string"} + }, + "required": ["genre"], + "title": "count_booksArguments" +} +``` + +`genre` is the only argument the model can pass. The lifespan is your server's business. + +`@mcp.resource()` and `@mcp.prompt()` functions can take a `ctx` parameter too, written as a bare `Context` for a reason the next section gets to. Everything `ctx` carries is in **The Context**. + +### It really is typed + +Look at the annotation again: `ctx: Context[AppContext]`. + +That one type parameter is why `ctx.request_context.lifespan_context` **is** an `AppContext` to your type checker. `.db` autocompletes; `.dbb` is an error before you ever run the server. + +Write a bare `Context` instead and `lifespan_context` is typed as `dict[str, Any]`: the type checker has no way to know what your lifespan yielded. The object is still there at runtime; you've lost the help. + +!!! warning + `Context[AppContext]` is a **tool-only** spelling. Put it on an `@mcp.resource()` or + `@mcp.prompt()` function and every call to that handler fails. The client gets an error back, + and the server log shows why: + + ```text + Context is not available outside of a request + ``` + + In resources and prompts, write the bare `ctx: Context`. The object your lifespan yielded is + still `ctx.request_context.lifespan_context` at runtime; you give up the type parameter, not + the object. + +!!! tip + There is always a lifespan. If you don't pass one, the SDK's default yields an empty `dict`, + so `ctx.request_context.lifespan_context` is `{}`, never `None`. That default is also why a + bare `Context` types it as `dict[str, Any]`. + +## Watch it happen + +"Startup runs before the first request" is the kind of sentence you should not have to take on faith. + +Strip the server down to the lifecycle: give `Database` a `connected` flag, flip it in `connect()` and `disconnect()`, and add a tool that reports it. + +```python title="server.py" hl_lines="11 14 17 25 44" +--8<-- "docs_src/lifespan/tutorial002.py" +``` + +`database` lives at module level for one reason: so you can look at it from *outside* the server. + +!!! check + Three moments, three values: + + * Before the server starts, `database.connected` is `False`. Importing the module connected nothing. + * While it's running, call `database_status` and the result is `"connected"`. + * Stop the server and the `finally` block runs: `database.connected` is `False` again. + + The work happened exactly where you put it: around the `yield`, not at import time and not per request. + +## Recap + +* `lifespan=` takes an `@asynccontextmanager` that receives the server and `yield`s one object. +* Code before the `yield` is startup. The `finally` after it is shutdown. +* It runs once, around the whole life of the server, not per request. +* Whatever you `yield` is `ctx.request_context.lifespan_context` in every tool, resource, and prompt. +* `ctx: Context[AppContext]` makes that access fully typed in tools. Resources and prompts take the bare `Context`. +* No `lifespan=` means an empty `dict`, never `None`. + +Next: tools that return more than text, **Media**. diff --git a/docs/tutorial/logging.md b/docs/tutorial/logging.md new file mode 100644 index 0000000000..f4a58b70f2 --- /dev/null +++ b/docs/tutorial/logging.md @@ -0,0 +1,78 @@ +# Logging + +Log from a tool the way you log from any other Python function: with the standard library. + +MCP has a protocol-level **logging capability**: a server could push its log messages to the client as notifications, through methods on the `Context` object. The 2026-07-28 revision of the spec **deprecates that capability and does not replace it**, so this tutorial doesn't teach it. The full list of what's deprecated and what to do instead is in **Deprecated features**. + +What you do instead is what you do in every other Python program: the standard library. + +## A tool that logs + +```python title="server.py" hl_lines="1 5 13" +--8<-- "docs_src/logging/tutorial001.py" +``` + +* `logging.getLogger(__name__)` gives you a logger named after your module. Create it once, at the top. +* Inside the tool you call `logger.info(...)` like in any other function. Nothing to inject, nothing to `await`, nothing MCP-specific. + +!!! check + Call the tool and look at the whole result: + + ```python + result.content # [TextContent(text="Found 3 books matching 'dune'.")] + result.structured_content # {'result': "Found 3 books matching 'dune'."} + ``` + + The log line is nowhere in it. Logging is for **you**, the person operating the server. The model + never sees it. If the model should read something, `return` it. + +## Where it goes + +For a **stdio** server this question matters more than usual. The host launched your server as a subprocess and is reading MCP messages from its **stdout**. Standard error is yours. + +The standard library already does the right thing: log output goes to `sys.stderr` by default. Your `logger.info(...)` lines land in the terminal (or wherever the host collects the subprocess's stderr), and the protocol stream stays clean. + +!!! tip + Never `print()` in a stdio server. `print` writes to **stdout**, and stdout *is* the wire: one stray + line and the client is trying to parse it as JSON-RPC. + + `logger.debug("got here")` is the same one line of effort and goes to the right place. + +## The level + +You don't have to call `logging.basicConfig()` yourself. Constructing an `MCPServer` already did, with a handler pointed at standard error, at the level you pass as `log_level=`, so `MCPServer("Bookshop", log_level="DEBUG")` is all it takes to see your `logger.debug(...)` lines. + +The default is `"INFO"`. + +`logging.basicConfig()` never replaces handlers that already exist. If you configure logging yourself before creating the server, your configuration wins. + +## Try it + +Run the server with the MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Call `search_books` from the **Tools** tab. The Inspector shows you the result: only the return value. The line + +```text +Searching for 'dune' +``` + +went to standard error: the terminal, not the wire. + +!!! info + If what you actually want is *tracing* (every request, how long it took, whether it failed), you + don't want log lines, you want spans. The SDK ships an `OpenTelemetryMiddleware` for exactly that. + See **Middleware**. + +## Recap + +* The MCP protocol's logging capability is deprecated by the 2026-07-28 spec and not replaced. Don't build on it. +* `logger = logging.getLogger(__name__)` at module level, `logger.info(...)` in the tool. That's the whole pattern. +* Log output never reaches the model. Only the value you `return` does. +* Standard error is yours; stdout belongs to the protocol. Never `print()` in a stdio server. +* `MCPServer(..., log_level="DEBUG")` sets the level, and a logging configuration you made first is left alone. + +Next: the in-memory client that has been running every example on these pages, and how to point it at your own server, in **Testing**. diff --git a/docs/tutorial/media.md b/docs/tutorial/media.md new file mode 100644 index 0000000000..a473c0bba2 --- /dev/null +++ b/docs/tutorial/media.md @@ -0,0 +1,108 @@ +# Media + +Text is not the only thing a tool can return. + +The SDK ships two helpers for binary results (**`Image`** and **`Audio`**) and an **`Icon`** type for giving your server, tools, resources, and prompts a face in the client's UI. + +## Returning an image + +Annotate the return type as `Image` and return one: + +```python title="server.py" hl_lines="14 16" +--8<-- "docs_src/media/tutorial001.py" +``` + +* `Image` takes exactly one of `data` (raw bytes) or `path` (a file to read). +* `format="png"` becomes the MIME type the client sees: `image/png`. +* The bytes here are a one-pixel placeholder so the file runs on its own. In a real server they come from Pillow, matplotlib, a headless browser, or anything else that hands you `bytes`. + +`Image` is an SDK convenience, not a protocol type. On the wire your return value becomes an **`ImageContent`** block (your bytes base64-encoded, plus the MIME type): + +```python +result.content # [ImageContent(type="image", data="iVBORw0KGgoAAAANSUhEUg...", mime_type="image/png")] +result.structured_content # None +``` + +Two things to notice: + +* `data` is base64. You returned raw `bytes`; the SDK did the encoding. +* `structured_content` is `None`. An `Image` is content for the model to look at, not data for the application to parse: there is no output schema. (Contrast **Structured Output**, where the return annotation *is* the schema.) + +!!! info + `ImageContent` and `AudioContent` live in `mcp_types`, right next to the `TextContent` + you met in **Tools**. A tool result is a list of content blocks; `Image` and `Audio` are + the shortest way to produce the two binary kinds. + +### Try it + +```console +uv run mcp dev server.py +``` + +Open the **Tools** tab and call `logo`. The result is not a string: it is an `image` content block, and the Inspector renders it as a picture. You returned `bytes`; everything between that and the pixels on screen was the SDK. + +## Returning audio + +`Audio` is the same shape: + +```python title="server.py" hl_lines="21-24" +--8<-- "docs_src/media/tutorial002.py" +``` + +The result is an **`AudioContent`** block: + +```python +result.content # [AudioContent(type="audio", data="UklGRjQAAABXQVZFZm1...", mime_type="audio/wav")] +result.structured_content # None +``` + +Same deal: raw bytes in, base64 and a MIME type out, no output schema. + +## Bytes or a file + +Both helpers also accept `path=` instead of `data=`. The file is read when the result is built, and the MIME type is guessed from the suffix: + +* `Image`: `.png`, `.jpg`, `.jpeg`, `.gif`, `.webp`. +* `Audio`: `.wav`, `.mp3`, `.ogg`, `.flac`, `.aac`, `.m4a`. + +A suffix it doesn't recognise falls back to `application/octet-stream`. + +!!! check + With `data=` there is no filename, so there is nothing to guess from. Forget `format=` and + the SDK falls back to a default: `image/png` for images, `audio/wav` for audio. Build an + `Audio` from MP3 bytes that way and the client is told `mime_type="audio/wav"`, then + faithfully fails to decode it. When you pass `data=`, pass `format=`. + +## Icons + +An `Icon` is metadata, not content. It doesn't carry the image; it points at one with a URI, and a client may fetch it and show it next to your server's name, a tool, a resource, or a prompt. + +```python title="server.py" hl_lines="5-6 8 11 17" +--8<-- "docs_src/media/tutorial003.py" +``` + +* `src` is a URI the client can resolve: `https:`, or a `data:` URI if you want the icon embedded with no extra fetch. +* `mime_type` and `sizes` (`"48x48"`, or `"any"` for a scalable format) let the client pick the right one when you offer several. +* `theme="light"` or `theme="dark"` marks an icon for one colour scheme. + +The same `icons=[...]` keyword is accepted by `MCPServer(...)`, `@mcp.tool()`, `@mcp.resource()`, and `@mcp.prompt()`. + +### Where a client sees them + +Icons travel with whatever they decorate. The server's arrive during the handshake, on `client.server_info`: + +```python +client.server_info.icons # [Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])] +``` + +A tool's icons are on the `Tool` object from `tools/list`, a resource's on the `Resource` from `resources/list`, a prompt's on the `Prompt` from `prompts/list`. The field is always called `icons`. + +## Recap + +* Return an `Image` or `Audio` from a tool and the client receives an `ImageContent` / `AudioContent` block: your bytes base64-encoded, with a MIME type. +* Build one from in-memory `data=` plus an explicit `format=`, or from a `path=` and let the suffix decide. +* Media results carry no `structured_content` and no output schema. +* An `Icon` is a pointer: a `src` URI plus optional `mime_type`, `sizes`, and `theme`. +* `icons=[...]` works on the server, on tools, on resources, and on prompts, and clients find them on the matching objects. + +That is everything a tool can put *into* a result. Helping the user fill in a prompt's or a resource template's arguments *before* anything runs is **Completions**. diff --git a/docs/tutorial/progress.md b/docs/tutorial/progress.md new file mode 100644 index 0000000000..3267e89193 --- /dev/null +++ b/docs/tutorial/progress.md @@ -0,0 +1,117 @@ +# Progress + +A tool that takes thirty seconds and says nothing for thirty seconds looks broken. + +**Progress notifications** fix that. The tool reports how far along it is; the client decides what to draw with it: a bar, a spinner, a log line. + +## Report it from the tool + +Take a **`Context`** parameter and call `report_progress`: + +```python title="server.py" hl_lines="8 11" +--8<-- "docs_src/progress/tutorial001.py" +``` + +Three arguments, and you decide what they mean: + +* `progress`: how far you are. The spec requires it to **increase** with every report; never repeat a value or go backwards. +* `total`: how much there is in total, if you know. Optional. +* `message`: one human-readable line about *this* step. Optional. + +`ctx` is injected because of its type hint and the model never sees it: `import_catalog`'s input schema has a single property, `urls`. **The Context** chapter is all about that object; progress is one of the things it gives you. + +## Listen for it from the client + +The client opts in **per call**, by passing `progress_callback=` to `call_tool`: + +```python title="client.py" hl_lines="7 16" +import anyio +from mcp import Client + +from server import mcp + + +async def show(progress: float, total: float | None, message: str | None) -> None: + print(f"{message} ({progress}/{total})") + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.call_tool( + "import_catalog", + {"urls": ["https://example.com/a.json", "https://example.com/b.json"]}, + progress_callback=show, + ) + print(result.structured_content) + + +anyio.run(main) +``` + +The callback is an `async` function taking exactly what the server reported: `progress`, `total`, `message`. + +!!! info + `Client(mcp)` connects straight to the server object, in memory, the same client the **Testing** + chapter is built on. `progress_callback` is the same parameter whatever transport the `Client` + uses; the *timing* you are about to see is the in-memory connection's. It runs your callback + inline, so every report lands before `call_tool` returns. Over a real transport the + notifications race the result, and a slow callback can still be running after `call_tool` has + returned. + +### Try it + +Put `client.py` next to `server.py` and run it: + +```console +python client.py +``` + +```text +Imported https://example.com/a.json (1/2) +Imported https://example.com/b.json (2/2) +{'result': 'Imported 2 records.'} +``` + +Every `await ctx.report_progress(...)` on the server became one call to `show` on the client, in order, and both lines printed **before** `call_tool` returned. Progress is not bundled into the result; it streams while the tool is still working. + +!!! warning + `progress_callback` belongs to the **call**, not the `Client`. There is no constructor argument + for it, because different calls want different callbacks: one drives a download bar, the next + one a log line. + +!!! check + Now delete `progress_callback=show` and run it again: + + ```text + {'result': 'Imported 2 records.'} + ``` + + No error, no warning, same result. `report_progress` is a **no-op when the caller didn't ask + for progress**, so you report unconditionally and never have to wonder whether anyone is + listening. + +## When you don't know the total + +`total` is for when you know the denominator. Often you don't: you're draining a feed, walking a cursor, downloading something with no length header. + +Leave it out: + +```python title="server.py" hl_lines="20" +--8<-- "docs_src/progress/tutorial002.py" +``` + +The callback receives `total=None`. A client can still show *activity* ("3 imported so far...") but it can't show a percentage. Don't invent a total to get a prettier bar. + +!!! tip + `progress` doesn't have to count anything in particular. Bytes, rows, pages: pick the unit the + user would recognise, and only promise a `total` you can keep. + +## Recap + +* `await ctx.report_progress(progress, total=None, message=None)` from any tool that takes a `Context`. +* The client passes `progress_callback=` to `call_tool`: per call, never on the `Client`. +* The callback is `async (progress, total, message) -> None` and fires while the tool is still running. +* No callback on the call means `report_progress` does nothing. Report unconditionally. +* Omit `total` when you don't know it; the callback gets `None`. + +Progress is what a running tool shows the *user*. The lines it logs for *you*, the person operating the server, are a different channel: **Logging** is next. diff --git a/docs/tutorial/prompts.md b/docs/tutorial/prompts.md new file mode 100644 index 0000000000..44c23fa2e2 --- /dev/null +++ b/docs/tutorial/prompts.md @@ -0,0 +1,150 @@ +# Prompts + +A **prompt** is a message template the user picks. + +Tools are for the model. A prompt is the opposite: the user chooses one from a menu in their client (a slash command, a button), fills in its arguments, and the rendered messages go into the conversation as if they had typed them. + +You declare one by putting `@mcp.prompt()` on a function that returns the text. + +## Your first prompt + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/prompts/tutorial001.py" +``` + +The SDK reads the same three things it read from your tools: + +* The **name** is the function name: `review_code`. +* The **description** the client shows is the docstring: `Review a piece of code.` +* The **arguments** come from the parameters. `code` has no default, so it's required. + +That is what a client gets back from `prompts/list`: + +```json +{ + "name": "review_code", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "required": true} + ] +} +``` + +There is no JSON Schema here. Prompt arguments are a flat list of **named string values**: a form a person fills in, not a payload a model constructs. + +### Rendering it + +The client renders the template with `prompts/get`, passing the arguments. Your function runs and the `str` you return becomes **one user message**: + +```json +{ + "description": "Review a piece of code.", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Please review this code:\n\ndef add(a, b): return a + b" + } + } + ], + "resultType": "complete" +} +``` + +That is the entire life of a prompt: listed by name, rendered on demand, dropped into the chat. + +!!! check + `required` is enforced before your function runs. Render `review_code` without `code` and the + request itself fails with a JSON-RPC error (code `-32603`): + + ```text + mcp.shared.exceptions.MCPError: Internal server error + ``` + + There is no tool-style error result to hand back to a model, because no model is in the loop: + the call raises. The reason (`Missing required arguments: {'code'}`) lands in your server's log. + +### Try it + +Run the server with the MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Open the **Prompts** tab and select `review_code`. The Inspector draws a form with one required `code` field. Fill it in, render it, and you get back exactly the user message above. + +## More than one message + +A code review is one message. A debugging session is a conversation, and a prompt can seed the whole thing. + +Return a list of messages instead of a `str`: + +```python title="server.py" hl_lines="2 13-20" +--8<-- "docs_src/prompts/tutorial002.py" +``` + +* `UserMessage` and `AssistantMessage` come from `mcp.server.mcpserver.prompts.base`. Hand them a `str` and they wrap it in `TextContent` for you. The role is the class name. +* `Message` is their common base. Use it as the return annotation. + +Rendering `debug_error` now produces three messages, in order: + +```json +{ + "description": "Start a debugging conversation.", + "messages": [ + {"role": "user", "content": {"type": "text", "text": "I'm seeing this error:"}}, + {"role": "user", "content": {"type": "text", "text": "TypeError: 'int' object is not iterable"}}, + { + "role": "assistant", + "content": {"type": "text", "text": "I'll help debug that. What have you tried so far?"} + } + ], + "resultType": "complete" +} +``` + +Notice the last one. Pre-filling an `assistant` turn is how you steer the model's *next* reply without making the user type the steering themselves. + +## Titles and argument descriptions + +`review_code` is a function name, not a label. Give the client something better to put on the button, and describe each argument so the form explains itself: + +```python title="server.py" hl_lines="10-13" +--8<-- "docs_src/prompts/tutorial003.py" +``` + +* `title="Code review"` is the human-readable name, exactly like a tool's `title`. +* `Annotated[str, Field(description=...)]` is the same pattern you used in **Tools**. Here the description lands on the argument instead of in a schema. +* `language` has a default, so it stops being required. + +The `prompts/list` entry now carries everything a client needs to draw a good form: + +```json +{ + "name": "review_code", + "title": "Code review", + "description": "Review a piece of code.", + "arguments": [ + {"name": "code", "description": "The code to review.", "required": true}, + {"name": "language", "description": "The language the code is written in.", "required": false} + ] +} +``` + +!!! info + If you have read **Tools**, you already know everything on this page. Same decorator, same + docstring-as-description, same `Annotated`/`Field`. The only things that change are who + triggers it (the user) and where the result goes (into the conversation). + +## Recap + +* `@mcp.prompt()` on a function makes it a prompt. Name from the function, description from the docstring. +* Prompts are **user-controlled**: the client lists them, the user picks one and fills in the arguments. +* Arguments are a flat list of named strings (no schema). A parameter with a default is optional. +* Return a `str` and it becomes one user message. Return a list of `UserMessage` / `AssistantMessage` to seed a multi-turn conversation. +* `title=` and `Field(description=...)` are what a client puts in its UI. +* A missing required argument fails the whole request. There is no per-prompt error result. + +Next up: the one extra parameter a tool, resource or prompt can ask the SDK for, **The Context**. diff --git a/docs/tutorial/resources.md b/docs/tutorial/resources.md new file mode 100644 index 0000000000..5cf35503f9 --- /dev/null +++ b/docs/tutorial/resources.md @@ -0,0 +1,139 @@ +# Resources + +A **resource** is data you expose for the application to read. + +That's the split. A tool is something the **model** decides to call. A resource is something the **application** decides to load (a config file, a record, a document) and put in front of the model as context. + +You declare one by putting `@mcp.resource(uri)` on a plain Python function. + +## Your first resource + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/resources/tutorial001.py" +``` + +It's the same shape as a tool, plus one thing: the **URI**. Resources are addressed, not named. A client asks for `config://app`, never for `get_config`. + +The SDK still reads the rest from the function: + +* The **name** is the function name: `get_config`. +* The **description** the client sees is the docstring. +* The **content** is whatever you return. + +During `resources/list` the client gets this: + +```json +{ + "name": "get_config", + "uri": "config://app", + "description": "The active shop configuration.", + "mimeType": "text/plain" +} +``` + +And when it reads `config://app`, your function runs and the return value comes back as text: + +```python +result.contents # [TextResourceContents(uri="config://app", mime_type="text/plain", text="theme=dark\nlanguage=en")] +``` + +!!! tip + Listing is cheap. Your function is **not** called during `resources/list`, only during + `resources/read`, and only for the URI that was asked for. Expose a thousand resources + and you pay for the ones somebody opens. + +### Try it + +Run the server with the MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Open the URL it prints and go to the **Resources** tab. `config://app` is in the list with its description. Click it and the Inspector reads it: there are your two lines of config. + +## Resource templates + +One URI per record doesn't scale. Put a **placeholder** in the URI and a matching parameter on the function: + +```python title="server.py" hl_lines="12-13" +--8<-- "docs_src/resources/tutorial002.py" +``` + +`{user_id}` in the URI, `user_id: str` on the function. That is the entire contract. + +This is now a **resource template**, and it moves house: it leaves `resources/list` and shows up in `resources/templates/list` instead, as a pattern rather than an address: + +```json +{ + "name": "get_user_profile", + "uriTemplate": "users://{user_id}/profile", + "description": "A customer's profile.", + "mimeType": "text/plain" +} +``` + +The client fills in the placeholder and reads a concrete URI: `users://42/profile`, `users://ada/profile`. One function answers all of them, with the matched value passed in as `user_id`: + +```python +result.contents # [TextResourceContents(uri="users://42/profile", text="User 42: 12 orders since 2021.")] +``` + +Notice the `uri` in the result. It is the **concrete** URI the client asked for, not the template. + +!!! check + The placeholders and the parameters have to agree. Rename the function parameter to + `user` while the URI still says `{user_id}` and the decorator refuses **at import time**, + before any client gets near it: + + ```text + ValueError: Mismatch between URI parameters {'user_id'} and function parameters {'user'} + ``` + + A mismatch can only ever be a bug, so the SDK makes it impossible to start the server with one. + +`get_user_profile` can also take a parameter annotated `Context`. The SDK injects it without ever treating it as a URI parameter, and **The Context** chapter covers what it gives you. + +## What you return + +You're not limited to `str`. Give each resource a `mime_type` and return whatever fits: + +```python title="server.py" hl_lines="8-9 14-15 20-21" +--8<-- "docs_src/resources/tutorial003.py" +``` + +* `readme` returns a `str`, so it's sent as-is. This is the common case. +* `catalog_stats` returns a `dict`, so the SDK serialises it to **JSON text** for you: + + ```json + { + "books": 1204, + "authors": 391 + } + ``` + +* `placeholder_cover` returns `bytes`, so the client gets a `BlobResourceContents` instead of a `TextResourceContents`, with your bytes base64-encoded in its `blob` field. + +The same rule applies to anything else JSON-serialisable: a list, a Pydantic model, a dataclass. If it isn't a `str` and isn't `bytes`, it becomes JSON. + +`mime_type` is yours to declare, and it defaults to `text/plain`. The SDK never inspects what you return to guess it, so a `dict` resource you don't label is still advertised as plain text. + +!!! tip + `name=`, `title=` and `description=` are also accepted by `@mcp.resource()` when you don't + want to derive them from the function. And when there's no function to write at all, + `mcp.server.mcpserver.resources` has ready-made `Resource` classes (`TextResource`, + `BinaryResource`, `FileResource`, `HttpResource`, `DirectoryResource`) that you register + with `mcp.add_resource(...)`. + +A client can also **subscribe** to a resource and be notified when it changes; that's the client's half of the story and it lives in **The Client**. + +## Recap + +* `@mcp.resource(uri)` on a function makes it a resource. The URI is the address, the return value is the content, the docstring is the description. +* A `{placeholder}` in the URI makes it a **template**: it's listed under `resources/templates/list` and one function serves every URI that matches. +* Placeholder names must equal the function's parameter names. Get it wrong and you find out at import time, not in production. +* Your function runs when the resource is **read**, not when it's listed. +* `str` becomes text, `bytes` becomes a base64 blob, anything else becomes JSON text. `mime_type=` is how you label it. +* Tools are for the model to act. Resources are for the application to read. + +Next: the third primitive, the one a person picks from a menu, **Prompts**. diff --git a/docs/tutorial/structured-output.md b/docs/tutorial/structured-output.md new file mode 100644 index 0000000000..7f20b670ad --- /dev/null +++ b/docs/tutorial/structured-output.md @@ -0,0 +1,245 @@ +# Structured Output + +In **Tools** you returned a `str` and the result came back twice: as text in `content`, and as `{"result": "..."}` in `structured_content`. + +This chapter is about that second channel: where it comes from, every shape it can take, and how the SDK keeps it honest. + +The short version: **the return type annotation is the output schema**. You already wrote it. + +## The output schema + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial001.py" +``` + +The line that matters is the signature: `-> int`. + +Because of it, the tool the SDK sends during `tools/list` carries an `output_schema` next to the input schema you met in **Tools**: + +```json +{ + "properties": { + "result": {"title": "Result", "type": "integer"} + }, + "required": ["result"], + "title": "get_temperatureOutput", + "type": "object" +} +``` + +A bare `int` isn't a JSON object, so the SDK **wraps** it in `{"result": ...}`. Call the tool and both channels are filled: + +```python +result.content # [TextContent(text="17")] +result.structured_content # {"result": 17} +``` + +Every scalar gets the same wrapper: `str`, `int`, `float`, `bool`, `bytes`, `None`. + +## Two channels + +Why send the same value twice? + +* `content` is for the **model**. A language model reads text; this is the only part of the result it sees. +* `structured_content` is for the **application** the model runs inside: code that wants `17`, not a sentence containing "17". +* `output_schema` is the contract between them, published before the tool is ever called. + +You return one Python value. The SDK fills in all three. + +## Return a model + +Declare the shape as a Pydantic `BaseModel` and return an instance: + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/structured_output/tutorial002.py" +``` + +`WeatherData` **is** the schema now. No wrapper, no `result` key: + +```json +{ + "properties": { + "temperature": {"description": "Degrees Celsius.", "title": "Temperature", "type": "number"}, + "humidity": {"description": "Relative humidity, 0 to 1.", "title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" +} +``` + +`structured_content` is the object, field for field: + +```python +result.structured_content # {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"} +``` + +And the model is not left out. The SDK serializes the same object to JSON text for `content`: + +```json +{ + "temperature": 16.2, + "humidity": 0.83, + "conditions": "Overcast" +} +``` + +Notice the `Field(description=...)` on `temperature` and `humidity` landed in the schema. The same `Field` that described your **inputs** describes your outputs. + +!!! info + If you've used FastAPI's `response_model`, you already know this: a Pydantic model as the declared + response, serialized and documented for you. The only difference is that here the return annotation + is the whole declaration. + +## A `TypedDict` + +Not every shape deserves a class. A `TypedDict` produces the same schema: + +```python title="server.py" hl_lines="8" +--8<-- "docs_src/structured_output/tutorial003.py" +``` + +A `TypedDict` is a plain `dict` at runtime, so that is what you build and return. The schema, the validation, and `structured_content` are identical to the `BaseModel` version (minus the descriptions, which `TypedDict` has no place for). + +## A dataclass + +Dataclasses work too, and so does any ordinary class whose attributes have type hints. The SDK builds a Pydantic model out of the annotations behind the scenes. + +```python title="server.py" hl_lines="8-9" +--8<-- "docs_src/structured_output/tutorial004.py" +``` + +Three spellings, one schema. Use whichever your codebase already has. + +## Lists + +A `list[...]` isn't a JSON object either, so it gets the `{"result": ...}` wrapper, with your item type as a `$defs` reference inside it: + +```python title="server.py" hl_lines="15" +--8<-- "docs_src/structured_output/tutorial005.py" +``` + +```json +{ + "$defs": { + "WeatherData": { + "properties": { + "temperature": {"title": "Temperature", "type": "number"}, + "humidity": {"title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"} + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object" + } + }, + "properties": { + "result": {"items": {"$ref": "#/$defs/WeatherData"}, "title": "Result", "type": "array"} + }, + "required": ["result"], + "title": "get_forecastOutput", + "type": "object" +} +``` + +Ask for a two-day forecast and `structured_content` is `{"result": [{...}, {...}]}`. `content` becomes **two** `TextContent` blocks, one per item: a list is flattened for the model rather than dumped as one string. + +`tuple[...]`, unions, and `Optional[...]` are wrapped the same way. + +## Dictionaries + +`dict[str, ...]` is the one generic that already *is* a JSON object, so it isn't wrapped: + +```python title="server.py" hl_lines="9" +--8<-- "docs_src/structured_output/tutorial006.py" +``` + +```json +{ + "additionalProperties": {"type": "number"}, + "title": "get_temperaturesDictOutput", + "type": "object" +} +``` + +```python +result.structured_content # {"London": 16.2, "Reykjavik": 4.4} +``` + +The keys must be `str`. A `dict[int, float]` can't be a JSON object, so it falls back to the `{"result": ...}` wrapper. + +## Validation + +`output_schema` is not documentation. Whatever your function returns is **validated against it** before it leaves the server. + +You don't notice while you build the value by hand: Pydantic already made sure your `WeatherData` was a `WeatherData`. You notice the day the data comes from somewhere you don't control: + +```python title="server.py" hl_lines="9 21" +--8<-- "docs_src/structured_output/tutorial007.py" +``` + +The annotation promises `WeatherData`. The upstream response stopped sending `humidity`. + +!!! check + Call `get_weather` and it does not quietly hand the client a half-empty object. The call fails, + and the first lines of the error name the field: + + ```text + Error executing tool get_weather: 1 validation error for WeatherData + humidity + Field required [type=missing, input_value={'temperature': 16.2, 'conditions': 'Overcast'}, input_type=dict] + ``` + + That text comes back as the tool result with `is_error=True`, so the model knows the call failed + instead of confidently reading weather that isn't there. + +Returning a plain `dict` from a `-> WeatherData` tool is fine, by the way. That's exactly what `json.loads` produced. Validation is on the value, not on the Python type. + +## Opting out + +Sometimes the return annotation is for your type checker, not for the protocol. Pass `structured_output=False` and the tool is text-only: + +```python title="server.py" hl_lines="6" +--8<-- "docs_src/structured_output/tutorial008.py" +``` + +No `output_schema`, no wrapping, no validation. `structured_content` is `None` and `content` is the string you returned. + +The opposite, `structured_output=True`, turns the automatic detection into a requirement: a tool whose return type can't produce a schema raises at import time instead of falling back to text. + +## A class without type hints + +There is one way to end up unstructured without asking for it: return a class that has **no annotations on its body**. + +```python title="server.py" hl_lines="6-9" +--8<-- "docs_src/structured_output/tutorial009.py" +``` + +`Station` sets `name` and `online` inside `__init__`, but the *class* declares nothing. The SDK reads class annotations, finds none, and gives up. + +!!! warning + It gives up **silently**. `output_schema` is `None`, `structured_content` is `None`, and the text + the model reads is the object's `repr`: + + ```text + "" + ``` + + No error, no warning, a useless tool. Move the annotations onto the class body, or pass + `structured_output=True`, which turns this into a hard error the moment the module imports: + `Function get_station: return type is not serializable for structured output`. + +!!! tip + Need full control (building the `CallToolResult` yourself, or attaching `_meta` that the + application can see but the model can't)? That's **The low-level Server**. + +## Recap + +* The **return type annotation** is the output schema. It's published in `tools/list` as `output_schema`. +* Scalars, lists, tuples and unions are wrapped in `{"result": ...}`. Models, `TypedDict`s, dataclasses, annotated classes and `dict[str, ...]` are objects already and stay as they are. +* Every result carries `content` (text, for the model) **and** `structured_content` (data, for the application). +* What you return is validated against the schema. A mismatch is a tool error, not a corrupt result. +* `structured_output=False` opts a tool out. A class without type hints opts out silently; watch for it. + +You now own everything a tool can say back. Next, the second primitive: **Resources**. diff --git a/docs/tutorial/testing.md b/docs/tutorial/testing.md new file mode 100644 index 0000000000..9e31aa095f --- /dev/null +++ b/docs/tutorial/testing.md @@ -0,0 +1,106 @@ +# Testing + +The Python SDK ships a `Client` class with an **in-memory transport**: pass it your server object and it connects to it directly. + +No subprocess. No port. No transport at all. It's the same idea as FastAPI's `TestClient`. + +## Basic usage + +Let's assume you have a simple server with a single tool: + +```python title="server.py" +--8<-- "docs_src/testing/tutorial001.py" +``` + +To run the test below you'll need two extra (development) dependencies: + +=== "uv" + + ```bash + uv add --dev pytest inline-snapshot + ``` + +=== "pip" + + ```bash + pip install pytest inline-snapshot + ``` + +!!! info + These docs assume you already know [`pytest`](https://docs.pytest.org/en/stable/). + + [`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) is what the test below + uses to assert on the whole result object in one line. It records the output of a test as the + `snapshot(...)` literal you see. If you'd rather not use it, drop the import and assert on the + fields you care about (`result.content[0].text == "3"`) like in any other test. + +Now the test: + +```python title="test_server.py" +import pytest +from inline_snapshot import snapshot +from mcp import Client +from mcp_types import CallToolResult, TextContent + +from server import mcp + + +@pytest.fixture +def anyio_backend(): # (1)! + return "asyncio" + + +@pytest.fixture +async def client(): # (2)! + async with Client(mcp, raise_exceptions=True) as c: + yield c + + +@pytest.mark.anyio +async def test_call_add_tool(client: Client): + result = await client.call_tool("add", {"a": 1, "b": 2}) + assert result == snapshot( + CallToolResult( + content=[TextContent(type="text", text="3")], + structured_content={"result": 3}, + ) + ) +``` + +1. If you are using `trio`, return `"trio"` instead. See the [anyio documentation](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on) for the details. +2. The fixture yields a connected client. Every test that takes `client` gets a fresh in-memory connection to the same server. + +There you go! You can now extend your tests to cover more scenarios. + +## Why `raise_exceptions=True`? + +Two different things can go wrong, and this flag only touches one of them. + +An exception inside one of **your tools** is not a protocol failure. It becomes a normal result with +`is_error=True`, and the model reads the message. `raise_exceptions` doesn't change that: with or +without it, `call_tool` returns the same `is_error=True` result. There's a whole chapter on it: +**Handling errors**. + +A failure **outside** a tool body is different. On the connection `Client(mcp)` gives you, the +server sanitises it into a generic `"Internal server error"` before the client sees it. You should +never leak the details of an unexpected crash to a remote caller. In a test that is exactly what +you *don't* want, and it is what `raise_exceptions=True` changes: your test sees the real message +instead of the sanitised one. + +Leave it on in tests. It has no meaning in production code. + +## In-process by default + +!!! note + `Client(mcp)` connects in-process and is **era-neutral** by default: it probes the server and + picks the appropriate protocol path. Pin `mode="legacy"` if your test exercises legacy-specific + semantics (sampling or elicitation push, `message_handler`), and drop `raise_exceptions=True` + there: a legacy connection never sanitises in the first place, and the flag re-raises the + failure inside the server task instead of in your test. + +That one line is also why the rest of this tutorial can promise you that its examples work: every +example file is exercised by the SDK's own test suite through exactly this client. You're using the +same tool the SDK uses on itself. + +The tutorial ends here. Putting your tested server in front of a real client, over a real +transport, is **Running your server**. diff --git a/docs/tutorial/tools.md b/docs/tutorial/tools.md new file mode 100644 index 0000000000..774638856d --- /dev/null +++ b/docs/tutorial/tools.md @@ -0,0 +1,172 @@ +# Tools + +A **tool** is a function the model can call. + +You declare one by putting `@mcp.tool()` on a plain Python function. That's the whole API. + +## Your first tool + +```python title="server.py" hl_lines="6-8" +--8<-- "docs_src/tools/tutorial001.py" +``` + +Look at what you wrote. There are no schemas, no JSON, no protocol, just a function. The SDK reads three things from it: + +* The **name** of the tool is the name of the function: `search_books`. +* The **description** the model sees is the docstring: `Search the catalog by title or author.` +* The **arguments** the model is allowed to pass come from the type hints: `query: str` and `limit: int`. + +### The input schema + +From those type hints the SDK generates a JSON Schema and sends it to the client during `tools/list`: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"title": "Limit", "type": "integer"} + }, + "required": ["query", "limit"], + "title": "search_booksArguments" +} +``` + +Both arguments are in `required` because neither has a default. You'll fix that in a moment. (The `title` keys are Pydantic artifacts; the properties, their types, and `required` are the contract.) + +!!! tip + Type hints aren't documentation here. They are **the contract**. If a client sends `"limit": "ten"`, + the SDK rejects it before your function ever runs. + +### What the model gets back + +Call the tool with `{"query": "dune", "limit": 5}` and the result has two parts: + +```python +result.content # [TextContent(text="Found 3 books matching 'dune' (showing up to 5).")] +result.structured_content # {'result': "Found 3 books matching 'dune' (showing up to 5)."} +``` + +`content` is the text the **model** reads. `structured_content` is typed data for the **client application**. It's there because you declared the return type as `-> str`. + +Don't worry about `structured_content` yet. Return real Python objects from your tools and the right thing happens; the **Structured Output** chapter is all about it. + +### Try it + +Run the server with the MCP Inspector: + +```console +uv run mcp dev server.py +``` + +Open the URL it prints, go to the **Tools** tab, and call `search_books`. + +The Inspector renders a form with a required `query` text field and a required `limit` number field. It built that form from your type hints. So will every other MCP client. + +## Optional arguments + +Give a parameter a default value and it stops being required. That's it. It's just Python. + +```python title="server.py" hl_lines="7" +--8<-- "docs_src/tools/tutorial002.py" +``` + +The schema follows: + +```json +{ + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"} + }, + "required": ["query"], + "title": "search_booksArguments" +} +``` + +`limit` left `required` and gained `"default": 10`. A client that omits it gets `10`, exactly as Python would. + +## Richer schemas with `Field` + +Type hints get you a long way, but sometimes you want to *describe* an argument, or constrain it. + +Wrap the type in `Annotated` and add a Pydantic `Field`: + +```python title="server.py" hl_lines="12-14" +--8<-- "docs_src/tools/tutorial003.py" +``` + +Three new things, all on the parameters: + +* `Field(description=...)`: a per-argument description the model reads alongside the docstring. +* `Field(ge=1, le=50)`: numeric bounds. They land in the schema as `"minimum": 1, "maximum": 50`. +* `Literal["fiction", "non-fiction", "poetry"]`: an enum. The model can only pick one of those. + +!!! check + Constraints are not decoration. Call the tool with `limit=999` and the SDK answers with a + tool error **before your function runs**: + + ```text + Input should be less than or equal to 50 + ``` + + That error goes back to the model as the tool result, and the model reads it and retries with + a valid value. You wrote `le=50` once and got self-correcting agents for free. + +!!! info + If you've used FastAPI or Pydantic, you already know all of this. It's the same `Field`, + the same `Annotated`, the same validation. There is nothing MCP-specific to learn here. + +## A model as a parameter + +When a tool takes more than a couple of arguments, group them into a Pydantic model: + +```python title="server.py" hl_lines="8-11 15" +--8<-- "docs_src/tools/tutorial004.py" +``` + +The `Book` schema is nested inside the tool's input schema (as a `$defs` reference), the model fills it in as a JSON object, and your function receives a **real `Book` instance**, already validated, with `.title`, `.author` and `.year` attributes. + +You can mix and match: plain parameters next to model parameters, nested models, lists of models. It's Pydantic all the way down. + +## `async def` + +If a tool does I/O (calls an API, reads a file, queries a database), declare it `async def` and `await` inside it. The SDK awaits it. + +A plain `def` tool works too: the SDK runs it in a thread so it never blocks the server. + +There is nothing else to configure. + +## Names, titles, and annotations + +Everything the SDK infers, you can override in the decorator: + +```python title="server.py" hl_lines="8-11" +--8<-- "docs_src/tools/tutorial005.py" +``` + +* `title` is a human-readable name for UIs. Clients show *"Search the catalog"* instead of `search_books`. +* `annotations` are behavioural **hints** for the client: + * `read_only_hint=True`: this tool doesn't change anything. + * `open_world_hint=False`: it works on a closed set of things (this catalog), not the open web. + * The other two, `destructive_hint` and `idempotent_hint`, describe a tool that *writes*: may it + delete something, and is calling it twice the same as calling it once? The spec defines both + only for non-read-only tools, so they would say nothing on `search_books`. + +A well-behaved client uses them to decide things like *"do I need to ask the user before running this?"*. They are hints, not security. Never rely on a client honouring them. + +!!! tip + `name=` and `description=` are also accepted by `@mcp.tool()` if you don't want to derive them + from the function name and docstring. Most of the time you do. + +## Recap + +* `@mcp.tool()` on a function makes it a tool. Name from the function, description from the docstring. +* Type hints **are** the input schema. Defaults make arguments optional. +* `Annotated[..., Field(...)]` adds descriptions and constraints; `Literal` adds enums. +* A Pydantic model parameter is how you take a structured "body". +* Bad arguments are rejected for you, with an error the model can read and recover from. +* `async def` for I/O, plain `def` for everything else. + +Next up, **Structured Output**: what happens to the value you `return`. diff --git a/docs_src/__init__.py b/docs_src/__init__.py new file mode 100644 index 0000000000..d19acbc088 --- /dev/null +++ b/docs_src/__init__.py @@ -0,0 +1,7 @@ +"""Complete, runnable source for every code example in `docs/`. + +Each `docs/.md` includes its examples from `docs_src//tutorialNNN.py` +via `--8<--`, and `tests/docs_src/test_.py` imports the same module and +exercises it through the in-memory `mcp.Client`. The file you read in the docs is +the file CI runs. +""" diff --git a/docs_src/asgi/__init__.py b/docs_src/asgi/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/asgi/tutorial001.py b/docs_src/asgi/tutorial001.py new file mode 100644 index 0000000000..800f19b5f2 --- /dev/null +++ b/docs_src/asgi/tutorial001.py @@ -0,0 +1,12 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Notes") + + +@mcp.tool() +def add_note(text: str) -> str: + """Save a note.""" + return f"Saved: {text}" + + +app = mcp.streamable_http_app() diff --git a/docs_src/asgi/tutorial002.py b/docs_src/asgi/tutorial002.py new file mode 100644 index 0000000000..15e5388301 --- /dev/null +++ b/docs_src/asgi/tutorial002.py @@ -0,0 +1,27 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server import MCPServer + +mcp = MCPServer("Notes") + + +@mcp.tool() +def add_note(text: str) -> str: + """Save a note.""" + return f"Saved: {text}" + + +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with mcp.session_manager.run(): + yield + + +app = Starlette( + routes=[Mount("/", app=mcp.streamable_http_app())], + lifespan=lifespan, +) diff --git a/docs_src/asgi/tutorial003.py b/docs_src/asgi/tutorial003.py new file mode 100644 index 0000000000..cea736ec2d --- /dev/null +++ b/docs_src/asgi/tutorial003.py @@ -0,0 +1,39 @@ +from collections.abc import AsyncIterator +from contextlib import AsyncExitStack, asynccontextmanager + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server import MCPServer + +notes = MCPServer("Notes") +tasks = MCPServer("Tasks") + + +@notes.tool() +def add_note(text: str) -> str: + """Save a note.""" + return f"Saved: {text}" + + +@tasks.tool() +def add_task(title: str) -> str: + """Create a task.""" + return f"Created: {title}" + + +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with AsyncExitStack() as stack: + await stack.enter_async_context(notes.session_manager.run()) + await stack.enter_async_context(tasks.session_manager.run()) + yield + + +app = Starlette( + routes=[ + Mount("/notes", app=notes.streamable_http_app()), + Mount("/tasks", app=tasks.streamable_http_app()), + ], + lifespan=lifespan, +) diff --git a/docs_src/asgi/tutorial004.py b/docs_src/asgi/tutorial004.py new file mode 100644 index 0000000000..785a808b28 --- /dev/null +++ b/docs_src/asgi/tutorial004.py @@ -0,0 +1,27 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server import MCPServer + +mcp = MCPServer("Notes") + + +@mcp.tool() +def add_note(text: str) -> str: + """Save a note.""" + return f"Saved: {text}" + + +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with mcp.session_manager.run(): + yield + + +app = Starlette( + routes=[Mount("/notes", app=mcp.streamable_http_app(streamable_http_path="/"))], + lifespan=lifespan, +) diff --git a/docs_src/asgi/tutorial005.py b/docs_src/asgi/tutorial005.py new file mode 100644 index 0000000000..abd627512e --- /dev/null +++ b/docs_src/asgi/tutorial005.py @@ -0,0 +1,52 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.cors import CORSMiddleware +from starlette.routing import Mount + +from mcp.server import MCPServer +from mcp.server.transport_security import TransportSecuritySettings + +mcp = MCPServer("Notes") + + +@mcp.tool() +def add_note(text: str) -> str: + """Save a note.""" + return f"Saved: {text}" + + +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with mcp.session_manager.run(): + yield + + +security = TransportSecuritySettings( + allowed_hosts=["mcp.example.com", "mcp.example.com:*"], + allowed_origins=["https://app.example.com"], +) + +app = Starlette( + routes=[Mount("/", app=mcp.streamable_http_app(transport_security=security))], + middleware=[ + Middleware( + CORSMiddleware, + allow_origins=["https://app.example.com"], + allow_methods=["GET", "POST", "DELETE"], + allow_headers=[ + "Authorization", + "Content-Type", + "Last-Event-ID", + "Mcp-Method", + "Mcp-Name", + "Mcp-Protocol-Version", + "Mcp-Session-Id", + ], + expose_headers=["Mcp-Session-Id"], + ) + ], + lifespan=lifespan, +) diff --git a/docs_src/asgi/tutorial006.py b/docs_src/asgi/tutorial006.py new file mode 100644 index 0000000000..a3554ec443 --- /dev/null +++ b/docs_src/asgi/tutorial006.py @@ -0,0 +1,20 @@ +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +from mcp.server import MCPServer + +mcp = MCPServer("Notes") + + +@mcp.tool() +def add_note(text: str) -> str: + """Save a note.""" + return f"Saved: {text}" + + +@mcp.custom_route("/health", methods=["GET"]) +async def health(request: Request) -> Response: + return JSONResponse({"status": "ok"}) + + +app = mcp.streamable_http_app() diff --git a/docs_src/authorization/__init__.py b/docs_src/authorization/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/authorization/tutorial001.py b/docs_src/authorization/tutorial001.py new file mode 100644 index 0000000000..f15f54fd79 --- /dev/null +++ b/docs_src/authorization/tutorial001.py @@ -0,0 +1,31 @@ +from pydantic import AnyHttpUrl + +from mcp.server import MCPServer +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.server.auth.settings import AuthSettings + +KNOWN_TOKENS = { + "alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"]), +} + + +class StaticTokenVerifier(TokenVerifier): + async def verify_token(self, token: str) -> AccessToken | None: + return KNOWN_TOKENS.get(token) + + +mcp = MCPServer( + "Notes", + token_verifier=StaticTokenVerifier(), + auth=AuthSettings( + issuer_url=AnyHttpUrl("https://auth.example.com"), + resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"), + required_scopes=["notes:read"], + ), +) + + +@mcp.tool() +def list_notes() -> list[str]: + """List every note in the notebook.""" + return ["Buy milk", "Ship the release"] diff --git a/docs_src/authorization/tutorial002.py b/docs_src/authorization/tutorial002.py new file mode 100644 index 0000000000..55b024f2cc --- /dev/null +++ b/docs_src/authorization/tutorial002.py @@ -0,0 +1,35 @@ +from pydantic import AnyHttpUrl + +from mcp.server import MCPServer +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 + +KNOWN_TOKENS = { + "alice-token": AccessToken(token="alice-token", client_id="alice", scopes=["notes:read"]), +} + + +class StaticTokenVerifier(TokenVerifier): + async def verify_token(self, token: str) -> AccessToken | None: + return KNOWN_TOKENS.get(token) + + +mcp = MCPServer( + "Notes", + token_verifier=StaticTokenVerifier(), + auth=AuthSettings( + issuer_url=AnyHttpUrl("https://auth.example.com"), + resource_server_url=AnyHttpUrl("http://127.0.0.1:8000/mcp"), + required_scopes=["notes:read"], + ), +) + + +@mcp.tool() +def whoami() -> str: + """Report which OAuth client is calling.""" + token = get_access_token() + if token is None: + return "anonymous" + return f"{token.client_id} (scopes: {', '.join(token.scopes)})" diff --git a/docs_src/client/__init__.py b/docs_src/client/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/client/tutorial001.py b/docs_src/client/tutorial001.py new file mode 100644 index 0000000000..b020926dda --- /dev/null +++ b/docs_src/client/tutorial001.py @@ -0,0 +1,18 @@ +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop", instructions="Search the catalog before recommending a book.") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +async def main() -> None: + async with Client(mcp) as client: + print(client.server_info) + print(client.server_capabilities) + print(client.protocol_version) + print(client.instructions) diff --git a/docs_src/client/tutorial002.py b/docs_src/client/tutorial002.py new file mode 100644 index 0000000000..a3e379ab44 --- /dev/null +++ b/docs_src/client/tutorial002.py @@ -0,0 +1,20 @@ +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool(title="Search the catalog") +def search_books(query: str, limit: int = 10) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r} (showing up to {limit})." + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.list_tools() + for tool in result.tools: + print(tool.name) + print(tool.title) + print(tool.description) + print(tool.input_schema) diff --git a/docs_src/client/tutorial003.py b/docs_src/client/tutorial003.py new file mode 100644 index 0000000000..1aeab63a49 --- /dev/null +++ b/docs_src/client/tutorial003.py @@ -0,0 +1,33 @@ +from mcp_types import TextContent +from pydantic import BaseModel + +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +class Book(BaseModel): + title: str + author: str + year: int + + +@mcp.tool() +def lookup_book(title: str) -> Book: + """Look up a book by its exact title.""" + if title != "Dune": + raise ValueError(f"No book titled {title!r} in the catalog.") + return Book(title="Dune", author="Frank Herbert", year=1965) + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.call_tool("lookup_book", {"title": "Dune"}) + + for block in result.content: + if isinstance(block, TextContent): + print(block.text) + + print(result.structured_content) + print(result.is_error) diff --git a/docs_src/client/tutorial004.py b/docs_src/client/tutorial004.py new file mode 100644 index 0000000000..fddcde90a5 --- /dev/null +++ b/docs_src/client/tutorial004.py @@ -0,0 +1,32 @@ +from mcp_types import TextResourceContents + +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.resource("catalog://genres") +def genres() -> list[str]: + """The genres the catalog is organised by.""" + return ["fiction", "non-fiction", "poetry"] + + +@mcp.resource("catalog://genres/{genre}") +def books_in_genre(genre: str) -> str: + """Every title we stock in one genre.""" + return f"3 books filed under {genre}." + + +async def main() -> None: + async with Client(mcp) as client: + listed = await client.list_resources() + print([resource.uri for resource in listed.resources]) + + templates = await client.list_resource_templates() + print([template.uri_template for template in templates.resource_templates]) + + result = await client.read_resource("catalog://genres/poetry") + for contents in result.contents: + if isinstance(contents, TextResourceContents): + print(contents.text) diff --git a/docs_src/client/tutorial005.py b/docs_src/client/tutorial005.py new file mode 100644 index 0000000000..ce4d164775 --- /dev/null +++ b/docs_src/client/tutorial005.py @@ -0,0 +1,20 @@ +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.prompt(title="Recommend a book") +def recommend(genre: str) -> str: + """Ask for a recommendation in a genre.""" + return f"Recommend one {genre} book from the catalog and say why." + + +async def main() -> None: + async with Client(mcp) as client: + listed = await client.list_prompts() + print(listed.prompts) + + result = await client.get_prompt("recommend", {"genre": "poetry"}) + for message in result.messages: + print(message.role, message.content) diff --git a/docs_src/client/tutorial006.py b/docs_src/client/tutorial006.py new file mode 100644 index 0000000000..b76b6a0f11 --- /dev/null +++ b/docs_src/client/tutorial006.py @@ -0,0 +1,32 @@ +from mcp_types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference + +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + +GENRES = ["fiction", "non-fiction", "poetry"] + + +@mcp.prompt() +def recommend(genre: str) -> str: + """Ask for a recommendation in a genre.""" + return f"Recommend one {genre} book from the catalog and say why." + + +@mcp.completion() +async def complete_genre( + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, +) -> Completion | None: + return Completion(values=[genre for genre in GENRES if genre.startswith(argument.value)]) + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.complete( + ref=PromptReference(type="ref/prompt", name="recommend"), + argument={"name": "genre", "value": "p"}, + ) + print(result.completion.values) diff --git a/docs_src/client/tutorial007.py b/docs_src/client/tutorial007.py new file mode 100644 index 0000000000..594b052020 --- /dev/null +++ b/docs_src/client/tutorial007.py @@ -0,0 +1,31 @@ +from mcp_types import Tool + +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +@mcp.tool() +def reserve_book(title: str) -> str: + """Put a book on hold.""" + return f"Reserved {title!r}." + + +async def main() -> None: + async with Client(mcp) as client: + tools: list[Tool] = [] + cursor: str | None = None + while True: + page = await client.list_tools(cursor=cursor) + tools.extend(page.tools) + if page.next_cursor is None: + break + cursor = page.next_cursor + print([tool.name for tool in tools]) diff --git a/docs_src/client_callbacks/__init__.py b/docs_src/client_callbacks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/client_callbacks/tutorial001.py b/docs_src/client_callbacks/tutorial001.py new file mode 100644 index 0000000000..154bc4dd81 --- /dev/null +++ b/docs_src/client_callbacks/tutorial001.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel + +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Library") + + +class CardHolder(BaseModel): + name: str + + +@mcp.tool() +async def issue_card(ctx: Context) -> str: + """Issue a new library card.""" + answer = await ctx.elicit("What name should go on the card?", schema=CardHolder) + if answer.action == "accept": + return f"Card issued to {answer.data.name}." + return "No card issued." diff --git a/docs_src/client_callbacks/tutorial002.py b/docs_src/client_callbacks/tutorial002.py new file mode 100644 index 0000000000..2bae985d60 --- /dev/null +++ b/docs_src/client_callbacks/tutorial002.py @@ -0,0 +1,21 @@ +from mcp_types import ElicitRequestParams, ElicitResult + +from mcp import Client +from mcp.client import ClientRequestContext + + +async def handle_elicitation( + context: ClientRequestContext, + params: ElicitRequestParams, +) -> ElicitResult: + return ElicitResult(action="accept", content={"name": "Ada Lovelace"}) + + +async def main() -> None: + async with Client( + "http://127.0.0.1:8000/mcp", + mode="legacy", + elicitation_callback=handle_elicitation, + ) as client: + result = await client.call_tool("issue_card") + print(result.content) diff --git a/docs_src/client_callbacks/tutorial003.py b/docs_src/client_callbacks/tutorial003.py new file mode 100644 index 0000000000..c7a269a36d --- /dev/null +++ b/docs_src/client_callbacks/tutorial003.py @@ -0,0 +1,31 @@ +from mcp_types import ClientCapabilities, ElicitationCapability, RootsCapability, SamplingCapability +from pydantic import BaseModel + +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Library") + + +class CardHolder(BaseModel): + name: str + + +@mcp.tool() +async def issue_card(ctx: Context) -> str: + """Issue a new library card.""" + answer = await ctx.elicit("What name should go on the card?", schema=CardHolder) + if answer.action == "accept": + return f"Card issued to {answer.data.name}." + return "No card issued." + + +@mcp.tool() +def client_features(ctx: Context) -> list[str]: + """Which optional features the connected client declared.""" + declared = { + "elicitation": ClientCapabilities(elicitation=ElicitationCapability()), + "sampling": ClientCapabilities(sampling=SamplingCapability()), + "roots": ClientCapabilities(roots=RootsCapability()), + } + return [name for name, capability in declared.items() if ctx.session.check_client_capability(capability)] diff --git a/docs_src/client_callbacks/tutorial004.py b/docs_src/client_callbacks/tutorial004.py new file mode 100644 index 0000000000..20c9b81870 --- /dev/null +++ b/docs_src/client_callbacks/tutorial004.py @@ -0,0 +1,19 @@ +from mcp_types import CreateMessageRequestParams, CreateMessageResult, ListRootsResult, Root, TextContent +from pydantic import FileUrl + +from mcp.client import ClientRequestContext + + +async def handle_sampling( + context: ClientRequestContext, + params: CreateMessageRequestParams, +) -> CreateMessageResult: + return CreateMessageResult( + role="assistant", + content=TextContent(type="text", text="The answer is 42."), + model="my-llm", + ) + + +async def handle_list_roots(context: ClientRequestContext) -> ListRootsResult: + return ListRootsResult(roots=[Root(uri=FileUrl("file:///home/ada/notebooks"), name="notebooks")]) diff --git a/docs_src/client_transports/__init__.py b/docs_src/client_transports/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/client_transports/tutorial001.py b/docs_src/client_transports/tutorial001.py new file mode 100644 index 0000000000..5920f3d92b --- /dev/null +++ b/docs_src/client_transports/tutorial001.py @@ -0,0 +1,16 @@ +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +async def main() -> None: + async with Client(mcp) as client: + result = await client.call_tool("search_books", {"query": "dune"}) + print(result.structured_content) diff --git a/docs_src/client_transports/tutorial002.py b/docs_src/client_transports/tutorial002.py new file mode 100644 index 0000000000..8350a90556 --- /dev/null +++ b/docs_src/client_transports/tutorial002.py @@ -0,0 +1,7 @@ +from mcp import Client + + +async def main() -> None: + async with Client("http://localhost:8000/mcp") as client: + result = await client.list_tools() + print([tool.name for tool in result.tools]) diff --git a/docs_src/client_transports/tutorial003.py b/docs_src/client_transports/tutorial003.py new file mode 100644 index 0000000000..0134a72561 --- /dev/null +++ b/docs_src/client_transports/tutorial003.py @@ -0,0 +1,16 @@ +import httpx + +from mcp import Client +from mcp.client.streamable_http import streamable_http_client + + +async def main() -> None: + async with httpx.AsyncClient( + headers={"Authorization": "Bearer ..."}, + timeout=httpx.Timeout(30.0, read=300.0), + follow_redirects=True, + ) as http_client: + transport = streamable_http_client("http://localhost:8000/mcp", http_client=http_client) + async with Client(transport) as client: + result = await client.list_tools() + print([tool.name for tool in result.tools]) diff --git a/docs_src/client_transports/tutorial004.py b/docs_src/client_transports/tutorial004.py new file mode 100644 index 0000000000..8e07e09741 --- /dev/null +++ b/docs_src/client_transports/tutorial004.py @@ -0,0 +1,14 @@ +from mcp import Client, StdioServerParameters +from mcp.client.stdio import stdio_client + +server = StdioServerParameters( + command="uv", + args=["run", "server.py"], + env={"BOOKSHOP_API_KEY": "secret"}, +) + + +async def main() -> None: + async with Client(stdio_client(server)) as client: + result = await client.list_tools() + print([tool.name for tool in result.tools]) diff --git a/docs_src/completions/__init__.py b/docs_src/completions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/completions/tutorial001.py b/docs_src/completions/tutorial001.py new file mode 100644 index 0000000000..8326a65e84 --- /dev/null +++ b/docs_src/completions/tutorial001.py @@ -0,0 +1,15 @@ +from mcp.server import MCPServer + +mcp = MCPServer("GitHub Explorer") + + +@mcp.resource("github://repos/{owner}/{repo}") +def github_repo(owner: str, repo: str) -> str: + """A GitHub repository.""" + return f"Repository: {owner}/{repo}" + + +@mcp.prompt() +def review_code(language: str, code: str) -> str: + """Review a snippet of code.""" + return f"Review this {language} code:\n{code}" diff --git a/docs_src/completions/tutorial002.py b/docs_src/completions/tutorial002.py new file mode 100644 index 0000000000..471527792b --- /dev/null +++ b/docs_src/completions/tutorial002.py @@ -0,0 +1,30 @@ +from mcp_types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference + +from mcp.server import MCPServer + +mcp = MCPServer("GitHub Explorer") + +LANGUAGES = ["go", "javascript", "python", "rust", "typescript"] + + +@mcp.resource("github://repos/{owner}/{repo}") +def github_repo(owner: str, repo: str) -> str: + """A GitHub repository.""" + return f"Repository: {owner}/{repo}" + + +@mcp.prompt() +def review_code(language: str, code: str) -> str: + """Review a snippet of code.""" + return f"Review this {language} code:\n{code}" + + +@mcp.completion() +async def handle_completion( + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, +) -> Completion | None: + if isinstance(ref, PromptReference) and argument.name == "language": + return Completion(values=[lang for lang in LANGUAGES if lang.startswith(argument.value)]) + return None diff --git a/docs_src/completions/tutorial003.py b/docs_src/completions/tutorial003.py new file mode 100644 index 0000000000..3cbe21bcd6 --- /dev/null +++ b/docs_src/completions/tutorial003.py @@ -0,0 +1,40 @@ +from mcp_types import Completion, CompletionArgument, CompletionContext, PromptReference, ResourceTemplateReference + +from mcp.server import MCPServer + +mcp = MCPServer("GitHub Explorer") + +LANGUAGES = ["go", "javascript", "python", "rust", "typescript"] + +REPOS_BY_OWNER = { + "modelcontextprotocol": ["python-sdk", "typescript-sdk", "inspector"], + "pydantic": ["pydantic", "pydantic-ai", "logfire"], +} + + +@mcp.resource("github://repos/{owner}/{repo}") +def github_repo(owner: str, repo: str) -> str: + """A GitHub repository.""" + return f"Repository: {owner}/{repo}" + + +@mcp.prompt() +def review_code(language: str, code: str) -> str: + """Review a snippet of code.""" + return f"Review this {language} code:\n{code}" + + +@mcp.completion() +async def handle_completion( + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, +) -> Completion | None: + if isinstance(ref, PromptReference) and argument.name == "language": + return Completion(values=[lang for lang in LANGUAGES if lang.startswith(argument.value)]) + if isinstance(ref, ResourceTemplateReference) and argument.name == "repo": + if context is None or context.arguments is None: + return None + repos = REPOS_BY_OWNER.get(context.arguments.get("owner", ""), []) + return Completion(values=[repo for repo in repos if repo.startswith(argument.value)]) + return None diff --git a/docs_src/context/__init__.py b/docs_src/context/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/context/tutorial001.py b/docs_src/context/tutorial001.py new file mode 100644 index 0000000000..1666ca5d22 --- /dev/null +++ b/docs_src/context/tutorial001.py @@ -0,0 +1,10 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str, ctx: Context) -> str: + """Search the catalog by title or author.""" + return f"[request {ctx.request_id}] Found 3 books matching {query!r}." diff --git a/docs_src/context/tutorial002.py b/docs_src/context/tutorial002.py new file mode 100644 index 0000000000..f85caf00f2 --- /dev/null +++ b/docs_src/context/tutorial002.py @@ -0,0 +1,17 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Bookshop") + + +@mcp.resource("catalog://genres") +def genres() -> str: + """The genres the catalog is organised into.""" + return "fiction, non-fiction, poetry" + + +@mcp.tool() +async def describe_catalog(ctx: Context) -> str: + """Describe how the catalog is organised.""" + [contents] = await ctx.read_resource("catalog://genres") + return f"The catalog is organised into: {contents.content}" diff --git a/docs_src/context/tutorial003.py b/docs_src/context/tutorial003.py new file mode 100644 index 0000000000..d3a741d3c2 --- /dev/null +++ b/docs_src/context/tutorial003.py @@ -0,0 +1,17 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Bookshop") + + +def recommend_book(genre: str) -> str: + """Recommend a book in the given genre.""" + return f"In {genre}, try 'Dune'." + + +@mcp.tool() +async def enable_recommendations(ctx: Context) -> str: + """Switch on the recommendation tool.""" + mcp.add_tool(recommend_book) + await ctx.session.send_tool_list_changed() + return "Recommendations are now available." diff --git a/docs_src/elicitation/__init__.py b/docs_src/elicitation/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/elicitation/tutorial001.py b/docs_src/elicitation/tutorial001.py new file mode 100644 index 0000000000..083194833c --- /dev/null +++ b/docs_src/elicitation/tutorial001.py @@ -0,0 +1,26 @@ +from pydantic import BaseModel, Field + +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Bistro") + + +class AlternativeDate(BaseModel): + accept_alternative: bool = Field(description="Try another date?") + date: str = Field(default="2025-12-26", description="Alternative date (YYYY-MM-DD)") + + +@mcp.tool() +async def book_table(date: str, party_size: int, ctx: Context) -> str: + """Book a table at the bistro.""" + if date != "2025-12-25": + return f"Booked a table for {party_size} on {date}." + + result = await ctx.elicit( + message=f"No tables for {party_size} on {date}. Would you like to try another date?", + schema=AlternativeDate, + ) + if result.action == "accept" and result.data.accept_alternative: + return await book_table(result.data.date, party_size, ctx) + return "No booking made." diff --git a/docs_src/elicitation/tutorial002.py b/docs_src/elicitation/tutorial002.py new file mode 100644 index 0000000000..b8e3456b0c --- /dev/null +++ b/docs_src/elicitation/tutorial002.py @@ -0,0 +1,24 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Bistro") + + +@mcp.tool() +async def pay_deposit(booking_id: str, ctx: Context) -> str: + """Take the deposit that confirms a booking.""" + result = await ctx.elicit_url( + message="A 20 EUR deposit confirms your booking.", + url=f"https://pay.example.com/deposit/{booking_id}", + elicitation_id=f"deposit-{booking_id}", + ) + if result.action == "accept": + return "Complete the payment in your browser." + return "No deposit taken. The booking expires in one hour." + + +@mcp.tool() +async def confirm_deposit(booking_id: str, ctx: Context) -> str: + """Record a payment reported by the payment provider.""" + await ctx.session.send_elicit_complete(f"deposit-{booking_id}") + return f"Deposit received for booking {booking_id}." diff --git a/docs_src/elicitation/tutorial003.py b/docs_src/elicitation/tutorial003.py new file mode 100644 index 0000000000..f6bb4020b6 --- /dev/null +++ b/docs_src/elicitation/tutorial003.py @@ -0,0 +1,22 @@ +from mcp_types import ElicitRequestParams, ElicitRequestURLParams, ElicitResult + +from mcp import Client +from mcp.client import ClientRequestContext + + +async def handle_elicitation(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + if isinstance(params, ElicitRequestURLParams): + print(f"Open this link to continue: {params.url}") + return ElicitResult(action="accept") + print(params.message) + return ElicitResult(action="accept", content={"accept_alternative": True, "date": "2025-12-27"}) + + +async def main() -> None: + async with Client( + "http://127.0.0.1:8000/mcp", + mode="legacy", + elicitation_callback=handle_elicitation, + ) as client: + result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2}) + print(result.content) diff --git a/docs_src/first_steps/__init__.py b/docs_src/first_steps/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/first_steps/tutorial001.py b/docs_src/first_steps/tutorial001.py new file mode 100644 index 0000000000..7c3e7a5a1e --- /dev/null +++ b/docs_src/first_steps/tutorial001.py @@ -0,0 +1,21 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Demo") + + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + +@mcp.resource("greeting://{name}") +def greeting(name: str) -> str: + """Greet someone by name.""" + return f"Hello, {name}!" + + +@mcp.prompt() +def summarize(text: str) -> str: + """Summarize a piece of text in one sentence.""" + return f"Summarize the following text in one sentence:\n\n{text}" diff --git a/docs_src/handling_errors/__init__.py b/docs_src/handling_errors/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/handling_errors/tutorial001.py b/docs_src/handling_errors/tutorial001.py new file mode 100644 index 0000000000..003ea94669 --- /dev/null +++ b/docs_src/handling_errors/tutorial001.py @@ -0,0 +1,13 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + +CATALOG = {"Dune": "Frank Herbert", "Neuromancer": "William Gibson"} + + +@mcp.tool() +def get_author(title: str) -> str: + """Look up the author of a book in the catalog.""" + if title not in CATALOG: + raise ValueError(f"No book titled {title!r} in the catalog.") + return CATALOG[title] diff --git a/docs_src/handling_errors/tutorial002.py b/docs_src/handling_errors/tutorial002.py new file mode 100644 index 0000000000..b45c67e967 --- /dev/null +++ b/docs_src/handling_errors/tutorial002.py @@ -0,0 +1,16 @@ +from mcp_types import INVALID_PARAMS + +from mcp import MCPError +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + +CATALOG = {"Dune": "Frank Herbert", "Neuromancer": "William Gibson"} + + +@mcp.tool() +def get_author(title: str) -> str: + """Look up the author of a book in the catalog.""" + if title not in CATALOG: + raise MCPError(code=INVALID_PARAMS, message=f"No book titled {title!r} in the catalog.") + return CATALOG[title] diff --git a/docs_src/handling_errors/tutorial003.py b/docs_src/handling_errors/tutorial003.py new file mode 100644 index 0000000000..55f2c4f07f --- /dev/null +++ b/docs_src/handling_errors/tutorial003.py @@ -0,0 +1,14 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver.exceptions import ResourceNotFoundError + +mcp = MCPServer("Bookshop") + +CATALOG = {"Dune": "Frank Herbert", "Neuromancer": "William Gibson"} + + +@mcp.resource("books://{title}") +def book(title: str) -> str: + """The catalog entry for one book.""" + if title not in CATALOG: + raise ResourceNotFoundError(f"No book titled {title!r} in the catalog.") + return f"{title} by {CATALOG[title]}" diff --git a/docs_src/index/__init__.py b/docs_src/index/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/index/tutorial001.py b/docs_src/index/tutorial001.py new file mode 100644 index 0000000000..d975940fef --- /dev/null +++ b/docs_src/index/tutorial001.py @@ -0,0 +1,15 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Demo") + + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b + + +@mcp.resource("greeting://{name}") +def greeting(name: str) -> str: + """Greet someone by name.""" + return f"Hello, {name}!" diff --git a/docs_src/lifespan/__init__.py b/docs_src/lifespan/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/lifespan/tutorial001.py b/docs_src/lifespan/tutorial001.py new file mode 100644 index 0000000000..9b7ba3db54 --- /dev/null +++ b/docs_src/lifespan/tutorial001.py @@ -0,0 +1,41 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + + +class Database: + @classmethod + async def connect(cls) -> "Database": + return cls() + + async def disconnect(self) -> None: ... + + def query(self) -> int: + return 3 + + +@dataclass +class AppContext: + db: Database + + +@asynccontextmanager +async def app_lifespan(server: MCPServer) -> AsyncIterator[AppContext]: + db = await Database.connect() + try: + yield AppContext(db=db) + finally: + await db.disconnect() + + +mcp = MCPServer("Bookshop", lifespan=app_lifespan) + + +@mcp.tool() +def count_books(genre: str, ctx: Context[AppContext]) -> str: + """Count the books in a genre.""" + db = ctx.request_context.lifespan_context.db + return f"{db.query()} books in {genre!r}." diff --git a/docs_src/lifespan/tutorial002.py b/docs_src/lifespan/tutorial002.py new file mode 100644 index 0000000000..6ed3c6a374 --- /dev/null +++ b/docs_src/lifespan/tutorial002.py @@ -0,0 +1,44 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + + +class Database: + def __init__(self) -> None: + self.connected = False + + async def connect(self) -> None: + self.connected = True + + async def disconnect(self) -> None: + self.connected = False + + +@dataclass +class AppContext: + db: Database + + +database = Database() + + +@asynccontextmanager +async def app_lifespan(server: MCPServer) -> AsyncIterator[AppContext]: + await database.connect() + try: + yield AppContext(db=database) + finally: + await database.disconnect() + + +mcp = MCPServer("Bookshop", lifespan=app_lifespan) + + +@mcp.tool() +def database_status(ctx: Context[AppContext]) -> str: + """Report whether the database connection is up.""" + db = ctx.request_context.lifespan_context.db + return "connected" if db.connected else "disconnected" diff --git a/docs_src/logging/__init__.py b/docs_src/logging/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/logging/tutorial001.py b/docs_src/logging/tutorial001.py new file mode 100644 index 0000000000..ee90d220c1 --- /dev/null +++ b/docs_src/logging/tutorial001.py @@ -0,0 +1,14 @@ +import logging + +from mcp.server import MCPServer + +logger = logging.getLogger(__name__) + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + logger.info("Searching for %r", query) + return f"Found 3 books matching {query!r}." diff --git a/docs_src/lowlevel/__init__.py b/docs_src/lowlevel/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/lowlevel/tutorial001.py b/docs_src/lowlevel/tutorial001.py new file mode 100644 index 0000000000..999c707f25 --- /dev/null +++ b/docs_src/lowlevel/tutorial001.py @@ -0,0 +1,33 @@ +from mcp_types import ( + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) + +from mcp.server import Server, ServerRequestContext + +SEARCH_BOOKS = Tool( + name="search_books", + description="Search the catalog by title or author.", + input_schema={ + "type": "object", + "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}}, + "required": ["query", "limit"], + }, +) + + +async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[SEARCH_BOOKS]) + + +async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + args = params.arguments or {} + text = f"Found 3 books matching {args['query']!r} (showing up to {args['limit']})." + return CallToolResult(content=[TextContent(type="text", text=text)]) + + +server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool) diff --git a/docs_src/lowlevel/tutorial002.py b/docs_src/lowlevel/tutorial002.py new file mode 100644 index 0000000000..d3033f6013 --- /dev/null +++ b/docs_src/lowlevel/tutorial002.py @@ -0,0 +1,48 @@ +from mcp_types import ( + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) + +from mcp.server import Server, ServerRequestContext + +SEARCH_BOOKS = Tool( + name="search_books", + description="Search the catalog by title or author.", + input_schema={ + "type": "object", + "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}}, + "required": ["query", "limit"], + }, +) + +ADD_BOOK = Tool( + name="add_book", + description="Add a book to the catalog.", + input_schema={ + "type": "object", + "properties": {"title": {"type": "string"}, "author": {"type": "string"}, "year": {"type": "integer"}}, + "required": ["title", "author", "year"], + }, +) + + +async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[SEARCH_BOOKS, ADD_BOOK]) + + +async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + args = params.arguments or {} + if params.name == "search_books": + text = f"Found 3 books matching {args['query']!r} (showing up to {args['limit']})." + elif params.name == "add_book": + text = f"Added {args['title']!r} by {args['author']} ({args['year']})." + else: + raise ValueError(f"Unknown tool: {params.name}") + return CallToolResult(content=[TextContent(type="text", text=text)]) + + +server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool) diff --git a/docs_src/lowlevel/tutorial003.py b/docs_src/lowlevel/tutorial003.py new file mode 100644 index 0000000000..f350397006 --- /dev/null +++ b/docs_src/lowlevel/tutorial003.py @@ -0,0 +1,41 @@ +from mcp_types import ( + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) + +from mcp.server import Server, ServerRequestContext + +SEARCH_BOOKS = Tool( + name="search_books", + description="Search the catalog by title or author.", + input_schema={ + "type": "object", + "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}}, + "required": ["query", "limit"], + }, + output_schema={ + "type": "object", + "properties": {"matches": {"type": "integer"}, "query": {"type": "string"}}, + "required": ["matches", "query"], + }, +) + + +async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[SEARCH_BOOKS]) + + +async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + args = params.arguments or {} + data = {"matches": 3, "query": args["query"]} + return CallToolResult( + content=[TextContent(type="text", text=f"Found 3 books matching {args['query']!r}.")], + structured_content=data, + ) + + +server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool) diff --git a/docs_src/lowlevel/tutorial004.py b/docs_src/lowlevel/tutorial004.py new file mode 100644 index 0000000000..18b0bef8f6 --- /dev/null +++ b/docs_src/lowlevel/tutorial004.py @@ -0,0 +1,42 @@ +from mcp_types import ( + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) + +from mcp.server import Server, ServerRequestContext + +SEARCH_BOOKS = Tool( + name="search_books", + description="Search the catalog by title or author.", + input_schema={ + "type": "object", + "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}}, + "required": ["query", "limit"], + }, + output_schema={ + "type": "object", + "properties": {"matches": {"type": "integer"}, "query": {"type": "string"}}, + "required": ["matches", "query"], + }, +) + + +async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[SEARCH_BOOKS]) + + +async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + args = params.arguments or {} + data = {"matches": 3, "query": args["query"]} + return CallToolResult( + content=[TextContent(type="text", text=f"Found 3 books matching {args['query']!r}.")], + structured_content=data, + _meta={"bookshop/record_ids": ["bk_17", "bk_42", "bk_99"]}, + ) + + +server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool) diff --git a/docs_src/lowlevel/tutorial005.py b/docs_src/lowlevel/tutorial005.py new file mode 100644 index 0000000000..e33077ecec --- /dev/null +++ b/docs_src/lowlevel/tutorial005.py @@ -0,0 +1,51 @@ +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from mcp_types import ( + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) + +from mcp.server import Server, ServerRequestContext + + +@dataclass +class Catalog: + books: list[str] + + def search(self, query: str) -> list[str]: + return [title for title in self.books if query.lower() in title.lower()] + + +@asynccontextmanager +async def lifespan(server: Server[Catalog]) -> AsyncIterator[Catalog]: + yield Catalog(books=["Dune", "Dune Messiah", "Children of Dune"]) + + +SEARCH_BOOKS = Tool( + name="search_books", + description="Search the catalog by title or author.", + input_schema={ + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, +) + + +async def list_tools(ctx: ServerRequestContext[Catalog], params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[SEARCH_BOOKS]) + + +async def call_tool(ctx: ServerRequestContext[Catalog], params: CallToolRequestParams) -> CallToolResult: + matches = ctx.lifespan_context.search((params.arguments or {})["query"]) + text = f"Found {len(matches)} books: {', '.join(matches)}." + return CallToolResult(content=[TextContent(type="text", text=text)]) + + +server = Server("Bookshop", lifespan=lifespan, on_list_tools=list_tools, on_call_tool=call_tool) diff --git a/docs_src/lowlevel/tutorial006.py b/docs_src/lowlevel/tutorial006.py new file mode 100644 index 0000000000..601fe5c576 --- /dev/null +++ b/docs_src/lowlevel/tutorial006.py @@ -0,0 +1,48 @@ +from mcp_types import ( + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + RequestParams, + TextContent, + Tool, +) +from pydantic import BaseModel + +from mcp.server import Server, ServerRequestContext + +SEARCH_BOOKS = Tool( + name="search_books", + description="Search the catalog by title or author.", + input_schema={ + "type": "object", + "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}}, + "required": ["query", "limit"], + }, +) + + +async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[SEARCH_BOOKS]) + + +async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + args = params.arguments or {} + text = f"Found 3 books matching {args['query']!r} (showing up to {args['limit']})." + return CallToolResult(content=[TextContent(type="text", text=text)]) + + +class ReindexParams(RequestParams): + full: bool = False + + +class ReindexResult(BaseModel): + indexed: int + + +async def reindex(ctx: ServerRequestContext, params: ReindexParams) -> ReindexResult: + return ReindexResult(indexed=3) + + +server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool) +server.add_request_handler("bookshop/reindex", ReindexParams, reindex) diff --git a/docs_src/media/__init__.py b/docs_src/media/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/media/tutorial001.py b/docs_src/media/tutorial001.py new file mode 100644 index 0000000000..646817fbb0 --- /dev/null +++ b/docs_src/media/tutorial001.py @@ -0,0 +1,16 @@ +import base64 + +from mcp.server import MCPServer +from mcp.server.mcpserver import Image + +mcp = MCPServer("Brand kit") + +LOGO_PNG = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGOQ9bsBAAHPAURf8l/aAAAAAElFTkSuQmCC" +) + + +@mcp.tool() +def logo() -> Image: + """The brand logo as a PNG.""" + return Image(data=LOGO_PNG, format="png") diff --git a/docs_src/media/tutorial002.py b/docs_src/media/tutorial002.py new file mode 100644 index 0000000000..c98bddcd03 --- /dev/null +++ b/docs_src/media/tutorial002.py @@ -0,0 +1,24 @@ +import base64 + +from mcp.server import MCPServer +from mcp.server.mcpserver import Audio, Image + +mcp = MCPServer("Brand kit") + +LOGO_PNG = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGOQ9bsBAAHPAURf8l/aAAAAAElFTkSuQmCC" +) + +CHIME_WAV = base64.b64decode("UklGRjQAAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YRAAAAAAAAAAAAAAAAAAAAAAAAAA") + + +@mcp.tool() +def logo() -> Image: + """The brand logo as a PNG.""" + return Image(data=LOGO_PNG, format="png") + + +@mcp.tool() +def chime() -> Audio: + """The notification chime as a WAV.""" + return Audio(data=CHIME_WAV, format="wav") diff --git a/docs_src/media/tutorial003.py b/docs_src/media/tutorial003.py new file mode 100644 index 0000000000..a06e6dfcd1 --- /dev/null +++ b/docs_src/media/tutorial003.py @@ -0,0 +1,20 @@ +from mcp_types import Icon + +from mcp.server import MCPServer + +LOGO = Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"]) +PALETTE = Icon(src="https://example.com/palette.svg", mime_type="image/svg+xml", sizes=["any"]) + +mcp = MCPServer("Brand kit", icons=[LOGO]) + + +@mcp.tool(icons=[PALETTE]) +def palette() -> list[str]: + """The brand colour palette as hex codes.""" + return ["#1d4ed8", "#f59e0b", "#10b981"] + + +@mcp.resource("brand://guidelines", icons=[LOGO]) +def guidelines() -> str: + """How to use the brand assets.""" + return "Use the primary colour for calls to action." diff --git a/docs_src/middleware/__init__.py b/docs_src/middleware/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/middleware/tutorial001.py b/docs_src/middleware/tutorial001.py new file mode 100644 index 0000000000..71be62db8f --- /dev/null +++ b/docs_src/middleware/tutorial001.py @@ -0,0 +1,50 @@ +import logging +import time + +from mcp_types import ( + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) + +from mcp.server import Server, ServerRequestContext +from mcp.server.context import CallNext, HandlerResult + +logger = logging.getLogger(__name__) + + +async def on_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult( + tools=[ + Tool( + name="search_books", + description="Search the catalog by title or author.", + input_schema={ + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + ) + ] + ) + + +async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + query = (params.arguments or {})["query"] + return CallToolResult(content=[TextContent(type="text", text=f"Found 3 books matching {query!r}.")]) + + +async def log_timing(ctx: ServerRequestContext, call_next: CallNext) -> HandlerResult: + start = time.perf_counter() + try: + return await call_next(ctx) + finally: + elapsed_ms = (time.perf_counter() - start) * 1000 + logger.info("%s took %.1f ms", ctx.method, elapsed_ms) + + +server = Server("Bookshop", on_list_tools=on_list_tools, on_call_tool=on_call_tool) +server.middleware.append(log_timing) diff --git a/docs_src/mrtr/__init__.py b/docs_src/mrtr/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/mrtr/tutorial001.py b/docs_src/mrtr/tutorial001.py new file mode 100644 index 0000000000..c0f4153cab --- /dev/null +++ b/docs_src/mrtr/tutorial001.py @@ -0,0 +1,53 @@ +from mcp_types import ( + CallToolRequestParams, + CallToolResult, + ElicitRequest, + ElicitRequestFormParams, + ElicitResult, + InputRequiredResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) + +from mcp.server import Server, ServerRequestContext + +ASK_REGION = ElicitRequest( + params=ElicitRequestFormParams( + message="Which region should the database live in?", + requested_schema={ + "type": "object", + "properties": {"region": {"type": "string"}}, + "required": ["region"], + }, + ) +) + + +async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult( + tools=[ + Tool( + name="provision", + description="Provision a database. Asks which region to put it in.", + input_schema={ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + }, + ) + ] + ) + + +async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult | InputRequiredResult: + answer = (params.input_responses or {}).get("region") + if not isinstance(answer, ElicitResult) or answer.content is None: + return InputRequiredResult(input_requests={"region": ASK_REGION}, request_state="provision-v1") + name = (params.arguments or {})["name"] + text = f"Provisioned {name!r} in {answer.content['region']}." + return CallToolResult(content=[TextContent(type="text", text=text)]) + + +server = Server("Provisioner", on_list_tools=list_tools, on_call_tool=call_tool) diff --git a/docs_src/mrtr/tutorial002.py b/docs_src/mrtr/tutorial002.py new file mode 100644 index 0000000000..a6556fe365 --- /dev/null +++ b/docs_src/mrtr/tutorial002.py @@ -0,0 +1,23 @@ +from mcp_types import CallToolResult, ElicitRequest, ElicitResult, InputRequest, InputRequiredResult, InputResponse + +from mcp import Client + + +def fulfil(request: InputRequest) -> InputResponse: + if not isinstance(request, ElicitRequest): + raise NotImplementedError(f"this client cannot answer a {request.method!r} request") + return ElicitResult(action="accept", content={"region": "eu-west-1"}) + + +async def provision(client: Client, name: str) -> CallToolResult: + result = await client.call_tool("provision", {"name": name}, allow_input_required=True) + while isinstance(result, InputRequiredResult): + responses = {key: fulfil(request) for key, request in (result.input_requests or {}).items()} + result = await client.call_tool( + "provision", + {"name": name}, + input_responses=responses, + request_state=result.request_state, + allow_input_required=True, + ) + return result diff --git a/docs_src/oauth_clients/__init__.py b/docs_src/oauth_clients/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/oauth_clients/tutorial001.py b/docs_src/oauth_clients/tutorial001.py new file mode 100644 index 0000000000..4360379a29 --- /dev/null +++ b/docs_src/oauth_clients/tutorial001.py @@ -0,0 +1,62 @@ +from urllib.parse import parse_qs, urlparse + +import httpx +from pydantic import AnyUrl + +from mcp import Client +from mcp.client.auth import AuthorizationCodeResult, OAuthClientProvider +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken + + +class InMemoryTokenStorage: + def __init__(self) -> None: + self.tokens: OAuthToken | None = None + self.client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self.tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + self.tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self.client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self.client_info = client_info + + +async def open_browser(authorization_url: str) -> None: + print(f"Visit: {authorization_url}") + + +async def wait_for_callback() -> AuthorizationCodeResult: + redirect_url = input("Paste the URL you were redirected to: ") + params = parse_qs(urlparse(redirect_url).query) + return AuthorizationCodeResult( + code=params["code"][0], + state=params["state"][0], + iss=params["iss"][0] if "iss" in params else None, + ) + + +oauth = OAuthClientProvider( + server_url="http://localhost:8001/mcp", + client_metadata=OAuthClientMetadata( + client_name="Bookshop Agent", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + scope="user", + ), + storage=InMemoryTokenStorage(), + redirect_handler=open_browser, + callback_handler=wait_for_callback, +) + + +async def main() -> None: + async with httpx.AsyncClient(auth=oauth, follow_redirects=True) as http_client: + transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client) + async with Client(transport) as client: + result = await client.list_tools() + print([tool.name for tool in result.tools]) diff --git a/docs_src/oauth_clients/tutorial002.py b/docs_src/oauth_clients/tutorial002.py new file mode 100644 index 0000000000..b5b052c962 --- /dev/null +++ b/docs_src/oauth_clients/tutorial002.py @@ -0,0 +1,41 @@ +import httpx + +from mcp import Client +from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken + + +class InMemoryTokenStorage: + def __init__(self) -> None: + self.tokens: OAuthToken | None = None + self.client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self.tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + self.tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self.client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self.client_info = client_info + + +oauth = ClientCredentialsOAuthProvider( + server_url="http://localhost:8001/mcp", + storage=InMemoryTokenStorage(), + client_id="reporting-agent", + client_secret="...", + scopes="user", +) + + +async def main() -> None: + async with httpx.AsyncClient(auth=oauth, follow_redirects=True) as http_client: + transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client) + async with Client(transport) as client: + result = await client.list_tools() + print([tool.name for tool in result.tools]) diff --git a/docs_src/pagination/__init__.py b/docs_src/pagination/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/pagination/tutorial001.py b/docs_src/pagination/tutorial001.py new file mode 100644 index 0000000000..2ad4b9453f --- /dev/null +++ b/docs_src/pagination/tutorial001.py @@ -0,0 +1,20 @@ +from typing import Any + +from mcp_types import ListResourcesResult, PaginatedRequestParams, Resource + +from mcp.server import Server, ServerRequestContext + +BOOKS = [f"book-{n}" for n in range(1, 101)] + +PAGE_SIZE = 10 + + +async def list_books(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListResourcesResult: + start = 0 if params is None or params.cursor is None else int(params.cursor) + end = start + PAGE_SIZE + page = [Resource(uri=f"books://catalog/{name}", name=name) for name in BOOKS[start:end]] + next_cursor = str(end) if end < len(BOOKS) else None + return ListResourcesResult(resources=page, next_cursor=next_cursor) + + +server = Server("Bookshop", on_list_resources=list_books) diff --git a/docs_src/pagination/tutorial002.py b/docs_src/pagination/tutorial002.py new file mode 100644 index 0000000000..cacb796e8b --- /dev/null +++ b/docs_src/pagination/tutorial002.py @@ -0,0 +1,34 @@ +from typing import Any + +from mcp_types import ListResourcesResult, PaginatedRequestParams, Resource + +from mcp import Client +from mcp.server import Server, ServerRequestContext + +BOOKS = [f"book-{n}" for n in range(1, 101)] + +PAGE_SIZE = 10 + + +async def list_books(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListResourcesResult: + start = 0 if params is None or params.cursor is None else int(params.cursor) + end = start + PAGE_SIZE + page = [Resource(uri=f"books://catalog/{name}", name=name) for name in BOOKS[start:end]] + next_cursor = str(end) if end < len(BOOKS) else None + return ListResourcesResult(resources=page, next_cursor=next_cursor) + + +server = Server("Bookshop", on_list_resources=list_books) + + +async def main() -> None: + async with Client(server) as client: + resources: list[Resource] = [] + cursor: str | None = None + while True: + page = await client.list_resources(cursor=cursor) + resources.extend(page.resources) + if page.next_cursor is None: + break + cursor = page.next_cursor + print(f"{len(resources)} resources") diff --git a/docs_src/progress/__init__.py b/docs_src/progress/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/progress/tutorial001.py b/docs_src/progress/tutorial001.py new file mode 100644 index 0000000000..afa9d496cf --- /dev/null +++ b/docs_src/progress/tutorial001.py @@ -0,0 +1,12 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +async def import_catalog(urls: list[str], ctx: Context) -> str: + """Import book records from a list of catalog URLs.""" + for done, url in enumerate(urls, start=1): + await ctx.report_progress(done, total=len(urls), message=f"Imported {url}") + return f"Imported {len(urls)} records." diff --git a/docs_src/progress/tutorial002.py b/docs_src/progress/tutorial002.py new file mode 100644 index 0000000000..270d9fc97a --- /dev/null +++ b/docs_src/progress/tutorial002.py @@ -0,0 +1,21 @@ +from collections.abc import AsyncIterator + +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Bookshop") + + +async def fetch_records(feed_url: str) -> AsyncIterator[str]: + for title in ("Dune", "Neuromancer", "Hyperion"): + yield f"{feed_url}#{title}" + + +@mcp.tool() +async def import_feed(feed_url: str, ctx: Context) -> str: + """Import every record a catalog feed yields.""" + imported = 0 + async for record in fetch_records(feed_url): + imported += 1 + await ctx.report_progress(imported, message=f"Imported {record}") + return f"Imported {imported} records." diff --git a/docs_src/prompts/__init__.py b/docs_src/prompts/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/prompts/tutorial001.py b/docs_src/prompts/tutorial001.py new file mode 100644 index 0000000000..8ae504225c --- /dev/null +++ b/docs_src/prompts/tutorial001.py @@ -0,0 +1,9 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Code Helper") + + +@mcp.prompt() +def review_code(code: str) -> str: + """Review a piece of code.""" + return f"Please review this code:\n\n{code}" diff --git a/docs_src/prompts/tutorial002.py b/docs_src/prompts/tutorial002.py new file mode 100644 index 0000000000..3db7862b88 --- /dev/null +++ b/docs_src/prompts/tutorial002.py @@ -0,0 +1,20 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, UserMessage + +mcp = MCPServer("Code Helper") + + +@mcp.prompt() +def review_code(code: str) -> str: + """Review a piece of code.""" + return f"Please review this code:\n\n{code}" + + +@mcp.prompt() +def debug_error(error: str) -> list[Message]: + """Start a debugging conversation.""" + return [ + UserMessage("I'm seeing this error:"), + UserMessage(error), + AssistantMessage("I'll help debug that. What have you tried so far?"), + ] diff --git a/docs_src/prompts/tutorial003.py b/docs_src/prompts/tutorial003.py new file mode 100644 index 0000000000..63600c6180 --- /dev/null +++ b/docs_src/prompts/tutorial003.py @@ -0,0 +1,16 @@ +from typing import Annotated + +from pydantic import Field + +from mcp.server import MCPServer + +mcp = MCPServer("Code Helper") + + +@mcp.prompt(title="Code review") +def review_code( + code: Annotated[str, Field(description="The code to review.")], + language: Annotated[str, Field(description="The language the code is written in.")] = "python", +) -> str: + """Review a piece of code.""" + return f"Please review this {language} code:\n\n{code}" diff --git a/docs_src/protocol_versions/__init__.py b/docs_src/protocol_versions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/protocol_versions/tutorial001.py b/docs_src/protocol_versions/tutorial001.py new file mode 100644 index 0000000000..23570e3bcf --- /dev/null +++ b/docs_src/protocol_versions/tutorial001.py @@ -0,0 +1,15 @@ +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +async def main() -> None: + async with Client(mcp) as client: + print(client.protocol_version) diff --git a/docs_src/protocol_versions/tutorial002.py b/docs_src/protocol_versions/tutorial002.py new file mode 100644 index 0000000000..5c00c8b4ee --- /dev/null +++ b/docs_src/protocol_versions/tutorial002.py @@ -0,0 +1,15 @@ +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +async def main() -> None: + async with Client(mcp, mode="legacy") as client: + print(client.protocol_version) diff --git a/docs_src/protocol_versions/tutorial003.py b/docs_src/protocol_versions/tutorial003.py new file mode 100644 index 0000000000..5fd32ac109 --- /dev/null +++ b/docs_src/protocol_versions/tutorial003.py @@ -0,0 +1,15 @@ +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +async def main() -> None: + async with Client(mcp, mode="2026-07-28") as client: + print(client.protocol_version) diff --git a/docs_src/protocol_versions/tutorial004.py b/docs_src/protocol_versions/tutorial004.py new file mode 100644 index 0000000000..c1b8fc6b5b --- /dev/null +++ b/docs_src/protocol_versions/tutorial004.py @@ -0,0 +1,19 @@ +from mcp import Client +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +async def main() -> None: + async with Client(mcp) as client: + saved = client.session.discover_result + + async with Client(mcp, mode="2026-07-28", prior_discover=saved) as client: + print(client.protocol_version) + print(client.server_info.name) diff --git a/docs_src/resources/__init__.py b/docs_src/resources/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/resources/tutorial001.py b/docs_src/resources/tutorial001.py new file mode 100644 index 0000000000..99c6d89050 --- /dev/null +++ b/docs_src/resources/tutorial001.py @@ -0,0 +1,9 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.resource("config://app") +def get_config() -> str: + """The active shop configuration.""" + return "theme=dark\nlanguage=en" diff --git a/docs_src/resources/tutorial002.py b/docs_src/resources/tutorial002.py new file mode 100644 index 0000000000..557fa92410 --- /dev/null +++ b/docs_src/resources/tutorial002.py @@ -0,0 +1,15 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.resource("config://app") +def get_config() -> str: + """The active shop configuration.""" + return "theme=dark\nlanguage=en" + + +@mcp.resource("users://{user_id}/profile") +def get_user_profile(user_id: str) -> str: + """A customer's profile.""" + return f"User {user_id}: 12 orders since 2021." diff --git a/docs_src/resources/tutorial003.py b/docs_src/resources/tutorial003.py new file mode 100644 index 0000000000..10881d3a4b --- /dev/null +++ b/docs_src/resources/tutorial003.py @@ -0,0 +1,23 @@ +import base64 + +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.resource("docs://readme", mime_type="text/markdown") +def readme() -> str: + """How to use this server.""" + return "# Bookshop\n\nSearch the catalog with the `search_books` tool." + + +@mcp.resource("stats://catalog", mime_type="application/json") +def catalog_stats() -> dict[str, int]: + """Live counts for the catalog.""" + return {"books": 1204, "authors": 391} + + +@mcp.resource("covers://placeholder", mime_type="image/gif") +def placeholder_cover() -> bytes: + """A 1x1 transparent GIF, shown when a book has no cover.""" + return base64.b64decode("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7") diff --git a/docs_src/run/__init__.py b/docs_src/run/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/run/tutorial001.py b/docs_src/run/tutorial001.py new file mode 100644 index 0000000000..c8cd92854e --- /dev/null +++ b/docs_src/run/tutorial001.py @@ -0,0 +1,13 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +if __name__ == "__main__": + mcp.run() diff --git a/docs_src/run/tutorial002.py b/docs_src/run/tutorial002.py new file mode 100644 index 0000000000..686dc44296 --- /dev/null +++ b/docs_src/run/tutorial002.py @@ -0,0 +1,13 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +if __name__ == "__main__": + mcp.run(transport="streamable-http", port=3001) diff --git a/docs_src/run/tutorial003.py b/docs_src/run/tutorial003.py new file mode 100644 index 0000000000..19a9c11a69 --- /dev/null +++ b/docs_src/run/tutorial003.py @@ -0,0 +1,13 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop", log_level="DEBUG") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." + + +if __name__ == "__main__": + mcp.run() diff --git a/docs_src/session_groups/__init__.py b/docs_src/session_groups/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/session_groups/tutorial001.py b/docs_src/session_groups/tutorial001.py new file mode 100644 index 0000000000..08bec6d66e --- /dev/null +++ b/docs_src/session_groups/tutorial001.py @@ -0,0 +1,15 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Library") + + +@mcp.tool() +def search(query: str) -> str: + """Search the library catalog.""" + return f"3 books match {query!r}." + + +@mcp.resource("library://hours") +def hours() -> str: + """When the library is open.""" + return "Mon-Fri 09:00-17:00" diff --git a/docs_src/session_groups/tutorial002.py b/docs_src/session_groups/tutorial002.py new file mode 100644 index 0000000000..154c279c05 --- /dev/null +++ b/docs_src/session_groups/tutorial002.py @@ -0,0 +1,9 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Web") + + +@mcp.tool() +def search(query: str) -> str: + """Search the web.""" + return f"12 pages match {query!r}." diff --git a/docs_src/session_groups/tutorial003.py b/docs_src/session_groups/tutorial003.py new file mode 100644 index 0000000000..f4360f5cb7 --- /dev/null +++ b/docs_src/session_groups/tutorial003.py @@ -0,0 +1,19 @@ +import asyncio + +from mcp import ClientSessionGroup, StdioServerParameters + + +async def main() -> None: + library = StdioServerParameters(command="uv", args=["run", "mcp", "run", "library_server.py"]) + web = StdioServerParameters(command="uv", args=["run", "mcp", "run", "web_server.py"]) + + async with ClientSessionGroup() as group: + await group.connect_to_server(library) + await group.connect_to_server(web) + + result = await group.call_tool("search", {"query": "model context protocol"}) + print(result.structured_content) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs_src/session_groups/tutorial004.py b/docs_src/session_groups/tutorial004.py new file mode 100644 index 0000000000..7d107669f7 --- /dev/null +++ b/docs_src/session_groups/tutorial004.py @@ -0,0 +1,26 @@ +import asyncio + +from mcp_types import Implementation + +from mcp import ClientSessionGroup, StdioServerParameters + + +def by_server(name: str, server_info: Implementation) -> str: + return f"{server_info.name}.{name}" + + +async def main() -> None: + library = StdioServerParameters(command="uv", args=["run", "mcp", "run", "library_server.py"]) + web = StdioServerParameters(command="uv", args=["run", "mcp", "run", "web_server.py"]) + + async with ClientSessionGroup(component_name_hook=by_server) as group: + await group.connect_to_server(library) + await group.connect_to_server(web) + + print(sorted(group.tools)) + result = await group.call_tool("Web.search", {"query": "model context protocol"}) + print(result.structured_content) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs_src/structured_output/__init__.py b/docs_src/structured_output/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/structured_output/tutorial001.py b/docs_src/structured_output/tutorial001.py new file mode 100644 index 0000000000..191c9bd8fd --- /dev/null +++ b/docs_src/structured_output/tutorial001.py @@ -0,0 +1,11 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + +READINGS = {"London": 17, "Cairo": 34, "Reykjavik": 4} + + +@mcp.tool() +def get_temperature(city: str) -> int: + """Current temperature in a city, in whole degrees Celsius.""" + return READINGS[city] diff --git a/docs_src/structured_output/tutorial002.py b/docs_src/structured_output/tutorial002.py new file mode 100644 index 0000000000..8ea0ef998b --- /dev/null +++ b/docs_src/structured_output/tutorial002.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel, Field + +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + + +class WeatherData(BaseModel): + temperature: float = Field(description="Degrees Celsius.") + humidity: float = Field(description="Relative humidity, 0 to 1.") + conditions: str + + +@mcp.tool() +def get_weather(city: str) -> WeatherData: + """Current weather for a city.""" + return WeatherData(temperature=16.2, humidity=0.83, conditions="Overcast") diff --git a/docs_src/structured_output/tutorial003.py b/docs_src/structured_output/tutorial003.py new file mode 100644 index 0000000000..783dc40316 --- /dev/null +++ b/docs_src/structured_output/tutorial003.py @@ -0,0 +1,17 @@ +from typing import TypedDict + +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + + +class WeatherData(TypedDict): + temperature: float + humidity: float + conditions: str + + +@mcp.tool() +def get_weather(city: str) -> WeatherData: + """Current weather for a city.""" + return WeatherData(temperature=16.2, humidity=0.83, conditions="Overcast") diff --git a/docs_src/structured_output/tutorial004.py b/docs_src/structured_output/tutorial004.py new file mode 100644 index 0000000000..fb4c6e29d7 --- /dev/null +++ b/docs_src/structured_output/tutorial004.py @@ -0,0 +1,18 @@ +from dataclasses import dataclass + +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + + +@dataclass +class WeatherData: + temperature: float + humidity: float + conditions: str + + +@mcp.tool() +def get_weather(city: str) -> WeatherData: + """Current weather for a city.""" + return WeatherData(temperature=16.2, humidity=0.83, conditions="Overcast") diff --git a/docs_src/structured_output/tutorial005.py b/docs_src/structured_output/tutorial005.py new file mode 100644 index 0000000000..7bbaae500f --- /dev/null +++ b/docs_src/structured_output/tutorial005.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel + +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + + +class WeatherData(BaseModel): + temperature: float + humidity: float + conditions: str + + +@mcp.tool() +def get_forecast(city: str, days: int) -> list[WeatherData]: + """Daily forecast for a city.""" + return [WeatherData(temperature=16.2 + day, humidity=0.83, conditions="Overcast") for day in range(days)] diff --git a/docs_src/structured_output/tutorial006.py b/docs_src/structured_output/tutorial006.py new file mode 100644 index 0000000000..205abd2c17 --- /dev/null +++ b/docs_src/structured_output/tutorial006.py @@ -0,0 +1,11 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + +READINGS = {"London": 16.2, "Cairo": 34.1, "Reykjavik": 4.4} + + +@mcp.tool() +def get_temperatures(cities: list[str]) -> dict[str, float]: + """Current temperature for each city, in degrees Celsius.""" + return {city: READINGS[city] for city in cities} diff --git a/docs_src/structured_output/tutorial007.py b/docs_src/structured_output/tutorial007.py new file mode 100644 index 0000000000..c889a302c0 --- /dev/null +++ b/docs_src/structured_output/tutorial007.py @@ -0,0 +1,21 @@ +import json + +from pydantic import BaseModel + +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + +UPSTREAM = {"London": '{"temperature": 16.2, "conditions": "Overcast"}'} + + +class WeatherData(BaseModel): + temperature: float + humidity: float + conditions: str + + +@mcp.tool() +def get_weather(city: str) -> WeatherData: + """Current weather for a city.""" + return json.loads(UPSTREAM[city]) diff --git a/docs_src/structured_output/tutorial008.py b/docs_src/structured_output/tutorial008.py new file mode 100644 index 0000000000..b0e992398d --- /dev/null +++ b/docs_src/structured_output/tutorial008.py @@ -0,0 +1,9 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + + +@mcp.tool(structured_output=False) +def weather_report(city: str) -> str: + """A human-readable weather report for a city.""" + return f"{city}: 17 degrees, overcast, light rain easing by evening." diff --git a/docs_src/structured_output/tutorial009.py b/docs_src/structured_output/tutorial009.py new file mode 100644 index 0000000000..c5a2d17d0e --- /dev/null +++ b/docs_src/structured_output/tutorial009.py @@ -0,0 +1,15 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + + +class Station: + def __init__(self, name: str, online: bool): + self.name = name + self.online = online + + +@mcp.tool() +def get_station(name: str) -> Station: + """Look up a weather station by name.""" + return Station(name=name, online=True) diff --git a/docs_src/testing/__init__.py b/docs_src/testing/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/testing/tutorial001.py b/docs_src/testing/tutorial001.py new file mode 100644 index 0000000000..ab7938b890 --- /dev/null +++ b/docs_src/testing/tutorial001.py @@ -0,0 +1,9 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Calculator") + + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b diff --git a/docs_src/tools/__init__.py b/docs_src/tools/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/tools/tutorial001.py b/docs_src/tools/tutorial001.py new file mode 100644 index 0000000000..b324ec8140 --- /dev/null +++ b/docs_src/tools/tutorial001.py @@ -0,0 +1,9 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str, limit: int) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r} (showing up to {limit})." diff --git a/docs_src/tools/tutorial002.py b/docs_src/tools/tutorial002.py new file mode 100644 index 0000000000..1ade813788 --- /dev/null +++ b/docs_src/tools/tutorial002.py @@ -0,0 +1,9 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str, limit: int = 10) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r} (showing up to {limit})." diff --git a/docs_src/tools/tutorial003.py b/docs_src/tools/tutorial003.py new file mode 100644 index 0000000000..1bc415a593 --- /dev/null +++ b/docs_src/tools/tutorial003.py @@ -0,0 +1,18 @@ +from typing import Annotated, Literal + +from pydantic import Field + +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books( + query: Annotated[str, Field(description="Title or author to search for.")], + limit: Annotated[int, Field(ge=1, le=50, description="Maximum number of results.")] = 10, + genre: Literal["fiction", "non-fiction", "poetry"] | None = None, +) -> str: + """Search the catalog by title or author.""" + where = f" in {genre}" if genre else "" + return f"Found 3 books matching {query!r}{where} (showing up to {limit})." diff --git a/docs_src/tools/tutorial004.py b/docs_src/tools/tutorial004.py new file mode 100644 index 0000000000..a52f06643f --- /dev/null +++ b/docs_src/tools/tutorial004.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel, Field + +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +class Book(BaseModel): + title: str + author: str + year: int = Field(ge=1450, description="Year of first publication.") + + +@mcp.tool() +def add_book(book: Book) -> str: + """Add a book to the catalog.""" + return f"Added {book.title!r} by {book.author} ({book.year})." diff --git a/docs_src/tools/tutorial005.py b/docs_src/tools/tutorial005.py new file mode 100644 index 0000000000..f9fcbce966 --- /dev/null +++ b/docs_src/tools/tutorial005.py @@ -0,0 +1,14 @@ +from mcp_types import ToolAnnotations + +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool( + title="Search the catalog", + annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False), +) +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." diff --git a/mkdocs.yml b/mkdocs.yml index cb89faf0f0..d3bbba2119 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,5 @@ -site_name: MCP Server -site_description: MCP Server +site_name: MCP Python SDK +site_description: The official Python SDK for the Model Context Protocol strict: true repo_name: modelcontextprotocol/python-sdk @@ -11,14 +11,42 @@ site_url: https://py.sdk.modelcontextprotocol.io/v2/ # copyright: © Model Context Protocol 2025 to present nav: - - Introduction: index.md + - MCP Python SDK: index.md - Installation: installation.md + - Tutorial - User Guide: + - tutorial/index.md + - First steps: tutorial/first-steps.md + - Tools: tutorial/tools.md + - Structured Output: tutorial/structured-output.md + - Resources: tutorial/resources.md + - Prompts: tutorial/prompts.md + - The Context: tutorial/context.md + - Handling errors: tutorial/handling-errors.md + - Lifespan: tutorial/lifespan.md + - Media: tutorial/media.md + - Completions: tutorial/completions.md + - Elicitation: tutorial/elicitation.md + - Progress: tutorial/progress.md + - Logging: tutorial/logging.md + - Testing: tutorial/testing.md + - Running your server: + - run/index.md + - ASGI: run/asgi.md + - The Client: + - client/index.md + - Client callbacks: client/callbacks.md + - Client transports: client/transports.md + - Protocol versions: client/protocol-versions.md + - Advanced: + - Multi-round-trip requests: advanced/multi-round-trip.md + - The low-level Server: advanced/low-level-server.md + - Pagination: advanced/pagination.md + - Middleware: advanced/middleware.md + - Authorization: advanced/authorization.md + - OAuth clients: advanced/oauth-clients.md + - Session groups: advanced/session-groups.md + - Deprecated features: advanced/deprecated.md - Migration Guide: migration.md - - Documentation: - - Concepts: concepts.md - - Low-Level Server: low-level-server.md - - Authorization: authorization.md - - Testing: testing.md - API Reference: api/ theme: @@ -78,7 +106,13 @@ markdown_extensions: - pymdownx.critic - pymdownx.mark - pymdownx.superfences - - pymdownx.snippets + # Code examples live as complete, importable, tested files under `docs_src/` + # and are included into pages with `--8<-- "docs_src//tutorialNNN.py"` + # (resolved against the repo root, the extension's default base_path). + # `check_paths: true` + `strict: true` turn a renamed/deleted example into a + # build failure instead of a silently empty code block. + - pymdownx.snippets: + check_paths: true - pymdownx.tilde - pymdownx.inlinehilite - pymdownx.highlight: @@ -103,6 +137,7 @@ markdown_extensions: watch: - src/mcp + - docs_src plugins: - search diff --git a/pyproject.toml b/pyproject.toml index e7ef057f3b..22ba4d4f4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -137,6 +137,7 @@ include = [ "src/mcp", "src/mcp-types/mcp_types", "tests", + "docs_src", "examples/stories", "examples/servers", "examples/snippets", @@ -166,6 +167,9 @@ executionEnvironments = [ { root = "examples/servers", extraPaths = [ "examples/servers/simple-auth", ], reportUnusedFunction = false }, + # docs_src/ holds the complete, runnable code examples included into docs/*.md. + # Decorated (@mcp.tool/...) module-level functions are never called by name. + { root = "docs_src", reportUnusedFunction = false }, ] [tool.ruff] diff --git a/scripts/update_readme_snippets.py b/scripts/update_readme_snippets.py index 8a534e5cb5..413c980175 100755 --- a/scripts/update_readme_snippets.py +++ b/scripts/update_readme_snippets.py @@ -43,11 +43,13 @@ def process_snippet_block(match: re.Match[str], check_mode: bool = False) -> str file_path = match.group(2) try: - # Read the entire file + # Read the entire file. A missing source file must be fatal: a "Warning" + # that returns the stale block lets --check pass with exit 0, so a + # renamed or deleted snippet is invisible to CI. SystemExit deliberately + # escapes the `except Exception` below. file = Path(file_path) if not file.exists(): - print(f"Warning: File not found: {file_path}") - return full_match + sys.exit(f"Error: snippet-source file not found: {file_path}") code = file.read_text().rstrip() github_url = get_github_url(file_path) @@ -69,7 +71,7 @@ def process_snippet_block(match: re.Match[str], check_mode: bool = False) -> str if existing_content is not None: existing_lines = existing_content.strip().split("\n") # Find code between ```python and ``` - code_lines = [] + code_lines: list[str] = [] in_code = False for line in existing_lines: if line.strip() == "```python": diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index 308f28d1c2..f5ca07400b 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -207,7 +207,7 @@ async def main(): def __post_init__(self) -> None: 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')" + f" ({self.mode!r} is a handshake-era version; use mode='legacy')" if self.mode in HANDSHAKE_PROTOCOL_VERSIONS else "" ) diff --git a/src/mcp/server/mcpserver/context.py b/src/mcp/server/mcpserver/context.py index aeb91fdfe4..15b6fd4ad4 100644 --- a/src/mcp/server/mcpserver/context.py +++ b/src/mcp/server/mcpserver/context.py @@ -85,7 +85,7 @@ def mcp_server(self) -> MCPServer: @property def request_context(self) -> ServerRequestContext[LifespanContextT, RequestT]: """Access to the underlying request context.""" - if self._request_context is None: # pragma: no cover + if self._request_context is None: raise ValueError("Context is not available outside of a request") return self._request_context diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 60b8b8473d..028c6a4753 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -193,7 +193,7 @@ def __init__( raise ValueError("Cannot specify both auth_server_provider and token_verifier") if not auth_server_provider and not token_verifier: # pragma: no cover raise ValueError("Must specify either auth_server_provider or token_verifier when auth is enabled") - elif auth_server_provider or token_verifier: # pragma: no cover + elif auth_server_provider or token_verifier: raise ValueError("Cannot specify auth_server_provider or token_verifier without auth settings") self._auth_server_provider = auth_server_provider diff --git a/src/mcp/server/mcpserver/utilities/types.py b/src/mcp/server/mcpserver/utilities/types.py index 63f94d4cc7..937a7fa9b6 100644 --- a/src/mcp/server/mcpserver/utilities/types.py +++ b/src/mcp/server/mcpserver/utilities/types.py @@ -27,7 +27,7 @@ def __init__( def _get_mime_type(self) -> str: """Get MIME type from format or guess from file extension.""" - if self._format: # pragma: no cover + if self._format: return f"image/{self._format.lower()}" if self.path: @@ -39,14 +39,14 @@ def _get_mime_type(self) -> str: ".gif": "image/gif", ".webp": "image/webp", }.get(suffix, "application/octet-stream") - return "image/png" # pragma: no cover # default for raw binary data + return "image/png" # default for raw binary data def to_image_content(self) -> ImageContent: """Convert to MCP ImageContent.""" if self.path: with open(self.path, "rb") as f: data = base64.b64encode(f.read()).decode() - elif self.data is not None: # pragma: no cover + elif self.data is not None: data = base64.b64encode(self.data).decode() else: # pragma: no cover raise ValueError("No image data available") @@ -73,7 +73,7 @@ def __init__( def _get_mime_type(self) -> str: """Get MIME type from format or guess from file extension.""" - if self._format: # pragma: no cover + if self._format: return f"audio/{self._format.lower()}" if self.path: @@ -86,14 +86,14 @@ def _get_mime_type(self) -> str: ".aac": "audio/aac", ".m4a": "audio/mp4", }.get(suffix, "application/octet-stream") - return "audio/wav" # pragma: no cover # default for raw binary data + return "audio/wav" # default for raw binary data def to_audio_content(self) -> AudioContent: """Convert to MCP AudioContent.""" if self.path: with open(self.path, "rb") as f: data = base64.b64encode(f.read()).decode() - elif self.data is not None: # pragma: no cover + elif self.data is not None: data = base64.b64encode(self.data).decode() else: # pragma: no cover raise ValueError("No audio data available") diff --git a/tests/client/test_client.py b/tests/client/test_client.py index f869d1f1bc..e8557bcec4 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -509,7 +509,7 @@ def test_client_rejects_handshake_era_mode_at_construction() -> None: `__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'"): + 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/docs_src/__init__.py b/tests/docs_src/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/docs_src/test_asgi.py b/tests/docs_src/test_asgi.py new file mode 100644 index 0000000000..93aa502428 --- /dev/null +++ b/tests/docs_src/test_asgi.py @@ -0,0 +1,213 @@ +"""`docs/run/asgi.md`: every claim the page makes, proved against the real SDK.""" + +import inspect + +import httpx +import pytest +from mcp_types import TextContent +from starlette.applications import Starlette +from starlette.middleware.cors import CORSMiddleware +from starlette.requests import Request +from starlette.responses import PlainTextResponse, Response +from starlette.routing import Mount, Route + +from docs_src.asgi import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006 +from mcp import Client +from mcp.server import MCPServer + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_streamable_http_app_is_a_starlette_app_with_one_route() -> None: + """tutorial001: the factory returns a Starlette application with a single route at `/mcp`.""" + (route,) = tutorial001.app.routes + assert isinstance(route, Route) + assert route.path == "/mcp" + + +async def test_the_server_behind_the_app_is_unchanged() -> None: + """tutorial001: wrapping the server in an ASGI app changes nothing about its tools.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("add_note", {"text": "milk"}) + assert result.content == [TextContent(type="text", text="Saved: milk")] + assert result.structured_content == {"result": "Saved: milk"} + + +async def test_streamable_http_app_takes_runs_options_except_port() -> None: + """The tip: every `run("streamable-http", ...)` option is here except `port`. `host` is one of them.""" + parameters = set(inspect.signature(MCPServer.streamable_http_app).parameters) - {"self"} + assert parameters == { + "streamable_http_path", + "json_response", + "stateless_http", + "event_store", + "retry_interval", + "transport_security", + "host", + } + + +async def test_a_request_before_the_session_manager_runs_is_rejected() -> None: + """The `!!! check`: nothing starts the session manager except its lifespan.""" + transport = httpx.ASGITransport(app=tutorial001.app) + async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http: + with pytest.raises(RuntimeError, match=r"Task group is not initialized\. Make sure to use run\(\)\."): + await http.post("/mcp") + + +async def test_mounting_at_the_root_keeps_the_default_path() -> None: + """tutorial002: `Mount("/")` plus the default `streamable_http_path` leaves the endpoint at `/mcp`.""" + (mount,) = tutorial002.app.routes + assert isinstance(mount, Mount) + assert mount.path == "" + (inner,) = mount.routes + assert isinstance(inner, Route) + assert inner.path == "/mcp" + + +async def test_a_root_mount_swallows_routes_listed_after_it() -> None: + """The mounting bullet: `Mount("/")` matches every path, so your own routes go before it in the list.""" + + async def about(request: Request) -> Response: + return PlainTextResponse("about") + + mcp_app = MCPServer("Notes").streamable_http_app() + listed_after = Starlette(routes=[Mount("/", app=mcp_app), Route("/about", about)]) + listed_before = Starlette(routes=[Route("/about", about), Mount("/", app=mcp_app)]) + + transport = httpx.ASGITransport(app=listed_after) + async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http: + assert (await http.get("/about")).status_code == 404 + + transport = httpx.ASGITransport(app=listed_before) + async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http: + assert (await http.get("/about")).status_code == 200 + + +async def test_the_host_lifespan_enters_the_session_manager() -> None: + """tutorial002: the host app's lifespan owns `session_manager.run()` and starts and stops cleanly.""" + async with tutorial002.lifespan(tutorial002.app): + async with Client(tutorial002.mcp) as client: + result = await client.call_tool("add_note", {"text": "milk"}) + assert result.structured_content == {"result": "Saved: milk"} + + +async def test_two_servers_get_two_mounts() -> None: + """tutorial003: each server is mounted under its own prefix, each still ending in `/mcp`.""" + notes_mount, tasks_mount = tutorial003.app.routes + assert isinstance(notes_mount, Mount) + assert isinstance(tasks_mount, Mount) + assert notes_mount.path == "/notes" + assert tasks_mount.path == "/tasks" + + +async def test_one_lifespan_starts_both_session_managers() -> None: + """tutorial003: a single `AsyncExitStack` lifespan runs both managers; both servers answer.""" + async with tutorial003.lifespan(tutorial003.app): + async with Client(tutorial003.notes) as client: + notes_result = await client.call_tool("add_note", {"text": "milk"}) + assert notes_result.structured_content == {"result": "Saved: milk"} + async with Client(tutorial003.tasks) as client: + tasks_result = await client.call_tool("add_task", {"title": "ship"}) + assert tasks_result.structured_content == {"result": "Created: ship"} + + +async def test_streamable_http_path_moves_the_endpoint_to_the_mount_prefix() -> None: + """tutorial004: `streamable_http_path="/"` makes the `Mount` prefix the whole public path.""" + (mount,) = tutorial004.app.routes + assert isinstance(mount, Mount) + assert mount.path == "/notes" + (inner,) = mount.routes + assert isinstance(inner, Route) + assert inner.path == "/" + + +async def test_cors_exposes_the_session_id_header() -> None: + """tutorial005: the browser origin gets the three MCP methods and can read `Mcp-Session-Id`.""" + (middleware,) = tutorial005.app.user_middleware + assert middleware.cls is CORSMiddleware + transport = httpx.ASGITransport(app=tutorial005.app) + async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http: + preflight = await http.options( + "/mcp", + headers={"Origin": "https://app.example.com", "Access-Control-Request-Method": "POST"}, + ) + assert preflight.status_code == 200 + assert preflight.headers["access-control-allow-methods"] == "GET, POST, DELETE" + + response = await http.get("/not-the-endpoint", headers={"Origin": "https://app.example.com"}) + assert response.headers["access-control-allow-origin"] == "https://app.example.com" + assert response.headers["access-control-expose-headers"] == "Mcp-Session-Id" + + +async def test_custom_route_lands_next_to_the_mcp_endpoint() -> None: + """tutorial006: `@mcp.custom_route()` adds a plain Starlette route to the returned app.""" + mcp_route, health_route = tutorial006.app.routes + assert isinstance(mcp_route, Route) + assert isinstance(health_route, Route) + assert mcp_route.path == "/mcp" + assert health_route.path == "/health" + + +async def test_the_health_check_answers_outside_the_protocol() -> None: + """tutorial006: `GET /health` is ordinary HTTP, with no session manager and no MCP.""" + transport = httpx.ASGITransport(app=tutorial006.app) + async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1") as http: + response = await http.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +INITIALIZE = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "b", "version": "1"}}, +} +MCP_HEADERS = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"} + + +async def test_the_default_app_is_localhost_only() -> None: + """The "Localhost only" section: with no `transport_security=`, the app answers a real hostname + with the page's `421 Invalid Host header` and a foreign Origin with `403 Invalid Origin header`, + before any MCP code runs.""" + bare = MCPServer("Notes") + app = bare.streamable_http_app() + transport = httpx.ASGITransport(app=app) + async with bare.session_manager.run(): + async with httpx.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http: + wrong_host = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS) + async with httpx.AsyncClient(transport=transport, base_url="http://localhost:8000") as http: + wrong_origin = await http.post( + "/mcp", json=INITIALIZE, headers={**MCP_HEADERS, "Origin": "https://app.example.com"} + ) + assert (wrong_host.status_code, wrong_host.text) == (421, "Invalid Host header") + assert (wrong_origin.status_code, wrong_origin.text) == (403, "Invalid Origin header") + + +async def test_the_documented_browser_origin_works_end_to_end() -> None: + """tutorial005: the page's scenario for real. The public hostname, the browser origin, a + realistic preflight naming the `Mcp-*` headers, then the actual request.""" + transport = httpx.ASGITransport(app=tutorial005.app) + async with tutorial005.lifespan(tutorial005.app): + async with httpx.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http: + preflight = await http.options( + "/mcp", + headers={ + "Origin": "https://app.example.com", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "content-type, mcp-protocol-version, mcp-session-id", + }, + ) + assert preflight.status_code == 200 + allowed = {h.strip().lower() for h in preflight.headers["access-control-allow-headers"].split(",")} + assert {"content-type", "mcp-protocol-version", "mcp-session-id"} <= allowed + + response = await http.post( + "/mcp", json=INITIALIZE, headers={**MCP_HEADERS, "Origin": "https://app.example.com"} + ) + assert response.status_code == 200 + assert response.headers["mcp-session-id"] + assert response.headers["access-control-allow-origin"] == "https://app.example.com" + assert response.headers["access-control-expose-headers"] == "Mcp-Session-Id" diff --git a/tests/docs_src/test_authorization.py b/tests/docs_src/test_authorization.py new file mode 100644 index 0000000000..4c7554ed75 --- /dev/null +++ b/tests/docs_src/test_authorization.py @@ -0,0 +1,98 @@ +"""`docs/advanced/authorization.md`: every claim the page makes, proved against the real SDK.""" + +import httpx +import pytest +from inline_snapshot import snapshot +from mcp_types import TextContent +from starlette.routing import Route + +from docs_src.authorization import tutorial001, tutorial002 +from mcp import Client +from mcp.client.streamable_http import streamable_http_client +from mcp.server import MCPServer + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_the_in_memory_client_never_authenticates() -> None: + """tutorial001: `Client(mcp)` connects to the server object directly, so no token is ever checked.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("list_notes", {}) + assert not result.is_error + assert result.structured_content == {"result": ["Buy milk", "Ship the release"]} + + +async def test_token_verifier_and_auth_settings_must_travel_together() -> None: + """tutorial001: passing `token_verifier=` without `auth=` is refused at construction time.""" + with pytest.raises(ValueError, match="Cannot specify auth_server_provider or token_verifier without auth settings"): + MCPServer("Notes", token_verifier=tutorial001.StaticTokenVerifier()) + + +async def test_the_app_grows_a_protected_resource_metadata_route() -> None: + """tutorial001: the HTTP app has the `/mcp` endpoint plus the RFC 9728 well-known route.""" + mcp_route, metadata_route = tutorial001.mcp.streamable_http_app().routes + assert isinstance(mcp_route, Route) + assert isinstance(metadata_route, Route) + assert mcp_route.path == "/mcp" + assert metadata_route.path == "/.well-known/oauth-protected-resource/mcp" + + +async def test_the_metadata_document_is_built_from_auth_settings() -> None: + """tutorial001: `GET` on the well-known route returns the Protected Resource Metadata the page shows.""" + transport = httpx.ASGITransport(app=tutorial001.mcp.streamable_http_app()) + async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client: + response = await http_client.get("/.well-known/oauth-protected-resource/mcp") + assert response.status_code == 200 + assert response.json() == snapshot( + { + "resource": "http://127.0.0.1:8000/mcp", + "authorization_servers": ["https://auth.example.com/"], + "scopes_supported": ["notes:read"], + "bearer_methods_supported": ["header"], + } + ) + + +async def test_a_request_without_a_token_never_reaches_the_protocol() -> None: + """The `!!! check`: no `Authorization` header means a 401 that points at the metadata document.""" + transport = httpx.ASGITransport(app=tutorial001.mcp.streamable_http_app()) + async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client: + response = await http_client.post("/mcp", json={}) + assert response.status_code == 401 + assert response.json() == {"error": "invalid_token", "error_description": "Authentication required"} + assert response.headers["www-authenticate"] == ( + 'Bearer error="invalid_token", error_description="Authentication required", ' + 'resource_metadata="http://127.0.0.1:8000/.well-known/oauth-protected-resource/mcp"' + ) + + +async def test_a_token_the_verifier_rejects_gets_the_same_401() -> None: + """tutorial001: `verify_token` returning `None` and a missing header are indistinguishable to the caller.""" + transport = httpx.ASGITransport(app=tutorial001.mcp.streamable_http_app()) + async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http_client: + response = await http_client.post("/mcp", json={}, headers={"Authorization": "Bearer not-a-real-token"}) + assert response.status_code == 401 + assert response.json() == {"error": "invalid_token", "error_description": "Authentication required"} + + +async def test_get_access_token_is_none_outside_an_authenticated_request() -> None: + """tutorial002: in-memory there is no HTTP layer, so `get_access_token()` returns `None`.""" + async with Client(tutorial002.mcp) as client: + result = await client.call_tool("whoami", {}) + assert result.structured_content == {"result": "anonymous"} + + +async def test_get_access_token_is_the_callers_access_token() -> None: + """tutorial002: over Streamable HTTP a valid bearer token reaches the tool as an `AccessToken`.""" + url = "http://127.0.0.1:8000/mcp" + transport = httpx.ASGITransport(app=tutorial002.mcp.streamable_http_app()) + headers = {"Authorization": "Bearer alice-token"} + async with tutorial002.mcp.session_manager.run(): + async with ( + httpx.AsyncClient(transport=transport, base_url=url, headers=headers) as http_client, + Client(streamable_http_client(url, http_client=http_client)) as client, + ): + result = await client.call_tool("whoami", {}) + assert result.content == [TextContent(type="text", text="alice (scopes: notes:read)")] + assert result.structured_content == {"result": "alice (scopes: notes:read)"} diff --git a/tests/docs_src/test_client.py b/tests/docs_src/test_client.py new file mode 100644 index 0000000000..97cc327dcb --- /dev/null +++ b/tests/docs_src/test_client.py @@ -0,0 +1,182 @@ +"""`docs/client/index.md`: every claim the page makes, proved against the real SDK.""" + +import pytest +from inline_snapshot import snapshot +from mcp_types import Prompt, PromptArgument, PromptReference, TextContent, TextResourceContents, Tool + +from docs_src.client import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006, tutorial007 +from mcp import Client, MCPError +from mcp.shared.metadata_utils import get_display_name + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_every_client_program_on_the_page_runs(capsys: pytest.CaptureFixture[str]) -> None: + """Each `main()` is the literal client program shown on the page; all seven run clean in-memory.""" + await tutorial001.main() + await tutorial002.main() + await tutorial003.main() + await tutorial004.main() + await tutorial005.main() + await tutorial006.main() + await tutorial007.main() + assert "Bookshop" in capsys.readouterr().out + + +async def test_connected_properties_are_populated_inside_the_block() -> None: + """tutorial001: server_info, server_capabilities, protocol_version and instructions are just there.""" + async with Client(tutorial001.mcp) as client: + assert client.server_info.name == "Bookshop" + assert client.protocol_version == "2026-07-28" + assert client.instructions == "Search the catalog before recommending a book." + assert client.server_capabilities.tools is not None + assert client.server_capabilities.logging is None + + +async def test_a_client_is_not_reusable_after_the_block_ends() -> None: + """tutorial001: `async with` is the whole lifecycle. Construct a new Client per connection.""" + client = Client(tutorial001.mcp) + async with client: + assert client.server_info.name == "Bookshop" + with pytest.raises(RuntimeError, match="cannot reenter"): + await client.__aenter__() + + +async def test_list_tools_returns_the_full_definition() -> None: + """tutorial002: each listed tool carries its name, title, description and the derived input schema.""" + async with Client(tutorial002.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.name == "search_books" + assert tool.title == "Search the catalog" + assert tool.description == "Search the catalog by title or author." + assert tool.input_schema == snapshot( + { + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"}, + }, + "required": ["query"], + "title": "search_booksArguments", + } + ) + + +def test_get_display_name_prefers_the_title() -> None: + """The `!!! tip`: get_display_name returns the title when there is one and the name when there isn't.""" + titled = Tool(name="search_books", title="Search the catalog", input_schema={"type": "object"}) + untitled = Tool(name="search_books", input_schema={"type": "object"}) + assert get_display_name(titled) == "Search the catalog" + assert get_display_name(untitled) == "search_books" + + +async def test_call_tool_result_has_three_things_to_read() -> None: + """tutorial003: content for the model, structured_content for code, is_error for both.""" + async with Client(tutorial003.mcp) as client: + result = await client.call_tool("lookup_book", {"title": "Dune"}) + assert not result.is_error + (block,) = result.content + assert isinstance(block, TextContent) + assert block.text == '{\n "title": "Dune",\n "author": "Frank Herbert",\n "year": 1965\n}' + assert result.structured_content == {"title": "Dune", "author": "Frank Herbert", "year": 1965} + + +async def test_a_raising_tool_is_a_result_not_an_exception() -> None: + """tutorial003 `!!! check`: the exception's message comes back in content with is_error=True.""" + async with Client(tutorial003.mcp) as client: + result = await client.call_tool("lookup_book", {"title": "Solaris"}) + assert result.is_error + (block,) = result.content + assert isinstance(block, TextContent) + assert block.text == "Error executing tool lookup_book: No book titled 'Solaris' in the catalog." + assert result.structured_content is None + + +async def test_an_unknown_tool_name_is_a_result_not_an_exception() -> None: + """The `!!! warning`: a tool the server doesn't have comes back as is_error=True, not as MCPError.""" + async with Client(tutorial003.mcp) as client: + result = await client.call_tool("does_not_exist", {}) + assert result.is_error + (block,) = result.content + assert isinstance(block, TextContent) + assert block.text == "Unknown tool: does_not_exist" + assert result.structured_content is None + + +async def test_resources_and_templates_are_two_separate_lists() -> None: + """tutorial004: concrete resources and parameterised templates come back from different verbs.""" + async with Client(tutorial004.mcp) as client: + (resource,) = (await client.list_resources()).resources + assert resource.uri == "catalog://genres" + (template,) = (await client.list_resource_templates()).resource_templates + assert template.uri_template == "catalog://genres/{genre}" + + +async def test_read_resource_fills_in_a_template() -> None: + """tutorial004: read_resource takes a plain str URI; narrow the contents with isinstance.""" + async with Client(tutorial004.mcp) as client: + (contents,) = (await client.read_resource("catalog://genres/poetry")).contents + assert isinstance(contents, TextResourceContents) + assert contents.text == "3 books filed under poetry." + + +async def test_mcpserver_does_not_implement_resource_subscriptions() -> None: + """The Resources section: MCPServer advertises subscribe=False and rejects subscribe_resource with -32601.""" + async with Client(tutorial004.mcp) as client: + assert client.server_capabilities.resources is not None + assert client.server_capabilities.resources.subscribe is False + with pytest.raises(MCPError) as exc_info: + await client.subscribe_resource("catalog://genres") + assert exc_info.value.error.code == -32601 + assert exc_info.value.error.message == "Method not found" + + +async def test_list_prompts_describes_the_arguments() -> None: + """tutorial005: a listed prompt carries its name, title and the arguments it needs.""" + async with Client(tutorial005.mcp) as client: + (prompt,) = (await client.list_prompts()).prompts + assert prompt == snapshot( + Prompt( + name="recommend", + title="Recommend a book", + description="Ask for a recommendation in a genre.", + arguments=[PromptArgument(name="genre", required=True)], + ) + ) + + +async def test_get_prompt_renders_the_messages() -> None: + """tutorial005: get_prompt returns the rendered messages a host hands to the model.""" + async with Client(tutorial005.mcp) as client: + result = await client.get_prompt("recommend", {"genre": "poetry"}) + (message,) = result.messages + assert message.role == "user" + assert message.content == TextContent( + type="text", text="Recommend one poetry book from the catalog and say why." + ) + + +async def test_complete_suggests_values_for_an_argument() -> None: + """tutorial006: complete takes a ref and a name/value pair and returns the matching values.""" + async with Client(tutorial006.mcp) as client: + result = await client.complete( + ref=PromptReference(type="ref/prompt", name="recommend"), + argument={"name": "genre", "value": "p"}, + ) + assert result.completion.values == ["poetry"] + + +async def test_a_single_page_server_ends_the_pagination_loop_immediately() -> None: + """tutorial007: every list_* takes cursor=; next_cursor is None when there is nothing left.""" + async with Client(tutorial007.mcp) as client: + page = await client.list_tools(cursor=None) + assert page.next_cursor is None + assert [tool.name for tool in page.tools] == ["search_books", "reserve_book"] + + +async def test_raise_exceptions_is_a_constructor_flag() -> None: + """The `## In tests` section: `raise_exceptions=True` is accepted by the in-memory Client.""" + async with Client(tutorial001.mcp, raise_exceptions=True) as client: + result = await client.call_tool("search_books", {"query": "dune"}) + assert result.structured_content == {"result": "Found 3 books matching 'dune'."} diff --git a/tests/docs_src/test_client_callbacks.py b/tests/docs_src/test_client_callbacks.py new file mode 100644 index 0000000000..b615c4700f --- /dev/null +++ b/tests/docs_src/test_client_callbacks.py @@ -0,0 +1,129 @@ +"""`docs/client/callbacks.md`: every claim the page makes, proved against the real SDK.""" + +import pytest +from inline_snapshot import snapshot +from mcp_types import ( + INVALID_REQUEST, + CreateMessageRequestParams, + CreateMessageResult, + ElicitRequestFormParams, + ElicitRequestParams, + ElicitResult, + ErrorData, + ListRootsResult, + Root, + SamplingMessage, + TextContent, +) +from pydantic import FileUrl + +from docs_src.client_callbacks import tutorial001, tutorial002, tutorial003, tutorial004 +from mcp import Client, MCPError +from mcp.client import ClientRequestContext + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_the_callback_answers_the_servers_question() -> None: + """tutorial001+002: the server's `ctx.elicit` is resolved by the client's `elicitation_callback`.""" + async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=tutorial002.handle_elicitation) as client: + result = await client.call_tool("issue_card") + assert not result.is_error + assert result.content == [TextContent(type="text", text="Card issued to Ada Lovelace.")] + + +async def test_the_callback_receives_the_servers_question_as_form_params() -> None: + """tutorial002: the callback gets `ElicitRequestFormParams` (the message and the requested schema).""" + received: list[ElicitRequestParams] = [] + + async def recording(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + received.append(params) + return await tutorial002.handle_elicitation(context, params) + + async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=recording) as client: + await client.call_tool("issue_card") + (params,) = received + assert isinstance(params, ElicitRequestFormParams) + assert params.mode == "form" + assert params.message == "What name should go on the card?" + assert params.requested_schema == snapshot( + { + "properties": {"name": {"title": "Name", "type": "string"}}, + "required": ["name"], + "title": "CardHolder", + "type": "object", + } + ) + + +async def test_returning_error_data_refuses_the_request_and_fails_the_call() -> None: + """The callback's only other return type: `ErrorData` refuses the request and fails the whole call.""" + + async def refuse(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult | ErrorData: + return ErrorData(code=INVALID_REQUEST, message="No forms here.") + + async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=refuse) as client: + with pytest.raises(MCPError, match="No forms here") as exc_info: + await client.call_tool("issue_card") + assert exc_info.value.error.code == INVALID_REQUEST + + +async def test_without_the_callback_the_servers_request_is_refused() -> None: + """The `!!! check`: no `elicitation_callback` means the SDK answers with an error and the call fails.""" + async with Client(tutorial001.mcp, mode="legacy") as client: + with pytest.raises(MCPError, match="Elicitation not supported") as exc_info: + await client.call_tool("issue_card") + assert exc_info.value.error.code == INVALID_REQUEST + + +async def test_registering_the_callback_declares_the_capability() -> None: + """tutorial003: `elicitation_callback` alone advertises exactly the `elicitation` capability.""" + async with Client(tutorial003.mcp, mode="legacy", elicitation_callback=tutorial002.handle_elicitation) as client: + result = await client.call_tool("client_features") + assert result.structured_content == {"result": ["elicitation"]} + + +async def test_no_callbacks_means_no_capabilities() -> None: + """tutorial003: a client constructed without callbacks declares nothing.""" + async with Client(tutorial003.mcp, mode="legacy") as client: + result = await client.call_tool("client_features") + assert result.structured_content == {"result": []} + + +async def test_each_callback_declares_its_own_capability() -> None: + """The page's table: the elicitation, sampling, and roots callbacks each declare their capability.""" + async with Client( + tutorial003.mcp, + mode="legacy", + elicitation_callback=tutorial002.handle_elicitation, + sampling_callback=tutorial004.handle_sampling, + list_roots_callback=tutorial004.handle_list_roots, + ) as client: + result = await client.call_tool("client_features") + assert result.structured_content == {"result": ["elicitation", "sampling", "roots"]} + + +async def test_the_modern_in_memory_path_has_no_back_channel() -> None: + """The `!!! info`: under the default mode the negotiated path has no back-channel for `elicitation/create`.""" + async with Client(tutorial001.mcp, elicitation_callback=tutorial002.handle_elicitation) as client: + with pytest.raises(MCPError, match="Method not found"): + await client.call_tool("issue_card") + + +async def test_the_deprecated_callbacks_return_what_the_page_says() -> None: + """tutorial004: the sampling and roots callbacks produce the result types the page names.""" + async with Client(tutorial003.mcp, mode="legacy") as client: + context = ClientRequestContext(session=client.session, request_id=1) + params = CreateMessageRequestParams( + messages=[SamplingMessage(role="user", content=TextContent(type="text", text="6 * 7?"))], + max_tokens=16, + ) + assert await tutorial004.handle_sampling(context, params) == snapshot( + CreateMessageResult( + role="assistant", content=TextContent(type="text", text="The answer is 42."), model="my-llm" + ) + ) + assert await tutorial004.handle_list_roots(context) == snapshot( + ListRootsResult(roots=[Root(uri=FileUrl("file:///home/ada/notebooks"), name="notebooks")]) + ) diff --git a/tests/docs_src/test_client_transports.py b/tests/docs_src/test_client_transports.py new file mode 100644 index 0000000000..848eddd52e --- /dev/null +++ b/tests/docs_src/test_client_transports.py @@ -0,0 +1,58 @@ +"""`docs/client/transports.md`: every claim the page makes, proved against the real SDK.""" + +import inspect + +import pytest + +from docs_src.client_transports import tutorial001, tutorial004 +from mcp import Client +from mcp.client.stdio import get_default_environment, stdio_client +from mcp.client.streamable_http import streamable_http_client + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_the_in_memory_program_on_the_page_runs(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial001's `main()` is the literal client program on the page; it runs clean end to end.""" + await tutorial001.main() + assert "Found 3 books matching 'dune'." in capsys.readouterr().out + + +async def test_in_memory_client_talks_to_the_server_object() -> None: + """tutorial001: passing the server object connects in-process. No subprocess, no port.""" + async with Client(tutorial001.mcp) as client: + assert client.server_info.name == "Bookshop" + assert client.protocol_version == "2026-07-28" + result = await client.call_tool("search_books", {"query": "dune"}) + assert result.structured_content == {"result": "Found 3 books matching 'dune'."} + + +async def test_constructing_a_client_does_not_connect_it() -> None: + """tutorial002: a URL string is accepted as-is, and nothing happens until `async with`.""" + client = Client("http://localhost:8000/mcp") + with pytest.raises(RuntimeError, match="Client must be used within an async context manager"): + client.session + + +async def test_streamable_http_configuration_lives_on_the_httpx_client() -> None: + """tutorial003: `streamable_http_client` takes `http_client=`; there is no `headers=` or any other HTTP knob.""" + assert list(inspect.signature(streamable_http_client).parameters) == ["url", "http_client", "terminate_on_close"] + + +async def test_stdio_parameters_are_wrapped_by_stdio_client() -> None: + """tutorial004: `stdio_client(params)` is the transport, and `Client` takes it like any other.""" + client = Client(stdio_client(tutorial004.server)) + with pytest.raises(RuntimeError, match="Client must be used within an async context manager"): + client.session + + +async def test_the_child_environment_is_an_allowlist(monkeypatch: pytest.MonkeyPatch) -> None: + """tutorial004: a variable set in the parent process is not inherited; `env=` adds it back explicitly.""" + monkeypatch.setenv("BOOKSHOP_API_KEY", "from-the-parent") + inherited = get_default_environment() + assert "PATH" in inherited + assert "BOOKSHOP_API_KEY" not in inherited + extra = tutorial004.server.env + assert extra is not None + assert (inherited | extra)["BOOKSHOP_API_KEY"] == "secret" diff --git a/tests/docs_src/test_completions.py b/tests/docs_src/test_completions.py new file mode 100644 index 0000000000..b1f5c18164 --- /dev/null +++ b/tests/docs_src/test_completions.py @@ -0,0 +1,116 @@ +"""`docs/tutorial/completions.md`: every claim the page makes, proved against the real SDK.""" + +import pytest +from inline_snapshot import snapshot +from mcp_types import ( + Completion, + CompletionContext, + CompletionsCapability, + ErrorData, + PromptReference, + ResourceTemplateReference, +) + +from docs_src.completions import tutorial001, tutorial002, tutorial003 +from mcp import Client, MCPError + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + +TEMPLATE_REF = ResourceTemplateReference(uri="github://repos/{owner}/{repo}") +PROMPT_REF = PromptReference(name="review_code") + + +async def test_a_server_with_no_handler_has_no_completions_capability() -> None: + """tutorial001: there is something worth completing, but no handler and no advertised capability.""" + async with Client(tutorial001.mcp) as client: + (template,) = (await client.list_resource_templates()).resource_templates + assert template.uri_template == "github://repos/{owner}/{repo}" + (prompt,) = (await client.list_prompts()).prompts + assert prompt.name == "review_code" + assert client.server_capabilities.completions is None + + +async def test_completing_without_a_handler_is_method_not_found() -> None: + """tutorial001: nothing handles `completion/complete`, so the request is a JSON-RPC error.""" + async with Client(tutorial001.mcp) as client: + with pytest.raises(MCPError) as excinfo: + await client.complete(ref=PROMPT_REF, argument={"name": "language", "value": "py"}) + assert excinfo.value.error == ErrorData(code=-32601, message="Method not found", data="completion/complete") + + +async def test_registering_the_handler_advertises_the_capability() -> None: + """tutorial002: `@mcp.completion()` is the whole declaration; the capability is derived from it.""" + async with Client(tutorial002.mcp) as client: + assert client.server_capabilities.completions == CompletionsCapability() + + +async def test_prompt_argument_completion_filters_on_the_typed_prefix() -> None: + """tutorial002: the handler returns the languages that start with `argument.value`.""" + async with Client(tutorial002.mcp) as client: + result = await client.complete(ref=PROMPT_REF, argument={"name": "language", "value": "py"}) + assert result.completion == snapshot(Completion(values=["python"])) + + +async def test_empty_value_returns_every_suggestion() -> None: + """tutorial002: an empty prefix matches everything, so the client gets the whole list.""" + async with Client(tutorial002.mcp) as client: + result = await client.complete(ref=PROMPT_REF, argument={"name": "language", "value": ""}) + assert result.completion.values == ["go", "javascript", "python", "rust", "typescript"] + + +async def test_returning_none_is_an_empty_list_not_an_error() -> None: + """tutorial002: an argument the handler does not recognise produces `values=[]`, never a failure.""" + async with Client(tutorial002.mcp) as client: + result = await client.complete(ref=PROMPT_REF, argument={"name": "code", "value": "x"}) + assert result.completion == snapshot(Completion(values=[])) + result = await client.complete(ref=TEMPLATE_REF, argument={"name": "repo", "value": ""}) + assert result.completion.values == [] + + +async def test_context_arguments_resolve_a_dependent_parameter() -> None: + """tutorial003: the already-resolved `owner` arrives in `context.arguments` and picks the repo list.""" + async with Client(tutorial003.mcp) as client: + result = await client.complete( + ref=TEMPLATE_REF, + argument={"name": "repo", "value": ""}, + context_arguments={"owner": "modelcontextprotocol"}, + ) + assert result.completion == snapshot(Completion(values=["python-sdk", "typescript-sdk", "inspector"])) + + +async def test_the_typed_prefix_still_filters_a_dependent_parameter() -> None: + """tutorial003: `argument.value` narrows the owner's repos exactly as it narrows a prompt argument.""" + async with Client(tutorial003.mcp) as client: + result = await client.complete( + ref=TEMPLATE_REF, + argument={"name": "repo", "value": "py"}, + context_arguments={"owner": "modelcontextprotocol"}, + ) + assert result.completion.values == ["python-sdk"] + + +def test_context_arguments_is_optional() -> None: + """tutorial003: `context.arguments` is `dict[str, str] | None`; the handler's `None` guard is required.""" + assert CompletionContext.model_fields["arguments"].annotation == (dict[str, str] | None) + assert CompletionContext().arguments is None + + +async def test_no_context_means_no_suggestions() -> None: + """tutorial003: without a resolved `owner` (or with an unknown one) the handler has nothing to offer.""" + async with Client(tutorial003.mcp) as client: + result = await client.complete(ref=TEMPLATE_REF, argument={"name": "repo", "value": ""}) + assert result.completion.values == [] + result = await client.complete( + ref=TEMPLATE_REF, + argument={"name": "repo", "value": ""}, + context_arguments={"owner": "nobody"}, + ) + assert result.completion.values == [] + + +async def test_the_prompt_branch_is_untouched_by_the_new_one() -> None: + """tutorial003: adding the resource-template branch leaves prompt-argument completion as it was.""" + async with Client(tutorial003.mcp) as client: + result = await client.complete(ref=PROMPT_REF, argument={"name": "language", "value": "type"}) + assert result.completion.values == ["typescript"] diff --git a/tests/docs_src/test_context.py b/tests/docs_src/test_context.py new file mode 100644 index 0000000000..2948b10f57 --- /dev/null +++ b/tests/docs_src/test_context.py @@ -0,0 +1,88 @@ +"""`docs/tutorial/context.md`: every claim the page makes, proved against the real SDK.""" + +import re + +import pytest +from inline_snapshot import snapshot +from mcp_types import TextContent, TextResourceContents, ToolListChangedNotification + +from docs_src.context import tutorial001, tutorial002, tutorial003 +from mcp import Client + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_the_context_parameter_is_not_in_the_input_schema() -> None: + """tutorial001: the injected `Context` never appears in the schema the model sees.""" + async with Client(tutorial001.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.input_schema == snapshot( + { + "type": "object", + "properties": {"query": {"title": "Query", "type": "string"}}, + "required": ["query"], + "title": "search_booksArguments", + } + ) + + +async def test_every_request_gets_its_own_context() -> None: + """tutorial001: `ctx.request_id` identifies the request being served, so it changes per call.""" + async with Client(tutorial001.mcp) as client: + first = await client.call_tool("search_books", {"query": "dune"}) + second = await client.call_tool("search_books", {"query": "dune"}) + assert isinstance(first.content[0], TextContent) + assert isinstance(second.content[0], TextContent) + assert re.fullmatch(r"\[request \d+\] Found 3 books matching 'dune'\.", first.content[0].text) + assert first.content[0].text != second.content[0].text + + +async def test_a_tool_reads_the_servers_own_resource() -> None: + """tutorial002: `ctx.read_resource` resolves the URI through the same registry `resources/read` uses.""" + async with Client(tutorial002.mcp) as client: + result = await client.call_tool("describe_catalog", {}) + assert not result.is_error + assert result.content == [ + TextContent(type="text", text="The catalog is organised into: fiction, non-fiction, poetry") + ] + (contents,) = (await client.read_resource("catalog://genres")).contents + assert isinstance(contents, TextResourceContents) + assert contents.text == "fiction, non-fiction, poetry" + + +async def test_a_context_only_tool_takes_no_arguments() -> None: + """tutorial002: a tool whose only parameter is the `Context` has an empty input schema.""" + async with Client(tutorial002.mcp) as client: + tools = {tool.name: tool for tool in (await client.list_tools()).tools} + assert tools["describe_catalog"].input_schema == snapshot( + {"type": "object", "properties": {}, "title": "describe_catalogArguments"} + ) + + +async def test_register_a_tool_at_runtime_and_notify_the_client() -> None: + """tutorial003: `mcp.add_tool` takes effect immediately and `send_tool_list_changed` reaches the client.""" + messages: list[object] = [] + + async def collect(message: object) -> None: + messages.append(message) + + async with Client(tutorial003.mcp, mode="legacy", message_handler=collect) as client: + assert [tool.name for tool in (await client.list_tools()).tools] == ["enable_recommendations"] + + missing = await client.call_tool("recommend_book", {"genre": "fiction"}) + assert missing.is_error + assert missing.content == [TextContent(type="text", text="Unknown tool: recommend_book")] + + enabled = await client.call_tool("enable_recommendations", {}) + assert enabled.content == [TextContent(type="text", text="Recommendations are now available.")] + + assert [tool.name for tool in (await client.list_tools()).tools] == [ + "enable_recommendations", + "recommend_book", + ] + result = await client.call_tool("recommend_book", {"genre": "fiction"}) + assert result.content == [TextContent(type="text", text="In fiction, try 'Dune'.")] + + (notification,) = messages + assert isinstance(notification, ToolListChangedNotification) diff --git a/tests/docs_src/test_deprecated.py b/tests/docs_src/test_deprecated.py new file mode 100644 index 0000000000..892a8f3627 --- /dev/null +++ b/tests/docs_src/test_deprecated.py @@ -0,0 +1,144 @@ +"""`docs/advanced/deprecated.md`: the page's behavioural claims, executed against the live SDK. + +This chapter has no `docs_src/` example by design: it is the one page allowed to name +the deprecated methods, and a runnable example would teach exactly what the page tells +the reader not to build. So instead of importing an example, each test here runs a +claim the page states in prose (the warning category and text, the warn-*then*-raise +order on a modern connection, the `ping` removal, and both `filterwarnings` recipes) +so the prose cannot drift away from what the SDK does. +""" + +import warnings + +import pytest +from mcp_types import CreateMessageRequestParams, CreateMessageResult, SamplingMessage, TextContent + +from mcp import Client, MCPDeprecationWarning, MCPError +from mcp.client import ClientRequestContext +from mcp.server import MCPServer +from mcp.server.mcpserver import Context +from mcp.shared.exceptions import NoBackChannelError + +pytestmark = pytest.mark.anyio + +mcp = MCPServer("Deprecated") + + +@mcp.tool() +async def ask_model(prompt: str, ctx: Context) -> str: + """A tool still built on server-initiated sampling.""" + result = await ctx.session.create_message( # pyright: ignore[reportDeprecated] + messages=[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))], + max_tokens=8, + ) + return str(result.content) + + +@mcp.tool() +async def old_log(ctx: Context) -> str: + """A tool still built on protocol logging.""" + await ctx.info("hello") # pyright: ignore[reportDeprecated] + return "ok" + + +async def test_create_message_warns_and_then_raises_on_a_modern_connection() -> None: + """The `!!! warning`: on a modern connection sampling warns AND THEN the send raises. + + The two signals are independent: `@deprecated` fires the moment the method is + called, and only afterwards does the channel refuse the send. The page reports + both, in that order. + """ + async with Client(mcp) as client: + with ( + pytest.warns( + MCPDeprecationWarning, + match=r"^The sampling capability is deprecated as of 2026-07-28 \(SEP-2577\)\.$", + ), + pytest.raises(NoBackChannelError) as exc, + ): + await client.call_tool("ask_model", {"prompt": "hi"}) + assert str(exc.value) == ( + "Cannot send 'sampling/createMessage': " + "this transport context has no back-channel for server-initiated requests." + ) + + +async def test_a_deprecated_feature_still_works_on_a_legacy_session() -> None: + """The page's headline: the deprecation is advisory. + + On a classic-handshake session, the same `ask_model` tool that fails on a modern + connection runs to completion: sampling round-trips through the client's callback + and the result comes back. The only difference is the visible warning. + """ + + async def canned_sampling(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult: + return CreateMessageResult( + role="assistant", + content=TextContent(type="text", text="four"), + model="canned", + stop_reason="endTurn", + ) + + async with Client(mcp, mode="legacy", sampling_callback=canned_sampling) as client: + with pytest.warns(MCPDeprecationWarning, match=r"The sampling capability is deprecated"): + result = await client.call_tool("ask_model", {"prompt": "What is 2 + 2?"}) + assert not result.is_error + [content] = result.content + assert isinstance(content, TextContent) + assert "four" in content.text + + +async def test_send_ping_still_carries_the_deprecation_warning() -> None: + """The opening sentence: every retired method carries an `MCPDeprecationWarning`. + + `ping` is removed from the 2026-07-28 protocol rather than put in a deprecation + window, but the SDK method is still decorated (its message says *removed*) and + a modern connection answers the actual request with "Method not found". + """ + async with Client(mcp) as client: + with ( + pytest.warns( + MCPDeprecationWarning, + match=r"^ping is removed as of 2026-07-28; the method only works under mode='legacy'\.$", + ), + pytest.raises(MCPError, match="^Method not found$"), + ): + await client.send_ping() # pyright: ignore[reportDeprecated] + + +def test_mcp_deprecation_warning_is_a_user_warning() -> None: + """The "Deprecated is advisory" section: the category subclasses `UserWarning`. + + Python's default filter hides `DeprecationWarning` outside `__main__`; deriving + from `UserWarning` is what makes the warning visible with no `-W` flag. + """ + assert issubclass(MCPDeprecationWarning, UserWarning) + assert not issubclass(MCPDeprecationWarning, DeprecationWarning) + + +@pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning") +async def test_error_filter_turns_the_deprecated_call_into_the_documented_tool_error() -> None: + """The `!!! check`: `"error::mcp.MCPDeprecationWarning"` makes `old_log` fail. + + Under the error filter the warning becomes the raised exception, the tool manager + wraps it, and the result is exactly the tool error the page quotes. + """ + async with Client(mcp) as client: + result = await client.call_tool("old_log", {}) + assert result.is_error + [content] = result.content + assert isinstance(content, TextContent) + assert content.text == ( + "Error executing tool old_log: The logging capability is deprecated as of 2026-07-28 (SEP-2577)." + ) + + +async def test_filterwarnings_ignore_silences_the_whole_category() -> None: + """The "Silencing the warning" snippet: one `filterwarnings` line quiets the category.""" + async with Client(mcp) as client: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + warnings.filterwarnings("ignore", category=MCPDeprecationWarning) + result = await client.call_tool("old_log", {}) + assert not result.is_error + assert not any(issubclass(w.category, MCPDeprecationWarning) for w in caught) diff --git a/tests/docs_src/test_elicitation.py b/tests/docs_src/test_elicitation.py new file mode 100644 index 0000000000..44523a141f --- /dev/null +++ b/tests/docs_src/test_elicitation.py @@ -0,0 +1,248 @@ +"""`docs/tutorial/elicitation.md`: every claim the page makes, proved against the real SDK.""" + +from typing import Literal + +import pytest +from inline_snapshot import snapshot +from mcp_types import ( + ElicitCompleteNotification, + ElicitRequestFormParams, + ElicitRequestParams, + ElicitRequestURLParams, + ElicitResult, + TextContent, +) +from pydantic import BaseModel + +from docs_src.elicitation import tutorial001, tutorial002, tutorial003 +from mcp import Client, MCPError +from mcp.client import ClientRequestContext +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_an_accepted_answer_resumes_the_tool() -> None: + """tutorial001: the user's answer comes back into the same call as a validated model.""" + + async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + return ElicitResult(action="accept", content={"accept_alternative": True, "date": "2025-12-26"}) + + async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_elicit) as client: + result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2}) + assert not result.is_error + assert result.content == [TextContent(type="text", text="Booked a table for 2 on 2025-12-26.")] + + +async def test_an_alternative_that_is_also_full_is_asked_about_again() -> None: + """tutorial001: the accepted date goes back through `book_table`, so a full date is re-asked, not booked.""" + asked: list[str] = [] + + async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + asked.append(params.message) + date = "2025-12-25" if len(asked) == 1 else "2025-12-27" + return ElicitResult(action="accept", content={"accept_alternative": True, "date": date}) + + async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_elicit) as client: + result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2}) + assert result.content == [TextContent(type="text", text="Booked a table for 2 on 2025-12-27.")] + assert asked == [ + "No tables for 2 on 2025-12-25. Would you like to try another date?", + "No tables for 2 on 2025-12-25. Would you like to try another date?", + ] + + +async def test_the_client_receives_the_message_and_the_generated_schema() -> None: + """tutorial001: form mode sends your message plus a JSON Schema built from the Pydantic model.""" + received: list[ElicitRequestParams] = [] + + async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + received.append(params) + return ElicitResult(action="accept", content={"accept_alternative": False}) + + async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_elicit) as client: + await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2}) + (params,) = received + assert isinstance(params, ElicitRequestFormParams) + assert params.message == "No tables for 2 on 2025-12-25. Would you like to try another date?" + assert params.requested_schema == snapshot( + { + "properties": { + "accept_alternative": { + "description": "Try another date?", + "title": "Accept Alternative", + "type": "boolean", + }, + "date": { + "default": "2025-12-26", + "description": "Alternative date (YYYY-MM-DD)", + "title": "Date", + "type": "string", + }, + }, + "required": ["accept_alternative"], + "title": "AlternativeDate", + "type": "object", + } + ) + + +async def test_decline_and_cancel_are_ordinary_return_values() -> None: + """tutorial001: a refusal is not an error; the tool sees the action and answers the model normally.""" + + async def on_decline(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + return ElicitResult(action="decline") + + async def on_cancel(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + return ElicitResult(action="cancel") + + async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_decline) as client: + declined = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2}) + async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_cancel) as client: + cancelled = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2}) + assert declined.content == [TextContent(type="text", text="No booking made.")] + assert not declined.is_error + assert cancelled.content == [TextContent(type="text", text="No booking made.")] + + +async def test_a_tool_that_does_not_ask_needs_nothing_from_the_client() -> None: + """tutorial001: the elicitation only happens on the path that needs it.""" + async with Client(tutorial001.mcp, mode="legacy") as client: + result = await client.call_tool("book_table", {"date": "2025-12-30", "party_size": 4}) + assert result.content == [TextContent(type="text", text="Booked a table for 4 on 2025-12-30.")] + + +async def test_an_answer_that_does_not_match_the_schema_never_reaches_the_tool_code() -> None: + """`!!! tip`: the client's content is validated against the model; a mismatch fails the call.""" + + async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + return ElicitResult(action="accept", content={"accept_alternative": "maybe"}) + + async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=on_elicit) as client: + result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2}) + assert result.is_error + assert isinstance(result.content[0], TextContent) + assert "Input should be a valid boolean" in result.content[0].text + + +class Address(BaseModel): + city: str + + +class Applicant(BaseModel): + name: str + address: Address + + +class Seating(BaseModel): + area: Literal["inside", "terrace"] + + +schema_gate_server = MCPServer("Bistro") +"""The `!!! warning` claims: what the elicitation schema gate accepts and rejects.""" + + +@schema_gate_server.tool() +async def sign_up(ctx: Context) -> str: + """Collect the new customer's details.""" + return str(await ctx.elicit(message="Who are you?", schema=Applicant)) + + +@schema_gate_server.tool() +async def choose_seating(ctx: Context) -> str: + """Ask where the party wants to sit.""" + result = await ctx.elicit(message="Where would you like to sit?", schema=Seating) + assert result.action == "accept" + return result.data.area + + +async def test_a_nested_model_is_rejected_before_anything_is_sent() -> None: + """`!!! warning`: a non-primitive field raises `TypeError` inside `ctx.elicit`, with this exact message.""" + async with Client(schema_gate_server, mode="legacy") as client: + result = await client.call_tool("sign_up", {}) + assert result.is_error + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == ( + "Error executing tool sign_up: Elicitation schema field 'address' rendered as " + "{'$ref': '#/$defs/Address'}, which is not a valid PrimitiveSchemaDefinition" + ) + + +async def test_a_literal_field_passes_the_gate_as_an_enum() -> None: + """`!!! warning`: a `Literal[...]` of strings renders as a JSON Schema `enum`, which the spec allows.""" + received: list[ElicitRequestParams] = [] + + async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + received.append(params) + return ElicitResult(action="accept", content={"area": "terrace"}) + + async with Client(schema_gate_server, mode="legacy", elicitation_callback=on_elicit) as client: + result = await client.call_tool("choose_seating", {}) + assert result.content == [TextContent(type="text", text="terrace")] + (params,) = received + assert isinstance(params, ElicitRequestFormParams) + assert params.requested_schema["properties"]["area"] == snapshot( + {"enum": ["inside", "terrace"], "title": "Area", "type": "string"} + ) + + +async def test_url_mode_sends_a_url_and_gets_consent_back_not_data() -> None: + """tutorial002: the client receives the URL and the elicitation id; only the action comes back.""" + received: list[ElicitRequestParams] = [] + + async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + received.append(params) + return ElicitResult(action="accept") + + async with Client(tutorial002.mcp, mode="legacy", elicitation_callback=on_elicit) as client: + result = await client.call_tool("pay_deposit", {"booking_id": "b42"}) + assert result.content == [TextContent(type="text", text="Complete the payment in your browser.")] + (params,) = received + assert isinstance(params, ElicitRequestURLParams) + assert params.url == "https://pay.example.com/deposit/b42" + assert params.elicitation_id == "deposit-b42" + + +async def test_a_declined_url_elicitation_is_an_ordinary_return_value() -> None: + """tutorial002: the tool decides what a refusal means.""" + + async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + return ElicitResult(action="decline") + + async with Client(tutorial002.mcp, mode="legacy", elicitation_callback=on_elicit) as client: + result = await client.call_tool("pay_deposit", {"booking_id": "b42"}) + assert result.content == [TextContent(type="text", text="No deposit taken. The booking expires in one hour.")] + + +async def test_send_elicit_complete_notifies_the_client_with_the_same_id() -> None: + """tutorial002: `send_elicit_complete` emits `notifications/elicitation/complete`.""" + notifications: list[object] = [] + + async def on_message(message: object) -> None: + notifications.append(message) + + async with Client(tutorial002.mcp, mode="legacy", message_handler=on_message) as client: + result = await client.call_tool("confirm_deposit", {"booking_id": "b42"}) + assert result.content == [TextContent(type="text", text="Deposit received for booking b42.")] + (notification,) = notifications + assert isinstance(notification, ElicitCompleteNotification) + assert notification.params.elicitation_id == "deposit-b42" + + +async def test_the_docs_client_callback_handles_both_modes() -> None: + """tutorial003: one `elicitation_callback` answers the form and the URL consent.""" + async with Client(tutorial001.mcp, mode="legacy", elicitation_callback=tutorial003.handle_elicitation) as client: + booked = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2}) + async with Client(tutorial002.mcp, mode="legacy", elicitation_callback=tutorial003.handle_elicitation) as client: + paid = await client.call_tool("pay_deposit", {"booking_id": "b42"}) + assert booked.content == [TextContent(type="text", text="Booked a table for 2 on 2025-12-27.")] + assert paid.content == [TextContent(type="text", text="Complete the payment in your browser.")] + + +async def test_a_client_without_the_callback_cannot_be_asked() -> None: + """`!!! check`: no `elicitation_callback` means no `elicitation` capability; the call is a protocol error.""" + async with Client(tutorial001.mcp, mode="legacy") as client: + with pytest.raises(MCPError, match="Elicitation not supported"): + await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2}) diff --git a/tests/docs_src/test_first_steps.py b/tests/docs_src/test_first_steps.py new file mode 100644 index 0000000000..15d6708ee2 --- /dev/null +++ b/tests/docs_src/test_first_steps.py @@ -0,0 +1,98 @@ +"""`docs/tutorial/first-steps.md`: every claim the page makes, proved against the real SDK.""" + +import pytest +from inline_snapshot import snapshot +from mcp_types import ( + PromptArgument, + PromptMessage, + TextContent, + TextResourceContents, +) + +from docs_src.first_steps import tutorial001 +from mcp import Client +from mcp.server import MCPServer + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_each_decorator_registers_one_primitive() -> None: + """tutorial001: name, description and schema all come from the decorated function.""" + async with Client(tutorial001.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.name == "add" + assert tool.description == "Add two numbers." + assert tool.input_schema == snapshot( + { + "type": "object", + "properties": { + "a": {"title": "A", "type": "integer"}, + "b": {"title": "B", "type": "integer"}, + }, + "required": ["a", "b"], + "title": "addArguments", + } + ) + + (template,) = (await client.list_resource_templates()).resource_templates + assert template.name == "greeting" + assert template.uri_template == "greeting://{name}" + assert template.description == "Greet someone by name." + + (prompt,) = (await client.list_prompts()).prompts + assert prompt.name == "summarize" + assert prompt.description == "Summarize a piece of text in one sentence." + assert prompt.arguments == [PromptArgument(name="text", required=True)] + + +async def test_call_the_tool() -> None: + """tutorial001: the Inspector walkthrough. `add` with 1 and 2 answers 3.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + assert not result.is_error + assert result.content == [TextContent(type="text", text="3")] + assert result.structured_content == {"result": 3} + + +async def test_templated_resource_is_a_template_not_a_resource() -> None: + """tutorial001: a `{param}` in the URI means the concrete-resource list stays empty.""" + async with Client(tutorial001.mcp) as client: + assert (await client.list_resources()).resources == [] + + +async def test_read_the_resource_template() -> None: + """tutorial001: supplying a `name` reads the template as a concrete resource.""" + async with Client(tutorial001.mcp) as client: + result = await client.read_resource("greeting://World") + assert result.contents == [ + TextResourceContents(uri="greeting://World", mime_type="text/plain", text="Hello, World!") + ] + + +async def test_get_the_prompt() -> None: + """tutorial001: the returned string becomes a single user message.""" + async with Client(tutorial001.mcp) as client: + result = await client.get_prompt("summarize", {"text": "MCP is a protocol."}) + rendered = "Summarize the following text in one sentence:\n\nMCP is a protocol." + assert result.messages == [PromptMessage(role="user", content=TextContent(type="text", text=rendered))] + + +async def test_the_three_primitive_capabilities_are_always_declared() -> None: + """tutorial001: `MCPServer` always declares tools/resources/prompts; only `completions` follows your code. + + An `MCPServer` with nothing registered declares the same three, which is why the + page ties registration to the *optional* capabilities only. + """ + async with Client(tutorial001.mcp) as client: + declared = client.server_capabilities + # The exact dictionary the page prints from `model_dump(exclude_none=True)`. + assert declared.model_dump(exclude_none=True) == snapshot( + { + "prompts": {"list_changed": False}, + "resources": {"subscribe": False, "list_changed": False}, + "tools": {"list_changed": False}, + } + ) + async with Client(MCPServer("Empty")) as client: + assert client.server_capabilities == declared diff --git a/tests/docs_src/test_handling_errors.py b/tests/docs_src/test_handling_errors.py new file mode 100644 index 0000000000..1a76a7bb77 --- /dev/null +++ b/tests/docs_src/test_handling_errors.py @@ -0,0 +1,86 @@ +"""`docs/tutorial/handling-errors.md`: every claim the page makes, proved against the real SDK.""" + +import pytest +from mcp_types import INVALID_PARAMS, ErrorData, TextContent, TextResourceContents + +from docs_src.handling_errors import tutorial001, tutorial002, tutorial003 +from mcp import Client, MCPError + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_a_plain_exception_becomes_a_tool_error_the_model_reads() -> None: + """tutorial001: any non-`MCPError` exception comes back as `is_error=True` with the message in `content`.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("get_author", {"title": "Nothing"}) + assert result.is_error + assert result.content == [ + TextContent(type="text", text="Error executing tool get_author: No book titled 'Nothing' in the catalog.") + ] + assert result.structured_content is None + + +async def test_a_title_the_catalog_knows_is_an_ordinary_result() -> None: + """tutorial001: the non-raising path is a plain `is_error=False` result.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("get_author", {"title": "Dune"}) + assert not result.is_error + assert result.structured_content == {"result": "Frank Herbert"} + + +async def test_a_bad_argument_never_reaches_the_function() -> None: + """tutorial001: schema validation rejects the call before `get_author` runs, as the same kind of tool error.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("get_author", {"title": 42}) + assert result.is_error + assert isinstance(result.content[0], TextContent) + assert "Input should be a valid string" in result.content[0].text + + +async def test_mcp_error_makes_the_call_itself_fail() -> None: + """tutorial002: `MCPError` is not caught. It surfaces as a JSON-RPC error, with `code` and `message` intact.""" + async with Client(tutorial002.mcp) as client: + with pytest.raises(MCPError) as exc_info: + await client.call_tool("get_author", {"title": "Nothing"}) + assert exc_info.value.code == INVALID_PARAMS + assert exc_info.value.message == "No book titled 'Nothing' in the catalog." + + +async def test_mcp_error_only_fires_on_the_raising_path() -> None: + """tutorial002: a title the catalog knows still returns a normal result.""" + async with Client(tutorial002.mcp) as client: + result = await client.call_tool("get_author", {"title": "Dune"}) + assert not result.is_error + assert result.structured_content == {"result": "Frank Herbert"} + + +async def test_resource_not_found_error_maps_to_invalid_params() -> None: + """tutorial003: `ResourceNotFoundError` from a template handler is `-32602` with the URI in `data`.""" + async with Client(tutorial003.mcp) as client: + with pytest.raises(MCPError) as exc_info: + await client.read_resource("books://Nothing") + assert exc_info.value.error == ErrorData( + code=INVALID_PARAMS, + message="No book titled 'Nothing' in the catalog.", + data={"uri": "books://Nothing"}, + ) + + +async def test_raise_exceptions_does_not_turn_a_tool_error_into_a_traceback() -> None: + """The closing `!!! info`: even `raise_exceptions=True` leaves a failing tool as the `is_error=True` result.""" + async with Client(tutorial001.mcp, raise_exceptions=True) as client: + result = await client.call_tool("get_author", {"title": "Nothing"}) + assert result.is_error + assert result.content == [ + TextContent(type="text", text="Error executing tool get_author: No book titled 'Nothing' in the catalog.") + ] + + +async def test_a_title_the_template_knows_reads_normally() -> None: + """tutorial003: the non-raising path resolves the template and returns text contents.""" + async with Client(tutorial003.mcp) as client: + result = await client.read_resource("books://Dune") + (contents,) = result.contents + assert isinstance(contents, TextResourceContents) + assert contents.text == "Dune by Frank Herbert" diff --git a/tests/docs_src/test_index.py b/tests/docs_src/test_index.py new file mode 100644 index 0000000000..3012ae1a08 --- /dev/null +++ b/tests/docs_src/test_index.py @@ -0,0 +1,31 @@ +"""`docs/index.md`: the landing-page server does exactly what the page says it does.""" + +import pytest +from inline_snapshot import snapshot +from mcp_types import CallToolResult, TextContent, TextResourceContents + +from docs_src.index.tutorial001 import mcp +from mcp import Client + +# `pyproject.toml` globally downgrades `mcp.MCPDeprecationWarning` to *ignore* because the +# SDK still calls those methods internally. A documentation example must never lean on +# that allowance, so every test that runs one re-arms the warning as an error. This is a +# per-module mark, not a conftest hook, because `pytest_collection_modifyitems` receives +# every item in the session. A hook here would break unrelated tests across the repo. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_add_tool() -> None: + async with Client(mcp) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + assert result == snapshot( + CallToolResult(content=[TextContent(type="text", text="3")], structured_content={"result": 3}) + ) + + +async def test_greeting_resource_template() -> None: + async with Client(mcp) as client: + result = await client.read_resource("greeting://World") + assert result.contents == snapshot( + [TextResourceContents(uri="greeting://World", mime_type="text/plain", text="Hello, World!")] + ) diff --git a/tests/docs_src/test_lifespan.py b/tests/docs_src/test_lifespan.py new file mode 100644 index 0000000000..d78764fd64 --- /dev/null +++ b/tests/docs_src/test_lifespan.py @@ -0,0 +1,113 @@ +"""`docs/tutorial/lifespan.md`: every claim the page makes, proved against the real SDK.""" + +import pytest +from inline_snapshot import snapshot +from mcp_types import TextContent, TextResourceContents + +from docs_src.lifespan import tutorial001, tutorial002 +from mcp import Client, MCPError +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_lifespan_object_reaches_the_tool() -> None: + """tutorial001: the object the lifespan yields is `ctx.request_context.lifespan_context`.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("count_books", {"genre": "poetry"}) + assert not result.is_error + assert result.content == [TextContent(type="text", text="3 books in 'poetry'.")] + assert result.structured_content == {"result": "3 books in 'poetry'."} + + +async def test_context_parameter_never_reaches_the_input_schema() -> None: + """tutorial001: `ctx` is injected by the SDK, so `genre` is the only argument the model sees.""" + async with Client(tutorial001.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.input_schema == snapshot( + { + "type": "object", + "properties": {"genre": {"title": "Genre", "type": "string"}}, + "required": ["genre"], + "title": "count_booksArguments", + } + ) + + +async def test_startup_runs_before_the_first_request_and_shutdown_after_the_last() -> None: + """tutorial002: `connect()` runs at startup, the `finally` runs `disconnect()` at shutdown.""" + assert not tutorial002.database.connected + async with Client(tutorial002.mcp) as client: + assert tutorial002.database.connected + result = await client.call_tool("database_status", {}) + assert result.structured_content == {"result": "connected"} + assert not tutorial002.database.connected + + +async def test_bare_context_reaches_the_lifespan_object_in_resources_and_prompts() -> None: + """A resource or prompt declaring a bare `ctx: Context` gets the same lifespan object a tool gets.""" + mcp = MCPServer("Bookshop", lifespan=tutorial001.app_lifespan) + + @mcp.resource("books://{genre}/count") + def genre_count(genre: str, ctx: Context) -> str: + """Count the books in a genre.""" + app = ctx.request_context.lifespan_context + assert isinstance(app, tutorial001.AppContext) + return f"{app.db.query()} books in {genre!r}." + + @mcp.prompt() + def stock_report(ctx: Context) -> str: + """Ask for a stock report.""" + app = ctx.request_context.lifespan_context + assert isinstance(app, tutorial001.AppContext) + return f"Summarise a shelf of {app.db.query()} books." + + async with Client(mcp) as client: + resource = await client.read_resource("books://poetry/count") + assert resource.contents == [ + TextResourceContents(uri="books://poetry/count", mime_type="text/plain", text="3 books in 'poetry'.") + ] + prompt = await client.get_prompt("stock_report") + (message,) = prompt.messages + assert message.content == TextContent(type="text", text="Summarise a shelf of 3 books.") + + +async def test_parameterized_context_is_tool_only(caplog: pytest.LogCaptureFixture) -> None: + """`Context[AppContext]` on a resource or prompt fails every call; the server logs the `ValueError`.""" + mcp = MCPServer("Bookshop", lifespan=tutorial001.app_lifespan) + + @mcp.resource("books://{genre}/count") + def genre_count(genre: str, ctx: Context[tutorial001.AppContext]) -> str: + """Count the books in a genre.""" + return f"{ctx.request_context.lifespan_context.db.query()} books in {genre!r}." + + @mcp.prompt() + def stock_report(ctx: Context[tutorial001.AppContext]) -> str: + """Ask for a stock report.""" + return f"Summarise a shelf of {ctx.request_context.lifespan_context.db.query()} books." + + async with Client(mcp) as client: + with pytest.raises(MCPError, match="Error creating resource from template"): + await client.read_resource("books://poetry/count") + assert "ValueError: Context is not available outside of a request" in caplog.text + + caplog.clear() + with pytest.raises(MCPError): + await client.get_prompt("stock_report") + assert "ValueError: Context is not available outside of a request" in caplog.text + + +async def test_default_lifespan_yields_an_empty_dict() -> None: + """No `lifespan=`: the SDK's default yields `{}`, so `lifespan_context` is never `None`.""" + bare = MCPServer("Bare") + + @bare.tool() + def show(ctx: Context) -> str: + """Show the lifespan context.""" + return repr(ctx.request_context.lifespan_context) + + async with Client(bare) as client: + result = await client.call_tool("show", {}) + assert result.structured_content == {"result": "{}"} diff --git a/tests/docs_src/test_logging.py b/tests/docs_src/test_logging.py new file mode 100644 index 0000000000..fa4b995c6e --- /dev/null +++ b/tests/docs_src/test_logging.py @@ -0,0 +1,62 @@ +"""`docs/tutorial/logging.md`: every claim the page makes, proved against the real SDK.""" + +import logging + +import pytest +from inline_snapshot import snapshot +from mcp_types import CallToolResult, TextContent + +from docs_src.logging import tutorial001 +from mcp import Client +from mcp.server import MCPServer + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_the_tool_logs_through_the_standard_library(caplog: pytest.LogCaptureFixture) -> None: + """tutorial001: `logger.info(...)` inside a tool emits an ordinary stdlib record named after the module.""" + caplog.set_level(logging.INFO) + async with Client(tutorial001.mcp) as client: + await client.call_tool("search_books", {"query": "dune"}) + (record,) = list(filter(lambda r: r.name == tutorial001.logger.name, caplog.records)) + assert record.levelname == "INFO" + assert record.getMessage() == "Searching for 'dune'" + + +async def test_the_log_line_never_reaches_the_client() -> None: + """tutorial001: the result is only the return value. Log output is invisible to the model.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("search_books", {"query": "dune"}) + assert result == snapshot( + CallToolResult( + content=[TextContent(type="text", text="Found 3 books matching 'dune'.")], + structured_content={"result": "Found 3 books matching 'dune'."}, + ) + ) + + +def test_log_level_configures_the_root_logger() -> None: + """`MCPServer(log_level=...)` calls `logging.basicConfig()` when nothing has configured logging yet.""" + root = logging.getLogger() + handlers, level = root.handlers[:], root.level + root.handlers = [] + try: + MCPServer("Bookshop", log_level="DEBUG") + assert root.level == logging.DEBUG + assert len(root.handlers) == 1 + finally: + root.handlers, root.level = handlers, level + + +def test_an_existing_logging_configuration_wins() -> None: + """`logging.basicConfig()` is a no-op once a handler is installed, so your own setup is not overridden.""" + root = logging.getLogger() + handlers, level = root.handlers[:], root.level + root.handlers, root.level = [logging.NullHandler()], logging.WARNING + try: + MCPServer("Bookshop", log_level="DEBUG") + assert root.level == logging.WARNING + assert len(root.handlers) == 1 + finally: + root.handlers, root.level = handlers, level diff --git a/tests/docs_src/test_lowlevel.py b/tests/docs_src/test_lowlevel.py new file mode 100644 index 0000000000..34746dd0b3 --- /dev/null +++ b/tests/docs_src/test_lowlevel.py @@ -0,0 +1,143 @@ +"""`docs/advanced/low-level-server.md`: every claim the page makes, proved against the real SDK.""" + +import pytest +from inline_snapshot import snapshot +from mcp_types import INTERNAL_ERROR, CallToolRequestParams, CallToolResult, ErrorData, RequestParams, TextContent + +from docs_src.lowlevel import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006 +from mcp import Client, MCPError +from mcp.server import Server, ServerRequestContext + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_the_input_schema_on_the_wire_is_the_dict_you_wrote() -> None: + """tutorial001: nothing is derived. `tools/list` returns the literal `input_schema` dict.""" + async with Client(tutorial001.server) as client: + (tool,) = (await client.list_tools()).tools + assert tool.name == "search_books" + assert tool.description == "Search the catalog by title or author." + assert tool.input_schema == snapshot( + { + "type": "object", + "properties": {"query": {"type": "string"}, "limit": {"type": "integer"}}, + "required": ["query", "limit"], + } + ) + assert tool.output_schema is None + + +async def test_the_client_does_not_care_which_server_class_it_connects_to() -> None: + """tutorial001: `Client(server)` accepts a low-level `Server` and the call answers like **Tools**.""" + async with Client(tutorial001.server) as client: + result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) + assert not result.is_error + assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune' (showing up to 5).")] + assert result.structured_content is None + + +async def test_only_the_handlers_you_passed_become_capabilities() -> None: + """tutorial001: two tool handlers advertise `tools` and nothing else.""" + async with Client(tutorial001.server) as client: + assert client.server_capabilities.model_dump(exclude_none=True) == snapshot({"tools": {"list_changed": False}}) + + +async def test_arguments_are_not_validated_against_your_schema() -> None: + """tutorial001: a call missing a `required` argument still reaches the handler and blows up there.""" + async with Client(tutorial001.server) as client: + with pytest.raises(MCPError) as exc_info: + await client.call_tool("search_books", {"query": "dune"}) + assert exc_info.value.error == ErrorData(code=INTERNAL_ERROR, message="Internal server error", data=None) + + +async def test_one_handler_routes_every_tool() -> None: + """tutorial002: `on_call_tool` is the single entry point; it dispatches on `params.name`.""" + async with Client(tutorial002.server) as client: + assert [tool.name for tool in (await client.list_tools()).tools] == ["search_books", "add_book"] + result = await client.call_tool("add_book", {"title": "Dune", "author": "Frank Herbert", "year": 1965}) + assert result.content == [TextContent(type="text", text="Added 'Dune' by Frank Herbert (1965).")] + + +async def test_an_unknown_tool_name_becomes_a_protocol_error_not_a_tool_error() -> None: + """tutorial002: raising from a handler is a `-32603` JSON-RPC error, never an `is_error` result.""" + async with Client(tutorial002.server) as client: + with pytest.raises(MCPError) as exc_info: + await client.call_tool("does_not_exist", {}) + assert exc_info.value.error == ErrorData(code=INTERNAL_ERROR, message="Internal server error", data=None) + + +async def test_output_schema_and_structured_content_are_both_yours_to_build() -> None: + """tutorial003: you declare the schema on the `Tool` and you build the matching payload.""" + async with Client(tutorial003.server) as client: + (tool,) = (await client.list_tools()).tools + assert tool.output_schema == snapshot( + { + "type": "object", + "properties": {"matches": {"type": "integer"}, "query": {"type": "string"}}, + "required": ["matches", "query"], + } + ) + result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) + assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune'.")] + assert result.structured_content == {"matches": 3, "query": "dune"} + + +async def test_the_client_checks_the_schema_you_promised() -> None: + """The page's warning: a `structured_content` that violates your `output_schema` fails in `call_tool`.""" + + async def promise_breaker(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + return CallToolResult(content=[TextContent(type="text", text="oops")], structured_content={"matches": "three"}) + + lying = Server("Bookshop", on_list_tools=tutorial003.list_tools, on_call_tool=promise_breaker) + async with Client(lying) as client: + with pytest.raises(RuntimeError, match="Invalid structured content returned by tool search_books"): + await client.call_tool("search_books", {"query": "dune", "limit": 5}) + + +async def test_meta_reaches_the_client_application() -> None: + """tutorial004: `_meta=` on the result comes back as `result.meta` and serialises under `_meta`.""" + async with Client(tutorial004.server) as client: + result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) + assert result.meta == {"bookshop/record_ids": ["bk_17", "bk_42", "bk_99"]} + assert result.model_dump(by_alias=True, exclude_none=True) == snapshot( + { + "_meta": {"bookshop/record_ids": ["bk_17", "bk_42", "bk_99"]}, + "content": [{"type": "text", "text": "Found 3 books matching 'dune'."}], + "structuredContent": {"matches": 3, "query": "dune"}, + "isError": False, + "resultType": "complete", + } + ) + + +async def test_the_lifespan_object_reaches_every_handler_with_its_type() -> None: + """tutorial005: what the lifespan yields is `ctx.lifespan_context`, typed by `Server[Catalog]`.""" + async with Client(tutorial005.server) as client: + result = await client.call_tool("search_books", {"query": "dune"}) + assert result.content == [TextContent(type="text", text="Found 3 books: Dune, Dune Messiah, Children of Dune.")] + + +async def test_add_request_handler_registers_a_method_the_constructor_does_not_know() -> None: + """tutorial006: the registry holds the handler and the params model it validates against.""" + entry = tutorial006.server.get_request_handler("bookshop/reindex") + assert entry is not None + assert entry.params_type is tutorial006.ReindexParams + assert tutorial006.server.get_request_handler("bookshop/burn") is None + + +async def test_a_custom_method_never_changes_the_advertised_capabilities() -> None: + """tutorial006: only the spec's method families map to capabilities. `bookshop/reindex` is invisible.""" + async with Client(tutorial006.server) as client: + assert client.server_capabilities.model_dump(exclude_none=True) == snapshot({"tools": {"list_changed": False}}) + + +def test_initialize_is_reserved() -> None: + """The page's `ValueError`: the handshake belongs to the runner, not to `add_request_handler`.""" + server = Server("Bookshop") + + async def grab_the_handshake(ctx: ServerRequestContext, params: RequestParams) -> None: + raise NotImplementedError + + with pytest.raises(ValueError, match="'initialize' is handled by the server runner"): + server.add_request_handler("initialize", RequestParams, grab_the_handshake) diff --git a/tests/docs_src/test_media.py b/tests/docs_src/test_media.py new file mode 100644 index 0000000000..96ea42a0b1 --- /dev/null +++ b/tests/docs_src/test_media.py @@ -0,0 +1,62 @@ +"""`docs/tutorial/media.md`: every claim the page makes, proved against the real SDK.""" + +import base64 + +import pytest +from mcp_types import AudioContent, Icon, ImageContent + +from docs_src.media import tutorial001, tutorial002, tutorial003 +from mcp import Client +from mcp.server.mcpserver import Audio, Image + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_image_return_becomes_an_image_content_block() -> None: + """tutorial001: `-> Image` reaches the client as a base64 `ImageContent` block, not text.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("logo", {}) + assert not result.is_error + assert result.content == [ + ImageContent(type="image", data=base64.b64encode(tutorial001.LOGO_PNG).decode(), mime_type="image/png") + ] + + +async def test_image_result_has_no_structured_content_and_no_output_schema() -> None: + """tutorial001: media is content for the model, not data for the application.""" + async with Client(tutorial001.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.output_schema is None + result = await client.call_tool("logo", {}) + assert result.structured_content is None + + +async def test_audio_return_becomes_an_audio_content_block() -> None: + """tutorial002: `Audio` is the same shape as `Image`.""" + async with Client(tutorial002.mcp) as client: + result = await client.call_tool("chime", {}) + assert not result.is_error + assert result.content == [ + AudioContent(type="audio", data=base64.b64encode(tutorial002.CHIME_WAV).decode(), mime_type="audio/wav") + ] + assert result.structured_content is None + + +def test_raw_data_without_a_format_falls_back_to_a_default_mime_type() -> None: + """The `!!! check`: with `data=` there is no suffix to guess from, so `format=` decides.""" + assert Image(data=b"\x89PNG\r\n\x1a\n", format="png").to_image_content().mime_type == "image/png" + assert Image(data=b"\x89PNG\r\n\x1a\n").to_image_content().mime_type == "image/png" + assert Audio(data=b"\xff\xfb").to_audio_content().mime_type == "audio/wav" + + +async def test_icons_are_visible_where_they_were_declared() -> None: + """tutorial003: server icons land on `server_info`, tool icons on the `Tool`, resource icons on the `Resource`.""" + async with Client(tutorial003.mcp) as client: + assert client.server_info.icons == [ + Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"]) + ] + (tool,) = (await client.list_tools()).tools + assert tool.icons == [Icon(src="https://example.com/palette.svg", mime_type="image/svg+xml", sizes=["any"])] + (resource,) = (await client.list_resources()).resources + assert resource.icons == [Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])] diff --git a/tests/docs_src/test_middleware.py b/tests/docs_src/test_middleware.py new file mode 100644 index 0000000000..97d9e96086 --- /dev/null +++ b/tests/docs_src/test_middleware.py @@ -0,0 +1,116 @@ +"""`docs/advanced/middleware.md`: every claim the page makes, proved against the real SDK.""" + +import logging +import re + +import pytest +from mcp_types import ( + INVALID_REQUEST, + METHOD_NOT_FOUND, + CallToolRequestParams, + ErrorData, + RequestId, + TextContent, +) + +from docs_src.middleware import tutorial001 +from mcp import Client, MCPError +from mcp.server import Server, ServerRequestContext +from mcp.server.context import CallNext, HandlerResult + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +def _is_timing_record(record: logging.LogRecord) -> bool: + """A record emitted by tutorial001's `log_timing` middleware (and nothing else caplog caught).""" + return record.name == tutorial001.logger.name + + +def test_timing_record_predicate() -> None: + """The caplog filter keeps the middleware's own records and drops everyone else's.""" + args = (logging.INFO, __file__, 1, "msg", None, None) + assert _is_timing_record(logging.LogRecord(tutorial001.logger.name, *args)) + assert not _is_timing_record(logging.LogRecord("somebody.elses.logger", *args)) + + +async def test_middleware_observes_every_inbound_message(caplog: pytest.LogCaptureFixture) -> None: + """tutorial001: two client calls produce three timed lines. `server/discover` is wrapped too.""" + with caplog.at_level(logging.INFO, logger=tutorial001.logger.name): + async with Client(tutorial001.server) as client: + await client.list_tools() + await client.call_tool("search_books", {"query": "dune"}) + messages = [record.getMessage() for record in filter(_is_timing_record, caplog.records)] + assert [message.split(" took ")[0] for message in messages] == ["server/discover", "tools/list", "tools/call"] + assert re.fullmatch(r"tools/call took \d+\.\d ms", messages[-1]) + + +async def test_the_result_passes_through_unchanged() -> None: + """tutorial001: `log_timing` returns what `call_next` returned, so the client sees the real result.""" + async with Client(tutorial001.server) as client: + result = await client.call_tool("search_books", {"query": "dune"}) + assert not result.is_error + assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune'.")] + + +async def test_a_notification_has_no_request_id() -> None: + """`ctx.request_id is None` is how middleware tells a notification from a request.""" + seen: list[tuple[str, RequestId | None]] = [] + + async def spy(ctx: ServerRequestContext, call_next: CallNext) -> HandlerResult: + seen.append((ctx.method, ctx.request_id)) + return await call_next(ctx) + + server = Server("Bookshop", on_list_tools=tutorial001.on_list_tools, on_call_tool=tutorial001.on_call_tool) + server.middleware.append(spy) + async with Client(server, mode="legacy") as client: + await client.list_tools() + assert seen == [("initialize", 1), ("notifications/initialized", None), ("tools/list", 2)] + + +async def test_raising_before_call_next_refuses_the_message() -> None: + """A middleware that raises instead of calling `call_next` answers with a JSON-RPC error.""" + + async def gate(ctx: ServerRequestContext, call_next: CallNext) -> HandlerResult: + if ctx.method == "tools/call": + raise MCPError(code=INVALID_REQUEST, message="No calls on Sundays.") + return await call_next(ctx) + + server = Server("Bookshop", on_list_tools=tutorial001.on_list_tools, on_call_tool=tutorial001.on_call_tool) + server.middleware.append(gate) + async with Client(server) as client: + with pytest.raises(MCPError) as exc_info: + await client.call_tool("search_books", {"query": "dune"}) + assert exc_info.value.error.code == INVALID_REQUEST + assert exc_info.value.error.message == "No calls on Sundays." + assert len((await client.list_tools()).tools) == 1 + + +async def test_an_unhandled_method_raises_through_the_middleware() -> None: + """A method without a handler raises `METHOD_NOT_FOUND` out of `call_next`, through the middleware.""" + seen: list[tuple[str, int]] = [] + + async def spy(ctx: ServerRequestContext, call_next: CallNext) -> HandlerResult: + try: + return await call_next(ctx) + except MCPError as exc: + seen.append((ctx.method, exc.error.code)) + raise + + server = Server("Bookshop", on_list_tools=tutorial001.on_list_tools, on_call_tool=tutorial001.on_call_tool) + server.middleware.append(spy) + async with Client(server) as client: + with pytest.raises(MCPError) as exc_info: + await client.read_resource("config://settings") + assert exc_info.value.error == ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="resources/read") + assert seen == [("resources/read", METHOD_NOT_FOUND)] + + +async def test_initialize_cannot_be_replaced_only_wrapped() -> None: + """`add_request_handler("initialize", ...)` is rejected: middleware is the sanctioned hook.""" + expected = ( + "'initialize' is handled by the server runner and cannot be overridden; " + "use Server.middleware to observe or wrap initialization" + ) + with pytest.raises(ValueError, match=re.escape(expected)): + tutorial001.server.add_request_handler("initialize", CallToolRequestParams, tutorial001.on_call_tool) diff --git a/tests/docs_src/test_mrtr.py b/tests/docs_src/test_mrtr.py new file mode 100644 index 0000000000..7dc78dbdc1 --- /dev/null +++ b/tests/docs_src/test_mrtr.py @@ -0,0 +1,103 @@ +"""`docs/advanced/multi-round-trip.md`: every claim the page makes, proved against the real SDK.""" + +import pytest +from inline_snapshot import snapshot +from mcp_types import ( + INTERNAL_ERROR, + CallToolResult, + CreateMessageRequest, + CreateMessageRequestParams, + ElicitRequest, + ElicitRequestFormParams, + ElicitResult, + InputRequiredResult, + TextContent, +) + +from docs_src.mrtr import tutorial001, tutorial002 +from mcp import Client, MCPError + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_first_call_returns_an_input_required_result() -> None: + """tutorial001: a tool that is missing input returns `InputRequiredResult` instead of calling back.""" + async with Client(tutorial001.server) as client: + result = await client.call_tool("provision", {"name": "orders"}, allow_input_required=True) + assert result == snapshot( + InputRequiredResult( + result_type="input_required", + input_requests={ + "region": ElicitRequest( + method="elicitation/create", + params=ElicitRequestFormParams( + mode="form", + message="Which region should the database live in?", + requested_schema={ + "type": "object", + "properties": {"region": {"type": "string"}}, + "required": ["region"], + }, + ), + ) + }, + request_state="provision-v1", + ) + ) + + +async def test_call_tool_raises_without_the_opt_in() -> None: + """The page's `!!! check`: `allow_input_required` defaults to `False` and the result is a hard error.""" + async with Client(tutorial001.server) as client: + with pytest.raises(RuntimeError) as exc: + await client.call_tool("provision", {"name": "orders"}) + assert str(exc.value) == ( + "Server returned InputRequiredResult; pass allow_input_required=True to receive it " + "and retry call_tool(..., input_responses=..., request_state=result.request_state)." + ) + + +async def test_retry_with_input_responses_and_request_state_completes_the_call() -> None: + """tutorial001: the retry carries `input_responses` keyed like `input_requests` plus the echoed token.""" + async with Client(tutorial001.server) as client: + result = await client.call_tool( + "provision", + {"name": "orders"}, + input_responses={"region": ElicitResult(action="accept", content={"region": "eu-west-1"})}, + request_state="provision-v1", + ) + assert result == snapshot( + CallToolResult(content=[TextContent(type="text", text="Provisioned 'orders' in eu-west-1.")]) + ) + + +async def test_the_manual_loop_drives_the_call_to_completion() -> None: + """tutorial002: `while isinstance(result, InputRequiredResult)` is the whole client API, and it terminates.""" + async with Client(tutorial001.server) as client: + result = await tutorial002.provision(client, "billing") + assert result == snapshot( + CallToolResult(content=[TextContent(type="text", text="Provisioned 'billing' in eu-west-1.")]) + ) + + +async def test_the_in_memory_client_negotiates_2026_07_28() -> None: + """`InputRequiredResult` only exists at 2026-07-28; `Client(server)` lands there without being asked.""" + async with Client(tutorial001.server) as client: + assert client.protocol_version == "2026-07-28" + + +async def test_a_pre_2026_session_has_nowhere_to_put_the_result() -> None: + """The page's `!!! warning`: on a legacy session the runner cannot serialize an `InputRequiredResult`.""" + async with Client(tutorial001.server, mode="legacy") as client: + with pytest.raises(MCPError) as exc: + await client.call_tool("provision", {"name": "orders"}, allow_input_required=True) + assert exc.value.error.code == INTERNAL_ERROR + assert exc.value.error.message == "Handler returned an invalid result" + + +def test_fulfil_refuses_a_request_it_cannot_answer() -> None: + """tutorial002: `fulfil` is the dispatch point. This client only knows how to answer an `ElicitRequest`.""" + request = CreateMessageRequest(params=CreateMessageRequestParams(messages=[], max_tokens=64)) + with pytest.raises(NotImplementedError, match="sampling/createMessage"): + tutorial002.fulfil(request) diff --git a/tests/docs_src/test_oauth_clients.py b/tests/docs_src/test_oauth_clients.py new file mode 100644 index 0000000000..3cb196eb67 --- /dev/null +++ b/tests/docs_src/test_oauth_clients.py @@ -0,0 +1,131 @@ +"""`docs/advanced/oauth-clients.md`: every claim the page makes, proved against the real SDK.""" + +import inspect + +import httpx +import pytest +from pydantic import AnyUrl, ValidationError + +from docs_src.oauth_clients import tutorial001, tutorial002 +from mcp.client.auth import OAuthClientProvider, OAuthFlowError, OAuthRegistrationError, OAuthTokenError, TokenStorage +from mcp.client.auth.extensions.client_credentials import ( + PrivateKeyJWTOAuthProvider, + RFC7523OAuthClientProvider, + static_assertion_provider, +) +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_in_memory_storage_satisfies_the_token_storage_protocol() -> None: + """tutorial001: `TokenStorage` is a Protocol: four async methods, no base class.""" + storage: TokenStorage = tutorial001.InMemoryTokenStorage() + assert await storage.get_tokens() is None + assert await storage.get_client_info() is None + + +async def test_storage_round_trips_tokens_and_client_info() -> None: + """tutorial001: whatever the provider stores, it gets back: the whole persistence contract.""" + storage = tutorial001.InMemoryTokenStorage() + tokens = OAuthToken(access_token="at-123", refresh_token="rt-456", expires_in=3600, scope="user") + client_info = OAuthClientInformationFull( + client_id="generated-by-the-as", + redirect_uris=[AnyUrl("http://localhost:3030/callback")], + ) + await storage.set_tokens(tokens) + await storage.set_client_info(client_info) + assert await storage.get_tokens() == tokens + assert await storage.get_client_info() == client_info + + +async def test_the_provider_is_an_httpx_auth() -> None: + """tutorial001: `OAuthClientProvider` plugs into httpx, not into MCP.""" + assert isinstance(tutorial001.oauth, httpx.Auth) + + +async def test_the_metadata_defaults_are_the_authorization_code_flow() -> None: + """tutorial001: `grant_types` and `response_types` default to code + refresh: nothing to set.""" + metadata = tutorial001.oauth.context.client_metadata + assert metadata.grant_types == ["authorization_code", "refresh_token"] + assert metadata.response_types == ["code"] + + +async def test_redirect_uris_is_required() -> None: + """The `!!! check`: registration metadata is validated locally, before any network.""" + with pytest.raises(ValidationError, match="redirect_uris\n Field required"): + OAuthClientMetadata.model_validate({"client_name": "Bookshop Agent"}) + + +async def test_the_redirect_handler_receives_the_authorization_url(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial001: `redirect_handler` is the one place the authorization URL surfaces.""" + await tutorial001.open_browser("https://auth.example.com/authorize?client_id=abc") + assert capsys.readouterr().out == "Visit: https://auth.example.com/authorize?client_id=abc\n" + + +async def test_client_credentials_provider_has_no_human_in_the_loop() -> None: + """tutorial002: `ClientCredentialsOAuthProvider` is the same `httpx.Auth`, minus the handlers.""" + assert isinstance(tutorial002.oauth, OAuthClientProvider) + assert isinstance(tutorial002.oauth, httpx.Auth) + assert tutorial002.oauth.context.redirect_handler is None + assert tutorial002.oauth.context.callback_handler is None + + +async def test_client_credentials_provider_builds_its_own_metadata() -> None: + """tutorial002: the grant is `client_credentials`, there is nothing to redirect to.""" + metadata = tutorial002.oauth.context.client_metadata + assert metadata.grant_types == ["client_credentials"] + assert metadata.token_endpoint_auth_method == "client_secret_basic" + assert metadata.redirect_uris is None + assert metadata.scope == "user" + + +async def test_the_three_remaining_keyword_arguments_have_defaults() -> None: + """The page names `timeout`, `client_metadata_url` and `validate_resource_url` as the remainder.""" + parameters = inspect.signature(OAuthClientProvider.__init__).parameters + supplied = ["server_url", "client_metadata", "storage", "redirect_handler", "callback_handler"] + remainder = ["timeout", "client_metadata_url", "validate_resource_url"] + assert list(parameters) == ["self", *supplied, *remainder] + assert all(parameters[name].default is not inspect.Parameter.empty for name in remainder) + + +async def test_the_one_more_provider_is_private_key_jwt() -> None: + """The `!!! info`: `PrivateKeyJWTOAuthProvider` is the same `httpx.Auth`, built the same way.""" + provider = PrivateKeyJWTOAuthProvider( + server_url="http://localhost:8001/mcp", + storage=tutorial002.InMemoryTokenStorage(), + client_id="reporting-agent", + assertion_provider=static_assertion_provider("a.prebuilt.jwt"), + ) + assert isinstance(provider, OAuthClientProvider) + assert isinstance(provider, httpx.Auth) + assert provider.context.client_metadata.token_endpoint_auth_method == "private_key_jwt" + + +async def test_the_page_does_not_count_the_deprecated_provider() -> None: + """Why the `!!! info` says *one* more provider: `RFC7523OAuthClientProvider` warns on construction.""" + with pytest.warns(DeprecationWarning, match="RFC7523OAuthClientProvider is deprecated"): + RFC7523OAuthClientProvider( + server_url="http://localhost:8001/mcp", + client_metadata=tutorial001.oauth.context.client_metadata, + storage=tutorial001.InMemoryTokenStorage(), + ) + + +async def test_every_oauth_error_is_an_oauth_flow_error() -> None: + """Catch `OAuthFlowError` and you have caught registration and token failures too.""" + assert issubclass(OAuthRegistrationError, OAuthFlowError) + assert issubclass(OAuthTokenError, OAuthFlowError) + + +async def test_not_everything_is_a_flow_error() -> None: + """A bad argument is a `ValueError`, not an `OAuthFlowError`: the page says *OAuth* failures.""" + with pytest.raises(ValueError, match="client_metadata_url must be a valid HTTPS URL") as exc_info: + OAuthClientProvider( + server_url="http://localhost:8001/mcp", + client_metadata=tutorial001.oauth.context.client_metadata, + storage=tutorial001.InMemoryTokenStorage(), + client_metadata_url="http://not-https.example/client.json", + ) + assert not isinstance(exc_info.value, OAuthFlowError) diff --git a/tests/docs_src/test_pagination.py b/tests/docs_src/test_pagination.py new file mode 100644 index 0000000000..ab5949df96 --- /dev/null +++ b/tests/docs_src/test_pagination.py @@ -0,0 +1,80 @@ +"""`docs/advanced/pagination.md`: every claim the page makes, proved against the real SDK.""" + +import pytest +from mcp_types import Resource + +from docs_src.pagination import tutorial001, tutorial002 +from mcp import Client, MCPError +from mcp.server import MCPServer +from mcp.server.mcpserver.resources import TextResource + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + +mcp = MCPServer("Bookshop") +for n in range(1, 101): + mcp.add_resource(TextResource(uri=f"books://catalog/book-{n}", name=f"book-{n}", text=f"book-{n}")) + + +async def test_mcpserver_never_pages() -> None: + """The page's framing: `MCPServer` answers `resources/list` in one page with `next_cursor=None`.""" + async with Client(mcp) as client: + result = await client.list_resources() + assert len(result.resources) == 100 + assert result.next_cursor is None + + +async def test_first_page_has_ten_resources_and_a_cursor() -> None: + """tutorial001: no cursor means page one: ten resources and a `next_cursor` the client may ignore.""" + async with Client(tutorial001.server) as client: + page = await client.list_resources() + assert [resource.name for resource in page.resources] == [f"book-{n}" for n in range(1, 11)] + assert page.next_cursor == "10" + + +async def test_the_cursor_resumes_where_the_last_page_stopped() -> None: + """tutorial001: handing `next_cursor` straight back yields the next page, no overlap.""" + async with Client(tutorial001.server) as client: + page = await client.list_resources(cursor="10") + assert page.resources[0].name == "book-11" + assert page.next_cursor == "20" + + +async def test_the_last_page_carries_no_cursor() -> None: + """tutorial001: `next_cursor=None` is the only end-of-list signal.""" + async with Client(tutorial001.server) as client: + page = await client.list_resources(cursor="90") + assert len(page.resources) == 10 + assert page.next_cursor is None + + +async def test_the_loop_collects_all_one_hundred() -> None: + """tutorial001: the `cursor=` loop visits ten pages and reassembles the whole catalog.""" + async with Client(tutorial001.server) as client: + resources: list[Resource] = [] + cursor: str | None = None + pages = 0 + while True: + page = await client.list_resources(cursor=cursor) + resources.extend(page.resources) + pages += 1 + if page.next_cursor is None: + break + cursor = page.next_cursor + assert pages == 10 + assert len({resource.uri for resource in resources}) == 100 + + +async def test_the_client_program_on_the_page_runs(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial002: `main()` is the literal client program on the page and prints the stitched total.""" + await tutorial002.main() + assert capsys.readouterr().out == "100 resources\n" + + +async def test_an_invented_cursor_is_an_error() -> None: + """Cursors are opaque: a string the server never minted blows up inside the handler.""" + async with Client(tutorial001.server) as client: + with pytest.raises(MCPError) as excinfo: + await client.list_resources(cursor="page-2") + assert excinfo.value.code == -32603 + assert str(excinfo.value) == "Internal server error" diff --git a/tests/docs_src/test_progress.py b/tests/docs_src/test_progress.py new file mode 100644 index 0000000000..45cc4df8eb --- /dev/null +++ b/tests/docs_src/test_progress.py @@ -0,0 +1,102 @@ +"""`docs/tutorial/progress.md`: every claim the page makes, proved against the real SDK.""" + +import inspect + +import anyio +import pytest +from mcp_types import TextContent + +from docs_src.progress import tutorial001, tutorial002 +from mcp import Client + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + +URLS = ["https://example.com/a.json", "https://example.com/b.json"] + + +async def test_context_parameter_is_invisible_to_the_model() -> None: + """tutorial001: `ctx` comes from the type hint and never reaches the input schema.""" + async with Client(tutorial001.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.input_schema["properties"] == { + "urls": {"items": {"type": "string"}, "title": "Urls", "type": "array"} + } + assert tool.input_schema["required"] == ["urls"] + + +async def test_each_report_becomes_one_callback_invocation_in_order() -> None: + """tutorial001: `progress_callback` receives every `(progress, total, message)` the tool reported.""" + updates: list[tuple[float, float | None, str | None]] = [] + + async def show(progress: float, total: float | None, message: str | None) -> None: + updates.append((progress, total, message)) + + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("import_catalog", {"urls": URLS}, progress_callback=show) + assert updates == [ + (1, 2, "Imported https://example.com/a.json"), + (2, 2, "Imported https://example.com/b.json"), + ] + assert result.content == [TextContent(type="text", text="Imported 2 records.")] + assert result.structured_content == {"result": "Imported 2 records."} + + +async def test_over_a_wire_dispatcher_callbacks_race_the_result() -> None: + """The `!!! info`: only the in-memory connection runs the callback inline. + + On a wire dispatcher (`mode="legacy"` here) each progress notification starts its own task, so + `call_tool` can return while a slow callback is still running. The callbacks below block on an + event that is only set *after* `call_tool` has returned: exactly the situation the page tells + you not to rule out. + """ + release = anyio.Event() + done = anyio.Event() + finished: list[float] = [] + + async def gated(progress: float, total: float | None, message: str | None) -> None: + await release.wait() + finished.append(progress) + if len(finished) == 2: + done.set() + + async with Client(tutorial001.mcp, mode="legacy") as client: + with anyio.fail_after(5): + result = await client.call_tool("import_catalog", {"urls": URLS}, progress_callback=gated) + assert finished == [] + release.set() + with anyio.fail_after(5): + await done.wait() + assert sorted(finished) == [1, 2] + assert result.structured_content == {"result": "Imported 2 records."} + + +async def test_without_a_callback_report_progress_is_a_no_op() -> None: + """The `!!! check`: omit `progress_callback` and the tool runs to the same result, no error.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("import_catalog", {"urls": URLS}) + assert not result.is_error + assert result.structured_content == {"result": "Imported 2 records."} + + +def test_progress_callback_is_per_call_not_per_client() -> None: + """The `!!! warning`: `call_tool` takes `progress_callback`; the `Client` constructor does not.""" + assert "progress_callback" in inspect.signature(Client.call_tool).parameters + assert "progress_callback" not in inspect.signature(Client.__init__).parameters + + +async def test_omitting_total_reaches_the_callback_as_none() -> None: + """tutorial002: a report without `total` arrives as `total=None`: activity, not a percentage.""" + updates: list[tuple[float, float | None, str | None]] = [] + + async def show(progress: float, total: float | None, message: str | None) -> None: + updates.append((progress, total, message)) + + async with Client(tutorial002.mcp) as client: + result = await client.call_tool("import_feed", {"feed_url": "https://example.com/feed"}, progress_callback=show) + assert updates == [ + (1, None, "Imported https://example.com/feed#Dune"), + (2, None, "Imported https://example.com/feed#Neuromancer"), + (3, None, "Imported https://example.com/feed#Hyperion"), + ] + assert result.structured_content == {"result": "Imported 3 records."} diff --git a/tests/docs_src/test_prompts.py b/tests/docs_src/test_prompts.py new file mode 100644 index 0000000000..1cbab3af0a --- /dev/null +++ b/tests/docs_src/test_prompts.py @@ -0,0 +1,101 @@ +"""`docs/tutorial/prompts.md`: every claim the page makes, proved against the real SDK.""" + +import traceback + +import pytest +from inline_snapshot import snapshot +from mcp_types import PromptArgument, PromptMessage, TextContent + +from docs_src.prompts import tutorial001, tutorial002, tutorial003 +from mcp import Client, MCPError + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_function_becomes_the_prompt() -> None: + """tutorial001: the name, the docstring and the parameters are the whole `prompts/list` entry.""" + async with Client(tutorial001.mcp) as client: + (prompt,) = (await client.list_prompts()).prompts + assert prompt.model_dump(mode="json", by_alias=True, exclude_none=True) == snapshot( + { + "name": "review_code", + "description": "Review a piece of code.", + "arguments": [{"name": "code", "required": True}], + } + ) + + +async def test_returned_string_becomes_one_user_message() -> None: + """tutorial001: a `str` return value is rendered as a single `user` message.""" + async with Client(tutorial001.mcp) as client: + result = await client.get_prompt("review_code", {"code": "def add(a, b): return a + b"}) + assert result.model_dump(mode="json", by_alias=True, exclude_none=True) == snapshot( + { + "description": "Review a piece of code.", + "messages": [ + { + "role": "user", + "content": { + "type": "text", + "text": "Please review this code:\n\ndef add(a, b): return a + b", + }, + } + ], + "resultType": "complete", + } + ) + + +async def test_missing_required_argument_is_a_protocol_error() -> None: + """tutorial001: omitting a required argument fails the request itself. There is no error result.""" + async with Client(tutorial001.mcp) as client: + with pytest.raises(MCPError) as exc_info: + await client.get_prompt("review_code") + assert exc_info.value.code == -32603 + assert exc_info.value.message == "Internal server error" + # The line a traceback prints, exactly as the page quotes it: the code is not in the message. + assert traceback.format_exception_only(exc_info.value) == snapshot( + ["mcp.shared.exceptions.MCPError: Internal server error\n"] + ) + + +async def test_message_list_becomes_a_multi_turn_template() -> None: + """tutorial002: a list of `UserMessage` / `AssistantMessage` renders in order, roles intact.""" + async with Client(tutorial002.mcp) as client: + assert [p.name for p in (await client.list_prompts()).prompts] == ["review_code", "debug_error"] + result = await client.get_prompt("debug_error", {"error": "TypeError: 'int' object is not iterable"}) + assert result.messages == [ + PromptMessage(role="user", content=TextContent(type="text", text="I'm seeing this error:")), + PromptMessage( + role="user", + content=TextContent(type="text", text="TypeError: 'int' object is not iterable"), + ), + PromptMessage( + role="assistant", + content=TextContent(type="text", text="I'll help debug that. What have you tried so far?"), + ), + ] + + +async def test_title_and_argument_descriptions() -> None: + """tutorial003: `title=` and `Field(description=...)` land in the `prompts/list` entry.""" + async with Client(tutorial003.mcp) as client: + (prompt,) = (await client.list_prompts()).prompts + assert prompt.title == "Code review" + assert prompt.arguments == [ + PromptArgument(name="code", description="The code to review.", required=True), + PromptArgument(name="language", description="The language the code is written in.", required=False), + ] + + +async def test_default_value_makes_the_argument_optional() -> None: + """tutorial003: a parameter with a default can be omitted and the default is used in the render.""" + async with Client(tutorial003.mcp) as client: + result = await client.get_prompt("review_code", {"code": "x = 1"}) + assert result.messages == [ + PromptMessage( + role="user", + content=TextContent(type="text", text="Please review this python code:\n\nx = 1"), + ) + ] diff --git a/tests/docs_src/test_protocol_versions.py b/tests/docs_src/test_protocol_versions.py new file mode 100644 index 0000000000..f8e5b19f16 --- /dev/null +++ b/tests/docs_src/test_protocol_versions.py @@ -0,0 +1,94 @@ +"""`docs/client/protocol-versions.md`: every claim the page makes, proved against the real SDK.""" + +import re + +import pytest +from mcp_types import DiscoverResult, Implementation, ServerCapabilities + +from docs_src.protocol_versions import tutorial001, tutorial002, tutorial003, tutorial004 +from mcp import Client + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_auto_lands_on_the_modern_version() -> None: + """tutorial001: the default `mode="auto"` probes `server/discover` and adopts the result.""" + async with Client(tutorial001.mcp) as client: + assert client.protocol_version == "2026-07-28" + assert client.server_info.name == "Bookshop" + assert client.session.discover_result is not None + assert client.session.initialize_result is None + + +async def test_legacy_forces_the_initialize_handshake() -> None: + """tutorial002: `mode="legacy"` runs `initialize` against the very same server.""" + async with Client(tutorial002.mcp, mode="legacy") as client: + assert client.protocol_version == "2025-11-25" + assert client.server_info.name == "Bookshop" + assert client.session.initialize_result is not None + assert client.session.discover_result is None + + +async def test_version_pin_sends_nothing_and_knows_nothing() -> None: + """tutorial003: a pin adopts the version locally; `server_info` and capabilities are blank.""" + async with Client(tutorial003.mcp, mode="2026-07-28") as client: + assert client.protocol_version == "2026-07-28" + assert client.server_info == Implementation(name="", version="") + # The `!!! check` fence is the literal `print(client.server_info)` output. + assert str(client.server_info) == "name='' title=None version='' description=None website_url=None icons=None" + assert client.server_capabilities == ServerCapabilities() + result = await client.call_tool("search_books", {"query": "dune"}) + assert result.structured_content == {"result": "Found 3 books matching 'dune'."} + + +def test_handshake_era_version_is_not_a_valid_pin() -> None: + """A pre-2026 version string is rejected at construction with the exact error the page shows.""" + with pytest.raises( + ValueError, + match=re.escape( + "mode must be 'legacy', 'auto', or one of ['2026-07-28']; " + "got '2025-06-18' ('2025-06-18' is a handshake-era version; use mode='legacy')" + ), + ): + Client(tutorial003.mcp, mode="2025-06-18") + + +async def test_prior_discover_round_trips() -> None: + """tutorial004: save `discover_result`, reconnect with it, and the identity comes back.""" + async with Client(tutorial004.mcp) as client: + saved = client.session.discover_result + assert saved is not None + assert saved.supported_versions == ["2026-07-28"] + + async with Client(tutorial004.mcp, mode="2026-07-28", prior_discover=saved) as client: + assert client.protocol_version == "2026-07-28" + assert client.server_info.name == "Bookshop" + assert client.server_capabilities.tools is not None + + +async def test_discover_result_survives_json() -> None: + """`DiscoverResult` is a Pydantic model: dump it to JSON, validate it back, reconnect with it.""" + async with Client(tutorial004.mcp) as client: + saved = client.session.discover_result + assert saved is not None + + restored = DiscoverResult.model_validate_json(saved.model_dump_json()) + assert restored == saved + + async with Client(tutorial004.mcp, mode="2026-07-28", prior_discover=restored) as client: + assert client.server_info.name == "Bookshop" + + +async def test_prior_discover_is_ignored_unless_mode_is_a_pin() -> None: + """The `!!! tip`: under `auto` the client probes anyway; under `legacy` it never discovers.""" + stale = DiscoverResult( + supported_versions=["2026-07-28"], + capabilities=ServerCapabilities(), + server_info=Implementation(name="Stale", version="0.0.0"), + ) + async with Client(tutorial004.mcp, prior_discover=stale) as client: + assert client.server_info.name == "Bookshop" + async with Client(tutorial004.mcp, mode="legacy", prior_discover=stale) as client: + assert client.session.discover_result is None + assert client.protocol_version == "2025-11-25" diff --git a/tests/docs_src/test_resources.py b/tests/docs_src/test_resources.py new file mode 100644 index 0000000000..85e827833d --- /dev/null +++ b/tests/docs_src/test_resources.py @@ -0,0 +1,119 @@ +"""`docs/tutorial/resources.md`: every claim the page makes, proved against the real SDK.""" + +import base64 + +import pytest +from inline_snapshot import snapshot +from mcp_types import BlobResourceContents, Resource, ResourceTemplate, TextResourceContents + +from docs_src.resources import tutorial001, tutorial002, tutorial003 +from mcp import Client +from mcp.server import MCPServer + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_function_becomes_a_listed_resource() -> None: + """tutorial001: the URI, the function name and the docstring are the whole listing entry.""" + async with Client(tutorial001.mcp) as client: + (resource,) = (await client.list_resources()).resources + assert resource == snapshot( + Resource( + name="get_config", + uri="config://app", + description="The active shop configuration.", + mime_type="text/plain", + ) + ) + + +async def test_read_returns_the_return_value_as_text() -> None: + """tutorial001: reading the URI runs the function and wraps the `str` in `TextResourceContents`.""" + async with Client(tutorial001.mcp) as client: + result = await client.read_resource("config://app") + assert result.contents == [ + TextResourceContents(uri="config://app", mime_type="text/plain", text="theme=dark\nlanguage=en") + ] + + +async def test_template_is_listed_separately_from_resources() -> None: + """tutorial002: a `{placeholder}` moves the entry from `resources/list` to `resources/templates/list`.""" + async with Client(tutorial002.mcp) as client: + assert [r.uri for r in (await client.list_resources()).resources] == ["config://app"] + (template,) = (await client.list_resource_templates()).resource_templates + assert template == snapshot( + ResourceTemplate( + name="get_user_profile", + uri_template="users://{user_id}/profile", + description="A customer's profile.", + mime_type="text/plain", + ) + ) + + +async def test_reading_a_template_fills_the_placeholder() -> None: + """tutorial002: the client reads a concrete URI; the matched value arrives as the function argument.""" + async with Client(tutorial002.mcp) as client: + result = await client.read_resource("users://42/profile") + assert result.contents == [ + TextResourceContents( + uri="users://42/profile", mime_type="text/plain", text="User 42: 12 orders since 2021." + ) + ] + + +def test_uri_params_must_match_function_params() -> None: + """The `!!! check`: a placeholder/parameter mismatch is rejected at decoration time, not at read time.""" + broken = MCPServer("Bookshop") + with pytest.raises(ValueError) as exc_info: + + @broken.resource("users://{user_id}/profile") + def get_user_profile(user: str) -> None: + """A customer's profile.""" + + assert str(exc_info.value) == snapshot( + "Mismatch between URI parameters {'user_id'} and function parameters {'user'}" + ) + + +async def test_mime_type_is_what_you_declare() -> None: + """tutorial003: `mime_type=` lands in the listing verbatim; the SDK never guesses it from the value.""" + async with Client(tutorial003.mcp) as client: + resources = (await client.list_resources()).resources + assert {r.uri: r.mime_type for r in resources} == snapshot( + { + "docs://readme": "text/markdown", + "stats://catalog": "application/json", + "covers://placeholder": "image/gif", + } + ) + + +async def test_str_return_is_sent_as_is() -> None: + """tutorial003: a `str` return value is the text content, untouched.""" + async with Client(tutorial003.mcp) as client: + (content,) = (await client.read_resource("docs://readme")).contents + assert isinstance(content, TextResourceContents) + assert content.text == "# Bookshop\n\nSearch the catalog with the `search_books` tool." + + +async def test_dict_return_becomes_json_text() -> None: + """tutorial003: a non-`str`, non-`bytes` return value is serialised to JSON text.""" + async with Client(tutorial003.mcp) as client: + (content,) = (await client.read_resource("stats://catalog")).contents + assert isinstance(content, TextResourceContents) + assert content.text == snapshot('{\n "books": 1204,\n "authors": 391\n}') + + +async def test_bytes_return_becomes_a_blob() -> None: + """tutorial003: a `bytes` return value arrives as `BlobResourceContents`, base64-encoded in `blob`.""" + async with Client(tutorial003.mcp) as client: + (content,) = (await client.read_resource("covers://placeholder")).contents + assert isinstance(content, BlobResourceContents) + assert content == BlobResourceContents( + uri="covers://placeholder", + mime_type="image/gif", + blob="R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", + ) + assert base64.b64decode(content.blob) == tutorial003.placeholder_cover() diff --git a/tests/docs_src/test_run.py b/tests/docs_src/test_run.py new file mode 100644 index 0000000000..4b9a8926ad --- /dev/null +++ b/tests/docs_src/test_run.py @@ -0,0 +1,52 @@ +"""`docs/run/index.md`: every claim the page makes that is observable without a transport.""" + +from typing import Any + +import pytest +from inline_snapshot import snapshot +from mcp_types import CallToolResult, TextContent + +from docs_src.run import tutorial001, tutorial002, tutorial003 +from mcp import Client +from mcp.server import MCPServer + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_the_run_call_is_guarded_so_importing_does_not_start_a_server() -> None: + """tutorial001: `run()` sits under `__main__`, so the module imports cleanly and serves in-memory.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("search_books", {"query": "dune"}) + assert result == snapshot( + CallToolResult( + content=[TextContent(type="text", text="Found 3 books matching 'dune'.")], + structured_content={"result": "Found 3 books matching 'dune'."}, + ) + ) + + +async def test_the_transport_never_changes_what_the_server_is() -> None: + """tutorial001/002/003 differ only in how they run: every client sees the identical tool.""" + async with ( + Client(tutorial001.mcp) as stdio_client, + Client(tutorial002.mcp) as http_client, + Client(tutorial003.mcp) as configured_client, + ): + baseline = await stdio_client.list_tools() + assert baseline == await http_client.list_tools() + assert baseline == await configured_client.list_tools() + + +def test_transport_options_are_not_constructor_options() -> None: + """The page's warning: `port=` belongs to `run()`; the constructor rejects it.""" + options: dict[str, Any] = {"port": 3001} + with pytest.raises(TypeError, match="unexpected keyword argument 'port'"): + MCPServer("Bookshop", **options) + + +def test_settings_are_constructor_arguments_and_land_on_settings() -> None: + """tutorial003: `log_level=` ends up on `mcp.settings`; the defaults are INFO and not-debug.""" + assert tutorial001.mcp.settings.log_level == "INFO" + assert tutorial001.mcp.settings.debug is False + assert tutorial003.mcp.settings.log_level == "DEBUG" diff --git a/tests/docs_src/test_session_groups.py b/tests/docs_src/test_session_groups.py new file mode 100644 index 0000000000..e6fee8ce92 --- /dev/null +++ b/tests/docs_src/test_session_groups.py @@ -0,0 +1,98 @@ +"""`docs/advanced/session-groups.md`: every claim the page makes, proved against the real SDK. + +`connect_to_server` opens a real transport (a subprocess or a socket), so these tests drive the +exact same aggregation path through `connect_with_session` with in-memory sessions instead. +""" + +import traceback + +import pytest +from mcp_types import INVALID_PARAMS, Implementation + +from docs_src.session_groups import tutorial001, tutorial002, tutorial004 +from mcp import Client, ClientSessionGroup, MCPError + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_both_servers_call_their_tool_search() -> None: + """tutorial001 + tutorial002: two unrelated servers, one colliding tool name.""" + async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web: + (library_tool,) = (await library.list_tools()).tools + (web_tool,) = (await web.list_tools()).tools + assert library_tool.name == "search" + assert web_tool.name == "search" + + +async def test_a_connected_server_is_aggregated_into_the_group() -> None: + """tutorial003: the group exposes every component of every connected server as a dict.""" + async with Client(tutorial001.mcp) as library: + group = ClientSessionGroup() + await group.connect_with_session(library.server_info, library.session) + assert sorted(group.tools) == ["search"] + assert sorted(group.resources) == ["hours"] + assert group.prompts == {} + assert group.tools["search"].description == "Search the library catalog." + + +async def test_colliding_names_are_rejected() -> None: + """tutorial003: without a hook the second `search` raises, and nothing from `Web` is kept.""" + async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web: + group = ClientSessionGroup() + await group.connect_with_session(library.server_info, library.session) + with pytest.raises(MCPError) as exc_info: + await group.connect_with_session(web.server_info, web.session) + assert str(exc_info.value) == "{'search'} already exist in group tools." + assert exc_info.value.error.code == INVALID_PARAMS + assert sorted(group.tools) == ["search"] + # The page's `!!! check` fence is the last line of the traceback, verbatim. + assert traceback.format_exception_only(exc_info.value) == [ + "mcp.shared.exceptions.MCPError: {'search'} already exist in group tools.\n" + ] + + +async def test_component_name_hook_prefixes_every_name() -> None: + """tutorial004: the hook rewrites every registered name, so both servers coexist.""" + async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web: + group = ClientSessionGroup(component_name_hook=tutorial004.by_server) + await group.connect_with_session(library.server_info, library.session) + await group.connect_with_session(web.server_info, web.session) + assert sorted(group.tools) == ["Library.search", "Web.search"] + assert sorted(group.resources) == ["Library.hours"] + + +def test_the_hook_is_a_plain_function_of_name_and_server_info() -> None: + """tutorial004: `by_server` builds the key from `server_info.name`.""" + assert tutorial004.by_server("search", Implementation(name="Web", version="1.0.0")) == "Web.search" + + +async def test_the_key_is_prefixed_but_the_wire_name_is_not() -> None: + """tutorial004: the dict key is yours; the `Tool` inside keeps the name the server declared.""" + async with Client(tutorial002.mcp) as web: + group = ClientSessionGroup(component_name_hook=tutorial004.by_server) + await group.connect_with_session(web.server_info, web.session) + assert group.tools["Web.search"].name == "search" + + +async def test_call_tool_routes_to_the_owning_server() -> None: + """tutorial004: `group.call_tool` resolves the prefixed name to the session that owns it.""" + async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web: + group = ClientSessionGroup(component_name_hook=tutorial004.by_server) + await group.connect_with_session(library.server_info, library.session) + await group.connect_with_session(web.server_info, web.session) + web_result = await group.call_tool("Web.search", {"query": "model context protocol"}) + assert web_result.structured_content == {"result": "12 pages match 'model context protocol'."} + library_result = await group.call_tool("Library.search", {"query": "dune"}) + assert library_result.structured_content == {"result": "3 books match 'dune'."} + + +async def test_disconnect_removes_every_component_of_that_server() -> None: + """tutorial004: `disconnect_from_server` takes the session back out of all three dicts.""" + async with Client(tutorial001.mcp) as library, Client(tutorial002.mcp) as web: + group = ClientSessionGroup(component_name_hook=tutorial004.by_server) + await group.connect_with_session(library.server_info, library.session) + web_session = await group.connect_with_session(web.server_info, web.session) + await group.disconnect_from_server(web_session) + assert sorted(group.tools) == ["Library.search"] + assert sorted(group.resources) == ["Library.hours"] diff --git a/tests/docs_src/test_shape.py b/tests/docs_src/test_shape.py new file mode 100644 index 0000000000..375f3e4581 --- /dev/null +++ b/tests/docs_src/test_shape.py @@ -0,0 +1,193 @@ +"""Structural invariants every `docs_src/` example must satisfy. + +These are deliberately string/regex checks, not an AST analyzer: each predicate +is branch-free at the call site so the suite stays compatible with the repo's +100% branch-coverage gate, and a contributor whose doc PR goes red gets a +one-line reason, not a parser traceback. +""" + +import importlib +import re +from itertools import filterfalse +from pathlib import Path + +import pytest + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning") + +REPO_ROOT = Path(__file__).parent.parent.parent +DOCS_SRC = REPO_ROOT / "docs_src" + +EXAMPLE_FILES = sorted(p for p in DOCS_SRC.rglob("*.py") if p.name != "__init__.py") +"""Every example module under `docs_src/` (the `__init__.py` scaffolding is not an example).""" + +_PRIVATE_MCP_IMPORT = re.compile(r"^\s*(?:from|import)\s+(mcp(?:\.\w+)*\._\w+)", re.MULTILINE) +"""A `_`-private segment inside the imported MODULE path: `from mcp.client._memory import X`.""" + +_PRIVATE_MCP_NAME = re.compile(r"^\s*from\s+(mcp(?:\.\w+)*)\s+import\s+[^#\n]*?\b(_\w+)\b", re.MULTILINE) +"""A `_`-private NAME imported from a public `mcp` module: `from mcp.client import _memory`.""" + +RETIRED_NAMES = ("UrlElicitationRequiredError",) +"""Public SDK names built on protocol surfaces retired by the 2026-07-28 spec. + +`UrlElicitationRequiredError` is the `-32042` flow; the spec lists that code as +reserved-never-reused, so no documentation example may teach it even while the +symbol is still exported. +""" + +_INCLUDE_DIRECTIVE = re.compile(r"(?:--8<--\s*\"|` README marker.""" + +_TYPOGRAPHIC_NON_ASCII = re.compile("[\u2014\u2013\u2192\u2026\u2018\u2019\u201c\u201d\u00a0\u2264\u2265\u00a7]") +"""Typographic characters the documentation never uses: em/en dash, arrow, ellipsis, curly +quotes, no-break space, comparison signs, section sign. They are written as escapes so this +file satisfies the very check it implements. + +Plain ASCII punctuation is a deliberate style rule, and one of these is also a real bug: a U+2026 +inside a fenced example breaks the fence linter on Windows, where the example source is piped to +ruff in the platform encoding rather than UTF-8. Emoji are not banned; this is about typography. +""" + +BOOK_PAGES = sorted( + { + REPO_ROOT / "README.v2.md", + REPO_ROOT / "docs" / "index.md", + REPO_ROOT / "docs" / "installation.md", + *(REPO_ROOT / "docs" / "tutorial").rglob("*.md"), + *(REPO_ROOT / "docs" / "run").rglob("*.md"), + *(REPO_ROOT / "docs" / "client").rglob("*.md"), + *(REPO_ROOT / "docs" / "advanced").rglob("*.md"), + } +) +"""Every page of the tutorial book plus the README: the files this directory's tests stand behind.""" + +DOCS_TEST_FILES = sorted(Path(__file__).parent.glob("*.py")) +"""This directory itself. The prose in these modules' docstrings follows the same typography rule.""" + + +def _rel(path: Path) -> str: + """A repo-relative path, used as the parametrize id so failures name the file.""" + return path.relative_to(REPO_ROOT).as_posix() + + +def _module_name(path: Path) -> str: + """The dotted import name of an example, derived from its repo-relative path.""" + return _rel(path).removesuffix(".py").replace("/", ".") + + +def _private_mcp_imports(source: str) -> list[str]: + """Every `mcp.*` import in `source` that reaches a `_`-private module OR name. + + Two single-line spellings are covered: a private segment in the module path + (`from mcp.client._memory import X`, `import mcp.server._otel`) and a private + name pulled from a public module (`from mcp.client import _memory`). + """ + named = [f"{module}.{name}" for module, name in _PRIVATE_MCP_NAME.findall(source)] + return _PRIVATE_MCP_IMPORT.findall(source) + named + + +def _retired_names_used(source: str) -> list[str]: + """The retired SDK names that appear anywhere in `source`.""" + return [name for name in RETIRED_NAMES if name in source] + + +def _typographic_chars(text: str) -> list[str]: + """Every banned typographic character in `text`, in order of appearance.""" + return _TYPOGRAPHIC_NON_ASCII.findall(text) + + +def _referenced_examples() -> set[str]: + """Every `docs_src/...` path that some docs page or the README actually includes. + + The README is globbed rather than named so this survives the planned + `README.v2.md` -> `README.md` rename instead of crashing on a missing file. + """ + pages = [*sorted((REPO_ROOT / "docs").rglob("*.md")), *sorted(REPO_ROOT.glob("README*.md"))] + return {ref for page in pages for ref in _INCLUDE_DIRECTIVE.findall(page.read_text(encoding="utf-8"))} + + +def _is_real_file(rel: str) -> bool: + """Whether a repo-relative path exists on disk.""" + return (REPO_ROOT / rel).is_file() + + +def test_private_mcp_import_detector() -> None: + """The detector flags both single-line spellings of a private `mcp` reach-in, and only those. + + It does not parse Python: a private name hidden behind an `as` alias or inside a + parenthesised multi-line `import` would slip through. Examples are short single-line + imports, so the cheap detector is the right trade against a 100-line AST analyzer. + """ + assert _private_mcp_imports("from mcp.client._memory import InMemoryTransport") == ["mcp.client._memory"] + assert _private_mcp_imports("import mcp.server._otel") == ["mcp.server._otel"] + assert _private_mcp_imports("from mcp.client import _memory") == ["mcp.client._memory"] + assert _private_mcp_imports("from mcp.server import MCPServer\nfrom mcp.client.client import Client") == [] + # only `mcp` is policed: another library's private module is not this test's business + assert _private_mcp_imports("from pydantic._internal import _fields") == [] + + +def test_retired_name_detector() -> None: + """The detector flags a retired name and stays quiet on clean source.""" + assert _retired_names_used("raise UrlElicitationRequiredError([])") == ["UrlElicitationRequiredError"] + assert _retired_names_used("from mcp.server import MCPServer") == [] + + +def test_typographic_char_detector() -> None: + """The detector flags banned typography and allows plain ASCII and emoji.""" + assert _typographic_chars("a \u2014 b \u2192 c\u2026") == ["\u2014", "\u2192", "\u2026"] + assert _typographic_chars("plain ASCII with a \u2728 emoji is fine") == [] + + +@pytest.mark.parametrize("path", EXAMPLE_FILES, ids=_rel) +def test_example_imports(path: Path) -> None: + """The example imports cleanly against the current SDK. + + A renamed symbol, a moved import path, or a changed keyword argument breaks an + example at import time, long before anyone reads the page it appears on. + + Honest scope: an example another test in this directory already imported is a + `sys.modules` cache hit here and its real coverage is that behavioural test. + This test is the floor for the example that has a page but no test yet. + """ + importlib.import_module(_module_name(path)) + + +@pytest.mark.parametrize("path", EXAMPLE_FILES, ids=_rel) +def test_example_uses_only_public_mcp_modules(path: Path) -> None: + """An example is the public API contract: it must never import a `_`-private `mcp` module.""" + assert not _private_mcp_imports(path.read_text(encoding="utf-8")), f"{_rel(path)} reaches into private mcp" + + +@pytest.mark.parametrize("path", EXAMPLE_FILES, ids=_rel) +def test_example_avoids_retired_api(path: Path) -> None: + """An example must not teach an API the 2026-07-28 spec retired, even while it is still exported.""" + assert not _retired_names_used(path.read_text(encoding="utf-8")), f"{_rel(path)} uses a retired API" + + +@pytest.mark.parametrize("path", [*BOOK_PAGES, *EXAMPLE_FILES, *DOCS_TEST_FILES], ids=_rel) +def test_page_uses_plain_ascii_punctuation(path: Path) -> None: + """A page, example, or docs test never uses em-dashes, arrows, ellipses, or other typographic non-ASCII.""" + found = _typographic_chars(path.read_text(encoding="utf-8")) + assert not found, f"{_rel(path)} contains non-ASCII typography: {sorted(set(found))}" + + +def test_every_example_is_included_by_a_page() -> None: + """Every `docs_src/` example is shown by at least one docs page or the README. + + An orphan example is dead documentation: it gets type-checked and tested + but no reader ever sees it, so it silently stops describing anything. + """ + examples = {_rel(p) for p in EXAMPLE_FILES} + orphans = sorted(examples - _referenced_examples()) + assert not orphans, f"docs_src files no page includes: {orphans}" + + +def test_every_included_path_exists() -> None: + """Every `docs_src/` path a page includes exists on disk. + + `mkdocs build --strict` also enforces this, but only when the docs are + built; this puts the same guarantee inside the ordinary `pytest` run. + """ + missing = sorted(filterfalse(_is_real_file, _referenced_examples())) + assert not missing, f"pages include docs_src files that do not exist: {missing}" diff --git a/tests/docs_src/test_structured_output.py b/tests/docs_src/test_structured_output.py new file mode 100644 index 0000000000..795b0ccf1e --- /dev/null +++ b/tests/docs_src/test_structured_output.py @@ -0,0 +1,192 @@ +"""`docs/tutorial/structured-output.md`: every claim the page makes, proved against the real SDK.""" + +import pytest +from inline_snapshot import snapshot +from mcp_types import TextContent + +from docs_src.structured_output import ( + tutorial001, + tutorial002, + tutorial003, + tutorial004, + tutorial005, + tutorial006, + tutorial007, + tutorial008, + tutorial009, +) +from mcp import Client +from mcp.server import MCPServer +from mcp.server.mcpserver.exceptions import InvalidSignature + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_scalar_return_is_wrapped() -> None: + """tutorial001: `-> int` becomes a `{"result": ...}` output schema and fills both channels.""" + async with Client(tutorial001.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.output_schema == snapshot( + { + "properties": {"result": {"title": "Result", "type": "integer"}}, + "required": ["result"], + "title": "get_temperatureOutput", + "type": "object", + } + ) + result = await client.call_tool("get_temperature", {"city": "London"}) + assert not result.is_error + assert result.content == [TextContent(type="text", text="17")] + assert result.structured_content == {"result": 17} + + +async def test_basemodel_is_the_schema() -> None: + """tutorial002: a `BaseModel` return type is the output schema itself: no wrapper.""" + async with Client(tutorial002.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.output_schema == snapshot( + { + "properties": { + "temperature": {"description": "Degrees Celsius.", "title": "Temperature", "type": "number"}, + "humidity": {"description": "Relative humidity, 0 to 1.", "title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"}, + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object", + } + ) + result = await client.call_tool("get_weather", {"city": "London"}) + assert result.structured_content == {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"} + serialized = '{\n "temperature": 16.2,\n "humidity": 0.83,\n "conditions": "Overcast"\n}' + assert result.content == [TextContent(type="text", text=serialized)] + + +async def test_typeddict_produces_the_same_schema() -> None: + """tutorial003: a `TypedDict` return type produces the same object schema as the `BaseModel`.""" + async with Client(tutorial003.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.output_schema == snapshot( + { + "properties": { + "temperature": {"title": "Temperature", "type": "number"}, + "humidity": {"title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"}, + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object", + } + ) + result = await client.call_tool("get_weather", {"city": "London"}) + assert result.structured_content == {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"} + + +async def test_dataclass_produces_the_same_schema() -> None: + """tutorial004: a dataclass (an annotated class) produces the same object schema again.""" + async with Client(tutorial004.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.output_schema == snapshot( + { + "properties": { + "temperature": {"title": "Temperature", "type": "number"}, + "humidity": {"title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"}, + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object", + } + ) + result = await client.call_tool("get_weather", {"city": "London"}) + assert result.structured_content == {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"} + + +async def test_list_return_is_wrapped() -> None: + """tutorial005: `-> list[WeatherData]` is wrapped in `{"result": ...}` and flattened into one block per item.""" + async with Client(tutorial005.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.output_schema == snapshot( + { + "$defs": { + "WeatherData": { + "properties": { + "temperature": {"title": "Temperature", "type": "number"}, + "humidity": {"title": "Humidity", "type": "number"}, + "conditions": {"title": "Conditions", "type": "string"}, + }, + "required": ["temperature", "humidity", "conditions"], + "title": "WeatherData", + "type": "object", + } + }, + "properties": { + "result": {"items": {"$ref": "#/$defs/WeatherData"}, "title": "Result", "type": "array"} + }, + "required": ["result"], + "title": "get_forecastOutput", + "type": "object", + } + ) + result = await client.call_tool("get_forecast", {"city": "London", "days": 2}) + assert result.structured_content == { + "result": [ + {"temperature": 16.2, "humidity": 0.83, "conditions": "Overcast"}, + {"temperature": 17.2, "humidity": 0.83, "conditions": "Overcast"}, + ] + } + assert len(result.content) == 2 + + +async def test_dict_str_return_is_not_wrapped() -> None: + """tutorial006: `dict[str, float]` is already a JSON object, so there is no `result` wrapper.""" + async with Client(tutorial006.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.output_schema == snapshot( + {"additionalProperties": {"type": "number"}, "title": "get_temperaturesDictOutput", "type": "object"} + ) + result = await client.call_tool("get_temperatures", {"cities": ["London", "Reykjavik"]}) + assert result.structured_content == {"London": 16.2, "Reykjavik": 4.4} + + +async def test_return_value_is_validated_against_the_schema() -> None: + """tutorial007: a return value that does not match the output schema is a tool error, not a result.""" + async with Client(tutorial007.mcp) as client: + result = await client.call_tool("get_weather", {"city": "London"}) + assert result.is_error + assert result.structured_content is None + assert isinstance(result.content[0], TextContent) + assert result.content[0].text.startswith("Error executing tool get_weather: 1 validation error for WeatherData") + assert "humidity\n Field required" in result.content[0].text + + +async def test_structured_output_false_opts_out() -> None: + """tutorial008: `structured_output=False` drops the schema and the structured channel entirely.""" + async with Client(tutorial008.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.output_schema is None + result = await client.call_tool("weather_report", {"city": "London"}) + assert result.structured_content is None + assert result.content == [ + TextContent(type="text", text="London: 17 degrees, overcast, light rain easing by evening.") + ] + + +async def test_class_without_type_hints_is_silently_unstructured() -> None: + """tutorial009: a class with no annotations on its body gets no schema, and the model gets a `repr`.""" + async with Client(tutorial009.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.output_schema is None + result = await client.call_tool("get_station", {"name": "north"}) + assert not result.is_error + assert result.structured_content is None + assert isinstance(result.content[0], TextContent) + assert result.content[0].text.startswith('" None: + """tutorial009: `structured_output=True` refuses a return type it cannot build a schema for.""" + mcp = MCPServer("Weather") + with pytest.raises(InvalidSignature, match="is not serializable for structured output"): + mcp.add_tool(tutorial009.get_station, structured_output=True) diff --git a/tests/docs_src/test_testing.py b/tests/docs_src/test_testing.py new file mode 100644 index 0000000000..035f72312f --- /dev/null +++ b/tests/docs_src/test_testing.py @@ -0,0 +1,23 @@ +"""`docs/tutorial/testing.md`: the page's own test, run for real. + +The page shows this test against a `server.py` next to it; here the import path +is the only difference. +""" + +import pytest +from inline_snapshot import snapshot +from mcp_types import CallToolResult, TextContent + +from docs_src.testing.tutorial001 import mcp +from mcp import Client + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_call_add_tool() -> None: + async with Client(mcp, raise_exceptions=True) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + assert result == snapshot( + CallToolResult(content=[TextContent(type="text", text="3")], structured_content={"result": 3}) + ) diff --git a/tests/docs_src/test_tools.py b/tests/docs_src/test_tools.py new file mode 100644 index 0000000000..08e2a5ca69 --- /dev/null +++ b/tests/docs_src/test_tools.py @@ -0,0 +1,107 @@ +"""`docs/tutorial/tools.md`: every claim the page makes, proved against the real SDK.""" + +import pytest +from inline_snapshot import snapshot +from mcp_types import TextContent, ToolAnnotations + +from docs_src.tools import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005 +from mcp import Client + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_signature_becomes_the_schema() -> None: + """tutorial001: the function name, the docstring and the type hints are the whole tool definition.""" + async with Client(tutorial001.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.name == "search_books" + assert tool.description == "Search the catalog by title or author." + assert tool.input_schema == snapshot( + { + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"title": "Limit", "type": "integer"}, + }, + "required": ["query", "limit"], + "title": "search_booksArguments", + } + ) + + +async def test_call_returns_text_and_structured_content() -> None: + """tutorial001: the return value reaches the model as text and the client as typed data.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("search_books", {"query": "dune", "limit": 5}) + assert not result.is_error + assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune' (showing up to 5).")] + assert result.structured_content == {"result": "Found 3 books matching 'dune' (showing up to 5)."} + + +async def test_default_value_makes_the_argument_optional() -> None: + """tutorial002: a plain Python default drops the argument from `required` and lands in the schema. + + The whole schema is pinned because the page quotes it verbatim. + """ + async with Client(tutorial002.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.input_schema == snapshot( + { + "type": "object", + "properties": { + "query": {"title": "Query", "type": "string"}, + "limit": {"default": 10, "title": "Limit", "type": "integer"}, + }, + "required": ["query"], + "title": "search_booksArguments", + } + ) + result = await client.call_tool("search_books", {"query": "dune"}) + assert result.structured_content == {"result": "Found 3 books matching 'dune' (showing up to 10)."} + + +async def test_field_constraints_land_in_the_schema() -> None: + """tutorial003: `Field(...)` metadata and `Literal` choices become JSON Schema the model can see.""" + async with Client(tutorial003.mcp) as client: + (tool,) = (await client.list_tools()).tools + props = tool.input_schema["properties"] + assert props["query"]["description"] == "Title or author to search for." + assert props["limit"] == snapshot( + { + "default": 10, + "description": "Maximum number of results.", + "maximum": 50, + "minimum": 1, + "title": "Limit", + "type": "integer", + } + ) + assert props["genre"]["anyOf"][0]["enum"] == ["fiction", "non-fiction", "poetry"] + + +async def test_constraint_violation_is_an_error_the_model_can_read() -> None: + """tutorial003: an out-of-range argument is rejected by the schema, not by your code.""" + async with Client(tutorial003.mcp) as client: + result = await client.call_tool("search_books", {"query": "dune", "limit": 999}) + assert result.is_error + assert isinstance(result.content[0], TextContent) + assert "less than or equal to 50" in result.content[0].text + + +async def test_pydantic_model_parameter() -> None: + """tutorial004: a `BaseModel` parameter nests its own schema and arrives as a real instance.""" + async with Client(tutorial004.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.input_schema["$defs"]["Book"]["required"] == ["title", "author", "year"] + book = {"title": "Dune", "author": "Frank Herbert", "year": 1965} + result = await client.call_tool("add_book", {"book": book}) + assert result.structured_content == {"result": "Added 'Dune' by Frank Herbert (1965)."} + + +async def test_title_and_annotations() -> None: + """tutorial005: `title` and `ToolAnnotations` are display and behaviour metadata for the client.""" + async with Client(tutorial005.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.title == "Search the catalog" + assert tool.annotations == ToolAnnotations(read_only_hint=True, open_world_hint=False) diff --git a/tests/test_examples.py b/tests/test_examples.py index ae35767f7c..f24e932bed 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -93,8 +93,25 @@ async def test_desktop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): assert "file2.txt" in content.text -# TODO(v2): Change back to README.md when v2 is released -@pytest.mark.parametrize("example", list(find_examples("README.v2.md")), ids=str) +# TODO(v2): Change back to README.md when v2 is released. +# `--8<--` include directives lint clean as Python, so pages built from +# `docs_src/` includes cost nothing here; the real validation of those files is +# pyright + ruff + tests/docs_src/. +@pytest.mark.parametrize( + "example", + list( + find_examples( + "README.v2.md", + "docs/index.md", + "docs/installation.md", + "docs/tutorial", + "docs/run", + "docs/client", + "docs/advanced", + ) + ), + ids=str, +) def test_docs_examples(example: CodeExample, eval_example: EvalExample): ruff_ignore: list[str] = ["F841", "I001", "F821"] # F821: undefined names (snippets lack imports) From 1b92efedc83303f4fecb2a18569e1a05b137cab7 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:05:05 +0200 Subject: [PATCH 007/100] Add .claude/skills/test-quality and reference it from AGENTS.md (#2993) --- .claude/skills/test-quality/SKILL.md | 150 +++++++++++++++++++++++++++ AGENTS.md | 9 ++ 2 files changed, 159 insertions(+) create mode 100644 .claude/skills/test-quality/SKILL.md diff --git a/.claude/skills/test-quality/SKILL.md b/.claude/skills/test-quality/SKILL.md new file mode 100644 index 0000000000..28152705b7 --- /dev/null +++ b/.claude/skills/test-quality/SKILL.md @@ -0,0 +1,150 @@ +--- +name: test-quality +description: Test quality bar for this repo. Read when writing, reviewing, or designing tests — covers naming, abstraction level, assertions, determinism, and the process for agent-driven test work. +--- + +# Test & code quality guide + +What "best practice for new work" means in this repo. Each rule carries its recorded reasoning +where one exists; a rule with no stated why is a convention — follow it anyway. + +## Naming & shape + +- **Test names are behaviour sentences** stating the observable outcome, not the feature being + poked: `test_elicit_form_decline_returns_no_content`, never `test_elicit_form_decline`. +- Plain top-level `test_*` functions; no `Test` classes (legacy files have them — don't copy). +- **Docstrings: 1–2 sentences of behaviour, honest about provenance** — spec-mandated, + SDK-defined, or pinning a known gap? Say which: provenance is the triage key when the test + later fails. A pinned-gap assertion breaking usually means a change *fixed* the gap; a + spec-mandated assertion breaking means a regression. +- Define things in dependency order; nothing forward-references. For client↔server tests: + handlers → server construction → client setup → act → assert — the test reads in the order + the conversation happens. +- Inline the server (or equivalent setup) in the test, so the whole observable behaviour fits + on one screen. Lift to a file-level fixture only when several tests in *that file* genuinely + share it; never share across files. +- A big multi-step test is fine when the property is irreducibly multi-step (e.g. resumability). + Split when a failure wouldn't tell you which claim broke — not for shortness. Compensate with + a numbered "Steps:" docstring so a reader sees the choreography before the body. + +## Level of abstraction + +- **Drive through the highest-level public API that can observe the property.** Hand-built wire + requests are brittle and don't prove the user-facing contract; tests that stay above the + internals keep working when the internals change. Drop to raw HTTP only when the assertion is + about something the high-level API *cannot* observe (status codes, headers, wire framing). +- **Scripting a peer over raw streams is a last resort**, reserved for behaviour the typed API + cannot *produce* (malformed input, an impossible peer response). First ask what it would take + for the public API to express it — often a small helper suffices. Every such test's docstring + states why the public API couldn't do it. +- **In-memory / in-process first.** HTTP-, SSE-, and auth-shaped behaviour can all be driven + through an in-process ASGI transport; threads only when necessary, subprocesses only when the + process boundary is itself the thing under test. In-process isn't just faster — it surfaces + bugs (a real stream leak was found this way) that subprocess indirection masks. +- **Tests read like real user code**: no aliasing shims in conftest, no walls of suppressions, + no private imports unless that is genuinely the documented way to do the thing. +- The assertion must prove the round trip — no side-channel state. What the server saw comes + back through the protocol, or via a closure-captured list asserted after the call. Handlers + assert their dispatch identity first (`assert params.name == "add"`), proving the request + that arrived is the request the test sent. + +## Assertions + +- **Transformations** (input → output the SDK produced) → full-object `snapshot(...)` equality, + so an added or dropped field fails. Regenerate with `--inline-snapshot=fix` so intentional + changes arrive as a reviewable diff; never hand-edit snapshot literals. +- **Pass-through values** (opaque tokens, `_meta`, cursors) → identity against the same + variable you sent. A snapshot of a pass-through value only "matches" because a human checked + two literals correspond — it proves nothing. +- **Errors** → `pytest.raises` + `.code` against the named constant; snapshot SDK-authored + messages; never `match=` on message text. Third-party text (pydantic, jsonschema) → stable + prefix only — never pin text that changes with a dependency upgrade — with a comment saying so. + +## Determinism + +- **No sleeps, ever.** A sleep guesses at timing instead of waiting on the condition, so it + either flakes or pads the run — sleeps head the list of the older test code's failure modes. + Coordinate with `anyio.Event` so the wait ends exactly when the condition holds (that + discipline is why 529 e2e tests run in ~10 s). Sole exception: tests *of* time-based + features, with a comment. +- Bound every indefinite wait with `anyio.fail_after(5)`. **5 is the standard** — widening it + needs an articulable reason; "10 to be safe" is covering up a flake, not fixing one, and + unexplained widenings propagate (one agent used 10 and every later one copied it). +- **Never assert wall-clock time, even with huge margins.** `elapsed < 0.9` on a ~0.01 s + operation — a 90× margin — was still rejected in review. `fail_after` bounding a hang is the + only timing primitive allowed. +- **Concurrency tests must prove genuine interleaving.** Without barriers the scheduler is free + to serialize — "a" can start, finish, and return before "b" even begins — so two `start_soon` + calls prove nothing. Gate with events so all parties are mid-flight before any proceeds, emit + interleaved, then assert the demux. +- **Don't over-synchronize either.** When delivery ordering is guaranteed (notifications + emitted during a request you're awaiting, over a single ordered in-memory stream), a plain + collected list asserted after the call is correct; events are for messages not tied to an + awaited operation. Verify the ordering guarantee actually holds for your transport first. +- Async tests use anyio, not asyncio. + +## Behaviour philosophy + +- **Pin current behaviour; never xfail.** A green suite asserting what actually happens is a + regression bar for any refactor; an xfail proves nothing about it. Where current behaviour + falls short of spec, pin the divergent output and record the gap as data — a tracking issue, + with the docstring naming the known gap (suites with a requirements manifest record it as a + divergence entry). Not hidden, not skipped. +- **Hollow-proof check**: before claiming a test covers a behaviour, re-read the claim and ask + "which assertion proves *this*?" A passing test near a behaviour is not proof of it — a full + review of the e2e suite found two such cases even under this discipline. + +## Hygiene + +- No new `# pragma`, `# type: ignore`, `# noqa` by default — restructure first; a suppression + usually means a test or a type is missing. The narrow sanctioned escape hatches (and the + audit to run before pushing) are in AGENTS.md. In tests, narrow types with `assert + isinstance`; never `Any`/`object` when a real type exists. +- **Warnings raised during tests are findings, not noise** (the repo runs + `filterwarnings = error`). Fix the cause; if the fix can't land in the same change, scope the + suppression to the one fixture that needs it and track the real fix explicitly. +- Registered-but-never-invoked handler bodies are `raise NotImplementedError`, so they cannot + silently become load-bearing. +- Comments live next to the line they explain, not in docstrings; single backticks for code + refs; match the surrounding comment density (one-liners next to one-liners). No + `from __future__ import annotations` (py310+ repo). +- Test work doesn't change `src/` as a side effect — the one mechanical exception is deleting a + pragma a new test now covers. If a test can't be written without a library change, raise it + as a finding or defer the test; don't quietly edit `src/`. + +## Process (for agent-driven work) + +- **Small chunks.** ≤10 tests per human-reviewed batch; when fanning out to multiple agents, + ≤5 per agent. Review quality scales inversely with batch size: the observed shortcuts + (over-stacked tests, a timing assertion, wrong abstraction level) all surfaced in one + oversized 27-test batch. +- **Design → review → implement, as separate steps.** The design deliverable is not just the + plan — it's the judgement calls (abstraction level, deferrals, contested assertions) stated + explicitly, so the reviewer vetoes them before implementation rather than discovering them + in the diff. +- **High-stakes areas get fresh adversarial reviewers** on both the design and the + implementation — fresh, because whoever designed it (or saw the proposed fix) anchors on the + same layer. SERIOUS findings (wrong assertion, missed MUST/SHOULD, an unrecorded known gap) + re-loop; style doesn't. Reserve the full panel for areas where being wrong is worse than + being slow. +- **Investigations pair a full-context look with a fresh agent given only the problem** — + never the proposed fix — and compare conclusions. The unbiased read regularly catches + anchoring on the wrong layer. +- **When review questions a decision, reconsider genuinely**: re-derive the tradeoff, state the + options, recommend with reasons. Defending the original choice is a valid outcome; + reflexively agreeing with every challenge is as bad as ignoring it. +- **Validate the reference artifact before building on it.** Whatever you treat as ground truth + (a spec import, a baseline, a generated list), check it *first* — discovering it was + incomplete after five batches costs far more than before batch one. +- **Verify as you go**: per-file `uv run --frozen pytest -q` + pyright + ruff while + iterating; full suite, coverage, and `./scripts/test` (when `src/` was touched) at + integration. +- **Every agent writes notes**: what it did, what broke, and — most importantly — what it + couldn't decide, so open questions surface instead of being silently resolved by whoever hit + them. +- **Quality over speed is a stated goal, not a preference** ("rushing something out the door is + not the goal here, explicitly so"). Don't quietly de-scope agreed work mid-stream; + renegotiate scope explicitly. +- **Don't copy patterns from existing test code by default** — much of the repo's older test + code is below the current bar (sleeps, mocks, `Test` classes, raciness). These rules define + the bar for new work. diff --git a/AGENTS.md b/AGENTS.md index 1dbac17e9b..9f9675e979 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,8 @@ ## Testing +- When writing or reviewing tests, conform to `.claude/skills/test-quality/SKILL.md` + — it defines the bar for naming, abstraction level, assertions, and determinism. - Framework: `uv run --frozen pytest` - Async testing: use anyio, not asyncio - Do not use `Test` prefixed classes — write plain top-level `test_*` functions. @@ -129,6 +131,13 @@ changes softened by a backwards-compatibility shim. Include: Search for related sections in the migration guide and group related changes together rather than adding new standalone sections. +## Documentation + +When a change affects public API or user-visible behaviour, update the relevant +page(s) under `docs/` in the same PR. Docs are organised by topic +(`tutorial/`, `client/`, `run/`, `advanced/`) — find the page covering the +feature you touched rather than adding a new one. + ## Formatting & Type Checking - Format: `uv run --frozen ruff format .` From 3a8da8c0c3c87f63171bdaa023dbee71502bdb11 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Fri, 26 Jun 2026 13:16:09 +0200 Subject: [PATCH 008/100] Fix docs/release follow-ups from the mcp-types package split (#2977) --- .github/workflows/deploy-docs.yml | 1 + .github/workflows/publish-pypi.yml | 4 +++ .github/workflows/shared.yml | 7 ++++ AGENTS.md | 7 +++- RELEASE.md | 14 +++++++- docs/hooks/gen_ref_pages.py | 47 ++++++++++++++----------- docs/migration.md | 17 ++++----- mkdocs.yml | 4 +-- schema/README.md | 3 +- src/mcp-types/pyproject.toml | 2 +- tests/{shared => types}/test_version.py | 0 11 files changed, 71 insertions(+), 35 deletions(-) rename tests/{shared => types}/test_version.py (100%) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index d28d3721f2..b958fd23ab 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -12,6 +12,7 @@ on: - docs_src/** - mkdocs.yml - src/mcp/** + - src/mcp-types/** - scripts/build-docs.sh - pyproject.toml - uv.lock diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index d77820b1d1..41b127f923 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -58,3 +58,7 @@ jobs: - name: Publish package distributions to PyPI uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 + with: + # Lets a re-run after a partially failed upload publish the remaining + # files instead of erroring on the ones already on PyPI. + skip-existing: true diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index 8989639b51..3ab4753568 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -35,6 +35,13 @@ jobs: uv sync --group codegen --frozen uv run --frozen --group codegen python scripts/gen_surface_types.py --check + # Resolves only mcp-types' declared dependencies into an empty environment, + # so an import of the SDK or anything from its stack fails here. + - name: mcp-types installs and imports standalone + run: | + uv run --isolated --no-project --with ./src/mcp-types python -c \ + "import mcp_types, mcp_types.jsonrpc, mcp_types.methods, mcp_types.version, mcp_types.v2025_11_25, mcp_types.v2026_07_28" + # TODO(Max): Drop this in v2. Deliberate updates (e.g. the v2 status # banner) go through the 'override-readme-freeze' label. - name: Check README.md is not modified diff --git a/AGENTS.md b/AGENTS.md index 9f9675e979..efe321db00 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,12 @@ ## Package Management - ONLY use uv, NEVER pip -- Installation: `uv add ` +- Installation: `uv add `. Exception: the root project's runtime + dependencies are dynamic (the published `mcp` wheel exact-pins `mcp-types`), + so `uv add` cannot edit them — add the requirement to + `[tool.hatch.metadata.hooks.uv-dynamic-versioning].dependencies` in + `pyproject.toml` by hand, then run `uv lock`. Dependency groups, extras, and + the example packages still take plain `uv add`. - Running tools: `uv run --frozen `. Always pass `--frozen` so uv doesn't rewrite `uv.lock` as a side effect. - Cross-version testing: `uv run --frozen --python 3.10 pytest ...` to run diff --git a/RELEASE.md b/RELEASE.md index ea46ebdb41..fba7115bbc 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -2,7 +2,9 @@ ## Bumping Dependencies -1. Change dependency version in `pyproject.toml` +1. Change the dependency version in `pyproject.toml`. The root `mcp` project's + runtime dependencies are dynamic and live under + `[tool.hatch.metadata.hooks.uv-dynamic-versioning].dependencies`. 2. Upgrade lock with `uv lock --resolution lowest-direct` ## Major or Minor Release @@ -21,6 +23,16 @@ The package version will be set automatically from the tag. v2 pre-releases are cut from `main` with a PEP 440 pre-release tag: `v2.0.0aN` for alphas, later `bN`/`rcN` for betas and release candidates. +A release publishes two distributions, `mcp` and `mcp-types`, at the same +version, and the `mcp` wheel exact-pins `mcp-types`. Before the first release +that includes both, the `mcp-types` PyPI project must be given the same +trusted publisher as `mcp` (this repository, workflow `publish-pypi.yml`, +environment `release`) and the same owners — without it the `mcp-types` +upload is rejected. If only some of the files upload, fix the cause and re-run +the publish job — `skip-existing` makes it skip whatever already landed. The +`Development Status` classifier in both `pyproject.toml` files is permanently +`5 - Production/Stable`; it is not bumped as part of any release. + 1. Check the full test matrix is green on the release commit. The matrix runs with `continue-on-error`, so a green workflow run does not mean the tests passed — check the individual jobs. diff --git a/docs/hooks/gen_ref_pages.py b/docs/hooks/gen_ref_pages.py index ad8c19b45f..8e1afeee68 100644 --- a/docs/hooks/gen_ref_pages.py +++ b/docs/hooks/gen_ref_pages.py @@ -9,27 +9,32 @@ root = Path(__file__).parent.parent.parent src = root / "src" -for path in sorted(src.rglob("*.py")): - module_path = path.relative_to(src).with_suffix("") - doc_path = path.relative_to(src).with_suffix(".md") - full_doc_path = Path("api", doc_path) - - parts = tuple(module_path.parts) - - if parts[-1] == "__init__": - parts = parts[:-1] - doc_path = doc_path.with_name("index.md") - full_doc_path = full_doc_path.with_name("index.md") - elif parts[-1].startswith("_"): - continue - - nav[parts] = doc_path.as_posix() - - with mkdocs_gen_files.open(full_doc_path, "w") as fd: - ident = ".".join(parts) - fd.write(f"::: {ident}") - - mkdocs_gen_files.set_edit_path(full_doc_path, path.relative_to(root)) +# `src/mcp-types` is a distribution directory, not an import package, so each +# package's dotted module path is taken relative to its own parent: deriving it +# from `src/` would emit the unimportable `mcp-types.mcp_types.*`. +for package in (src / "mcp", src / "mcp-types" / "mcp_types"): + base = package.parent + for path in sorted(package.rglob("*.py")): + module_path = path.relative_to(base).with_suffix("") + doc_path = path.relative_to(base).with_suffix(".md") + full_doc_path = Path("api", doc_path) + + parts = tuple(module_path.parts) + + if parts[-1] == "__init__": + parts = parts[:-1] + doc_path = doc_path.with_name("index.md") + full_doc_path = full_doc_path.with_name("index.md") + elif parts[-1].startswith("_"): + continue + + nav[parts] = doc_path.as_posix() + + with mkdocs_gen_files.open(full_doc_path, "w") as fd: + ident = ".".join(parts) + fd.write(f"::: {ident}") + + mkdocs_gen_files.set_edit_path(full_doc_path, path.relative_to(root)) with mkdocs_gen_files.open("api/SUMMARY.md", "w") as nav_file: nav_file.writelines(nav.build_literate_nav()) diff --git a/docs/migration.md b/docs/migration.md index 0ea24991fa..e64de8128c 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -213,10 +213,11 @@ The WebSocket transport has been removed: `mcp.client.websocket.websocket_client ### `mcp.types` moved to the `mcp-types` package The protocol wire types now live in a standalone distribution, `mcp-types`, imported as -`mcp_types`. It depends only on `pydantic`, so code that just needs to (de)serialize MCP -traffic can install it without the full SDK. The `mcp` package depends on `mcp-types` and +`mcp_types`. Its only runtime dependencies are `pydantic` and `typing-extensions`, so code +that just needs to (de)serialize MCP traffic can install it without the full SDK. The `mcp` package depends on `mcp-types` and continues to re-export the type names at the top level, so `from mcp import Tool` is -unchanged. Only the `mcp.types` submodule and `mcp.shared.version` were removed. +unchanged. Only the `mcp.types` submodule and `mcp.shared.version` were removed. The +package's API reference is at [`mcp_types`](api/mcp_types/index.md). **Why:** keeping the wire types in their own package lets tooling and lightweight clients depend on the protocol schema without pulling in `httpx`, `starlette`, `uvicorn`, and the @@ -225,18 +226,18 @@ rest of the server/transport stack. **Before (v1):** ```python -from mcp.types import Tool, CallToolResult +from mcp.types import Tool, Resource from mcp.shared.version import LATEST_PROTOCOL_VERSION ``` **After (v2):** ```python -from mcp_types import Tool, CallToolResult +from mcp_types import Tool, Resource from mcp_types.version import LATEST_PROTOCOL_VERSION -# Top-level re-exports are unchanged: -from mcp import Tool, CallToolResult +# Names `mcp` already re-exported at the top level are unchanged: +from mcp import Tool, Resource ``` ### Removed type aliases and classes @@ -814,7 +815,7 @@ 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. +`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. All of these live in `mcp_types.version` (previously `mcp.shared.version`): `from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS`. ### `ProgressContext` and `progress()` context manager removed diff --git a/mkdocs.yml b/mkdocs.yml index d3bbba2119..a703713ba8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -136,7 +136,7 @@ markdown_extensions: - sane_lists # this means you can start a list from any number watch: - - src/mcp + - src - docs_src plugins: @@ -152,7 +152,7 @@ plugins: - mkdocstrings: handlers: python: - paths: [src] + paths: [src, src/mcp-types] options: relative_crossrefs: true members_order: source diff --git a/schema/README.md b/schema/README.md index 534360c51b..7bb2145f7b 100644 --- a/schema/README.md +++ b/schema/README.md @@ -3,7 +3,8 @@ JSON Schema files for each protocol version the SDK has a wire-shape surface package for, vendored from the [spec repository] at the commit recorded in `PINNED.json`. `scripts/gen_surface_types.py` reads these to regenerate -`src/mcp/types/v/__init__.py`; CI runs the generator with `--check`. +`src/mcp-types/mcp_types/v/__init__.py`; CI runs the generator with +`--check`. To bump: drop the new `schema.json` here as `.json`, update the matching entry in `PINNED.json` (commit + sha256), and run diff --git a/src/mcp-types/pyproject.toml b/src/mcp-types/pyproject.toml index 1ae7bae809..51cabf501b 100644 --- a/src/mcp-types/pyproject.toml +++ b/src/mcp-types/pyproject.toml @@ -14,7 +14,7 @@ maintainers = [ keywords = ["mcp", "llm", "automation"] license = { text = "MIT" } classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", diff --git a/tests/shared/test_version.py b/tests/types/test_version.py similarity index 100% rename from tests/shared/test_version.py rename to tests/types/test_version.py From 5b2713d40c2053fa76f55b68317bcb213dc28601 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 26 Jun 2026 14:36:56 +0200 Subject: [PATCH 009/100] Mirror x-mcp-header tool arguments into Mcp-Param-* request headers (SEP-2243) (#2990) --- .github/actions/conformance/client.py | 30 +++- .../expected-failures.2026-07-28.yml | 4 +- .../actions/conformance/expected-failures.yml | 5 - .github/workflows/conformance.yml | 37 +++- docs/migration.md | 4 + src/mcp/client/session.py | 30 +++- src/mcp/shared/inbound.py | 53 ++++++ tests/interaction/_requirements.py | 13 ++ .../transports/test_hosting_http_modern.py | 162 ++++++++++++++++++ tests/shared/test_inbound.py | 69 ++++++++ 10 files changed, 393 insertions(+), 14 deletions(-) diff --git a/.github/actions/conformance/client.py b/.github/actions/conformance/client.py index 4a57d5aeee..ec4ff2245f 100644 --- a/.github/actions/conformance/client.py +++ b/.github/actions/conformance/client.py @@ -335,6 +335,34 @@ async def run_http_invalid_tool_headers(server_url: str) -> None: logger.exception(f"call_tool({tool.name!r}) failed") +@register("http-custom-headers") +async def run_http_custom_headers(server_url: str) -> None: + """List tools, then replay the harness's `toolCalls` so x-mcp-header args mirror into headers (SEP-2243). + + The scenario supplies the exact arguments to send (including the null/edge-case values that + exercise omission and Base64 encoding) via the context `toolCalls`; using them verbatim is + what drives every per-parameter check. `list_tools` first so the SDK caches each tool's + annotations; a tool the SDK dropped (invalid annotations) is skipped. Per-call failures are + logged and skipped rather than aborting the run. + """ + tool_calls: list[dict[str, Any]] = [] + if os.environ.get("MCP_CONFORMANCE_CONTEXT"): + tool_calls = get_conformance_context().get("toolCalls", []) + async with Client(server_url, mode=client_mode()) as client: + listed = await client.list_tools() + surfaced = {tool.name for tool in listed.tools} + logger.debug(f"Surfaced tools: {sorted(surfaced)}") + for call in tool_calls: + name = call["name"] + if name not in surfaced: + logger.debug(f"skipping {name!r}: not surfaced by list_tools") + continue + try: + await client.call_tool(name, call.get("arguments") or {}) + except Exception: + logger.exception(f"call_tool({name!r}) failed") + + @register("elicitation-sep1034-client-defaults") async def run_elicitation_defaults(server_url: str) -> None: """Connect with elicitation callback that applies schema defaults.""" @@ -526,8 +554,6 @@ def main() -> None: elif scenario.startswith("auth/"): asyncio.run(run_auth_code_client(server_url)) else: - # Unhandled scenarios: - # - http-custom-headers (SEP-2243 / S8: Mcp-Param-* emission) 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 a4b4f44806..39fcdde482 100644 --- a/.github/actions/conformance/expected-failures.2026-07-28.yml +++ b/.github/actions/conformance/expected-failures.2026-07-28.yml @@ -21,9 +21,7 @@ # milestone. client: - # SEP-2243 (HTTP standardization): no client Mcp-Param-* support yet — needs the - # tool-schema-cache vs per-call tool_definition design (S8). - - http-custom-headers + [] # 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 cb59dba029..b8994f76b5 100644 --- a/.github/actions/conformance/expected-failures.yml +++ b/.github/actions/conformance/expected-failures.yml @@ -11,11 +11,6 @@ # on stale entries), so the baseline burns down per milestone. client: - # --- Draft-spec scenarios (in `--suite draft`, also part of `--suite all`) --- - # SEP-2243 (HTTP standardization): no client Mcp-Param-* support yet — needs the - # tool-schema-cache vs per-call tool_definition design (S8). - - http-custom-headers - # --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 --- # SEP-990 (enterprise-managed authorization extension): no fixture handler / # client support for the token-exchange + JWT bearer flow. diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 9f5ce489fe..e68991e47e 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -18,7 +18,16 @@ env: # Use a published version, e.g. @modelcontextprotocol/conformance@0.2.0-alpha.7. # Bump deliberately and reconcile both # .github/actions/conformance/expected-failures*.yml files in the same change. - CONFORMANCE_PKG: "@modelcontextprotocol/conformance@0.2.0-alpha.7" + # + # Temporarily pinned to the pkg.pr.new preview build of conformance#371, which + # fixes the http-custom-headers fixture's spec-forbidden `number`-typed + # x-mcp-header annotations. Because this is a mutable URL (not a registry + # spec), CONFORMANCE_PKG_SHA256 pins the tarball and the fetch-and-verify step + # below downloads, checks the digest, and repoints CONFORMANCE_PKG at the + # verified local copy. Repin to the published release that includes #371 once + # it ships, then drop CONFORMANCE_PKG_SHA256 and the fetch-and-verify steps. + CONFORMANCE_PKG: "https://pkg.pr.new/@modelcontextprotocol/conformance@371" + CONFORMANCE_PKG_SHA256: "9d8b25874d55e304b006cbaa066571773582f5828143c53a2b8a6830f203ca1d" jobs: server-conformance: @@ -34,6 +43,19 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 + - name: Fetch and verify conformance harness + # Only when CONFORMANCE_PKG is a URL: download, check the recorded + # sha256, and re-point CONFORMANCE_PKG at the verified local tarball. + # When CONFORMANCE_PKG is a registry spec, this step is a no-op (npm's + # own integrity check applies). + run: | + case "$CONFORMANCE_PKG" in + https://*) + curl -fsSL "$CONFORMANCE_PKG" -o /tmp/conformance.tgz + echo "$CONFORMANCE_PKG_SHA256 /tmp/conformance.tgz" | sha256sum -c - + echo "CONFORMANCE_PKG=file:/tmp/conformance.tgz" >> "$GITHUB_ENV" + ;; + esac - run: uv sync --frozen --all-extras --package mcp-everything-server - name: Run server conformance (active suite) run: >- @@ -65,6 +87,19 @@ jobs: - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 24 + - name: Fetch and verify conformance harness + # Only when CONFORMANCE_PKG is a URL: download, check the recorded + # sha256, and re-point CONFORMANCE_PKG at the verified local tarball. + # When CONFORMANCE_PKG is a registry spec, this step is a no-op (npm's + # own integrity check applies). + run: | + case "$CONFORMANCE_PKG" in + https://*) + curl -fsSL "$CONFORMANCE_PKG" -o /tmp/conformance.tgz + echo "$CONFORMANCE_PKG_SHA256 /tmp/conformance.tgz" | sha256sum -c - + echo "CONFORMANCE_PKG=file:/tmp/conformance.tgz" >> "$GITHUB_ENV" + ;; + esac - run: uv sync --frozen --all-extras --package mcp - name: Run client conformance (all suite) # The harness runs all scenarios via unbounded Promise.all; with 40 diff --git a/docs/migration.md b/docs/migration.md index e64de8128c..cc49638c07 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -399,6 +399,10 @@ For an in-process `Client(server)` (where `server` is a `Server` or `MCPServer` For protocol 2026-07-28, a `tools/call` request may return an `InputRequiredResult` asking the client to supply additional input and retry. By default `call_tool` (on `ClientSession`, `Client`, and `ClientSessionGroup`) still returns `CallToolResult` and raises `RuntimeError` if the server requests input. Pass `allow_input_required=True` to receive the `InputRequiredResult` instead, then retry with `input_responses=` / `request_state=`. +### `call_tool` mirrors `x-mcp-header` arguments into `Mcp-Param-*` headers (SEP-2243) + +For protocol 2026-07-28 over Streamable HTTP, a tool's input-schema property may carry an `x-mcp-header` annotation. When a tool the client has listed is called, each annotated argument is mirrored into an `Mcp-Param-` request header (string verbatim, integer as decimal, boolean as `true`/`false`, base64-sentinel-wrapped when not header-safe; `null`/absent arguments are omitted). The argument is also left in the request body. `list_tools` caches a tool's annotations, so list a tool before calling it to enable mirroring; a tool the client never listed emits no `Mcp-Param-*` headers. Other transports ignore the annotation. + ### `McpError` renamed to `MCPError` The `McpError` exception class has been renamed to `MCPError` for consistent naming with the MCP acronym style used throughout the SDK. diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 0c6e0270c1..b9e6860562 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -41,6 +41,8 @@ NAME_BEARING_METHODS, encode_header_value, find_invalid_x_mcp_header, + mcp_param_headers, + x_mcp_header_map, ) from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher from mcp.shared.message import ClientMessageMetadata, SessionMessage @@ -68,7 +70,10 @@ def stamp(data: dict[str, Any], opts: CallOptions) -> None: def _make_modern_stamp( - protocol_version: str, client_info: dict[str, Any], capabilities: dict[str, Any] + protocol_version: str, + client_info: dict[str, Any], + capabilities: dict[str, Any], + resolve_param_headers: Callable[[str, Mapping[str, Any]], dict[str, str]], ) -> Callable[[dict[str, Any], CallOptions], None]: def stamp(data: dict[str, Any], opts: CallOptions) -> None: params = data.setdefault("params", {}) @@ -83,6 +88,8 @@ def stamp(data: dict[str, Any], opts: CallOptions) -> None: name_key = NAME_BEARING_METHODS.get(data["method"]) if name_key is not None and isinstance(name := params.get(name_key), str): headers[MCP_NAME_HEADER] = encode_header_value(name) + if data["method"] == "tools/call" and isinstance(name := params.get("name"), str): + headers.update(resolve_param_headers(name, params.get("arguments") or {})) return stamp @@ -215,6 +222,7 @@ 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._x_mcp_header_maps: dict[str, dict[tuple[str, ...], str]] = {} self._initialize_result: types.InitializeResult | None = None self._discover_result: types.DiscoverResult | None = None self._negotiated_version: str | None = None @@ -393,7 +401,7 @@ def adopt(self, result: types.InitializeResult | types.DiscoverResult) -> None: ) 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._stamp = _make_modern_stamp(mutual[-1], client_info, capabilities, self._resolve_param_headers) self._discover_result = result self._initialize_result = None self._negotiated_version = mutual[-1] @@ -646,6 +654,11 @@ async def call_tool( ) -> types.CallToolResult | types.InputRequiredResult: """Send a tools/call request with optional progress callback support. + On a modern (2026-07-28) connection, arguments annotated with `x-mcp-header` + in the tool's input schema are mirrored into `Mcp-Param-*` request headers. + The annotations are read from the tool's last `list_tools` entry, so list + the tool before calling it to enable header emission. + Args: input_responses: Responses to a prior `InputRequiredResult.input_requests`. request_state: Opaque state echoed from a prior `InputRequiredResult`. @@ -657,7 +670,6 @@ async def call_tool( RuntimeError: If the server returns an `InputRequiredResult` and ``allow_input_required`` is ``False``. """ - result = await self.send_request( types.CallToolRequest( params=types.CallToolRequestParams( @@ -683,6 +695,13 @@ async def call_tool( ) return result + def _resolve_param_headers(self, name: str, arguments: Mapping[str, Any]) -> dict[str, str]: + """`Mcp-Param-*` headers for a `tools/call`, or empty when the tool was never listed.""" + header_map = self._x_mcp_header_maps.get(name) + if header_map is None: + return {} + return mcp_param_headers(header_map, arguments) + async def _validate_tool_result(self, name: str, result: types.CallToolResult) -> None: """Validate the structured content of a tool result against its output schema.""" if name not in self._tool_output_schemas: @@ -767,7 +786,12 @@ async def list_tools(self, *, params: types.PaginatedRequestParams | None = None for tool in result.tools: if (reason := find_invalid_x_mcp_header(tool.input_schema)) is not None: logger.warning("dropping tool %r: invalid x-mcp-header (%s)", tool.name, reason) + # Evict any map cached from a prior valid listing so a stale entry can't + # mirror headers for a tool this listing dropped. + self._x_mcp_header_maps.pop(tool.name, None) continue + # Cache the arg→header map so a later tools/call mirrors it into Mcp-Param-* headers. + self._x_mcp_header_maps[tool.name] = x_mcp_header_map(tool.input_schema) kept.append(tool) result.tools = kept diff --git a/src/mcp/shared/inbound.py b/src/mcp/shared/inbound.py index a2a2a9c271..3eb16495ee 100644 --- a/src/mcp/shared/inbound.py +++ b/src/mcp/shared/inbound.py @@ -42,6 +42,7 @@ "InboundModernRoute", "MCP_METHOD_HEADER", "MCP_NAME_HEADER", + "MCP_PARAM_HEADER_PREFIX", "MCP_PROTOCOL_VERSION_HEADER", "NAME_BEARING_METHODS", "X_MCP_HEADER_KEY", @@ -49,6 +50,8 @@ "decode_header_value", "encode_header_value", "find_invalid_x_mcp_header", + "mcp_param_headers", + "x_mcp_header_map", ] MCP_PROTOCOL_VERSION_HEADER: Final = "mcp-protocol-version" @@ -216,6 +219,56 @@ def find_invalid_x_mcp_header(input_schema: Any) -> str | None: return None +MCP_PARAM_HEADER_PREFIX: Final = "Mcp-Param-" +"""Prefix the `x-mcp-header` token is joined to, forming the per-parameter HTTP header name.""" + + +def x_mcp_header_map(input_schema: Any) -> dict[tuple[str, ...], str]: + """Map each property carrying a valid `x-mcp-header` to its annotation token, keyed by property path. + + The key is the chain of `properties` keys from the schema root to the + annotated property; a top-level property has a one-element path, a nested + one a longer path. Call only on a schema that + :func:`find_invalid_x_mcp_header` accepts; an invalid schema yields an + undefined subset. + """ + mapping: dict[tuple[str, ...], str] = {} + for path, schema in _walk_schema_positions(input_schema): + if path and isinstance(header := schema.get(X_MCP_HEADER_KEY), str): + mapping[path] = header + return mapping + + +def mcp_param_headers(header_map: Mapping[tuple[str, ...], str], arguments: Mapping[str, Any]) -> dict[str, str]: + """Build the `Mcp-Param-*` headers a `tools/call` mirrors from its arguments. + + For each `(path, token)` in `header_map`, read the value at that property + path in `arguments` and, when it is present and not `None`, emit + `Mcp-Param-` carrying it: `bool` as `true`/`false`, other scalars via + `str`, each passed through :func:`encode_header_value` so a non-token value + is base64-wrapped. A path that hits a missing key or a non-mapping node is + skipped, matching the spec's "omit the header when no value is present". + """ + headers: dict[str, str] = {} + for path, token in header_map.items(): + value = _value_at_path(arguments, path) + if value is None: + continue + rendered = ("true" if value else "false") if isinstance(value, bool) else str(value) + headers[f"{MCP_PARAM_HEADER_PREFIX}{token}"] = encode_header_value(rendered) + return headers + + +def _value_at_path(arguments: Mapping[str, Any], path: tuple[str, ...]) -> Any: + """Read the value at a `properties`-key path in `arguments`, or `None` if any step is missing or non-mapping.""" + node: Any = arguments + for key in path: + if not isinstance(node, Mapping): + return None + node = cast("Mapping[str, Any]", node).get(key) + return node + + # 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/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 50449c459a..0150513e26 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -3372,6 +3372,19 @@ def __post_init__(self) -> None: transports=("streamable-http",), note="Only observable over streamable HTTP: headers are derived from the body envelope at the transport seam.", ), + "client-transport:http:custom-param-headers": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#custom-headers-from-tool-parameters", + behavior=( + "On a tools/call, a client mirrors each argument annotated with x-mcp-header in the tool's " + "inputSchema into an Mcp-Param- header -- string as-is, integer as decimal, boolean as " + "true/false, base64-sentinel-wrapped when not header-safe -- omitting null or absent arguments and " + "never mirroring unannotated parameters. The schema is taken from the tool's last list_tools entry; " + "a tool the client never listed emits no Mcp-Param-* headers." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note="Only observable over streamable HTTP: headers are derived from the cached tool schema at the seam.", + ), "client-transport:http:stateless-ignores-session-id": Requirement( source=f"{SPEC_2026_BASE_URL}/basic/transports#stateless-request-headers", behavior=( diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py index 77ac850182..a8f1f53c7b 100644 --- a/tests/interaction/transports/test_hosting_http_modern.py +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -38,6 +38,7 @@ from mcp_types.version import LATEST_MODERN_VERSION from mcp import MCPError +from mcp.client.client import Client from mcp.client.session import ClientSession from mcp.client.streamable_http import streamable_http_client from mcp.server import Server, ServerRequestContext @@ -388,3 +389,164 @@ async def on_response(response: httpx.Response) -> None: assert len(responses) == len(requests) assert all("mcp-session-id" not in r.headers for r in requests) assert all("mcp-session-id" not in r.headers for r in responses) + + +_CUSTOM_HEADER_TOOL = Tool( + name="run", + input_schema={ + "type": "object", + "properties": { + "region": {"type": "string", "x-mcp-header": "Region"}, + "priority": {"type": "integer", "x-mcp-header": "Priority"}, + "verbose": {"type": "boolean", "x-mcp-header": "Verbose"}, + "note": {"type": "string", "x-mcp-header": "Note"}, + "query": {"type": "string"}, + }, + "required": ["region"], + }, +) + + +def _custom_header_server() -> Server: + """A server with one tool whose schema annotates four args with `x-mcp-header` and leaves `query` plain.""" + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[_CUSTOM_HEADER_TOOL], ttl_ms=0, cache_scope="public") + + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + return CallToolResult(content=[TextContent(text="ok")]) + + return Server("custom-headers", on_list_tools=list_tools, on_call_tool=call_tool) + + +@requirement("client-transport:http:custom-param-headers") +async def test_modern_client_mirrors_x_mcp_header_args_into_mcp_param_headers() -> None: + """A tools/call mirrors the tool's `x-mcp-header` arguments into `Mcp-Param-*` headers. + + After `list_tools` caches the tool's annotations, the client renders each annotated argument into + its header per the spec's Value Encoding rules: `region` verbatim, `priority` as a decimal, `verbose` + as `false`, and the non-ASCII `note` base64-sentinel-wrapped. The unannotated `query` and the omitted + `verbose`-sibling stay out of the headers, and every mirrored value remains in the request body. Asserted + at the wire because the client never surfaces the outgoing headers. + """ + requests: list[httpx.Request] = [] + + async def on_request(request: httpx.Request) -> None: + requests.append(request) + + discover = DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + server_info=Implementation(name="srv", version="0"), + ) + with anyio.fail_after(5): + async with ( + mounted_app(_custom_header_server(), on_request=on_request) as (http, _), + Client( + streamable_http_client(f"{BASE_URL}/mcp", http_client=http), + mode=LATEST_MODERN_VERSION, + prior_discover=discover, + ) as client, + ): + await client.list_tools() + await client.call_tool("run", {"region": "us-west1", "priority": 42, "verbose": False, "note": "héllo"}) + + call = next(r for r in requests if json.loads(r.content)["method"] == "tools/call") + assert {k: v for k, v in call.headers.items() if k.startswith("mcp-param-")} == snapshot( + { + "mcp-param-region": "us-west1", + "mcp-param-priority": "42", + "mcp-param-verbose": "false", + "mcp-param-note": "=?base64?aMOpbGxv?=", + } + ) + # Mirroring is additive: the arguments are unchanged in the body. + assert json.loads(call.content)["params"]["arguments"] == snapshot( + {"region": "us-west1", "priority": 42, "verbose": False, "note": "héllo"} + ) + + +@requirement("client-transport:http:custom-param-headers") +async def test_modern_client_emits_no_param_headers_for_an_unlisted_tool() -> None: + """A `tools/call` for a tool the client never listed carries no `Mcp-Param-*` headers. + + The spec lets a client that lacks the tool's `inputSchema` send the request without custom headers. + The call is made with no prior `list_tools`, so the first `tools/call` POST -- captured before the + implicit output-schema `list_tools` runs -- has no cached annotations and emits no `Mcp-Param-*` header. + """ + requests: list[httpx.Request] = [] + + async def on_request(request: httpx.Request) -> None: + if json.loads(request.content)["method"] == "tools/call": + requests.append(request) + + discover = DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + server_info=Implementation(name="srv", version="0"), + ) + with anyio.fail_after(5): + async with ( + mounted_app(_custom_header_server(), on_request=on_request) as (http, _), + Client( + streamable_http_client(f"{BASE_URL}/mcp", http_client=http), + mode=LATEST_MODERN_VERSION, + prior_discover=discover, + ) as client, + ): + await client.call_tool("run", {"region": "us-west1"}) + + assert not any(k.startswith("mcp-param-") for k in requests[0].headers) + + +@requirement("client-transport:http:custom-param-headers") +async def test_modern_client_stops_mirroring_after_a_re_list_drops_the_tool() -> None: + """A re-list that drops a previously valid tool stops mirroring its `x-mcp-header` args. + + The tool is first listed with a valid annotation (so a call mirrors `Mcp-Param-Region`), then re-listed + with an invalid annotation -- the modern client drops it and evicts the cached map, so a later `tools/call` + by name carries no `Mcp-Param-*` header. Asserted at the wire, where the eviction is observable. + """ + schema = {"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "Region"}}} + bad_schema = {"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "bad name"}}} + valid = Tool(name="run", input_schema=schema) + invalid = Tool(name="run", input_schema=bad_schema) + listings = iter([valid, invalid]) + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[next(listings)], ttl_ms=0, cache_scope="public") + + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + return CallToolResult(content=[TextContent(text="ok")]) + + server = Server("evict", on_list_tools=list_tools, on_call_tool=call_tool) + + tool_calls: list[httpx.Request] = [] + + async def on_request(request: httpx.Request) -> None: + if json.loads(request.content)["method"] == "tools/call": + tool_calls.append(request) + + discover = DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + server_info=Implementation(name="srv", version="0"), + ) + 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=LATEST_MODERN_VERSION, + prior_discover=discover, + ) as client, + ): + assert [t.name for t in (await client.list_tools()).tools] == ["run"] + await client.call_tool("run", {"a": "x"}) + + assert [t.name for t in (await client.list_tools()).tools] == [] + await client.call_tool("run", {"a": "x"}) + + before, after = tool_calls + assert before.headers.get("mcp-param-region") == "x" + assert not any(k.startswith("mcp-param-") for k in after.headers) diff --git a/tests/shared/test_inbound.py b/tests/shared/test_inbound.py index 8478c37339..11e20d632c 100644 --- a/tests/shared/test_inbound.py +++ b/tests/shared/test_inbound.py @@ -40,6 +40,8 @@ decode_header_value, encode_header_value, find_invalid_x_mcp_header, + mcp_param_headers, + x_mcp_header_map, ) CLIENT_INFO = {"name": "t", "version": "0"} @@ -505,3 +507,70 @@ def test_find_invalid_x_mcp_header_reports_dotted_path_for_nested_property() -> schema = _schema(outer={"type": "object", "properties": {"r": {"type": "object", "x-mcp-header": "R"}}}) reason = find_invalid_x_mcp_header(schema) assert reason is not None and "'outer.r'" in reason + + +# --- x_mcp_header_map ---------------------------------------------------------- + + +def test_x_mcp_header_map_keys_top_level_and_nested_properties_by_path() -> None: + """Each annotated property maps to its token under its full `properties` path; unannotated props are absent.""" + schema = _schema( + region={"type": "string", "x-mcp-header": "Region"}, + query={"type": "string"}, + outer={"type": "object", "properties": {"inner": {"type": "string", "x-mcp-header": "Inner"}}}, + ) + assert x_mcp_header_map(schema) == {("region",): "Region", ("outer", "inner"): "Inner"} + + +@pytest.mark.parametrize("input_schema", [None, "not-a-mapping", {"type": "object"}]) +def test_x_mcp_header_map_empty_for_schemas_without_annotations(input_schema: Any) -> None: + assert x_mcp_header_map(input_schema) == {} + + +# --- mcp_param_headers --------------------------------------------------------- + + +def test_mcp_param_headers_renders_primitive_types_per_spec() -> None: + """String verbatim, integer as decimal, boolean as lowercase `true`/`false`, header named `Mcp-Param-`.""" + header_map = {("region",): "Region", ("priority",): "Priority", ("verbose",): "Verbose", ("debug",): "Debug"} + arguments = {"region": "us-west1", "priority": 42, "verbose": False, "debug": True} + assert mcp_param_headers(header_map, arguments) == { + "Mcp-Param-Region": "us-west1", + "Mcp-Param-Priority": "42", + "Mcp-Param-Verbose": "false", + "Mcp-Param-Debug": "true", + } + + +@pytest.mark.parametrize( + ("value", "encoded"), + [ + pytest.param("us-west1", "us-west1", id="plain-ascii"), + pytest.param("Hello, 世界", "=?base64?SGVsbG8sIOS4lueVjA==?=", id="non-ascii"), + pytest.param(" padded ", "=?base64?IHBhZGRlZCA=?=", id="edge-whitespace"), + pytest.param("line1\nline2", "=?base64?bGluZTEKbGluZTI=?=", id="control-char"), + pytest.param("=?base64?literal?=", "=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?=", id="sentinel-lookalike"), + ], +) +def test_mcp_param_headers_base64_wraps_header_unsafe_strings(value: str, encoded: str) -> None: + """Matches the spec's Value Encoding table: a non-header-safe string is base64-sentinel wrapped.""" + assert mcp_param_headers({("v",): "Val"}, {"v": value}) == {"Mcp-Param-Val": encoded} + + +def test_mcp_param_headers_omits_absent_or_null_arguments() -> None: + """A path that hits a missing key or a `None` value emits no header (spec: omit when no value is present).""" + header_map = {("present",): "Present", ("missing",): "Missing", ("nulled",): "Nulled"} + assert mcp_param_headers(header_map, {"present": "x", "nulled": None}) == {"Mcp-Param-Present": "x"} + + +def test_mcp_param_headers_reads_nested_argument_path() -> None: + """A nested annotated property reads its value at the matching nested `arguments` path.""" + headers = mcp_param_headers({("outer", "inner"): "Inner"}, {"outer": {"inner": "deep"}}) + assert headers == {"Mcp-Param-Inner": "deep"} + + +def test_mcp_param_headers_omits_when_nested_path_is_broken() -> None: + """A nested path through a non-mapping or missing intermediate node yields no header.""" + header_map = {("outer", "inner"): "Inner"} + assert mcp_param_headers(header_map, {"outer": "not-a-mapping"}) == {} + assert mcp_param_headers(header_map, {}) == {} From cc596195bb7a57b837115281e311f49062a0fa1b Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 26 Jun 2026 15:27:57 +0200 Subject: [PATCH 010/100] Switch RFC7523OAuthClientProvider warning to MCPDeprecationWarning (#2996) --- src/mcp/client/auth/extensions/client_credentials.py | 3 ++- tests/client/auth/extensions/test_client_credentials.py | 3 ++- tests/docs_src/test_oauth_clients.py | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/mcp/client/auth/extensions/client_credentials.py b/src/mcp/client/auth/extensions/client_credentials.py index 5efd596110..1daf55c1c5 100644 --- a/src/mcp/client/auth/extensions/client_credentials.py +++ b/src/mcp/client/auth/extensions/client_credentials.py @@ -19,6 +19,7 @@ from mcp.client.auth import OAuthClientProvider, OAuthFlowError, OAuthTokenError, TokenStorage from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata +from mcp.shared.exceptions import MCPDeprecationWarning class ClientCredentialsOAuthProvider(OAuthClientProvider): @@ -412,7 +413,7 @@ def __init__( warnings.warn( "RFC7523OAuthClientProvider is deprecated. Use ClientCredentialsOAuthProvider " "or PrivateKeyJWTOAuthProvider instead.", - DeprecationWarning, + MCPDeprecationWarning, stacklevel=2, ) super().__init__(server_url, client_metadata, storage, redirect_handler, callback_handler, timeout) diff --git a/tests/client/auth/extensions/test_client_credentials.py b/tests/client/auth/extensions/test_client_credentials.py index a964891316..3ad649d1f2 100644 --- a/tests/client/auth/extensions/test_client_credentials.py +++ b/tests/client/auth/extensions/test_client_credentials.py @@ -20,6 +20,7 @@ OAuthMetadata, OAuthToken, ) +from mcp.shared.exceptions import MCPDeprecationWarning class MockTokenStorage: @@ -68,7 +69,7 @@ async def callback_handler() -> AuthorizationCodeResult: # pragma: no cover return AuthorizationCodeResult(code="test_auth_code", state="test_state") with warnings.catch_warnings(): - warnings.simplefilter("ignore", DeprecationWarning) + warnings.simplefilter("ignore", MCPDeprecationWarning) return RFC7523OAuthClientProvider( server_url="https://api.example.com/v1/mcp", client_metadata=client_metadata, diff --git a/tests/docs_src/test_oauth_clients.py b/tests/docs_src/test_oauth_clients.py index 3cb196eb67..a85eab388f 100644 --- a/tests/docs_src/test_oauth_clients.py +++ b/tests/docs_src/test_oauth_clients.py @@ -14,6 +14,7 @@ static_assertion_provider, ) from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken +from mcp.shared.exceptions import MCPDeprecationWarning # See test_index.py for why this is a per-module mark and not a conftest hook. pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] @@ -105,7 +106,7 @@ async def test_the_one_more_provider_is_private_key_jwt() -> None: async def test_the_page_does_not_count_the_deprecated_provider() -> None: """Why the `!!! info` says *one* more provider: `RFC7523OAuthClientProvider` warns on construction.""" - with pytest.warns(DeprecationWarning, match="RFC7523OAuthClientProvider is deprecated"): + with pytest.warns(MCPDeprecationWarning, match="RFC7523OAuthClientProvider is deprecated"): RFC7523OAuthClientProvider( server_url="http://localhost:8001/mcp", client_metadata=tutorial001.oauth.context.client_metadata, From b31d95a429bcff7b9bbc7a91e01dac1a21cc0a4b Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 26 Jun 2026 15:47:37 +0200 Subject: [PATCH 011/100] Make OpenTelemetry tracing the single default middleware (#2995) --- docs/advanced/middleware.md | 38 ++------- docs/advanced/opentelemetry.md | 107 ++++++++++++++++++++++++++ docs/tutorial/logging.md | 4 +- docs_src/opentelemetry/__init__.py | 0 docs_src/opentelemetry/tutorial001.py | 9 +++ mkdocs.yml | 1 + src/mcp/server/lowlevel/server.py | 6 +- src/mcp/server/runner.py | 106 ++++++++----------------- tests/docs_src/test_opentelemetry.py | 35 +++++++++ tests/server/test_otel.py | 76 ++++++++++++------ tests/server/test_runner.py | 106 ------------------------- tests/shared/test_otel.py | 10 ++- 12 files changed, 260 insertions(+), 238 deletions(-) create mode 100644 docs/advanced/opentelemetry.md create mode 100644 docs_src/opentelemetry/__init__.py create mode 100644 docs_src/opentelemetry/tutorial001.py create mode 100644 tests/docs_src/test_opentelemetry.py diff --git a/docs/advanced/middleware.md b/docs/advanced/middleware.md index 6fdf9d4a19..cb10c6cf1c 100644 --- a/docs/advanced/middleware.md +++ b/docs/advanced/middleware.md @@ -79,33 +79,12 @@ In increasing order of how much you should hesitate: an elicitation) while handling `initialize` therefore **deadlocks the connection**: the response you are waiting for can never be read. Fire-and-forget notifications are fine. -## `OpenTelemetryMiddleware` - -The SDK ships one middleware: `OpenTelemetryMiddleware`. Construct it and append it -(`server.middleware.append(OpenTelemetryMiddleware())`), exactly the line you already wrote -for `log_timing`. - -Every inbound message becomes a `SERVER` span named after the method and its target, so a -`tools/call` for `search_books` is the span `tools/call search_books`. - -* Every span carries `mcp.method.name` and `mcp.protocol.version`; a request's span also - carries its JSON-RPC request id (a notification has none). -* A `tools/call` span gets OpenTelemetry's GenAI semantic conventions, - `gen_ai.operation.name` (`"execute_tool"`) and `gen_ai.tool.name`, so a tracing UI groups - your tool calls the way it groups any other agent's. A `prompts/get` span gets - `gen_ai.prompt.name`. The list methods carry no `gen_ai.*` keys. -* A handler that raises sets the span's status to error. So does a tool result with - `is_error=True`. - -!!! tip - The SDK depends only on `opentelemetry-api`. With no exporter installed those spans are - no-ops, so appending this middleware costs you nothing. Install `opentelemetry-sdk` plus an - exporter and everything lights up, with no server change. - -The import is the catch. The class lives at `from mcp.server._otel import OpenTelemetryMiddleware` -today, and the leading underscore is not an accident: it is the same provisional flag this whole -page opened with. The SDK has not given it a public spelling yet, so the import path is the one -line here you should expect to change. +## The one middleware that ships on by default + +The SDK ships exactly one middleware, and it is already on your server's list: the one that +emits an OpenTelemetry span for every message. You don't append it, and most of the time you +don't think about it. It is a no-op until you install an exporter, and it has its own page: +**OpenTelemetry**. !!! info If you have written ASGI middleware, you already know this shape. Starlette's @@ -121,9 +100,8 @@ line here you should expect to change. unknown methods) and runs outermost-first. * `ctx.request_id is None` is how you tell a notification from a request. * Raise instead of calling `call_next` to refuse one message; the connection survives. -* `OpenTelemetryMiddleware` turns each message into a span (with GenAI attributes on tool - calls and prompt gets) for the price of one `append`, and costs nothing until you install - an exporter. +* The SDK's own OpenTelemetry tracing is a middleware too, already on the list. See + **OpenTelemetry**. * The whole surface is provisional. Observe with it; don't build on it. That is everything that wraps a request. **Authorization** is what decides whether the request diff --git a/docs/advanced/opentelemetry.md b/docs/advanced/opentelemetry.md new file mode 100644 index 0000000000..0d971d6e08 --- /dev/null +++ b/docs/advanced/opentelemetry.md @@ -0,0 +1,107 @@ +# OpenTelemetry + +Your server is already traced. You don't have to add anything. + +Every server you create emits an [OpenTelemetry](https://opentelemetry.io/) span for every +message it handles. You didn't write that, and you don't import it. It is there the moment you +call `MCPServer(...)`. + +```python title="server.py" +--8<-- "docs_src/opentelemetry/tutorial001.py" +``` + +That is a complete, traced server. Call `search_books` and a span is created for it. The same is +true for the low-level `Server`: the tracing lives on both. + +## What you get + +Every inbound message becomes a `SERVER` span named after the method and its target. So a +`tools/call` for `search_books` is the span `tools/call search_books`, and a bare `tools/list` +is just `tools/list`. + +Each span carries a few attributes: + +* `mcp.method.name` and `mcp.protocol.version`, on every span. +* `jsonrpc.request.id`, on a request (a notification has none). +* A handler that raises sets the span status to error. So does a tool result with `is_error=True`. + +And because tracing a tool call is such a common thing to want, `tools/call` spans speak +OpenTelemetry's [GenAI semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/): + +* `gen_ai.operation.name`, set to `"execute_tool"`. +* `gen_ai.tool.name`, set to the tool being called. + +A `prompts/get` span gets `gen_ai.prompt.name` in the same spirit. The list methods carry no +`gen_ai.*` keys, because there is nothing to name. + +!!! tip + Those GenAI attributes are the reason a tracing UI groups your tool calls the way it groups + any other agent's. You get that grouping for free, with no extra code. + +## It costs nothing until you want it + +Here is the part that makes "on by default" a comfortable default. + +The SDK depends only on `opentelemetry-api`, the lightweight half of OpenTelemetry. With no SDK +and no exporter installed, creating a span is a no-op. So the spans your server is emitting right +now cost you almost nothing, and nobody is collecting them. + +The day you want to *see* them, you install the other half and point it somewhere: + +```console +uv add opentelemetry-sdk opentelemetry-exporter-otlp +``` + +Configure an exporter the usual OpenTelemetry way, and every span the SDK has been quietly +creating lights up. Your server code does not change. Not one line. + +!!! info + [Pydantic Logfire](https://logfire.pydantic.dev/) is one such backend, and it does the + configuration for you: `pip install logfire`, `logfire.configure()`, and your MCP spans show + up in the live view. It is built on OpenTelemetry, so anything below applies to it too. + +## Traces that cross the wire + +A trace is most useful when it follows a request from the client into the server, in one +connected picture. + +When the client and the server both run the SDK, that connection is automatic. The client injects +the [W3C trace context](https://www.w3.org/TR/trace-context/) into the request, and the server +reads it back out, so the server span nests under the client span in the same trace. This is +[SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414), and you get it without +asking. + +If the inbound message carries no trace context, for example a request from a client that is not +the SDK, the server span simply parents to whatever span is already current on the server, rather +than starting a brand-new orphan trace. + +## Turning it off + +Tracing is a middleware, the first one on your server's list. If you really want a server that +emits no spans, take it off: + +```python +from mcp.server._otel import OpenTelemetryMiddleware + +mcp._lowlevel_server.middleware[:] = [ + m for m in mcp._lowlevel_server.middleware if not isinstance(m, OpenTelemetryMiddleware) +] +``` + +!!! warning + That import has a leading underscore, and that is on purpose. The class is provisional, the + same way [`Server.middleware`](middleware.md) is provisional, so the import path is something + you should expect to change. You almost never need this: with no exporter installed the spans + are free, so the usual answer is to leave them on and not install an exporter. + +## Recap + +* Every `MCPServer` and every low-level `Server` emits one `SERVER` span per inbound message, out + of the box. You write nothing. +* Spans carry `mcp.method.name` and `mcp.protocol.version`; `tools/call` and `prompts/get` also + carry GenAI attributes so your tool calls group like any other agent's. +* It costs nothing until you install an OpenTelemetry SDK and an exporter, and then it lights up + with no change to your server. +* Client-to-server trace context propagates automatically when both sides run the SDK. + +Next, the thing that decides whether a request runs at all: **Authorization**. diff --git a/docs/tutorial/logging.md b/docs/tutorial/logging.md index f4a58b70f2..628c4ce268 100644 --- a/docs/tutorial/logging.md +++ b/docs/tutorial/logging.md @@ -64,8 +64,8 @@ went to standard error: the terminal, not the wire. !!! info If what you actually want is *tracing* (every request, how long it took, whether it failed), you - don't want log lines, you want spans. The SDK ships an `OpenTelemetryMiddleware` for exactly that. - See **Middleware**. + don't want log lines, you want spans. Your server already emits them: the SDK traces every + message with OpenTelemetry out of the box. See **OpenTelemetry**. ## Recap diff --git a/docs_src/opentelemetry/__init__.py b/docs_src/opentelemetry/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/opentelemetry/tutorial001.py b/docs_src/opentelemetry/tutorial001.py new file mode 100644 index 0000000000..3e66e90844 --- /dev/null +++ b/docs_src/opentelemetry/tutorial001.py @@ -0,0 +1,9 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + + +@mcp.tool() +def search_books(query: str) -> str: + """Search the catalog by title or author.""" + return f"Found 3 books matching {query!r}." diff --git a/mkdocs.yml b/mkdocs.yml index a703713ba8..af32c74ab6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -42,6 +42,7 @@ nav: - The low-level Server: advanced/low-level-server.md - Pagination: advanced/pagination.md - Middleware: advanced/middleware.md + - OpenTelemetry: advanced/opentelemetry.md - Authorization: advanced/authorization.md - OAuth clients: advanced/oauth-clients.md - Session groups: advanced/session-groups.md diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index a4fbc10057..ea6ea77df8 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -52,6 +52,7 @@ async def main(): from starlette.routing import Mount, Route from typing_extensions import TypeVar +from mcp.server._otel import OpenTelemetryMiddleware from mcp.server.auth.middleware.auth_context import AuthContextMiddleware from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenVerifier @@ -231,10 +232,13 @@ def __init__( # Context-tier middleware: wraps every inbound request (including # `initialize`, lookup, validation, handler) with # `(ctx, call_next)`. Applied in `ServerRunner._on_request`. + # `OpenTelemetryMiddleware` ships on by default so every server emits a + # SERVER span per message; it is a no-op until an OTel exporter is + # installed. Drop it from this list to opt out. # TODO(L54): provisional - signature and semantics change with the # Context/middleware rework (covariant `Context[L]`, outbound seam) before # v2 final. - self.middleware: list[ServerMiddleware[LifespanResultT]] = [] + self.middleware: list[ServerMiddleware[LifespanResultT]] = [OpenTelemetryMiddleware()] logger.debug("Initializing server %r", name) _spec_requests: list[tuple[str, type[BaseModel], RequestHandler[LifespanResultT, Any] | None]] = [ diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index f75e2ca420..55d43c2d91 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -37,7 +37,6 @@ ) from mcp_types import methods as _methods from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION -from opentelemetry.trace import SpanKind, StatusCode from pydantic import BaseModel, ValidationError from typing_extensions import TypeVar @@ -45,7 +44,6 @@ from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext from mcp.server.models import InitializationOptions from mcp.server.session import ServerSession -from mcp.shared._otel import extract_trace_context, otel_span from mcp.shared._stream_protocols import ReadStream, WriteStream from mcp.shared.dispatcher import DispatchContext, Dispatcher, DispatchMiddleware, OnNotify, OnRequest from mcp.shared.exceptions import MCPError @@ -62,7 +60,6 @@ "ServerRunner", "aclose_shielded", "modern_on_request", - "otel_middleware", "serve_connection", "serve_loop", "serve_one", @@ -91,58 +88,6 @@ def _extract_meta(params: Mapping[str, Any] | None) -> RequestParamsMeta | None: return None -def otel_middleware(call_next: OnRequest) -> OnRequest: - """Dispatch-tier middleware that wraps each request in an OpenTelemetry span. - - Mirrors the span shape of the existing `Server._handle_request`: span name - `"MCP handle []"`, `mcp.method.name` attribute, W3C - trace context extracted from `params._meta` (SEP-414), and an ERROR - status if the handler raises. - """ - - async def wrapped( - dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None - ) -> dict[str, Any]: - target: str | None - match params: - case {"name": str() as target}: - pass - case _: - target = None - parent: Any | None - match params: - case {"_meta": {**meta}}: - parent = extract_trace_context(meta) - case _: - parent = None - span_name = f"MCP handle {method}{f' {target}' if target else ''}" - # `otel_middleware` wraps `on_request` only, so `request_id` is always set. - attributes = {"mcp.method.name": method, "jsonrpc.request.id": str(dctx.request_id)} - with otel_span( - span_name, - kind=SpanKind.SERVER, - attributes=attributes, - context=parent, - record_exception=False, - set_status_on_exception=False, - ) as span: - try: - return await call_next(dctx, method, params) - except MCPError as e: - span.set_status(StatusCode.ERROR, e.error.message) - raise - except ValidationError: - # Mirror the sanitized wire response; pydantic messages carry client input. - span.set_status(StatusCode.ERROR, "Invalid request parameters") - raise - except Exception as e: - span.record_exception(e) - span.set_status(StatusCode.ERROR, str(e)) - raise - - return wrapped - - def _dump_result(result: Any) -> dict[str, Any]: if result is None: return {} @@ -196,7 +141,10 @@ class ServerRunner(Generic[LifespanT]): _: KW_ONLY init_options: InitializationOptions | None = None """`InitializeResult` payload. Defaults to `server.create_initialization_options()`.""" - dispatch_middleware: Sequence[DispatchMiddleware] = (otel_middleware,) + dispatch_middleware: Sequence[DispatchMiddleware] = () + """Raw dispatch-tier wrappers `(dctx, method, params) -> dict`, applied outermost-first + around `_on_request`. Empty by default; OpenTelemetry tracing lives at the context tier + (`OpenTelemetryMiddleware`, seeded into `Server.middleware`).""" @cached_property def on_request(self) -> OnRequest: @@ -223,7 +171,6 @@ async def _on_request( meta = _extract_meta(params) version = self.connection.protocol_version ctx = self._make_context(dctx, method, params, meta, version) - is_spec_method = method in _methods.SPEC_CLIENT_METHODS async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> HandlerResult: # Read method/params off `ctx` so a middleware that rewrote them via @@ -242,7 +189,7 @@ async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> HandlerResult: # the gate become a per-version legacy path then. Initialize runs inline # (read loop parked), so awaiting the peer anywhere on this path deadlocks. if method == "initialize": - return self._handle_initialize(params) + return self._serialize(method, version, self._handle_initialize(params)) # Methods without a handler are METHOD_NOT_FOUND regardless of # initialization state: JSON-RPC 2.0 reserves -32601 for "not # available on this server", and clients probing a server before @@ -261,25 +208,14 @@ async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> HandlerResult: if isinstance(result, ErrorData): # Raise inside the chain so middleware observes the failure. raise MCPError.from_error_data(result) - return result + # Dump and serialize inside the chain so the OpenTelemetry span (the + # outermost middleware) records a failing handler return shape too. + return self._serialize(method, version, result) call = self._compose_server_middleware(_inner) + # `_inner` already produced the wire dict; a middleware that short-circuited + # without `call_next` is trusted to return its own well-formed result. result = _dump_result(await call(ctx)) - # TODO(L56): reject resultType values outside {"complete", "input_required"} unless the - # corresponding extension is in this request's _meta clientCapabilities.extensions; the - # explicit MUST-reject is client-side (basic/index.mdx ResultType), this enforces it proactively. - if is_spec_method: - try: - result = _methods.serialize_server_result(method, version, result) - except KeyError: - # Middleware short-circuited a wrong-version spec method without - # calling `call_next`; it owns the result shape. - pass - except ValidationError: - # Server bug, not client fault. Detail stays in the server log: - # pydantic messages echo the result body. - logger.exception("handler for %r returned an invalid result", method) - raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result") from None if method == "initialize": # Commit only on chain success, so a middleware veto leaves no state. # Race-free: the read loop is parked until this call returns. @@ -387,6 +323,28 @@ def _make_context( close_standalone_sse_stream=close_standalone_sse_stream, ) + @staticmethod + def _serialize(method: str, version: str, result: HandlerResult) -> dict[str, Any]: + """Dump a handler result to the wire dict, serializing spec methods. + + Runs inside the middleware chain so the OpenTelemetry span observes a + failing return shape (unsupported type, malformed spec result) as an + error rather than closing on a request that the client sees fail. + """ + dumped = _dump_result(result) + # TODO(L56): reject resultType values outside {"complete", "input_required"} unless the + # corresponding extension is in this request's _meta clientCapabilities.extensions; the + # explicit MUST-reject is client-side (basic/index.mdx ResultType), this enforces it proactively. + if method not in _methods.SPEC_CLIENT_METHODS: + return dumped + try: + return _methods.serialize_server_result(method, version, dumped) + except ValidationError: + # Server bug, not client fault. Detail stays in the server log: + # pydantic messages echo the result body. + logger.exception("handler for %r returned an invalid result", method) + raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result") from None + @staticmethod def _negotiate_initialize(params: Mapping[str, Any] | None) -> tuple[InitializeRequestParams, str]: """Validate `initialize` params and pick the protocol version.""" diff --git a/tests/docs_src/test_opentelemetry.py b/tests/docs_src/test_opentelemetry.py new file mode 100644 index 0000000000..00f3af8aac --- /dev/null +++ b/tests/docs_src/test_opentelemetry.py @@ -0,0 +1,35 @@ +"""`docs/advanced/opentelemetry.md`: every claim the page makes, proved against the real SDK.""" + +import pytest +from logfire.testing import CaptureLogfire + +from docs_src.opentelemetry import tutorial001 +from mcp import Client + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_a_plain_server_is_traced_with_no_extra_code(capfire: CaptureLogfire) -> None: + """tutorial001: calling a tool emits a `tools/call` SERVER span, though the example adds no middleware.""" + async with Client(tutorial001.mcp) as client: + await client.call_tool("search_books", {"query": "dune"}) + + spans = {s["name"]: s for s in capfire.exporter.exported_spans_as_dict()} + assert "tools/call search_books" in spans + + attributes = spans["tools/call search_books"]["attributes"] + assert attributes["mcp.method.name"] == "tools/call" + assert attributes["gen_ai.operation.name"] == "execute_tool" + assert attributes["gen_ai.tool.name"] == "search_books" + + +async def test_client_and_server_share_one_trace(capfire: CaptureLogfire) -> None: + """When both sides run the SDK, the client and server spans land in one trace (SEP-414).""" + async with Client(tutorial001.mcp, mode="legacy") as client: + await client.call_tool("search_books", {"query": "dune"}) + + spans = {s["name"]: s for s in capfire.exporter.exported_spans_as_dict()} + client_span = spans["MCP send tools/call search_books"] + server_span = spans["tools/call search_books"] + assert server_span["context"]["trace_id"] == client_span["context"]["trace_id"] diff --git a/tests/server/test_otel.py b/tests/server/test_otel.py index 0cf843eeff..4c4a86d14f 100644 --- a/tests/server/test_otel.py +++ b/tests/server/test_otel.py @@ -1,4 +1,9 @@ -"""Tests for `OpenTelemetryMiddleware` (the context-tier OTel span middleware).""" +"""Tests for `OpenTelemetryMiddleware` (the context-tier OTel span middleware). + +Every `Server` ships `OpenTelemetryMiddleware` at the head of `Server.middleware`, +so these tests assert against the default-configured server rather than appending +the middleware by hand. +""" from dataclasses import replace from typing import Any @@ -6,6 +11,7 @@ import anyio import pytest from mcp_types import ( + INTERNAL_ERROR, INVALID_PARAMS, CallToolRequestParams, CallToolResult, @@ -21,8 +27,7 @@ from mcp.server._otel import OpenTelemetryMiddleware from mcp.server.context import CallNext from mcp.server.lowlevel.server import Server -from mcp.server.runner import otel_middleware -from mcp.shared._otel import inject_trace_context +from mcp.shared._otel import inject_trace_context, otel_span from mcp.shared.exceptions import MCPError from .conftest import SpanCapture @@ -41,10 +46,14 @@ async def _ok_tool(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]: return {"content": [], "isError": False} +def test_server_ships_opentelemetry_middleware_by_default() -> None: + server = Server(name="test-server", version="0.0.1") + assert any(isinstance(m, OpenTelemetryMiddleware) for m in server.middleware) + + @pytest.mark.anyio async def test_emits_server_span_with_method_and_target(server: SrvT, spans: SpanCapture): server.add_request_handler("tools/call", CallToolRequestParams, _ok_tool) - server.middleware.append(OpenTelemetryMiddleware()) async with connected_runner(server) as (client, _): spans.clear() result = await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}}) @@ -65,7 +74,6 @@ async def err_tool(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]: return {"content": [], "isError": True} server.add_request_handler("tools/call", CallToolRequestParams, err_tool) - server.middleware.append(OpenTelemetryMiddleware()) async with connected_runner(server) as (client, _): spans.clear() await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}}) @@ -81,7 +89,6 @@ async def err_tool(ctx: Ctx, params: CallToolRequestParams) -> CallToolResult: return CallToolResult(content=[], is_error=True) server.add_request_handler("tools/call", CallToolRequestParams, err_tool) - server.middleware.append(OpenTelemetryMiddleware()) async with connected_runner(server) as (client, _): spans.clear() await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}}) @@ -99,7 +106,6 @@ async def err_tool(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]: return {"content": [], "is_error": True} server.add_request_handler("tools/call", CallToolRequestParams, err_tool) - server.middleware.append(OpenTelemetryMiddleware()) async with connected_runner(server) as (client, _): spans.clear() result = await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}}) @@ -116,7 +122,6 @@ async def custom(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]: return {"content": [], "isError": False} server.add_request_handler("custom/op", CallToolRequestParams, custom) - server.middleware.append(OpenTelemetryMiddleware()) async with connected_runner(server) as (client, _): spans.clear() await client.send_raw_request("custom/op", {"name": "thing", "arguments": {}}) @@ -134,7 +139,6 @@ async def get_prompt(ctx: Ctx, params: GetPromptRequestParams) -> GetPromptResul return GetPromptResult(messages=[]) server.add_request_handler("prompts/get", GetPromptRequestParams, get_prompt) - server.middleware.append(OpenTelemetryMiddleware()) async with connected_runner(server) as (client, _): spans.clear() await client.send_raw_request("prompts/get", {"name": "myprompt"}) @@ -151,7 +155,6 @@ async def on_roots(ctx: Ctx, params: NotificationParams | None) -> None: return None server.add_notification_handler("notifications/roots/list_changed", NotificationParams, on_roots) - server.middleware.append(OpenTelemetryMiddleware()) async with connected_runner(server) as (client, _): spans.clear() await client.notify("notifications/roots/list_changed", None) @@ -163,24 +166,34 @@ async def on_roots(ctx: Ctx, params: NotificationParams | None) -> None: assert "jsonrpc.request.id" not in span.attributes +def _ambient_span(call_next: Any) -> Any: + """Dispatch-tier wrapper that opens an ambient SERVER span around the whole + request, so the context-tier span has a current span to nest under when the + inbound message carries no traceparent.""" + + async def wrapped(dctx: Any, method: str, params: dict[str, Any] | None) -> Any: + with otel_span("ambient", kind=SpanKind.SERVER): + return await call_next(dctx, method, params) + + return wrapped + + @pytest.mark.anyio async def test_nests_under_ambient_span_when_no_traceparent(server: SrvT, spans: SpanCapture): - """With no `_meta` on the inbound message (a non-SDK client), the - context-tier span must parent to the ambient current span (here, the - dispatch-tier `otel_middleware` span) rather than become an orphan root. + """With no `_meta` on the inbound message (a non-SDK client), the span must + parent to the ambient current span rather than become an orphan root. SDK-defined: SEP-414 only covers the traceparent-present case.""" def strip_meta(call_next: Any) -> Any: # The in-process client always injects `_meta.traceparent`; strip it so - # both server tiers see the no-carrier path. + # the span sees the no-carrier path. async def wrapped(dctx: Any, method: str, params: dict[str, Any] | None) -> Any: stripped = {k: v for k, v in (params or {}).items() if k != "_meta"} return await call_next(dctx, method, stripped or None) return wrapped - server.middleware.append(OpenTelemetryMiddleware()) - async with connected_runner(server, dispatch_middleware=[strip_meta, otel_middleware]) as (client, _): + async with connected_runner(server, dispatch_middleware=[_ambient_span, strip_meta]) as (client, _): spans.clear() await client.send_raw_request("tools/list", None) server_spans = [s for s in spans.finished() if s.kind == SpanKind.SERVER] @@ -207,8 +220,7 @@ async def wrapped(dctx: Any, method: str, params: dict[str, Any] | None) -> Any: return wrapped - server.middleware.append(OpenTelemetryMiddleware()) - async with connected_runner(server, dispatch_middleware=[replace_meta, otel_middleware]) as (client, _): + async with connected_runner(server, dispatch_middleware=[_ambient_span, replace_meta]) as (client, _): spans.clear() await client.send_raw_request("tools/list", None) server_spans = [s for s in spans.finished() if s.kind == SpanKind.SERVER] @@ -225,7 +237,6 @@ async def wrapped(dctx: Any, method: str, params: dict[str, Any] | None) -> Any: async def test_extracts_trace_context_from_meta(server: SrvT, spans: SpanCapture): meta: dict[str, Any] = {} inject_trace_context(meta) - server.middleware.append(OpenTelemetryMiddleware()) async with connected_runner(server) as (client, _): spans.clear() await client.send_raw_request("tools/list", {"_meta": meta}) @@ -235,7 +246,6 @@ async def test_extracts_trace_context_from_meta(server: SrvT, spans: SpanCapture @pytest.mark.anyio async def test_records_error_status_on_mcp_error(server: SrvT, spans: SpanCapture): - server.middleware.append(OpenTelemetryMiddleware()) async with connected_runner(server) as (client, _): spans.clear() with pytest.raises(MCPError) as exc: @@ -253,7 +263,6 @@ async def test_records_error_status_on_mcp_error(server: SrvT, spans: SpanCaptur @pytest.mark.anyio async def test_validation_failure_sets_sanitized_status(server: SrvT, spans: SpanCapture): server.add_request_handler("tools/call", CallToolRequestParams, _ok_tool) - server.middleware.append(OpenTelemetryMiddleware()) async with connected_runner(server) as (client, _): spans.clear() with pytest.raises(MCPError): @@ -275,7 +284,6 @@ async def failing(ctx: Ctx, params: PaginatedRequestParams | None) -> Any: raise ValueError("handler blew up") server.add_request_handler("tools/list", PaginatedRequestParams, failing) - server.middleware.append(OpenTelemetryMiddleware()) async with connected_runner(server) as (client, _): spans.clear() with pytest.raises(MCPError): @@ -290,6 +298,28 @@ async def failing(ctx: Ctx, params: PaginatedRequestParams | None) -> Any: assert event.attributes["exception.type"] == "ValueError" +@pytest.mark.anyio +async def test_records_error_status_on_malformed_spec_result(server: SrvT, spans: SpanCapture): + """Result serialization runs inside the span, so a handler returning a + malformed dict for a spec method (INTERNAL_ERROR on the wire) is recorded + on the span rather than closing it as a success.""" + + async def bad_result(ctx: Ctx, params: PaginatedRequestParams | None) -> dict[str, Any]: + return {"tools": 42} + + server.add_request_handler("tools/list", PaginatedRequestParams, bad_result) + async with connected_runner(server) as (client, _): + spans.clear() + with pytest.raises(MCPError) as exc: + await client.send_raw_request("tools/list", None) + assert exc.value.error.code == INTERNAL_ERROR + [span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER] + assert span.status.status_code == StatusCode.ERROR + assert span.attributes is not None + assert span.attributes["error.type"] == str(INTERNAL_ERROR) + assert span.attributes["rpc.response.status_code"] == str(INTERNAL_ERROR) + + @pytest.mark.anyio async def test_passes_rewritten_context_through(server: SrvT, spans: SpanCapture): seen_arguments: dict[str, Any] = {} @@ -304,7 +334,7 @@ async def inject_arg(ctx: Ctx, call_next: CallNext) -> Any: return await call_next(replace(ctx, params={**ctx.params, "arguments": arguments})) server.add_request_handler("tools/call", CallToolRequestParams, call_tool) - server.middleware.extend([OpenTelemetryMiddleware(), inject_arg]) + server.middleware.append(inject_arg) async with connected_runner(server) as (client, _): spans.clear() await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {"x": 1}}) diff --git a/tests/server/test_runner.py b/tests/server/test_runner.py index 4d787dc5d7..8acd403312 100644 --- a/tests/server/test_runner.py +++ b/tests/server/test_runner.py @@ -21,7 +21,6 @@ INVALID_PARAMS, LATEST_PROTOCOL_VERSION, METHOD_NOT_FOUND, - CallToolRequestParams, ClientCapabilities, ErrorData, Implementation, @@ -35,7 +34,6 @@ Tool, ) from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION, OLDEST_SUPPORTED_VERSION -from opentelemetry.trace import SpanKind, StatusCode import mcp.server.runner from mcp.server.connection import Connection @@ -46,7 +44,6 @@ ServerRunner, _extract_meta, aclose_shielded, - otel_middleware, serve_connection, serve_one, ) @@ -60,7 +57,6 @@ from ..shared.conftest import jsonrpc_pair from ..shared.test_dispatcher import Recorder, echo_handlers -from .conftest import SpanCapture Ctx = ServerRequestContext[dict[str, Any], Any] @@ -1005,108 +1001,6 @@ async def test_runner_initialize_echoes_supported_version_and_falls_back_to_late assert result["protocolVersion"] == LATEST_HANDSHAKE_VERSION -@pytest.mark.anyio -async def test_otel_middleware_emits_server_span_with_method_and_target(server: SrvT, spans: SpanCapture): - async def call_tool(ctx: Ctx, params: CallToolRequestParams) -> dict[str, Any]: - return {"content": [], "isError": False} - - server.add_request_handler("tools/call", CallToolRequestParams, call_tool) - async with connected_runner(server, dispatch_middleware=[otel_middleware]) as (client, _): - spans.clear() - result = await client.send_raw_request("tools/call", {"name": "mytool", "arguments": {}}) - assert result == {"content": [], "isError": False} - finished = [s for s in spans.finished() if s.kind == SpanKind.SERVER] - [span] = finished - assert span.name == "MCP handle tools/call mytool" - assert span.attributes is not None - assert span.attributes["mcp.method.name"] == "tools/call" - assert isinstance(span.attributes["jsonrpc.request.id"], str) - assert span.status.status_code == StatusCode.UNSET - - -@pytest.mark.anyio -async def test_otel_trace_context_propagates_client_to_server(server: SrvT, spans: SpanCapture): - """The client dispatcher injects traceparent into `_meta`; the server's - `otel_middleware` extracts it, so client and server spans share a trace.""" - async with connected_runner(server, dispatch_middleware=[otel_middleware]) as (client, _): - spans.clear() - await client.send_raw_request("tools/list", None) - [client_span] = [s for s in spans.finished() if s.kind == SpanKind.CLIENT] - [server_span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER] - assert server_span.parent is not None - assert client_span.context is not None and server_span.context is not None - assert server_span.parent.span_id == client_span.context.span_id - assert server_span.context.trace_id == client_span.context.trace_id - assert client_span.attributes is not None and server_span.attributes is not None - assert client_span.attributes["jsonrpc.request.id"] == server_span.attributes["jsonrpc.request.id"] - - -@pytest.mark.anyio -async def test_otel_middleware_malformed_traceparent_degrades_to_no_parent(server: SrvT, spans: SpanCapture): - """A non-string traceparent in `_meta` must not fail the request; the server span simply gets no parent.""" - - def break_traceparent(call_next: OnRequest) -> OnRequest: - async def wrapped(ctx: DispatchContext[Any], method: str, params: Any) -> dict[str, Any]: - mangled = {"_meta": {"traceparent": 123}} if method == "tools/list" else params - return await call_next(ctx, method, mangled) - - return wrapped - - async with connected_runner(server, dispatch_middleware=[break_traceparent, otel_middleware]) as (client, _): - spans.clear() - await client.send_raw_request("tools/list", None) - [server_span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER] - assert server_span.parent is None - - -@pytest.mark.anyio -async def test_otel_middleware_validation_failure_sets_sanitized_status(server: SrvT, spans: SpanCapture): - """Malformed params set the sanitized wire message as span status and do - not record the pydantic exception (it carries client input).""" - async with connected_runner(server, dispatch_middleware=[otel_middleware]) as (client, _): - spans.clear() - with pytest.raises(MCPError) as exc: - await client.send_raw_request("tools/call", {"name": 123}) - assert exc.value.error.code == INVALID_PARAMS - [span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER] - assert span.status.status_code == StatusCode.ERROR - assert span.status.description == "Invalid request parameters" - assert not span.events - - -@pytest.mark.anyio -async def test_otel_middleware_records_error_status_on_mcp_error(server: SrvT, spans: SpanCapture): - async with connected_runner(server, dispatch_middleware=[otel_middleware]) as (client, _): - spans.clear() - with pytest.raises(MCPError) as exc: - await client.send_raw_request("resources/list", None) - assert exc.value.error.code == METHOD_NOT_FOUND - [span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER] - assert span.status.status_code == StatusCode.ERROR - assert span.status.description == "Method not found" - # MCPError is a protocol-level response, not a crash - no traceback event. - assert not [e for e in span.events if e.name == "exception"] - - -@pytest.mark.anyio -async def test_otel_middleware_records_error_status_on_handler_exception(server: SrvT, spans: SpanCapture): - async def failing(ctx: Ctx, params: PaginatedRequestParams | None) -> Any: - raise ValueError("handler blew up") - - server.add_request_handler("tools/list", PaginatedRequestParams, failing) - async with connected_runner(server, dispatch_middleware=[otel_middleware]) as (client, _): - spans.clear() - with pytest.raises(MCPError) as exc: - await client.send_raw_request("tools/list", None) - assert exc.value.error.code == 0 - [span] = [s for s in spans.finished() if s.kind == SpanKind.SERVER] - assert span.status.status_code == StatusCode.ERROR - assert span.status.description == "handler blew up" - [event] = [e for e in span.events if e.name == "exception"] - assert event.attributes is not None - assert event.attributes["exception.type"] == "ValueError" - - @pytest.mark.anyio async def test_runner_connection_exit_stack_unwinds_after_run_returns(server: SrvT) -> None: """`runner.connection.exit_stack` is closed when the dispatcher loop ends.""" diff --git a/tests/shared/test_otel.py b/tests/shared/test_otel.py index 3c5be30269..51bece1fd6 100644 --- a/tests/shared/test_otel.py +++ b/tests/shared/test_otel.py @@ -6,10 +6,16 @@ from mcp.client.client import Client from mcp.server.mcpserver import MCPServer +from mcp.shared._otel import extract_trace_context pytestmark = pytest.mark.anyio +def test_extract_trace_context_degrades_to_no_parent_on_malformed_traceparent() -> None: + """A non-string `traceparent` makes `extract()` raise; the helper must return `None`, not propagate.""" + assert extract_trace_context({"traceparent": 123}) is None + + async def test_client_and_server_spans(capfire: CaptureLogfire): """Verify that calling a tool produces client and server spans with correct attributes.""" server = MCPServer("test") @@ -29,10 +35,10 @@ def greet(name: str) -> str: span_names = {s["name"] for s in spans} assert "MCP send tools/call greet" in span_names - assert "MCP handle tools/call greet" in span_names + assert "tools/call greet" in span_names client_span = next(s for s in spans if s["name"] == "MCP send tools/call greet") - server_span = next(s for s in spans if s["name"] == "MCP handle tools/call greet") + server_span = next(s for s in spans if s["name"] == "tools/call greet") assert client_span["attributes"]["mcp.method.name"] == "tools/call" assert server_span["attributes"]["mcp.method.name"] == "tools/call" From 3caa445c6c4d909c8453806a84821cf2803e19b3 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:01:42 +0200 Subject: [PATCH 012/100] Pin conformance harness by commit SHA (ed314a73) instead of PR number (#3000) --- .github/workflows/conformance.yml | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index e68991e47e..c73c1e2db7 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -19,15 +19,17 @@ env: # Bump deliberately and reconcile both # .github/actions/conformance/expected-failures*.yml files in the same change. # - # Temporarily pinned to the pkg.pr.new preview build of conformance#371, which - # fixes the http-custom-headers fixture's spec-forbidden `number`-typed - # x-mcp-header annotations. Because this is a mutable URL (not a registry - # spec), CONFORMANCE_PKG_SHA256 pins the tarball and the fetch-and-verify step - # below downloads, checks the digest, and repoints CONFORMANCE_PKG at the - # verified local copy. Repin to the published release that includes #371 once - # it ships, then drop CONFORMANCE_PKG_SHA256 and the fetch-and-verify steps. - CONFORMANCE_PKG: "https://pkg.pr.new/@modelcontextprotocol/conformance@371" - CONFORMANCE_PKG_SHA256: "9d8b25874d55e304b006cbaa066571773582f5828143c53a2b8a6830f203ca1d" + # Temporarily pinned to the pkg.pr.new build of conformance main@b18aa918 + # (the merge of #371, which fixes the http-custom-headers fixture's + # spec-forbidden `number`-typed x-mcp-header annotations) — no published + # release includes it yet. Pinned by commit SHA so the tarball cannot move + # under us; CONFORMANCE_PKG_SHA256 pins the bytes and the fetch-and-verify + # step below downloads, checks the digest, and repoints CONFORMANCE_PKG at the + # verified local copy. Repin to the next published @modelcontextprotocol/ + # conformance release (>0.2.0-alpha.7) once it ships, then drop + # CONFORMANCE_PKG_SHA256 and the fetch-and-verify steps. + CONFORMANCE_PKG: "https://pkg.pr.new/@modelcontextprotocol/conformance@b18aa918" + CONFORMANCE_PKG_SHA256: "e9f6bc25085b4692e988cbdbd024a4203d54a52a6aaa065376cf8ecaa09bb680" jobs: server-conformance: From 3945bdde1168a0e57dbf9eb2473dcfdcd67816d4 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 26 Jun 2026 17:08:16 +0200 Subject: [PATCH 013/100] Remove the dispatch-tier middleware hook (#2997) --- src/mcp/server/runner.py | 20 ++++------------ src/mcp/shared/dispatcher.py | 4 ---- tests/server/test_otel.py | 41 +++++++++++++------------------- tests/server/test_runner.py | 46 ++++++++++-------------------------- 4 files changed, 32 insertions(+), 79 deletions(-) diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index 55d43c2d91..0b57c3e5c2 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -14,9 +14,9 @@ from __future__ import annotations import logging -from collections.abc import Awaitable, Mapping, Sequence +from collections.abc import Awaitable, Mapping from dataclasses import KW_ONLY, dataclass -from functools import cached_property, partial, reduce +from functools import cached_property, partial from typing import TYPE_CHECKING, Any, Generic, cast import anyio @@ -45,7 +45,7 @@ from mcp.server.models import InitializationOptions from mcp.server.session import ServerSession from mcp.shared._stream_protocols import ReadStream, WriteStream -from mcp.shared.dispatcher import DispatchContext, Dispatcher, DispatchMiddleware, OnNotify, OnRequest +from mcp.shared.dispatcher import DispatchContext, Dispatcher, OnNotify, OnRequest from mcp.shared.exceptions import MCPError from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher from mcp.shared.message import ServerMessageMetadata, SessionMessage @@ -141,22 +141,10 @@ class ServerRunner(Generic[LifespanT]): _: KW_ONLY init_options: InitializationOptions | None = None """`InitializeResult` payload. Defaults to `server.create_initialization_options()`.""" - dispatch_middleware: Sequence[DispatchMiddleware] = () - """Raw dispatch-tier wrappers `(dctx, method, params) -> dict`, applied outermost-first - around `_on_request`. Empty by default; OpenTelemetry tracing lives at the context tier - (`OpenTelemetryMiddleware`, seeded into `Server.middleware`).""" @cached_property def on_request(self) -> OnRequest: - """`_on_request` wrapped in `dispatch_middleware`, outermost-first. - - Dispatch-tier middleware sees raw `(dctx, method, params) -> dict` and - wraps everything - initialize, METHOD_NOT_FOUND, validation failures - included. - """ - return reduce( - lambda handler, middleware: middleware(handler), reversed(self.dispatch_middleware), self._on_request - ) + return self._on_request @cached_property def on_notify(self) -> OnNotify: diff --git a/src/mcp/shared/dispatcher.py b/src/mcp/shared/dispatcher.py index eaefd3d1cb..de83189f13 100644 --- a/src/mcp/shared/dispatcher.py +++ b/src/mcp/shared/dispatcher.py @@ -29,7 +29,6 @@ __all__ = [ "CallOptions", "DispatchContext", - "DispatchMiddleware", "Dispatcher", "OnNotify", "OnRequest", @@ -185,9 +184,6 @@ async def progress(self, progress: float, total: float | None = None, message: s OnNotify = Callable[[DispatchContext[TransportContext], str, Mapping[str, Any] | None], Awaitable[None]] """Handler for inbound notifications: `(ctx, method, params)`.""" -DispatchMiddleware = Callable[[OnRequest], OnRequest] -"""Wraps an `OnRequest` to produce another `OnRequest`. Applied outermost-first.""" - class Dispatcher(Outbound, Protocol[TransportT_co]): """A duplex request/notification channel with call-return semantics. diff --git a/tests/server/test_otel.py b/tests/server/test_otel.py index 4c4a86d14f..c3a06e1a50 100644 --- a/tests/server/test_otel.py +++ b/tests/server/test_otel.py @@ -5,6 +5,7 @@ the middleware by hand. """ +from collections.abc import Callable from dataclasses import replace from typing import Any @@ -20,6 +21,7 @@ ListToolsResult, NotificationParams, PaginatedRequestParams, + RequestParamsMeta, Tool, ) from opentelemetry.trace import SpanKind, StatusCode @@ -166,16 +168,17 @@ async def on_roots(ctx: Ctx, params: NotificationParams | None) -> None: assert "jsonrpc.request.id" not in span.attributes -def _ambient_span(call_next: Any) -> Any: - """Dispatch-tier wrapper that opens an ambient SERVER span around the whole - request, so the context-tier span has a current span to nest under when the - inbound message carries no traceparent.""" +def _ambient(rewrite_meta: Callable[[RequestParamsMeta | None], RequestParamsMeta | None]) -> Any: + """A middleware placed outside `OpenTelemetryMiddleware` (head of + `Server.middleware`) that opens an ambient SERVER span around the request + and rewrites `ctx.meta` via `rewrite_meta`, so the context-tier span sees + the no-traceparent path yet has a current span to nest under.""" - async def wrapped(dctx: Any, method: str, params: dict[str, Any] | None) -> Any: + async def middleware(ctx: Ctx, call_next: CallNext) -> Any: with otel_span("ambient", kind=SpanKind.SERVER): - return await call_next(dctx, method, params) + return await call_next(replace(ctx, meta=rewrite_meta(ctx.meta))) - return wrapped + return middleware @pytest.mark.anyio @@ -184,16 +187,10 @@ async def test_nests_under_ambient_span_when_no_traceparent(server: SrvT, spans: parent to the ambient current span rather than become an orphan root. SDK-defined: SEP-414 only covers the traceparent-present case.""" - def strip_meta(call_next: Any) -> Any: - # The in-process client always injects `_meta.traceparent`; strip it so - # the span sees the no-carrier path. - async def wrapped(dctx: Any, method: str, params: dict[str, Any] | None) -> Any: - stripped = {k: v for k, v in (params or {}).items() if k != "_meta"} - return await call_next(dctx, method, stripped or None) - - return wrapped - - async with connected_runner(server, dispatch_middleware=[_ambient_span, strip_meta]) as (client, _): + # The in-process client always injects `_meta.traceparent`; drop it so the + # span sees the no-carrier path. + server.middleware.insert(0, _ambient(lambda _meta: None)) + async with connected_runner(server) as (client, _): spans.clear() await client.send_raw_request("tools/list", None) server_spans = [s for s in spans.finished() if s.kind == SpanKind.SERVER] @@ -213,14 +210,8 @@ async def test_nests_under_ambient_span_when_meta_lacks_traceparent(server: SrvT would orphan the span; the middleware must fall through to ambient parenting just as if `_meta` were absent.""" - def replace_meta(call_next: Any) -> Any: - async def wrapped(dctx: Any, method: str, params: dict[str, Any] | None) -> Any: - rewritten = {**(params or {}), "_meta": {"progressToken": "tok"}} - return await call_next(dctx, method, rewritten) - - return wrapped - - async with connected_runner(server, dispatch_middleware=[_ambient_span, replace_meta]) as (client, _): + server.middleware.insert(0, _ambient(lambda _meta: {"progressToken": "tok"})) + async with connected_runner(server) as (client, _): spans.clear() await client.send_raw_request("tools/list", None) server_spans = [s for s in spans.finished() if s.kind == SpanKind.SERVER] diff --git a/tests/server/test_runner.py b/tests/server/test_runner.py index 8acd403312..ed9662f08d 100644 --- a/tests/server/test_runner.py +++ b/tests/server/test_runner.py @@ -9,7 +9,7 @@ from collections.abc import AsyncIterator, Mapping from contextlib import asynccontextmanager -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from functools import partial from typing import Any, cast @@ -48,7 +48,7 @@ serve_one, ) from mcp.server.session import ServerSession -from mcp.shared.dispatcher import CallOptions, DispatchContext, DispatchMiddleware, OnRequest +from mcp.shared.dispatcher import CallOptions from mcp.shared.exceptions import MCPError from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher from mcp.shared.message import MessageMetadata @@ -91,7 +91,6 @@ async def connected_runner( *, initialized: bool = True, init_options: InitializationOptions | None = None, - dispatch_middleware: list[DispatchMiddleware] | None = None, connection: Connection | None = None, ) -> AsyncIterator[tuple[JSONRPCDispatcher[TransportContext], ServerRunner[dict[str, Any]]]]: """Yield `(client, runner)` running over an in-memory JSON-RPC dispatcher pair. @@ -116,7 +115,6 @@ async def connected_runner( connection=connection, lifespan_state={}, init_options=init_options, - dispatch_middleware=dispatch_middleware or [], ) c_req, c_notify = echo_handlers(Recorder()) body_exc: BaseException | None = None @@ -511,8 +509,8 @@ async def test_runner_absent_wire_params_reaches_request_handler_as_defaults_mod """A request with no `params` member on the wire reaches the handler as the params model with its defaults, never `None`. - The in-SDK client always attaches `_meta`, so a dispatch middleware - forwards `params=None` to model what an external client sends. + The in-SDK client always attaches `_meta`, so a middleware rewrites + `ctx.params` to `None` to model what an external client sends. """ seen: list[PaginatedRequestParams | None] = [] @@ -520,14 +518,12 @@ async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToo seen.append(params) return ListToolsResult(tools=[]) - def drop_params(next_on_request: OnRequest) -> OnRequest: - async def wrapped(dctx: DispatchContext[Any], method: str, params: Any) -> dict[str, Any]: - return await next_on_request(dctx, method, None if method == "tools/list" else params) - - return wrapped + async def drop_params(ctx: Ctx, call_next: Any) -> Any: + return await call_next(replace(ctx, params=None) if ctx.method == "tools/list" else ctx) server: SrvT = Server(name="s", on_list_tools=list_tools) - async with connected_runner(server, dispatch_middleware=[drop_params]) as (client, _): + server.middleware.append(drop_params) + async with connected_runner(server) as (client, _): await client.send_raw_request("tools/list", None) assert seen == [PaginatedRequestParams()] @@ -543,15 +539,13 @@ class GreetParams(RequestParams): async def greet(ctx: Ctx, params: GreetParams) -> dict[str, Any]: raise NotImplementedError - def drop_params(next_on_request: OnRequest) -> OnRequest: - async def wrapped(dctx: DispatchContext[Any], method: str, params: Any) -> dict[str, Any]: - return await next_on_request(dctx, method, None if method == "custom/greet" else params) - - return wrapped + async def drop_params(ctx: Ctx, call_next: Any) -> Any: + return await call_next(replace(ctx, params=None) if ctx.method == "custom/greet" else ctx) server: SrvT = Server(name="s") server.add_request_handler("custom/greet", GreetParams, greet) - async with connected_runner(server, dispatch_middleware=[drop_params]) as (client, _): + server.middleware.append(drop_params) + async with connected_runner(server) as (client, _): with pytest.raises(MCPError) as exc: await client.send_raw_request("custom/greet", {"name": "x"}) assert exc.value.error.code == INVALID_PARAMS @@ -574,22 +568,6 @@ async def on_roots(ctx: Ctx, params: NotificationParams | None) -> None: assert seen == [NotificationParams()] # only the post-init one reached the handler -@pytest.mark.anyio -async def test_runner_dispatch_middleware_wraps_everything_including_initialize(server: SrvT): - seen_methods: list[str] = [] - - def trace_mw(next_on_request: Any) -> Any: - async def wrapped(dctx: Any, method: str, params: Any) -> Any: - seen_methods.append(method) - return await next_on_request(dctx, method, params) - - return wrapped - - async with connected_runner(server, dispatch_middleware=[trace_mw]) as (client, _): - await client.send_raw_request("tools/list", None) - assert seen_methods == ["initialize", "tools/list"] - - @pytest.mark.anyio async def test_runner_server_middleware_wraps_every_request_including_initialize(server: SrvT): seen: list[tuple[str, Any]] = [] From 08b62308d4e653d54efc8ccd3b291d39dc4f0363 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:35:23 +0200 Subject: [PATCH 014/100] Client auto-resolves InputRequiredResult via existing callbacks (SEP-2322) (#2998) --- .github/actions/conformance/client.py | 56 ++--- docs/advanced/deprecated.md | 6 +- docs/advanced/multi-round-trip.md | 50 +++-- docs/client/callbacks.md | 6 +- docs/migration.md | 8 +- docs/tutorial/elicitation.md | 1 + docs_src/mrtr/tutorial002.py | 4 +- docs_src/mrtr/tutorial003.py | 14 ++ examples/stories/manifest.toml | 4 +- examples/stories/mrtr/README.md | 68 ++++-- examples/stories/mrtr/__init__.py | 0 examples/stories/mrtr/client.py | 46 ++++ examples/stories/mrtr/server.py | 39 ++++ examples/stories/mrtr/server_lowlevel.py | 62 ++++++ src/mcp-types/mcp_types/_types.py | 2 +- src/mcp/__init__.py | 2 + src/mcp/client/__init__.py | 3 +- src/mcp/client/_input_required.py | 127 +++++++++++ src/mcp/client/client.py | 197 +++++++++++------ src/mcp/client/session.py | 165 ++++++++++++-- tests/client/test_client.py | 238 +++++++++++++++++++- tests/client/test_input_required.py | 266 +++++++++++++++++++++++ tests/client/test_session.py | 57 ++++- tests/docs_src/test_mrtr.py | 30 ++- tests/server/mcpserver/test_server.py | 6 +- tests/test_types.py | 3 +- 26 files changed, 1258 insertions(+), 202 deletions(-) create mode 100644 docs_src/mrtr/tutorial003.py create mode 100644 examples/stories/mrtr/__init__.py create mode 100644 examples/stories/mrtr/client.py create mode 100644 examples/stories/mrtr/server.py create mode 100644 examples/stories/mrtr/server_lowlevel.py create mode 100644 src/mcp/client/_input_required.py create mode 100644 tests/client/test_input_required.py diff --git a/.github/actions/conformance/client.py b/.github/actions/conformance/client.py index ec4ff2245f..d8dffb0d29 100644 --- a/.github/actions/conformance/client.py +++ b/.github/actions/conformance/client.py @@ -22,7 +22,7 @@ http-standard-headers - Connect, call a tool (Mcp-* headers checked) http-invalid-tool-headers - List tools, call every surfaced tool (x-mcp-header filter) elicitation-sep1034-client-defaults - Elicitation with default accept callback - sep-2322-client-request-state - Drive the manual MRTR retry surface + sep-2322-client-request-state - Drive the MRTR auto-loop (SEP-2322) auth/client-credentials-jwt - Client credentials with private_key_jwt auth/client-credentials-basic - Client credentials with client_secret_basic auth/* - Authorization code flow (default for auth scenarios) @@ -374,46 +374,28 @@ async def run_elicitation_defaults(server_url: str) -> None: @register("sep-2322-client-request-state") async def run_mrtr_client(server_url: str) -> None: - """Drive the manual MRTR retry surface against the SEP-2322 client mock. - - The mock speaks the modern lifecycle (server/discover, no initialize) and - inspects the wire params of each tools/call round, so this exercises the - explicit allow_input_required=True path rather than an auto-loop: round 1 - receives an InputRequiredResult, the fixture fulfils the elicitation - locally, then round 2 retries with input_responses + the echoed - request_state. Passing request_state straight off the typed result -- a - str when the server sent one, None when it didn't -- lets the - serializer's exclude_none drop the key in the no-state case without a - branch here. The unrelated call between rounds proves MRTR params don't - leak across tools, and the no-result-type call must parse as a complete - CallToolResult with no retry. + """Drive the SEP-2322 client mock through `Client.call_tool`'s auto-loop. + + The mock inspects raw `tools/call` params, so registering an + `elicitation_callback` and letting the driver run is enough to satisfy + all five wire-shape checks: the driver echoes `request_state` byte-exact + and omits it when the server sent none, every retry mints a fresh + JSON-RPC id, the unrelated call between auto-loops carries no MRTR + params, and the no-`resultType` response parses as a terminal + `CallToolResult` so the driver never retries it. """ - async with Client(server_url, mode=client_mode()) as client: - await client.list_tools() - confirm = {"confirm": types.ElicitResult(action="accept", content={"confirmed": True})} - r1 = await client.call_tool("test_mrtr_echo_state", {}, allow_input_required=True) - assert isinstance(r1, types.InputRequiredResult) - - await client.call_tool("test_mrtr_unrelated", {}) + async def confirm( + context: ClientRequestContext, params: types.ElicitRequestParams + ) -> types.ElicitResult | types.ErrorData: + return types.ElicitResult(action="accept", content={"confirmed": True}) - await client.call_tool( - "test_mrtr_echo_state", - {}, - input_responses=confirm, - request_state=r1.request_state, - allow_input_required=True, - ) + async with Client(server_url, mode=client_mode(), elicitation_callback=confirm) as client: + await client.list_tools() - r2 = await client.call_tool("test_mrtr_no_state", {}, allow_input_required=True) - assert isinstance(r2, types.InputRequiredResult) - await client.call_tool( - "test_mrtr_no_state", - {}, - input_responses=confirm, - request_state=r2.request_state, - allow_input_required=True, - ) + await client.call_tool("test_mrtr_echo_state", {}) + await client.call_tool("test_mrtr_unrelated", {}) + await client.call_tool("test_mrtr_no_state", {}) result = await client.call_tool("test_mrtr_no_result_type", {}) assert isinstance(result, types.CallToolResult) diff --git a/docs/advanced/deprecated.md b/docs/advanced/deprecated.md index 4a3d4f831a..5bff0e9553 100644 --- a/docs/advanced/deprecated.md +++ b/docs/advanced/deprecated.md @@ -8,7 +8,7 @@ The table below names each deprecated feature, why it is going away, and the rep | Deprecated | Why | What you do instead | |---|---|---| -| **Roots**: `ctx.session.list_roots()`, `client.send_roots_list_changed()`, the `list_roots_callback=` you pass to `Client(...)` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) retires the capability. | Take the paths that matter as ordinary tool arguments or resource URIs. | +| **Roots**: `ctx.session.list_roots()`, `client.send_roots_list_changed()`, the `list_roots_callback=` you pass to `Client(...)` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) retires the capability. | Take the paths as ordinary tool arguments or resource URIs, or embed a `ListRootsRequest` in an `InputRequiredResult` (see **Multi-round-trip requests**). | | **Server-initiated sampling**: `ctx.session.create_message()`, the `sampling_callback=` you pass to `Client(...)` | SEP-2577 retires the capability. | Return `InputRequiredResult` and let the client retry the call (see **Multi-round-trip requests**). | | **Protocol logging**: `ctx.log()`, `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`, `ctx.session.send_log_message()`, `client.set_logging_level()` | SEP-2577 retires the capability. Nothing in-protocol replaces it. | Ordinary `import logging` to stderr (see **Logging**). | | **`ping`**: `client.send_ping()` | **Removed** from the protocol, not merely deprecated. There is no `ping` method in 2026-07-28. | Nothing. It only works against a `mode="legacy"` connection. | @@ -17,7 +17,7 @@ The table below names each deprecated feature, why it is going away, and the rep Three things fall out of that table: * Roots, sampling, and logging go together. One proposal, **SEP-2577**, deprecates all three capabilities at once. -* Sampling and roots share a deeper problem: they are the two places a **server** sends a **request** to the **client**. That whole direction is what 2026-07-28 replaces with **Multi-round-trip requests**. +* Sampling and roots share a deeper problem: they are places a **server** sends a **request** to the **client**. That whole direction is what 2026-07-28 replaces with **Multi-round-trip requests**. It is the standalone RPC methods (`sampling/createMessage`, `roots/list`, and push-style `elicitation/create`) that are gone; the `CreateMessageRequest` / `ListRootsRequest` / `ElicitRequest` payload types survive, embedded in `InputRequiredResult.input_requests`, and on the client they hit the same callbacks. * `ping` is the odd one out. The protocol does not deprecate it, it removes it. The SDK method still warns (its message says *removed*, not *deprecated*) and calling it on a modern connection answers with *"Method not found"*. ## Deprecated is advisory @@ -82,7 +82,7 @@ That is the whole API. There is no per-method switch, and you don't want one: th ## Recap * The 2026-07-28 spec deprecates **roots**, server-initiated **sampling**, and protocol **logging** (all SEP-2577), restricts **progress** to server-to-client, and removes **`ping`**. -* The replacement column points you onward: **Multi-round-trip requests** for sampling, **Logging** for logging, **Progress** for progress. Roots needs no chapter (pass the paths as arguments) and `ping` needs nothing at all. +* The replacement column points you onward: **Multi-round-trip requests** for sampling and roots, **Logging** for logging, **Progress** for progress. `ping` needs nothing at all. * Deprecated is advisory: no wire changes, everything keeps working against pre-2026 sessions, and you get a visible `MCPDeprecationWarning` (a `UserWarning`, so it is on by default). * Sampling and roots additionally need a back-channel that a 2026-07-28 session does not have. On a modern connection they warn and then they raise. * `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` silences the whole category; `"error::mcp.MCPDeprecationWarning"` in pytest turns it into a test failure. diff --git a/docs/advanced/multi-round-trip.md b/docs/advanced/multi-round-trip.md index 2b02cabffc..a90cb5e980 100644 --- a/docs/advanced/multi-round-trip.md +++ b/docs/advanced/multi-round-trip.md @@ -33,40 +33,43 @@ Everything else in that file (the explicit `input_schema`, the hand-built `CallT ## The client side -`call_tool` will not hand you an `InputRequiredResult` unless you opt in. +`Client` runs the loop for you. -!!! check - Call a tool that needs input without opting in and `call_tool` raises: +Register the callbacks the server might ask for (`elicitation_callback`, `sampling_callback`, `list_roots_callback`) and call the tool. When an `InputRequiredResult` arrives, `Client` dispatches each entry in `input_requests` to the matching callback, retries with the answers and the echoed `request_state`, and keeps going until a `CallToolResult` comes back: + +```python title="client.py" hl_lines="12 13" +--8<-- "docs_src/mrtr/tutorial003.py" +``` - ```text - Server returned InputRequiredResult; pass allow_input_required=True to receive it and retry call_tool(..., input_responses=..., request_state=result.request_state). - ``` +* That `elicitation_callback` is the same one a pre-2026 server's back-channel `elicitation/create` would have hit. The same is true of `sampling_callback` for `sampling/createMessage` and `list_roots_callback` for `roots/list`: at 2026-07-28 the standalone server->client RPCs are gone, but the identical `ElicitRequest` / `CreateMessageRequest` / `ListRootsRequest` payloads ride inside `input_requests` and dispatch to the same three callbacks. One set of callbacks serves both eras. +* `call_tool` returns a plain `CallToolResult`. The intermediate rounds are invisible to the caller. +* `get_prompt` and `read_resource` drive the same loop. - That is deliberate. Most call sites expect a result or an exception, not a third thing in the - middle of the happy path, and pyright agrees: without the flag, `call_tool` is typed to return - a plain `CallToolResult`. +!!! check + Leave the callback off and the loop fails on the first round: the SDK's stand-in callback + answers every elicitation with an error, and `call_tool` raises `MCPError` with the message + *"Elicitation not supported"*. -Pass `allow_input_required=True` and the result reaches you intact: +The loop is bounded. `Client(..., input_required_max_rounds=10)` is the default cap; a server that keeps returning `InputRequiredResult` past it makes `call_tool` raise. If a round carries only `request_state` and no `input_requests`, `Client` sleeps briefly (50ms doubling to a 250ms ceiling) before retrying, so a server that is just saying *"not done yet"* isn't busy-polled. -```python -result.result_type # 'input_required' -result.request_state # 'provision-v1' -result.input_requests # {'region': ElicitRequest(method='elicitation/create', params=ElicitRequestFormParams(...))} -``` +### Driving the loop yourself + +The auto-loop is enough for a single-process client. Own the loop instead when: -### The retry loop +* Your client is **distributed**: the process that renders the question to the user is not the process that called `call_tool`, so a different worker issues the retry. `request_state` is the persistable token you carry across that boundary, through your own storage, and `input_responses` is what the other side sends back with it. +* You want to **inspect** each round: log or audit every `input_requests` entry, refuse certain request kinds, or apply your own backoff between legs. +* You want a **wall-clock** bound rather than a round-count bound: wrap your own loop in `anyio.fail_after(...)` instead of relying on `input_required_max_rounds`. -Now you own the loop. There is no automatic driver yet; `while isinstance(result, InputRequiredResult)` **is** the API: +Drop to the underlying session, where `allow_input_required=True` hands you the union directly: -```python title="client.py" hl_lines="13-15 17-20" +```python title="client.py" hl_lines="13 14 20" --8<-- "docs_src/mrtr/tutorial002.py" ``` -* `allow_input_required=True` widens the return type to `CallToolResult | InputRequiredResult`. That union is exactly what the `isinstance` is narrowing. +* `client.session.call_tool(..., allow_input_required=True)` widens the return type to `CallToolResult | InputRequiredResult`. The `isinstance` is what narrows it back. +* `request_state` is now in your hands. Write it down between legs and the conversation can resume from a fresh process. * For every entry in `input_requests` you put an `InputResponse` under the **same key** in `input_responses`. `fulfil` is where your UI goes; this one hard-codes the answer. * Same tool name, same `arguments`, every leg. The retry is the original call carried out again, not a new method. -* `request_state=result.request_state`: copy it across. Never inspect it, never invent it. -* When the server has everything it needs it returns a `CallToolResult` and the loop exits. ## A 2026-07-28 result @@ -88,9 +91,8 @@ Now you own the loop. There is no automatic driver yet; `while isinstance(result * At 2026-07-28 a server that needs input mid-call **returns** an `InputRequiredResult`. It never opens a request to the client. * `input_requests` is what it needs. `request_state` is an opaque resume token only the server reads. -* The client answers by calling the **same tool again** with `input_responses=` and `request_state=`. -* By default `call_tool` raises on an `InputRequiredResult`; `allow_input_required=True` opts in and widens the return type. -* The manual `while isinstance(result, InputRequiredResult)` loop is the whole client API; there is no auto-retry driver yet. +* `Client` runs the retry loop for you: register `elicitation_callback` / `sampling_callback` / `list_roots_callback` and `call_tool` returns a plain `CallToolResult`. `input_required_max_rounds` (default 10) bounds it. +* To inspect or persist rounds, use `client.session.call_tool(..., allow_input_required=True)` and own the `while isinstance(result, InputRequiredResult)` loop yourself. * The server side is the **low-level** `Server` only; `@mcp.tool()` has no sugar for this yet. This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **Deprecated features**. diff --git a/docs/client/callbacks.md b/docs/client/callbacks.md index 3c2ca5f096..db2c4d7cd0 100644 --- a/docs/client/callbacks.md +++ b/docs/client/callbacks.md @@ -61,6 +61,10 @@ One `tools/call` from you, one `elicitation/create` back from the server, answer protocol does, in-memory and over a URL alike. Pin `mode="legacy"` whenever your client has to answer one; every test behind this page does. **Protocol versions** has the whole story. + On a 2026-07-28 session the callback isn't dead, it's fed differently: when a tool returns an + `InputRequiredResult` carrying an `ElicitRequest`, `Client` dispatches that entry to the same + `elicitation_callback` and retries the call for you. That flow is **Multi-round-trip requests**. + ## A callback is a capability You never told the server that your client can answer elicitation requests. The SDK did. @@ -109,7 +113,7 @@ Pass all three callbacks and you get `['elicitation', 'sampling', 'roots']`. Pas `sampling_callback` answers `sampling/createMessage`: the server asking *your* model to complete something. `list_roots_callback` answers `roots/list`: the server asking which directories it may work in. -Both work. Both follow the rule above. And both serve features the **2026-07-28 spec deprecates**: a modern server doesn't call back into your model mid-request, it hands the request back to you as part of the tool result (**Multi-round-trip requests**), and roots give way to plain tool arguments and resource URIs. The whole list is in **Deprecated features**. +Both work. Both follow the rule above. And both serve RPCs the **2026-07-28 spec removes**: a modern server doesn't call back into your client mid-request, it hands the request back to you as part of the tool result (**Multi-round-trip requests**). The callbacks themselves are not dead. When an `InputRequiredResult` carries a `CreateMessageRequest` or a `ListRootsRequest`, `Client`'s auto-loop dispatches it to the same `sampling_callback` or `list_roots_callback` you registered here. The whole list is in **Deprecated features**. You still need the callbacks to talk to servers that haven't moved. The signatures: diff --git a/docs/migration.md b/docs/migration.md index cc49638c07..7a342c4cda 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -395,9 +395,13 @@ For an in-process `Client(server)` (where `server` is a `Server` or `MCPServer` `Client.send_ping()` is deprecated (ping is removed in 2026-07-28); pin `mode='legacy'` if you need it. -### `call_tool` can return `InputRequiredResult` (opt-in) +### `InputRequiredResult` handling differs between `Client` and `ClientSession` -For protocol 2026-07-28, a `tools/call` request may return an `InputRequiredResult` asking the client to supply additional input and retry. By default `call_tool` (on `ClientSession`, `Client`, and `ClientSessionGroup`) still returns `CallToolResult` and raises `RuntimeError` if the server requests input. Pass `allow_input_required=True` to receive the `InputRequiredResult` instead, then retry with `input_responses=` / `request_state=`. +For protocol 2026-07-28, `tools/call`, `prompts/get`, and `resources/read` may return an `InputRequiredResult` asking the client to supply additional input (sampling, elicitation, roots) and retry. + +On the high-level `Client`, `call_tool`, `get_prompt`, and `read_resource` resolve this automatically: they dispatch each requested input to the matching callback (`sampling_callback`, `elicitation_callback`, `list_roots_callback`) and retry until a final result is returned, so the call still returns the bare `CallToolResult` / `GetPromptResult` / `ReadResourceResult`. The round limit is `Client(input_required_max_rounds=...)` (default 10). Earlier v2 prereleases exposed an `allow_input_required` parameter on these `Client` methods; that parameter has been removed. For manual control use `client.session.call_tool(..., allow_input_required=True)`. Note that `read_timeout_seconds` now bounds each underlying round, not the whole loop; wrap the call in `anyio.fail_after(...)` for a whole-loop bound. + +On `ClientSession`, `call_tool` / `get_prompt` / `read_resource` still return the bare result and raise `RuntimeError` if the server requests input. Pass `allow_input_required=True` to receive the `InputRequiredResult` instead, then drive the loop yourself with `input_responses=` / `request_state=`. `ClientSessionGroup.call_tool` accepts the same flag. ### `call_tool` mirrors `x-mcp-header` arguments into `Mcp-Param-*` headers (SEP-2243) diff --git a/docs/tutorial/elicitation.md b/docs/tutorial/elicitation.md index ef8d5911b5..df7ae477f2 100644 --- a/docs/tutorial/elicitation.md +++ b/docs/tutorial/elicitation.md @@ -149,5 +149,6 @@ Now swap in the URL-mode `server.py` and point the same `main()` at `pay_deposit * `result.action` is `"accept"`, `"decline"` or `"cancel"`; `result.data` exists only on accept. * `await ctx.elicit_url(message, url, elicitation_id)` is for everything that must not pass through the model; `ctx.session.send_elicit_complete(elicitation_id)` says the out-of-band part is done. * The client answers with one `elicitation_callback`, branching on the params type; registering it is what declares the capability. +* On a 2026-07-28 connection the server returns the question instead of pushing it; the same callback is fed by **Multi-round-trip requests**. A tool that can ask is good. A tool that says how far along it is (**Progress**) is next. diff --git a/docs_src/mrtr/tutorial002.py b/docs_src/mrtr/tutorial002.py index a6556fe365..0a14021833 100644 --- a/docs_src/mrtr/tutorial002.py +++ b/docs_src/mrtr/tutorial002.py @@ -10,10 +10,10 @@ def fulfil(request: InputRequest) -> InputResponse: async def provision(client: Client, name: str) -> CallToolResult: - result = await client.call_tool("provision", {"name": name}, allow_input_required=True) + result = await client.session.call_tool("provision", {"name": name}, allow_input_required=True) while isinstance(result, InputRequiredResult): responses = {key: fulfil(request) for key, request in (result.input_requests or {}).items()} - result = await client.call_tool( + result = await client.session.call_tool( "provision", {"name": name}, input_responses=responses, diff --git a/docs_src/mrtr/tutorial003.py b/docs_src/mrtr/tutorial003.py new file mode 100644 index 0000000000..03eb6bf74f --- /dev/null +++ b/docs_src/mrtr/tutorial003.py @@ -0,0 +1,14 @@ +from mcp_types import ElicitRequestParams, ElicitResult + +from mcp import Client +from mcp.client import ClientRequestContext + + +async def handle_elicitation(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + return ElicitResult(action="accept", content={"region": "eu-west-1"}) + + +async def main() -> None: + async with Client("http://127.0.0.1:8000/mcp", elicitation_callback=handle_elicitation) as client: + result = await client.call_tool("provision", {"name": "orders"}) + print(result.content) diff --git a/examples/stories/manifest.toml b/examples/stories/manifest.toml index 7a1f079e8e..9726289a78 100644 --- a/examples/stories/manifest.toml +++ b/examples/stories/manifest.toml @@ -34,6 +34,9 @@ multi_connection = true # progress + log notifications dropped on the modern streamable-HTTP path pending SSE wiring xfail = ["http-asgi:modern"] +[story.mrtr] +era = "modern" + [story.legacy_elicitation] era = "legacy" status = "legacy" @@ -140,7 +143,6 @@ fixed_port = 8000 # issuer/PRM metadata bake in :8 [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" diff --git a/examples/stories/mrtr/README.md b/examples/stories/mrtr/README.md index 6058e3a84d..d801b8ff0f 100644 --- a/examples/stories/mrtr/README.md +++ b/examples/stories/mrtr/README.md @@ -1,30 +1,54 @@ # 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 is in this base — -[#2967](https://github.com/modelcontextprotocol/python-sdk/pull/2967) -(`ae13ede`) widened the tool/prompt/resource handler return types to include -`InputRequiredResult`. The runnable story is deliberately a follow-up PR to -keep this one reviewable. +Multi-round tool result: on the 2026-07-28 protocol a tool that needs user +input mid-call **returns** `resultType: "input_required"` with embedded +`inputRequests` and an opaque `requestState`, instead of pushing a +server→client request. The client fulfils the embedded requests and retries the +original `tools/call` carrying `inputResponses` and the echoed `requestState`. +The story shows both the `Client` auto-loop (one `await call_tool`, callbacks +fired transparently) and a manual `client.session` loop (the persistable form). + +## Run it + +```bash +# HTTP — the client self-hosts the server on a free port, runs, then tears it +# down (the InputRequiredResult round-trip is 2026-era only) +uv run python -m stories.mrtr.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.mrtr.client --http --server server_lowlevel +``` + +## What to look at + +- `client.py` `main` — the auto-loop is invisible at the call site: + `Client(target, mode=mode, elicitation_callback=on_elicit)` then + `await client.call_tool("deploy", ...)`. The same `on_elicit` callback the + legacy push path uses is dispatched for each embedded `inputRequests` entry. +- `client.py` manual block — `client.session.call_tool(..., + allow_input_required=True)` returns the raw `InputRequiredResult` so + `request_state` can be persisted between rounds; the retry is just another + `tools/call` with `input_responses=` / `request_state=`. +- `server.py` `deploy` — `ctx.input_responses` / `ctx.request_state` read the + retry payload; the first round returns + `InputRequiredResult(input_requests={...}, request_state=...)`, the second + returns the final string. +- `server_lowlevel.py` — same wire contract via `params.input_responses` / + `params.request_state` and a hand-built `InputRequiredResult`. + +## Caveats + +- **Loop bound.** The auto-loop gives up after `input_required_max_rounds` + (default 10) with `InputRequiredRoundsExceededError`; raise it on the + `Client` ctor or drop to the manual loop. +- **`requestState` integrity is the server's job.** The client echoes it + byte-exact and never inspects it; the server MUST treat it as + attacker-controlled. The SDK ships no signing helper yet. ## 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). +[Multi-round results — server features](https://modelcontextprotocol.io/specification/draft/server/tools#multi-round-results) ## 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. +`legacy_elicitation/` and `sampling/` — the handshake-era push equivalents this +mechanism replaces on the 2026 protocol. diff --git a/examples/stories/mrtr/__init__.py b/examples/stories/mrtr/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/mrtr/client.py b/examples/stories/mrtr/client.py new file mode 100644 index 0000000000..5b686c3c9c --- /dev/null +++ b/examples/stories/mrtr/client.py @@ -0,0 +1,46 @@ +"""Drive the deploy tool both ways: the Client auto-loop, and a manual session-level loop.""" + +import mcp_types as 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: + # The same callback serves legacy push-style elicitation/create requests AND embedded + # InputRequiredResult.input_requests entries — the driver dispatches both here. + assert isinstance(params, types.ElicitRequestFormParams) + assert "confirm" in params.requested_schema["properties"] + return types.ElicitResult(action="accept", content={"confirm": True}) + + +async def main(target: Target, *, mode: str = "auto") -> None: + async with Client(target, mode=mode, elicitation_callback=on_elicit) as client: + # ── auto-loop: Client.call_tool dispatches input_requests to on_elicit and retries + # internally; the caller just sees the final CallToolResult. + deployed = await client.call_tool("deploy", {"env": "production"}) + assert isinstance(deployed.content[0], types.TextContent) + assert deployed.content[0].text == "deployed to production", deployed + + # ── manual loop: drop to client.session for the raw InputRequiredResult so the + # request_state can be persisted between rounds (e.g. across a process restart). + first = await client.session.call_tool("deploy", {"env": "staging"}, allow_input_required=True) + assert isinstance(first, types.InputRequiredResult) + assert first.input_requests is not None and "confirm" in first.input_requests + assert first.request_state == "awaiting-confirm" + # Decline this time so the path diverges from the auto-loop run above. + responses: types.InputResponses = {"confirm": types.ElicitResult(action="decline")} + second = await client.session.call_tool( + "deploy", + {"env": "staging"}, + input_responses=responses, + request_state=first.request_state, + allow_input_required=True, + ) + assert isinstance(second, types.CallToolResult) + assert isinstance(second.content[0], types.TextContent) + assert second.content[0].text == "deployment to staging cancelled", second + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/mrtr/server.py b/examples/stories/mrtr/server.py new file mode 100644 index 0000000000..d83c2e9835 --- /dev/null +++ b/examples/stories/mrtr/server.py @@ -0,0 +1,39 @@ +"""Multi-round tool result (2026 era): a tool returns input_required and resumes from echoed state.""" + +from mcp_types import ElicitRequest, ElicitRequestedSchema, ElicitRequestFormParams, ElicitResult, InputRequiredResult + +from mcp.server.mcpserver import Context, MCPServer +from stories._hosting import run_server_from_args + +CONFIRM_SCHEMA: ElicitRequestedSchema = { + "type": "object", + "properties": {"confirm": {"type": "boolean", "description": "Proceed with the deployment?"}}, + "required": ["confirm"], +} + + +def build_server() -> MCPServer: + mcp = MCPServer("mrtr-example") + + @mcp.tool(description="Deploy to an environment, asking the user to confirm first.") + async def deploy(env: str, ctx: Context) -> str | InputRequiredResult: + responses = ctx.input_responses + if responses is None or "confirm" not in responses: + # First round: ask the client to elicit confirmation. request_state is opaque + # to the client; here it carries the step name so the retry can verify the echo. + ask = ElicitRequest( + params=ElicitRequestFormParams(message=f"Deploy to {env}?", requested_schema=CONFIRM_SCHEMA) + ) + return InputRequiredResult(input_requests={"confirm": ask}, request_state="awaiting-confirm") + # Retry round: the client echoed request_state byte-exact and supplied the answer. + assert ctx.request_state == "awaiting-confirm", ctx.request_state + answer = responses["confirm"] + if isinstance(answer, ElicitResult) and answer.action == "accept" and (answer.content or {}).get("confirm"): + return f"deployed to {env}" + return f"deployment to {env} cancelled" + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/mrtr/server_lowlevel.py b/examples/stories/mrtr/server_lowlevel.py new file mode 100644 index 0000000000..0ed13cea49 --- /dev/null +++ b/examples/stories/mrtr/server_lowlevel.py @@ -0,0 +1,62 @@ +"""Multi-round tool result (2026 era) against the low-level Server.""" + +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from stories._hosting import run_server_from_args + +CONFIRM_SCHEMA: types.ElicitRequestedSchema = { + "type": "object", + "properties": {"confirm": {"type": "boolean", "description": "Proceed with the deployment?"}}, + "required": ["confirm"], +} +DEPLOY_INPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"env": {"type": "string"}}, + "required": ["env"], +} + + +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="deploy", + description="Deploy to an environment, asking the user to confirm first.", + input_schema=DEPLOY_INPUT_SCHEMA, + ) + ] + ) + + async def call_tool( + ctx: ServerRequestContext[Any], params: types.CallToolRequestParams + ) -> types.CallToolResult | types.InputRequiredResult: + assert params.name == "deploy" and params.arguments is not None + env = params.arguments["env"] + responses = params.input_responses + if responses is None or "confirm" not in responses: + ask = types.ElicitRequest( + params=types.ElicitRequestFormParams(message=f"Deploy to {env}?", requested_schema=CONFIRM_SCHEMA) + ) + return types.InputRequiredResult(input_requests={"confirm": ask}, request_state="awaiting-confirm") + assert params.request_state == "awaiting-confirm", params.request_state + answer = responses["confirm"] + if ( + isinstance(answer, types.ElicitResult) + and answer.action == "accept" + and (answer.content or {}).get("confirm") + ): + return types.CallToolResult(content=[types.TextContent(text=f"deployed to {env}")]) + return types.CallToolResult(content=[types.TextContent(text=f"deployment to {env} cancelled")]) + + return Server("mrtr-example", on_list_tools=list_tools, on_call_tool=call_tool) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/src/mcp-types/mcp_types/_types.py b/src/mcp-types/mcp_types/_types.py index 7f50d02fe7..09bf94d22b 100644 --- a/src/mcp-types/mcp_types/_types.py +++ b/src/mcp-types/mcp_types/_types.py @@ -2061,7 +2061,7 @@ class InputRequiredResult(Result): @model_validator(mode="after") def _require_one_field(self) -> Self: - if self.input_requests is None and self.request_state is None: + if not self.input_requests and self.request_state is None: raise ValueError("InputRequiredResult requires at least one of input_requests or request_state") return self diff --git a/src/mcp/__init__.py b/src/mcp/__init__.py index 3988289f6b..49bb494f94 100644 --- a/src/mcp/__init__.py +++ b/src/mcp/__init__.py @@ -58,6 +58,7 @@ ) from mcp_types import Role as SamplingRole +from .client._input_required import InputRequiredRoundsExceededError from .client.client import Client from .client.session import ClientSession from .client.session_group import ClientSessionGroup @@ -87,6 +88,7 @@ "InitializeRequest", "InitializeResult", "InitializedNotification", + "InputRequiredRoundsExceededError", "JSONRPCError", "JSONRPCRequest", "JSONRPCResponse", diff --git a/src/mcp/client/__init__.py b/src/mcp/client/__init__.py index 59bce03b8a..f9f732ad9e 100644 --- a/src/mcp/client/__init__.py +++ b/src/mcp/client/__init__.py @@ -1,8 +1,9 @@ """MCP Client module.""" +from mcp.client._input_required import InputRequiredRoundsExceededError from mcp.client._transport import Transport from mcp.client.client import Client from mcp.client.context import ClientRequestContext from mcp.client.session import ClientSession -__all__ = ["Client", "ClientRequestContext", "ClientSession", "Transport"] +__all__ = ["Client", "ClientRequestContext", "ClientSession", "InputRequiredRoundsExceededError", "Transport"] diff --git a/src/mcp/client/_input_required.py b/src/mcp/client/_input_required.py new file mode 100644 index 0000000000..fe3f59e175 --- /dev/null +++ b/src/mcp/client/_input_required.py @@ -0,0 +1,127 @@ +"""SEP-2322 client-side multi-round-trip driver. + +When a server returns `InputRequiredResult` instead of the normal result of a +`tools/call` / `prompts/get` / `resources/read`, the client fulfils the +embedded `input_requests` (sampling, elicitation, roots) and retries the +original request carrying the responses and the echoed opaque `request_state`. +This module implements that retry loop as a pure function so it can drive any +of the three methods identically; `Client` builds the `dispatch` and `retry` +closures, `ClientSession` stays mechanics-only. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import TypeVar + +import anyio +import anyio.abc +from mcp_types import ErrorData, InputRequest, InputRequiredResult, InputResponse, InputResponses + +from mcp.shared.exceptions import MCPError + +DEFAULT_INPUT_REQUIRED_MAX_ROUNDS = 10 +"""Default cap on `InputRequiredResult` retry rounds before the driver gives up. + +Matches the typescript-sdk default; csharp-sdk and go-sdk use the same value +as a hard constant. +""" + +_STATE_ONLY_BACKOFF_INITIAL_SECONDS = 0.05 +"""First sleep when an `InputRequiredResult` carries only `request_state` (no input requests).""" + +_STATE_ONLY_BACKOFF_CAP_SECONDS = 0.25 +"""Upper bound on the state-only backoff sleep; reached after three consecutive state-only legs.""" + + +ResultT = TypeVar("ResultT") + + +class InputRequiredRoundsExceededError(RuntimeError): + """The server kept returning `InputRequiredResult` past the configured `max_rounds`.""" + + def __init__(self, max_rounds: int) -> None: + super().__init__( + f"Server returned InputRequiredResult for more than {max_rounds} rounds; " + "raise input_required_max_rounds on the Client, or use " + "client.session.(..., allow_input_required=True) to drive the loop manually." + ) + self.max_rounds = max_rounds + + +async def run_input_required_driver( + first: InputRequiredResult, + *, + dispatch: Callable[[str, InputRequest], Awaitable[InputResponse | ErrorData]], + retry: Callable[[InputResponses | None, str | None], Awaitable[ResultT | InputRequiredResult]], + max_rounds: int = DEFAULT_INPUT_REQUIRED_MAX_ROUNDS, +) -> ResultT: + """Resolve an `InputRequiredResult` to its terminal result. + + Loops until `retry` returns a non-`InputRequiredResult`, or `max_rounds` is + exhausted. Each round either dispatches all `input_requests` concurrently + and retries with the collected responses, or — when the server sent only + `request_state` — sleeps with exponential backoff (50ms doubling to a 250ms + cap, reset by any leg that carries input requests) and retries empty. + `request_state` is passed through byte-exact and never inspected. + + Args: + first: The `InputRequiredResult` the original call returned. + dispatch: Runs one embedded `InputRequest` through the client's + sampling / elicitation / roots callbacks. Called concurrently per + request key. An `ErrorData` return aborts the loop as an `MCPError`. + retry: Re-issues the original request with the collected responses and + the latest `request_state`. Each call mints a fresh JSON-RPC id. + max_rounds: Cap on retry rounds. + + Raises: + InputRequiredRoundsExceededError: `max_rounds` exhausted. + MCPError: A `dispatch` call returned `ErrorData`. + """ + rounds = 0 + state_only_delay = _STATE_ONLY_BACKOFF_INITIAL_SECONDS + current: ResultT | InputRequiredResult = first + while isinstance(current, InputRequiredResult): + rounds += 1 + if rounds > max_rounds: + raise InputRequiredRoundsExceededError(max_rounds) + if current.input_requests: + state_only_delay = _STATE_ONLY_BACKOFF_INITIAL_SECONDS + responses: InputResponses | None = await _dispatch_all(current.input_requests, dispatch) + else: + await anyio.sleep(state_only_delay) + state_only_delay = min(state_only_delay * 2, _STATE_ONLY_BACKOFF_CAP_SECONDS) + responses = None + current = await retry(responses, current.request_state) + return current + + +async def _dispatch_all( + requests: dict[str, InputRequest], + dispatch: Callable[[str, InputRequest], Awaitable[InputResponse | ErrorData]], +) -> InputResponses: + """Run `dispatch` concurrently for every key, raising `MCPError` on the first `ErrorData`. + + The first task to return `ErrorData` cancels its siblings via the task + group's cancel scope, so a refused input does not wait on a slow peer. + A callback that *raises* propagates as an `ExceptionGroup` like any other + task-group failure. + """ + responses: InputResponses = {} + refused: ErrorData | None = None + + async def run_one(tg: anyio.abc.TaskGroup, key: str, req: InputRequest) -> None: + nonlocal refused + result = await dispatch(key, req) + if isinstance(result, ErrorData): + refused = result + tg.cancel_scope.cancel() + else: + responses[key] = result + + async with anyio.create_task_group() as tg: + for key, req in requests.items(): + tg.start_soon(run_one, tg, key, req) + if refused is not None: + raise MCPError.from_error_data(refused) + return responses diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index f5ca07400b..d6a6e4caae 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -5,7 +5,7 @@ from collections.abc import Awaitable, Callable, Mapping from contextlib import AsyncExitStack from dataclasses import KW_ONLY, dataclass, field -from typing import Any, Literal, TypeVar, overload +from typing import Any, Literal, TypeVar import anyio import mcp_types as types @@ -13,9 +13,12 @@ CallToolResult, CompleteResult, EmptyResult, + ErrorData, GetPromptResult, Implementation, + InputRequest, InputRequiredResult, + InputResponse, InputResponses, ListPromptsResult, ListResourcesResult, @@ -32,10 +35,19 @@ from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS from typing_extensions import deprecated +from mcp.client._input_required import DEFAULT_INPUT_REQUIRED_MAX_ROUNDS, run_input_required_driver from mcp.client._memory import InMemoryTransport from mcp.client._probe import negotiate_auto from mcp.client._transport import Transport -from mcp.client.session import ClientSession, ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT +from mcp.client.session import ( + ClientRequestContext, + 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 @@ -51,6 +63,7 @@ forward-compat; ``Client.__post_init__`` rejects anything outside that set at construction.""" _T = TypeVar("_T") +_ResultT = TypeVar("_ResultT") _Connector = Callable[[AsyncExitStack, ConnectMode, bool], Awaitable["Dispatcher[Any]"]] """Resolved at ``__post_init__`` from the shape of ``server`` alone: enter whatever resources @@ -199,6 +212,11 @@ async def main(): elicitation_callback: ElicitationFnT | None = None """Callback for handling elicitation requests.""" + input_required_max_rounds: int = DEFAULT_INPUT_REQUIRED_MAX_ROUNDS + """Cap on `InputRequiredResult` retry rounds before `call_tool` / `get_prompt` / + `read_resource` give up. Use `client.session.(..., allow_input_required=True)` + to drive the loop manually instead.""" + _entered: bool = field(init=False, default=False) _session: ClientSession | None = field(init=False, default=None) _exit_stack: AsyncExitStack | None = field(init=False, default=None) @@ -356,17 +374,42 @@ async def list_resource_templates( """List available resource templates from the server.""" return await self.session.list_resource_templates(params=PaginatedRequestParams(cursor=cursor, _meta=meta)) - async def read_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> ReadResourceResult: + async def read_resource( + self, + uri: str, + *, + input_responses: InputResponses | None = None, + request_state: str | None = None, + meta: RequestParamsMeta | None = None, + ) -> ReadResourceResult: """Read a resource from the server. + If the server returns an `InputRequiredResult`, the embedded input + requests are dispatched to this client's sampling / elicitation / roots + callbacks and the read is retried automatically (up to + `input_required_max_rounds`). + Args: uri: The URI of the resource to read. + input_responses: Responses to seed the first call with (e.g. when + resuming from a persisted `InputRequiredResult`). + request_state: Opaque state to seed the first call with. meta: Additional metadata for the request. Returns: The resource content. + + Raises: + InputRequiredRoundsExceededError: `input_required_max_rounds` exhausted. + MCPError: A callback returned `ErrorData` for an embedded input request. """ - return await self.session.read_resource(uri, meta=meta) + + async def retry(r: InputResponses | None, s: str | None) -> ReadResourceResult | InputRequiredResult: + return await self.session.read_resource( + uri, input_responses=r, request_state=s, meta=meta, allow_input_required=True + ) + + return await self._drive_input_required(await retry(input_responses, request_state), retry) async def subscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> EmptyResult: """Subscribe to resource updates.""" @@ -376,34 +419,6 @@ async def unsubscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None """Unsubscribe from resource updates.""" return await self.session.unsubscribe_resource(uri, meta=meta) - @overload - async def call_tool( - self, - name: str, - arguments: dict[str, Any] | None = None, - read_timeout_seconds: float | None = None, - progress_callback: ProgressFnT | None = None, - *, - input_responses: InputResponses | None = None, - request_state: str | None = None, - meta: RequestParamsMeta | None = None, - allow_input_required: Literal[False] = False, - ) -> CallToolResult: ... - - @overload - async def call_tool( - self, - name: str, - arguments: dict[str, Any] | None = None, - read_timeout_seconds: float | None = None, - progress_callback: ProgressFnT | None = None, - *, - input_responses: InputResponses | None = None, - request_state: str | None = None, - meta: RequestParamsMeta | None = None, - allow_input_required: bool, - ) -> CallToolResult | InputRequiredResult: ... - async def call_tool( self, name: str, @@ -414,42 +429,47 @@ async def call_tool( input_responses: InputResponses | None = None, request_state: str | None = None, meta: RequestParamsMeta | None = None, - allow_input_required: bool = False, - ) -> CallToolResult | InputRequiredResult: + ) -> CallToolResult: """Call a tool on the server. + If the server returns an `InputRequiredResult`, the embedded input + requests are dispatched to this client's sampling / elicitation / roots + callbacks and the call is retried automatically (up to + `input_required_max_rounds`). To drive the loop yourself — e.g. to + persist `request_state` across process restarts — use + `client.session.call_tool(..., allow_input_required=True)`. + Args: - name: The name of the tool to call - arguments: Arguments to pass to the tool - read_timeout_seconds: Timeout for the tool call - progress_callback: Callback for progress updates - input_responses: Responses to a prior `InputRequiredResult.input_requests` - request_state: Opaque state echoed from a prior `InputRequiredResult` - meta: Additional metadata for the request - allow_input_required: When ``False`` (default), an `InputRequiredResult` - from the server raises `RuntimeError`; when ``True``, it is returned - so the caller can resolve the requests and retry. + name: The name of the tool to call. + arguments: Arguments to pass to the tool. + read_timeout_seconds: Timeout for each underlying `tools/call` round. + progress_callback: Callback for progress updates. + input_responses: Responses to seed the first call with (e.g. when + resuming from a persisted `InputRequiredResult`). + request_state: Opaque state to seed the first call with. + meta: Additional metadata for the request. Returns: - The tool result. When ``allow_input_required=True``, may instead be an - `InputRequiredResult` carrying the server's input requests and opaque - ``request_state`` for the retry. + The tool result. Raises: - RuntimeError: If the server returns an `InputRequiredResult` and - ``allow_input_required`` is ``False``. + InputRequiredRoundsExceededError: `input_required_max_rounds` exhausted. + MCPError: A callback returned `ErrorData` for an embedded input request. """ - # TODO(L84): stop forwarding allow_input_required; run the MRTR auto-loop driver here (S6). - return await self.session.call_tool( - name=name, - arguments=arguments, - read_timeout_seconds=read_timeout_seconds, - progress_callback=progress_callback, - input_responses=input_responses, - request_state=request_state, - meta=meta, - allow_input_required=allow_input_required, - ) + + async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | InputRequiredResult: + return await self.session.call_tool( + name, + arguments, + read_timeout_seconds=read_timeout_seconds, + progress_callback=progress_callback, + input_responses=r, + request_state=s, + meta=meta, + allow_input_required=True, + ) + + return await self._drive_input_required(await retry(input_responses, request_state), retry) async def list_prompts( self, @@ -461,19 +481,66 @@ async def list_prompts( return await self.session.list_prompts(params=PaginatedRequestParams(cursor=cursor, _meta=meta)) async def get_prompt( - self, name: str, arguments: dict[str, str] | None = None, *, meta: RequestParamsMeta | None = None + self, + name: str, + arguments: dict[str, str] | None = None, + *, + input_responses: InputResponses | None = None, + request_state: str | None = None, + meta: RequestParamsMeta | None = None, ) -> GetPromptResult: """Get a prompt from the server. + If the server returns an `InputRequiredResult`, the embedded input + requests are dispatched to this client's sampling / elicitation / roots + callbacks and the get is retried automatically (up to + `input_required_max_rounds`). + Args: - name: The name of the prompt - arguments: Arguments to pass to the prompt - meta: Additional metadata for the request + name: The name of the prompt. + arguments: Arguments to pass to the prompt. + input_responses: Responses to seed the first call with (e.g. when + resuming from a persisted `InputRequiredResult`). + request_state: Opaque state to seed the first call with. + meta: Additional metadata for the request. Returns: The prompt content. + + Raises: + InputRequiredRoundsExceededError: `input_required_max_rounds` exhausted. + MCPError: A callback returned `ErrorData` for an embedded input request. """ - return await self.session.get_prompt(name=name, arguments=arguments, meta=meta) + + async def retry(r: InputResponses | None, s: str | None) -> GetPromptResult | InputRequiredResult: + return await self.session.get_prompt( + name, arguments, input_responses=r, request_state=s, meta=meta, allow_input_required=True + ) + + return await self._drive_input_required(await retry(input_responses, request_state), retry) + + async def _drive_input_required( + self, + first: _ResultT | InputRequiredResult, + retry: Callable[[InputResponses | None, str | None], Awaitable[_ResultT | InputRequiredResult]], + ) -> _ResultT: + """Hand an `InputRequiredResult` to the SEP-2322 driver, or pass a terminal result through. + + `dispatch` routes each embedded request through the same callback table + that serves legacy server→client RPCs, so the two paths stay + behaviourally identical by construction. + """ + if not isinstance(first, InputRequiredResult): + return first + session = self.session + + async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData: + ctx = ClientRequestContext(session=session, request_id=key, meta=req.params.meta if req.params else None) + return await session._dispatch_input_request(ctx, req) # pyright: ignore[reportPrivateUsage] + + return await run_input_required_driver( + first, dispatch=dispatch, retry=retry, max_rounds=self.input_required_max_rounds + ) async def complete( self, diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index b9e6860562..fa71d1330d 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -185,6 +185,19 @@ async def _default_logging_callback( _CallToolResultAdapter: TypeAdapter[types.CallToolResult | types.InputRequiredResult] = TypeAdapter( types.CallToolResult | types.InputRequiredResult ) +_GetPromptResultAdapter: TypeAdapter[types.GetPromptResult | types.InputRequiredResult] = TypeAdapter( + types.GetPromptResult | types.InputRequiredResult +) +_ReadResourceResultAdapter: TypeAdapter[types.ReadResourceResult | types.InputRequiredResult] = TypeAdapter( + types.ReadResourceResult | types.InputRequiredResult +) + + +def _input_required_unexpected(method: str) -> RuntimeError: + return RuntimeError( + "Server returned InputRequiredResult; pass allow_input_required=True to receive it " + f"and retry {method}(..., input_responses=..., request_state=result.request_state)." + ) class ClientSession: @@ -591,12 +604,64 @@ async def list_resource_templates( types.ListResourceTemplatesResult, ) - async def read_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> types.ReadResourceResult: - """Send a resources/read request.""" - return await self.send_request( - types.ReadResourceRequest(params=types.ReadResourceRequestParams(uri=uri, _meta=meta)), - types.ReadResourceResult, + @overload + async def read_resource( + self, + uri: str, + *, + input_responses: types.InputResponses | None = None, + request_state: str | None = None, + meta: RequestParamsMeta | None = None, + allow_input_required: Literal[False] = False, + ) -> types.ReadResourceResult: ... + + @overload + async def read_resource( + self, + uri: str, + *, + input_responses: types.InputResponses | None = None, + request_state: str | None = None, + meta: RequestParamsMeta | None = None, + allow_input_required: bool, + ) -> types.ReadResourceResult | types.InputRequiredResult: ... + + async def read_resource( + self, + uri: str, + *, + input_responses: types.InputResponses | None = None, + request_state: str | None = None, + meta: RequestParamsMeta | None = None, + allow_input_required: bool = False, + ) -> types.ReadResourceResult | types.InputRequiredResult: + """Send a resources/read request. + + Args: + input_responses: Responses to a prior `InputRequiredResult.input_requests`. + request_state: Opaque state echoed from a prior `InputRequiredResult`. + allow_input_required: When `False` (default), an `InputRequiredResult` + from the server raises `RuntimeError`; when `True`, it is returned + so the caller can resolve the requests and retry. + + Raises: + RuntimeError: If the server returns an `InputRequiredResult` and + `allow_input_required` is `False`. + """ + result = await self.send_request( + types.ReadResourceRequest( + params=types.ReadResourceRequestParams( + uri=uri, + input_responses=input_responses, + request_state=request_state, + _meta=meta, + ), + ), + _ReadResourceResultAdapter, ) + if isinstance(result, types.InputRequiredResult) and not allow_input_required: + raise _input_required_unexpected("read_resource") + return result async def subscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult: """Send a resources/subscribe request.""" @@ -689,10 +754,7 @@ async def call_tool( await self._validate_tool_result(name, result) if isinstance(result, types.InputRequiredResult) and not allow_input_required: - raise RuntimeError( - "Server returned InputRequiredResult; pass allow_input_required=True to receive it " - "and retry call_tool(..., input_responses=..., request_state=result.request_state)." - ) + raise _input_required_unexpected("call_tool") return result def _resolve_param_headers(self, name: str, arguments: Mapping[str, Any]) -> dict[str, str]: @@ -734,18 +796,68 @@ async def list_prompts(self, *, params: types.PaginatedRequestParams | None = No """ return await self.send_request(types.ListPromptsRequest(params=params), types.ListPromptsResult) + @overload + async def get_prompt( + self, + name: str, + arguments: dict[str, str] | None = None, + *, + input_responses: types.InputResponses | None = None, + request_state: str | None = None, + meta: RequestParamsMeta | None = None, + allow_input_required: Literal[False] = False, + ) -> types.GetPromptResult: ... + + @overload + async def get_prompt( + self, + name: str, + arguments: dict[str, str] | None = None, + *, + input_responses: types.InputResponses | None = None, + request_state: str | None = None, + meta: RequestParamsMeta | None = None, + allow_input_required: bool, + ) -> types.GetPromptResult | types.InputRequiredResult: ... + async def get_prompt( self, name: str, arguments: dict[str, str] | None = None, *, + input_responses: types.InputResponses | None = None, + request_state: str | None = None, meta: RequestParamsMeta | None = None, - ) -> types.GetPromptResult: - """Send a prompts/get request.""" - return await self.send_request( - types.GetPromptRequest(params=types.GetPromptRequestParams(name=name, arguments=arguments, _meta=meta)), - types.GetPromptResult, + allow_input_required: bool = False, + ) -> types.GetPromptResult | types.InputRequiredResult: + """Send a prompts/get request. + + Args: + input_responses: Responses to a prior `InputRequiredResult.input_requests`. + request_state: Opaque state echoed from a prior `InputRequiredResult`. + allow_input_required: When `False` (default), an `InputRequiredResult` + from the server raises `RuntimeError`; when `True`, it is returned + so the caller can resolve the requests and retry. + + Raises: + RuntimeError: If the server returns an `InputRequiredResult` and + `allow_input_required` is `False`. + """ + result = await self.send_request( + types.GetPromptRequest( + params=types.GetPromptRequestParams( + name=name, + arguments=arguments, + input_responses=input_responses, + request_state=request_state, + _meta=meta, + ), + ), + _GetPromptResultAdapter, ) + if isinstance(result, types.InputRequiredResult) and not allow_input_required: + raise _input_required_unexpected("get_prompt") + return result async def complete( self, @@ -829,13 +941,7 @@ async def _on_request( ctx = ClientRequestContext( session=self, request_id=dctx.request_id, meta=request.params.meta if request.params else None ) - match request: - case types.CreateMessageRequest(params=sampling_params): - response = await self._sampling_callback(ctx, sampling_params) - case types.ElicitRequest(params=elicit_params): - response = await self._elicitation_callback(ctx, elicit_params) - case types.ListRootsRequest(): # pragma: no branch - response = await self._list_roots_callback(ctx) + response = await self._dispatch_input_request(ctx, request) client_response = ClientResponse.validate_python(response) if isinstance(client_response, types.ErrorData): raise MCPError.from_error_data(client_response) @@ -847,6 +953,23 @@ async def _on_request( raise MCPError(code=INTERNAL_ERROR, message="Client callback returned an invalid result") from None return dumped + async def _dispatch_input_request( + self, ctx: ClientRequestContext, req: types.InputRequest + ) -> types.InputResponse | types.ErrorData: + """Route a server-initiated input request to the matching constructor callback. + + Shared by the legacy server→client RPC path (`_on_request`) and the + 2026-07-28 multi-round-trip driver, which dispatches the embedded + `InputRequiredResult.input_requests` through the same callbacks. + """ + match req: + case types.CreateMessageRequest(params=p): + return await self._sampling_callback(ctx, p) + case types.ElicitRequest(params=p): + return await self._elicitation_callback(ctx, p) + case types.ListRootsRequest(): # pragma: no branch + return await self._list_roots_callback(ctx) + async def _on_notify( self, dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None ) -> None: diff --git a/tests/client/test_client.py b/tests/client/test_client.py index e8557bcec4..a49ef55076 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -33,14 +33,16 @@ ToolsCapability, ) from mcp_types.version import LATEST_HANDSHAKE_VERSION +from pydantic import FileUrl from mcp import MCPError from mcp.client._memory import InMemoryTransport from mcp.client._transport import TransportStreams from mcp.client.client import Client +from mcp.client.session import ClientRequestContext from mcp.client.streamable_http import streamable_http_client from mcp.server import Server, ServerRequestContext -from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver import Context, MCPServer from mcp.shared.memory import MessageStream, create_client_server_memory_streams from mcp.shared.message import SessionMessage from tests.interaction._connect import BASE_URL, mounted_app @@ -513,3 +515,237 @@ def test_client_rejects_handshake_era_mode_at_construction() -> None: 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") + + +# ── SEP-2322 multi-round-trip auto-loop ──────────────────────────────────────── + + +_NAME_SCHEMA = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]} + + +def _name_elicitation(message: str = "What is your name?") -> types.ElicitRequest: + return types.ElicitRequest(params=types.ElicitRequestFormParams(message=message, requested_schema=_NAME_SCHEMA)) + + +async def test_call_tool_auto_loop_dispatches_elicitation_then_returns_final_result() -> None: + """When the server returns `InputRequiredResult` carrying an elicitation, + `Client.call_tool` routes it to `elicitation_callback` and retries + automatically — the caller sees only the terminal `CallToolResult`.""" + server = MCPServer("test") + + @server.tool() + async def greet(ctx: Context) -> str | types.InputRequiredResult: + responses = ctx.input_responses + if responses and "user_name" in responses: + answer = responses["user_name"] + assert isinstance(answer, types.ElicitResult) + assert answer.content is not None + return f"Hello, {answer.content['name']}!" + return types.InputRequiredResult(input_requests={"user_name": _name_elicitation()}) + + callback_params: list[types.ElicitRequestParams] = [] + + async def elicitation_callback( + context: ClientRequestContext, params: types.ElicitRequestParams + ) -> types.ElicitResult | types.ErrorData: + callback_params.append(params) + assert context.request_id == "user_name" # the inputRequests key is the request id + return types.ElicitResult(action="accept", content={"name": "Ada"}) + + with anyio.fail_after(5): + async with Client(server, elicitation_callback=elicitation_callback) as client: + result = await client.call_tool("greet") + + assert result == snapshot( + CallToolResult(content=[TextContent(text="Hello, Ada!")], structured_content={"result": "Hello, Ada!"}) + ) + assert len(callback_params) == 1 + assert isinstance(callback_params[0], types.ElicitRequestFormParams) + assert callback_params[0].message == "What is your name?" + assert callback_params[0].requested_schema == _NAME_SCHEMA + + +async def test_call_tool_auto_loop_dispatches_sampling_then_returns_final_result() -> None: + """`InputRequiredResult` with an embedded `CreateMessageRequest` is routed + to `sampling_callback` and the call retried with the model's reply.""" + server = MCPServer("test") + + @server.tool() + async def ask(ctx: Context) -> str | types.InputRequiredResult: + responses = ctx.input_responses + if responses and "q" in responses: + answer = responses["q"] + assert isinstance(answer, types.CreateMessageResult) + assert answer.content.type == "text" + return f"Model said: {answer.content.text}" + return types.InputRequiredResult( + input_requests={ + "q": types.CreateMessageRequest( + params=types.CreateMessageRequestParams( + messages=[types.SamplingMessage(role="user", content=TextContent(text="Capital of France?"))], + max_tokens=10, + ) + ) + } + ) + + callback_params: list[types.CreateMessageRequestParams] = [] + + async def sampling_callback( + context: ClientRequestContext, params: types.CreateMessageRequestParams + ) -> types.CreateMessageResult | types.ErrorData: + callback_params.append(params) + return types.CreateMessageResult(role="assistant", content=TextContent(text="Paris"), model="echo") + + with anyio.fail_after(5): + async with Client(server, sampling_callback=sampling_callback) as client: + result = await client.call_tool("ask") + + assert result == snapshot( + CallToolResult( + content=[TextContent(text="Model said: Paris")], structured_content={"result": "Model said: Paris"} + ) + ) + assert len(callback_params) == 1 + assert callback_params[0].messages[0].content == TextContent(text="Capital of France?") + + +async def test_call_tool_auto_loop_dispatches_list_roots_then_returns_final_result() -> None: + """`InputRequiredResult` with an embedded `ListRootsRequest` is routed to + `list_roots_callback` and the call retried with the returned roots.""" + server = MCPServer("test") + + @server.tool() + async def count_roots(ctx: Context) -> str | types.InputRequiredResult: + responses = ctx.input_responses + if responses and "roots" in responses: + answer = responses["roots"] + assert isinstance(answer, types.ListRootsResult) + return f"Client exposed {len(answer.roots)} root(s)." + return types.InputRequiredResult(input_requests={"roots": types.ListRootsRequest()}) + + callback_called: list[ClientRequestContext] = [] + + async def list_roots_callback(context: ClientRequestContext) -> types.ListRootsResult | types.ErrorData: + callback_called.append(context) + return types.ListRootsResult(roots=[types.Root(uri=FileUrl("file:///workspace"))]) + + with anyio.fail_after(5): + async with Client(server, list_roots_callback=list_roots_callback) as client: + result = await client.call_tool("count_roots") + + assert result == snapshot( + CallToolResult( + content=[TextContent(text="Client exposed 1 root(s).")], + structured_content={"result": "Client exposed 1 root(s)."}, + ) + ) + assert len(callback_called) == 1 + assert callback_called[0].request_id == "roots" + + +async def test_call_tool_auto_loop_round_trips_evolving_request_state_across_three_rounds() -> None: + """A three-round flow where each `InputRequiredResult.request_state` + encodes the round number: the driver echoes it back byte-exact, the server + advances per round, and the elicitation callback runs once per round.""" + server = MCPServer("test") + + @server.tool() + async def multi(ctx: Context) -> str | types.InputRequiredResult: + # Round number is the integer the server stashed in `request_state` last leg. + round_num = int(ctx.request_state) if ctx.request_state else 0 + if round_num == 3: + return "done after 3 rounds" + next_round = round_num + 1 + return types.InputRequiredResult( + input_requests={f"step{next_round}": _name_elicitation(f"Round {next_round}?")}, + request_state=str(next_round), + ) + + messages: list[str] = [] + + async def elicitation_callback( + context: ClientRequestContext, params: types.ElicitRequestParams + ) -> types.ElicitResult | types.ErrorData: + assert isinstance(params, types.ElicitRequestFormParams) + messages.append(params.message) + return types.ElicitResult(action="accept", content={"name": "x"}) + + with anyio.fail_after(5): + async with Client(server, elicitation_callback=elicitation_callback) as client: + result = await client.call_tool("multi") + + assert result.content == [TextContent(text="done after 3 rounds")] + assert messages == ["Round 1?", "Round 2?", "Round 3?"] + + +async def test_call_tool_auto_loop_raises_mcp_error_when_no_callback_registered() -> None: + """SDK-defined: with no `elicitation_callback`, the default returns + `ErrorData(INVALID_REQUEST, ...)` and the driver raises it as `MCPError` + rather than retrying.""" + server = MCPServer("test") + + @server.tool() + async def needs_input(ctx: Context) -> str | types.InputRequiredResult: + if ctx.input_responses: + raise NotImplementedError # unreachable: client errors before retrying + return types.InputRequiredResult(input_requests={"ask": _name_elicitation()}) + + async with Client(server) as client: + with anyio.fail_after(5), pytest.raises(MCPError) as exc: + await client.call_tool("needs_input") + assert exc.value.error.code == types.INVALID_REQUEST + + +async def test_get_prompt_auto_loop_resolves_input_required_via_callbacks() -> None: + """`Client.get_prompt` runs the same driver as `call_tool`: an + `InputRequiredResult` from `prompts/get` is fulfilled and retried.""" + + async def handler( + ctx: ServerRequestContext, params: types.GetPromptRequestParams + ) -> types.GetPromptResult | types.InputRequiredResult: + assert params.name == "summary" + if params.input_responses and "ask" in params.input_responses: + return GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="ok"))]) + return types.InputRequiredResult(input_requests={"ask": _name_elicitation()}) + + server = Server("test") + server.add_request_handler("prompts/get", types.GetPromptRequestParams, handler) + + async def elicitation_callback( + context: ClientRequestContext, params: types.ElicitRequestParams + ) -> types.ElicitResult | types.ErrorData: + return types.ElicitResult(action="accept", content={"name": "x"}) + + with anyio.fail_after(5): + async with Client(server, mode="2026-07-28", elicitation_callback=elicitation_callback) as client: + result = await client.get_prompt("summary") + assert result == snapshot(GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="ok"))])) + + +async def test_read_resource_auto_loop_resolves_input_required_via_callbacks() -> None: + """`Client.read_resource` runs the same driver as `call_tool`: an + `InputRequiredResult` from `resources/read` is fulfilled and retried.""" + + async def handler( + ctx: ServerRequestContext, params: types.ReadResourceRequestParams + ) -> types.ReadResourceResult | types.InputRequiredResult: + assert params.uri == "memory://gated" + if params.input_responses and "ask" in params.input_responses: + return ReadResourceResult(contents=[TextResourceContents(uri="memory://gated", text="unlocked")]) + return types.InputRequiredResult(input_requests={"ask": _name_elicitation()}) + + server = Server("test") + server.add_request_handler("resources/read", types.ReadResourceRequestParams, handler) + + async def elicitation_callback( + context: ClientRequestContext, params: types.ElicitRequestParams + ) -> types.ElicitResult | types.ErrorData: + return types.ElicitResult(action="accept", content={"name": "x"}) + + with anyio.fail_after(5): + async with Client(server, mode="2026-07-28", elicitation_callback=elicitation_callback) as client: + result = await client.read_resource("memory://gated") + assert result == snapshot( + ReadResourceResult(contents=[TextResourceContents(uri="memory://gated", text="unlocked")]) + ) diff --git a/tests/client/test_input_required.py b/tests/client/test_input_required.py new file mode 100644 index 0000000000..cc58cf8dbe --- /dev/null +++ b/tests/client/test_input_required.py @@ -0,0 +1,266 @@ +"""Unit tests for the SEP-2322 client-side multi-round-trip driver. + +`run_input_required_driver` is pure: it takes the first `InputRequiredResult` +plus `dispatch` / `retry` closures and loops until a terminal result. These +tests build those closures by hand (scripted lists, recording lists) so the +driver is exercised without a `ClientSession`. Integration against a real +server lives in `test_client.py`. +""" + +import anyio +import pytest +from inline_snapshot import snapshot +from mcp_types import ( + INVALID_REQUEST, + CallToolResult, + ElicitRequest, + ElicitRequestFormParams, + ElicitResult, + ErrorData, + InputRequest, + InputRequiredResult, + InputResponse, + InputResponses, + TextContent, +) +from trio.testing import MockClock + +from mcp import MCPError +from mcp.client._input_required import ( + _STATE_ONLY_BACKOFF_CAP_SECONDS, + _STATE_ONLY_BACKOFF_INITIAL_SECONDS, + DEFAULT_INPUT_REQUIRED_MAX_ROUNDS, + InputRequiredRoundsExceededError, + run_input_required_driver, +) + +pytestmark = pytest.mark.anyio + + +def _elicit(message: str = "What is your name?") -> ElicitRequest: + schema = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]} + return ElicitRequest(params=ElicitRequestFormParams(message=message, requested_schema=schema)) + + +async def _never_dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData: + """Dispatch closure for tests whose script never carries `input_requests`.""" + raise NotImplementedError + + +async def test_single_round_dispatches_then_retries_to_terminal_result() -> None: + """One `InputRequiredResult` with one elicit request: dispatch runs once, + retry runs once with the collected response, and the terminal result is returned.""" + first = InputRequiredResult(input_requests={"ask": _elicit()}) + terminal = CallToolResult(content=[TextContent(text="done")]) + dispatched: list[tuple[str, InputRequest]] = [] + retried: list[tuple[InputResponses | None, str | None]] = [] + + async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData: + dispatched.append((key, req)) + return ElicitResult(action="accept", content={"name": "Ada"}) + + async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult: + retried.append((responses, state)) + return terminal + + with anyio.fail_after(5): + result = await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=3) + + assert result is terminal + assert first.input_requests is not None + assert dispatched == [("ask", first.input_requests["ask"])] + assert retried == [({"ask": ElicitResult(action="accept", content={"name": "Ada"})}, None)] + + +async def test_multi_round_loops_until_retry_returns_non_input_required() -> None: + """Two consecutive `InputRequiredResult` legs followed by a terminal result: + the driver dispatches and retries each leg in order.""" + terminal = CallToolResult(content=[TextContent(text="done")]) + script: list[CallToolResult | InputRequiredResult] = [ + InputRequiredResult(input_requests={"b": _elicit("second?")}), + terminal, + ] + retried: list[tuple[InputResponses | None, str | None]] = [] + dispatched_keys: list[str] = [] + + async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData: + dispatched_keys.append(key) + return ElicitResult(action="decline") + + async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult: + retried.append((responses, state)) + return script.pop(0) + + first = InputRequiredResult(input_requests={"a": _elicit("first?")}) + with anyio.fail_after(5): + result = await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=5) + + assert result is terminal + assert dispatched_keys == ["a", "b"] + assert retried == snapshot( + [ + ({"a": ElicitResult(action="decline")}, None), + ({"b": ElicitResult(action="decline")}, None), + ] + ) + + +async def test_exceeding_max_rounds_raises_with_the_configured_cap() -> None: + """When every retry returns another `InputRequiredResult`, the driver gives + up after `max_rounds` retries with `InputRequiredRoundsExceededError`.""" + rounds: list[int] = [] + + async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData: + return ElicitResult(action="decline") + + async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult: + rounds.append(len(rounds)) + return InputRequiredResult(input_requests={"again": _elicit()}) + + first = InputRequiredResult(input_requests={"again": _elicit()}) + with anyio.fail_after(5): + with pytest.raises(InputRequiredRoundsExceededError) as exc: + await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=3) + + assert exc.value.max_rounds == 3 + # `first` counts as round 1; rounds 1-3 each retry, round 4 trips the cap before dispatching. + assert len(rounds) == 3 + + +async def test_dispatch_returning_error_data_aborts_the_loop_as_mcp_error() -> None: + """SDK-defined: a callback that refuses an embedded request returns + `ErrorData`; the driver surfaces it as `MCPError` rather than retrying.""" + + async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData: + return ErrorData(code=INVALID_REQUEST, message="not supported") + + async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult: + raise NotImplementedError # unreachable: dispatch errored before any retry + + first = InputRequiredResult(input_requests={"ask": _elicit()}) + with anyio.fail_after(5): + with pytest.raises(MCPError) as exc: + await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=3) + assert exc.value.error.code == INVALID_REQUEST + + +async def test_request_state_passes_through_byte_identical() -> None: + """`request_state` is opaque to the driver: each leg's value reaches `retry` + as the same object the server sent, never parsed or rebuilt.""" + states = ['{"round": 1, "tag": "héllo"}', '{"round": 2, "tag": "wörld"}'] + received_states: list[str | None] = [] + + async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData: + return ElicitResult(action="decline") + + async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult: + received_states.append(state) + if len(received_states) < 2: + return InputRequiredResult(input_requests={"k": _elicit()}, request_state=states[1]) + return CallToolResult(content=[]) + + first = InputRequiredResult(input_requests={"k": _elicit()}, request_state=states[0]) + with anyio.fail_after(5): + await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=3) + + assert received_states[0] is states[0] + assert received_states[1] is states[1] + + +# Runs on trio's autojumping virtual clock so the backoff sleeps add zero +# wall-clock and the recorded deltas are exact: `anyio.sleep` advances the +# MockClock by precisely the requested duration once every task is idle. +@pytest.mark.parametrize( + "anyio_backend", + [pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")], +) +async def test_state_only_legs_back_off_exponentially_to_the_cap() -> None: + """SDK-defined pacing: state-only legs sleep 50ms, 100ms, 200ms, then cap at + 250ms. Six state-only rounds → deltas `[0.05, 0.1, 0.2, 0.25, 0.25, 0.25]`.""" + retry_times: list[float] = [] + + async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult: + retry_times.append(anyio.current_time()) + assert responses is None + if len(retry_times) == 6: + return CallToolResult(content=[]) + return InputRequiredResult(request_state="poll") + + start = anyio.current_time() + first = InputRequiredResult(request_state="poll") + await run_input_required_driver(first, dispatch=_never_dispatch, retry=retry, max_rounds=10) + + deltas = [round(retry_times[0] - start, 9)] + [ + round(retry_times[i] - retry_times[i - 1], 9) for i in range(1, len(retry_times)) + ] + assert deltas == snapshot([0.05, 0.1, 0.2, 0.25, 0.25, 0.25]) + assert _STATE_ONLY_BACKOFF_INITIAL_SECONDS == 0.05 + assert _STATE_ONLY_BACKOFF_CAP_SECONDS == 0.25 + + +@pytest.mark.parametrize( + "anyio_backend", + [pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")], +) +async def test_backoff_counter_resets_after_a_leg_with_input_requests() -> None: + """A leg carrying `input_requests` resets `consecutive_state_only`: the + next state-only leg sleeps the initial 50ms again, not the prior position.""" + # state-only, state-only, dispatch leg (no sleep), state-only, terminal. + script: list[CallToolResult | InputRequiredResult] = [ + InputRequiredResult(request_state="s"), + InputRequiredResult(input_requests={"k": _elicit()}), + InputRequiredResult(request_state="s"), + CallToolResult(content=[]), + ] + retry_times: list[float] = [] + + async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData: + return ElicitResult(action="decline") + + async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult: + retry_times.append(anyio.current_time()) + return script.pop(0) + + start = anyio.current_time() + first = InputRequiredResult(request_state="s") + await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=10) + + deltas = [round(retry_times[0] - start, 9)] + [ + round(retry_times[i] - retry_times[i - 1], 9) for i in range(1, len(retry_times)) + ] + # 0.05, 0.1 (two state-only), 0.0 (dispatch leg has no sleep), 0.05 (reset). + assert deltas == snapshot([0.05, 0.1, 0.0, 0.05]) + + +async def test_input_requests_are_dispatched_concurrently() -> None: + """All `input_requests` in a round are dispatched together: each dispatch + blocks on a shared gate that only opens once every key has started, so a + sequential implementation would deadlock under the `fail_after`.""" + keys = ["a", "b", "c"] + started: set[str] = set() + all_started = anyio.Event() + + async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData: + started.add(key) + if started == set(keys): + all_started.set() + await all_started.wait() # blocks until every sibling is in-flight + return ElicitResult(action="accept", content={"name": key}) + + received: list[InputResponses | None] = [] + + async def retry(responses: InputResponses | None, state: str | None) -> CallToolResult | InputRequiredResult: + received.append(responses) + return CallToolResult(content=[]) + + first = InputRequiredResult(input_requests={k: _elicit() for k in keys}) + with anyio.fail_after(5): + await run_input_required_driver(first, dispatch=dispatch, retry=retry, max_rounds=2) + + assert received[0] is not None + assert received[0] == {k: ElicitResult(action="accept", content={"name": k}) for k in keys} + + +def test_default_max_rounds_constant() -> None: + """SDK-defined default; matches the typescript-sdk.""" + assert DEFAULT_INPUT_REQUIRED_MAX_ROUNDS == 10 diff --git a/tests/client/test_session.py b/tests/client/test_session.py index b66ca1bbaa..83893e36f9 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -1662,7 +1662,10 @@ async def test_discover_reraises_unsupported_version_with_malformed_error_data() @pytest.mark.anyio -async def test_call_tool_returns_input_required_result_when_server_requests_input() -> None: +async def test_session_call_tool_returns_input_required_result_when_opted_in() -> None: + """`ClientSession.call_tool(..., allow_input_required=True)` surfaces the + raw `InputRequiredResult` so the caller can drive the loop manually.""" + # `on_call_tool` is still typed `-> CallToolResult` on this branch (#2967 widens it later); # `add_request_handler` is `HandlerResult`-typed and accepts `InputRequiredResult` cleanly. async def handler(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.InputRequiredResult: @@ -1672,7 +1675,7 @@ async def handler(ctx: ServerRequestContext, params: types.CallToolRequestParams server.add_request_handler("tools/call", types.CallToolRequestParams, handler) with anyio.fail_after(5): async with Client(server, mode="2026-07-28") as client: - result = await client.call_tool("ask", allow_input_required=True) + result = await client.session.call_tool("ask", allow_input_required=True) assert isinstance(result, types.InputRequiredResult) assert result.request_state == "s" @@ -1703,7 +1706,11 @@ async def on_list_tools( @pytest.mark.anyio -async def test_client_call_tool_raises_on_input_required_without_opt_in() -> None: +async def test_session_call_tool_raises_on_input_required_without_opt_in() -> None: + """SDK-defined: `ClientSession.call_tool` is mechanics-only; an + `InputRequiredResult` with the default `allow_input_required=False` raises + `RuntimeError` (the auto-loop policy lives on `Client`, not here).""" + async def handler(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.InputRequiredResult: return types.InputRequiredResult(request_state="s") @@ -1711,7 +1718,45 @@ async def handler(ctx: ServerRequestContext, params: types.CallToolRequestParams server.add_request_handler("tools/call", types.CallToolRequestParams, handler) with anyio.fail_after(5): async with Client(server, mode="2026-07-28") as client: - with pytest.raises(RuntimeError): - await client.call_tool("t") - result = await client.call_tool("t", allow_input_required=True) + with pytest.raises(RuntimeError, match="allow_input_required=True"): + await client.session.call_tool("t") + result = await client.session.call_tool("t", allow_input_required=True) + assert isinstance(result, types.InputRequiredResult) + + +@pytest.mark.anyio +async def test_session_get_prompt_returns_input_required_result_when_opted_in() -> None: + """`ClientSession.get_prompt` mirrors `call_tool`: opting in returns the + raw `InputRequiredResult`; the default raises `RuntimeError`.""" + + async def handler(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> types.InputRequiredResult: + return types.InputRequiredResult(request_state="prompt-state") + + server = Server("test") + server.add_request_handler("prompts/get", types.GetPromptRequestParams, handler) + with anyio.fail_after(5): + async with Client(server, mode="2026-07-28") as client: + with pytest.raises(RuntimeError, match="allow_input_required=True"): + await client.session.get_prompt("p") + result = await client.session.get_prompt("p", allow_input_required=True) + assert isinstance(result, types.InputRequiredResult) + assert result.request_state == "prompt-state" + + +@pytest.mark.anyio +async def test_session_read_resource_returns_input_required_result_when_opted_in() -> None: + """`ClientSession.read_resource` mirrors `call_tool`: opting in returns the + raw `InputRequiredResult`; the default raises `RuntimeError`.""" + + async def handler(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> types.InputRequiredResult: + return types.InputRequiredResult(request_state="resource-state") + + server = Server("test") + server.add_request_handler("resources/read", types.ReadResourceRequestParams, handler) + with anyio.fail_after(5): + async with Client(server, mode="2026-07-28") as client: + with pytest.raises(RuntimeError, match="allow_input_required=True"): + await client.session.read_resource("memory://r") + result = await client.session.read_resource("memory://r", allow_input_required=True) assert isinstance(result, types.InputRequiredResult) + assert result.request_state == "resource-state" diff --git a/tests/docs_src/test_mrtr.py b/tests/docs_src/test_mrtr.py index 7dc78dbdc1..4be449edc0 100644 --- a/tests/docs_src/test_mrtr.py +++ b/tests/docs_src/test_mrtr.py @@ -4,6 +4,7 @@ from inline_snapshot import snapshot from mcp_types import ( INTERNAL_ERROR, + INVALID_REQUEST, CallToolResult, CreateMessageRequest, CreateMessageRequestParams, @@ -14,7 +15,7 @@ TextContent, ) -from docs_src.mrtr import tutorial001, tutorial002 +from docs_src.mrtr import tutorial001, tutorial002, tutorial003 from mcp import Client, MCPError # See test_index.py for why this is a per-module mark and not a conftest hook. @@ -24,7 +25,7 @@ async def test_first_call_returns_an_input_required_result() -> None: """tutorial001: a tool that is missing input returns `InputRequiredResult` instead of calling back.""" async with Client(tutorial001.server) as client: - result = await client.call_tool("provision", {"name": "orders"}, allow_input_required=True) + result = await client.session.call_tool("provision", {"name": "orders"}, allow_input_required=True) assert result == snapshot( InputRequiredResult( result_type="input_required", @@ -47,15 +48,22 @@ async def test_first_call_returns_an_input_required_result() -> None: ) -async def test_call_tool_raises_without_the_opt_in() -> None: - """The page's `!!! check`: `allow_input_required` defaults to `False` and the result is a hard error.""" +async def test_the_auto_loop_drives_the_call_to_completion() -> None: + """tutorial003: register `elicitation_callback`, call the tool, get a plain `CallToolResult` back.""" + async with Client(tutorial001.server, elicitation_callback=tutorial003.handle_elicitation) as client: + result = await client.call_tool("provision", {"name": "orders"}) + assert result == snapshot( + CallToolResult(content=[TextContent(type="text", text="Provisioned 'orders' in eu-west-1.")]) + ) + + +async def test_the_auto_loop_without_a_callback_raises_mcp_error() -> None: + """The page's `!!! check`: no `elicitation_callback` means the SDK's stand-in answers with an error.""" async with Client(tutorial001.server) as client: - with pytest.raises(RuntimeError) as exc: + with pytest.raises(MCPError) as exc: await client.call_tool("provision", {"name": "orders"}) - assert str(exc.value) == ( - "Server returned InputRequiredResult; pass allow_input_required=True to receive it " - "and retry call_tool(..., input_responses=..., request_state=result.request_state)." - ) + assert exc.value.error.code == INVALID_REQUEST + assert exc.value.error.message == "Elicitation not supported" async def test_retry_with_input_responses_and_request_state_completes_the_call() -> None: @@ -73,7 +81,7 @@ async def test_retry_with_input_responses_and_request_state_completes_the_call() async def test_the_manual_loop_drives_the_call_to_completion() -> None: - """tutorial002: `while isinstance(result, InputRequiredResult)` is the whole client API, and it terminates.""" + """tutorial002: `client.session.call_tool(..., allow_input_required=True)` for callers who own the loop.""" async with Client(tutorial001.server) as client: result = await tutorial002.provision(client, "billing") assert result == snapshot( @@ -91,7 +99,7 @@ async def test_a_pre_2026_session_has_nowhere_to_put_the_result() -> None: """The page's `!!! warning`: on a legacy session the runner cannot serialize an `InputRequiredResult`.""" async with Client(tutorial001.server, mode="legacy") as client: with pytest.raises(MCPError) as exc: - await client.call_tool("provision", {"name": "orders"}, allow_input_required=True) + await client.call_tool("provision", {"name": "orders"}) assert exc.value.error.code == INTERNAL_ERROR assert exc.value.error.message == "Handler returned an invalid result" diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 47f3384a87..2c58b8fdea 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -1588,7 +1588,7 @@ async def ask(ctx: Context) -> str | InputRequiredResult: with anyio.fail_after(5): async with Client(mcp, mode="2026-07-28") as client: - result = await client.call_tool("ask", allow_input_required=True) + result = await client.session.call_tool("ask", allow_input_required=True) assert isinstance(result, InputRequiredResult) assert result.request_state == "round-1" @@ -1624,11 +1624,11 @@ async def greet(ctx: Context) -> str | InputRequiredResult: with anyio.fail_after(5): async with Client(mcp, mode="2026-07-28") as client: - r1 = await client.call_tool("greet", allow_input_required=True) + r1 = await client.session.call_tool("greet", allow_input_required=True) assert isinstance(r1, InputRequiredResult) assert r1.input_requests is not None and "who" in r1.input_requests - r2 = await client.call_tool( + r2 = await client.session.call_tool( "greet", input_responses={"who": ElicitResult(action="accept", content={"name": "Alice"})}, request_state=r1.request_state, diff --git a/tests/test_types.py b/tests/test_types.py index c32ade2957..3083774200 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -444,5 +444,6 @@ def test_input_required_result_dumps_its_discriminating_tag(): def test_input_required_result_requires_at_least_one_of_input_requests_or_request_state(): with pytest.raises(ValidationError): InputRequiredResult() - assert InputRequiredResult(input_requests={}).request_state is None + with pytest.raises(ValidationError): + InputRequiredResult(input_requests={}) assert InputRequiredResult(request_state="s").input_requests is None From ecdf09d44f6c7d46d411d17f8e336db1ad9bea82 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 26 Jun 2026 17:51:13 +0200 Subject: [PATCH 015/100] Deprecate Server.__init__ handlers for removed capabilities (#3002) --- docs/migration.md | 2 + examples/stories/streaming/server_lowlevel.py | 2 +- src/mcp/server/lowlevel/server.py | 201 +++++++++++++++++- tests/client/test_client.py | 4 +- tests/interaction/lowlevel/test_flows.py | 2 +- tests/interaction/lowlevel/test_initialize.py | 2 +- tests/interaction/lowlevel/test_logging.py | 10 +- tests/interaction/lowlevel/test_progress.py | 2 +- tests/interaction/lowlevel/test_roots.py | 2 +- tests/interaction/lowlevel/test_wire.py | 2 +- tests/interaction/transports/_stdio_server.py | 6 +- .../transports/test_hosting_http.py | 2 +- 12 files changed, 221 insertions(+), 16 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 7a342c4cda..c626e5b2fa 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1345,6 +1345,8 @@ The user-facing methods for these features now carry `typing_extensions.deprecat - Roots: `ServerSession.list_roots()`, `ClientPeer.list_roots()`, `ClientSession.send_roots_list_changed()`, `Client.send_roots_list_changed()` - Logging: `ServerSession.send_log_message()`, `Connection.log()`, `ClientSession.set_logging_level()`, `Client.set_logging_level()`, `mcp.server.context.Context.log()` (the lowlevel `Context`), and the `MCPServer` `Context` helpers `log()`, `debug()`, `info()`, `warning()`, `error()` +Registering a handler for a deprecated capability is deprecated too. The `Server.__init__` parameters `on_set_logging_level` (Logging) and `on_roots_list_changed` (Roots) are now split out into a `typing_extensions.deprecated` overload, so passing either is flagged by type checkers and emits `mcp.MCPDeprecationWarning` at construction time. `on_progress` follows the same pattern (see below). The non-deprecated overload omits these parameters, so the common case stays warning-free. + The runtime warning is emitted as `mcp.MCPDeprecationWarning`, which subclasses `UserWarning` (not `DeprecationWarning`) so it is visible by default. To silence it, filter that category: ```python diff --git a/examples/stories/streaming/server_lowlevel.py b/examples/stories/streaming/server_lowlevel.py index 07daf641b4..6d9add0b6c 100644 --- a/examples/stories/streaming/server_lowlevel.py +++ b/examples/stories/streaming/server_lowlevel.py @@ -57,7 +57,7 @@ async def set_logging_level( """Registered so the server advertises the `logging` capability; never called.""" raise NotImplementedError - return Server( + return Server( # pyright: ignore[reportDeprecated] "streaming-example", on_list_tools=list_tools, on_call_tool=call_tool, diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index ea6ea77df8..778e89e458 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -37,11 +37,12 @@ async def main(): from __future__ import annotations import logging +import warnings from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import AbstractAsyncContextManager, asynccontextmanager from dataclasses import dataclass from importlib.metadata import version as importlib_version -from typing import Any, Generic +from typing import Any, Generic, overload import mcp_types as types from mcp_types.version import MODERN_PROTOCOL_VERSIONS @@ -50,7 +51,7 @@ async def main(): from starlette.middleware import Middleware from starlette.middleware.authentication import AuthenticationMiddleware from starlette.routing import Mount, Route -from typing_extensions import TypeVar +from typing_extensions import TypeVar, deprecated from mcp.server._otel import OpenTelemetryMiddleware from mcp.server.auth.middleware.auth_context import AuthContextMiddleware @@ -65,6 +66,7 @@ async def main(): from mcp.server.streamable_http_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager from mcp.server.transport_security import TransportSecuritySettings from mcp.shared._stream_protocols import ReadStream, WriteStream +from mcp.shared.exceptions import MCPDeprecationWarning from mcp.shared.message import SessionMessage logger = logging.getLogger(__name__) @@ -127,6 +129,89 @@ def _package_version(package: str) -> str: class Server(Generic[LifespanResultT]): + @overload + def __init__( + self, + name: str, + *, + version: str | None = None, + title: str | None = None, + description: str | None = None, + instructions: str | None = None, + website_url: str | None = None, + icons: list[types.Icon] | None = None, + lifespan: Callable[ + [Server[LifespanResultT]], + AbstractAsyncContextManager[LifespanResultT], + ] = lifespan, + # Request handlers + on_list_tools: Callable[ + [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], + Awaitable[types.ListToolsResult], + ] + | None = None, + on_call_tool: Callable[ + [ServerRequestContext[LifespanResultT], types.CallToolRequestParams], + Awaitable[types.CallToolResult | types.InputRequiredResult], + ] + | None = None, + on_list_resources: Callable[ + [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], + Awaitable[types.ListResourcesResult], + ] + | None = None, + on_list_resource_templates: Callable[ + [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], + Awaitable[types.ListResourceTemplatesResult], + ] + | None = None, + on_read_resource: Callable[ + [ServerRequestContext[LifespanResultT], types.ReadResourceRequestParams], + Awaitable[types.ReadResourceResult | types.InputRequiredResult], + ] + | None = None, + on_subscribe_resource: Callable[ + [ServerRequestContext[LifespanResultT], types.SubscribeRequestParams], + Awaitable[types.EmptyResult], + ] + | None = None, + on_unsubscribe_resource: Callable[ + [ServerRequestContext[LifespanResultT], types.UnsubscribeRequestParams], + Awaitable[types.EmptyResult], + ] + | None = None, + on_subscriptions_listen: Callable[ + [ServerRequestContext[LifespanResultT], types.SubscriptionsListenRequestParams], + Awaitable[types.EmptyResult], + ] + | None = None, + on_list_prompts: Callable[ + [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], + Awaitable[types.ListPromptsResult], + ] + | None = None, + on_get_prompt: Callable[ + [ServerRequestContext[LifespanResultT], types.GetPromptRequestParams], + Awaitable[types.GetPromptResult | types.InputRequiredResult], + ] + | None = None, + on_completion: Callable[ + [ServerRequestContext[LifespanResultT], types.CompleteRequestParams], + Awaitable[types.CompleteResult], + ] + | None = None, + on_ping: Callable[ + [ServerRequestContext[LifespanResultT], types.RequestParams | None], + Awaitable[types.EmptyResult], + ] = _ping_handler, + ) -> None: ... + @overload + @deprecated( + "on_set_logging_level (Logging) and on_roots_list_changed (Roots) are deprecated as of 2026-07-28 " + "(SEP-2577); on_progress (client-to-server progress) is deprecated as of 2026-07-28. Passing any of " + "them emits an MCPDeprecationWarning at runtime.", + category=MCPDeprecationWarning, + ) def __init__( self, name: str, @@ -217,7 +302,117 @@ def __init__( Awaitable[None], ] | None = None, - ): + ) -> None: ... + def __init__( + self, + name: str, + *, + version: str | None = None, + title: str | None = None, + description: str | None = None, + instructions: str | None = None, + website_url: str | None = None, + icons: list[types.Icon] | None = None, + lifespan: Callable[ + [Server[LifespanResultT]], + AbstractAsyncContextManager[LifespanResultT], + ] = lifespan, + # Request handlers + on_list_tools: Callable[ + [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], + Awaitable[types.ListToolsResult], + ] + | None = None, + on_call_tool: Callable[ + [ServerRequestContext[LifespanResultT], types.CallToolRequestParams], + Awaitable[types.CallToolResult | types.InputRequiredResult], + ] + | None = None, + on_list_resources: Callable[ + [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], + Awaitable[types.ListResourcesResult], + ] + | None = None, + on_list_resource_templates: Callable[ + [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], + Awaitable[types.ListResourceTemplatesResult], + ] + | None = None, + on_read_resource: Callable[ + [ServerRequestContext[LifespanResultT], types.ReadResourceRequestParams], + Awaitable[types.ReadResourceResult | types.InputRequiredResult], + ] + | None = None, + on_subscribe_resource: Callable[ + [ServerRequestContext[LifespanResultT], types.SubscribeRequestParams], + Awaitable[types.EmptyResult], + ] + | None = None, + on_unsubscribe_resource: Callable[ + [ServerRequestContext[LifespanResultT], types.UnsubscribeRequestParams], + Awaitable[types.EmptyResult], + ] + | None = None, + on_subscriptions_listen: Callable[ + [ServerRequestContext[LifespanResultT], types.SubscriptionsListenRequestParams], + Awaitable[types.EmptyResult], + ] + | None = None, + on_list_prompts: Callable[ + [ServerRequestContext[LifespanResultT], types.PaginatedRequestParams | None], + Awaitable[types.ListPromptsResult], + ] + | None = None, + on_get_prompt: Callable[ + [ServerRequestContext[LifespanResultT], types.GetPromptRequestParams], + Awaitable[types.GetPromptResult | types.InputRequiredResult], + ] + | None = None, + on_completion: Callable[ + [ServerRequestContext[LifespanResultT], types.CompleteRequestParams], + Awaitable[types.CompleteResult], + ] + | None = None, + on_set_logging_level: Callable[ + [ServerRequestContext[LifespanResultT], types.SetLevelRequestParams], + Awaitable[types.EmptyResult], + ] + | None = None, + on_ping: Callable[ + [ServerRequestContext[LifespanResultT], types.RequestParams | None], + Awaitable[types.EmptyResult], + ] = _ping_handler, + # Notification handlers + on_roots_list_changed: Callable[ + [ServerRequestContext[LifespanResultT], types.NotificationParams | None], + Awaitable[None], + ] + | None = None, + on_progress: Callable[ + [ServerRequestContext[LifespanResultT], types.ProgressNotificationParams], + Awaitable[None], + ] + | None = None, + ) -> None: + if on_set_logging_level is not None: + warnings.warn( + "The logging capability is deprecated as of 2026-07-28 (SEP-2577).", + MCPDeprecationWarning, + stacklevel=2, + ) + if on_roots_list_changed is not None: + warnings.warn( + "The roots capability is deprecated as of 2026-07-28 (SEP-2577).", + MCPDeprecationWarning, + stacklevel=2, + ) + if on_progress is not None: + warnings.warn( + "Client-to-server progress is deprecated as of 2026-07-28.", + MCPDeprecationWarning, + stacklevel=2, + ) + self.name = name self.version = version self.title = title diff --git a/tests/client/test_client.py b/tests/client/test_client.py index a49ef55076..a6a9ac6ea8 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -75,7 +75,7 @@ async def handle_set_logging_level(ctx: ServerRequestContext, params: types.SetL async def handle_completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> types.CompleteResult: return types.CompleteResult(completion=types.Completion(values=[])) - return Server( + return Server( # pyright: ignore[reportDeprecated] name="test_server", on_list_resources=handle_list_resources, on_subscribe_resource=handle_subscribe_resource, @@ -279,7 +279,7 @@ async def handle_progress(ctx: ServerRequestContext, params: types.ProgressNotif received_from_client = {"progress_token": params.progress_token, "progress": params.progress} event.set() - server = Server(name="test_server", on_progress=handle_progress) + server = Server(name="test_server", on_progress=handle_progress) # pyright: ignore[reportDeprecated] with anyio.fail_after(5): async with Client(server, mode="legacy") as client: diff --git a/tests/interaction/lowlevel/test_flows.py b/tests/interaction/lowlevel/test_flows.py index 77cf78888f..19788db4a1 100644 --- a/tests/interaction/lowlevel/test_flows.py +++ b/tests/interaction/lowlevel/test_flows.py @@ -173,7 +173,7 @@ async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelReq """Registered so the logging capability is advertised; the client never sets a level.""" raise NotImplementedError - server = Server( + server = Server( # pyright: ignore[reportDeprecated] "gatekeeper", on_list_tools=_list_tools("read_files"), on_call_tool=call_tool, diff --git a/tests/interaction/lowlevel/test_initialize.py b/tests/interaction/lowlevel/test_initialize.py index 29eb7297c9..f3fd9589a6 100644 --- a/tests/interaction/lowlevel/test_initialize.py +++ b/tests/interaction/lowlevel/test_initialize.py @@ -127,7 +127,7 @@ async def completion(ctx: ServerRequestContext, params: types.CompleteRequestPar """Registered only so the completions capability is advertised; never called.""" raise NotImplementedError - server = Server( + server = Server( # pyright: ignore[reportDeprecated] "full", on_list_tools=list_tools, on_list_resources=list_resources, diff --git a/tests/interaction/lowlevel/test_logging.py b/tests/interaction/lowlevel/test_logging.py index 90af931cf2..bfc86509d2 100644 --- a/tests/interaction/lowlevel/test_logging.py +++ b/tests/interaction/lowlevel/test_logging.py @@ -36,7 +36,7 @@ async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelReq assert params.level == "warning" return EmptyResult() - server = Server("logger", on_set_logging_level=set_logging_level) + server = Server("logger", on_set_logging_level=set_logging_level) # pyright: ignore[reportDeprecated] async with connect(server) as client: result = await client.set_logging_level("warning") # pyright: ignore[reportDeprecated] @@ -76,7 +76,9 @@ async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelReq """Registered so the logging capability is advertised; the client never sets a level.""" raise NotImplementedError - server = Server("logger", on_list_tools=list_tools, on_call_tool=call_tool, on_set_logging_level=set_logging_level) + server = Server( # pyright: ignore[reportDeprecated] + "logger", on_list_tools=list_tools, on_call_tool=call_tool, on_set_logging_level=set_logging_level + ) async with connect(server, logging_callback=collect) as client: result = await client.call_tool("chatty", {}) @@ -115,7 +117,9 @@ async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelReq """Registered so the logging capability is advertised; the client never sets a level.""" raise NotImplementedError - server = Server("logger", on_list_tools=list_tools, on_call_tool=call_tool, on_set_logging_level=set_logging_level) + server = Server( # pyright: ignore[reportDeprecated] + "logger", on_list_tools=list_tools, on_call_tool=call_tool, on_set_logging_level=set_logging_level + ) async with connect(server, logging_callback=collect) as client: await client.call_tool("siren", {}) diff --git a/tests/interaction/lowlevel/test_progress.py b/tests/interaction/lowlevel/test_progress.py index b2e94bae36..7f75e18eeb 100644 --- a/tests/interaction/lowlevel/test_progress.py +++ b/tests/interaction/lowlevel/test_progress.py @@ -118,7 +118,7 @@ async def on_progress(ctx: ServerRequestContext, params: ProgressNotificationPar received.append(params) delivered.set() - server = Server("observer", on_progress=on_progress) + server = Server("observer", on_progress=on_progress) # pyright: ignore[reportDeprecated] async with connect(server) as client: await client.send_progress_notification("upload-1", 0.5, total=1.0, message="halfway") # pyright: ignore[reportDeprecated] diff --git a/tests/interaction/lowlevel/test_roots.py b/tests/interaction/lowlevel/test_roots.py index 5e38b8aab1..bfd6cc90a0 100644 --- a/tests/interaction/lowlevel/test_roots.py +++ b/tests/interaction/lowlevel/test_roots.py @@ -153,7 +153,7 @@ async def roots_list_changed(ctx: ServerRequestContext, params: types.Notificati received.append(params) delivered.set() - server = Server("rooted", on_roots_list_changed=roots_list_changed) + server = Server("rooted", on_roots_list_changed=roots_list_changed) # pyright: ignore[reportDeprecated] async def list_roots(context: ClientRequestContext) -> ListRootsResult: """Registered so the client declares the roots capability; the server never asks for roots.""" diff --git a/tests/interaction/lowlevel/test_wire.py b/tests/interaction/lowlevel/test_wire.py index cddbfdfa5d..73452f1afb 100644 --- a/tests/interaction/lowlevel/test_wire.py +++ b/tests/interaction/lowlevel/test_wire.py @@ -262,7 +262,7 @@ async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelReq """Registered so the logging capability is advertised; never called -- params validation fails first.""" raise NotImplementedError - server = Server("logger", on_set_logging_level=set_logging_level) + server = Server("logger", on_set_logging_level=set_logging_level) # pyright: ignore[reportDeprecated] errors: list[ErrorData] = [] async with create_client_server_memory_streams() as (client_streams, server_streams): diff --git a/tests/interaction/transports/_stdio_server.py b/tests/interaction/transports/_stdio_server.py index cc3dc3a8a1..811de1540b 100644 --- a/tests/interaction/transports/_stdio_server.py +++ b/tests/interaction/transports/_stdio_server.py @@ -53,7 +53,11 @@ async def set_logging_level(ctx: ServerRequestContext, params: SetLevelRequestPa raise NotImplementedError -server = Server("stdio-echo", on_list_tools=list_tools, on_call_tool=call_tool, on_set_logging_level=set_logging_level) +with warnings.catch_warnings(): + warnings.simplefilter("ignore", MCPDeprecationWarning) + server = Server( # pyright: ignore[reportDeprecated] + "stdio-echo", on_list_tools=list_tools, on_call_tool=call_tool, on_set_logging_level=set_logging_level + ) async def main() -> None: diff --git a/tests/interaction/transports/test_hosting_http.py b/tests/interaction/transports/test_hosting_http.py index 9c83e213c6..6331c2dae1 100644 --- a/tests/interaction/transports/test_hosting_http.py +++ b/tests/interaction/transports/test_hosting_http.py @@ -73,7 +73,7 @@ async def subscribe_resource(ctx: ServerRequestContext, params: SubscribeRequest """Registered so the resources subscribe sub-capability is advertised; the client never subscribes.""" raise NotImplementedError - return Server( + return Server( # pyright: ignore[reportDeprecated] "hosted", on_list_tools=list_tools, on_call_tool=call_tool, From c0ecb70e2485d6a2bf2ac78a3c3350fa28b3ba58 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 26 Jun 2026 17:57:10 +0200 Subject: [PATCH 016/100] Support RFC 8693 token exchange for enterprise IdP flows (SEP-990) (#2988) Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com> --- docs/migration.md | 31 ++ .../clients/identity_assertion_client.py | 82 ++++ examples/snippets/pyproject.toml | 1 + .../servers/identity_assertion_server.py | 103 +++++ .../auth/extensions/identity_assertion.py | 213 +++++++++ src/mcp/client/auth/oauth2.py | 6 +- src/mcp/server/auth/handlers/register.py | 17 +- src/mcp/server/auth/handlers/token.py | 66 ++- src/mcp/server/auth/middleware/client_auth.py | 5 + src/mcp/server/auth/provider.py | 62 +++ src/mcp/server/auth/routes.py | 23 +- src/mcp/server/auth/settings.py | 6 + src/mcp/server/lowlevel/server.py | 1 + src/mcp/server/mcpserver/server.py | 1 + src/mcp/shared/auth.py | 6 + .../extensions/test_identity_assertion.py | 411 ++++++++++++++++++ tests/client/test_auth.py | 13 +- tests/interaction/_requirements.py | 58 +++ tests/interaction/auth/_harness.py | 10 +- tests/interaction/auth/_provider.py | 30 ++ .../auth/test_identity_assertion.py | 302 +++++++++++++ tests/server/auth/test_identity_assertion.py | 397 +++++++++++++++++ 22 files changed, 1830 insertions(+), 14 deletions(-) create mode 100644 examples/snippets/clients/identity_assertion_client.py create mode 100644 examples/snippets/servers/identity_assertion_server.py create mode 100644 src/mcp/client/auth/extensions/identity_assertion.py create mode 100644 tests/client/auth/extensions/test_identity_assertion.py create mode 100644 tests/interaction/auth/test_identity_assertion.py create mode 100644 tests/server/auth/test_identity_assertion.py diff --git a/docs/migration.md b/docs/migration.md index c626e5b2fa..e987b626c6 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1451,6 +1451,37 @@ client_metadata = OAuthClientMetadata( Under OIDC, omitting `application_type` defaults to `"web"`, which an authorization server may reject for the `localhost` redirect URIs native clients use; sending `"native"` avoids that. Non-OIDC servers ignore the parameter. +### Identity Assertion Authorization Grant for enterprise IdP flows (SEP-990) + +The SDK now supports SEP-990's enterprise identity-provider policy controls. The client presents an Identity Assertion Authorization Grant (ID-JAG) - a signed JWT issued by the enterprise IdP - to the MCP authorization server using the RFC 7523 jwt-bearer grant (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, the ID-JAG as `assertion`), and receives an MCP access token. This matches the SEP-990 normative profile and interoperates with the other MCP SDKs. (Leg 1 - exchanging the user's IdP ID token for the ID-JAG against the IdP - is deployment-specific and out of scope for the SDK.) This is additive and opt-in on both sides; existing flows are unchanged. + +On the client, `IdentityAssertionOAuthProvider` (in `mcp.client.auth.extensions.identity_assertion`) is an `httpx.Auth` that posts the jwt-bearer request. The ID-JAG is supplied lazily through an async `assertion_provider(audience, resource)` callback - `audience` is the authorization server's issuer (the ID-JAG `aud`) and `resource` is the MCP server's identifier (the ID-JAG `resource` claim): + +```python +from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider + + +async def fetch_id_jag(audience: str, resource: str) -> str: + # The ID-JAG must carry `audience` as `aud` and `resource` as its `resource` claim. + return await my_idp.issue_id_jag(audience=audience, resource=resource) + + +provider = IdentityAssertionOAuthProvider( + server_url="https://mcp.example.com/mcp", + storage=my_token_storage, + client_id="enterprise-mcp-client", + client_secret="enterprise-mcp-secret", + issuer="https://auth.example.com", + assertion_provider=fetch_id_jag, +) +``` + +SEP-990 §5.1 requires the client to authenticate; this SDK currently requires a shared secret, so `client_secret` is mandatory (`token_endpoint_auth_method` chooses `client_secret_post` (default) or `client_secret_basic`; the spec also permits `private_key_jwt`). The authorization server is configuration, not discovery: `issuer` is the AS the client is provisioned for, authorization-server metadata is fetched from that issuer's RFC 8414 well-known, and the resource server is never asked which AS to use - so a hostile resource server cannot redirect the ID-JAG or secret. + +On the authorization server, set `AuthSettings(identity_assertion_enabled=True)` (or pass `identity_assertion_enabled=True` to `create_auth_routes`) and implement `exchange_identity_assertion` on your `OAuthAuthorizationServerProvider`. The method receives an `IdentityAssertionParams` (the ID-JAG `assertion`, requested scopes, and request `resource`) and returns a plain RFC 6749 `OAuthToken`. The flag gates both metadata advertisement and the token endpoint: when off, `/token` rejects the grant with `unsupported_grant_type` even if the provider implements the hook. When on, the metadata advertises the jwt-bearer grant and the `urn:ietf:params:oauth:grant-profile:id-jag` profile in `authorization_grant_profiles_supported` (the discovery mechanism per ext-auth §6). + +The implementation is responsible for validating the assertion per RFC 7523 §3 and SEP-990 §5.1 - verify the signature/`iss`/`exp`/`typ`, require `aud` to be this AS, require the ID-JAG's `client_id` claim to match the authenticated client, audience-restrict the issued token to the ID-JAG's `resource` claim (not the client-controlled request `resource`), and derive scopes from the ID-JAG rather than granting the request verbatim. See `examples/snippets/servers/identity_assertion_server.py`, which fails closed. Two hardening points are enforced by the SDK: the handler rejects clients without a stored secret before calling the hook (and `ClientAuthenticator` itself now refuses a secret-based auth method registered without a secret), and Dynamic Client Registration refuses the jwt-bearer grant so the ID-JAG flow requires a pre-registered confidential client. + ### 2025-11-25 and 2026-07-28 protocol fields modeled `mcp_types` models the 2025-11-25 and 2026-07-28 protocol fields (e.g. `resultType`, `ttlMs`/`cacheScope` on cacheable results, `inputResponses`/`requestState` on retried requests), so inbound payloads carrying these keys parse into typed fields and round-trip. `ttlMs`/`cacheScope` default to `0`/`"private"` (immediately stale, not shared-cacheable); `resultType` defaults to `"complete"` on concrete results (`None` on `EmptyResult`); the server strips all of them from the wire at pre-2026 versions. diff --git a/examples/snippets/clients/identity_assertion_client.py b/examples/snippets/clients/identity_assertion_client.py new file mode 100644 index 0000000000..218df4bcfc --- /dev/null +++ b/examples/snippets/clients/identity_assertion_client.py @@ -0,0 +1,82 @@ +"""Client side of SEP-990 (enterprise IdP policy controls). + +`IdentityAssertionOAuthProvider` presents an Identity Assertion Authorization Grant (ID-JAG) issued +by the enterprise IdP to the MCP authorization server using the RFC 7523 jwt-bearer grant +(`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, ID-JAG as `assertion`), and receives an +MCP access token. No browser redirect or dynamic client registration is involved. + +Obtaining the ID-JAG (logging into the IdP and the leg-1 exchange against it) is deployment-specific +and out of scope for the SDK; supply it through the `assertion_provider` callback. The callback +receives the authorization server's issuer (the ID-JAG `aud`) and the MCP server's resource +identifier (the ID-JAG `resource` claim). SEP-990 requires a confidential client, so a client secret +is mandatory, and `issuer` is the authorization server the credentials are provisioned for - the +provider fetches metadata from that issuer's well-known and never asks the resource server which AS +to use. +""" + +import asyncio + +import httpx + +from mcp import ClientSession +from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken + + +class InMemoryTokenStorage: + """Demo in-memory token storage.""" + + def __init__(self) -> None: + self.tokens: OAuthToken | None = None + self.client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self.tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + self.tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self.client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self.client_info = client_info + + +async def fetch_id_jag(audience: str, resource: str) -> str: + """Return the ID-JAG to present. + + `audience` is the MCP authorization server's issuer (the ID-JAG `aud` claim); `resource` is the + MCP server's RFC 9728 identifier (the ID-JAG `resource` claim, which the AS audience-restricts + the issued token against). In production this exchanges the user's IdP ID token for an ID-JAG + against the enterprise identity provider. + """ + raise NotImplementedError("Obtain the ID-JAG from your enterprise identity provider") + + +async def main() -> None: + oauth_auth = IdentityAssertionOAuthProvider( + server_url="http://localhost:8001/mcp", + storage=InMemoryTokenStorage(), + client_id="enterprise-mcp-client", + client_secret="enterprise-mcp-secret", + issuer="http://localhost:8001", + assertion_provider=fetch_id_jag, + scope="user", + ) + + async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as http_client: + async with streamable_http_client("http://localhost:8001/mcp", http_client=http_client) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + tools = await session.list_tools() + print(f"Available tools: {[tool.name for tool in tools.tools]}") + + +def run() -> None: + asyncio.run(main()) + + +if __name__ == "__main__": + run() diff --git a/examples/snippets/pyproject.toml b/examples/snippets/pyproject.toml index 4e68846a09..735d522239 100644 --- a/examples/snippets/pyproject.toml +++ b/examples/snippets/pyproject.toml @@ -21,4 +21,5 @@ completion-client = "clients.completion_client:main" direct-execution-server = "servers.direct_execution:main" display-utilities-client = "clients.display_utilities:main" oauth-client = "clients.oauth_client:run" +identity-assertion-client = "clients.identity_assertion_client:run" elicitation-client = "clients.url_elicitation_client:run" diff --git a/examples/snippets/servers/identity_assertion_server.py b/examples/snippets/servers/identity_assertion_server.py new file mode 100644 index 0000000000..9406111f8b --- /dev/null +++ b/examples/snippets/servers/identity_assertion_server.py @@ -0,0 +1,103 @@ +"""Authorization-server side of SEP-990 (enterprise IdP policy controls). + +An authorization server enables the Identity Assertion Authorization Grant by setting +`identity_assertion_enabled=True` and implementing `exchange_identity_assertion` on its provider. +The client presents the IdP-issued ID-JAG using the RFC 7523 jwt-bearer grant; the provider +validates the assertion and mints an MCP access token. + +Validating the ID-JAG is the provider's responsibility and is only stubbed here. A real +implementation MUST, per RFC 7523 §3 and SEP-990 §5.1: + +- verify the JWT signature, `iss`, and `exp`, and that `typ` is `oauth-id-jag+jwt`; +- require `aud` to identify this authorization server; +- require the ID-JAG's `client_id` claim to match the authenticated client; +- audience-restrict the issued token to the resource named in the ID-JAG's `resource` claim + (NOT the client-supplied `params.resource`); +- derive the granted scopes from the ID-JAG and policy. + +`_decode_and_validate_id_jag` below raises `NotImplementedError` so this snippet fails closed and +forces a real implementation. Wire the returned routes into a Starlette app with +`create_auth_routes(..., identity_assertion_enabled=True)`, or set +`AuthSettings(identity_assertion_enabled=True)` with `MCPServer`/`Server`. +""" + +import secrets +import time +from dataclasses import dataclass + +from mcp.server.auth.provider import ( + AccessToken, + AuthorizationCode, + IdentityAssertionParams, + OAuthAuthorizationServerProvider, + RefreshToken, +) +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken + + +@dataclass +class IdJagClaims: + """The trusted claims extracted from a validated ID-JAG.""" + + subject: str # the end user the ID-JAG was issued for + client_id: str # the ID-JAG `client_id` claim; §5.1 requires it to match the authenticated client + resource: str # the MCP server the issued token must be audience-restricted to + scopes: list[str] + + +class IdentityAssertionProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]): + """Authorization-server provider that accepts an ID-JAG via the RFC 7523 jwt-bearer grant.""" + + def __init__(self) -> None: + self.access_tokens: dict[str, AccessToken] = {} + # SEP-990 clients are pre-registered out of band (DCR refuses the grant) and must be + # confidential. `get_client` must return them, or the token endpoint 401s before the + # exchange runs. Real deployments load these from their own store. + self.clients: dict[str, OAuthClientInformationFull] = { + "enterprise-mcp-client": OAuthClientInformationFull( + client_id="enterprise-mcp-client", + client_secret="enterprise-mcp-secret", + redirect_uris=None, + grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"], + token_endpoint_auth_method="client_secret_post", + ) + } + + async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: + return self.clients.get(client_id) + + async def exchange_identity_assertion( + self, client: OAuthClientInformationFull, params: IdentityAssertionParams + ) -> OAuthToken: + claims = self._decode_and_validate_id_jag(params.assertion, client) + + access_token = f"access_{secrets.token_hex(16)}" + self.access_tokens[access_token] = AccessToken( + token=access_token, + client_id=claims.client_id, + scopes=claims.scopes, + expires_at=int(time.time()) + 3600, + # Bind to the resource from the validated ID-JAG, not the client-controlled request. + resource=claims.resource, + subject=claims.subject, + ) + # No refresh token: SEP-990 relies on the IdP re-issuing ID-JAGs to control session lifetime. + return OAuthToken( + access_token=access_token, + token_type="Bearer", + expires_in=3600, + scope=" ".join(claims.scopes), + ) + + def _decode_and_validate_id_jag(self, assertion: str, client: OAuthClientInformationFull) -> IdJagClaims: + """Verify the ID-JAG and return its trusted claims, or reject the request. + + Replace this stub with real RFC 7523 §3 / SEP-990 §5.1 validation. It fails closed - it + raises rather than trusting the assertion - so a copy of this example cannot accidentally + accept unverified tokens. RFC 7523 §3.1 / RFC 6749 §5.2 specify `invalid_grant` for a + rejected assertion. + """ + raise NotImplementedError("Validate the ID-JAG (signature, iss/aud/exp/typ, client_id, resource)") + + async def load_access_token(self, token: str) -> AccessToken | None: + return self.access_tokens.get(token) diff --git a/src/mcp/client/auth/extensions/identity_assertion.py b/src/mcp/client/auth/extensions/identity_assertion.py new file mode 100644 index 0000000000..2d97e5eff2 --- /dev/null +++ b/src/mcp/client/auth/extensions/identity_assertion.py @@ -0,0 +1,213 @@ +"""SEP-990 Identity Assertion Authorization Grant (RFC 7523 jwt-bearer) client provider. + +`IdentityAssertionOAuthProvider` is the client side of SEP-990 leg 2: it presents an Identity +Assertion Authorization Grant (ID-JAG) - a signed JWT issued by the enterprise identity provider - +to the MCP authorization server's token endpoint using the RFC 7523 jwt-bearer grant +(`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, ID-JAG as `assertion`), and receives an +MCP access token. + +The authorization server is configuration, not discovery. SEP-990's trust model is the inverse of +the default OAuth client's: the AS issuer is supplied at construction, authorization-server metadata +is fetched from that issuer's own RFC 8414 well-known, and the resource server is never asked which +AS to use - so it cannot redirect the ID-JAG or client secret elsewhere. There is no protected +resource metadata fetch, no dynamic client registration, and no server-driven scope selection. + +Obtaining the ID-JAG (logging into the IdP and the leg-1 token exchange against it) is +deployment-specific and out of scope for the SDK. The caller supplies it through the +`assertion_provider` callback, which receives the configured issuer (the `aud` the ID-JAG must +carry) and the MCP server's resource identifier (the `resource` claim it must carry, per ext-auth +section 4.3), and returns the ID-JAG. +""" + +import base64 +import time +from collections.abc import AsyncGenerator, Awaitable, Callable +from typing import Literal +from urllib.parse import quote, urlsplit + +import anyio +import httpx + +from mcp.client.auth import OAuthFlowError, OAuthTokenError, TokenStorage +from mcp.client.auth.utils import ( + build_oauth_authorization_server_metadata_discovery_urls, + create_oauth_metadata_request, + extract_field_from_www_auth, + extract_scope_from_www_auth, + handle_auth_metadata_response, + handle_token_response_scopes, + union_scopes, + validate_metadata_issuer, +) +from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken +from mcp.shared.auth_utils import calculate_token_expiry, resource_url_from_server_url + +_DEFAULT_PORTS = {"https": 443, "http": 80} + + +def _origin(url: str) -> tuple[str, str, int | None]: + """Return the (scheme, host, port) origin of a URL for same-origin comparison. + + The port is normalized to the scheme's default so an explicit `:443`/`:80` compares equal to the + same origin written without a port. + """ + parsed = urlsplit(url) + port = parsed.port if parsed.port is not None else _DEFAULT_PORTS.get(parsed.scheme) + return (parsed.scheme, parsed.hostname or "", port) + + +class IdentityAssertionOAuthProvider(httpx.Auth): + """`httpx.Auth` for the SEP-990 ID-JAG flow (RFC 7523 jwt-bearer grant) against a configured AS. + + The authorization server `issuer` is fixed at construction; metadata is fetched from its + RFC 8414 well-known and the ID-JAG and client secret are sent only to that issuer's token + endpoint. The resource server is never consulted for AS selection. The ID-JAG is fetched lazily + from `assertion_provider` so a fresh assertion is used on each exchange. + + Example: + ```python + async def fetch_id_jag(audience: str, resource: str) -> str: + # `audience` is the configured issuer (the ID-JAG `aud`); `resource` is the MCP + # server's identifier (the ID-JAG `resource` claim). Obtaining the ID-JAG from the + # enterprise IdP is deployment-specific and not handled by the SDK. + return await my_idp.issue_id_jag(audience=audience, resource=resource) + + + provider = IdentityAssertionOAuthProvider( + server_url="https://mcp.example.com/mcp", + storage=my_token_storage, + client_id="my-client-id", + client_secret="my-client-secret", + issuer="https://auth.example.com", + assertion_provider=fetch_id_jag, + ) + ``` + """ + + requires_response_body = True + + def __init__( + self, + server_url: str, + storage: TokenStorage, + client_id: str, + client_secret: str, + issuer: str, + assertion_provider: Callable[[str, str], Awaitable[str]], + scope: str | None = None, + token_endpoint_auth_method: Literal["client_secret_basic", "client_secret_post"] = "client_secret_post", + ) -> None: + """Initialize the identity-assertion OAuth provider. + + Args: + server_url: The MCP server URL. + storage: Token storage implementation. + client_id: The OAuth client ID registered with the MCP authorization server. + client_secret: The client secret. SEP-990 section 5.1 requires a confidential client. + issuer: The issuer identifier of the MCP authorization server this client is provisioned + for. Authorization-server metadata is fetched from this issuer's well-known and the + ID-JAG and secret are sent only to its token endpoint. + assertion_provider: Async callback taking `(audience, resource)` - the configured issuer + and the MCP server's resource identifier - and returning the ID-JAG. + scope: Optional space-separated list of scopes to request. + token_endpoint_auth_method: Confidential-client auth method, either `client_secret_post` + (default) or `client_secret_basic`. + """ + if not client_secret: + raise ValueError("client_secret is required: SEP-990 mandates a confidential client") + if not issuer: + raise ValueError("issuer is required: the authorization server is configuration, not discovery") + self._resource = resource_url_from_server_url(server_url) + self._storage = storage + self._issuer = issuer + self._assertion_provider = assertion_provider + self._scope = scope + self._client = OAuthClientInformationFull( + client_id=client_id, + client_secret=client_secret, + redirect_uris=None, + grant_types=[JWT_BEARER_GRANT_TYPE], + token_endpoint_auth_method=token_endpoint_auth_method, + issuer=issuer, + ) + self._token_endpoint: str | None = None + self._tokens: OAuthToken | None = None + self._expiry: float | None = None + self._lock = anyio.Lock() + self._initialized = False + + def _build_token_request(self, scope: str | None, assertion: str) -> httpx.Request: + """Build the RFC 7523 jwt-bearer token request, applying confidential-client auth.""" + assert self._token_endpoint is not None + assert self._client.client_id is not None and self._client.client_secret is not None + data: dict[str, str] = { + "grant_type": JWT_BEARER_GRANT_TYPE, + "assertion": assertion, + "client_id": self._client.client_id, + "resource": self._resource, + } + if scope: + data["scope"] = scope + headers = {"Content-Type": "application/x-www-form-urlencoded"} + if self._client.token_endpoint_auth_method == "client_secret_basic": + # RFC 6749 section 2.3.1: URL-encode each part, then base64 the colon-joined pair. + encoded_id = quote(self._client.client_id, safe="") + encoded_secret = quote(self._client.client_secret, safe="") + credentials = base64.b64encode(f"{encoded_id}:{encoded_secret}".encode()).decode() + headers["Authorization"] = f"Basic {credentials}" + else: + data["client_secret"] = self._client.client_secret + return httpx.Request("POST", self._token_endpoint, data=data, headers=headers) + + async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]: + async with self._lock: + if not self._initialized: + self._tokens = await self._storage.get_tokens() + self._expiry = calculate_token_expiry(self._tokens.expires_in) if self._tokens else None + self._initialized = True + + if self._tokens and (self._expiry is None or time.time() <= self._expiry): + request.headers["Authorization"] = f"Bearer {self._tokens.access_token}" + response = yield request + + if response.status_code == 401: + scope_to_request = self._scope + elif response.status_code == 403 and extract_field_from_www_auth(response, "error") == "insufficient_scope": + scope_to_request = union_scopes(self._scope, extract_scope_from_www_auth(response)) + else: + return + + # Discover ASM from the configured issuer's well-known. The RS is not consulted: both + # arguments are the issuer, so even the helper's legacy fallback resolves there. + if self._token_endpoint is None: + for url in build_oauth_authorization_server_metadata_discovery_urls(self._issuer, self._issuer): + asm_response = yield create_oauth_metadata_request(url) + ok, asm = await handle_auth_metadata_response(asm_response) + if not ok: + break + if asm is not None: + validate_metadata_issuer(asm, self._issuer) + token_endpoint = str(asm.token_endpoint) + if _origin(token_endpoint) != _origin(self._issuer): + raise OAuthFlowError( + f"Token endpoint {token_endpoint} is not on the configured issuer origin {self._issuer}" + ) + self._token_endpoint = token_endpoint + break + if self._token_endpoint is None: + raise OAuthFlowError(f"No authorization server metadata at configured issuer {self._issuer}") + + assertion = await self._assertion_provider(self._issuer, self._resource) + token_response = yield self._build_token_request(scope_to_request, assertion) + if token_response.status_code != 200: + body = (await token_response.aread()).decode(errors="replace") + raise OAuthTokenError(f"Token exchange failed ({token_response.status_code}): {body}") + tokens = await handle_token_response_scopes(token_response) + if tokens.scope is None: + tokens.scope = scope_to_request + self._tokens = tokens + self._expiry = calculate_token_expiry(tokens.expires_in) + await self._storage.set_tokens(tokens) + + request.headers["Authorization"] = f"Bearer {tokens.access_token}" + yield request diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index aae4d20b0b..711848d724 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -417,9 +417,9 @@ async def _exchange_token_authorization_code( async def _handle_token_response(self, response: httpx.Response) -> None: """Handle token exchange response.""" if response.status_code not in {200, 201}: - body = await response.aread() # pragma: no cover - body_text = body.decode("utf-8") # pragma: no cover - raise OAuthTokenError(f"Token exchange failed ({response.status_code}): {body_text}") # pragma: no cover + body = await response.aread() + body_text = body.decode("utf-8") + raise OAuthTokenError(f"Token exchange failed ({response.status_code}): {body_text}") # Parse and validate response with scope validation token_response = await handle_token_response_scopes(response) diff --git a/src/mcp/server/auth/handlers/register.py b/src/mcp/server/auth/handlers/register.py index 79eb0fb0c1..e565b27383 100644 --- a/src/mcp/server/auth/handlers/register.py +++ b/src/mcp/server/auth/handlers/register.py @@ -12,7 +12,7 @@ from mcp.server.auth.json_response import PydanticJSONResponse from mcp.server.auth.provider import OAuthAuthorizationServerProvider, RegistrationError, RegistrationErrorCode from mcp.server.auth.settings import ClientRegistrationOptions -from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata +from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthClientMetadata # this alias is a no-op; it's just to separate out the types exposed to the # provider from what we use in the HTTP handler @@ -79,6 +79,21 @@ async def handle(self, request: Request) -> Response: status_code=400, ) + # SEP-990 §5.1 / draft-ietf-oauth-identity-assertion-authz-grant §8.1: the ID-JAG flow is + # for confidential clients provisioned out of band. Refuse to grant it through DCR so a + # self-registered client cannot reach the identity-assertion provider hook. + if JWT_BEARER_GRANT_TYPE in client_metadata.grant_types: + return PydanticJSONResponse( + content=RegistrationErrorResponse( + error="invalid_client_metadata", + error_description=( + f"grant_types must not include '{JWT_BEARER_GRANT_TYPE}'; " + "the identity-assertion grant requires a pre-registered client" + ), + ), + status_code=400, + ) + # The MCP spec requires servers to use the authorization `code` flow # with PKCE if "code" not in client_metadata.response_types: diff --git a/src/mcp/server/auth/handlers/token.py b/src/mcp/server/auth/handlers/token.py index 534a478a91..0e644c378a 100644 --- a/src/mcp/server/auth/handlers/token.py +++ b/src/mcp/server/auth/handlers/token.py @@ -10,7 +10,12 @@ from mcp.server.auth.errors import stringify_pydantic_error from mcp.server.auth.json_response import PydanticJSONResponse from mcp.server.auth.middleware.client_auth import AuthenticationError, ClientAuthenticator -from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenError, TokenErrorCode +from mcp.server.auth.provider import ( + IdentityAssertionParams, + OAuthAuthorizationServerProvider, + TokenError, + TokenErrorCode, +) from mcp.shared.auth import OAuthToken @@ -40,7 +45,24 @@ class RefreshTokenRequest(BaseModel): resource: str | None = Field(None, description="Resource indicator for the token") -TokenRequest = Annotated[AuthorizationCodeRequest | RefreshTokenRequest, Field(discriminator="grant_type")] +class JwtBearerRequest(BaseModel): + # RFC 7523 §2.1 JWT bearer authorization grant. SEP-990 leg 2: the client presents the + # enterprise IdP-issued ID-JAG to the MCP authorization server as the `assertion`. + grant_type: Literal["urn:ietf:params:oauth:grant-type:jwt-bearer"] + # See https://datatracker.ietf.org/doc/html/rfc7523#section-2.1 + assertion: str = Field(..., description="The ID-JAG (a signed JWT) being presented as the grant") + scope: str | None = Field(None, description="Optional scope parameter") + client_id: str + # we use the client_secret param, per https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1 + client_secret: str | None = None + # RFC 8707 resource indicator + resource: str | None = Field(None, description="Resource indicator for the token") + + +TokenRequest = Annotated[ + AuthorizationCodeRequest | RefreshTokenRequest | JwtBearerRequest, + Field(discriminator="grant_type"), +] token_request_adapter = TypeAdapter[TokenRequest](TokenRequest) @@ -62,6 +84,7 @@ class TokenErrorResponse(BaseModel): class TokenHandler: provider: OAuthAuthorizationServerProvider[Any, Any, Any] client_authenticator: ClientAuthenticator + identity_assertion_enabled: bool = False def response(self, obj: TokenSuccessResponse | TokenErrorResponse): status_code = 200 @@ -98,7 +121,7 @@ async def handle(self, request: Request): form_data = await request.form() # TODO(Marcelo): Can someone check if this `dict()` wrapper is necessary? token_request = token_request_adapter.validate_python(dict(form_data)) - except ValidationError as validation_error: # pragma: no cover + except ValidationError as validation_error: return self.response( TokenErrorResponse( error="invalid_request", @@ -106,7 +129,7 @@ async def handle(self, request: Request): ) ) - if token_request.grant_type not in client_info.grant_types: # pragma: no cover + if token_request.grant_type not in client_info.grant_types: return self.response( TokenErrorResponse( error="unsupported_grant_type", @@ -178,7 +201,7 @@ async def handle(self, request: Request): except TokenError as e: return self.response(TokenErrorResponse(error=e.error, error_description=e.error_description)) - case RefreshTokenRequest(): # pragma: no branch + case RefreshTokenRequest(): refresh_token = await self.provider.load_refresh_token(client_info, token_request.refresh_token) if refresh_token is None or refresh_token.client_id != token_request.client_id: # if token belongs to different client, pretend it doesn't exist @@ -216,4 +239,37 @@ async def handle(self, request: Request): except TokenError as e: return self.response(TokenErrorResponse(error=e.error, error_description=e.error_description)) + case JwtBearerRequest(): # pragma: no branch + if not self.identity_assertion_enabled: + return self.response( + TokenErrorResponse( + error="unsupported_grant_type", + error_description="The JWT bearer grant is not supported by this authorization server", + ) + ) + + # SEP-990 §5.1: only confidential clients may present an ID-JAG. ClientAuthenticator + # already rejects a secret-based method with no stored secret; this additionally + # rejects the public `none` method so an unauthenticated client never reaches the + # provider hook. + if not client_info.client_secret: + # RFC 6749 §5.2: the client authenticated but is not permitted this grant, so + # unauthorized_client (not invalid_client, which is for failed authentication). + return self.response( + TokenErrorResponse( + error="unauthorized_client", + error_description="The JWT bearer grant requires a confidential client", + ) + ) + + params = IdentityAssertionParams( + assertion=token_request.assertion, + scopes=token_request.scope.split(" ") if token_request.scope else None, + resource=token_request.resource, + ) + try: + tokens = await self.provider.exchange_identity_assertion(client_info, params) + except TokenError as e: + return self.response(TokenErrorResponse(error=e.error, error_description=e.error_description)) + return self.response(tokens) diff --git a/src/mcp/server/auth/middleware/client_auth.py b/src/mcp/server/auth/middleware/client_auth.py index 2832f83523..3d5067d611 100644 --- a/src/mcp/server/auth/middleware/client_auth.py +++ b/src/mcp/server/auth/middleware/client_auth.py @@ -96,6 +96,11 @@ async def authenticate_request(self, request: Request) -> OAuthClientInformation f"Unsupported auth method: {client.token_endpoint_auth_method}" ) + # A client registered for a secret-based auth method but with no stored secret is + # misconfigured: nothing was actually verified above, so it must not pass authentication. + if client.token_endpoint_auth_method != "none" and not client.client_secret: + raise AuthenticationError("Client is registered for secret-based authentication but has no stored secret") + # If client from the store expects a secret, validate that the request provides # that secret if client.client_secret: diff --git a/src/mcp/server/auth/provider.py b/src/mcp/server/auth/provider.py index bb47c19566..eeb371f1c2 100644 --- a/src/mcp/server/auth/provider.py +++ b/src/mcp/server/auth/provider.py @@ -16,6 +16,19 @@ class AuthorizationParams(BaseModel): resource: str | None = None # RFC 8707 resource indicator +class IdentityAssertionParams(BaseModel): + """Validated parameters of a SEP-990 identity-assertion (RFC 7523 jwt-bearer) request. + + Passed to ``OAuthAuthorizationServerProvider.exchange_identity_assertion``. ``assertion`` is the + ID-JAG (a signed JWT) the enterprise identity provider issued; the provider validates it per + RFC 7523 §3 and the SEP-990 §5.1 processing rules before issuing an access token. + """ + + assertion: str # RFC 7523 §2.1: the JWT (ID-JAG) presented as the authorization grant + scopes: list[str] | None = None + resource: str | None = None # RFC 8707 resource indicator from the token request + + class AuthorizationCode(BaseModel): code: str scopes: list[str] @@ -85,6 +98,8 @@ class AuthorizeError(Exception): "unauthorized_client", "unsupported_grant_type", "invalid_scope", + # RFC 8707 §2: the requested resource (RFC 8707 indicator) is unknown or unsupported. + "invalid_target", ] @@ -270,6 +285,53 @@ async def revoke_token( token: The token to revoke. """ + async def exchange_identity_assertion( + self, + client: OAuthClientInformationFull, + params: IdentityAssertionParams, + ) -> OAuthToken: + """Exchanges an Identity Assertion Authorization Grant (ID-JAG) for an access token. + + This is leg 2 of SEP-990: the client presents an ID-JAG - issued by the enterprise + identity provider - using the RFC 7523 ``urn:ietf:params:oauth:grant-type:jwt-bearer`` + grant, and receives an access token for this MCP server. The default implementation + rejects every request as an unsupported grant type; override it to enable the grant. + + The implementation is responsible for validating ``params.assertion`` per RFC 7523 §3 + and the SEP-990 §5.1 processing rules, in particular: + + - verify the JWT signature, ``iss``, and ``exp``, and that ``typ`` is ``oauth-id-jag+jwt``; + - require ``aud`` to identify this authorization server (its own issuer); + - require a ``sub`` (RFC 7523 §3 makes it mandatory) identifying the end user; + - reject replays - enforce ``exp``, and track ``jti`` for the assertion's lifetime; + - require the ID-JAG's ``client_id`` claim to match the authenticated ``client`` - do + NOT derive authorization from ``client.client_id`` alone, which for a confidential + client is authenticated but for any client is ultimately self-asserted in the request; + - audience-restrict the issued access token to the resource named in the ID-JAG's + ``resource`` claim, not merely ``params.resource`` (which the client controls); + - derive the granted scopes from the ID-JAG and policy rather than granting + ``params.scopes`` verbatim. + + The handler guarantees ``client`` is confidential (it rejects clients without a stored + secret before calling this hook), but the ID-JAG remains the authoritative grant. + + Args: + client: The authenticated client presenting the assertion. + params: The validated jwt-bearer request parameters (the ID-JAG and indicators). + + Returns: + The OAuth token, containing the issued access token. A refresh token SHOULD NOT be + issued: SEP-990 relies on the IdP to control session lifetime via re-issued ID-JAGs. + + Raises: + TokenError: If the assertion or request is invalid. Use ``invalid_grant`` for a + rejected assertion and ``invalid_target`` for an unknown ``resource``. + """ + raise TokenError( + error="unsupported_grant_type", + error_description="The JWT bearer grant is not supported by this authorization server", + ) + def construct_redirect_uri(redirect_uri_base: str, **params: str | None) -> str: parsed_uri = urlparse(redirect_uri_base) diff --git a/src/mcp/server/auth/routes.py b/src/mcp/server/auth/routes.py index d88b6d1b13..fa88dddcf4 100644 --- a/src/mcp/server/auth/routes.py +++ b/src/mcp/server/auth/routes.py @@ -17,7 +17,7 @@ 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.shared.auth import OAuthMetadata, ProtectedResourceMetadata +from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthMetadata, ProtectedResourceMetadata from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER @@ -47,6 +47,9 @@ def validate_issuer_url(url: AnyHttpUrl): REGISTRATION_PATH = "/register" REVOCATION_PATH = "/revoke" +# SEP-990: leg 2 uses the RFC 7523 jwt-bearer grant; support is advertised as the ID-JAG profile. +ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag" + def cors_middleware( handler: Callable[[Request], Response | Awaitable[Response]], @@ -67,6 +70,7 @@ def create_auth_routes( service_documentation_url: AnyHttpUrl | None = None, client_registration_options: ClientRegistrationOptions | None = None, revocation_options: RevocationOptions | None = None, + identity_assertion_enabled: bool = False, ) -> list[Route]: validate_issuer_url(issuer_url) @@ -77,6 +81,7 @@ def create_auth_routes( service_documentation_url, client_registration_options, revocation_options, + supports_identity_assertion=identity_assertion_enabled, ) client_authenticator = ClientAuthenticator(provider) @@ -103,7 +108,9 @@ def create_auth_routes( Route( TOKEN_PATH, endpoint=cors_middleware( - TokenHandler(provider, client_authenticator).handle, + TokenHandler( + provider, client_authenticator, identity_assertion_enabled=identity_assertion_enabled + ).handle, ["POST", "OPTIONS"], ), methods=["POST", "OPTIONS"], @@ -147,10 +154,19 @@ def build_metadata( service_documentation_url: AnyHttpUrl | None, client_registration_options: ClientRegistrationOptions, revocation_options: RevocationOptions, + supports_identity_assertion: bool = False, ) -> OAuthMetadata: authorization_url = AnyHttpUrl(str(issuer_url).rstrip("/") + AUTHORIZATION_PATH) token_url = AnyHttpUrl(str(issuer_url).rstrip("/") + TOKEN_PATH) + grant_types_supported = ["authorization_code", "refresh_token"] + # SEP-990 / ext-auth §6: support for the ID-JAG flow is advertised as a grant PROFILE, not as + # the jwt-bearer grant type (which an AS might support for other purposes). + authorization_grant_profiles_supported: list[str] | None = None + if supports_identity_assertion: + grant_types_supported.append(JWT_BEARER_GRANT_TYPE) + authorization_grant_profiles_supported = [ID_JAG_GRANT_PROFILE] + # Create metadata metadata = OAuthMetadata( issuer=issuer_url, @@ -159,7 +175,7 @@ def build_metadata( scopes_supported=client_registration_options.valid_scopes, response_types_supported=["code"], response_modes_supported=None, - grant_types_supported=["authorization_code", "refresh_token"], + grant_types_supported=grant_types_supported, token_endpoint_auth_methods_supported=["client_secret_post", "client_secret_basic"], token_endpoint_auth_signing_alg_values_supported=None, service_documentation=service_documentation_url, @@ -168,6 +184,7 @@ def build_metadata( op_tos_uri=None, introspection_endpoint=None, code_challenge_methods_supported=["S256"], + authorization_grant_profiles_supported=authorization_grant_profiles_supported, ) # Add registration endpoint if supported diff --git a/src/mcp/server/auth/settings.py b/src/mcp/server/auth/settings.py index f88dc147dc..ae2083a38b 100644 --- a/src/mcp/server/auth/settings.py +++ b/src/mcp/server/auth/settings.py @@ -27,6 +27,12 @@ class AuthSettings(BaseModel): client_registration_options: ClientRegistrationOptions | None = None revocation_options: RevocationOptions | None = None required_scopes: list[str] | None = None + identity_assertion_enabled: bool = Field( + default=False, + description="Advertise and accept the SEP-990 Identity Assertion Authorization Grant " + "(the RFC 7523 jwt-bearer grant carrying an ID-JAG) at the token endpoint, for enterprise " + "IdP flows. The provider must implement `exchange_identity_assertion`.", + ) # Resource Server settings (when operating as RS only) resource_server_url: AnyHttpUrl | None = Field( diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 778e89e458..bbd2ff3318 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -724,6 +724,7 @@ def streamable_http_app( service_documentation_url=auth.service_documentation_url, client_registration_options=auth.client_registration_options, revocation_options=auth.revocation_options, + identity_assertion_enabled=auth.identity_assertion_enabled, ) ) diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 028c6a4753..855770eda7 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -964,6 +964,7 @@ async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no service_documentation_url=self.settings.auth.service_documentation_url, client_registration_options=self.settings.auth.client_registration_options, revocation_options=self.settings.auth.revocation_options, + identity_assertion_enabled=self.settings.auth.identity_assertion_enabled, ) ) diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index 4fabb1a894..2bbf7a715a 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -2,6 +2,9 @@ from pydantic import AnyHttpUrl, AnyUrl, BaseModel, ConfigDict, Field, field_validator +# RFC 7523 JWT bearer grant; SEP-990 leg 2 uses this to present the ID-JAG. +JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer" + class OAuthToken(BaseModel): """See https://datatracker.ietf.org/doc/html/rfc6749#section-5.1""" @@ -168,6 +171,9 @@ class OAuthMetadata(BaseModel): code_challenge_methods_supported: list[str] | None = None client_id_metadata_document_supported: bool | None = None authorization_response_iss_parameter_supported: bool | None = None + # SEP-990 / draft-ietf-oauth-identity-assertion-authz-grant §7.2: profiles whose grants the + # authorization server supports, e.g. `urn:ietf:params:oauth:grant-profile:id-jag`. + authorization_grant_profiles_supported: list[str] | None = None class ProtectedResourceMetadata(BaseModel): diff --git a/tests/client/auth/extensions/test_identity_assertion.py b/tests/client/auth/extensions/test_identity_assertion.py new file mode 100644 index 0000000000..1bc63a1173 --- /dev/null +++ b/tests/client/auth/extensions/test_identity_assertion.py @@ -0,0 +1,411 @@ +"""Unit tests for the standalone SEP-990 jwt-bearer `httpx.Auth`. + +The provider's authorization server is configuration; these tests assert that authorization-server +metadata is fetched only from the configured issuer, that the resource server is never consulted for +AS selection, and that the ID-JAG and client secret reach only the issuer's token endpoint. +""" + +import base64 +import json +import urllib.parse + +import httpx +import pytest + +from mcp.client.auth import OAuthFlowError, OAuthTokenError +from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider, _origin +from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken + +ISSUER = "https://auth.example.com" +RS = "https://mcp.example.com" +ASM_PATH = "/.well-known/oauth-authorization-server" +OIDC_PATH = "/.well-known/openid-configuration" + + +class InMemoryStorage: + def __init__(self, tokens: OAuthToken | None = None) -> None: + self.tokens = tokens + + 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: + raise NotImplementedError + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + raise NotImplementedError + + +def asm_body(*, issuer: str = ISSUER, token_endpoint: str | None = None) -> bytes: + return json.dumps( + { + "issuer": issuer, + "authorization_endpoint": f"{issuer}/authorize", + "token_endpoint": token_endpoint or f"{issuer}/token", + } + ).encode() + + +def token_body(*, access_token: str = "issued-token", scope: str | None = None) -> bytes: + payload: dict[str, object] = {"access_token": access_token, "token_type": "Bearer", "expires_in": 3600} + if scope is not None: + payload["scope"] = scope + return json.dumps(payload).encode() + + +def make_provider( + storage: InMemoryStorage | None = None, + *, + scope: str | None = "mcp", + token_endpoint_auth_method: str = "client_secret_post", + record: list[tuple[str, str]] | None = None, +) -> IdentityAssertionOAuthProvider: + async def assertion_provider(audience: str, resource: str) -> str: + if record is not None: + record.append((audience, resource)) + return "the-id-jag" + + return IdentityAssertionOAuthProvider( + server_url=f"{RS}/mcp", + storage=storage if storage is not None else InMemoryStorage(), + client_id="test-client-id", + client_secret="test-client-secret", + issuer=ISSUER, + assertion_provider=assertion_provider, + scope=scope, + token_endpoint_auth_method=token_endpoint_auth_method, # type: ignore[arg-type] + ) + + +def mock_transport( + requests: list[httpx.Request], + *, + asm: bytes | int = 200, + token: bytes | int = 200, + rs_first_status: int = 401, + rs_first_headers: dict[str, str] | None = None, +) -> httpx.MockTransport: + """Build a `MockTransport` that records every request and serves the configured ASM and token. + + `asm` / `token` are either a body (served as 200 JSON) or an int status (served with no body). + The MCP resource server's first response is `rs_first_status` (default 401) with optional + headers; subsequent RS requests return 200. + """ + rs_hits = 0 + + def handle(request: httpx.Request) -> httpx.Response: + nonlocal rs_hits + requests.append(request) + host, path = request.url.host, request.url.path + if host == "mcp.example.com": + rs_hits += 1 + if rs_hits == 1: + return httpx.Response(rs_first_status, headers=rs_first_headers or {}) + return httpx.Response(200, json={"ok": True}) + if host == "auth.example.com" and path in (ASM_PATH, OIDC_PATH): + if isinstance(asm, int): + return httpx.Response(asm) + return httpx.Response(200, content=asm, headers={"content-type": "application/json"}) + if host == "auth.example.com" and path == "/token": + if isinstance(token, int): + return httpx.Response(token, json={"error": "invalid_grant"}) + return httpx.Response(200, content=token, headers={"content-type": "application/json"}) + raise AssertionError(f"unexpected request: {request.method} {request.url}") # pragma: no cover + + return httpx.MockTransport(handle) + + +def form(request: httpx.Request) -> dict[str, str]: + return dict(urllib.parse.parse_qsl(request.content.decode())) + + +@pytest.mark.anyio +async def test_on_401_exchanges_assertion_at_configured_issuer_and_retries() -> None: + """A 401 fetches ASM from the configured issuer, posts the jwt-bearer grant, and retries.""" + requests: list[httpx.Request] = [] + record: list[tuple[str, str]] = [] + storage = InMemoryStorage() + auth = make_provider(storage, record=record) + + async with httpx.AsyncClient( + transport=mock_transport(requests, asm=asm_body(), token=token_body(scope="mcp")), auth=auth + ) as http: + response = await http.post(f"{RS}/mcp") + + assert [(r.method, str(r.url)) for r in requests] == [ + ("POST", f"{RS}/mcp"), + ("GET", f"{ISSUER}{ASM_PATH}"), + ("POST", f"{ISSUER}/token"), + ("POST", f"{RS}/mcp"), + ] + body = form(requests[2]) + assert body == { + "grant_type": JWT_BEARER_GRANT_TYPE, + "assertion": "the-id-jag", + "client_id": "test-client-id", + "resource": f"{RS}/mcp", + "scope": "mcp", + "client_secret": "test-client-secret", + } + assert "Authorization" not in requests[2].headers + assert record == [(ISSUER, f"{RS}/mcp")] + assert response.status_code == 200 + assert storage.tokens is not None + assert storage.tokens.access_token == "issued-token" + assert storage.tokens.scope == "mcp" + + +@pytest.mark.anyio +async def test_resource_server_metadata_is_never_consulted() -> None: + """No PRM well-known and no RS-origin ASM well-known is ever fetched. + + This is the by-construction property: the AS is configuration, so the resource server has no + input into where the ID-JAG or client secret go. Any GET to the RS host fails the test. + """ + requests: list[httpx.Request] = [] + auth = make_provider() + + async with httpx.AsyncClient( + transport=mock_transport(requests, asm=asm_body(), token=token_body()), auth=auth + ) as http: + await http.post(f"{RS}/mcp") + + rs_gets = [r for r in requests if r.url.host == "mcp.example.com" and r.method == "GET"] + assert rs_gets == [] + assert all(r.url.host == "auth.example.com" for r in requests if r.method == "GET") + # No DCR was attempted anywhere. + assert not any(r.url.path == "/register" for r in requests) + + +@pytest.mark.anyio +async def test_asm_404_at_configured_issuer_raises_before_minting_assertion() -> None: + """If the issuer's well-knowns 404, the flow fails closed and the assertion is never minted.""" + requests: list[httpx.Request] = [] + record: list[tuple[str, str]] = [] + auth = make_provider(record=record) + + async with httpx.AsyncClient(transport=mock_transport(requests, asm=404), auth=auth) as http: + with pytest.raises(OAuthFlowError, match="No authorization server metadata"): + await http.post(f"{RS}/mcp") + + # Both RFC 8414 and OIDC well-knowns were tried at the configured issuer; nothing else. + assert [str(r.url) for r in requests if r.method == "GET"] == [f"{ISSUER}{ASM_PATH}", f"{ISSUER}{OIDC_PATH}"] + assert record == [] + assert not any(r.url.path == "/token" for r in requests) + + +@pytest.mark.anyio +async def test_asm_5xx_stops_discovery_and_raises() -> None: + """A 5xx at the issuer's well-known stops discovery without trying further URLs.""" + requests: list[httpx.Request] = [] + auth = make_provider() + + async with httpx.AsyncClient(transport=mock_transport(requests, asm=500), auth=auth) as http: + with pytest.raises(OAuthFlowError, match="No authorization server metadata"): + await http.post(f"{RS}/mcp") + + assert [str(r.url) for r in requests if r.method == "GET"] == [f"{ISSUER}{ASM_PATH}"] + + +@pytest.mark.anyio +async def test_asm_with_wrong_issuer_is_rejected_before_minting_assertion() -> None: + """RFC 8414 section 3.3: metadata whose `issuer` differs from the configured one is rejected.""" + requests: list[httpx.Request] = [] + record: list[tuple[str, str]] = [] + auth = make_provider(record=record) + + async with httpx.AsyncClient( + transport=mock_transport(requests, asm=asm_body(issuer="https://other.example")), auth=auth + ) as http: + with pytest.raises(OAuthFlowError, match="issuer mismatch"): + await http.post(f"{RS}/mcp") + + assert record == [] + assert not any(r.url.path == "/token" for r in requests) + + +@pytest.mark.anyio +async def test_asm_with_off_origin_token_endpoint_is_rejected_before_minting_assertion() -> None: + """A `token_endpoint` off the configured issuer's origin is refused before any credential is sent.""" + requests: list[httpx.Request] = [] + record: list[tuple[str, str]] = [] + auth = make_provider(record=record) + + async with httpx.AsyncClient( + transport=mock_transport(requests, asm=asm_body(token_endpoint="https://other.example/token")), auth=auth + ) as http: + with pytest.raises(OAuthFlowError, match="not on the configured issuer origin"): + await http.post(f"{RS}/mcp") + + assert record == [] + assert not any(r.url.path == "/token" for r in requests) + + +@pytest.mark.anyio +async def test_403_insufficient_scope_unions_challenged_scope_with_configured() -> None: + """A 403 `insufficient_scope` re-exchanges with the union of configured and challenged scopes.""" + requests: list[httpx.Request] = [] + auth = make_provider(scope="mcp") + + transport = mock_transport( + requests, + asm=asm_body(), + token=token_body(), + rs_first_status=403, + rs_first_headers={"WWW-Authenticate": 'Bearer error="insufficient_scope", scope="mcp files:write"'}, + ) + async with httpx.AsyncClient(transport=transport, auth=auth) as http: + response = await http.post(f"{RS}/mcp") + + [token_req] = [r for r in requests if r.url.path == "/token"] + assert form(token_req)["scope"] == "mcp files:write" + assert response.status_code == 200 + + +@pytest.mark.anyio +async def test_403_without_insufficient_scope_does_not_reauthorize() -> None: + """A plain 403 (not `insufficient_scope`) is returned to the caller without re-exchanging.""" + requests: list[httpx.Request] = [] + record: list[tuple[str, str]] = [] + auth = make_provider(record=record) + + transport = mock_transport(requests, rs_first_status=403, rs_first_headers={"WWW-Authenticate": "Bearer"}) + async with httpx.AsyncClient(transport=transport, auth=auth) as http: + response = await http.post(f"{RS}/mcp") + + assert response.status_code == 403 + assert record == [] + assert [str(r.url) for r in requests] == [f"{RS}/mcp"] + + +@pytest.mark.anyio +async def test_token_endpoint_error_surfaces_as_oauth_token_error() -> None: + requests: list[httpx.Request] = [] + auth = make_provider() + + async with httpx.AsyncClient(transport=mock_transport(requests, asm=asm_body(), token=400), auth=auth) as http: + with pytest.raises(OAuthTokenError, match=r"Token exchange failed \(400\).*invalid_grant"): + await http.post(f"{RS}/mcp") + + +@pytest.mark.anyio +async def test_client_secret_basic_sends_basic_header_not_body_secret() -> None: + requests: list[httpx.Request] = [] + auth = make_provider(token_endpoint_auth_method="client_secret_basic") + + async with httpx.AsyncClient( + transport=mock_transport(requests, asm=asm_body(), token=token_body()), auth=auth + ) as http: + await http.post(f"{RS}/mcp") + + [token_req] = [r for r in requests if r.url.path == "/token"] + assert "client_secret" not in form(token_req) + decoded = base64.b64decode(token_req.headers["Authorization"].removeprefix("Basic ")).decode() + assert decoded == "test-client-id:test-client-secret" + + +@pytest.mark.anyio +async def test_stored_token_is_reused_without_reauthorizing() -> None: + """A valid stored token is sent on the first request; on success no ASM or /token is fetched.""" + requests: list[httpx.Request] = [] + storage = InMemoryStorage(tokens=OAuthToken(access_token="cached", token_type="Bearer", expires_in=3600)) + auth = make_provider(storage) + + transport = mock_transport(requests, rs_first_status=200) + async with httpx.AsyncClient(transport=transport, auth=auth) as http: + response = await http.post(f"{RS}/mcp") + + assert response.status_code == 200 + assert [str(r.url) for r in requests] == [f"{RS}/mcp"] + assert requests[0].headers["Authorization"] == "Bearer cached" + + +@pytest.mark.anyio +async def test_second_401_re_exchanges_without_refetching_asm() -> None: + """ASM is discovered once; a later 401 mints a fresh assertion against the cached token endpoint.""" + requests: list[httpx.Request] = [] + record: list[tuple[str, str]] = [] + auth = make_provider(record=record) + rs_hits = 0 + + def handle(request: httpx.Request) -> httpx.Response: + nonlocal rs_hits + requests.append(request) + host, path = request.url.host, request.url.path + if host == "mcp.example.com": + rs_hits += 1 + # First and third RS hits draw a 401; second and fourth succeed. + return httpx.Response(401 if rs_hits in (1, 3) else 200) + if host == "auth.example.com" and path == ASM_PATH: + return httpx.Response(200, content=asm_body(), headers={"content-type": "application/json"}) + assert host == "auth.example.com" and path == "/token" + return httpx.Response(200, content=token_body(), headers={"content-type": "application/json"}) + + async with httpx.AsyncClient(transport=httpx.MockTransport(handle), auth=auth) as http: + await http.post(f"{RS}/mcp") + await http.post(f"{RS}/mcp") + + asm_gets = [r for r in requests if r.url.path == ASM_PATH] + token_posts = [r for r in requests if r.url.path == "/token"] + assert len(asm_gets) == 1 + assert len(token_posts) == 2 + assert len(record) == 2 + + +@pytest.mark.anyio +async def test_no_configured_scope_omits_scope_and_backfills_from_request() -> None: + """With no configured scope and no scope in the token response, the stored token records None.""" + requests: list[httpx.Request] = [] + storage = InMemoryStorage() + auth = make_provider(storage, scope=None) + + async with httpx.AsyncClient( + transport=mock_transport(requests, asm=asm_body(), token=token_body()), auth=auth + ) as http: + await http.post(f"{RS}/mcp") + + [token_req] = [r for r in requests if r.url.path == "/token"] + assert "scope" not in form(token_req) + assert storage.tokens is not None + assert storage.tokens.scope is None + + +def test_empty_client_secret_is_rejected() -> None: + async def assertion_provider(audience: str, resource: str) -> str: + raise NotImplementedError + + with pytest.raises(ValueError, match="client_secret is required"): + IdentityAssertionOAuthProvider( + server_url=f"{RS}/mcp", + storage=InMemoryStorage(), + client_id="c", + client_secret="", + issuer=ISSUER, + assertion_provider=assertion_provider, + ) + + +def test_empty_issuer_is_rejected() -> None: + async def assertion_provider(audience: str, resource: str) -> str: + raise NotImplementedError + + with pytest.raises(ValueError, match="issuer is required"): + IdentityAssertionOAuthProvider( + server_url=f"{RS}/mcp", + storage=InMemoryStorage(), + client_id="c", + client_secret="s", + issuer="", + assertion_provider=assertion_provider, + ) + + +def test_origin_normalizes_default_ports() -> None: + """`_origin` treats an explicit scheme-default port as equal to the port-less form.""" + assert _origin("https://host") == _origin("https://host:443") + assert _origin("http://host") == _origin("http://host:80") + assert _origin("https://host") != _origin("https://host:8443") + assert _origin("https://host") != _origin("https://other") diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index cdbba1b588..1ec38ccf6f 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -12,7 +12,7 @@ from pydantic import AnyHttpUrl, AnyUrl from mcp.client.auth import OAuthClientProvider, PKCEParameters -from mcp.client.auth.exceptions import OAuthFlowError +from mcp.client.auth.exceptions import OAuthFlowError, OAuthTokenError from mcp.client.auth.utils import ( build_oauth_authorization_server_metadata_discovery_urls, build_protected_resource_metadata_discovery_urls, @@ -2860,6 +2860,17 @@ async def test_handle_token_response_backfills_omitted_scope_from_request( assert stored.scope == "read admin" +@pytest.mark.anyio +async def test_handle_token_response_raises_on_non_2xx_with_body(oauth_provider: OAuthClientProvider): + response = httpx.Response( + 400, + json={"error": "invalid_grant"}, + request=httpx.Request("POST", "https://auth.example.com/token"), + ) + with pytest.raises(OAuthTokenError, match=r"Token exchange failed \(400\).*invalid_grant"): + await oauth_provider._handle_token_response(response) + + @pytest.mark.anyio async def test_handle_refresh_response_carries_prior_scope_and_refresh_token_when_omitted( oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 0150513e26..af6289ae70 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -3689,6 +3689,64 @@ def __post_init__(self) -> None: "elsewhere into the auth provider's state, so the absence cannot be observed end to end." ), ), + "client-auth:identity-assertion": Requirement( + source="sdk", + behavior=( + "The identity-assertion provider (SEP-990) presents an enterprise IdP-issued ID-JAG to the MCP " + "authorization server via the RFC 7523 jwt-bearer grant, with no authorize or registration step, " + "and the issued bearer token authorizes subsequent requests." + ), + transports=("streamable-http",), + note="OAuth is HTTP-only.", + ), + "client-auth:identity-assertion:assertion-callback": Requirement( + source="sdk", + behavior=( + "The identity-assertion provider sources the ID-JAG from its async assertion_provider callback, " + "invoked with the authorization server's issuer as audience and the MCP server's resource " + "identifier, and sends it as `assertion` on the RFC 7523 jwt-bearer request." + ), + transports=("streamable-http",), + note="OAuth is HTTP-only.", + ), + "client-auth:identity-assertion:issuer-pinning": Requirement( + source="sdk", + behavior=( + "The identity-assertion provider's authorization server is configuration: metadata is " + "fetched only from the configured issuer's RFC 8414 well-known, the resource server is " + "never consulted for AS selection, and the ID-JAG and client secret are not sent unless " + "that metadata validates." + ), + transports=("streamable-http",), + note="OAuth is HTTP-only.", + ), + "client-auth:identity-assertion:disabled-rejected": Requirement( + source="sdk", + behavior=( + "When the authorization server has the identity-assertion grant disabled, the token endpoint " + "rejects it with unsupported_grant_type and the connection fails rather than issuing a token." + ), + transports=("streamable-http",), + note="OAuth is HTTP-only.", + ), + "client-auth:identity-assertion:invalid-assertion": Requirement( + source="sdk", + behavior=( + "A jwt-bearer request whose ID-JAG the authorization server rejects surfaces as an OAuth error " + "and the connection fails rather than proceeding with a bearer token." + ), + transports=("streamable-http",), + note="OAuth is HTTP-only.", + ), + "client-auth:identity-assertion:metadata-advertised": Requirement( + source="sdk", + behavior=( + "When the identity-assertion grant is enabled, the authorization-server metadata advertises the " + "jwt-bearer grant type and the id-jag grant profile in authorization_grant_profiles_supported." + ), + transports=("streamable-http",), + note="OAuth is HTTP-only.", + ), # ═══════════════════════════════════════════════════════════════════════════ # stdio transport # ═══════════════════════════════════════════════════════════════════════════ diff --git a/tests/interaction/auth/_harness.py b/tests/interaction/auth/_harness.py index b7ffc78c86..4fd1110c9b 100644 --- a/tests/interaction/auth/_harness.py +++ b/tests/interaction/auth/_harness.py @@ -178,7 +178,10 @@ async def callback_handler(self) -> AuthorizationCodeResult: def auth_settings( - *, required_scopes: Sequence[str] = ("mcp",), valid_scopes: Sequence[str] | None = None + *, + required_scopes: Sequence[str] = ("mcp",), + valid_scopes: Sequence[str] | None = None, + identity_assertion_enabled: bool = False, ) -> AuthSettings: """Build `AuthSettings` for the co-hosted authorization + resource server. @@ -188,6 +191,10 @@ def auth_settings( validation; tests pass a wider set when they need the protected-resource metadata's `scopes_supported` (which mirrors `required_scopes`) to differ from what the client may register or when AS metadata should advertise additional scopes such as `offline_access`. + + `identity_assertion_enabled` advertises and accepts the SEP-990 ID-JAG grant (RFC 7523 + jwt-bearer); the provider must implement `exchange_identity_assertion` for the endpoint to + issue tokens. """ required = list(required_scopes) valid = list(valid_scopes) if valid_scopes is not None else required @@ -199,6 +206,7 @@ def auth_settings( enabled=True, valid_scopes=valid, default_scopes=required ), revocation_options=RevocationOptions(enabled=False), + identity_assertion_enabled=identity_assertion_enabled, ) diff --git a/tests/interaction/auth/_provider.py b/tests/interaction/auth/_provider.py index 422134becf..0c54d4fd37 100644 --- a/tests/interaction/auth/_provider.py +++ b/tests/interaction/auth/_provider.py @@ -15,6 +15,7 @@ AccessToken, AuthorizationCode, AuthorizationParams, + IdentityAssertionParams, OAuthAuthorizationServerProvider, RefreshToken, TokenError, @@ -25,6 +26,10 @@ _TOKEN_LIFETIME_SECONDS = 3600 +# The only ID-JAG assertion the in-memory provider accepts; any other value is rejected with +# invalid_grant, standing in for the signature/policy validation a real AS performs. +VALID_ASSERTION = "valid-id-jag" + class InMemoryAuthorizationServerProvider( OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken] @@ -71,6 +76,9 @@ def __init__( self.codes: dict[str, AuthorizationCode] = {} self.refresh_tokens: dict[str, RefreshToken] = {} self.access_tokens: dict[str, AccessToken] = {} + # The most recent jwt-bearer request the SDK handler passed to exchange_identity_assertion, + # for tests to assert what the client sent (None until the first exchange). + self.last_assertion_params: IdentityAssertionParams | None = None def _next_expires_in(self) -> int: self._tokens_issued += 1 @@ -187,6 +195,28 @@ async def exchange_refresh_token( refresh_token=new_refresh, ) + async def exchange_identity_assertion( + self, client: OAuthClientInformationFull, params: IdentityAssertionParams + ) -> OAuthToken: + """Validate the ID-JAG assertion and mint an MCP access token (RFC 7523 jwt-bearer / SEP-990). + + Records `params` for inspection and rejects any assertion other than `VALID_ASSERTION` with + invalid_grant (standing in for signature/policy validation). The granted scopes are exactly + those the client requested; a real provider would derive them from the validated ID-JAG. + """ + self.last_assertion_params = params + assert client.client_id is not None + if params.assertion != VALID_ASSERTION: + raise TokenError(error="invalid_grant", error_description="assertion is not valid") + scopes = params.scopes if params.scopes is not None else self._default_scopes + access = self.mint_access_token(client_id=client.client_id, scopes=scopes, resource=params.resource) + return OAuthToken( + access_token=access, + token_type="Bearer", + expires_in=self._next_expires_in(), + scope=" ".join(scopes), + ) + async def revoke_token(self, token: AccessToken | RefreshToken) -> None: """Not exercised by this suite; revocation is out of scope for the interaction tests.""" raise NotImplementedError diff --git a/tests/interaction/auth/test_identity_assertion.py b/tests/interaction/auth/test_identity_assertion.py new file mode 100644 index 0000000000..e03859274e --- /dev/null +++ b/tests/interaction/auth/test_identity_assertion.py @@ -0,0 +1,302 @@ +"""End-to-end SEP-990 Identity Assertion (RFC 7523 jwt-bearer) flows. + +These exercise the FULL stack: the real `IdentityAssertionOAuthProvider` on the client and the real +authorization-server token endpoint on the server, with `InMemoryAuthorizationServerProvider` +implementing `exchange_identity_assertion`. The client supplies the ID-JAG through its +`assertion_provider` callback; the provider validates it and the issued bearer authorizes the MCP +request. + +Recording-first: the recorded `/token` request is asserted before the call result, so a surprise in +the exchange path produces a readable diff of what fired. +""" + +from urllib.parse import parse_qsl + +import anyio +import mcp_types as types +import pytest +from inline_snapshot import snapshot +from mcp_types import ListToolsResult, Tool + +from mcp.client.auth import OAuthFlowError, OAuthTokenError +from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider +from mcp.server import Server, ServerRequestContext +from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata +from tests.interaction._connect import BASE_URL, mounted_app +from tests.interaction._requirements import requirement +from tests.interaction.auth._harness import ( + InMemoryTokenStorage, + RecordedRequest, + auth_settings, + connect_with_oauth, + record_requests, +) +from tests.interaction.auth._provider import VALID_ASSERTION, InMemoryAuthorizationServerProvider + +pytestmark = pytest.mark.anyio + +ASM_ROOT = "/.well-known/oauth-authorization-server" +JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer" +ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag" +CLIENT_ID = "enterprise-mcp-client" +CLIENT_SECRET = "enterprise-secret" +# The AS metadata issuer carries a trailing slash (built from an AnyHttpUrl object); the client +# pins against exactly that. +EXPECTED_ISSUER = f"{BASE_URL}/" + + +async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[Tool(name="echo", input_schema={"type": "object"})]) + + +def find(recorded: list[RecordedRequest], method: str, path: str) -> list[RecordedRequest]: + return [r for r in recorded if r.method == method and r.path == path] + + +def form_body(request: RecordedRequest) -> dict[str, str]: + return dict(parse_qsl(request.content.decode())) + + +def preregister_confidential_client(provider: InMemoryAuthorizationServerProvider) -> None: + """Seed a pre-registered confidential client allowed to use the identity-assertion grant. + + SEP-990 clients are provisioned out of band (DCR refuses the grant), so the server already knows + the client; `IdentityAssertionOAuthProvider` presents the same id + secret without registering. + """ + provider.clients[CLIENT_ID] = OAuthClientInformationFull( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + redirect_uris=None, + grant_types=[JWT_BEARER_GRANT_TYPE], + token_endpoint_auth_method="client_secret_post", + scope="mcp", + ) + + +def identity_assertion_provider( + storage: InMemoryTokenStorage, + *, + assertion: str = VALID_ASSERTION, + issuer: str = EXPECTED_ISSUER, + record: list[tuple[str, str]] | None = None, +) -> IdentityAssertionOAuthProvider: + async def assertion_provider(audience: str, resource: str) -> str: + if record is not None: + record.append((audience, resource)) + return assertion + + return IdentityAssertionOAuthProvider( + server_url=f"{BASE_URL}/mcp", + storage=storage, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + issuer=issuer, + assertion_provider=assertion_provider, + scope="mcp", + ) + + +@requirement("client-auth:identity-assertion") +async def test_identity_assertion_obtains_a_token_and_authorizes_the_request() -> None: + """The identity-assertion provider connects end to end with no authorize/register step. + + The full stack runs: the client posts grant_type=jwt-bearer with the ID-JAG as `assertion` to the + real token endpoint, the provider validates it and issues a bearer, and the bearer authorizes + list_tools. The recorded sequence proves no /authorize or /register request was made. + """ + recorded, on_request = record_requests() + provider = InMemoryAuthorizationServerProvider() + preregister_confidential_client(provider) + server = Server("guarded", on_list_tools=list_tools) + storage = InMemoryTokenStorage() + auth = identity_assertion_provider(storage) + + with anyio.fail_after(5): + async with connect_with_oauth( + server, + provider=provider, + settings=auth_settings(identity_assertion_enabled=True), + auth=auth, + on_request=on_request, + ) as (client, headless): + result = await client.list_tools() + + # Recording-first: assert what fired before the call result. + assert headless.authorize_url is None + assert find(recorded, "GET", "/authorize") == [] + assert find(recorded, "POST", "/register") == [] + # The AS is configuration: PRM is never fetched, so the resource server has no input into where + # the credentials go. + assert not any(r.path.startswith("/.well-known/oauth-protected-resource") for r in recorded) + + [token_req] = find(recorded, "POST", "/token") + body = form_body(token_req) + assert body == snapshot( + { + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": "valid-id-jag", + "client_id": "enterprise-mcp-client", + "resource": "http://127.0.0.1:8000/mcp", + "scope": "mcp", + "client_secret": "enterprise-secret", + } + ) + + assert result.tools[0].name == "echo" + assert provider.last_assertion_params is not None + assert provider.last_assertion_params.assertion == VALID_ASSERTION + assert storage.tokens is not None + assert storage.tokens.access_token in provider.access_tokens + + +@requirement("client-auth:identity-assertion") +async def test_configured_scope_is_sent_regardless_of_server_advertised_scopes() -> None: + """The caller's configured scope reaches the wire; server-advertised scopes have no effect. + + The provider has no scope-selection step, so this is true by construction; the test pins it. + Here the AS advertises `mcp extra` but the client requested only `mcp`, so the recorded `/token` + body must carry `scope=mcp`, not the advertised superset. + """ + recorded, on_request = record_requests() + provider = InMemoryAuthorizationServerProvider() + preregister_confidential_client(provider) + server = Server("guarded", on_list_tools=list_tools) + auth = identity_assertion_provider(InMemoryTokenStorage()) + + with anyio.fail_after(5): + async with connect_with_oauth( + server, + provider=provider, + # AS metadata advertises a broader scopes_supported than the client requests. + settings=auth_settings(identity_assertion_enabled=True, valid_scopes=["mcp", "extra"]), + auth=auth, + on_request=on_request, + ) as (client, _): + await client.list_tools() + + [token_req] = find(recorded, "POST", "/token") + assert form_body(token_req)["scope"] == "mcp" + assert provider.last_assertion_params is not None + assert provider.last_assertion_params.scopes == ["mcp"] + + +@requirement("client-auth:identity-assertion:assertion-callback") +async def test_assertion_callback_receives_issuer_audience_and_resource() -> None: + """The assertion_provider gets the AS issuer as audience and the MCP resource identifier.""" + record: list[tuple[str, str]] = [] + provider = InMemoryAuthorizationServerProvider() + preregister_confidential_client(provider) + server = Server("guarded", on_list_tools=list_tools) + auth = identity_assertion_provider(InMemoryTokenStorage(), record=record) + + with anyio.fail_after(5): + async with connect_with_oauth( + server, + provider=provider, + settings=auth_settings(identity_assertion_enabled=True), + auth=auth, + ) as (client, _): + await client.list_tools() + + assert record == [(EXPECTED_ISSUER, f"{BASE_URL}/mcp")] + + +@requirement("client-auth:identity-assertion:issuer-pinning") +async def test_unexpected_issuer_aborts_before_sending_credentials() -> None: + """When the configured issuer's metadata fails RFC 8414 validation, no credential is sent. + + The AS issuer is configuration; metadata is fetched from that issuer's well-known and validated + per RFC 8414 section 3.3. Here the in-process server's metadata has a different issuer than the + one the client is configured for, so validation fails before the assertion callback is invoked + or any credential is posted. PRM is never fetched and no DCR is attempted. + """ + recorded, on_request = record_requests() + record: list[tuple[str, str]] = [] + provider = InMemoryAuthorizationServerProvider() + preregister_confidential_client(provider) + server = Server("guarded", on_list_tools=list_tools) + # The served AS metadata has issuer BASE_URL/, but the client is configured for a different one. + auth = identity_assertion_provider(InMemoryTokenStorage(), issuer="https://corp-as.example/", record=record) + + with anyio.fail_after(5): + with pytest.RaisesGroup(pytest.RaisesExc(OAuthFlowError, match="issuer mismatch"), flatten_subgroups=True): + await connect_with_oauth( + server, + provider=provider, + settings=auth_settings(identity_assertion_enabled=True), + auth=auth, + on_request=on_request, + ).__aenter__() + + assert record == [] + assert provider.last_assertion_params is None + assert find(recorded, "POST", "/token") == [] + assert find(recorded, "POST", "/register") == [] + assert not any(r.path.startswith("/.well-known/oauth-protected-resource") for r in recorded) + + +@requirement("client-auth:identity-assertion:disabled-rejected") +async def test_identity_assertion_is_rejected_when_disabled_on_the_server() -> None: + """With the grant disabled, the token endpoint returns unsupported_grant_type and the flow fails.""" + provider = InMemoryAuthorizationServerProvider() + preregister_confidential_client(provider) + server = Server("guarded", on_list_tools=list_tools) + auth = identity_assertion_provider(InMemoryTokenStorage()) + + with anyio.fail_after(5): + with pytest.RaisesGroup( + pytest.RaisesExc(OAuthTokenError, match=r"Token exchange failed \(400\):.*unsupported_grant_type"), + flatten_subgroups=True, + ): + await connect_with_oauth( + server, + provider=provider, + settings=auth_settings(identity_assertion_enabled=False), + auth=auth, + ).__aenter__() + + assert provider.last_assertion_params is None + + +@requirement("client-auth:identity-assertion:invalid-assertion") +async def test_a_rejected_assertion_aborts_the_flow() -> None: + """An ID-JAG the provider rejects surfaces as OAuthTokenError; no bearer is issued.""" + provider = InMemoryAuthorizationServerProvider() + preregister_confidential_client(provider) + server = Server("guarded", on_list_tools=list_tools) + storage = InMemoryTokenStorage() + auth = identity_assertion_provider(storage, assertion="forged-id-jag") + + with anyio.fail_after(5): + with pytest.RaisesGroup( + pytest.RaisesExc(OAuthTokenError, match=r"Token exchange failed \(400\):.*invalid_grant"), + flatten_subgroups=True, + ): + await connect_with_oauth( + server, + provider=provider, + settings=auth_settings(identity_assertion_enabled=True), + auth=auth, + ).__aenter__() + + assert provider.last_assertion_params is not None + assert provider.last_assertion_params.assertion == "forged-id-jag" + assert storage.tokens is None + + +@requirement("client-auth:identity-assertion:metadata-advertised") +async def test_metadata_advertises_jwt_bearer_grant_and_id_jag_profile() -> None: + """When enabled, AS metadata lists the jwt-bearer grant and the id-jag grant profile.""" + server = Server("bare") + provider = InMemoryAuthorizationServerProvider() + + async with mounted_app( + server, auth=auth_settings(identity_assertion_enabled=True), auth_server_provider=provider + ) as (http, _): + response = await http.get(ASM_ROOT) + + assert response.status_code == 200 + metadata = OAuthMetadata.model_validate_json(response.content) + assert metadata.grant_types_supported is not None + assert JWT_BEARER_GRANT_TYPE in metadata.grant_types_supported + assert metadata.authorization_grant_profiles_supported == [ID_JAG_GRANT_PROFILE] diff --git a/tests/server/auth/test_identity_assertion.py b/tests/server/auth/test_identity_assertion.py new file mode 100644 index 0000000000..c83bbe1e84 --- /dev/null +++ b/tests/server/auth/test_identity_assertion.py @@ -0,0 +1,397 @@ +"""Server-side SEP-990 Identity Assertion Authorization Grant (RFC 7523 jwt-bearer) handling.""" + +import secrets +import time + +import httpx +import pytest +from httpx import ASGITransport +from pydantic import AnyHttpUrl +from starlette.applications import Starlette + +from mcp.server.auth.provider import ( + AccessToken, + AuthorizationCode, + AuthorizationParams, + IdentityAssertionParams, + OAuthAuthorizationServerProvider, + RefreshToken, + TokenError, +) +from mcp.server.auth.routes import build_metadata, create_auth_routes +from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions +from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken + +ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag" +VALID_ASSERTION = "valid-id-jag" +CONFIDENTIAL_CLIENT_ID = "enterprise-client" +CONFIDENTIAL_CLIENT_SECRET = "enterprise-secret" + + +class IdentityAssertionProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]): + """A provider that implements `exchange_identity_assertion`; everything else is unused here.""" + + def __init__(self) -> None: + self.clients: dict[str, OAuthClientInformationFull] = {} + self.tokens: dict[str, AccessToken] = {} + self.last_params: IdentityAssertionParams | None = None + + 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: + raise NotImplementedError + + async def load_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: str + ) -> AuthorizationCode | None: + raise NotImplementedError + + async def exchange_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode + ) -> OAuthToken: + raise NotImplementedError + + 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 load_access_token(self, token: str) -> AccessToken | None: + return self.tokens.get(token) + + async def revoke_token(self, token: AccessToken | RefreshToken) -> None: + raise NotImplementedError + + async def exchange_identity_assertion( + self, client: OAuthClientInformationFull, params: IdentityAssertionParams + ) -> OAuthToken: + self.last_params = params + # Stand-in for RFC 7523 §3 / SEP-990 §5.1 assertion validation. + if params.assertion != VALID_ASSERTION: + raise TokenError(error="invalid_grant", error_description="assertion is not valid") + assert client.client_id is not None + scopes = params.scopes or ["mcp"] + access = f"access_{secrets.token_hex(16)}" + self.tokens[access] = AccessToken( + token=access, + client_id=client.client_id, + scopes=scopes, + expires_at=int(time.time()) + 3600, + resource=params.resource, + subject="assertion-user", + ) + return OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=" ".join(scopes)) + + +@pytest.fixture +def provider() -> IdentityAssertionProvider: + prov = IdentityAssertionProvider() + # Pre-register a confidential client (DCR refuses the grant; see the DCR test). + prov.clients[CONFIDENTIAL_CLIENT_ID] = OAuthClientInformationFull( + client_id=CONFIDENTIAL_CLIENT_ID, + client_secret=CONFIDENTIAL_CLIENT_SECRET, + redirect_uris=None, + grant_types=[JWT_BEARER_GRANT_TYPE], + token_endpoint_auth_method="client_secret_post", + scope="mcp", + ) + return prov + + +@pytest.fixture +def app(provider: IdentityAssertionProvider) -> Starlette: + routes = create_auth_routes( + provider, + issuer_url=AnyHttpUrl("https://auth.example.com"), + client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=["mcp"]), + revocation_options=RevocationOptions(enabled=False), + identity_assertion_enabled=True, + ) + return Starlette(routes=routes) + + +@pytest.fixture +async def client(app: Starlette): + transport = ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="https://auth.example.com") as http: + yield http + + +def assertion_form(**overrides: str) -> dict[str, str]: + form = { + "grant_type": JWT_BEARER_GRANT_TYPE, + "client_id": CONFIDENTIAL_CLIENT_ID, + "client_secret": CONFIDENTIAL_CLIENT_SECRET, + "assertion": VALID_ASSERTION, + } + form.update(overrides) + return form + + +def test_build_metadata_advertises_id_jag_profile_when_enabled(): + enabled = build_metadata( + AnyHttpUrl("https://auth.example.com"), + None, + ClientRegistrationOptions(), + RevocationOptions(), + supports_identity_assertion=True, + ) + assert JWT_BEARER_GRANT_TYPE in (enabled.grant_types_supported or []) + assert enabled.authorization_grant_profiles_supported == [ID_JAG_GRANT_PROFILE] + # The grant is confidential-only, so the `none` auth method is NOT advertised. + assert "none" not in (enabled.token_endpoint_auth_methods_supported or []) + + disabled = build_metadata( + AnyHttpUrl("https://auth.example.com"), + None, + ClientRegistrationOptions(), + RevocationOptions(), + ) + assert JWT_BEARER_GRANT_TYPE not in (disabled.grant_types_supported or []) + assert disabled.authorization_grant_profiles_supported is None + + +@pytest.mark.anyio +async def test_metadata_endpoint_lists_id_jag_profile(client: httpx.AsyncClient): + response = await client.get("/.well-known/oauth-authorization-server") + assert response.status_code == 200 + body = response.json() + assert JWT_BEARER_GRANT_TYPE in body["grant_types_supported"] + assert body["authorization_grant_profiles_supported"] == [ID_JAG_GRANT_PROFILE] + + +@pytest.mark.anyio +async def test_identity_assertion_success(client: httpx.AsyncClient, provider: IdentityAssertionProvider): + response = await client.post("/token", data=assertion_form(scope="mcp", resource="https://mcp.example.com/mcp")) + + assert response.status_code == 200, response.content + body = response.json() + assert body["token_type"] == "Bearer" + assert "issued_token_type" not in body # plain RFC 6749 response under jwt-bearer + + issued = await provider.load_access_token(body["access_token"]) + assert issued is not None + assert issued.scopes == ["mcp"] + assert issued.subject == "assertion-user" + + assert provider.last_params is not None + assert provider.last_params.assertion == VALID_ASSERTION + assert provider.last_params.scopes == ["mcp"] + assert provider.last_params.resource == "https://mcp.example.com/mcp" + + +@pytest.mark.anyio +async def test_identity_assertion_invalid_assertion(client: httpx.AsyncClient): + response = await client.post("/token", data=assertion_form(assertion="forged")) + + assert response.status_code == 400 + assert response.json() == {"error": "invalid_grant", "error_description": "assertion is not valid"} + + +@pytest.mark.anyio +async def test_identity_assertion_rejected_when_disabled(provider: IdentityAssertionProvider): + routes = create_auth_routes( + provider, + issuer_url=AnyHttpUrl("https://auth.example.com"), + client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=["mcp"]), + revocation_options=RevocationOptions(enabled=False), + identity_assertion_enabled=False, + ) + app = Starlette(routes=routes) + transport = ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="https://auth.example.com") as http: + response = await http.post("/token", data=assertion_form()) + + assert response.status_code == 400 + assert response.json()["error"] == "unsupported_grant_type" + assert provider.last_params is None + + +@pytest.mark.anyio +async def test_identity_assertion_rejects_public_client(client: httpx.AsyncClient, provider: IdentityAssertionProvider): + """A public (auth method 'none') client cannot use the grant, even if it presents a valid assertion.""" + provider.clients["public-client"] = OAuthClientInformationFull( + client_id="public-client", + redirect_uris=None, + grant_types=[JWT_BEARER_GRANT_TYPE], + token_endpoint_auth_method="none", + scope="mcp", + ) + + response = await client.post( + "/token", + data={"grant_type": JWT_BEARER_GRANT_TYPE, "client_id": "public-client", "assertion": VALID_ASSERTION}, + ) + + assert response.status_code == 400 + assert response.json()["error"] == "unauthorized_client" + assert provider.last_params is None + + +@pytest.mark.anyio +async def test_identity_assertion_rejects_secretless_confidential_client( + client: httpx.AsyncClient, provider: IdentityAssertionProvider +): + """A client registered with a secret-based method but no stored secret fails authentication. + + `ClientAuthenticator` rejects this misconfiguration as `invalid_client`, so the request never + reaches the jwt-bearer handler or the provider hook. + """ + provider.clients["secretless-client"] = OAuthClientInformationFull( + client_id="secretless-client", + client_secret=None, + redirect_uris=None, + grant_types=[JWT_BEARER_GRANT_TYPE], + token_endpoint_auth_method="client_secret_post", + scope="mcp", + ) + + response = await client.post( + "/token", + data={"grant_type": JWT_BEARER_GRANT_TYPE, "client_id": "secretless-client", "assertion": VALID_ASSERTION}, + ) + + assert response.status_code == 401 + body = response.json() + assert body["error"] == "invalid_client" + assert "no stored secret" in body["error_description"] + assert provider.last_params is None + + +@pytest.mark.anyio +async def test_malformed_request_missing_assertion_is_invalid_request(client: httpx.AsyncClient): + """A jwt-bearer request without the required `assertion` fails validation with invalid_request.""" + response = await client.post( + "/token", + data={ + "grant_type": JWT_BEARER_GRANT_TYPE, + "client_id": CONFIDENTIAL_CLIENT_ID, + "client_secret": CONFIDENTIAL_CLIENT_SECRET, + }, + ) + + assert response.status_code == 400 + assert response.json()["error"] == "invalid_request" + + +@pytest.mark.anyio +async def test_client_without_the_grant_registered_is_rejected( + client: httpx.AsyncClient, provider: IdentityAssertionProvider +): + """A confidential client whose registration omits the jwt-bearer grant is refused the grant.""" + provider.clients["no-grant-client"] = OAuthClientInformationFull( + client_id="no-grant-client", + client_secret="s", + redirect_uris=None, + grant_types=["authorization_code"], + token_endpoint_auth_method="client_secret_post", + scope="mcp", + ) + + response = await client.post( + "/token", + data={ + "grant_type": JWT_BEARER_GRANT_TYPE, + "client_id": "no-grant-client", + "client_secret": "s", + "assertion": VALID_ASSERTION, + }, + ) + + assert response.status_code == 400 + assert response.json()["error"] == "unsupported_grant_type" + assert provider.last_params is None + + +@pytest.mark.anyio +async def test_dcr_refuses_to_register_the_jwt_bearer_grant( + client: httpx.AsyncClient, provider: IdentityAssertionProvider +): + """Dynamic client registration rejects the jwt-bearer grant; the ID-JAG flow needs pre-registration.""" + response = await client.post( + "/register", + json={ + "redirect_uris": ["https://client.example.com/callback"], + "token_endpoint_auth_method": "client_secret_post", + "grant_types": ["authorization_code", JWT_BEARER_GRANT_TYPE], + "response_types": ["code"], + }, + ) + + assert response.status_code == 400 + body = response.json() + assert body["error"] == "invalid_client_metadata" + assert JWT_BEARER_GRANT_TYPE in body["error_description"] + + # A registration without the jwt-bearer grant still succeeds and is stored. + ok = await client.post( + "/register", + json={ + "redirect_uris": ["https://client.example.com/callback"], + "token_endpoint_auth_method": "client_secret_post", + "grant_types": ["authorization_code"], + "response_types": ["code"], + }, + ) + assert ok.status_code == 201 + assert ok.json()["client_id"] in provider.clients + + +@pytest.mark.anyio +async def test_default_provider_rejects_identity_assertion(): + """A provider that does not override `exchange_identity_assertion` rejects with unsupported_grant_type.""" + + class BareProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]): + async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: + raise NotImplementedError + + async def register_client(self, client_info: OAuthClientInformationFull) -> None: + raise NotImplementedError + + async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: + raise NotImplementedError + + async def load_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: str + ) -> AuthorizationCode | None: + raise NotImplementedError + + async def exchange_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode + ) -> OAuthToken: + raise NotImplementedError + + 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 load_access_token(self, token: str) -> AccessToken | None: + raise NotImplementedError + + async def revoke_token(self, token: AccessToken | RefreshToken) -> None: + raise NotImplementedError + + bare = BareProvider() + client_info = OAuthClientInformationFull( + redirect_uris=None, + client_id="c", + grant_types=[JWT_BEARER_GRANT_TYPE], + ) + params = IdentityAssertionParams(assertion=VALID_ASSERTION) + with pytest.raises(TokenError) as excinfo: + await bare.exchange_identity_assertion(client_info, params) + assert excinfo.value.error == "unsupported_grant_type" From 067f90578c0f08001f25ada200426681073cc602 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Fri, 26 Jun 2026 19:09:08 +0200 Subject: [PATCH 017/100] Add SSE response mode to the 2026 streamable-HTTP server entry (#3001) --- .../expected-failures.2026-07-28.yml | 4 - examples/stories/manifest.toml | 2 - examples/stories/streaming/README.md | 13 +- src/mcp/client/streamable_http.py | 7 +- src/mcp/server/_streamable_http_modern.py | 139 ++++++- src/mcp/server/streamable_http.py | 39 +- src/mcp/server/streamable_http_manager.py | 4 +- src/mcp/shared/jsonrpc_dispatcher.py | 19 +- tests/examples/conftest.py | 2 +- tests/interaction/_requirements.py | 44 --- tests/server/test_streamable_http_modern.py | 356 +++++++++++++++++- 11 files changed, 517 insertions(+), 112 deletions(-) diff --git a/.github/actions/conformance/expected-failures.2026-07-28.yml b/.github/actions/conformance/expected-failures.2026-07-28.yml index 39fcdde482..702575ce41 100644 --- a/.github/actions/conformance/expected-failures.2026-07-28.yml +++ b/.github/actions/conformance/expected-failures.2026-07-28.yml @@ -28,10 +28,6 @@ client: # neither run nor evaluated on this leg. server: - # The stateless 2026 path now reaches handlers for plain request/response - # scenarios; tools-call-with-progress still fails because the stateless - # server has no channel for server→client progress notifications. - - tools-call-with-progress # SEP-2322 (multi-round-trip requests / IncompleteResult): the prompt pipeline # cannot return InputRequiredResult from MCPServer yet (tools/call can). - input-required-result-non-tool-request diff --git a/examples/stories/manifest.toml b/examples/stories/manifest.toml index 9726289a78..fb688d2942 100644 --- a/examples/stories/manifest.toml +++ b/examples/stories/manifest.toml @@ -31,8 +31,6 @@ 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.mrtr] era = "modern" diff --git a/examples/stories/streaming/README.md b/examples/stories/streaming/README.md index 86e2e74780..e6bedb915a 100644 --- a/examples/stories/streaming/README.md +++ b/examples/stories/streaming/README.md @@ -17,16 +17,12 @@ uv run python -m stories.streaming.client uv run python -m stories.streaming.client --server server_lowlevel # HTTP — the client self-hosts the server on a free port, runs, then tears it -# down (--legacy: see the note below) -uv run python -m stories.streaming.client --http --legacy +# down +uv run python -m stories.streaming.client --http # same, against the lowlevel-API server variant -uv run python -m stories.streaming.client --http --legacy --server server_lowlevel +uv run python -m stories.streaming.client --http --server server_lowlevel ``` -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, @@ -60,9 +56,6 @@ Caveats). 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 diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index 551bfa5f37..f28eb7c7ab 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -156,7 +156,10 @@ async def _handle_sse_event( # Otherwise, return False to continue listening return isinstance(message, JSONRPCResponse | JSONRPCError) - except Exception as exc: # pragma: no cover + # Forwarding to a closed read stream lands here when the caller cancels mid-SSE + # (BrokenResourceError, not a parse failure); coverage is timing-dependent in the + # streaming story's modern HTTP cancellation leg. + except Exception as exc: # pragma: lax no cover logger.exception("Error parsing SSE message") if original_request_id is not None: error_data = ErrorData(code=PARSE_ERROR, message=f"Failed to parse SSE message: {exc}") @@ -372,7 +375,7 @@ async def _handle_sse_response( await response.aclose() return # Normal completion, no reconnect needed except Exception: - logger.debug("SSE stream ended", exc_info=True) # pragma: no cover + logger.debug("SSE stream ended", exc_info=True) # pragma: lax no cover # Stream ended without response - reconnect if we received an event with ID if last_event_id is not None: # pragma: no branch diff --git a/src/mcp/server/_streamable_http_modern.py b/src/mcp/server/_streamable_http_modern.py index cecf21f08e..e36ac7dd4e 100644 --- a/src/mcp/server/_streamable_http_modern.py +++ b/src/mcp/server/_streamable_http_modern.py @@ -5,9 +5,15 @@ path for earlier protocol revisions. A 2026-07-28 request is a self-contained POST: no `initialize` handshake, no -`Mcp-Session-Id`, one JSON-RPC request in, one JSON-RPC response out. This -module handles such a request directly in the ASGI task - no memory streams, -no per-request task group, no `JSONRPCDispatcher`. +`Mcp-Session-Id`, one JSON-RPC request in, one JSON-RPC response out. JSON +mode handles the request directly in the ASGI task. SSE mode runs the handler +as a sibling task and defers committing to `text/event-stream` until the +handler emits a notification or `_SSE_PING_INTERVAL` elapses, whichever +comes first: a handler that completes (or raises) within that window without +emitting still gets a JSON response with the table-mapped HTTP status, so +the spec's `404`/`400` MUSTs hold for kernel-dispatch errors; a handler that +runs silent past the window commits SSE so the keepalive ping can keep the +connection open behind a proxy idle-read timeout. """ from __future__ import annotations @@ -16,9 +22,10 @@ import logging from collections.abc import Awaitable, Mapping from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, Any, Final, TypeVar import anyio +from anyio.streams.memory import MemoryObjectSendStream from mcp_types import ( INTERNAL_ERROR, INVALID_REQUEST, @@ -27,8 +34,10 @@ ErrorData, Implementation, JSONRPCError, + JSONRPCNotification, JSONRPCRequest, JSONRPCResponse, + ProgressToken, RequestId, ) from pydantic import BaseModel, ValidationError @@ -38,6 +47,7 @@ from mcp.server.connection import Connection from mcp.server.runner import serve_one +from mcp.server.streamable_http import check_accept_headers from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings from mcp.shared.dispatcher import CallOptions from mcp.shared.exceptions import NoBackChannelError @@ -46,7 +56,7 @@ InboundLadderRejection, classify_inbound_request, ) -from mcp.shared.jsonrpc_dispatcher import handler_exception_to_error_data +from mcp.shared.jsonrpc_dispatcher import handler_exception_to_error_data, progress_token_from_params from mcp.shared.message import MessageMetadata, ServerMessageMetadata from mcp.shared.transport_context import TransportContext @@ -66,12 +76,15 @@ class _SingleExchangeDispatchContext: Structurally satisfies `mcp.shared.dispatcher.DispatchContext`. The back-channel is closed by construction: a 2026-07-28 server cannot send - requests to the client. + requests to the client. The SSE sink, when present, carries request-scoped + notifications onto this request's response stream. """ transport: TransportContext request_id: RequestId message_metadata: MessageMetadata + progress_token: ProgressToken | None = None + sink: MemoryObjectSendStream[bytes] | None = None cancel_requested: anyio.Event = field(default_factory=anyio.Event) can_send_request: bool = field(default=False, init=False) @@ -84,12 +97,23 @@ async def send_raw_request( raise NoBackChannelError(method) 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 + if self.sink is None: + return + body = dict(params) if params is not None else None + try: + await self.sink.send(_sse_event(JSONRPCNotification(jsonrpc="2.0", method=method, params=body))) + except (anyio.ClosedResourceError, anyio.BrokenResourceError): + logger.debug("dropped %s: response stream closed", method) async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: - # TODO(D-005a): no progressToken plumbing yet; ships with the SSE response mode. - return None + if self.progress_token is None: + return + params: dict[str, Any] = {"progressToken": self.progress_token, "progress": progress} + if total is not None: + params["total"] = total + if message is not None: + params["message"] = message + await self.notify("notifications/progress", params) def _typed(model: type[_ModelT], raw: Any) -> _ModelT | None: @@ -126,6 +150,28 @@ async def _to_jsonrpc_response( return JSONRPCResponse(jsonrpc="2.0", id=request_id, result=result) +_SSE_PING_INTERVAL: float = 15.0 +"""Seconds between SSE comment-line keepalives once `text/event-stream` has committed.""" + +_SSE_HEADERS: Final[list[tuple[bytes, bytes]]] = [ + (b"content-type", b"text/event-stream"), + (b"cache-control", b"no-cache, no-transform"), + (b"connection", b"keep-alive"), + (b"x-accel-buffering", b"no"), +] + + +def _sse_event(msg: JSONRPCResponse | JSONRPCError | JSONRPCNotification) -> bytes: + """Serialise a JSON-RPC message as one SSE `event: message` frame. + + SSE mode begins after the handler has emitted, so a `JSONRPCError` here + always carries the request's id; the `id: null` case lives in `_write`. + """ + body = msg.model_dump(mode="json", by_alias=True, exclude_none=True) + data = json.dumps(body, separators=(",", ":")) + return f"event: message\r\ndata: {data}\r\n\r\n".encode() + + async def _write( msg: JSONRPCResponse | JSONRPCError, scope: Scope, @@ -149,6 +195,7 @@ async def _write( async def handle_modern_request( app: Server[Any], security_settings: TransportSecuritySettings | None, + json_response: bool, lifespan_state: Any, scope: Scope, receive: Receive, @@ -169,14 +216,17 @@ async def handle_modern_request( await err(scope, receive, send) return - # TODO(D-005a): validate Accept once the JSON-vs-SSE response mode is settled. - if request.method != "POST": # HTTP-layer rejection (Allow accompanies 405 per RFC 9110) — happens # before JSON-RPC parsing, so it doesn't go through `_write`. await Response(status_code=405, headers={"Allow": "POST"})(scope, receive, send) return + has_json, has_sse = check_accept_headers(request) + if not has_json or (not json_response and not has_sse): + await Response(status_code=406)(scope, receive, send) + return + body = await request.body() try: decoded = json.loads(body) @@ -219,8 +269,65 @@ async def handle_modern_request( transport=TransportContext(kind="streamable-http", can_send_request=False, headers=request.headers), request_id=req.id, message_metadata=ServerMessageMetadata(request_context=request), + progress_token=progress_token_from_params(req.params), ) - 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) + + if json_response: + 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) + return + + send_ch, recv_ch = anyio.create_memory_object_stream[bytes](0) + dctx.sink = send_ch + result: list[JSONRPCResponse | JSONRPCError] = [] + + async def run_handler() -> None: + async with send_ch: + result.append( + await _to_jsonrpc_response( + req.id, + serve_one(app, dctx, req.method, req.params, connection=connection, lifespan_state=lifespan_state), + ) + ) + + async def watch_disconnect(cancel_scope: anyio.CancelScope) -> None: + while (await receive()).get("type") != "http.disconnect": + pass # pragma: no cover + cancel_scope.cancel() + + async with recv_ch, anyio.create_task_group() as tg: + tg.start_soon(run_handler) + tg.start_soon(watch_disconnect, tg.cancel_scope) + + event: bytes | None = None + done = False + with anyio.move_on_after(_SSE_PING_INTERVAL): + try: + event = await recv_ch.receive() + except anyio.EndOfStream: + done = True + + if done: + # Handler completed within the deferral window without emitting: + # `application/json` with the table-mapped status. Kernel-dispatch + # errors (METHOD_NOT_FOUND, missing-capability, INVALID_PARAMS) + # resolve here in practice. + await _write(result[0], scope, receive, send) + else: + # First notification arrived, or the deferral window elapsed: commit + # `text/event-stream` and start pinging so a proxy idle-read timeout + # cannot close the stream (which on this path cancels the handler). + await send({"type": "http.response.start", "status": _OK_STATUS, "headers": _SSE_HEADERS}) + while not done: + await send({"type": "http.response.body", "body": event or b": ping\r\n\r\n", "more_body": True}) + event = None + with anyio.move_on_after(_SSE_PING_INTERVAL): + try: + event = await recv_ch.receive() + except anyio.EndOfStream: + done = True + await send({"type": "http.response.body", "body": _sse_event(result[0]), "more_body": False}) + + tg.cancel_scope.cancel() diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index b6128d3e08..d316345c7e 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -76,6 +76,24 @@ SSEEvent = dict[str, Any] +def check_accept_headers(request: Request) -> tuple[bool, bool]: + """Return (has_json, has_sse) for the request's Accept header, with RFC 7231 wildcard handling. + + Supports wildcard media types per RFC 7231, section 5.3.2: + - */* matches any media type + - application/* matches any application/ subtype + - text/* matches any text/ subtype + """ + accept_header = request.headers.get("accept", "") + accept_types = [media_type.strip().split(";")[0].strip().lower() for media_type in accept_header.split(",")] + + has_wildcard = "*/*" in accept_types + has_json = has_wildcard or any(t in (CONTENT_TYPE_JSON, "application/*") for t in accept_types) + has_sse = has_wildcard or any(t in (CONTENT_TYPE_SSE, "text/*") for t in accept_types) + + return has_json, has_sse + + @dataclass class EventMessage: """A JSONRPCMessage with an optional event ID for stream resumability.""" @@ -415,23 +433,6 @@ async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> No else: await self._handle_unsupported_request(request, send) - def _check_accept_headers(self, request: Request) -> tuple[bool, bool]: - """Check if the request accepts the required media types. - - Supports wildcard media types per RFC 7231, section 5.3.2: - - */* matches any media type - - application/* matches any application/ subtype - - text/* matches any text/ subtype - """ - accept_header = request.headers.get("accept", "") - accept_types = [media_type.strip().split(";")[0].strip().lower() for media_type in accept_header.split(",")] - - has_wildcard = "*/*" in accept_types - has_json = has_wildcard or any(t in (CONTENT_TYPE_JSON, "application/*") for t in accept_types) - has_sse = has_wildcard or any(t in (CONTENT_TYPE_SSE, "text/*") for t in accept_types) - - return has_json, has_sse - def _check_content_type(self, request: Request) -> bool: """Check if the request has the correct Content-Type.""" content_type = request.headers.get("content-type", "") @@ -441,7 +442,7 @@ def _check_content_type(self, request: Request) -> bool: async def _validate_accept_header(self, request: Request, scope: Scope, send: Send) -> bool: """Validate Accept header based on response mode. Returns True if valid.""" - has_json, has_sse = self._check_accept_headers(request) + has_json, has_sse = check_accept_headers(request) if self.is_json_response_enabled: # For JSON-only responses, only require application/json if not has_json: @@ -661,7 +662,7 @@ async def _handle_get_request(self, request: Request, send: Send) -> None: raise ValueError("No read stream writer available. Ensure connect() is called first.") # Validate Accept header - must include text/event-stream - _, has_sse = self._check_accept_headers(request) + _, has_sse = check_accept_headers(request) if not has_sse: response = self._create_error_response( diff --git a/src/mcp/server/streamable_http_manager.py b/src/mcp/server/streamable_http_manager.py index 578639853a..60b0989611 100644 --- a/src/mcp/server/streamable_http_manager.py +++ b/src/mcp/server/streamable_http_manager.py @@ -170,7 +170,9 @@ async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> No 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 HANDSHAKE_PROTOCOL_VERSIONS: - await handle_modern_request(self.app, self.security_settings, self._lifespan_state, scope, receive, send) + await handle_modern_request( + self.app, self.security_settings, self.json_response, self._lifespan_state, scope, receive, send + ) return # Dispatch to the appropriate handler diff --git a/src/mcp/shared/jsonrpc_dispatcher.py b/src/mcp/shared/jsonrpc_dispatcher.py index 2e784e2c20..64fcd3298d 100644 --- a/src/mcp/shared/jsonrpc_dispatcher.py +++ b/src/mcp/shared/jsonrpc_dispatcher.py @@ -49,7 +49,7 @@ ) from mcp.shared.transport_context import TransportContext -__all__ = ["JSONRPCDispatcher", "handler_exception_to_error_data"] +__all__ = ["JSONRPCDispatcher", "handler_exception_to_error_data", "progress_token_from_params"] logger = logging.getLogger(__name__) @@ -84,6 +84,15 @@ def handler_exception_to_error_data(exc: BaseException) -> ErrorData | None: return None +def progress_token_from_params(params: Mapping[str, Any] | None) -> ProgressToken | None: + """Read `params._meta.progressToken`; reject bool (bool subclasses int, so True would alias 1).""" + match params: + case {"_meta": {"progressToken": str() | int() as token}} if not isinstance(token, bool): + return token + case _: + return None + + def _coerce_id(request_id: RequestId) -> RequestId: """Coerce a stringified int request ID back to int so a peer-echoed ID still correlates (matches the TS SDK).""" if isinstance(request_id, str): @@ -515,13 +524,7 @@ async def _dispatch_request( on_request: OnRequest, sender_ctx: contextvars.Context | None, ) -> None: - progress_token: ProgressToken | None - match req.params: - # bool subclasses int: without the guard True would alias request id 1. - case {"_meta": {"progressToken": str() | int() as progress_token}} if not isinstance(progress_token, bool): - pass - case _: - progress_token = None + progress_token = progress_token_from_params(req.params) try: transport_ctx = self._transport_builder(metadata) except Exception: diff --git a/tests/examples/conftest.py b/tests/examples/conftest.py index ffe22caad8..48c5bffa5c 100644 --- a/tests/examples/conftest.py +++ b/tests/examples/conftest.py @@ -99,7 +99,7 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: 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")) + marks.append(pytest.mark.xfail(strict=True, reason="manifest xfail")) # pragma: lax no cover params.append(pytest.param(leg, marks=marks, id=leg.id)) metafunc.parametrize("leg", params) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index af6289ae70..d376f0b9f0 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -93,12 +93,6 @@ "unimplemented." ) -_MODERN_NOTIFY_DROP = ( - "The modern single-exchange dispatch context no-ops notify() on the streamable-http driver; " - "handler-emitted logging/progress notifications never reach the per-request SSE response. " - "Passes once SSE response mode lands." -) - @dataclass(frozen=True, kw_only=True) class Divergence: @@ -656,9 +650,6 @@ 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", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None), - ), ), "protocol:progress:token-injected": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow", @@ -676,9 +667,6 @@ def __post_init__(self) -> None: "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", @@ -691,9 +679,6 @@ 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", 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", @@ -831,9 +816,6 @@ 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", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None), - ), ), "tools:call:progress": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow", @@ -841,9 +823,6 @@ 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", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None), - ), ), "tools:call:sampling-roundtrip": Requirement( source=f"{SPEC_BASE_URL}/client/sampling#creating-messages", @@ -1064,18 +1043,12 @@ 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", 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", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None), - ), ), "mcpserver:context:elicit": Requirement( source="sdk", @@ -1433,9 +1406,6 @@ 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", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None), - ), ), "logging:message:fields": Requirement( source=f"{SPEC_BASE_URL}/server/utilities/logging#log-message-notifications", @@ -1443,9 +1413,6 @@ 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", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None), - ), ), "logging:message:filtered": Requirement( source=f"{SPEC_BASE_URL}/server/utilities/logging#setting-log-level", @@ -2065,17 +2032,6 @@ def __post_init__(self) -> None: "client cannot learn that the set changed without polling." ), ), - 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 - ), - issue=None, - ), - ), ), # ═══════════════════════════════════════════════════════════════════════════ # Pagination diff --git a/tests/server/test_streamable_http_modern.py b/tests/server/test_streamable_http_modern.py index 0ba61cf391..6e8df458d1 100644 --- a/tests/server/test_streamable_http_modern.py +++ b/tests/server/test_streamable_http_modern.py @@ -6,6 +6,7 @@ ``handle_modern_request``. """ +import json import logging from typing import Any @@ -26,11 +27,14 @@ JSONRPCError, JSONRPCResponse, ListToolsResult, + LoggingMessageNotification, + LoggingMessageNotificationParams, PaginatedRequestParams, Tool, ) from mcp_types.version import LATEST_MODERN_VERSION -from starlette.types import Receive, Scope, Send +from starlette.types import Message, Receive, Scope, Send +from trio.testing import MockClock from mcp.server import Server, ServerRequestContext, runner from mcp.server._streamable_http_modern import ( @@ -42,12 +46,14 @@ from mcp.shared.exceptions import MCPError, NoBackChannelError from mcp.shared.inbound import MCP_METHOD_HEADER, MCP_NAME_HEADER, MCP_PROTOCOL_VERSION_HEADER from mcp.shared.transport_context import TransportContext +from tests.interaction.transports import StreamingASGITransport pytestmark = pytest.mark.anyio async def test_single_exchange_dispatch_context_has_no_back_channel() -> None: - """The per-request dispatch context refuses server-initiated requests and drops notify/progress.""" + """The per-request dispatch context refuses server-initiated requests; without an SSE sink, + notify/progress are no-ops.""" dctx = _SingleExchangeDispatchContext( transport=TransportContext(kind="streamable-http", can_send_request=False), request_id=1, @@ -60,17 +66,24 @@ async def test_single_exchange_dispatch_context_has_no_back_channel() -> None: assert await dctx.progress(0.5, total=1.0, message="half") is None -def _asgi_client(server: Server[Any], security_settings: TransportSecuritySettings | None = None) -> httpx.AsyncClient: +def _asgi_client( + server: Server[Any], + security_settings: TransportSecuritySettings | None = None, + *, + json_response: bool = True, + accept: str = "application/json, text/event-stream", +) -> httpx.AsyncClient: async def app(scope: Scope, receive: Receive, send: Send) -> None: async with server.lifespan(server) as lifespan_state: - await handle_modern_request(server, security_settings, lifespan_state, scope, receive, send) + await handle_modern_request(server, security_settings, json_response, lifespan_state, scope, receive, send) return httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), + transport=StreamingASGITransport(app), base_url="http://testserver", headers={ MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION, "content-type": "application/json", + "accept": accept, }, ) @@ -301,3 +314,336 @@ async def test_handle_modern_request_rejects_mismatched_name_header_with_400_and ) assert response.status_code == 400 assert response.json()["error"]["code"] == HEADER_MISMATCH + + +# --- SSE response mode --------------------------------------------------------- + + +def _sse_payloads(body: str) -> list[dict[str, Any]]: + """Parse an SSE body into the list of JSON `data:` payloads, in delivery order.""" + return [ + json.loads(line.removeprefix("data:").strip()) + for line in body.replace("\r\n", "\n").splitlines() + if line.startswith("data:") + ] + + +def _list_tools_body_with_token(token: str | int) -> dict[str, Any]: + body = _list_tools_body() + body["params"]["_meta"]["progressToken"] = token + return body + + +async def test_sse_mode_streams_progress_then_result() -> None: + """SSE mode: a handler's `report_progress` calls stream as `notifications/progress` events + (carrying the request's progressToken) before the terminal JSON-RPC response event. + + Spec-mandated: `notifications/progress` carries the caller's token; the per-request SSE stream + closes after the terminal response. Asserted at the wire because Content-Type and event order + are the contract. + """ + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + await ctx.session.report_progress(1.0, total=3.0) + await ctx.session.report_progress(2.0, total=3.0, message="almost") + return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public") + + async with _asgi_client(Server("test", on_list_tools=list_tools), json_response=False) as http: + with anyio.fail_after(5): + response = await http.post( + "/mcp", json=_list_tools_body_with_token("tok-1"), headers={MCP_METHOD_HEADER: "tools/list"} + ) + + assert response.status_code == 200 + assert response.headers["content-type"].split(";", 1)[0] == "text/event-stream" + events = _sse_payloads(response.text) + assert len(events) == 3 + assert events[0] == { + "jsonrpc": "2.0", + "method": "notifications/progress", + "params": {"progressToken": "tok-1", "progress": 1.0, "total": 3.0}, + } + assert events[1] == { + "jsonrpc": "2.0", + "method": "notifications/progress", + "params": {"progressToken": "tok-1", "progress": 2.0, "total": 3.0, "message": "almost"}, + } + assert events[2]["id"] == 1 + assert events[2]["result"]["tools"] == [] + + +@pytest.mark.parametrize( + "anyio_backend", + [pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")], +) +async def test_sse_mode_emits_keepalive_comment_between_events(monkeypatch: pytest.MonkeyPatch) -> None: + """SSE mode: while the stream is idle between events the server emits an SSE comment line so a + proxy idle-read timeout does not close the stream (which would cancel the handler). + SDK-defined: spec encourages keepalive comments for long-lived streams. + + Runs on trio's autojumping MockClock so the `move_on_after(_SSE_PING_INTERVAL)` deadlines and + the handler's `anyio.sleep` advance without wall-clock time.""" + monkeypatch.setattr("mcp.server._streamable_http_modern._SSE_PING_INTERVAL", 1.0) + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + await ctx.session.report_progress(1.0) + await anyio.sleep(2.5) + return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public") + + async with _asgi_client(Server("test", on_list_tools=list_tools), json_response=False) as http: + with anyio.fail_after(5): + response = await http.post( + "/mcp", json=_list_tools_body_with_token("tok"), headers={MCP_METHOD_HEADER: "tools/list"} + ) + + assert response.headers["content-type"].split(";", 1)[0] == "text/event-stream" + assert response.content.count(b": ping\r\n\r\n") == 2 + events = _sse_payloads(response.text) + assert len(events) == 2 + assert events[0]["method"] == "notifications/progress" + assert events[1]["result"]["tools"] == [] + + +@pytest.mark.parametrize( + "anyio_backend", + [pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")], +) +async def test_sse_mode_silent_handler_commits_sse_after_ping_interval(monkeypatch: pytest.MonkeyPatch) -> None: + """SSE mode: a handler that runs silent past the deferral window commits `text/event-stream` + and starts pinging — even though it never emits a notification — so a proxy idle-read timeout + does not close the connection and cancel it. SDK-defined: the deferral window is bounded by + `_SSE_PING_INTERVAL`. + + Runs on trio's autojumping MockClock; the 2.5s handler sleep takes no wall-clock time.""" + monkeypatch.setattr("mcp.server._streamable_http_modern._SSE_PING_INTERVAL", 1.0) + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + await anyio.sleep(2.5) + return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public") + + async with _asgi_client(Server("test", on_list_tools=list_tools), json_response=False) as http: + with anyio.fail_after(5): + response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "tools/list"}) + + assert response.status_code == 200 + assert response.headers["content-type"].split(";", 1)[0] == "text/event-stream" + assert response.content.count(b": ping\r\n\r\n") == 2 + events = _sse_payloads(response.text) + assert len(events) == 1 + assert events[0]["result"]["tools"] == [] + + +async def test_sse_mode_streams_log_notification() -> None: + """SSE mode: a request-scoped `notifications/message` emitted by the handler precedes the + terminal response on the same stream. SDK-defined: notifications sent on the request's outbound + channel reach the per-request SSE response.""" + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + await ctx.session.send_notification( + LoggingMessageNotification(params=LoggingMessageNotificationParams(level="info", data="hello")), + related_request_id=ctx.request_id, + ) + return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public") + + async with _asgi_client(Server("test", on_list_tools=list_tools), json_response=False) as http: + with anyio.fail_after(5): + response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "tools/list"}) + + assert response.headers["content-type"].split(";", 1)[0] == "text/event-stream" + events = _sse_payloads(response.text) + assert len(events) == 2 + assert events[0]["method"] == "notifications/message" + assert events[0]["params"] == {"level": "info", "data": "hello"} + assert events[1]["result"]["tools"] == [] + + +async def test_json_mode_drops_progress() -> None: + """JSON mode: `report_progress` is a no-op (no sink); the response is a plain + `application/json` body carrying only the terminal result. SDK-defined.""" + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + await ctx.session.report_progress(1, total=2) + return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public") + + async with _asgi_client(Server("test", on_list_tools=list_tools), json_response=True) as http: + response = await http.post( + "/mcp", json=_list_tools_body_with_token("tok"), headers={MCP_METHOD_HEADER: "tools/list"} + ) + + assert response.headers["content-type"].split(";", 1)[0] == "application/json" + body = response.json() + assert body["id"] == 1 + assert body["result"]["tools"] == [] + assert "notifications/progress" not in response.text + + +async def test_sse_mode_error_before_any_notify_is_json_with_mapped_status() -> None: + """SSE mode: an error raised before the handler emits any notification is written as + `application/json` with the table-mapped HTTP status — SSE has not committed yet. + Spec-mandated: METHOD_NOT_FOUND MUST be `404 Not Found`.""" + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + raise MCPError(code=METHOD_NOT_FOUND, message="nope") + + async with _asgi_client(Server("test", on_list_tools=list_tools), json_response=False) as http: + with anyio.fail_after(5): + response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "tools/list"}) + + assert response.status_code == 404 + assert response.headers["content-type"].split(";", 1)[0] == "application/json" + assert response.json() == {"jsonrpc": "2.0", "id": 1, "error": {"code": METHOD_NOT_FOUND, "message": "nope"}} + + +async def test_sse_mode_error_after_notify_is_sse_event() -> None: + """SSE mode: an error raised after the handler has emitted is delivered as the terminal SSE + event (HTTP 200) — `text/event-stream` headers were committed on the first notification.""" + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + await ctx.session.report_progress(1.0) + raise MCPError(code=INTERNAL_ERROR, message="boom") + + async with _asgi_client(Server("test", on_list_tools=list_tools), json_response=False) as http: + with anyio.fail_after(5): + response = await http.post( + "/mcp", json=_list_tools_body_with_token("tok"), headers={MCP_METHOD_HEADER: "tools/list"} + ) + + assert response.status_code == 200 + assert response.headers["content-type"].split(";", 1)[0] == "text/event-stream" + events = _sse_payloads(response.text) + assert len(events) == 2 + assert events[0]["method"] == "notifications/progress" + assert events[1] == {"jsonrpc": "2.0", "id": 1, "error": {"code": INTERNAL_ERROR, "message": "boom"}} + + +async def test_sse_mode_no_notify_response_is_json() -> None: + """SSE mode: a handler that emits nothing (here `report_progress` is a no-op because no + `progressToken` was supplied) gets a plain `application/json` response. SDK-defined: SSE only + commits once there is something to stream.""" + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + await ctx.session.report_progress(1, total=2) + return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public") + + async with _asgi_client(Server("test", on_list_tools=list_tools), json_response=False) as http: + with anyio.fail_after(5): + response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "tools/list"}) + + assert response.status_code == 200 + assert response.headers["content-type"].split(";", 1)[0] == "application/json" + assert response.json()["result"]["tools"] == [] + + +async def test_accept_missing_sse_406_in_sse_mode() -> None: + """SDK-defined: in SSE mode the client must accept both `application/json` and + `text/event-stream`; an Accept header naming only JSON is rejected at HTTP 406 before any + JSON-RPC parsing.""" + async with _asgi_client(Server("test"), json_response=False, accept="application/json") as http: + response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "tools/list"}) + assert response.status_code == 406 + assert response.content == b"" + + +async def test_accept_missing_sse_ok_in_json_mode() -> None: + """SDK-defined: in JSON mode only `application/json` need be acceptable; an Accept header that + omits `text/event-stream` still routes (200 + result).""" + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public") + + async with _asgi_client( + Server("test", on_list_tools=list_tools), json_response=True, accept="application/json" + ) as http: + response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "tools/list"}) + assert response.status_code == 200 + assert response.headers["content-type"].split(";", 1)[0] == "application/json" + + +@pytest.mark.parametrize("json_response", [True, False]) +async def test_accept_wildcard_satisfies_both_response_modes(json_response: bool) -> None: + """SDK-defined: `Accept: */*` satisfies both representations (RFC 7231 wildcard) in either + response mode.""" + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public") + + async with _asgi_client( + Server("test", on_list_tools=list_tools), json_response=json_response, accept="*/*" + ) as http: + with anyio.fail_after(5): + response = await http.post("/mcp", json=_list_tools_body(), headers={MCP_METHOD_HEADER: "tools/list"}) + assert response.status_code == 200 + + +async def test_late_notify_after_terminal_dropped() -> None: + """SDK-defined: a `notify()` after the SSE sink has closed is silently dropped — the closed + stream must not propagate as an exception out of the dispatch context.""" + send_ch, recv_ch = anyio.create_memory_object_stream[bytes](0) + dctx = _SingleExchangeDispatchContext( + transport=TransportContext(kind="streamable-http", can_send_request=False), + request_id=1, + message_metadata=None, + sink=send_ch, + ) + await recv_ch.aclose() + # Neither raises despite the receiver being gone (BrokenResourceError caught and dropped). + assert await dctx.notify("notifications/message", {"level": "info", "data": "late"}) is None + dctx.progress_token = "tok" + assert await dctx.progress(1.0) is None + await send_ch.aclose() + + +async def test_disconnect_cancels_handler_and_runs_exit_stack() -> None: + """SSE mode: when the client disconnects mid-stream the handler task is cancelled and + `connection.exit_stack` still unwinds. SDK-defined: `serve_one`'s shielded cleanup runs in the + cancellation path so handler-registered teardown is not skipped on disconnect.""" + handler_started = anyio.Event() + cleanup_ran = anyio.Event() + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + ctx.session._connection.exit_stack.callback(cleanup_ran.set) + handler_started.set() + await anyio.Event().wait() + raise AssertionError("unreachable") # pragma: no cover + + server: Server[Any] = Server("test", on_list_tools=list_tools) + body = json.dumps(_list_tools_body()).encode() + scope: Scope = { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": "POST", + "scheme": "http", + "server": ("testserver", 80), + "client": ("127.0.0.1", 1234), + "path": "/mcp", + "raw_path": b"/mcp", + "query_string": b"", + "root_path": "", + "headers": [ + (b"host", b"testserver"), + (b"content-type", b"application/json"), + (b"accept", b"application/json, text/event-stream"), + (MCP_PROTOCOL_VERSION_HEADER.encode(), LATEST_MODERN_VERSION.encode()), + (MCP_METHOD_HEADER.encode(), b"tools/list"), + ], + } + request_delivered = anyio.Event() + + async def receive() -> Message: + # First call delivers the request body; once the handler is parked, deliver disconnect. + if not request_delivered.is_set(): + request_delivered.set() + return {"type": "http.request", "body": body, "more_body": False} + await handler_started.wait() + return {"type": "http.disconnect"} + + async def send(message: Message) -> None: # pragma: no cover + pass + + with anyio.fail_after(5): + async with server.lifespan(server) as lifespan_state: + await handle_modern_request(server, None, False, lifespan_state, scope, receive, send) + await cleanup_ran.wait() + + assert handler_started.is_set() From 24717cc8eb1701880151697c817e6c7e999a3c9e Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:29:17 +0200 Subject: [PATCH 018/100] feat: RFC 6570 URI templates with operator-aware security (#2356) --- docs/advanced/uri-templates.md | 269 ++++ docs/migration.md | 70 ++ docs/tutorial/resources.md | 2 + docs_src/uri_templates/__init__.py | 0 docs_src/uri_templates/tutorial001.py | 43 + docs_src/uri_templates/tutorial002.py | 14 + docs_src/uri_templates/tutorial003.py | 25 + docs_src/uri_templates/tutorial004.py | 28 + docs_src/uri_templates/tutorial005.py | 55 + mkdocs.yml | 1 + src/mcp/__init__.py | 3 + src/mcp/server/mcpserver/__init__.py | 11 +- .../server/mcpserver/resources/__init__.py | 5 +- .../mcpserver/resources/resource_manager.py | 24 +- .../server/mcpserver/resources/templates.py | 130 +- src/mcp/server/mcpserver/server.py | 88 +- src/mcp/shared/path_security.py | 176 +++ src/mcp/shared/uri_template.py | 1116 +++++++++++++++++ tests/docs_src/test_uri_templates.py | 214 ++++ .../resources/test_resource_template.py | 146 +++ tests/server/mcpserver/test_server.py | 257 +++- tests/shared/test_path_security.py | 159 +++ tests/shared/test_uri_template.py | 1001 +++++++++++++++ 23 files changed, 3800 insertions(+), 37 deletions(-) create mode 100644 docs/advanced/uri-templates.md create mode 100644 docs_src/uri_templates/__init__.py create mode 100644 docs_src/uri_templates/tutorial001.py create mode 100644 docs_src/uri_templates/tutorial002.py create mode 100644 docs_src/uri_templates/tutorial003.py create mode 100644 docs_src/uri_templates/tutorial004.py create mode 100644 docs_src/uri_templates/tutorial005.py create mode 100644 src/mcp/shared/path_security.py create mode 100644 src/mcp/shared/uri_template.py create mode 100644 tests/docs_src/test_uri_templates.py create mode 100644 tests/shared/test_path_security.py create mode 100644 tests/shared/test_uri_template.py diff --git a/docs/advanced/uri-templates.md b/docs/advanced/uri-templates.md new file mode 100644 index 0000000000..32560f8ecd --- /dev/null +++ b/docs/advanced/uri-templates.md @@ -0,0 +1,269 @@ +# URI templates and path safety + +This is the reference for the URI-template syntax that +[`@mcp.resource`](../tutorial/resources.md) accepts, and for the +path-safety policy the SDK applies to extracted values. For an +introduction to what resources are and when to use them, start with +**Resources**; this page assumes you're already comfortable declaring a +resource and want the full operator set, the security knobs, or the +low-level wiring. + +The template syntax is [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570). +The SDK supports a subset chosen for matching incoming `resources/read` +URIs, plus a security layer that rejects values that would resolve +outside the directory you intend to serve. For the protocol-level +details (message formats, lifecycle, pagination) see the +[MCP resources specification](https://modelcontextprotocol.io/specification/latest/server/resources). + +## The full operator set + +**Resources** showed one placeholder, `{user_id}`. There are four more +operator forms; here they are on one server so you can see them next to +each other: + +```python title="server.py" hl_lines="16-17 22-23 28-29 34-35 40-41" +--8<-- "docs_src/uri_templates/tutorial001.py" +``` + +Each highlighted decorator is a different way of carving up the URI. +The sections below walk them top to bottom. + +### Simple expansion: `{name}` + +`books://{isbn}` is the form you already know. The placeholder maps to +the `isbn` parameter, so a client reading `books://978-0441172719` calls +`get_book("978-0441172719")`. + +A plain `{name}` stops at the first `/`. `books://978/extra` does not +match because the slash after `978` ends the capture and `/extra` is +left over. + +### Type conversion + +Extracted values arrive as strings, but you can declare a more specific +type and the SDK will convert. `orders://{order_id}` lands in a function +whose parameter is `order_id: int`, so reading `orders://12345` calls +`get_order(12345)`, not `get_order("12345")`. The handler does +arithmetic on it (`order_id + 1`) without a cast. + +### Multi-segment paths: `{+name}` + +To capture a value that contains slashes, use `{+name}`. With +`manuals://{+path}`: + +* `manuals://returns.md` gives `path = "returns.md"` +* `manuals://printing/setup.md` gives `path = "printing/setup.md"` + +Reach for `{+name}` whenever the value is hierarchical: filesystem +paths, nested object keys, URL paths you're proxying. + +### Query parameters: `{?a,b,c}` + +`reviews://{isbn}{?limit,sort}` puts `limit` and `sort` after the `?`. +The path identifies *which* book; the query tunes *how* you read it. + +Query params are matched leniently: order doesn't matter, extras are +ignored, and omitted params fall through to your function defaults. So +`reviews://978-0441172719` uses `limit=10, sort="newest"`, and +`reviews://978-0441172719?sort=top` overrides only `sort`. + +### Path segments as a list: `{/name*}` + +If you want each path segment as a separate list item rather than one +string with slashes, use `{/name*}`. With `shelves://browse{/path*}`, a +client reading `shelves://browse/fiction/sci-fi` calls +`browse_shelf(["fiction", "sci-fi"])`. + +### Template reference + +The most common patterns: + +| Pattern | Example input | You get | +|--------------|-----------------------|-------------------------| +| `{name}` | `alice` | `"alice"` | +| `{name}` | `docs/intro.md` | *no match* (stops at `/`) | +| `{+path}` | `docs/intro.md` | `"docs/intro.md"` | +| `{.ext}` | `.json` | `"json"` | +| `{/segment}` | `/v2` | `"v2"` | +| `{?key}` | `?key=value` | `"value"` | +| `{?a,b}` | `?a=1&b=2` | `"1"`, `"2"` | +| `{/path*}` | `/a/b/c` | `["a", "b", "c"]` | + +### What the parser rejects + +A few template shapes are caught up front rather than failing on the +first request. `@mcp.resource` parses the template when the decorator +runs, so none of these ever reach a running server. + +`UriTemplate.parse()` raises `InvalidUriTemplate` for: + +* **Two variables with nothing between them.** `manuals://{+path}{ext}` + is rejected: matching can't tell where `path` ends and `ext` begins. + Put a literal between them (`manuals://{+path}/{ext}`), or use an + operator that supplies its own delimiter. `manuals://{+path}{.ext}` + is accepted because `{.ext}` contributes the `.` itself. +* **More than one multi-segment variable.** At most one of `{+var}`, + `{#var}`, or an exploded variable (`{/var*}`, `{.var*}`, `{;var*}`) + per template. Two are inherently ambiguous: there is no principled + way to decide which one absorbs an extra segment. +* **The usual syntax errors**: an unclosed brace, a variable name used + twice, or an RFC 6570 feature the SDK doesn't support, such as the + `{var:3}` prefix modifier or the `{?vars*}` query explode. + +On top of that, `@mcp.resource` raises `ValueError` when a handler +parameter is bound to a query variable in the template's trailing +`{?...}`/`{&...}` run but has no Python default. Those variables are +matched leniently (a client may leave any of them out), so a parameter +without a default would only surface as an opaque internal error on the +first request that omits it. `reviews://{isbn}{?limit,sort}` in the +server above is the well-formed version: `limit` and `sort` both carry +defaults. + +## Security + +Template parameters come from the client. If they flow into filesystem +or database operations unchecked, values like `../../etc/passwd` can +resolve outside the directory you intended to serve. + +### What the SDK checks by default + +Before your handler runs, the SDK rejects any parameter that: + +* would escape its starting directory via `..` components +* looks like an absolute path (`/etc/passwd`, `C:\Windows`) or a + Windows drive-relative one (`C:foo`). A drive-relative value and a + namespaced identifier like `x:y` are indistinguishable as strings, + so any single-letter-plus-colon value is rejected by default; + exempt the parameter if it legitimately receives such values +* contains a null byte (`\x00`) + +The `..` check is component-based, not a substring scan. Values like +`v1.0..v2.0` or `HEAD~3..HEAD` pass because `..` is not a standalone +path segment there. + +These checks apply to the decoded value, so they catch traversal +regardless of how it was encoded in the URI (`../etc`, `..%2Fetc`, +`%2E%2E/etc`, `..%5Cetc`, `%00` all get caught). + +!!! check + Read `manuals://../etc/passwd` from the server above and the request + is rejected outright: template matching stops at the first failure, + so no later (potentially more permissive) template is tried as a + fallback. The client sees the same `-32602` "Unknown resource" error + it would for a URI that matches no template at all, and + `read_manual` never runs. + +### Filesystem handlers: use safe_join + +The built-in checks stop the common cases but can't know your sandbox +boundary. For filesystem access, use `safe_join` to resolve the path +and verify it stays inside your base directory: + +```python title="server.py" hl_lines="4 14" +--8<-- "docs_src/uri_templates/tutorial002.py" +``` + +`safe_join` catches symlink escapes, `..` sequences, and absolute-path +tricks that a simple string check would miss. If the resolved path +escapes `DOCS_ROOT`, it raises `PathEscapeError`, which surfaces to the +client as a `ResourceError`. + +### When the defaults get in the way + +Sometimes the checks block legitimate values. A catalog-import tool +might intentionally receive an absolute path, or a parameter might be a +relative reference like `../sibling` that your handler interprets +safely without touching the filesystem. Exempt that parameter, or relax +the policy for the whole server: + +```python title="server.py" hl_lines="9 16-19" +--8<-- "docs_src/uri_templates/tutorial003.py" +``` + +* `security=ResourceSecurity(exempt_params={"source"})` on the decorator + skips the checks for that one parameter on that one resource. The + rest of the server keeps the default policy. +* `resource_security=` on the `MCPServer` constructor sets the default + for every resource. Here `relaxed` turns off the `..` check entirely. + +The configurable checks: + +| Setting | Default | What it does | +|-------------------------|---------|-------------------------------------| +| `reject_path_traversal` | `True` | Rejects `..` sequences that escape the starting directory | +| `reject_absolute_paths` | `True` | Rejects `/foo`, `C:\foo`, UNC paths, and drive-relative `C:foo` (also catches `x:y`) | +| `reject_null_bytes` | `True` | Rejects values containing `\x00` | +| `exempt_params` | empty | Parameter names to skip checks for | + +These checks are a heuristic pre-filter; for filesystem access, +`safe_join` remains the containment boundary. + +!!! tip + If your handler can't fulfil the request (the file doesn't exist, + the id is unknown), raise an exception. The SDK turns it into an + error response. See **Handling errors** for the difference between a + protocol error and a tool error. + +## Resources on the low-level Server + +If you're building on the low-level `Server` (see **The low-level +Server**), you register handlers for the `resources/list` and +`resources/read` protocol methods directly. There's no decorator; you +return the protocol types yourself. + +### Static resources + +For fixed URIs, keep a registry and dispatch on exact match: + +```python title="server.py" hl_lines="18 22 28" +--8<-- "docs_src/uri_templates/tutorial004.py" +``` + +The list handler tells clients what's available; the read handler +serves the content. Check your registry first, fall through to +templates (below) if you have any, then raise for anything else. + +### Templates + +The template engine `MCPServer` uses lives in `mcp.shared.uri_template` +and works on its own. You get the same parsing and matching; you wire +up the routing and security policy yourself. + +```python title="server.py" hl_lines="14-17 23-26 30 34 46" +--8<-- "docs_src/uri_templates/tutorial005.py" +``` + +Three things are happening in the highlighted lines: + +* **Parse once, match per request.** `UriTemplate.parse()` builds the + template; `template.match(uri)` returns the extracted variables as a + `dict`, or `None` if the URI doesn't fit. URL decoding happens inside + `match()`; the decoded values are returned as-is without path-safety + validation. Values come out as strings: convert them yourself + (`int(matched["id"])`, `Path(matched["path"])`). +* **Apply the safety checks yourself.** The `..` and absolute-path + checks `MCPServer` runs by default live in `mcp.shared.path_security`. + `read_manual_safely` calls them before touching `MANUALS`. If a + parameter isn't a filesystem path (an ISBN, a search query), skip the + checks for that value: you control the policy per handler rather than + through a config object. +* **List the templates from the same source.** Clients discover + templates through `resources/templates/list`. `str(template)` gives + back the original template string, so the listing and the matcher + share one source of truth. + +## Recap + +* `{name}` matches one segment; `{+name}` keeps the slashes; `{?a,b}` + pulls from the query string; `{/name*}` splits segments into a list. +* Two variables with nothing between them, or a second multi-segment + variable, are rejected at parse time. A parameter bound to a trailing + `{?...}`/`{&...}` query variable must declare a Python default. +* Annotate the parameter (`order_id: int`) and the SDK converts. +* The default security policy rejects `..`, absolute paths, and null + bytes before your handler runs; override per resource with + `security=ResourceSecurity(...)` or server-wide with + `resource_security=`. +* For filesystem access, `safe_join` is the containment boundary. +* On the low-level `Server`, parse with `UriTemplate.parse()`, match + with `.match()`, and apply `mcp.shared.path_security` yourself. diff --git a/docs/migration.md b/docs/migration.md index e987b626c6..42d420bf04 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -609,6 +609,76 @@ Reading a missing resource now returns JSON-RPC error code `-32602` (invalid par The underlying lookups now raise typed exceptions instead of `ValueError`. `ResourceManager.get_resource()` raises `ResourceNotFoundError` when no resource or template matches the URI, and `ResourceTemplate.create_resource()` raises `ResourceError` when the template function fails. Neither subclasses `ValueError`, so callers catching `ValueError` should switch to `ResourceNotFoundError` / `ResourceError` (both importable from `mcp.server.mcpserver.exceptions`; `ResourceNotFoundError` subclasses `ResourceError`). +### Resource templates: matching behavior changes + +Resource template matching has been rewritten with RFC 6570 support. +Several behaviors have changed: + +**Path-safety checks applied by default.** Extracted parameter values +containing `..` as a path component, a null byte, or looking like an +absolute path (`/etc/passwd`, `C:\Windows`) now cause the read to +fail — the client receives an "Unknown resource" error and template +iteration stops, so a strict template's rejection does not fall +through to a later permissive template. This is checked on the +decoded value, so `..%2Fetc`, `%2E%2E`, and `%00` are caught too. +Note that `..` is only flagged as a standalone path component, so +values like `v1.0..v2.0` or `HEAD~3..HEAD` are unaffected. + +If a parameter legitimately needs to receive absolute paths or +traversal sequences, exempt it: + +```python +from mcp.server.mcpserver import ResourceSecurity + +@mcp.resource( + "inspect://file/{+target}", + security=ResourceSecurity(exempt_params={"target"}), +) +def inspect_file(target: str) -> str: ... +``` + +**Template literals and structural delimiters match exactly.** The +previous matcher built a regex without escaping, so `.` matched any +character and simple `{var}` swallowed `?`, `#`, `&`, and `,`. Now +`data://v1.0/{id}` no longer matches `data://v1X0/42`, and +`api://{id}` no longer matches `api://foo?x=1` — use `api://{id}{?x}` +to capture the query parameter. + +**`{var}` now matches an empty value.** A simple expression captures +zero or more characters, so `tickets://{ticket_id}` now matches +`tickets://` with `ticket_id=""` (v1.x's `[^/]+` regex required at +least one). This makes `match` round-trip `expand` for empty values — RFC 6570 +expands an empty string to nothing — but handlers that assumed a +non-empty value should validate it explicitly. + +**Template syntax errors surface at decoration time.** Unclosed +braces, duplicate variable names, and unsupported syntax raise +`InvalidUriTemplate` when the decorator runs rather than `re.error` +on first match. Two variables with no literal between them are also +rejected — matching cannot tell where one ends and the next begins — +so `{name}{+path}` raises. Write `{name}/{+path}`, or use an operator +that emits its own delimiter: `{+path}{.ext}` is fine because the `.` +operator contributes a literal `.` between the two. A handler +parameter bound to a query variable in the template's trailing +`{?...}`/`{&...}` run — the variables `match()` treats as optional, +listed by `UriTemplate.query_variable_names` — must declare a Python +default: a client may omit those, so a handler that requires one now +raises `ValueError` when the decorator runs instead of failing on the +first request that leaves it out. (A `{&...}` expression with no +preceding `{?...}` is not in that run: it is matched strictly, may +not be omitted, and needs no default.) + +**Static URIs with Context-only handlers now error.** A non-template +URI paired with a handler that takes only a `Context` parameter +previously registered but was silently unreachable (the resource +could never be read). This now raises `ValueError` at decoration time. +Context injection for static resources is not supported — use a +template with at least one variable or access context through other +means. + +See [URI templates](advanced/uri-templates.md) for the full template syntax, +security configuration, and filesystem safety utilities. + ### Registering lowlevel handlers from `MCPServer` `MCPServer` does not expose public APIs for `subscribe_resource`, `unsubscribe_resource`, or `set_logging_level` handlers. In v1, the workaround was to reach into the private lowlevel server and use its decorator methods: diff --git a/docs/tutorial/resources.md b/docs/tutorial/resources.md index 5cf35503f9..749b8227d6 100644 --- a/docs/tutorial/resources.md +++ b/docs/tutorial/resources.md @@ -92,6 +92,8 @@ Notice the `uri` in the result. It is the **concrete** URI the client asked for, A mismatch can only ever be a bug, so the SDK makes it impossible to start the server with one. +The placeholder syntax is RFC 6570: `{+path}` for multi-segment values, `{?q,lang}` for optional query parameters, and more. The SDK also applies path-safety checks to extracted values by default. See **[URI templates and path safety](../advanced/uri-templates.md)** for the full reference. + `get_user_profile` can also take a parameter annotated `Context`. The SDK injects it without ever treating it as a URI parameter, and **The Context** chapter covers what it gives you. ## What you return diff --git a/docs_src/uri_templates/__init__.py b/docs_src/uri_templates/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/uri_templates/tutorial001.py b/docs_src/uri_templates/tutorial001.py new file mode 100644 index 0000000000..87685c0e56 --- /dev/null +++ b/docs_src/uri_templates/tutorial001.py @@ -0,0 +1,43 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + +BOOKS = { + "978-0441172719": {"title": "Dune", "author": "Frank Herbert"}, + "978-0553293357": {"title": "Foundation", "author": "Isaac Asimov"}, +} + +MANUALS = { + "printing/setup.md": "# Printer setup\n\nLoad paper, then power on.", + "returns.md": "# Returns policy\n\nThirty days with a receipt.", +} + + +@mcp.resource("books://{isbn}") +def get_book(isbn: str) -> dict[str, str]: + """A single book by ISBN.""" + return BOOKS[isbn] + + +@mcp.resource("orders://{order_id}") +def get_order(order_id: int) -> dict[str, object]: + """An order by its numeric id.""" + return {"order_id": order_id, "next_order": order_id + 1, "status": "shipped"} + + +@mcp.resource("manuals://{+path}") +def read_manual(path: str) -> str: + """A staff manual page. The path keeps its slashes.""" + return MANUALS[path] + + +@mcp.resource("reviews://{isbn}{?limit,sort}") +def list_reviews(isbn: str, limit: int = 10, sort: str = "newest") -> str: + """Reviews of a book, optionally limited and sorted.""" + return f"{limit} {sort} reviews of {BOOKS[isbn]['title']}" + + +@mcp.resource("shelves://browse{/path*}") +def browse_shelf(path: list[str]) -> str: + """A shelf in the category tree, addressed by segments.""" + return " > ".join(["catalog", *path]) diff --git a/docs_src/uri_templates/tutorial002.py b/docs_src/uri_templates/tutorial002.py new file mode 100644 index 0000000000..2ad1ec7c1e --- /dev/null +++ b/docs_src/uri_templates/tutorial002.py @@ -0,0 +1,14 @@ +from pathlib import Path + +from mcp.server import MCPServer +from mcp.shared.path_security import safe_join + +mcp = MCPServer("Bookshop") + +DOCS_ROOT = Path("./manuals") + + +@mcp.resource("manuals://{+path}") +def read_manual(path: str) -> str: + """A staff manual page, served from a directory on disk.""" + return safe_join(DOCS_ROOT, path).read_text() diff --git a/docs_src/uri_templates/tutorial003.py b/docs_src/uri_templates/tutorial003.py new file mode 100644 index 0000000000..bc7a59cb3b --- /dev/null +++ b/docs_src/uri_templates/tutorial003.py @@ -0,0 +1,25 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver import ResourceSecurity + +mcp = MCPServer("Bookshop") + + +@mcp.resource( + "imports://preview/{+source}", + security=ResourceSecurity(exempt_params={"source"}), +) +def preview_import(source: str) -> str: + """Preview a catalog import. `source` may be an absolute path.""" + return f"Would import from {source}" + + +relaxed = MCPServer( + "Bookshop", + resource_security=ResourceSecurity(reject_path_traversal=False), +) + + +@relaxed.resource("imports://preview/{+source}") +def preview_import_relaxed(source: str) -> str: + """The server-wide flag exempts every resource on `relaxed`.""" + return f"Would import from {source}" diff --git a/docs_src/uri_templates/tutorial004.py b/docs_src/uri_templates/tutorial004.py new file mode 100644 index 0000000000..c1920b3cc5 --- /dev/null +++ b/docs_src/uri_templates/tutorial004.py @@ -0,0 +1,28 @@ +from mcp_types import ( + ListResourcesResult, + PaginatedRequestParams, + ReadResourceRequestParams, + ReadResourceResult, + Resource, + TextResourceContents, +) + +from mcp.server import Server, ServerRequestContext + +RESOURCES = { + "config://shop": '{"currency": "USD", "tax_rate": 0.08}', + "status://health": "ok", +} + + +async def list_resources(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListResourcesResult: + return ListResourcesResult(resources=[Resource(name=uri, uri=uri) for uri in RESOURCES]) + + +async def read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult: + if (text := RESOURCES.get(params.uri)) is not None: + return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=text)]) + raise ValueError(f"Unknown resource: {params.uri}") + + +server = Server("Bookshop", on_list_resources=list_resources, on_read_resource=read_resource) diff --git a/docs_src/uri_templates/tutorial005.py b/docs_src/uri_templates/tutorial005.py new file mode 100644 index 0000000000..716ff08dc1 --- /dev/null +++ b/docs_src/uri_templates/tutorial005.py @@ -0,0 +1,55 @@ +from mcp_types import ( + ListResourceTemplatesResult, + PaginatedRequestParams, + ReadResourceRequestParams, + ReadResourceResult, + ResourceTemplate, + TextResourceContents, +) + +from mcp.server import Server, ServerRequestContext +from mcp.shared.path_security import contains_path_traversal, is_absolute_path +from mcp.shared.uri_template import UriTemplate + +TEMPLATES = { + "manuals": UriTemplate.parse("manuals://{+path}"), + "books": UriTemplate.parse("books://{isbn}"), +} + +MANUALS = {"printing/setup.md": "# Printer setup", "returns.md": "# Returns policy"} +BOOKS = {"978-0441172719": "Dune by Frank Herbert"} + + +def read_manual_safely(path: str) -> str: + if contains_path_traversal(path) or is_absolute_path(path): + raise ValueError("rejected") + return MANUALS[path] + + +async def read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult: + if (matched := TEMPLATES["manuals"].match(params.uri)) is not None: + text = read_manual_safely(str(matched["path"])) + return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=text)]) + + if (matched := TEMPLATES["books"].match(params.uri)) is not None: + text = BOOKS[str(matched["isbn"])] + return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=text)]) + + raise ValueError(f"Unknown resource: {params.uri}") + + +async def list_resource_templates( + ctx: ServerRequestContext, params: PaginatedRequestParams | None +) -> ListResourceTemplatesResult: + return ListResourceTemplatesResult( + resource_templates=[ + ResourceTemplate(name=name, uri_template=str(template)) for name, template in TEMPLATES.items() + ] + ) + + +server = Server( + "Bookshop", + on_read_resource=read_resource, + on_list_resource_templates=list_resource_templates, +) diff --git a/mkdocs.yml b/mkdocs.yml index af32c74ab6..93127a410a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -40,6 +40,7 @@ nav: - Advanced: - Multi-round-trip requests: advanced/multi-round-trip.md - The low-level Server: advanced/low-level-server.md + - URI templates: advanced/uri-templates.md - Pagination: advanced/pagination.md - Middleware: advanced/middleware.md - OpenTelemetry: advanced/opentelemetry.md diff --git a/src/mcp/__init__.py b/src/mcp/__init__.py index 49bb494f94..085e445d4a 100644 --- a/src/mcp/__init__.py +++ b/src/mcp/__init__.py @@ -66,6 +66,7 @@ from .server.session import ServerSession from .server.stdio import stdio_server from .shared.exceptions import MCPDeprecationWarning, MCPError, UrlElicitationRequiredError +from .shared.uri_template import InvalidUriTemplate, UriTemplate __all__ = [ "CallToolRequest", @@ -133,7 +134,9 @@ "ToolsCapability", "ToolUseContent", "UnsubscribeRequest", + "UriTemplate", "UrlElicitationRequiredError", + "InvalidUriTemplate", "stdio_client", "stdio_server", ] diff --git a/src/mcp/server/mcpserver/__init__.py b/src/mcp/server/mcpserver/__init__.py index e36a7ae7d6..741f16beb1 100644 --- a/src/mcp/server/mcpserver/__init__.py +++ b/src/mcp/server/mcpserver/__init__.py @@ -3,7 +3,16 @@ from mcp_types import Icon from .context import Context +from .resources import DEFAULT_RESOURCE_SECURITY, ResourceSecurity from .server import MCPServer from .utilities.types import Audio, Image -__all__ = ["MCPServer", "Context", "Image", "Audio", "Icon"] +__all__ = [ + "MCPServer", + "Context", + "Image", + "Audio", + "Icon", + "ResourceSecurity", + "DEFAULT_RESOURCE_SECURITY", +] diff --git a/src/mcp/server/mcpserver/resources/__init__.py b/src/mcp/server/mcpserver/resources/__init__.py index b5805fb348..f54ea44e42 100644 --- a/src/mcp/server/mcpserver/resources/__init__.py +++ b/src/mcp/server/mcpserver/resources/__init__.py @@ -1,6 +1,6 @@ from .base import Resource from .resource_manager import ResourceManager -from .templates import ResourceTemplate +from .templates import DEFAULT_RESOURCE_SECURITY, ResourceSecurity, ResourceSecurityError, ResourceTemplate from .types import ( BinaryResource, DirectoryResource, @@ -20,4 +20,7 @@ "DirectoryResource", "ResourceTemplate", "ResourceManager", + "ResourceSecurity", + "ResourceSecurityError", + "DEFAULT_RESOURCE_SECURITY", ] diff --git a/src/mcp/server/mcpserver/resources/resource_manager.py b/src/mcp/server/mcpserver/resources/resource_manager.py index 54f54549eb..41d3d7bb37 100644 --- a/src/mcp/server/mcpserver/resources/resource_manager.py +++ b/src/mcp/server/mcpserver/resources/resource_manager.py @@ -10,7 +10,12 @@ from mcp.server.mcpserver.exceptions import ResourceNotFoundError from mcp.server.mcpserver.resources.base import Resource -from mcp.server.mcpserver.resources.templates import ResourceTemplate +from mcp.server.mcpserver.resources.templates import ( + DEFAULT_RESOURCE_SECURITY, + ResourceSecurity, + ResourceSecurityError, + ResourceTemplate, +) from mcp.server.mcpserver.utilities.logging import get_logger if TYPE_CHECKING: @@ -63,6 +68,7 @@ def add_template( icons: list[Icon] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, + security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY, ) -> ResourceTemplate: """Add a template from a function.""" template = ResourceTemplate.from_function( @@ -75,6 +81,7 @@ def add_template( icons=icons, annotations=annotations, meta=meta, + security=security, ) self._templates[template.uri_template] = template return template @@ -85,6 +92,15 @@ async def get_resource(self, uri: AnyUrl | str, context: Context[LifespanContext Raises: ResourceNotFoundError: If no resource or template matches the URI. ResourceError: If a matching template fails to create the resource. + + Note: + Pydantic's ``AnyUrl`` normalises percent-encoding and + resolves ``..`` segments during validation, so a value + constructed as ``AnyUrl("file:///a/%2E%2E/b")`` arrives + here as ``file:///b``. The JSON-RPC protocol layer passes + raw ``str`` values and is unaffected, but internal callers + wrapping URIs in ``AnyUrl`` should be aware that security + checks see the already-normalised form. """ uri_str = str(uri) logger.debug("Getting resource", extra={"uri": uri_str}) @@ -95,7 +111,11 @@ async def get_resource(self, uri: AnyUrl | str, context: Context[LifespanContext # Then check templates for template in self._templates.values(): - if params := template.matches(uri_str): + try: + params = template.matches(uri_str) + except ResourceSecurityError as e: + raise ResourceNotFoundError(f"Unknown resource: {uri}") from e + if params is not None: return await template.create_resource(uri_str, params, context=context) raise ResourceNotFoundError(f"Unknown resource: {uri}") diff --git a/src/mcp/server/mcpserver/resources/templates.py b/src/mcp/server/mcpserver/resources/templates.py index 72707a11ab..f78b5ec666 100644 --- a/src/mcp/server/mcpserver/resources/templates.py +++ b/src/mcp/server/mcpserver/resources/templates.py @@ -3,10 +3,9 @@ from __future__ import annotations import functools -import re -from collections.abc import Callable +from collections.abc import Callable, Mapping, Set +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any -from urllib.parse import unquote import anyio.to_thread from mcp_types import Annotations, Icon @@ -18,6 +17,8 @@ from mcp.server.mcpserver.utilities.func_metadata import func_metadata from mcp.server.mcpserver.utilities.logging import get_logger from mcp.shared._callable_inspection import is_async_callable +from mcp.shared.path_security import contains_path_traversal, is_absolute_path +from mcp.shared.uri_template import UriTemplate logger = get_logger(__name__) @@ -26,6 +27,82 @@ from mcp.server.mcpserver.context import Context +@dataclass(frozen=True) +class ResourceSecurity: + """Security policy applied to extracted resource template parameters. + + These checks run after :meth:`~mcp.shared.uri_template.UriTemplate.match` + has extracted and decoded parameter values. They catch path-traversal + and absolute-path injection regardless of how the value was encoded in + the URI (literal, ``%2F``, ``%5C``, ``%2E%2E``). + + Example:: + + # Opt out for a parameter that legitimately contains .. + @mcp.resource( + "git://diff/{+range}", + security=ResourceSecurity(exempt_params={"range"}), + ) + def git_diff(range: str) -> str: ... + """ + + reject_path_traversal: bool = True + """Reject values containing ``..`` as a path component.""" + + reject_absolute_paths: bool = True + """Reject values that look like absolute filesystem paths.""" + + reject_null_bytes: bool = True + """Reject values containing NUL (``\\x00``). Null bytes defeat string + comparisons (``"..\\x00" != ".."``) and can cause truncation in C + extensions or subprocess calls.""" + + exempt_params: Set[str] = field(default_factory=frozenset[str]) + """Parameter names to skip all checks for.""" + + def validate(self, params: Mapping[str, str | list[str]]) -> str | None: + """Check all parameter values against the configured policy. + + Args: + params: Extracted template parameters. List values (from + explode variables) are checked element-wise. + + Returns: + The name of the first parameter that fails, or ``None`` if + all values pass. + """ + for name, value in params.items(): + if name in self.exempt_params: + continue + values = value if isinstance(value, list) else [value] + for v in values: + if self.reject_null_bytes and "\0" in v: + return name + if self.reject_path_traversal and contains_path_traversal(v): + return name + if self.reject_absolute_paths and is_absolute_path(v): + return name + return None + + +DEFAULT_RESOURCE_SECURITY = ResourceSecurity() +"""Secure-by-default policy: traversal, absolute paths, and null bytes rejected.""" + + +class ResourceSecurityError(ValueError): + """Raised when an extracted parameter fails :class:`ResourceSecurity` checks. + + Distinct from a simple ``None`` non-match so that template + iteration can stop at the first security rejection rather than + falling through to a later, possibly more permissive, template. + """ + + def __init__(self, template: str, param: str) -> None: + super().__init__(f"Parameter {param!r} of template {template!r} failed security validation") + self.template = template + self.param = param + + class ResourceTemplate(BaseModel): """A template for dynamically creating resources.""" @@ -40,6 +117,8 @@ class ResourceTemplate(BaseModel): fn: Callable[..., Any] = Field(exclude=True) parameters: dict[str, Any] = Field(description="JSON schema for function parameters") context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context") + parsed_template: UriTemplate = Field(exclude=True, description="Parsed RFC 6570 template") + security: ResourceSecurity = Field(exclude=True, description="Path-safety policy for extracted parameters") @classmethod def from_function( @@ -54,12 +133,20 @@ def from_function( annotations: Annotations | None = None, meta: dict[str, Any] | None = None, context_kwarg: str | None = None, + security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY, ) -> ResourceTemplate: - """Create a template from a function.""" + """Create a template from a function. + + Raises: + InvalidUriTemplate: If ``uri_template`` is malformed or uses + unsupported RFC 6570 features. + """ func_name = name or fn.__name__ if func_name == "": raise ValueError("You must provide a name for lambda functions") # pragma: no cover + parsed = UriTemplate.parse(uri_template) + # Find context parameter if it exists if context_kwarg is None: # pragma: no branch context_kwarg = find_context_parameter(fn) @@ -86,20 +173,35 @@ def from_function( fn=fn, parameters=parameters, context_kwarg=context_kwarg, + parsed_template=parsed, + security=security, ) - def matches(self, uri: str) -> dict[str, Any] | None: - """Check if URI matches template and extract parameters. + def matches(self, uri: str) -> dict[str, str | list[str]] | None: + """Check if a URI matches this template and extract parameters. + + Delegates to :meth:`UriTemplate.match` for RFC 6570 extraction, + then applies this template's :class:`ResourceSecurity` policy + (path traversal, absolute paths). - Extracted parameters are URL-decoded to handle percent-encoded characters. + Returns: + Extracted parameters on success, or ``None`` if the URI + doesn't match the template. + + Raises: + ResourceSecurityError: If the URI matches but an extracted + parameter fails security validation. Raising (rather + than returning ``None``) prevents the resource manager + from silently falling through to a later, possibly more + permissive, template. """ - # Convert template to regex pattern - pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)") - match = re.match(f"^{pattern}$", uri) - if match: - # URL-decode all extracted parameter values - return {key: unquote(value) for key, value in match.groupdict().items()} - return None + params = self.parsed_template.match(uri) + if params is None: + return None + failed = self.security.validate(params) + if failed is not None: + raise ResourceSecurityError(self.uri_template, failed) + return params async def create_resource( self, diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 855770eda7..029512a780 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -4,7 +4,6 @@ import base64 import inspect -import re from collections.abc import AsyncIterator, Awaitable, Callable, Iterable from contextlib import AbstractAsyncContextManager, asynccontextmanager from typing import Any, Generic, Literal, TypeVar, overload @@ -62,7 +61,13 @@ from mcp.server.mcpserver.context import Context from mcp.server.mcpserver.exceptions import ResourceError, ResourceNotFoundError from mcp.server.mcpserver.prompts import Prompt, PromptManager -from mcp.server.mcpserver.resources import FunctionResource, Resource, ResourceManager +from mcp.server.mcpserver.resources import ( + DEFAULT_RESOURCE_SECURITY, + FunctionResource, + Resource, + ResourceManager, + ResourceSecurity, +) from mcp.server.mcpserver.tools import Tool, ToolManager from mcp.server.mcpserver.utilities.context_injection import find_context_parameter from mcp.server.mcpserver.utilities.logging import configure_logging, get_logger @@ -72,6 +77,7 @@ from mcp.server.streamable_http_manager import StreamableHTTPSessionManager from mcp.server.transport_security import TransportSecuritySettings from mcp.shared.exceptions import MCPError +from mcp.shared.uri_template import UriTemplate logger = get_logger(__name__) @@ -150,7 +156,9 @@ def __init__( dependencies: list[str] | None = None, lifespan: Callable[[MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None = None, auth: AuthSettings | None = None, + resource_security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY, ): + self._resource_security = resource_security self.settings = Settings( debug=debug, log_level=log_level, @@ -622,6 +630,7 @@ def resource( icons: list[Icon] | None = None, annotations: Annotations | None = None, meta: dict[str, Any] | None = None, + security: ResourceSecurity | None = None, ) -> Callable[[_CallableT], _CallableT]: """Decorator to register a function as a resource. @@ -631,8 +640,9 @@ def resource( - bytes for binary content - other types will be converted to JSON - If the URI contains parameters (e.g. "resource://{param}") or the function - has parameters, it will be registered as a template resource. + If the URI contains parameters (e.g. "resource://{param}"), it is + registered as a template resource. Otherwise it is registered as a + static resource; function parameters on a static URI raise an error. Args: uri: URI for the resource (e.g. "resource://my-resource" or "resource://{param}") @@ -643,6 +653,9 @@ def resource( icons: Optional list of icons for the resource annotations: Optional annotations for the resource meta: Optional metadata dictionary for the resource + security: Path-safety policy for extracted template parameters. + Defaults to the server's ``resource_security`` setting. + Only applies to template resources. Example: ```python @@ -664,6 +677,15 @@ async def get_weather(city: str) -> str: data = await fetch_weather(city) return f"Weather for {city}: {data}" ``` + + Raises: + InvalidUriTemplate: If ``uri`` is not a valid RFC 6570 template. + ValueError: If URI template parameters don't match the + function's parameters, or if a parameter bound to a + ``{?...}``/``{&...}`` query variable has no default + (the client may omit it). + TypeError: If the decorator is applied without being called + (``@resource`` instead of ``@resource("uri")``). """ # Check if user passed function directly instead of calling decorator if callable(uri): @@ -672,27 +694,44 @@ async def get_weather(city: str) -> str: "Did you forget to call it? Use @resource('uri') instead of @resource" ) + # Parse once, early — surfaces malformed-template errors at + # decoration time with a clear position, and gives us correct + # variable names for all RFC 6570 operators. + parsed = UriTemplate.parse(uri) + uri_params = set(parsed.variable_names) + def decorator(fn: _CallableT) -> _CallableT: - # Check if this should be a template sig = inspect.signature(fn) - has_uri_params = "{" in uri and "}" in uri - has_func_params = bool(sig.parameters) - - if has_uri_params or has_func_params: - # Check for Context parameter to exclude from validation - context_param = find_context_parameter(fn) - - # Validate that URI params match function params (excluding context) - uri_params = set(re.findall(r"{(\w+)}", uri)) - # We need to remove the context_param from the resource function if - # there is any. - func_params = {p for p in sig.parameters.keys() if p != context_param} + context_param = find_context_parameter(fn) + func_params = {p for p in sig.parameters.keys() if p != context_param} + # Template/static is decided purely by the URI: variables + # present means template, none means static. + if uri_params: if uri_params != func_params: raise ValueError( f"Mismatch between URI parameters {uri_params} and function parameters {func_params}" ) + # A {?...}/{&...} query variable is optional on the wire: + # match() omits it from the extracted parameters when the + # client leaves it out of the URI. The handler parameter + # bound to it must therefore have a Python default; without + # one, the author only finds out on the first request that + # omits it, as an opaque internal error. + missing_defaults = sorted( + name + for name in parsed.query_variable_names + if sig.parameters[name].default is inspect.Parameter.empty + ) + if missing_defaults: + raise ValueError( + f"Resource {uri!r}: query parameter(s) {missing_defaults} have no " + f"default value. A client may omit a {{?...}}/{{&...}} query " + f"parameter, so the matching handler parameter must declare a " + f"default." + ) + # Register as template self._resource_manager.add_template( fn=fn, @@ -703,9 +742,24 @@ def decorator(fn: _CallableT) -> _CallableT: mime_type=mime_type, icons=icons, annotations=annotations, + security=security if security is not None else self._resource_security, meta=meta, ) else: + if func_params: + raise ValueError( + f"Resource {uri!r} has no URI template variables, but the " + f"handler declares parameters {func_params}. Add matching " + f"{{...}} variables to the URI or remove the parameters." + ) + if context_param is not None: + raise ValueError( + f"Resource {uri!r} has no URI template variables, but the " + f"handler declares a Context parameter. Context injection " + f"for static resources is not supported. " + f"Add a template variable to the URI or remove the " + f"Context parameter." + ) # Register as regular resource resource = FunctionResource.from_function( fn=fn, diff --git a/src/mcp/shared/path_security.py b/src/mcp/shared/path_security.py new file mode 100644 index 0000000000..0d338eacc2 --- /dev/null +++ b/src/mcp/shared/path_security.py @@ -0,0 +1,176 @@ +"""Filesystem path safety primitives for resource handlers. + +These functions help MCP servers reject paths that would resolve +outside the served root when extracted URI template parameters are +used in filesystem operations. They are standalone utilities usable from both the +high-level :class:`~mcp.server.mcpserver.MCPServer` and lowlevel server +implementations. + +The canonical safe pattern:: + + from mcp.shared.path_security import safe_join + + @mcp.resource("file://docs/{+path}") + def read_doc(path: str) -> str: + return safe_join("/data/docs", path).read_text() +""" + +import string +from pathlib import Path + +__all__ = ["PathEscapeError", "contains_path_traversal", "is_absolute_path", "safe_join"] + + +class PathEscapeError(ValueError): + """Raised by :func:`safe_join` when the resolved path escapes the base.""" + + +def contains_path_traversal(value: str) -> bool: + r"""Check whether a value, treated as a relative path, escapes its origin. + + This is a **base-free** check: it does not know the sandbox root, so + it detects only whether ``..`` components would move above the + starting point. Use :func:`safe_join` when you know the root — it + additionally catches symlink escapes and absolute-path injection. + + Note: + This is a string-level check on the value as supplied. It does + not model platform-specific filesystem normalisation (e.g. Win32 + stripping of trailing dots and spaces from the final path + component). For filesystem access, use :func:`safe_join`, which + resolves through the OS and verifies containment. + + The check is component-based: ``..`` is dangerous only as a + standalone path segment, not as a substring. Both ``/`` and ``\`` + are treated as separators. + + Example:: + + >>> contains_path_traversal("a/b/c") + False + >>> contains_path_traversal("../etc") + True + >>> contains_path_traversal("a/../../b") + True + >>> contains_path_traversal("a/../b") + False + >>> contains_path_traversal("1.0..2.0") + False + >>> contains_path_traversal("..") + True + + Args: + value: A string that may be used as a filesystem path. + + Returns: + ``True`` if the path would escape its starting directory. + """ + depth = 0 + for part in value.replace("\\", "/").split("/"): + if part == "..": + depth -= 1 + if depth < 0: + return True + elif part and part != ".": + depth += 1 + return False + + +def is_absolute_path(value: str) -> bool: + r"""Check whether a value is an absolute filesystem path. + + Absolute paths are dangerous when joined onto a base: in Python, + ``Path("/data") / "/etc/passwd"`` yields ``/etc/passwd`` — the + absolute right-hand side silently discards the base. + + Detects POSIX absolute (``/foo``), Windows drive-absolute + (``C:\foo``) and drive-relative (``C:foo``), and Windows + UNC/root-relative (``\\server\share``, ``\foo``). + + Example:: + + >>> is_absolute_path("relative/path") + False + >>> is_absolute_path("/etc/passwd") + True + >>> is_absolute_path("C:\\Windows") + True + >>> is_absolute_path("") + False + + Args: + value: A string that may be used as a filesystem path. + + Returns: + ``True`` if the path is absolute on any common platform. + """ + if not value: + return False + if value[0] in ("/", "\\"): + return True + # Windows drive form: C:, C:\, C:foo (drive-relative). A drive- + # relative right-hand side discards the join base when drives + # differ, so flag it even though PureWindowsPath.is_absolute() + # is False. This means single-letter-prefixed identifiers like + # "x:y" also match — opt out via ResourceSecurity(exempt_params=). + if len(value) >= 2 and value[1] == ":" and value[0] in string.ascii_letters: + return True + return False + + +def safe_join(base: str | Path, *parts: str) -> Path: + """Join path components onto a base, rejecting escapes. + + Resolves the joined path and verifies it remains within ``base``. + This is the **gold-standard** check: it catches ``..`` traversal, + absolute-path injection, and symlink escapes that the base-free + checks cannot. + + The symlink check is point-in-time: a directory swapped for a + symlink between this call and the caller's subsequent open would not + be re-checked. Handlers serving a tree that may be modified + concurrently should additionally open with ``O_NOFOLLOW`` or use + platform path-confinement primitives. + + Example:: + + >>> safe_join("/data/docs", "readme.txt") + PosixPath('/data/docs/readme.txt') + >>> safe_join("/data/docs", "../../../etc/passwd") + Traceback (most recent call last): + ... + PathEscapeError: ... + + Args: + base: The sandbox root. May be relative; it will be resolved. + parts: Path components to join. Each is checked for null bytes + and absolute form before joining. + + Returns: + The resolved path, verified to be within ``base`` at resolution + time. + + Raises: + PathEscapeError: If any part contains a null byte, any part is + absolute, or the resolved path is not contained within the + resolved base. + """ + base_resolved = Path(base).resolve() + + for part in parts: + # Null bytes pass through Path construction but fail at the + # syscall boundary with a cryptic error. Reject here so callers + # get a clear PathEscapeError instead. + if "\0" in part: + raise PathEscapeError(f"Path component contains a null byte; refusing to join onto {base_resolved}") + # Absolute parts would silently discard everything to the left + # in Path's / operator. + if is_absolute_path(part): + raise PathEscapeError(f"Path component {part!r} is absolute; refusing to join onto {base_resolved}") + + target = base_resolved.joinpath(*parts).resolve() + + if not target.is_relative_to(base_resolved): + raise PathEscapeError(f"Path {target} escapes base {base_resolved}") + + return target diff --git a/src/mcp/shared/uri_template.py b/src/mcp/shared/uri_template.py new file mode 100644 index 0000000000..dc57bfa757 --- /dev/null +++ b/src/mcp/shared/uri_template.py @@ -0,0 +1,1116 @@ +"""RFC 6570 URI Templates with bidirectional support. + +Provides both expansion (template + variables → URI) and matching +(URI → variables). RFC 6570 only specifies expansion; matching is the +inverse operation needed by MCP servers to route ``resources/read`` +requests to handlers. + +Supports Levels 1-3 fully, plus Level 4 explode modifier for path-like +operators (``{/var*}``, ``{.var*}``, ``{;var*}``). The Level 4 prefix +modifier (``{var:N}``) and query-explode (``{?var*}``) are not supported. + +Matching semantics +------------------ + +Matching is not specified by RFC 6570 (§1.4 explicitly defers to regex +languages). This implementation uses a two-ended scan that never +backtracks: match time is O(n·v) where n is URI length and v is the +number of template variables. Realistic templates have v < 10, making +this effectively linear; there is no input that produces +superpolynomial time. + +A template may contain **at most one multi-segment variable** — +``{+var}``, ``{#var}``, or an explode-modified variable (``{/var*}``, +``{.var*}``, ``{;var*}``). This variable greedily consumes whatever the +surrounding bounded variables and literals do not. Two such variables +in one template are inherently ambiguous (which one gets the extra +segment?) and are rejected at parse time. So are any two variables +adjacent with no literal between them — including a variable adjacent +to the multi-segment variable: the scan has nothing to anchor the +boundary on. Operators that emit their own lead character supply that +literal themselves, so ``{+path}{.ext}`` and ``{a}{.b}`` are fine +while ``{+path}{ext}`` and ``{a}{b}`` are not. + +Bounded variables before the multi-segment variable match **lazily** +(first occurrence of the following literal); those after match +**greedily** (last occurrence of the preceding literal). Templates +without a multi-segment variable match greedily throughout, identical +to regex semantics. + +Reserved expansion ``{+var}`` leaves ``?`` and ``#`` unencoded, but +the scan stops at those characters so ``{+path}{?q}`` can separate path +from query. A value containing a literal ``?`` or ``#`` expands fine +but will not round-trip through ``match()``. +""" + +from __future__ import annotations + +import re +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from typing import Literal, TypeAlias, cast +from urllib.parse import quote, unquote + +__all__ = [ + "DEFAULT_MAX_TEMPLATE_LENGTH", + "DEFAULT_MAX_VARIABLES", + "DEFAULT_MAX_URI_LENGTH", + "InvalidUriTemplate", + "Operator", + "UriTemplate", + "Variable", +] + +Operator = Literal["", "+", "#", ".", "/", ";", "?", "&"] + +_OPERATORS: frozenset[str] = frozenset({"+", "#", ".", "/", ";", "?", "&"}) + +# RFC 6570 §2.3: varname = varchar *(["."] varchar), varchar = ALPHA / DIGIT / "_" +# Dots appear only between varchar groups — not consecutive, not trailing. +# (Percent-encoded varchars are technically allowed but unseen in practice.) +_VARNAME_RE = re.compile(r"^[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*$") + +DEFAULT_MAX_TEMPLATE_LENGTH = 8_192 +DEFAULT_MAX_VARIABLES = 256 +DEFAULT_MAX_URI_LENGTH = 65_536 + +# RFC 3986 reserved characters, kept unencoded by {+var} and {#var}. +_RESERVED = ":/?#[]@!$&'()*+,;=" + + +@dataclass(frozen=True) +class _OperatorSpec: + """Expansion behavior for a single operator (RFC 6570 §3.2, Table in §A).""" + + prefix: str + """Leading character emitted before the first variable.""" + separator: str + """Character between variables (and between exploded list items).""" + named: bool + """Emit ``name=value`` pairs (query/path-param style) rather than bare values.""" + allow_reserved: bool + """Keep reserved characters unencoded ({+var}, {#var}).""" + ifemp: str + """Suffix after a named variable whose expanded value is empty (RFC §A): '' for ;, '=' for ?/&.""" + + +_OPERATOR_SPECS: dict[Operator, _OperatorSpec] = { + "": _OperatorSpec(prefix="", separator=",", named=False, allow_reserved=False, ifemp=""), + "+": _OperatorSpec(prefix="", separator=",", named=False, allow_reserved=True, ifemp=""), + "#": _OperatorSpec(prefix="#", separator=",", named=False, allow_reserved=True, ifemp=""), + ".": _OperatorSpec(prefix=".", separator=".", named=False, allow_reserved=False, ifemp=""), + "/": _OperatorSpec(prefix="/", separator="/", named=False, allow_reserved=False, ifemp=""), + ";": _OperatorSpec(prefix=";", separator=";", named=True, allow_reserved=False, ifemp=""), + "?": _OperatorSpec(prefix="?", separator="&", named=True, allow_reserved=False, ifemp="="), + "&": _OperatorSpec(prefix="&", separator="&", named=True, allow_reserved=False, ifemp="="), +} + +# Per-operator stop characters for the linear scan. A bounded variable's +# value ends at the first occurrence of any character in its stop set, +# mirroring the character-class boundaries a regex would use but without +# the backtracking. +_STOP_CHARS: dict[Operator, str] = { + "": "/?#&,", # simple: everything structural is pct-encoded + "+": "?#", # reserved: / allowed, stop at query/fragment + "#": "", # fragment: tail of URI, nothing stops it + ".": "./?#", # label: stop at next . + "/": "/?#", # path segment: stop at next / + ";": ";/?#", # path-param value (may be empty: ;name) + "?": "&#", # query value (may be empty: ?name=) + "&": "&#", # query-cont value +} + + +class InvalidUriTemplate(ValueError): + """Raised when a URI template string is malformed or unsupported. + + Attributes: + template: The template string that failed to parse. + position: Character offset where the error was detected, or None + if the error is not tied to a specific position. + """ + + def __init__(self, message: str, *, template: str, position: int | None = None) -> None: + super().__init__(message) + self.template = template + self.position = position + + +@dataclass(frozen=True) +class Variable: + """A single variable within a URI template expression.""" + + name: str + operator: Operator + explode: bool = False + + +@dataclass +class _Expression: + """A parsed ``{...}`` expression: one operator, one or more variables.""" + + operator: Operator + variables: list[Variable] + + +_Part = str | _Expression + + +@dataclass(frozen=True) +class _Lit: + """A literal run in the flattened match-atom sequence.""" + + text: str + + +@dataclass(frozen=True) +class _Cap: + """A single-variable capture in the flattened match-atom sequence. + + ``ifemp`` marks the ``;`` operator's optional-equals quirk: ``{;id}`` + expands to ``;id=value`` or bare ``;id`` when the value is empty, so + the scan must accept both forms. + """ + + var: Variable + ifemp: bool = False + + +_Atom: TypeAlias = _Lit | _Cap + + +def _is_greedy(var: Variable) -> bool: + """Return True if this variable can span multiple path segments. + + Reserved/fragment expansion and explode variables are the only + constructs whose match range is not bounded by a single structural + delimiter. A template may contain at most one such variable. + """ + return var.explode or var.operator in ("+", "#") + + +def _is_str_sequence(value: object) -> bool: + """Check if value is a non-string sequence whose items are all strings.""" + if isinstance(value, str) or not isinstance(value, Sequence): + return False + seq = cast(Sequence[object], value) + return all(isinstance(item, str) for item in seq) + + +_PCT_TRIPLET_RE = re.compile(r"%[0-9A-Fa-f]{2}") + + +def _encode(value: str, *, allow_reserved: bool) -> str: + """Percent-encode a value per RFC 6570 §3.2.1. + + Simple expansion encodes everything except unreserved characters. + Reserved expansion (``{+var}``, ``{#var}``) additionally keeps + RFC 3986 reserved characters intact and passes through existing + ``%XX`` pct-triplets unchanged (RFC 6570 §3.2.3). A bare ``%`` not + followed by two hex digits is still encoded to ``%25``. + """ + if not allow_reserved: + return quote(value, safe="") + + # Reserved expansion: walk the string, pass through triplets as-is, + # quote the gaps between them. A bare % with no triplet lands in a + # gap and gets encoded normally. + out: list[str] = [] + last = 0 + for m in _PCT_TRIPLET_RE.finditer(value): + out.append(quote(value[last : m.start()], safe=_RESERVED)) + out.append(m.group()) + last = m.end() + out.append(quote(value[last:], safe=_RESERVED)) + return "".join(out) + + +def _expand_expression(expr: _Expression, variables: Mapping[str, str | Sequence[str]]) -> str: + """Expand a single ``{...}`` expression into its URI fragment. + + Walks the expression's variables, encoding and joining defined ones + according to the operator's spec. Undefined variables are skipped + (RFC 6570 §2.3); if all are undefined, the expression contributes + nothing (no prefix is emitted). + """ + spec = _OPERATOR_SPECS[expr.operator] + rendered: list[str] = [] + + for var in expr.variables: + if var.name not in variables: + # Undefined: skip entirely, no placeholder. + continue + + value = variables[var.name] + + # Explicit type guard: reject non-str scalars with a clear message + # rather than a confusing "not iterable" from the sequence branch. + if not isinstance(value, str) and not _is_str_sequence(value): + raise TypeError(f"Variable {var.name!r} must be str or a sequence of str, got {type(value).__name__}") + + if isinstance(value, str): + encoded = _encode(value, allow_reserved=spec.allow_reserved) + if spec.named: + rendered.append(f"{var.name}{spec.ifemp}" if value == "" else f"{var.name}={encoded}") + else: + rendered.append(encoded) + else: + # Sequence value. + items = [_encode(v, allow_reserved=spec.allow_reserved) for v in value] + if not items: + continue + if var.explode: + # Each item gets the operator's separator; named ops repeat the key. + if spec.named: + rendered.append( + spec.separator.join(f"{var.name}{spec.ifemp}" if v == "" else f"{var.name}={v}" for v in items) + ) + else: + rendered.append(spec.separator.join(items)) + else: + # Non-explode: comma-join into a single value, then apply + # ifemp to the joined result (RFC §3.2.1: behaves as if the + # value were the joined string). + joined = ",".join(items) + if spec.named: + rendered.append(f"{var.name}{spec.ifemp}" if joined == "" else f"{var.name}={joined}") + else: + rendered.append(joined) + + if not rendered: + return "" + return spec.prefix + spec.separator.join(rendered) + + +@dataclass(frozen=True) +class UriTemplate: + """A parsed RFC 6570 URI template. + + Construct via :meth:`parse`. Instances are immutable and hashable; + equality is based on the template string alone. + """ + + template: str + _parts: list[_Part] = field(repr=False, compare=False) + _variables: list[Variable] = field(repr=False, compare=False) + _prefix: list[_Atom] = field(repr=False, compare=False) + _greedy: Variable | None = field(repr=False, compare=False) + _suffix: list[_Atom] = field(repr=False, compare=False) + _query_variables: list[Variable] = field(repr=False, compare=False) + + @staticmethod + def is_template(value: str) -> bool: + """Check whether a string contains URI template expressions. + + A cheap heuristic for distinguishing concrete URIs from templates + without the cost of full parsing. Returns ``True`` if the string + contains at least one ``{...}`` pair. + + Example:: + + >>> UriTemplate.is_template("file://docs/{name}") + True + >>> UriTemplate.is_template("file://docs/readme.txt") + False + + Note: + This does not validate the template. A ``True`` result does + not guarantee :meth:`parse` will succeed. + """ + open_i = value.find("{") + return open_i != -1 and value.find("}", open_i) != -1 + + @classmethod + def parse( + cls, + template: str, + *, + max_length: int = DEFAULT_MAX_TEMPLATE_LENGTH, + max_variables: int = DEFAULT_MAX_VARIABLES, + ) -> UriTemplate: + """Parse a URI template string. + + Args: + template: An RFC 6570 URI template. + max_length: Maximum permitted length of the template string. + Guards against resource exhaustion. + max_variables: Maximum number of variables permitted across + all expressions. Counting variables rather than + ``{...}`` expressions closes the gap where a single + ``{v0,v1,...,vN}`` expression packs arbitrarily many + variables under one expression count. + + Raises: + InvalidUriTemplate: If the template is malformed, exceeds the + size limits, or uses unsupported RFC 6570 features. + """ + if len(template) > max_length: + raise InvalidUriTemplate( + f"Template exceeds maximum length of {max_length}", + template=template, + ) + + parts, variables = _parse(template, max_variables=max_variables) + + # Trailing {?...}/{&...} expressions are split off and matched as + # a query string (order-agnostic, partial, extras ignored) rather + # than via the linear scan. + path_parts, query_vars = _split_query_tail(parts) + atoms = _flatten(path_parts) + prefix, greedy, suffix = _partition_greedy(atoms, template) + + return cls( + template=template, + _parts=parts, + _variables=variables, + _prefix=prefix, + _greedy=greedy, + _suffix=suffix, + _query_variables=query_vars, + ) + + @property + def variables(self) -> list[Variable]: + """All variables in the template, in order of appearance.""" + return list(self._variables) + + @property + def variable_names(self) -> list[str]: + """All variable names in the template, in order of appearance.""" + return [v.name for v in self._variables] + + @property + def query_variable_names(self) -> frozenset[str]: + """Names of variables that :meth:`match` treats as optional query parameters. + + These are the variables in a trailing run of ``{?...}``/``{&...}`` + expressions, which are matched leniently: a URI that omits some + (or all) of them still matches, and the omitted names are simply + absent from the result. Any value bound to such a name therefore + needs a fallback for the omitted case. + + Every other variable is bound on every successful :meth:`match` + (possibly to an empty string) and is *not* in this set. That + includes a ``{&...}`` expression with no preceding ``{?...}``: it + never emits the ``?`` the lenient query split keys on, so it is + matched strictly. + """ + return frozenset(v.name for v in self._query_variables) + + def expand(self, variables: Mapping[str, str | Sequence[str]]) -> str: + """Expand the template by substituting variable values. + + String values are percent-encoded according to their operator: + simple ``{var}`` encodes reserved characters; ``{+var}`` and + ``{#var}`` leave them intact. Sequence values are joined with + commas for non-explode variables, or with the operator's + separator for explode variables. + + Example:: + + >>> t = UriTemplate.parse("file://docs/{name}") + >>> t.expand({"name": "hello world.txt"}) + 'file://docs/hello%20world.txt' + + >>> t = UriTemplate.parse("file://docs/{+path}") + >>> t.expand({"path": "src/main.py"}) + 'file://docs/src/main.py' + + >>> t = UriTemplate.parse("/search{?q,lang}") + >>> t.expand({"q": "mcp", "lang": "en"}) + '/search?q=mcp&lang=en' + + >>> t = UriTemplate.parse("/files{/path*}") + >>> t.expand({"path": ["a", "b", "c"]}) + '/files/a/b/c' + + Args: + variables: Values for each template variable. Keys must be + strings; values must be ``str`` or a sequence of ``str``. + + Returns: + The expanded URI string. + + Note: + Per RFC 6570, variables absent from the mapping are + **silently omitted**. This is the correct behavior for + optional query parameters (``{?page}`` with no page yields + no ``?page=``), but for required path segments it produces + a structurally incomplete URI. If you need all variables + present, validate before calling:: + + missing = set(t.variable_names) - variables.keys() + if missing: + raise ValueError(f"Missing: {missing}") + + Raises: + TypeError: If a value is neither ``str`` nor an iterable of + ``str``. Non-string scalars (``int``, ``None``) are not + coerced. + """ + out: list[str] = [] + for part in self._parts: + if isinstance(part, str): + out.append(part) + else: + out.append(_expand_expression(part, variables)) + return "".join(out) + + def match(self, uri: str, *, max_uri_length: int = DEFAULT_MAX_URI_LENGTH) -> dict[str, str | list[str]] | None: + """Match a concrete URI against this template and extract variables. + + This is the inverse of :meth:`expand`. The URI is matched via a + linear scan of the template and captured values are + percent-decoded. The round-trip ``match(expand({k: v})) == {k: v}`` + holds when ``v`` does not contain its operator's separator + unencoded: ``{.ext}`` with ``ext="tar.gz"`` expands to + ``.tar.gz`` but does not match — the scan stops ``ext`` at the + first ``.`` and the trailing ``.gz`` has nothing to consume it. + RFC 6570 §1.4 notes this is an inherent reversal limitation. + + Matching is structural at the URI level only: a simple ``{name}`` + will not match across a literal ``/`` in the URI (the scan stops + there), but a percent-encoded ``%2F`` that decodes to ``/`` is + accepted as part of the value. Path-safety validation belongs at + a higher layer; see :mod:`mcp.shared.path_security`. + + Example:: + + >>> t = UriTemplate.parse("file://docs/{name}") + >>> t.match("file://docs/readme.txt") + {'name': 'readme.txt'} + >>> t.match("file://docs/hello%20world.txt") + {'name': 'hello world.txt'} + + >>> t = UriTemplate.parse("file://docs/{+path}") + >>> t.match("file://docs/src/main.py") + {'path': 'src/main.py'} + + >>> t = UriTemplate.parse("/files{/path*}") + >>> t.match("/files/a/b/c") + {'path': ['a', 'b', 'c']} + + **Query parameters** (``{?q,lang}`` at the end of a template) + are matched leniently: order-agnostic, partial, and unrecognized + params are ignored. Absent params are omitted from the result so + downstream function defaults can apply:: + + >>> t = UriTemplate.parse("logs://{service}{?since,level}") + >>> t.match("logs://api") + {'service': 'api'} + >>> t.match("logs://api?level=error") + {'service': 'api', 'level': 'error'} + >>> t.match("logs://api?level=error&since=5m&utm=x") + {'service': 'api', 'since': '5m', 'level': 'error'} + + Args: + uri: A concrete URI string. + max_uri_length: Maximum permitted length of the input URI. + Oversized inputs return ``None`` without scanning, + guarding against resource exhaustion. + + Returns: + A mapping from variable names to decoded values (``str`` for + scalar variables, ``list[str]`` for explode variables), or + ``None`` if the URI does not match the template or exceeds + ``max_uri_length``. + """ + if len(uri) > max_uri_length: + return None + + if self._query_variables: + # Two-phase: scan matches the path, the query is split and + # decoded manually. Query params may be partial, reordered, + # or include extras; absent params stay absent so downstream + # defaults can apply. Fragment is stripped first since the + # template's {?...} tail never describes a fragment. + before_fragment, _, _ = uri.partition("#") + path, _, query = before_fragment.partition("?") + result = self._scan(path) + if result is None: + return None + if query: + parsed = _parse_query(query) + for var in self._query_variables: + if var.name in parsed: + result[var.name] = parsed[var.name] + return result + + return self._scan(uri) + + def _scan(self, uri: str) -> dict[str, str | list[str]] | None: + """Run the two-ended linear scan against the path portion of a URI.""" + n = len(uri) + + if self._greedy is None: + # No greedy var: the suffix IS the whole template, scanned + # right-to-left and anchored so atoms[0] matches at position 0. + suffix = _scan_suffix(self._suffix, uri, n, anchored=True) + if suffix is None: + return None + suffix_result, suffix_start = suffix + return suffix_result if suffix_start == 0 else None + + # Greedy var present. The parser rejects a capture adjacent to + # the greedy slot, so a non-empty suffix begins with a _Lit whose + # rfind-derived anchor does not depend on how far the prefix + # scans. Scan the suffix first, then give the prefix that exact + # position as its ceiling so it cannot consume past the anchor. + suffix = _scan_suffix(self._suffix, uri, n, anchored=False) + if suffix is None: + return None + suffix_result, suffix_start = suffix + prefix = _scan_prefix(self._prefix, uri, 0, suffix_start) + if prefix is None: + return None + prefix_result, prefix_end = prefix + + # Prefix consumed [0, prefix_end); suffix consumed [suffix_start, n); + # the greedy var takes the gap. The prefix scan is bounded by + # suffix_start, so this holds by construction; guard explicitly + # rather than asserting so a future regression surfaces as a + # non-match, not an exception. + if suffix_start < prefix_end: + return None # pragma: no cover - unreachable while bounds hold + middle = uri[prefix_end:suffix_start] + greedy_value = _extract_greedy(self._greedy, middle) + if greedy_value is None: + return None + + return {**prefix_result, self._greedy.name: greedy_value, **suffix_result} + + def __str__(self) -> str: + return self.template + + +def _parse_query(query: str) -> dict[str, str]: + """Parse a query string into a name→value mapping. + + Unlike ``urllib.parse.parse_qs``, this follows RFC 3986 semantics: + ``+`` is a literal sub-delim, not a space. Form-urlencoding treats + ``+`` as space for HTML form submissions, but RFC 6570 and MCP + resource URIs follow RFC 3986 where only ``%20`` encodes a space. + + Parameter names are **not** percent-decoded. RFC 6570 expansion + never encodes variable names, so a legitimate match will always + have the name in literal form. Decoding names would let + ``%74oken=evil&token=real`` shadow the real ``token`` parameter + via first-wins. + + Duplicate keys keep the first value. Pairs without ``=`` are + treated as empty-valued. + """ + result: dict[str, str] = {} + for pair in query.split("&"): + name, _, value = pair.partition("=") + if name and name not in result: + result[name] = unquote(value) + return result + + +def _extract_greedy(var: Variable, raw: str) -> str | list[str] | None: + """Decode the greedy variable's isolated middle span. + + For scalar greedy (``{+var}``, ``{#var}``) this is a stop-char + validation and a single ``unquote``. For explode variables the span + is a run of separator-delimited segments (``/a/b/c`` or + ``;keys=a;keys=b``) that is split, validated, and decoded per item. + """ + spec = _OPERATOR_SPECS[var.operator] + stops = _STOP_CHARS[var.operator] + + if not var.explode: + if any(c in stops for c in raw): + return None + return unquote(raw) + + sep = spec.separator + if not raw: + return [] + # A non-empty explode span must begin with the separator: {/a*} + # expands to "/x/y", never "x/y". The scan does not consume the + # separator itself, so it must be the first character here. + if raw[0] != sep: + return None + # Segments must not contain the operator's non-separator stop + # characters (e.g. {/path*} segments may contain neither ? nor #). + body_stops = set(stops) - {sep} + if any(c in body_stops for c in raw): + return None + + segments: list[str] = [] + prefix = f"{var.name}=" + # split()[0] is always "" because raw starts with the separator; + # subsequent empties are legitimate values ({/path*} with + # ["a","","c"] expands to /a//c). + for seg in raw.split(sep)[1:]: + if spec.named: + # Named explode emits name=value per item (or bare name + # under ; with empty value). Validate the name and strip + # the prefix before decoding. + if seg.startswith(prefix): + seg = seg[len(prefix) :] + elif seg == var.name: + seg = "" + else: + return None + segments.append(unquote(seg)) + return segments + + +def _split_query_tail(parts: list[_Part]) -> tuple[list[_Part], list[Variable]]: + """Separate trailing ``?``/``&`` expressions from the path portion. + + Lenient query matching (order-agnostic, partial, ignores extras) + applies when a template ends with one or more consecutive ``?``/``&`` + expressions and the preceding path portion contains no literal + ``?``. If the path has a literal ``?`` (e.g., ``?fixed=1{&page}``), + the URI's ``?`` split won't align with the template's expression + boundary, so the strict scan is used instead. + + Returns: + A pair ``(path_parts, query_vars)``. If lenient matching does + not apply, ``query_vars`` is empty and ``path_parts`` is the + full input. + """ + split = len(parts) + for i in range(len(parts) - 1, -1, -1): + part = parts[i] + if isinstance(part, _Expression) and part.operator in ("?", "&"): + split = i + else: + break + + if split == len(parts): + return parts, [] + + # The tail must start with a {?...} expression so that expand() + # emits a ? the URI can split on. A standalone {&page} expands + # with an & prefix, which partition("?") won't find. + first = parts[split] + assert isinstance(first, _Expression) + if first.operator != "?": + return parts, [] + + # If the path portion contains a literal ?/# or a {?...}/{#...} + # expression, lenient matching's partition("#") then partition("?") + # would strip content the path scan expects to see. Fall back to + # the strict scan. + for part in parts[:split]: + if isinstance(part, str): + if "?" in part or "#" in part: + return parts, [] + elif part.operator in ("?", "#"): + return parts, [] + + query_vars: list[Variable] = [] + for part in parts[split:]: + assert isinstance(part, _Expression) + query_vars.extend(part.variables) + + return parts[:split], query_vars + + +def _parse(template: str, *, max_variables: int) -> tuple[list[_Part], list[Variable]]: + """Split a template into an ordered sequence of literals and expressions. + + Walks the string, alternating between collecting literal runs and + parsing ``{...}`` expressions. The resulting ``parts`` sequence + preserves positional interleaving so ``match()`` and ``expand()`` can + walk it in order. + + Raises: + InvalidUriTemplate: On unclosed braces, too many expressions, or + any error surfaced by :func:`_parse_expression`. + """ + parts: list[_Part] = [] + variables: list[Variable] = [] + i = 0 + n = len(template) + + while i < n: + # Find the next expression opener from the current cursor. + brace = template.find("{", i) + + if brace == -1: + # No more expressions; everything left is a trailing literal. + parts.append(template[i:]) + break + + if brace > i: + # Literal text between cursor and the brace. + parts.append(template[i:brace]) + + end = template.find("}", brace) + if end == -1: + raise InvalidUriTemplate( + f"Unclosed expression at position {brace}", + template=template, + position=brace, + ) + + # Delegate body (between braces, exclusive) to the expression parser. + expr = _parse_expression(template, template[brace + 1 : end], brace) + parts.append(expr) + variables.extend(expr.variables) + + if len(variables) > max_variables: + raise InvalidUriTemplate( + f"Template exceeds maximum of {max_variables} variables", + template=template, + ) + + # Advance past the closing brace. + i = end + 1 + + _check_duplicate_variables(template, variables) + _check_single_query_expression(template, parts) + return parts, variables + + +def _parse_expression(template: str, body: str, pos: int) -> _Expression: + """Parse the body of a single ``{...}`` expression. + + The body is everything between the braces. It consists of an optional + leading operator character followed by one or more comma-separated + variable specifiers. Each specifier is a name with an optional + trailing ``*`` (explode modifier). + + Args: + template: The full template string, for error reporting. + body: The expression body, braces excluded. + pos: Character offset of the opening brace, for error reporting. + + Raises: + InvalidUriTemplate: On empty body, invalid variable names, or + unsupported modifiers. + """ + if not body: + raise InvalidUriTemplate(f"Empty expression at position {pos}", template=template, position=pos) + + # Peel off the operator, if any. Membership check justifies the cast. + operator: Operator = "" + if body[0] in _OPERATORS: + operator = cast(Operator, body[0]) + body = body[1:] + if not body: + raise InvalidUriTemplate( + f"Expression has operator but no variables at position {pos}", + template=template, + position=pos, + ) + + # Remaining body is comma-separated variable specs: name[*] + variables: list[Variable] = [] + for spec in body.split(","): + if ":" in spec: + raise InvalidUriTemplate( + f"Prefix modifier {{var:N}} is not supported (in {spec!r} at position {pos})", + template=template, + position=pos, + ) + + explode = spec.endswith("*") + name = spec[:-1] if explode else spec + + if not _VARNAME_RE.match(name): + raise InvalidUriTemplate( + f"Invalid variable name {name!r} at position {pos}", + template=template, + position=pos, + ) + + # Explode only makes sense for operators that repeat a separator. + # Simple/reserved/fragment have no per-item separator; query-explode + # needs order-agnostic dict matching which we don't support yet. + if explode and operator in ("", "+", "#", "?", "&"): + raise InvalidUriTemplate( + f"Explode modifier on {{{operator}{name}*}} is not supported for matching", + template=template, + position=pos, + ) + + variables.append(Variable(name=name, operator=operator, explode=explode)) + + return _Expression(operator=operator, variables=variables) + + +def _check_duplicate_variables(template: str, variables: list[Variable]) -> None: + """Reject templates that use the same variable name more than once. + + RFC 6570 requires repeated variables to expand to the same value, + which would require backreference matching with potentially + exponential cost. Rather than silently returning only the last + captured value, we reject at parse time. + + Raises: + InvalidUriTemplate: If any variable name appears more than once. + """ + seen: set[str] = set() + for var in variables: + if var.name in seen: + raise InvalidUriTemplate( + f"Variable {var.name!r} appears more than once; repeated variables are not supported", + template=template, + ) + seen.add(var.name) + + +def _check_single_query_expression(template: str, parts: list[_Part]) -> None: + """Reject templates with more than one ``{?...}`` expression. + + The ``?`` operator emits a leading ``?``, so two such expressions + expand to a URI with two ``?`` characters — malformed per RFC 3986 + §3.4. Use ``{?a,b}`` or ``{?a}{&b}`` for multiple query parameters. + """ + seen = False + for part in parts: + if isinstance(part, _Expression) and part.operator == "?": + if seen: + raise InvalidUriTemplate( + "Template contains more than one {?...} expression; " + "use {?a,b} or {?a}{&b} for multiple query parameters", + template=template, + ) + seen = True + + +def _flatten(parts: list[_Part]) -> list[_Atom]: + """Lower expressions into a flat sequence of literals and single-variable captures. + + Operator prefixes and separators become explicit ``_Lit`` atoms so + the scan only ever sees two atom kinds. Adjacent literals are + coalesced so that anchor-finding (``find``/``rfind``) operates on + the longest possible literal, reducing false matches. + + Explode variables emit no lead literal: the explode capture + includes its own separator-prefixed repetitions (``{/a*}`` → + ``/x/y/z``, not ``/`` then ``x/y/z``). + """ + atoms: list[_Atom] = [] + + def push_lit(text: str) -> None: + if not text: + return + if atoms and isinstance(atoms[-1], _Lit): + atoms[-1] = _Lit(atoms[-1].text + text) + else: + atoms.append(_Lit(text)) + + for part in parts: + if isinstance(part, str): + push_lit(part) + continue + spec = _OPERATOR_SPECS[part.operator] + for i, var in enumerate(part.variables): + lead = spec.prefix if i == 0 else spec.separator + if var.explode: + atoms.append(_Cap(var)) + elif spec.named: + # ; uses ifemp (bare name when empty); ? and & always + # emit name= so the equals is part of the literal. + if part.operator == ";": + push_lit(f"{lead}{var.name}") + atoms.append(_Cap(var, ifemp=True)) + else: + push_lit(f"{lead}{var.name}=") + atoms.append(_Cap(var)) + else: + push_lit(lead) + atoms.append(_Cap(var)) + return atoms + + +def _partition_greedy(atoms: list[_Atom], template: str) -> tuple[list[_Atom], Variable | None, list[_Atom]]: + """Split atoms at the single greedy variable, if any. + + Returns ``(prefix, greedy_var, suffix)``. If there is no greedy + variable the entire atom list is returned as the suffix so that + the right-to-left scan (which matches regex-greedy semantics) + handles it. + + Raises: + InvalidUriTemplate: If two variables are adjacent with no + literal between them — whether or not one is the + multi-segment variable, the scan has nothing to anchor the + boundary on — or if more than one multi-segment variable + is present (two are inherently ambiguous: there is no + principled way to decide which one absorbs an extra + segment). + """ + greedy_idx: int | None = None + prev: _Atom | None = None + for i, atom in enumerate(atoms): + if isinstance(atom, _Cap): + if isinstance(prev, _Cap): + raise InvalidUriTemplate( + f"Variables {prev.var.name!r} and {atom.var.name!r} are adjacent " + "with no literal separator; matching cannot determine where one " + "ends and the other begins. Add a literal between them or use a " + "single variable.", + template=template, + ) + if _is_greedy(atom.var): + if greedy_idx is not None: + raise InvalidUriTemplate( + "Template contains more than one multi-segment variable " + "({+var}, {#var}, or explode modifier); matching would be ambiguous", + template=template, + ) + greedy_idx = i + prev = atom + if greedy_idx is None: + return [], None, atoms + greedy = atoms[greedy_idx] + assert isinstance(greedy, _Cap) + return atoms[:greedy_idx], greedy.var, atoms[greedy_idx + 1 :] + + +def _scan_suffix( + atoms: Sequence[_Atom], uri: str, end: int, *, anchored: bool +) -> tuple[dict[str, str | list[str]], int] | None: + """Scan atoms right-to-left from ``end``, returning captures and start position. + + Each bounded variable takes the minimum span that lets its + preceding literal match (found via ``rfind``), which makes the + *first* variable in template order greedy — identical to Python + regex semantics for a sequence of greedy groups. + + When ``anchored`` is true the atom sequence is the entire template + (no greedy variable), so ``atoms[0]`` must match at URI position 0 + rather than at its rightmost occurrence. + """ + result: dict[str, str | list[str]] = {} + pos = end + i = len(atoms) - 1 + while i >= 0: + atom = atoms[i] + if isinstance(atom, _Lit): + n = len(atom.text) + if pos < n or uri[pos - n : pos] != atom.text: + return None + pos -= n + i -= 1 + continue + + var = atom.var + stops = _STOP_CHARS[var.operator] + prev = atoms[i - 1] if i > 0 else None + + if atom.ifemp: + # ;name or ;name=value. The preceding _Lit is ";name". + # Try empty first: if the lit ends at pos the value is + # absent (RFC ifemp). Otherwise require =value. + assert isinstance(prev, _Lit) + if uri.endswith(prev.text, 0, pos): + result[var.name] = "" + i -= 1 + continue + earliest = pos + while earliest > 0 and uri[earliest - 1] not in stops: + earliest -= 1 + eq = uri.find("=", earliest, pos) + if eq == -1: + return None + result[var.name] = unquote(uri[eq + 1 : pos]) + pos = eq + i -= 1 + continue + + # Earliest valid start: the var cannot extend left past any + # stop-char, so scan backward to find that boundary. + earliest = pos + while earliest > 0 and uri[earliest - 1] not in stops: + earliest -= 1 + + if prev is None: + start = earliest + else: + # prev is a _Lit: the parser rejects two adjacent captures, + # so the only possible neighbour kind is a literal. + assert isinstance(prev, _Lit) + if anchored and i - 1 == 0: + # First atom of the whole template: positionally fixed at + # 0, not rightmost occurrence. rfind would land inside the + # value when the literal repeats there (e.g. "prefix-{id}" + # against "prefix-prefix-123"). + start = len(prev.text) + if start < earliest or start > pos: + return None + else: + # Rightmost occurrence of the preceding literal whose end + # falls within the var's valid range. + idx = uri.rfind(prev.text, 0, pos) + if idx == -1 or idx + len(prev.text) < earliest: + return None + start = idx + len(prev.text) + + result[var.name] = unquote(uri[start:pos]) + pos = start + i -= 1 + return result, pos + + +def _scan_prefix( + atoms: Sequence[_Atom], uri: str, start: int, limit: int +) -> tuple[dict[str, str | list[str]], int] | None: + """Scan atoms left-to-right from ``start``, not exceeding ``limit``. + + Each bounded variable takes the minimum span that lets its + following literal match (found via ``find``), leaving the + greedy variable as much of the URI as possible. + """ + result: dict[str, str | list[str]] = {} + pos = start + for i, atom in enumerate(atoms): + if isinstance(atom, _Lit): + end = pos + len(atom.text) + if end > limit or uri[pos:end] != atom.text: + return None + pos = end + continue + + var = atom.var + stops = _STOP_CHARS[var.operator] + # Every capture here is followed by a literal: the parser rejects + # two adjacent captures, and a capture at the END of the prefix + # would be adjacent to the greedy variable. + nxt = atoms[i + 1] + assert isinstance(nxt, _Lit) + + if atom.ifemp: + # RFC §3.2.7 ifemp: ;name=val for non-empty, bare ;name for + # empty. Decide which form is present without falling through + # to the stop-char scan when the value is empty. + if uri.startswith(nxt.text, pos): + # Following literal begins immediately: value is empty. + # Checked before '=' so a literal that itself starts + # with '=' is not mistaken for the ifemp separator. + result[var.name] = "" + continue + if pos < limit and uri[pos] == "=": + pos += 1 # value follows; fall through to the scan + else: + # The following literal does not start here and there is + # no '=': the URI's name continued past the template's + # (e.g. ;keys vs ;key) — no parse. + return None + + # Latest valid end: the var stops at the first stop-char or + # the scan limit, whichever comes first. + latest = pos + while latest < limit and uri[latest] not in stops: + latest += 1 + + # First occurrence of the following literal: the capture takes + # the minimum span, leaving the greedy variable as much of the + # URI as possible. The search window's upper bound already + # forces any hit to start at or before ``latest``, so the var + # never extends past a stop-char. + end = uri.find(nxt.text, pos, latest + len(nxt.text)) + if end == -1: + return None + + result[var.name] = unquote(uri[pos:end]) + pos = end + return result, pos diff --git a/tests/docs_src/test_uri_templates.py b/tests/docs_src/test_uri_templates.py new file mode 100644 index 0000000000..b90e099c19 --- /dev/null +++ b/tests/docs_src/test_uri_templates.py @@ -0,0 +1,214 @@ +"""`docs/advanced/uri-templates.md`: every claim the page makes, proved against the real SDK.""" + +from pathlib import Path + +import pytest +from inline_snapshot import snapshot +from mcp_types import INVALID_PARAMS, ErrorData, ResourceTemplate, TextResourceContents + +from docs_src.uri_templates import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005 +from mcp import Client, MCPError +from mcp.server import MCPServer +from mcp.shared.path_security import PathEscapeError, contains_path_traversal, safe_join +from mcp.shared.uri_template import InvalidUriTemplate, UriTemplate + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_simple_expansion_maps_the_segment_to_the_argument() -> None: + """tutorial001: `books://{isbn}` reads `books://978-...` and the matched string is the argument.""" + async with Client(tutorial001.mcp) as client: + (content,) = (await client.read_resource("books://978-0441172719")).contents + assert isinstance(content, TextResourceContents) + assert content.text == snapshot('{\n "title": "Dune",\n "author": "Frank Herbert"\n}') + + +async def test_an_int_parameter_is_converted_from_the_uri_string() -> None: + """tutorial001: `order_id: int` receives `12345`, not `"12345"`, so `order_id + 1` is `12346`.""" + async with Client(tutorial001.mcp) as client: + (content,) = (await client.read_resource("orders://12345")).contents + assert isinstance(content, TextResourceContents) + assert content.text == snapshot('{\n "order_id": 12345,\n "next_order": 12346,\n "status": "shipped"\n}') + + +async def test_plus_keeps_the_slashes_in_the_captured_value() -> None: + """tutorial001: `{+path}` matches `printing/setup.md` as one value; a plain `{path}` would not.""" + async with Client(tutorial001.mcp) as client: + (content,) = (await client.read_resource("manuals://printing/setup.md")).contents + assert isinstance(content, TextResourceContents) + assert content.text == "# Printer setup\n\nLoad paper, then power on." + + +async def test_omitted_query_params_fall_through_to_function_defaults() -> None: + """tutorial001: `{?limit,sort}` is lenient. No query string means `limit=10, sort="newest"`.""" + async with Client(tutorial001.mcp) as client: + (content,) = (await client.read_resource("reviews://978-0441172719")).contents + assert isinstance(content, TextResourceContents) + assert content.text == "10 newest reviews of Dune" + + +async def test_a_query_param_overrides_only_the_default_it_names() -> None: + """tutorial001: `?sort=top` sets `sort` and leaves `limit` at its default.""" + async with Client(tutorial001.mcp) as client: + (content,) = (await client.read_resource("reviews://978-0441172719?sort=top")).contents + assert isinstance(content, TextResourceContents) + assert content.text == "10 top reviews of Dune" + + +async def test_exploded_path_arrives_as_a_list_of_segments() -> None: + """tutorial001: `{/path*}` splits `/fiction/sci-fi` into `["fiction", "sci-fi"]`.""" + async with Client(tutorial001.mcp) as client: + (content,) = (await client.read_resource("shelves://browse/fiction/sci-fi")).contents + assert isinstance(content, TextResourceContents) + assert content.text == "catalog > fiction > sci-fi" + + +def test_two_adjacent_variables_are_rejected_at_parse_time() -> None: + """'What the parser rejects': nothing separates `path` from `ext`, so the template is refused.""" + with pytest.raises(InvalidUriTemplate) as exc_info: + UriTemplate.parse("manuals://{+path}{ext}") + assert str(exc_info.value) == snapshot( + "Variables 'path' and 'ext' are adjacent with no literal separator; matching cannot " + "determine where one ends and the other begins. Add a literal between them or use a single variable." + ) + + +def test_a_self_delimiting_operator_supplies_the_separator() -> None: + """'What the parser rejects': `{.ext}` contributes the `.` itself, so `{+path}{.ext}` is accepted.""" + template = UriTemplate.parse("manuals://{+path}{.ext}") + assert template.match("manuals://printing/setup.md") == {"path": "printing/setup", "ext": "md"} + + +def test_a_second_multi_segment_variable_is_rejected_at_parse_time() -> None: + """'What the parser rejects': two `{+...}` are ambiguous about which one absorbs an extra segment.""" + with pytest.raises(InvalidUriTemplate) as exc_info: + UriTemplate.parse("copy://{+source}/to/{+destination}") + assert str(exc_info.value) == snapshot( + "Template contains more than one multi-segment variable ({+var}, {#var}, or explode modifier); " + "matching would be ambiguous" + ) + + +def test_a_query_parameter_without_a_python_default_is_rejected_at_decoration_time() -> None: + """'What the parser rejects': a client may omit `{?limit}`, so the bound parameter must declare a default.""" + strict = MCPServer("Bookshop") + with pytest.raises(ValueError) as exc_info: + + @strict.resource("reviews://{isbn}{?limit}") + def list_reviews(isbn: str, limit: int) -> None: + """Reviews of a book.""" + + assert str(exc_info.value) == snapshot( + "Resource 'reviews://{isbn}{?limit}': query parameter(s) ['limit'] have no default value. " + "A client may omit a {?...}/{&...} query parameter, so the matching handler parameter " + "must declare a default." + ) + + +async def test_traversal_is_rejected_before_the_handler_runs() -> None: + """The `!!! check`: `../` triggers `-32602` "Unknown resource" and `read_manual` is never called.""" + async with Client(tutorial001.mcp) as client: + with pytest.raises(MCPError) as exc_info: + await client.read_resource("manuals://../etc/passwd") + assert exc_info.value.error == snapshot( + ErrorData( + code=INVALID_PARAMS, + message="Unknown resource: manuals://../etc/passwd", + data={"uri": "manuals://../etc/passwd"}, + ) + ) + + +def test_dotdot_is_a_component_check_not_a_substring_scan() -> None: + """The page's prose: `v1.0..v2.0` passes because `..` is not a standalone path segment.""" + assert contains_path_traversal("../etc") is True + assert contains_path_traversal("v1.0..v2.0") is False + + +async def test_safe_join_serves_a_file_inside_the_base_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """tutorial002: `safe_join(DOCS_ROOT, path).read_text()` returns the file under the base.""" + (tmp_path / "printing").mkdir() + (tmp_path / "printing" / "setup.md").write_text("# Printer setup") + monkeypatch.setattr(tutorial002, "DOCS_ROOT", tmp_path) + async with Client(tutorial002.mcp) as client: + (content,) = (await client.read_resource("manuals://printing/setup.md")).contents + assert isinstance(content, TextResourceContents) + assert content.text == "# Printer setup" + + +def test_safe_join_raises_when_the_resolved_path_escapes_the_base(tmp_path: Path) -> None: + """tutorial002: a path that climbs out of `DOCS_ROOT` raises `PathEscapeError`.""" + with pytest.raises(PathEscapeError): + safe_join(tmp_path, "../etc/passwd") + + +async def test_exempt_params_lets_an_absolute_path_through() -> None: + """tutorial003: `exempt_params={"source"}` skips the checks for that one parameter.""" + async with Client(tutorial003.mcp) as client: + (content,) = (await client.read_resource("imports://preview//srv/incoming/catalog.csv")).contents + assert isinstance(content, TextResourceContents) + assert content.text == "Would import from /srv/incoming/catalog.csv" + + +async def test_server_wide_resource_security_relaxes_every_resource() -> None: + """tutorial003: `resource_security=ResourceSecurity(reject_path_traversal=False)` exempts the whole server.""" + async with Client(tutorial003.relaxed) as client: + (content,) = (await client.read_resource("imports://preview/../sibling/catalog.csv")).contents + assert isinstance(content, TextResourceContents) + assert content.text == "Would import from ../sibling/catalog.csv" + + +async def test_lowlevel_static_dispatch_lists_and_reads_by_exact_uri() -> None: + """tutorial004: the registry is the listing, and a known URI returns its text.""" + async with Client(tutorial004.server) as client: + listed = (await client.list_resources()).resources + assert [r.uri for r in listed] == ["config://shop", "status://health"] + (content,) = (await client.read_resource("status://health")).contents + assert content == TextResourceContents(uri="status://health", text="ok") + + +async def test_lowlevel_unknown_uri_raises() -> None: + """tutorial004: a URI outside the registry raises and surfaces as a protocol error.""" + async with Client(tutorial004.server) as client: + with pytest.raises(MCPError): + await client.read_resource("config://missing") + + +def test_uritemplate_match_returns_a_dict_or_none() -> None: + """tutorial005: `match()` extracts decoded variables, or `None` when the URI doesn't fit.""" + assert tutorial005.TEMPLATES["manuals"].match("manuals://printing/setup.md") == {"path": "printing/setup.md"} + assert tutorial005.TEMPLATES["books"].match("manuals://nope") is None + + +async def test_lowlevel_match_routes_the_request_to_the_right_template() -> None: + """tutorial005: two templates, one handler. Each concrete URI lands in its own branch.""" + async with Client(tutorial005.server) as client: + (manual,) = (await client.read_resource("manuals://printing/setup.md")).contents + assert manual == TextResourceContents(uri="manuals://printing/setup.md", text="# Printer setup") + (book,) = (await client.read_resource("books://978-0441172719")).contents + assert book == TextResourceContents(uri="books://978-0441172719", text="Dune by Frank Herbert") + + +async def test_lowlevel_handler_applies_the_safety_checks_itself() -> None: + """tutorial005: there is no default policy down here; `read_manual_safely` is the gate.""" + async with Client(tutorial005.server) as client: + with pytest.raises(MCPError): + await client.read_resource("manuals://../etc/passwd") + with pytest.raises(MCPError): + await client.read_resource("nothing://matches") + + +async def test_str_of_a_template_round_trips_to_the_original_string() -> None: + """tutorial005: `str(template)` is the source string, so the listing reuses the parsed templates.""" + assert str(tutorial005.TEMPLATES["manuals"]) == "manuals://{+path}" + async with Client(tutorial005.server) as client: + result = await client.list_resource_templates() + assert result.resource_templates == snapshot( + [ + ResourceTemplate(name="manuals", uri_template="manuals://{+path}"), + ResourceTemplate(name="books", uri_template="books://{isbn}"), + ] + ) diff --git a/tests/server/mcpserver/resources/test_resource_template.py b/tests/server/mcpserver/resources/test_resource_template.py index 565afe81a7..58c072ae32 100644 --- a/tests/server/mcpserver/resources/test_resource_template.py +++ b/tests/server/mcpserver/resources/test_resource_template.py @@ -9,6 +9,152 @@ from mcp.server.mcpserver import Context, MCPServer from mcp.server.mcpserver.exceptions import ResourceError from mcp.server.mcpserver.resources import FunctionResource, ResourceTemplate +from mcp.server.mcpserver.resources.templates import ( + DEFAULT_RESOURCE_SECURITY, + ResourceSecurity, + ResourceSecurityError, +) + + +def _make(uri_template: str, security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY) -> ResourceTemplate: + def handler(**kwargs: Any) -> str: + raise NotImplementedError # these tests only exercise matches() + + return ResourceTemplate.from_function(fn=handler, uri_template=uri_template, security=security) + + +def test_matches_rfc6570_reserved_expansion(): + # {+path} allows / — the feature the old regex implementation couldn't support + t = _make("file://docs/{+path}") + assert t.matches("file://docs/src/main.py") == {"path": "src/main.py"} + + +def test_matches_rejects_encoded_slash_traversal(): + # %2F decodes to / in UriTemplate.match(), giving "../../etc/passwd". + # ResourceSecurity's traversal check then rejects the '..' components. + t = _make("file://docs/{name}") + with pytest.raises(ResourceSecurityError, match="'name'"): + t.matches("file://docs/..%2F..%2Fetc%2Fpasswd") + + +def test_matches_rejects_path_traversal_by_default(): + t = _make("file://docs/{name}") + with pytest.raises(ResourceSecurityError): + t.matches("file://docs/..") + + +def test_matches_rejects_path_traversal_in_reserved_var(): + # Even {+path} gets the traversal check — it's semantic, not structural + t = _make("file://docs/{+path}") + with pytest.raises(ResourceSecurityError): + t.matches("file://docs/../../etc/passwd") + + +def test_matches_rejects_absolute_path(): + t = _make("file://docs/{+path}") + with pytest.raises(ResourceSecurityError): + t.matches("file://docs//etc/passwd") + + +def test_matches_allows_dotdot_as_substring(): + # .. is only dangerous as a path component + t = _make("git://refs/{range}") + assert t.matches("git://refs/v1.0..v2.0") == {"range": "v1.0..v2.0"} + + +def test_matches_exempt_params_skip_security(): + policy = ResourceSecurity(exempt_params={"range"}) + t = _make("git://diff/{+range}", security=policy) + assert t.matches("git://diff/../foo") == {"range": "../foo"} + + +def test_matches_disabled_policy_allows_traversal(): + policy = ResourceSecurity(reject_path_traversal=False, reject_absolute_paths=False) + t = _make("file://docs/{name}", security=policy) + assert t.matches("file://docs/..") == {"name": ".."} + + +def test_matches_rejects_null_byte_by_default(): + # %00 decodes to \x00 which defeats string comparisons + # ("..\x00" != "..") and can truncate in C extensions. + t = _make("file://docs/{name}") + with pytest.raises(ResourceSecurityError): + t.matches("file://docs/key%00.txt") + # Null byte also defeats the traversal check's component comparison + with pytest.raises(ResourceSecurityError): + t.matches("file://docs/..%00%2Fsecret") + + +def test_matches_null_byte_check_can_be_disabled(): + policy = ResourceSecurity(reject_null_bytes=False) + t = _make("file://docs/{name}", security=policy) + assert t.matches("file://docs/key%00.txt") == {"name": "key\x00.txt"} + + +def test_security_rejection_does_not_fall_through_to_next_template(): + # A strict template's security rejection must halt iteration, not + # fall through to a later permissive template. Previously matches() + # returned None for both "no match" and "security failed", making + # registration order security-critical. + strict = _make("file://docs/{name}") + lax = _make( + "file://docs/{+path}", + security=ResourceSecurity(exempt_params={"path"}), + ) + uri = "file://docs/..%2Fsecrets" + # Strict matches structurally then fails security -> raises. + with pytest.raises(ResourceSecurityError) as exc: + strict.matches(uri) + assert exc.value.param == "name" + # If this raised, the resource manager never reaches the lax + # template. Verify the lax template WOULD have accepted it. + assert lax.matches(uri) == {"path": "../secrets"} + + +def test_matches_explode_checks_each_segment(): + t = _make("api{/parts*}") + assert t.matches("api/a/b/c") == {"parts": ["a", "b", "c"]} + # Any segment with traversal rejects the whole match + with pytest.raises(ResourceSecurityError): + t.matches("api/a/../c") + + +def test_matches_encoded_backslash_caught_by_traversal_check(): + # %5C decodes to '\\'. The traversal check normalizes '\\' to '/' + # and catches the '..' components. + t = _make("file://docs/{name}") + with pytest.raises(ResourceSecurityError): + t.matches("file://docs/..%5C..%5Csecret") + + +def test_matches_encoded_dots_caught_by_traversal_check(): + # %2E%2E decodes to '..' which the traversal check rejects. + t = _make("file://docs/{name}") + with pytest.raises(ResourceSecurityError): + t.matches("file://docs/%2E%2E") + + +def test_matches_mixed_encoded_and_literal_slash(): + # The literal '/' stops the simple-var regex, so the URI doesn't + # match the template at all. + t = _make("file://docs/{name}") + assert t.matches("file://docs/..%2F../etc") is None + + +def test_matches_encoded_slash_without_traversal_allowed(): + # %2F decoding to '/' is fine when there's no traversal involved. + # UriTemplate accepts it; ResourceSecurity only blocks '..' and + # absolute paths. Handlers that need single-segment should use + # safe_join or validate explicitly. + t = _make("file://docs/{name}") + assert t.matches("file://docs/sub%2Ffile.txt") == {"name": "sub/file.txt"} + + +def test_matches_escapes_template_literals(): + # Regression: old impl treated . as regex wildcard + t = _make("data://v1.0/{id}") + assert t.matches("data://v1.0/42") == {"id": "42"} + assert t.matches("data://v1X0/42") is None class TestResourceTemplate: diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 2c58b8fdea..70855f44b2 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -43,13 +43,14 @@ from mcp.client import Client from mcp.server.context import ServerRequestContext -from mcp.server.mcpserver import Context, MCPServer +from mcp.server.mcpserver import Context, MCPServer, ResourceSecurity from mcp.server.mcpserver.exceptions import ResourceNotFoundError, ToolError from mcp.server.mcpserver.prompts.base import Message, UserMessage from mcp.server.mcpserver.resources import FileResource, FunctionResource from mcp.server.mcpserver.utilities.types import Audio, Image from mcp.server.transport_security import TransportSecuritySettings from mcp.shared.exceptions import MCPError +from mcp.shared.uri_template import InvalidUriTemplate pytestmark = pytest.mark.anyio @@ -862,7 +863,7 @@ async def test_resource_with_params(self): parameters don't match""" mcp = MCPServer() - with pytest.raises(ValueError, match="Mismatch between URI parameters"): + with pytest.raises(ValueError, match="has no URI template variables"): @mcp.resource("resource://data") def get_data_fn(param: str) -> str: # pragma: no cover @@ -1489,6 +1490,258 @@ def prompt_fn(name: str) -> str: ... # pragma: no branch await client.get_prompt("prompt_fn") +async def test_resource_decorator_rfc6570_reserved_expansion(): + # Regression: old regex-based param extraction couldn't see `path` + # in `{+path}` and failed with a confusing mismatch error. + mcp = MCPServer() + + @mcp.resource("file://docs/{+path}") + def read_doc(path: str) -> str: + raise NotImplementedError + + templates = await mcp.list_resource_templates() + assert [t.uri_template for t in templates] == ["file://docs/{+path}"] + + +async def test_resource_decorator_rejects_malformed_template(): + mcp = MCPServer() + with pytest.raises(InvalidUriTemplate, match="Unclosed expression"): + mcp.resource("file://{name") + + +async def test_resource_optional_query_params_use_function_defaults(): + """Omitted {?...} query params should fall through to the + handler's Python defaults. Partial and reordered params work.""" + mcp = MCPServer() + + @mcp.resource("logs://{service}{?since,level}") + def tail_logs(service: str, since: str = "1h", level: str = "info") -> str: + return f"{service}|{since}|{level}" + + async with Client(mcp) as client: + # No query → all defaults + r = await client.read_resource("logs://api") + assert isinstance(r.contents[0], TextResourceContents) + assert r.contents[0].text == "api|1h|info" + + # Partial query → one default + r = await client.read_resource("logs://api?since=15m") + assert isinstance(r.contents[0], TextResourceContents) + assert r.contents[0].text == "api|15m|info" + + # Reordered, both present + r = await client.read_resource("logs://api?level=error&since=5m") + assert isinstance(r.contents[0], TextResourceContents) + assert r.contents[0].text == "api|5m|error" + + # Extra param ignored + r = await client.read_resource("logs://api?since=2h&utm=x") + assert isinstance(r.contents[0], TextResourceContents) + assert r.contents[0].text == "api|2h|info" + + +async def test_resource_query_param_without_default_rejected_at_decoration(): + """A handler parameter bound to a {?...} query variable must have a + Python default: a client may omit a query parameter, so the handler has + to be callable without it. Omitting the default is an error when the + decorator runs, not on the first request that leaves the parameter out.""" + mcp = MCPServer() + + with pytest.raises(ValueError, match=r"logs://.*\['level'\].*must declare a default"): + + @mcp.resource("logs://{service}{?level}") + def tail_logs(service: str, level: str) -> str: + raise NotImplementedError + + +async def test_resource_path_param_without_default_accepted(): + """The default requirement applies only to query-bound parameters. + A path variable is always present in a matching URI, so its handler + parameter may be required.""" + mcp = MCPServer() + + @mcp.resource("logs://{service}{?level}") + def tail_logs(service: str, level: str = "info") -> str: + raise NotImplementedError + + templates = await mcp.list_resource_templates() + assert [t.uri_template for t in templates] == ["logs://{service}{?level}"] + + +async def test_resource_security_default_rejects_traversal(): + mcp = MCPServer() + + @mcp.resource("data://items/{name}") + def get_item(name: str) -> str: + return f"item:{name}" + + async with Client(mcp) as client: + # Safe value passes through to the handler + r = await client.read_resource("data://items/widget") + assert isinstance(r.contents[0], TextResourceContents) + assert r.contents[0].text == "item:widget" + + # ".." as a path component is rejected by default policy + with pytest.raises(MCPError, match="Unknown resource"): + await client.read_resource("data://items/..") + + +async def test_resource_template_non_match_is_unknown_resource(): + """A URI that doesn't satisfy a registered template — including one + shorter than the template's literal segments — must surface as the + standard -32602 Unknown resource, not an internal error.""" + mcp = MCPServer() + + @mcp.resource("api://{+path}/{id}") + def get(path: str, id: str) -> str: + return f"{path}|{id}" + + async with Client(mcp) as client: + with pytest.raises(MCPError) as exc_info: + await client.read_resource("api://foo") + assert exc_info.value.error.code == INVALID_PARAMS + assert exc_info.value.error.message == "Unknown resource: api://foo" + + # And a satisfying URI still routes to the handler. + r = await client.read_resource("api://a/b/c") + assert isinstance(r.contents[0], TextResourceContents) + assert r.contents[0].text == "a/b|c" + + +async def test_resource_security_rejection_indistinguishable_from_not_found(): + """A path-safety rejection must produce the same wire error as a + genuinely-absent resource: same code, same message shape, no hint + about which check failed.""" + mcp = MCPServer() + + @mcp.resource("data://items/{name}") + def get_item(name: str) -> str: # pragma: no cover - never reached + return name + + async with Client(mcp) as client: + with pytest.raises(MCPError) as rejected: + await client.read_resource("data://items/..") + with pytest.raises(MCPError) as absent: + await client.read_resource("nosuch://thing") + + assert rejected.value.error.code == absent.value.error.code == INVALID_PARAMS + # Message echoes the requested URI and nothing else; no + # reference to which validation step rejected it. + assert rejected.value.error.message == "Unknown resource: data://items/.." + assert absent.value.error.message == "Unknown resource: nosuch://thing" + assert rejected.value.error.data == {"uri": "data://items/.."} + assert absent.value.error.data == {"uri": "nosuch://thing"} + + +async def test_resource_security_per_resource_override(): + mcp = MCPServer() + + @mcp.resource( + "git://diff/{+range}", + security=ResourceSecurity(exempt_params={"range"}), + ) + def git_diff(range: str) -> str: + return f"diff:{range}" + + async with Client(mcp) as client: + # "../foo" would be rejected by default, but "range" is exempt + result = await client.read_resource("git://diff/../foo") + assert isinstance(result.contents[0], TextResourceContents) + assert result.contents[0].text == "diff:../foo" + + +async def test_resource_security_server_wide_override(): + mcp = MCPServer(resource_security=ResourceSecurity(reject_path_traversal=False)) + + @mcp.resource("data://items/{name}") + def get_item(name: str) -> str: + return f"item:{name}" + + async with Client(mcp) as client: + # Server-wide policy disabled traversal check; ".." now allowed + result = await client.read_resource("data://items/..") + assert isinstance(result.contents[0], TextResourceContents) + assert result.contents[0].text == "item:.." + + +async def test_resource_security_namespaced_identifier_requires_exempt(): + """Single-letter-colon values like ``x:y`` are flagged by the + default absolute-path check (they parse as Windows drive-relative, + which discards the join base). A non-filesystem parameter that + legitimately accepts such values opts out via ``exempt_params``.""" + mcp = MCPServer() + + @mcp.resource("data://items/{id}") + def get_item(id: str) -> str: # pragma: no cover - rejected before call + return f"item:{id}" + + async with Client(mcp) as client: + with pytest.raises(MCPError, match="Unknown resource") as exc: + await client.read_resource("data://items/x:y") + assert exc.value.error.code == INVALID_PARAMS + + # Exempting the parameter lets the value through. + mcp = MCPServer() + + @mcp.resource("data://items/{id}", security=ResourceSecurity(exempt_params={"id"})) + def get_item_exempt(id: str) -> str: + return f"item:{id}" + + async with Client(mcp) as client: + r = await client.read_resource("data://items/x:y") + assert isinstance(r.contents[0], TextResourceContents) + assert r.contents[0].text == "item:x:y" + + +async def test_resource_security_rejection_halts_template_iteration(): + """A strict template's security rejection must surface as + not-found and stop; a later permissive template must not be + reached.""" + mcp = MCPServer() + + @mcp.resource("file://docs/{name}") + def strict(name: str) -> str: # pragma: no cover - never reached + return name + + @mcp.resource( + "file://docs/{+path}", + security=ResourceSecurity(exempt_params={"path"}), + ) + def lax(path: str) -> str: # pragma: no cover - must not be reached + raise AssertionError("permissive template reached after security rejection") + + async with Client(mcp) as client: + with pytest.raises(MCPError) as exc: + await client.read_resource("file://docs/..%2Fsecrets") + assert exc.value.error.code == INVALID_PARAMS + assert "Unknown resource" in exc.value.error.message + + +async def test_static_resource_with_context_param_errors(): + """A non-template URI with a Context-only handler should error + at decoration time with a clear message, not silently register + an unreachable resource.""" + mcp = MCPServer() + + with pytest.raises(ValueError, match="Context injection for static resources is not supported"): + + @mcp.resource("weather://current") + def current_weather(ctx: Context) -> str: + raise NotImplementedError + + +async def test_static_resource_with_extra_params_errors(): + """A non-template URI with non-Context params should error at + decoration time.""" + mcp = MCPServer() + + with pytest.raises(ValueError, match="has no URI template variables"): + + @mcp.resource("data://fixed") + def get_data(name: str) -> str: + raise NotImplementedError + + async def test_completion_decorator() -> None: """Test that the completion decorator registers a working handler.""" mcp = MCPServer() diff --git a/tests/shared/test_path_security.py b/tests/shared/test_path_security.py new file mode 100644 index 0000000000..46bf111a5e --- /dev/null +++ b/tests/shared/test_path_security.py @@ -0,0 +1,159 @@ +"""Tests for filesystem path safety primitives.""" + +from pathlib import Path + +import pytest + +from mcp.shared.path_security import ( + PathEscapeError, + contains_path_traversal, + is_absolute_path, + safe_join, +) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + # Safe: no traversal + ("a/b/c", False), + ("readme.txt", False), + ("", False), + (".", False), + ("./a/b", False), + # Safe: .. balanced by prior descent + ("a/../b", False), + ("a/b/../c", False), + ("a/b/../../c", False), + # Unsafe: net escape + ("..", True), + ("../etc", True), + ("../../etc/passwd", True), + ("a/../../b", True), + ("./../../etc", True), + # .. as substring, not component — safe + ("1.0..2.0", False), + ("foo..bar", False), + ("..foo", False), + ("foo..", False), + # Backslash separator + ("..\\etc", True), + ("a\\..\\..\\b", True), + ("a\\b\\c", False), + # Mixed separators + ("a/..\\..\\b", True), + ], +) +def test_contains_path_traversal(value: str, expected: bool): + assert contains_path_traversal(value) is expected + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + # Relative + ("relative/path", False), + ("file.txt", False), + ("", False), + (".", False), + ("..", False), + # POSIX absolute + ("/", True), + ("/etc/passwd", True), + ("/a", True), + # Windows drive + ("C:", True), + ("C:\\Windows", True), + ("c:/foo", True), + ("Z:\\", True), + # Windows UNC / backslash-absolute + ("\\\\server\\share", True), + ("\\foo", True), + # Windows drive-relative — discards the join base when drives differ + ("C:relative", True), + ("x:y", True), + ("a:debug", True), + # Not a drive: digit before colon + ("1:foo", False), + # Colon not in position 1 + ("ab:c", False), + # Non-ASCII letter is not a drive letter + ("Ω:namespace", False), + ("é:foo", False), + ], +) +def test_is_absolute_path(value: str, expected: bool): + assert is_absolute_path(value) is expected + + +def test_safe_join_simple(tmp_path: Path): + result = safe_join(tmp_path, "docs", "readme.txt") + assert result == tmp_path / "docs" / "readme.txt" + + +def test_safe_join_resolves_relative_base(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.chdir(tmp_path) + result = safe_join(".", "file.txt") + assert result == tmp_path / "file.txt" + + +def test_safe_join_rejects_dotdot_escape(tmp_path: Path): + with pytest.raises(PathEscapeError, match="escapes base"): + safe_join(tmp_path, "../../../etc/passwd") + + +def test_safe_join_rejects_balanced_then_escape(tmp_path: Path): + with pytest.raises(PathEscapeError, match="escapes base"): + safe_join(tmp_path, "a/../../etc") + + +def test_safe_join_allows_balanced_dotdot(tmp_path: Path): + result = safe_join(tmp_path, "a/../b") + assert result == tmp_path / "b" + + +def test_safe_join_rejects_absolute_part(tmp_path: Path): + with pytest.raises(PathEscapeError, match="is absolute"): + safe_join(tmp_path, "/etc/passwd") + + +def test_safe_join_rejects_absolute_in_later_part(tmp_path: Path): + with pytest.raises(PathEscapeError, match="is absolute"): + safe_join(tmp_path, "docs", "/etc/passwd") + + +def test_safe_join_rejects_windows_drive(tmp_path: Path): + with pytest.raises(PathEscapeError, match="is absolute"): + safe_join(tmp_path, "C:\\Windows\\System32") + + +def test_safe_join_rejects_null_byte(tmp_path: Path): + with pytest.raises(PathEscapeError, match="null byte"): + safe_join(tmp_path, "file\0.txt") + + +def test_safe_join_rejects_null_byte_in_later_part(tmp_path: Path): + with pytest.raises(PathEscapeError, match="null byte"): + safe_join(tmp_path, "docs", "file\0.txt") + + +def test_safe_join_rejects_symlink_escape(tmp_path: Path): + outside = tmp_path / "outside" + outside.mkdir() + sandbox = tmp_path / "sandbox" + sandbox.mkdir() + (sandbox / "escape").symlink_to(outside) + + with pytest.raises(PathEscapeError, match="escapes base"): + safe_join(sandbox, "escape", "secret.txt") + + +def test_safe_join_base_equals_target(tmp_path: Path): + # Joining nothing (or ".") should return the base itself + assert safe_join(tmp_path) == tmp_path + assert safe_join(tmp_path, ".") == tmp_path + + +def test_path_escape_error_is_value_error(): + with pytest.raises(ValueError): + safe_join("/tmp", "/etc") diff --git a/tests/shared/test_uri_template.py b/tests/shared/test_uri_template.py new file mode 100644 index 0000000000..48f6c66237 --- /dev/null +++ b/tests/shared/test_uri_template.py @@ -0,0 +1,1001 @@ +"""Tests for RFC 6570 URI template parsing, expansion, and matching.""" + +import dataclasses +import random +import string + +import pytest + +from mcp.shared.uri_template import DEFAULT_MAX_URI_LENGTH, InvalidUriTemplate, UriTemplate, Variable + + +def test_parse_literal_only(): + tmpl = UriTemplate.parse("file://docs/readme.txt") + assert tmpl.variables == [] + assert tmpl.variable_names == [] + assert str(tmpl) == "file://docs/readme.txt" + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ("file://docs/{name}", True), + ("file://docs/readme.txt", False), + ("", False), + ("{a}", True), + ("{", False), + ("}", False), + ("}{", False), + ("prefix{+path}/suffix", True), + ("{invalid syntax but still a template}", True), + ], +) +def test_is_template(value: str, expected: bool): + assert UriTemplate.is_template(value) is expected + + +def test_parse_simple_variable(): + tmpl = UriTemplate.parse("file://docs/{name}") + assert tmpl.variables == [Variable(name="name", operator="")] + assert tmpl.variable_names == ["name"] + + +@pytest.mark.parametrize( + ("template", "operator"), + [ + ("{+path}", "+"), + ("{#frag}", "#"), + ("{.ext}", "."), + ("{/seg}", "/"), + ("{;param}", ";"), + ("{?q}", "?"), + ("{&next}", "&"), + ], +) +def test_parse_all_operators(template: str, operator: str): + tmpl = UriTemplate.parse(template) + (var,) = tmpl.variables + assert var.operator == operator + assert var.explode is False + + +def test_parse_multiple_variables_in_expression(): + tmpl = UriTemplate.parse("{?q,lang,page}") + assert tmpl.variable_names == ["q", "lang", "page"] + assert all(v.operator == "?" for v in tmpl.variables) + + +def test_parse_multiple_expressions(): + tmpl = UriTemplate.parse("db://{table}/{id}{?format}") + assert tmpl.variable_names == ["table", "id", "format"] + ops = [v.operator for v in tmpl.variables] + assert ops == ["", "", "?"] + + +@pytest.mark.parametrize( + ("template", "expected"), + [ + ("logs://{service}{?a,b}", frozenset({"a", "b"})), + ("logs://{service}{?a,b}{&c}", frozenset({"a", "b", "c"})), + ("logs://{service}", frozenset[str]()), + # A lone {&...} never emits the leading ? that lenient query + # matching splits on, so it is matched strictly: c must be + # present in the URI and is not an optional query variable. + ("logs://{service}{&c}", frozenset[str]()), + ], +) +def test_query_variable_names(template: str, expected: frozenset[str]): + """query_variable_names is exactly the set match() treats as optional: + the trailing {?...}/{&...} variables a client may omit from the URI.""" + assert UriTemplate.parse(template).query_variable_names == expected + + +def test_parse_explode_modifier(): + tmpl = UriTemplate.parse("/files{/path*}") + (var,) = tmpl.variables + assert var.name == "path" + assert var.operator == "/" + assert var.explode is True + + +@pytest.mark.parametrize("template", ["{.labels*}", "{;params*}"]) +def test_parse_explode_supported_operators(template: str): + tmpl = UriTemplate.parse(template) + assert tmpl.variables[0].explode is True + + +def test_parse_mixed_explode_and_plain(): + tmpl = UriTemplate.parse("{/path*}{?q}") + assert tmpl.variables == [ + Variable(name="path", operator="/", explode=True), + Variable(name="q", operator="?"), + ] + + +def test_parse_varname_with_dots_and_underscores(): + tmpl = UriTemplate.parse("{foo_bar.baz}") + assert tmpl.variable_names == ["foo_bar.baz"] + + +def test_parse_rejects_unclosed_expression(): + with pytest.raises(InvalidUriTemplate, match="Unclosed expression") as exc: + UriTemplate.parse("file://{name") + assert exc.value.position == 7 + assert exc.value.template == "file://{name" + + +def test_parse_rejects_empty_expression(): + with pytest.raises(InvalidUriTemplate, match="Empty expression"): + UriTemplate.parse("file://{}") + + +def test_parse_rejects_operator_without_variable(): + with pytest.raises(InvalidUriTemplate, match="operator but no variables"): + UriTemplate.parse("{+}") + + +@pytest.mark.parametrize( + "name", + [ + "-bad", + "bad-name", + "bad name", + "bad/name", + # RFC §2.3: dots only between varchars, not consecutive or trailing + "foo..bar", + "foo.", + ], +) +def test_parse_rejects_invalid_varname(name: str): + with pytest.raises(InvalidUriTemplate, match="Invalid variable name"): + UriTemplate.parse(f"{{{name}}}") + + +def test_parse_accepts_dotted_varname(): + t = UriTemplate.parse("{a.b.c}") + assert t.variable_names == ["a.b.c"] + + +def test_parse_rejects_empty_spec_in_list(): + with pytest.raises(InvalidUriTemplate, match="Invalid variable name"): + UriTemplate.parse("{a,,b}") + + +def test_parse_rejects_prefix_modifier(): + with pytest.raises(InvalidUriTemplate, match="Prefix modifier"): + UriTemplate.parse("{var:3}") + + +@pytest.mark.parametrize("template", ["{var*}", "{+var*}", "{#var*}", "{?var*}", "{&var*}"]) +def test_parse_rejects_unsupported_explode(template: str): + with pytest.raises(InvalidUriTemplate, match="Explode modifier"): + UriTemplate.parse(template) + + +@pytest.mark.parametrize( + "template", + [ + "{/a*}/x{/b*}", # two explode vars: a literal between them doesn't help + # Multi-var + expression: each var is greedy (',' separates them) + "{+a,b}", + # Two {+var}/{#var} anywhere + "{+a}/x/{+b}", + "{+a},{+b}", + "{#a}/x/{+b}", + "{+a}.foo.{#b}", + ], +) +def test_parse_rejects_multiple_multi_segment_variables(template: str): + # Two multi-segment variables make matching inherently ambiguous: + # there is no principled way to decide which one absorbs an extra + # segment. The linear scan can only partition the URI around a + # single greedy slot. (Two ADJACENT multi-segment variables are + # caught by the adjacency rule first; see the test below.) + with pytest.raises(InvalidUriTemplate, match="more than one multi-segment"): + UriTemplate.parse(template) + + +@pytest.mark.parametrize( + "template", + [ + # Two bounded variables + "{a}{b}", + "{.a}{b}", + "{/a}{b}", + "{;a}{b}", + "{a}{b}X{+p}", + "{+p}X{a}{b}", + "pre{a}{b}post", + # A bounded variable adjacent to the multi-segment variable + "{a}{+b}", + "{+a}{b}", + "{#a}{b}", + "{.a}{+b}", + "{/a}{+b}", + "x{name}{+path}y", + "X{+a}{b}", + "{+p}{n}", + "{x}Y{+p}{n}", + "{?a}{+b}x", + # ... on either side, with a literal on the OTHER side + "{a}-{+p}{b}", + "{a}{+p}-{b}", + "{name}{+path}{.ext}", + "{base}{+p}{;k}", + # ... or on both sides + "{a}{+b}{c}", + "{a}{+p}{b}Y{c}", + "X{a}{+p}{b}Y{c}", + "{a}{/p*}{b}", + # An explode variable carries its operator's separators inside + # the capture, so it emits no lead literal that could anchor it + "{a}{/p*}", + "{/seg}{;k*}", + "item://{id}{;opts*}", + # ifemp: the ';key' literal anchors the LEFT edge of {;key}, but + # nothing separates its right edge from the multi-segment var + "api{;key}{+rest}", + # Two multi-segment variables that are ALSO adjacent + "{/a*}{/b*}", + "{/a*}{.b*}", + "{.a*}{;b*}", + "{/a*}{b}{.c*}", + "{+a}{/b*}", + ], +) +def test_parse_rejects_adjacent_variables(template: str) -> None: + # Two captures with no literal between them give the scan nothing to + # anchor the boundary on — whether or not one of them is the + # multi-segment variable. + with pytest.raises(InvalidUriTemplate, match="adjacent with no literal separator"): + UriTemplate.parse(template) + + +@pytest.mark.parametrize( + "template", + [ + "file://docs/{+path}", # + at end of template + "file://{+path}.txt", # + followed by literal only + "file://{+path}/edit", # + followed by literal only + "api/{+path}{?v,page}", # + followed by query tail (split off before scan) + "api/{+path}{&next}", # + followed by query-continuation + "page{#section}", # # at end + "{a}{#b}", # # emits a literal '#' that anchors the boundary + "{+a}/sep/{b}", # + with bounded vars after + "{+a},{b}", + # Operators that emit their own lead character ('.', '/', ';name') + # supply the literal anchor, so these are NOT adjacent variables. + "{+a}{/b}", + "{+a}{.b}", + "{+a}{;b}", + "{+path}{.ext}", + "prefix/{+path}{.ext}", + "tree://nodes{/path*}", + "api{;key}/{+rest}", + ], +) +def test_parse_allows_single_multi_segment_variable(template: str): + # One multi-segment variable is fine: the linear scan isolates it + # between the prefix and suffix boundaries, and the scan never + # backtracks so match time stays O(n) regardless of URI content. + t = UriTemplate.parse(template) + assert t is not None + + +@pytest.mark.parametrize( + "template", + ["{x}/{x}", "{x,x}", "{a}{b}{a}", "{+x}/foo/{x}"], +) +def test_parse_rejects_duplicate_variable_names(template: str): + with pytest.raises(InvalidUriTemplate, match="appears more than once"): + UriTemplate.parse(template) + + +@pytest.mark.parametrize( + "template", + ["/x{?a}{?b}", "/x{?a}/y{?b}", "{?a}{&b}{?c}"], +) +def test_parse_rejects_multiple_query_expressions(template: str) -> None: + with pytest.raises(InvalidUriTemplate, match=r"more than one \{\?"): + UriTemplate.parse(template) + + +def test_query_tail_roundtrip_correct_spellings() -> None: + for tmpl in ("/x{?a,b}", "/x{?a}{&b}"): + t = UriTemplate.parse(tmpl) + assert t.match(t.expand({"a": "1", "b": "2"})) == {"a": "1", "b": "2"} + + +def test_invalid_uri_template_is_value_error(): + with pytest.raises(ValueError): + UriTemplate.parse("{}") + + +@pytest.mark.parametrize( + "template", + [ + "{{name}}", # nested open: body becomes "{name" + "{a{b}c}", # brace inside expression + "{{]{}}{}", # garbage soup + "{a,{b}", # brace in comma list + ], +) +def test_parse_rejects_nested_braces(template: str): + # Nested/stray { inside an expression lands in the varname and + # fails the varname regex rather than needing special handling. + with pytest.raises(InvalidUriTemplate, match="Invalid variable name"): + UriTemplate.parse(template) + + +@pytest.mark.parametrize( + ("template", "position"), + [ + ("{", 0), + ("{{", 0), + ("file://{name", 7), + ("{a}{", 3), + ("}{", 1), # stray } is literal, then unclosed { + ], +) +def test_parse_rejects_unclosed_brace(template: str, position: int): + with pytest.raises(InvalidUriTemplate, match="Unclosed") as exc: + UriTemplate.parse(template) + assert exc.value.position == position + + +@pytest.mark.parametrize( + "template", + ["}}", "}", "a}b", "{a}}{b}"], +) +def test_parse_treats_stray_close_brace_as_literal(template: str): + # RFC 6570 §2.1 strictly excludes } from literals, but we accept it + # for TypeScript SDK parity. A stray } almost always indicates a + # typo; rejecting would be more helpful but would also break + # cross-SDK behavior. + tmpl = UriTemplate.parse(template) + assert str(tmpl) == template + + +def test_parse_stray_close_brace_between_expressions(): + tmpl = UriTemplate.parse("{a}}{b}") + assert tmpl.variable_names == ["a", "b"] + + +def test_parse_rejects_oversized_template(): + with pytest.raises(InvalidUriTemplate, match="maximum length"): + UriTemplate.parse("x" * 101, max_length=100) + + +def test_parse_rejects_too_many_variables(): + template = "".join(f"{{v{i}}}" for i in range(11)) + with pytest.raises(InvalidUriTemplate, match="maximum of 10 variables"): + UriTemplate.parse(template, max_variables=10) + + +def test_parse_counts_variables_not_expressions(): + # A single {v0,v1,...} expression packs many variables under one + # brace pair. Counting expressions would miss this. + template = "{" + ",".join(f"v{i}" for i in range(11)) + "}" + with pytest.raises(InvalidUriTemplate, match="maximum of 10 variables"): + UriTemplate.parse(template, max_variables=10) + + +def test_parse_custom_limits_allow_larger(): + template = "/".join(f"{{v{i}}}" for i in range(20)) + tmpl = UriTemplate.parse(template, max_variables=20) + assert len(tmpl.variables) == 20 + + +def test_equality_based_on_template_string(): + a = UriTemplate.parse("file://{name}") + b = UriTemplate.parse("file://{name}") + c = UriTemplate.parse("file://{other}") + assert a == b + assert a != c + assert hash(a) == hash(b) + + +def test_frozen(): + tmpl = UriTemplate.parse("{x}") + with pytest.raises(dataclasses.FrozenInstanceError): + tmpl.template = "changed" # type: ignore[misc] + + +@pytest.mark.parametrize( + ("template", "variables", "expected"), + [ + # Level 1: simple, encodes reserved chars + ("{var}", {"var": "value"}, "value"), + ("{var}", {"var": "hello world"}, "hello%20world"), + ("{var}", {"var": "a/b"}, "a%2Fb"), + ("file://docs/{name}", {"name": "readme.txt"}, "file://docs/readme.txt"), + # Level 2: reserved expansion keeps / ? # etc. + ("{+var}", {"var": "a/b/c"}, "a/b/c"), + ("{+var}", {"var": "a?b#c"}, "a?b#c"), + # RFC §3.2.3: reserved expansion passes through existing + # pct-triplets unchanged; bare % is still encoded. + ("{+var}", {"var": "path%2Fto"}, "path%2Fto"), + ("{+var}", {"var": "50%"}, "50%25"), + ("{+var}", {"var": "50%2"}, "50%252"), + ("{+var}", {"var": "a%2Fb%20c"}, "a%2Fb%20c"), + ("{#var}", {"var": "a%2Fb"}, "#a%2Fb"), + # Simple expansion still encodes % unconditionally (triplet + # preservation is reserved-only). + ("{var}", {"var": "path%2Fto"}, "path%252Fto"), + ("file://docs/{+path}", {"path": "src/main.py"}, "file://docs/src/main.py"), + # Level 2: fragment + ("{#var}", {"var": "section"}, "#section"), + ("{#var}", {"var": "a/b"}, "#a/b"), + # Level 3: label + ("file{.ext}", {"ext": "txt"}, "file.txt"), + # Level 3: path segment + ("{/seg}", {"seg": "docs"}, "/docs"), + # Level 3: path-style param + ("{;id}", {"id": "42"}, ";id=42"), + ("{;id}", {"id": ""}, ";id"), + # Level 3: query + ("{?q}", {"q": "search"}, "?q=search"), + ("{?q}", {"q": ""}, "?q="), + ("/search{?q,lang}", {"q": "mcp", "lang": "en"}, "/search?q=mcp&lang=en"), + # Level 3: query continuation + ("?a=1{&b}", {"b": "2"}, "?a=1&b=2"), + # Multi-var in one expression + ("{x,y}", {"x": "1", "y": "2"}, "1,2"), + # {+x,y} is rejected at parse time: each var in a + expression + # is multi-segment, and a template may only have one. + # Sequence values, non-explode (comma-join) + ("{/list}", {"list": ["a", "b", "c"]}, "/a,b,c"), + ("{?list}", {"list": ["a", "b"]}, "?list=a,b"), + # Explode: each item gets separator + ("{/path*}", {"path": ["a", "b", "c"]}, "/a/b/c"), + ("{.labels*}", {"labels": ["x", "y"]}, ".x.y"), + ("{;keys*}", {"keys": ["a", "b"]}, ";keys=a;keys=b"), + # RFC §3.2.7 ifemp: ; omits = for empty explode items + ("{;keys*}", {"keys": ["a", "", "b"]}, ";keys=a;keys;keys=b"), + # RFC §3.2.7 ifemp: ; omits = for empty, including non-explode list [""] + ("{;name}", {"name": [""]}, ";name"), + ("{;name}", {"name": ["", ""]}, ";name=,"), + ("{?name}", {"name": [""]}, "?name="), + ("{&name}", {"name": [""]}, "&name="), + ("{;name}", {"name": ""}, ";name"), + # Undefined variables omitted + ("{?q,page}", {"q": "x"}, "?q=x"), + ("{a,b}", {"a": "x"}, "x"), + ("{?page}", {}, ""), + # Empty sequence omitted + ("{/path*}", {"path": []}, ""), + # Literal-only template + ("file://static", {}, "file://static"), + ], +) +def test_expand(template: str, variables: dict[str, str | list[str]], expected: str): + assert UriTemplate.parse(template).expand(variables) == expected + + +def test_expand_encodes_special_chars_in_simple(): + t = UriTemplate.parse("{v}") + assert t.expand({"v": "a&b=c"}) == "a%26b%3Dc" + + +def test_expand_preserves_special_chars_in_reserved(): + t = UriTemplate.parse("{+v}") + assert t.expand({"v": "a&b=c"}) == "a&b=c" + + +@pytest.mark.parametrize( + "value", + [42, None, 3.14, {"a": "b"}, ["ok", 42], b"bytes"], +) +def test_expand_rejects_invalid_value_types(value: object): + t = UriTemplate.parse("{v}") + with pytest.raises(TypeError, match="must be str or a sequence of str"): + t.expand({"v": value}) # type: ignore[dict-item] + + +@pytest.mark.parametrize( + ("template", "uri", "expected"), + [ + # Level 1: simple + ("{var}", "hello", {"var": "hello"}), + ("file://docs/{name}", "file://docs/readme.txt", {"name": "readme.txt"}), + ("{a}/{b}", "foo/bar", {"a": "foo", "b": "bar"}), + # Level 2: reserved allows / + ("file://docs/{+path}", "file://docs/src/main.py", {"path": "src/main.py"}), + ("{+var}", "a/b/c", {"var": "a/b/c"}), + # Level 2: fragment + ("page{#section}", "page#intro", {"section": "intro"}), + # A multi-segment var next to an operator that emits its own + # lead character: the lead ('.', '/', '#') is a literal anchor, + # so these are NOT two adjacent variables. + ("{+path}{/name}", "a/b/c/readme", {"path": "a/b/c", "name": "readme"}), + ("{+path}{.ext}", "src/main.py", {"path": "src/main", "ext": "py"}), + ("prefix/{+path}{.ext}", "prefix/a/b.txt", {"path": "a/b", "ext": "txt"}), + ("{#section}{/page}", "#intro/1", {"section": "intro", "page": "1"}), + # Bounded vars before the multi-segment var match lazily (first + # anchor); those after match greedily (last anchor). + ("{owner}@{+path}", "alice@src/main", {"owner": "alice", "path": "src/main"}), + ("{+path}@{name}", "src@main@v1", {"path": "src@main", "name": "v1"}), + # Level 3: label + ("file{.ext}", "file.txt", {"ext": "txt"}), + # Level 3: path segment + ("api{/version}", "api/v1", {"version": "v1"}), + # Level 3: path-style param + ("item{;id}", "item;id=42", {"id": "42"}), + ("item{;id}", "item;id", {"id": ""}), + # Explode: ; emits name=value per item, match strips the prefix + ("item{;keys*}", "item;keys=a;keys=b", {"keys": ["a", "b"]}), + ("item{;keys*}", "item;keys=a;keys;keys=b", {"keys": ["a", "", "b"]}), + ("item{;keys*}", "item", {"keys": []}), + # Level 3: query. Lenient matching: partial, reordered, and + # extra params are all accepted. Absent params stay absent. + ("search{?q}", "search?q=hello", {"q": "hello"}), + ("search{?q}", "search?q=", {"q": ""}), + ("search{?q}", "search", {}), + ("search{?q,lang}", "search?q=mcp&lang=en", {"q": "mcp", "lang": "en"}), + ("search{?q,lang}", "search?lang=en&q=mcp", {"q": "mcp", "lang": "en"}), + ("search{?q,lang}", "search?q=mcp", {"q": "mcp"}), + ("search{?q,lang}", "search", {}), + ("search{?q}", "search?q=mcp&utm=x&ref=y", {"q": "mcp"}), + # URL-encoded query values are decoded + ("search{?q}", "search?q=hello%20world", {"q": "hello world"}), + # + is a literal sub-delim per RFC 3986, not a space (form-encoding) + ("search{?q}", "search?q=C++", {"q": "C++"}), + ("search{?q}", "search?q=1.0+build.5", {"q": "1.0+build.5"}), + # Fragment is stripped before query parsing + ("logs://{service}{?level}", "logs://api?level=error#section1", {"service": "api", "level": "error"}), + ("search{?q}", "search#frag", {}), + # Multiple ?/& expressions collected together + ("api{?v}{&page,limit}", "api?limit=10&v=2", {"v": "2", "limit": "10"}), + # Standalone {&var} falls through to the strict scan (expands + # with & prefix, no ? for lenient matching to split on) + ("api{&page}", "api&page=2", {"page": "2"}), + # Literal ? in path portion falls through to the strict scan + ("api?x{?page}", "api?x?page=2", {"page": "2"}), + # {#...} or literal # in path portion falls through: lenient + # matching would strip the fragment before the path scan sees it + ("page{#section}{?q}", "page#intro?q=x", {"section": "intro", "q": "x"}), + ("page#lit{?q}", "page#lit?q=x", {"q": "x"}), + # Empty & segments in query are skipped + ("search{?q}", "search?&q=hello&", {"q": "hello"}), + # Duplicate query keys keep first value + ("search{?q}", "search?q=first&q=second", {"q": "first"}), + # Percent-encoded parameter names are NOT decoded: RFC 6570 + # expansion never encodes names, so an encoded name cannot be + # a legitimate match. Prevents HTTP parameter pollution. + ("api://x{?token}", "api://x?%74oken=evil&token=real", {"token": "real"}), + ("api://x{?token}", "api://x?%74oken=evil", {}), + # Level 3: query continuation with literal ? falls back to + # the strict scan (template-order, all-present required) + ("?a=1{&b}", "?a=1&b=2", {"b": "2"}), + # Explode: path segments as list + ("/files{/path*}", "/files/a/b/c", {"path": ["a", "b", "c"]}), + ("/files{/path*}", "/files", {"path": []}), + ("/files{/path*}/edit", "/files/a/b/edit", {"path": ["a", "b"]}), + # Explode: labels + ("host{.labels*}", "host.example.com", {"labels": ["example", "com"]}), + # Repeated-slash literals preserved exactly + ("///{a}////{b}////", "///x////y////", {"a": "x", "b": "y"}), + ], +) +def test_match(template: str, uri: str, expected: dict[str, str | list[str]]): + assert UriTemplate.parse(template).match(uri) == expected + + +@pytest.mark.parametrize( + ("template", "uri"), + [ + ("file://docs/{name}", "file://other/readme.txt"), + ("{a}/{b}", "foo"), + ("file{.ext}", "file"), + ("static", "different"), + # Anchoring: trailing extra component must not match. Guards + # against a refactor from fullmatch() to match() or search(). + ("/users/{id}", "/users/123/extra"), + ("/users/{id}/posts/{pid}", "/users/1/posts/2/extra"), + # Repeated-slash literal with wrong slash count + ("///{a}////{b}////", "//x////y////"), + # ; name boundary: {;id} must not match a longer parameter name + ("item{;id}", "item;identity=john"), + ("item{;id}", "item;ident"), + # ; explode: wrong parameter name in any segment rejects the match + ("item{;keys*}", "item;admin=true"), + ("item{;keys*}", "item;keys=a;admin=true"), + # Lenient-query branch: path portion fails to match + ("api/{name}{?q}", "wrong/path?q=x"), + # Lenient-query branch: ; explode name mismatch in path portion + ("item{;keys*}{?q}", "item;wrong=x?q=1"), + ], +) +def test_match_no_match(template: str, uri: str): + assert UriTemplate.parse(template).match(uri) is None + + +def test_match_explode_preserves_empty_list_items(): + # Splitting the explode capture on its separator yields a leading + # empty item from the operator prefix; only that one is stripped. + # Subsequent empties are legitimate values from the input list. + t = UriTemplate.parse("{/path*}") + assert t.match("/a//c") == {"path": ["a", "", "c"]} + assert t.match("//a") == {"path": ["", "a"]} + assert t.match("/a/") == {"path": ["a", ""]} + + t = UriTemplate.parse("host{.labels*}") + assert t.match("host.a..c") == {"labels": ["a", "", "c"]} + + +def test_match_adjacent_vars_disambiguated_by_literal(): + # A literal between vars resolves the ambiguity. + t = UriTemplate.parse("{a}-{b}") + assert t.match("foo-bar") == {"a": "foo", "b": "bar"} + + +@pytest.mark.parametrize( + ("template", "variables"), + [ + # Leading literal appears inside the value: must anchor at + # position 0, not rfind to the rightmost occurrence. + ("prefix-{id}", {"id": "prefix-123"}), + ("u{s}", {"s": "xu"}), + ("_{x}", {"x": "_"}), + ("~{v}~", {"v": "~~~"}), + # Multi-occurrence with two vars: rfind correctly picks the + # rightmost literal BETWEEN vars, first literal anchors at 0. + ("L{a}L{b}", {"a": "xLy", "b": "z"}), + # Leading literal with stop-char: earliest bound still applies. + ("api/{name}", {"name": "api"}), + ], +) +def test_match_leading_literal_appears_in_value(template: str, variables: dict[str, str]): + # Regression: the R->L scan used rfind for the preceding literal, + # which lands inside the value when the template's leading literal + # is a substring of the expanded value. The first atom must anchor + # at position 0, not search. + t = UriTemplate.parse(template) + uri = t.expand(variables) + assert t.match(uri) == variables + + +@pytest.mark.parametrize( + ("template", "uri"), + [ + # Greedy var whose suffix literal is absent from the input. + ("{a}-{+b}x", "-" * 200), + # Chained anchors that all appear in input but suffix fails. + ("{a}L{b}L{c}L{d}M", "L" * 200), + ], +) +def test_match_no_backtracking_on_pathological_input(template: str, uri: str): + # These patterns caused O(n²) or worse backtracking under the regex + # matcher. The linear scan returns None without retrying splits. + # (Correctness check only; we benchmark separately to avoid flaky + # timing assertions in CI.) + assert UriTemplate.parse(template).match(uri) is None + + +@pytest.mark.parametrize( + ("template", "uri"), + [ + # Prefix literal mismatch before a greedy var + ("file://{+path}", "http://x"), + # Suffix literal absent: the suffix scan fails before the prefix runs + ("file://{+path}.txt", "file://x"), + # Prefix anchor not found: {a} needs '@' before greedy but none exists + ("{a}@{+path}", "no-at-sign-here"), + # Prefix literal doesn't fit within suffix boundary + ("foo{+a}oob", "fooob"), + # Greedy scalar contains its own stop-char ({+var} stops at ?) + ("api://{+path}", "api://foo?bar"), + # Explode span doesn't start with its separator + ("X{/path*}", "Xnoslash"), + # Explode body contains a non-separator stop-char + ("X{/path*}", "X/a?b"), + # ifemp name continuation: the literal after {;key} doesn't start + # at pos and there's no '=', so the URI's name kept going. + ("api{;key}suffix/{+p}", "api;keyZ/x"), + # Regression: suffix scan must not walk back into prefix territory. + # Input is shorter than prefix+suffix literals — these used to + # raise AssertionError instead of returning None. + ("api://{+path}/{id}", "api://foo"), + ("docs/{+path}/v/{name}", "docs/v/x"), + ], +) +def test_match_greedy_rejection_paths(template: str, uri: str): + assert UriTemplate.parse(template).match(uri) is None + + +@pytest.mark.parametrize( + ("template", "uri", "expected"), + [ + # ifemp before a literal that itself starts with '=': the literal + # check runs first so '=' is not mistaken for the ifemp separator. + ("api{;key}=base/{+path}", "api;key=base/a/b", {"key": "", "path": "a/b"}), + ("api{;key}=base/{+path}", "api;key=v=base/x", {"key": "v", "path": "x"}), + ], +) +def test_match_prefix_scan_edge_cases(template: str, uri: str, expected: dict[str, str]): + assert UriTemplate.parse(template).match(uri) == expected + + +@pytest.mark.parametrize( + ("template", "uri", "expected"), + [ + # Suffix-side ifemp: '=' inside the value is preserved — the + # value '=' is the first one after ;name, not the last. + ("item{;id}", "item;id=a=b", {"id": "a=b"}), + ("{;a}{;b}", ";a=x=y;b=z", {"a": "x=y", "b": "z"}), + ], +) +def test_match_suffix_ifemp_equals_in_value(template: str, uri: str, expected: dict[str, str]): + assert UriTemplate.parse(template).match(uri) == expected + + +def test_match_prefix_ifemp_empty_before_non_stop_literal(): + # Regression: _scan_prefix rejected the empty-value case when the + # following template literal starts with a non-stop-char. The + # name-continuation guard saw 'X' after ';key' and assumed the + # name continued, but 'X' is the template's next literal. + t = UriTemplate.parse("api{;key}X{+rest}") + # Non-empty round-trips fine: + assert t.match(t.expand({"key": "abc", "rest": "/tail"})) == {"key": "abc", "rest": "/tail"} + # Empty value (ifemp → bare ;key, then X) must also round-trip: + uri = t.expand({"key": "", "rest": "/tail"}) + assert uri == "api;keyX/tail" + assert t.match(uri) == {"key": "", "rest": "/tail"} + # But an actual name continuation still rejects: + assert t.match("api;keyZX/tail") is None + + +def test_match_large_uri_against_greedy_template(): + # Large payload against a greedy template — the scan visits each + # character once for the suffix anchor and once for the greedy + # validation, so this is O(n) not O(n²). + t = UriTemplate.parse("{+path}/end") + body = "seg/" * 15000 + result = t.match(body + "end") + assert result == {"path": body[:-1]} + # And the failing case returns None without retrying splits. + assert t.match(body + "nope") is None + + +def test_match_decodes_percent_encoding(): + t = UriTemplate.parse("file://docs/{name}") + assert t.match("file://docs/hello%20world.txt") == {"name": "hello world.txt"} + + +def test_match_escapes_template_literals(): + # Regression: previous impl didn't escape . in literals, making it + # a regex wildcard. "fileXtxt" should NOT match "file.txt/{id}". + t = UriTemplate.parse("file.txt/{id}") + assert t.match("file.txt/42") == {"id": "42"} + assert t.match("fileXtxt/42") is None + + +@pytest.mark.parametrize( + ("template", "uri", "expected"), + [ + # Percent-encoded delimiters round-trip through match/expand. + # Path-safety validation belongs to ResourceSecurity, not here. + ("file://docs/{name}", "file://docs/a%2Fb", {"name": "a/b"}), + ("{var}", "a%3Fb", {"var": "a?b"}), + ("{var}", "a%23b", {"var": "a#b"}), + ("{var}", "a%26b", {"var": "a&b"}), + ("file{.ext}", "file.a%2Eb", {"ext": "a.b"}), + ("api{/v}", "api/a%2Fb", {"v": "a/b"}), + ("search{?q}", "search?q=a%26b", {"q": "a&b"}), + ("{;filter}", ";filter=a%3Bb", {"filter": "a;b"}), + ], +) +def test_match_encoded_delimiters_roundtrip(template: str, uri: str, expected: dict[str, str]): + assert UriTemplate.parse(template).match(uri) == expected + + +def test_match_reserved_expansion_handles_slash(): + # {+var} allows literal / (not just encoded) + t = UriTemplate.parse("{+path}") + assert t.match("a%2Fb") == {"path": "a/b"} + assert t.match("a/b") == {"path": "a/b"} + + +def test_match_double_encoding_decoded_once(): + # %252F is %2F encoded again. Single decode gives "%2F" (a literal + # percent sign, a '2', and an 'F'). Guards against over-decoding. + t = UriTemplate.parse("file://docs/{name}") + assert t.match("file://docs/..%252Fetc") == {"name": "..%2Fetc"} + + +def test_match_rejects_oversized_uri(): + t = UriTemplate.parse("{var}") + assert t.match("x" * 100, max_uri_length=50) is None + + +def test_match_accepts_uri_within_custom_limit(): + t = UriTemplate.parse("{var}") + assert t.match("x" * 100, max_uri_length=200) == {"var": "x" * 100} + + +def test_match_default_uri_length_limit(): + t = UriTemplate.parse("{+var}") + # Just at the limit: should match + assert t.match("x" * DEFAULT_MAX_URI_LENGTH) is not None + # One over: should reject + assert t.match("x" * (DEFAULT_MAX_URI_LENGTH + 1)) is None + + +def test_match_explode_encoded_separator_in_segment(): + # An encoded separator inside a segment decodes as part of the value, + # not as a split point. The split happens at literal separators only. + t = UriTemplate.parse("/files{/path*}") + assert t.match("/files/a%2Fb/c") == {"path": ["a/b", "c"]} + + +@pytest.mark.parametrize( + ("template", "variables"), + [ + ("{var}", {"var": "hello"}), + ("file://docs/{name}", {"name": "readme.txt"}), + ("file://docs/{+path}", {"path": "src/main.py"}), + ("search{?q,lang}", {"q": "mcp", "lang": "en"}), + ("file{.ext}", {"ext": "txt"}), + ("/files{/path*}", {"path": ["a", "b", "c"]}), + ("{var}", {"var": "hello world"}), + ("item{;id}", {"id": "42"}), + ("item{;id}", {"id": ""}), + # Defined-but-empty values still emit the operator prefix; match + # must accept the empty capture after it. + ("page{#section}", {"section": ""}), + ("file{.ext}", {"ext": ""}), + ("api{/v}", {"v": ""}), + ("x{name}y", {"name": ""}), + ("item{;keys*}", {"keys": ["a", "b", "c"]}), + ("item{;keys*}", {"keys": ["a", "", "b"]}), + # Empty strings in explode lists round-trip for unnamed operators + ("{/path*}", {"path": ["a", "", "c"]}), + ("{/path*}", {"path": ["", "a"]}), + ("host{.labels*}", {"labels": ["a", "", "c"]}), + # Partial query expansion round-trips: expand omits undefined + # vars, match leaves them absent from the result. + ("logs://{service}{?since,level}", {"service": "api"}), + ("logs://{service}{?since,level}", {"service": "api", "since": "1h"}), + ("logs://{service}{?since,level}", {"service": "api", "since": "1h", "level": "error"}), + ("api{;key}=base/{+path}", {"key": "", "path": "a/b"}), + ], +) +def test_roundtrip_expand_then_match(template: str, variables: dict[str, str | list[str]]): + t = UriTemplate.parse(template) + uri = t.expand(variables) + assert t.match(uri) == variables + + +def test_match_simple_var_accepts_empty() -> None: + # RFC 6570 §3.2.2: {var} with var="" expands to nothing, so the inverse + # must accept it. v1.x's [^/]+ regex did not — see migration guide. + t = UriTemplate.parse("tickets://{ticket_id}") + assert t.match("tickets://") == {"ticket_id": ""} + assert t.match("tickets://42") == {"ticket_id": "42"} + + +# --- Property tests over the generated template space ------------------------ +# +# The two tests below generate template strings instead of enumerating +# examples, so the contracts they state are checked over the whole space +# `parse()` accepts in a single deterministic run. The generator deliberately +# produces strings the parser rejects (adjacent variables, two greedy +# variables, unsupported explode placements, a second `{?...}` expression) and +# relies on `parse()` to filter them: pre-selecting "known good" shapes would +# only ever exercise the shapes someone already thought of. + +_PROPERTY_SEED = 20260626 +_PROPERTY_OPERATORS = ["", "+", "#", ".", "/", ";", "?", "&"] +# Literal runs draw from URI punctuation (`- . / ~ _`) plus uppercase letters. +# Values draw only from lowercase letters and digits. The two alphabets are +# disjoint, so a round-trip failure can never be explained away as a value +# colliding with a literal, an operator prefix, or a separator. +_LITERAL_CHARS = "XY-._~/Z" +_VALUE_CHARS = string.ascii_lowercase + string.digits +_FUZZ_CHARS = string.printable + + +def _random_template(rng: random.Random) -> tuple[str, list[tuple[str, bool]]]: + """Build a candidate template string plus the (name, explode) spec of each variable.""" + parts: list[str] = [] + specs: list[tuple[str, bool]] = [] + for _ in range(rng.randint(1, 5)): + if rng.random() < 0.45: + parts.append("".join(rng.choice(_LITERAL_CHARS) for _ in range(rng.randint(1, 2)))) + continue + operator = rng.choice(_PROPERTY_OPERATORS) + names: list[str] = [] + # Multi-variable expressions and the explode modifier are produced for + # every operator; `parse()` rejects the combinations it does not allow. + for _ in range(2 if rng.random() < 0.2 else 1): + name = f"v{len(specs)}" + explode = rng.random() < 0.25 + specs.append((name, explode)) + names.append(f"{name}*" if explode else name) + parts.append("{" + operator + ",".join(names) + "}") + return "".join(parts), specs + + +def _random_value(rng: random.Random) -> str: + """Draw a short (possibly empty) value from the literal-disjoint alphabet.""" + return "".join(rng.choice(_VALUE_CHARS) for _ in range(rng.randint(0, 4))) + + +def _random_values(specs: list[tuple[str, bool]], rng: random.Random) -> dict[str, str | list[str]]: + """Draw a value for every variable: a string, or a non-empty list for explode variables.""" + return { + name: [_random_value(rng) for _ in range(rng.randint(1, 3))] if explode else _random_value(rng) + for name, explode in specs + } + + +def _mangled_inputs(uri: str, rng: random.Random) -> list[str]: + """Mangle one expansion into a batch of candidate inputs for `match()`.""" + candidates = [uri, "", uri[::-1], uri * 2] + for _ in range(6): + chars = list(uri) + mutation = rng.randint(0, 2) + if mutation == 0 and chars: + del chars[rng.randrange(len(chars))] + elif mutation == 1: + chars.insert(rng.randint(0, len(chars)), rng.choice(_FUZZ_CHARS)) + elif chars: + chars[rng.randrange(len(chars))] = rng.choice(_FUZZ_CHARS) + candidates.append("".join(chars)) + candidates.extend("".join(rng.choice(_FUZZ_CHARS) for _ in range(rng.randint(0, 30))) for _ in range(3)) + return candidates + + +def test_match_inverts_expand_for_every_parseable_template() -> None: + """For every template the parser accepts, matching the template's own expansion + yields a value set that re-expands to the same URI. + + Exact equality with the original values is not required: a different + pre-image (e.g. an explode list that flattens) is a correct answer as long + as it re-expands identically. SDK-defined contract — RFC 6570 specifies + only expansion, so `match()` is the inverse the SDK promises. + """ + rng = random.Random(_PROPERTY_SEED) + accepted = 0 + for _ in range(600): + template, specs = _random_template(rng) + try: + t = UriTemplate.parse(template) + except InvalidUriTemplate: + continue + accepted += 1 + for _ in range(2): + values = _random_values(specs, rng) + uri = t.expand(values) + got = t.match(uri) + assert got is not None, f"{template!r} did not match its own expansion {uri!r} of {values!r}" + assert t.expand(got) == uri, f"{template!r}: match({uri!r}) -> {got!r}, which re-expands differently" + # Floor the accepted count so the property can never go vacuous: a future + # change that rejects every generated template would otherwise pass silently. + assert accepted >= 150 + + +def test_match_never_raises() -> None: + """`match()` returns a dict or None for every input string; it never raises. + + Each accepted template's own expansion is mangled (a character inserted, + deleted, or replaced from a wide printable alphabet; emptied; reversed; + doubled) alongside fully random strings. SDK-defined contract — a URI that + does not fit the template is a non-match, not an error. + """ + rng = random.Random(_PROPERTY_SEED) + calls = 0 + for _ in range(600): + template, specs = _random_template(rng) + try: + t = UriTemplate.parse(template) + except InvalidUriTemplate: + continue + uri = t.expand(_random_values(specs, rng)) + for candidate in _mangled_inputs(uri, rng): + result = t.match(candidate) + assert result is None or isinstance(result, dict), f"{template!r}: match({candidate!r}) -> {result!r}" + calls += 1 + # Floor the call count so the property can never go vacuous: a future + # change that rejects every generated template would otherwise pass silently. + assert calls >= 4000 From 3b78f86886d4a8525760112cd2cdd2568a9bdc18 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Fri, 26 Jun 2026 21:01:46 +0200 Subject: [PATCH 019/100] Add docs, tested examples, and a story for SEP-990 identity assertion (#3004) --- docs/advanced/authorization.md | 2 + docs/advanced/identity-assertion.md | 146 +++++++++++++ docs/advanced/oauth-clients.md | 2 + docs_src/identity_assertion/__init__.py | 0 docs_src/identity_assertion/tutorial001.py | 69 ++++++ docs_src/identity_assertion/tutorial002.py | 107 ++++++++++ examples/stories/README.md | 3 +- examples/stories/_shared/auth.py | 21 +- examples/stories/identity_assertion/README.md | 103 +++++++++ .../stories/identity_assertion/__init__.py | 0 examples/stories/identity_assertion/client.py | 69 ++++++ examples/stories/identity_assertion/idp.py | 43 ++++ examples/stories/identity_assertion/server.py | 110 ++++++++++ .../identity_assertion/server_lowlevel.py | 65 ++++++ examples/stories/manifest.toml | 5 + mkdocs.yml | 1 + tests/docs_src/test_identity_assertion.py | 196 ++++++++++++++++++ tests/docs_src/test_shape.py | 44 ---- 18 files changed, 937 insertions(+), 49 deletions(-) create mode 100644 docs/advanced/identity-assertion.md create mode 100644 docs_src/identity_assertion/__init__.py create mode 100644 docs_src/identity_assertion/tutorial001.py create mode 100644 docs_src/identity_assertion/tutorial002.py create mode 100644 examples/stories/identity_assertion/README.md create mode 100644 examples/stories/identity_assertion/__init__.py create mode 100644 examples/stories/identity_assertion/client.py create mode 100644 examples/stories/identity_assertion/idp.py create mode 100644 examples/stories/identity_assertion/server.py create mode 100644 examples/stories/identity_assertion/server_lowlevel.py create mode 100644 tests/docs_src/test_identity_assertion.py diff --git a/docs/advanced/authorization.md b/docs/advanced/authorization.md index 5f96571f4b..2afb3d5a07 100644 --- a/docs/advanced/authorization.md +++ b/docs/advanced/authorization.md @@ -109,6 +109,8 @@ To watch all three parties move, run `examples/servers/simple-auth/` from the SD server inside your MCP server. It predates the AS/RS separation that the MCP authorization spec is built around. New servers should not reach for it. +An authorization server can also accept an enterprise identity provider's signed assertion in place of a user clicking through a consent screen, and the SDK supports both sides of that exchange. The grant, and the client that presents it, is **Identity assertion**. + ## Recap * Over Streamable HTTP your server is an OAuth 2.1 **resource server**: it verifies tokens, it never issues them. diff --git a/docs/advanced/identity-assertion.md b/docs/advanced/identity-assertion.md new file mode 100644 index 0000000000..5a48c13c74 --- /dev/null +++ b/docs/advanced/identity-assertion.md @@ -0,0 +1,146 @@ +# Identity assertion + +Every provider in **OAuth clients** starts by asking the MCP server a question: *which authorization server do you trust?* It follows the answer wherever it points, and then either a person signs in or a pre-shared secret stands in for one. + +An enterprise wants neither decided per server. It already runs an identity provider (Okta, Microsoft Entra ID, your own); the user already signed in to it this morning; and it is the one place the security team wants to decide who may reach what. [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990), the **Enterprise-Managed Authorization** extension, moves the decision there. The IdP signs a short-lived JWT, an **Identity Assertion JWT Authorization Grant**, the **ID-JAG**: a statement that *this user*, through *this client*, may reach *this MCP server*. The client trades it for an ordinary access token. No browser, no consent screen, no dynamic registration. + +This chapter is both ends of that trade. The MCP server itself never changes: it is still the resource server from **Authorization**, checking whatever token shows up. + +## Two token requests + +Two different authorities are in play, and naming them apart is most of understanding this page. The **enterprise IdP** is your organization's identity provider: it knows who the employee is, it is where policy lives, and it issues the ID-JAG. The SDK never talks to it. The **MCP authorization server** is the same party it was in **Authorization**: the issuer named in the MCP server's metadata, the thing that mints the tokens that MCP server accepts. In the flows you already know, those two roles are usually one box. Here they are two, and the whole grant is the second agreeing to trust the first. + +The client makes one token request to each. + +1. **To the enterprise IdP.** The client trades the user's sign-in (their OpenID Connect ID token) for the ID-JAG. This is an RFC 8693 token exchange, it is entirely your IdP's API, and **the SDK does not make it**. You do, inside one async callback. It is also where the policy decision happens: an IdP that says no never issues the ID-JAG, and there is nothing to present. +2. **To the MCP authorization server.** The client presents the ID-JAG under the RFC 7523 `jwt-bearer` grant (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, the ID-JAG as `assertion`) and receives the access token. **This is the request the SDK makes**, and accepting it is the one thing this page adds to an authorization server. + +Everything below is the second request: the client that sends it and the authorization server that answers it. + +## The client + +**`IdentityAssertionOAuthProvider`** lives in `mcp.client.auth.extensions.identity_assertion`. Like every provider in **OAuth clients** it is an `httpx.Auth`: construct one, put it on `auth=`, hand the `httpx.AsyncClient` to the transport. + +```python title="client.py" hl_lines="49-50 53-61" +--8<-- "docs_src/identity_assertion/tutorial001.py" +``` + +Read it from the bottom. + +* `main()` is the `main()` from **OAuth clients**, line for line. That is the point: once the provider exists, nothing downstream knows which grant produced the token. +* The provider takes what the other providers cannot discover: a `client_id` and `client_secret` somebody **pre-registered** with the authorization server, that authorization server's `issuer`, and `assertion_provider`, an async callback that returns a fresh ID-JAG on demand. +* `storage` is the same `TokenStorage` protocol. Only the two token methods are ever called; there is no dynamic registration here, so there is no `client_info` to remember. + +### The assertion provider + +`fetch_id_jag(audience, resource)` is the only code you write. It is awaited once per token exchange, never at construction, and only *after* the authorization server's metadata has been fetched and validated, so a misconfigured issuer never leaks an assertion. Its two arguments are two of the claims the ID-JAG must be minted with: `audience` is the authorization server's issuer (the ID-JAG `aud`) and `resource` is the MCP server's canonical identifier (the ID-JAG `resource`). The third is one you already hold: the ID-JAG's `client_id` claim must name the `client_id` you gave the provider, or the authorization server refuses the exchange. + +`idp_issue_id_jag` above it is **not your code**. It stands in for the identity provider, signing the assertion in-process so the file is complete and you can read every claim an ID-JAG carries. A real `fetch_id_jag` makes the first token request of the previous section instead: an RFC 8693 token exchange against your IdP, defined by the Identity Assertion JWT Authorization Grant draft that SEP-990 profiles. The signed-in user's ID token goes in as the `subject_token`, the `requested_token_type` is the ID-JAG's own URN (`urn:ietf:params:oauth:token-type:id-jag`), `audience` and `resource` pass straight through, and the response carries the ID-JAG. That exchange, under those names, is what to look for in your IdP's documentation. + +!!! tip + A fresh ID-JAG is requested for every exchange, and that is the point: it is a single-use, + minutes-lived grant, and the authorization server on this page refuses to accept the same one + twice. Do not cache it. The access token it buys you is the thing that gets reused. + +### The issuer is configuration + +Here is the inversion. `OAuthClientProvider` asks the resource server which authorization server to use and follows the answer wherever it points. This provider refuses to: `issuer` is required, the RFC 8414 metadata is fetched from that issuer's own well-known path, the token endpoint must be on that issuer's origin, and the resource server is never asked anything. + +The extension does not demand this; it is a deliberately stricter choice. This client carries two things worth stealing, a pre-registered secret and an audience-bound assertion, and a client that let a compromised MCP server steer it to an attacker's authorization server would post both to it. Pinning the issuer at construction deletes that conversation. + +!!! warning + The configured `issuer` is compared to the metadata document's `issuer` field by RFC 8414 §3.3 + simple string comparison: character for character, trailing slash included, no normalization. + Do not guess it. Fetch `/.well-known/oauth-authorization-server` from your authorization server + and copy the `issuer` value it returns. For the authorization server on this page that is + `https://auth.example.com/`, with the slash, because its issuer was built from a pydantic URL + object. A mismatch stops the flow at `OAuthFlowError: Authorization server metadata issuer + mismatch` before a single credential or assertion is sent. + +### A confidential client + +`client_secret` is required; the constructor raises `ValueError` without one. The IETF profile underneath SEP-990 reserves this grant for confidential clients, SEP-990 requires the client to authenticate, and this SDK enforces both by insisting on a shared secret. `token_endpoint_auth_method` picks where it travels: `client_secret_post` (the default, in the form body) or `client_secret_basic` (an HTTP Basic header). The profile also permits `private_key_jwt`; this provider does not support it. + +!!! tip + Read `client_secret` from the environment or a secret manager, never from source control. + +### What the provider does for you + +The first request goes out unauthenticated, and the server's `401` starts the flow. + +1. **Discovery.** It fetches the authorization server metadata from the configured issuer's RFC 8414 well-known path, checks the document's `issuer` matches, and checks the token endpoint is on the issuer's origin. +2. **The assertion.** It awaits your `assertion_provider`. +3. **Exchange.** It POSTs the `jwt-bearer` grant to the token endpoint, stores the `OAuthToken`, and replays your original request with `Authorization: Bearer ...`. + +A `403` whose `WWW-Authenticate` names `insufficient_scope` runs steps 2 and 3 again with the union of your `scope` and the challenged one. (`scope` is only ever a request; this page's authorization server grants what the ID-JAG says and nothing else.) There is no refresh token anywhere in this: when the access token expires, the next `401` mints a fresh ID-JAG and exchanges again, and *that* is the lever the IdP holds. Failures are the same two exceptions as the rest of **OAuth clients**: `OAuthFlowError` for discovery and validation, its subclass `OAuthTokenError` when the token endpoint says no. + +## The authorization server + +Most of the time you stop here. The MCP authorization server is somebody else's product, accepting ID-JAGs is its configuration to turn on, and the SDK's half of SEP-990 is the client above. + +The SDK can also *be* the authorization server: `create_auth_routes` returns the authorization server's routes as a list any Starlette app can mount, which is how `examples/servers/simple-auth/` in the repository runs one. SEP-990 adds one flag and one method to that surface: + +```python title="auth_server.py" hl_lines="48-50 105-107" +--8<-- "docs_src/identity_assertion/tutorial002.py" +``` + +* `identity_assertion_enabled=True` gates everything. Off, which is the default, `/token` answers this grant with `unsupported_grant_type` even if you implemented the hook, and the metadata does not mention it. On, the metadata gains the `jwt-bearer` grant type and lists `urn:ietf:params:oauth:grant-profile:id-jag` in `authorization_grant_profiles_supported`, the field the extension uses to advertise support. (This SDK's client never reads it: it is provisioned for one issuer and simply asks.) +* **`exchange_identity_assertion`** is the hook. Before it runs, the SDK has authenticated the client, refused public clients, and refused clients whose registration does not list the grant. You get an `IdentityAssertionParams` (the raw `assertion`, the requested `scopes` and `resource`) and return a plain `OAuthToken`. +* Dynamic client registration refuses this grant unconditionally, so `get_client` here serves a hand-provisioned client. An ID-JAG client cannot register itself into existence. +* Half the class is refusals. `OAuthAuthorizationServerProvider` is the *whole* authorization server, so it also asks for the authorization-code flow; a server that signs users in as well implements those for real, and this one has exactly one door. + +!!! warning + The SDK never decodes the assertion: only your deployment knows which IdP it trusts and which + keys that IdP publishes, so everything inside `exchange_identity_assertion` is load-bearing. + Verify the signature against the IdP's published keys (its JWKS; the shared secret here is the + demo's), and `iss` and `exp`, per RFC 7523 §3. Require the JWT header's `typ` to be + `oauth-id-jag+jwt`, the profile's guard against some other JWT being replayed as a grant. + Require `aud` to be your own issuer. Require the ID-JAG's `client_id` claim to equal the client + the handler authenticated, and its `resource` claim to name a resource you actually serve. + Track `jti` until the assertion's `exp` so it is accepted once. And take the granted scopes + and, above all, the issued token's `resource` from the validated ID-JAG, never from the + request: `params.resource` is whatever the client typed. The full processing rules are in the + [Enterprise-Managed Authorization specification](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization). + +Reject a bad assertion with `TokenError("invalid_grant", ...)`. The other error code in this flow is `invalid_target`: an ID-JAG that names a resource you do not serve is refused with it, which is what stops this server minting tokens for somebody else's. And the granted scopes come from the ID-JAG's `scope` claim (an assertion without one is refused too); yours might map the user's groups instead. + +And notice what the returned `OAuthToken` does not carry: a refresh token. The IdP decides how long this user keeps access by deciding whether to issue the next ID-JAG. A refresh token minted here would quietly hand that decision back. + +!!! info + A server that still embeds its authorization server with `auth_server_provider=` reaches the same + code through `AuthSettings(identity_assertion_enabled=True)`. **Authorization** explains why new + servers should not start there. + +!!! check + Wire the two files on this page together and the whole grant is one `POST /token`: + + ```text + grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer + assertion=eyJhbGciOiJIUzI1NiIsInR5cCI6Im9hdXRoLWlkLWphZytqd3QifQ... + client_id=finance-agent + resource=http://localhost:8001/mcp + scope=notes:read + client_secret=finance-agent-secret + + HTTP/1.1 200 OK + {"access_token": "mcp_...", "token_type": "Bearer", "expires_in": 300, "scope": "notes:read"} + ``` + + No `/authorize`, no `/register`, no protected-resource-metadata fetch. The only requests on the + wire are the one that drew the `401`, the well-known fetch, this exchange, and then ordinary + MCP traffic with the bearer attached. And the `sub` your validator read out of the ID-JAG is + exactly what `get_access_token().subject` reports inside a tool. + +### Try it + +`examples/stories/identity_assertion/` in the SDK repository is this page running for real: the same `exchange_identity_assertion` validator, an MCP server gated on its tokens, a stand-in IdP, and the client, in one self-checking program. `uv run python -m stories.identity_assertion.client --http` runs the whole exchange and asserts that the user the IdP named is the user the tool sees. + +## Recap + +* SEP-990 lets the enterprise identity provider, not the end user, decide which MCP servers a client may reach. The IdP signs that decision into an **ID-JAG**. +* Obtaining the ID-JAG is an RFC 8693 token exchange against *your IdP*, and the SDK does not make it. Presenting it to the MCP authorization server is the RFC 7523 `jwt-bearer` grant, and the SDK does both sides of that. +* `IdentityAssertionOAuthProvider` is another `httpx.Auth`: a pre-registered confidential client, a pinned `issuer`, and one `assertion_provider(audience, resource)` callback. No browser, no registration, no refresh token. +* The authorization server is never discovered from the resource server. Configure `issuer` to exactly the string its metadata document serves; the comparison is character for character. +* Server side, `identity_assertion_enabled=True` plus `exchange_identity_assertion`. The SDK authenticates the client and gates the grant; validating the ID-JAG is entirely yours, and the issued token is bound to the ID-JAG's `resource`, not the request's. + +The one party this page never touched is the MCP server. What it does with the token you just minted, it was already doing in **Authorization**. diff --git a/docs/advanced/oauth-clients.md b/docs/advanced/oauth-clients.md index 5acbd92a7b..3407f02666 100644 --- a/docs/advanced/oauth-clients.md +++ b/docs/advanced/oauth-clients.md @@ -119,6 +119,8 @@ By default the secret travels as HTTP Basic auth on the token request (`client_s the same pattern: construct one, put it on `auth=`. The same module ships `SignedJWTParameters` and `static_assertion_provider`, two helpers that build its assertion. +There is one more no-human situation: the client belongs to an enterprise whose identity provider, not the user, decides which MCP servers it may reach. That is a different grant with its own trust model and its own chapter, **Identity assertion**. + ## When it fails When the OAuth flow goes wrong, the provider raises an `OAuthFlowError` from `mcp.client.auth`. It has two subclasses. `OAuthRegistrationError` means the authorization server refused to register you. `OAuthTokenError` means the token endpoint said no. One `except OAuthFlowError:` covers discovery, registration, authorization, and exchange. diff --git a/docs_src/identity_assertion/__init__.py b/docs_src/identity_assertion/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/identity_assertion/tutorial001.py b/docs_src/identity_assertion/tutorial001.py new file mode 100644 index 0000000000..8a7e9a050b --- /dev/null +++ b/docs_src/identity_assertion/tutorial001.py @@ -0,0 +1,69 @@ +import time +import uuid + +import httpx +import jwt + +from mcp import Client +from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken + +IDP_SIGNING_KEY = "the-enterprise-idp-signing-key" + + +class InMemoryTokenStorage: + def __init__(self) -> None: + self.tokens: OAuthToken | None = None + self.client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self.tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + self.tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self.client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self.client_info = client_info + + +def idp_issue_id_jag(subject: str, audience: str, resource: str) -> str: + now = int(time.time()) + claims = { + "iss": "https://idp.example.com", + "sub": subject, + "aud": audience, + "client_id": "finance-agent", + "resource": resource, + "scope": "notes:read", + "jti": str(uuid.uuid4()), + "iat": now, + "exp": now + 300, + } + return jwt.encode(claims, IDP_SIGNING_KEY, algorithm="HS256", headers={"typ": "oauth-id-jag+jwt"}) + + +async def fetch_id_jag(audience: str, resource: str) -> str: + return idp_issue_id_jag("alice@example.com", audience, resource) + + +oauth = IdentityAssertionOAuthProvider( + server_url="http://localhost:8001/mcp", + storage=InMemoryTokenStorage(), + client_id="finance-agent", + client_secret="finance-agent-secret", + issuer="https://auth.example.com/", + assertion_provider=fetch_id_jag, + scope="notes:read", +) + + +async def main() -> None: + async with httpx.AsyncClient(auth=oauth, follow_redirects=True) as http_client: + transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client) + async with Client(transport) as client: + result = await client.list_tools() + print([tool.name for tool in result.tools]) diff --git a/docs_src/identity_assertion/tutorial002.py b/docs_src/identity_assertion/tutorial002.py new file mode 100644 index 0000000000..d537069f18 --- /dev/null +++ b/docs_src/identity_assertion/tutorial002.py @@ -0,0 +1,107 @@ +import secrets +import time + +import jwt +from pydantic import AnyHttpUrl +from starlette.applications import Starlette + +from mcp.server.auth.provider import ( + AccessToken, + AuthorizationCode, + AuthorizationParams, + AuthorizeError, + IdentityAssertionParams, + OAuthAuthorizationServerProvider, + RefreshToken, + TokenError, +) +from mcp.server.auth.routes import create_auth_routes +from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken + +ISSUER = "https://auth.example.com/" +MCP_SERVER = "http://localhost:8001/mcp" +IDP_ISSUER = "https://idp.example.com" +IDP_SIGNING_KEY = "the-enterprise-idp-signing-key" + +REGISTERED_CLIENTS = { + "finance-agent": OAuthClientInformationFull( + client_id="finance-agent", + client_secret="finance-agent-secret", + redirect_uris=None, + grant_types=[JWT_BEARER_GRANT_TYPE], + token_endpoint_auth_method="client_secret_post", + ) +} + + +class EnterpriseAuthorizationServer(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]): + def __init__(self) -> None: + self.access_tokens: dict[str, AccessToken] = {} + self.seen_jtis: set[str] = set() + + async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: + return REGISTERED_CLIENTS.get(client_id) + + async def load_access_token(self, token: str) -> AccessToken | None: + return self.access_tokens.get(token) + + async def exchange_identity_assertion( + self, client: OAuthClientInformationFull, params: IdentityAssertionParams + ) -> OAuthToken: + try: + header = jwt.get_unverified_header(params.assertion) + claims = jwt.decode( + params.assertion, + IDP_SIGNING_KEY, + algorithms=["HS256"], + issuer=IDP_ISSUER, + audience=ISSUER, + options={"require": ["iss", "sub", "aud", "exp", "iat", "jti", "client_id", "resource", "scope"]}, + ) + except jwt.InvalidTokenError as error: + raise TokenError("invalid_grant", "the assertion did not verify") from error + if header.get("typ") != "oauth-id-jag+jwt": + raise TokenError("invalid_grant", "the assertion is not an ID-JAG") + if claims["client_id"] != client.client_id: + raise TokenError("invalid_grant", "the assertion was issued to a different client") + if claims["resource"] != MCP_SERVER: + raise TokenError("invalid_target", "the assertion is for a resource this server does not serve") + if claims["jti"] in self.seen_jtis: + raise TokenError("invalid_grant", "the assertion has already been used") + self.seen_jtis.add(claims["jti"]) + scopes = claims["scope"].split() + access_token = f"mcp_{secrets.token_hex(16)}" + self.access_tokens[access_token] = AccessToken( + token=access_token, + client_id=claims["client_id"], + scopes=scopes, + expires_at=int(time.time()) + 300, + resource=claims["resource"], + subject=claims["sub"], + ) + return OAuthToken(access_token=access_token, token_type="Bearer", expires_in=300, scope=" ".join(scopes)) + + async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: + raise AuthorizeError("unauthorized_client", "this authorization server only accepts ID-JAGs") + + async def load_authorization_code(self, client: OAuthClientInformationFull, authorization_code: str) -> None: + return None + + async def exchange_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode + ) -> OAuthToken: + raise TokenError("invalid_grant", "this authorization server only accepts ID-JAGs") + + async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> None: + return None + + async def exchange_refresh_token( + self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str] + ) -> OAuthToken: + raise TokenError("invalid_grant", "this authorization server only accepts ID-JAGs") + + +provider = EnterpriseAuthorizationServer() +auth_app = Starlette( + routes=create_auth_routes(provider, issuer_url=AnyHttpUrl(ISSUER), identity_assertion_enabled=True) +) diff --git a/examples/stories/README.md b/examples/stories/README.md index 93f04a014b..8b267f3925 100644 --- a/examples/stories/README.md +++ b/examples/stories/README.md @@ -128,6 +128,7 @@ opens with a banner saying what replaces it. | [`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 | +| [`mrtr`](mrtr/) | `InputRequiredResult` round-trip: the `Client` auto-loop and a manual session-level loop | 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 | @@ -150,9 +151,9 @@ opens with a banner saying what replaces it. | [`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 | +| [`identity_assertion`](identity_assertion/) | SEP-990 enterprise IdP flow: present an ID-JAG under the `jwt-bearer` grant | 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) | diff --git a/examples/stories/_shared/auth.py b/examples/stories/_shared/auth.py index 63079ad6fc..3bedcd3ab9 100644 --- a/examples/stories/_shared/auth.py +++ b/examples/stories/_shared/auth.py @@ -86,10 +86,17 @@ def __init__(self) -> None: 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: + def mint_access_token( + self, *, client_id: str, scopes: list[str], resource: str | None = None, subject: 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 + token=access, + client_id=client_id, + scopes=scopes, + expires_at=int(time.time()) + 3600, + resource=resource, + subject=subject, ) return access @@ -148,12 +155,18 @@ 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.""" +def auth_settings( + *, required_scopes: list[str] | None = None, identity_assertion_enabled: bool = False +) -> AuthSettings: + """``AuthSettings`` for the co-hosted demo AS+RS on the loopback origin, DCR enabled. + + ``identity_assertion_enabled`` passes through to the SEP-990 jwt-bearer grant flag. + """ 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), + identity_assertion_enabled=identity_assertion_enabled, ) diff --git a/examples/stories/identity_assertion/README.md b/examples/stories/identity_assertion/README.md new file mode 100644 index 0000000000..34746ec09c --- /dev/null +++ b/examples/stories/identity_assertion/README.md @@ -0,0 +1,103 @@ +# identity-assertion + +SEP-990 (Enterprise-Managed Authorization): the enterprise identity provider, +not the end user, decides which MCP servers a client may reach. The IdP signs +that decision into an Identity Assertion JWT Authorization Grant (an ID-JAG); +the client presents it to the MCP authorization server under the RFC 7523 +`jwt-bearer` grant and gets an ordinary, audience-restricted access token back. +No browser, no consent screen, no dynamic client registration, no refresh +token. This story co-hosts the authorization server and the bearer-gated MCP +server on one app, stands in for the IdP with an in-process signer, and proves +the user the IdP named is the user the tool sees. + +## Run it + +```bash +# HTTP, self-hosted: the client spawns the co-hosted AS + MCP app, presents an +# ID-JAG, and asserts `whoami` reports the IdP's subject. Self-hosting uses +# this story's fixed :8000 (the issuer/PRM metadata bake it in), so :8000 must +# be free. +uv run python -m stories.identity_assertion.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.identity_assertion.client --http --server server_lowlevel + +# against a server you run yourself (real uvicorn on :8000). The next section's +# curl probes use it too and `kill` it when done. +uv run python -m stories.identity_assertion.server --port 8000 & +SERVER_PID=$! +uv run python -m stories.identity_assertion.client --http http://127.0.0.1:8000/mcp +``` + +`Client(url)` has no `auth=` passthrough, so both runners thread the module's +`build_auth` export (an `IdentityAssertionOAuthProvider`) 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 +# the AS metadata advertises the jwt-bearer grant AND the ID-JAG grant profile +curl -s http://127.0.0.1:8000/.well-known/oauth-authorization-server \ + | jq '{grant_types_supported, authorization_grant_profiles_supported}' + +# dynamic client registration refuses the jwt-bearer grant: an ID-JAG client +# must be pre-registered out of band +curl -si http://127.0.0.1:8000/register -H 'content-type: application/json' \ + -d '{"redirect_uris":["http://localhost:3030/cb"],"grant_types":["authorization_code","urn:ietf:params:oauth:grant-type:jwt-bearer"]}' \ + | head -1 + +# done with the server you started in "Run it" +kill "$SERVER_PID" +``` + +## What to look at + +- `client.py` `fetch_id_jag` — the one seam the SDK leaves you: given the + authorization server's issuer and the MCP server's resource identifier, + return a fresh ID-JAG. In production this is an RFC 8693 token exchange + against your IdP; here it calls the stand-in signer in `idp.py`. +- `client.py` `build_auth` — `IdentityAssertionOAuthProvider` is the same + `httpx.Auth` shape as every other provider. Note `issuer=ISSUER` with the + trailing slash: the provider compares it to the metadata document's `issuer` + by simple string comparison and refuses a mismatch before sending anything. +- `server.py` `exchange_identity_assertion` — the whole authorization-server + hook. The SDK authenticates the client and gates the grant; the signature, + `typ`, `aud`, `client_id`-match, `jti`-replay, and audience-restriction + checks inside the hook are the implementation's job. +- `server.py` `build_app` — `auth_settings(identity_assertion_enabled=True)` + is the one flag. Off (the default), `/token` answers the grant with + `unsupported_grant_type` even when the hook is implemented. +- `idp.py` — the claims an ID-JAG carries (`iss`, `sub`, `aud`, `client_id`, + `resource`, `scope`, `jti`, `iat`, `exp`) and its `typ: oauth-id-jag+jwt` + header. + +## Caveats + +- The IdP here is a module, not a service, and it signs with a shared HMAC + secret so the client process and a separately launched server process agree + on it. A real IdP signs with its private key, the authorization server + verifies against the IdP's published JWKS, and the client obtains the ID-JAG + over the network with an RFC 8693 token exchange. +- Co-hosting the authorization server and the MCP server on one app + (`auth_server_provider=`) is a demo convenience. SEP-990's model keeps them + separate, and either way the client only ever learns about the authorization + server from its own configuration, never from the MCP server. +- The provider's state is in-memory demo state: `seen_jtis` and the issued + tokens only ever grow. A real server evicts a `jti` once the assertion's + `exp` has passed and expires tokens out of its own store. +- `transport_security=NO_DNS_REBIND` is harness-only; drop it for a real + deployment. +- Auth is HTTP-only; over stdio or the in-memory transport there is no gate. + +## Spec + +[Enterprise-Managed Authorization (SEP-990)](https://modelcontextprotocol.io/extensions/auth/enterprise-managed-authorization) +· RFC 7523 (JWT bearer grant: the leg the SDK implements) +· RFC 8693 (token exchange: the IdP leg the SDK leaves to you) +· `draft-ietf-oauth-identity-assertion-authz-grant` (the ID-JAG profile) + +## See also + +`oauth/` (the interactive `authorization_code` grant) · +`oauth_client_credentials/` (machine to machine, no user at all) · +`bearer_auth/` (the resource-server half on its own). diff --git a/examples/stories/identity_assertion/__init__.py b/examples/stories/identity_assertion/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/identity_assertion/client.py b/examples/stories/identity_assertion/client.py new file mode 100644 index 0000000000..bd13909801 --- /dev/null +++ b/examples/stories/identity_assertion/client.py @@ -0,0 +1,69 @@ +"""HTTP-only SEP-990: `build_auth` presents an IdP-issued ID-JAG (jwt-bearer grant); `whoami` proves the subject.""" + +import httpx + +from mcp.client import Client +from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider +from stories._harness import Target, run_client +from stories._shared.auth import MCP_URL, InMemoryTokenStorage + +from .idp import issue_id_jag +from .server import DEMO_CLIENT_ID, DEMO_CLIENT_SECRET, DEMO_SCOPE, ISSUER + +# The end user the stand-in IdP says is signed in. +DEMO_SUBJECT = "alice@example.com" + + +async def fetch_id_jag(audience: str, resource: str) -> str: + """Step one, the part the SDK does not do: obtain a fresh ID-JAG from the enterprise IdP. + + A real implementation makes an RFC 8693 token-exchange request to the IdP, presenting the + signed-in user's ID token; `audience` (the authorization server's issuer) and `resource` (the + MCP server's identifier) pass straight through into the ID-JAG's `aud` and `resource` claims. + Here the stand-in IdP signs one in-process instead. + """ + return issue_id_jag( + subject=DEMO_SUBJECT, client_id=DEMO_CLIENT_ID, audience=audience, resource=resource, scope=DEMO_SCOPE + ) + + +def build_auth(_http: httpx.AsyncClient) -> httpx.Auth: + """An `IdentityAssertionOAuthProvider` for the pre-registered confidential client. + + `issuer` is configuration, not discovery: the provider fetches metadata from this issuer's + well-known and never asks the MCP server which authorization server to use. The string must + equal the `issuer` its metadata serves byte for byte (note the trailing slash). + `Client(url, auth=...)` doesn't exist yet, so the harness threads this onto the underlying + `httpx.AsyncClient` and hands `main` a target that is already routed through it. + """ + return IdentityAssertionOAuthProvider( + server_url=MCP_URL, + storage=InMemoryTokenStorage(), + client_id=DEMO_CLIENT_ID, + client_secret=DEMO_CLIENT_SECRET, + issuer=ISSUER, + assertion_provider=fetch_id_jag, + scope=DEMO_SCOPE, + ) + + +async def main(target: Target, *, mode: str = "auto") -> None: + # The target is already routed through `build_auth`'s provider. The first request 401s; the + # provider fetches the authorization server's metadata from the configured issuer (never from + # the MCP server), mints a fresh ID-JAG through `fetch_id_jag`, exchanges it at `/token` under + # the jwt-bearer grant, and retries with the bearer. No `/authorize`, no `/register`, no browser. + 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_SUBJECT, + "client_id": DEMO_CLIENT_ID, + "scopes": [DEMO_SCOPE], + }, result.structured_content + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/identity_assertion/idp.py b/examples/stories/identity_assertion/idp.py new file mode 100644 index 0000000000..5d77c665f1 --- /dev/null +++ b/examples/stories/identity_assertion/idp.py @@ -0,0 +1,43 @@ +"""A stand-in enterprise identity provider: it signs the ID-JAGs the demo authorization server trusts. + +In production the IdP is a separate service (Okta, Microsoft Entra ID, ...) and the client obtains +the ID-JAG from it with an RFC 8693 token-exchange request, presenting the signed-in user's ID +token. `issue_id_jag` collapses that whole step into one in-process signing call so the story runs +unattended; the README's caveats spell out what a real deployment changes. +""" + +import time +import uuid + +import jwt + +IDP_ISSUER = "https://idp.example.com" +# Demo only: a real IdP signs with its private key and the authorization server verifies the +# signature against the IdP's published JWKS. A shared HMAC secret keeps this story self-contained. +IDP_SIGNING_KEY = "demo-idp-signing-key" + + +def issue_id_jag(*, subject: str, client_id: str, audience: str, resource: str, scope: str) -> str: + """The IdP's short-lived, signed statement that `subject`, via `client_id`, may reach `resource`. + + This is where the enterprise enforces policy: an IdP that does not authorize the combination + simply never issues the ID-JAG, and there is nothing for the client to present. The `typ` + header and the claim set are fixed by the Identity Assertion JWT Authorization Grant profile. + """ + now = int(time.time()) + return jwt.encode( + { + "iss": IDP_ISSUER, + "sub": subject, + "aud": audience, + "client_id": client_id, + "resource": resource, + "scope": scope, + "jti": str(uuid.uuid4()), + "iat": now, + "exp": now + 300, + }, + IDP_SIGNING_KEY, + algorithm="HS256", + headers={"typ": "oauth-id-jag+jwt"}, + ) diff --git a/examples/stories/identity_assertion/server.py b/examples/stories/identity_assertion/server.py new file mode 100644 index 0000000000..8b0c8f4019 --- /dev/null +++ b/examples/stories/identity_assertion/server.py @@ -0,0 +1,110 @@ +"""SEP-990 authorization server + bearer-gated MCP server on one app; exports `build_app()`. + +`identity_assertion_enabled=True` turns the RFC 7523 jwt-bearer grant on, and the provider's +`exchange_identity_assertion` validates the IdP-signed ID-JAG and mints an access token bound to +the user and resource the assertion names. The MCP server half is ordinary bearer auth. +""" + +import jwt +from pydantic import BaseModel +from starlette.applications import Starlette + +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.provider import IdentityAssertionParams, TokenError +from mcp.server.mcpserver import MCPServer +from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken +from stories._hosting import NO_DNS_REBIND, run_app_from_args +from stories._shared.auth import MCP_URL, InMemoryAuthorizationServerProvider, auth_settings + +from .idp import IDP_ISSUER, IDP_SIGNING_KEY + +# DEMO ONLY: never hard-code real credentials. +DEMO_CLIENT_ID = "finance-agent" +DEMO_CLIENT_SECRET = "demo-finance-agent-secret" +DEMO_SCOPE = "mcp" +# The exact `issuer` string this authorization server's metadata serves. The client must configure +# the byte-identical string: RFC 8414 issuer comparison is character for character, and the +# settings' `AnyHttpUrl` renders the path-less loopback origin with a trailing slash. +ISSUER = str(auth_settings().issuer_url) + + +class Whoami(BaseModel): + subject: str + client_id: str + scopes: list[str] + + +class IdentityAssertionAuthorizationServer(InMemoryAuthorizationServerProvider): + """The demo in-process AS plus the SEP-990 hook: validate an ID-JAG, mint a bound token.""" + + def __init__(self) -> None: + super().__init__() + self.seen_jtis: set[str] = set() + # Pre-registered out of band. Dynamic client registration refuses the jwt-bearer grant, + # so an ID-JAG client always arrives already known and already confidential. + self.clients[DEMO_CLIENT_ID] = OAuthClientInformationFull( + client_id=DEMO_CLIENT_ID, + client_secret=DEMO_CLIENT_SECRET, + redirect_uris=None, + grant_types=[JWT_BEARER_GRANT_TYPE], + token_endpoint_auth_method="client_secret_post", + ) + + async def exchange_identity_assertion( + self, client: OAuthClientInformationFull, params: IdentityAssertionParams + ) -> OAuthToken: + """Validate the ID-JAG per RFC 7523 §3 and the SEP-990 processing rules, then issue the token.""" + try: + header = jwt.get_unverified_header(params.assertion) + claims = jwt.decode( + params.assertion, + IDP_SIGNING_KEY, + algorithms=["HS256"], + issuer=IDP_ISSUER, + audience=ISSUER, + options={"require": ["iss", "sub", "aud", "exp", "iat", "jti", "client_id", "resource", "scope"]}, + ) + except jwt.InvalidTokenError as error: + raise TokenError("invalid_grant", "the assertion did not verify") from error + if header.get("typ") != "oauth-id-jag+jwt": + raise TokenError("invalid_grant", "the assertion is not an ID-JAG") + if claims["client_id"] != client.client_id: + raise TokenError("invalid_grant", "the assertion was issued to a different client") + if claims["resource"] != MCP_URL: + raise TokenError("invalid_target", "the assertion is for a resource this server does not serve") + if claims["jti"] in self.seen_jtis: + raise TokenError("invalid_grant", "the assertion has already been used") + self.seen_jtis.add(claims["jti"]) + # Everything on the issued token comes from the validated assertion, the audience + # restriction above all: it binds the token to the ID-JAG's `resource` claim, never to + # the client-controlled `params.resource`. No refresh token is returned either; the IdP + # owns session lifetime by deciding whether to issue the next ID-JAG. + scopes = claims["scope"].split() + access = self.mint_access_token( + client_id=claims["client_id"], scopes=scopes, resource=claims["resource"], subject=claims["sub"] + ) + return OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=" ".join(scopes)) + + +def build_app() -> Starlette: + provider = IdentityAssertionAuthorizationServer() + # `auth_server_provider=` alone is enough: MCPServer derives a token verifier from it + # (passing both trips the mutex guard). + mcp = MCPServer( + "identity-assertion-example", + auth=auth_settings(required_scopes=[DEMO_SCOPE], identity_assertion_enabled=True), + auth_server_provider=provider, + ) + + @mcp.tool(description="Return the end user the ID-JAG named, plus the authenticated client and scopes.") + def whoami() -> Whoami: + token = get_access_token() + assert token is not None + assert token.subject is not None + return Whoami(subject=token.subject, 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/identity_assertion/server_lowlevel.py b/examples/stories/identity_assertion/server_lowlevel.py new file mode 100644 index 0000000000..1fcf8def79 --- /dev/null +++ b/examples/stories/identity_assertion/server_lowlevel.py @@ -0,0 +1,65 @@ +"""SEP-990 authorization server + bearer-gated MCP server (lowlevel API); same app shape.""" + +import json +from typing import Any + +import mcp_types as types +from starlette.applications import Starlette + +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 auth_settings + +from .server import DEMO_SCOPE, IdentityAssertionAuthorizationServer + +WHOAMI_OUTPUT_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": { + "subject": {"type": "string"}, + "client_id": {"type": "string"}, + "scopes": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["subject", "client_id", "scopes"], +} + + +def build_app() -> Starlette: + provider = IdentityAssertionAuthorizationServer() + + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="whoami", + description="Return the end user the ID-JAG named, plus the authenticated client and 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 + assert token.subject is not None + payload = {"subject": token.subject, "client_id": token.client_id, "scopes": token.scopes} + return types.CallToolResult(content=[types.TextContent(text=json.dumps(payload))], structured_content=payload) + + server = Server("identity-assertion-example", on_list_tools=list_tools, on_call_tool=call_tool) + # Unlike MCPServer (auth on the constructor), lowlevel.Server takes auth at app-build time. + return server.streamable_http_app( + auth=auth_settings(required_scopes=[DEMO_SCOPE], identity_assertion_enabled=True), + 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/manifest.toml b/examples/stories/manifest.toml index fb688d2942..cd7c352dbb 100644 --- a/examples/stories/manifest.toml +++ b/examples/stories/manifest.toml @@ -135,6 +135,11 @@ transports = ["http-asgi"] server_export = "app" fixed_port = 8000 # issuer/PRM metadata bake in :8000 +[story.identity_assertion] +transports = ["http-asgi"] +server_export = "app" +fixed_port = 8000 # issuer/PRM metadata bake in :8000 + # ───────────────────────────── deferred ───────────────────────────── # README-only placeholders; no client.py, not expanded into legs. # test_manifest_matches_filesystem checks these match the README-only dirs. diff --git a/mkdocs.yml b/mkdocs.yml index 93127a410a..63bf9aadc6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -46,6 +46,7 @@ nav: - OpenTelemetry: advanced/opentelemetry.md - Authorization: advanced/authorization.md - OAuth clients: advanced/oauth-clients.md + - Identity assertion: advanced/identity-assertion.md - Session groups: advanced/session-groups.md - Deprecated features: advanced/deprecated.md - Migration Guide: migration.md diff --git a/tests/docs_src/test_identity_assertion.py b/tests/docs_src/test_identity_assertion.py new file mode 100644 index 0000000000..afcfd83290 --- /dev/null +++ b/tests/docs_src/test_identity_assertion.py @@ -0,0 +1,196 @@ +"""`docs/advanced/identity-assertion.md`: every claim the page makes, proved against the real SDK.""" + +import inspect +from urllib.parse import parse_qsl + +import httpx +import jwt +import pytest +from inline_snapshot import snapshot +from pydantic import AnyHttpUrl +from starlette.applications import Starlette + +from docs_src.identity_assertion import tutorial001, tutorial002 +from docs_src.oauth_clients import tutorial001 as oauth_clients_tutorial001 +from mcp import Client +from mcp.client.auth import OAuthClientProvider +from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider +from mcp.client.streamable_http import streamable_http_client +from mcp.server import MCPServer +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.provider import IdentityAssertionParams, ProviderTokenVerifier, TokenError +from mcp.server.auth.settings import AuthSettings + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + +MCP_SERVER_URL = "http://localhost:8001/mcp" + + +class RecordingASGITransport(httpx.ASGITransport): + """An `httpx.ASGITransport` that appends every (method, path, body) it carries to a shared log.""" + + def __init__(self, app: Starlette, log: list[tuple[str, str, bytes]]) -> None: + super().__init__(app=app) + self.log = log + + async def handle_async_request(self, request: httpx.Request) -> httpx.Response: + self.log.append((request.method, request.url.path, request.content)) + return await super().handle_async_request(request) + + +async def test_the_provider_is_an_httpx_auth_but_not_an_oauth_client_provider() -> None: + """tutorial001: same `auth=` slot as the rest of OAuth clients, but nothing is discovered or registered.""" + assert isinstance(tutorial001.oauth, httpx.Auth) + assert not isinstance(tutorial001.oauth, OAuthClientProvider) + + +async def test_main_is_the_main_from_the_oauth_clients_page() -> None: + """The page says `main()` is unchanged to the character from the OAuth clients page.""" + assert inspect.getsource(tutorial001.main) == inspect.getsource(oauth_clients_tutorial001.main) + + +async def test_a_client_secret_is_required() -> None: + """tutorial001: the provider refuses to be constructed as a public client.""" + with pytest.raises(ValueError, match="client_secret is required"): + IdentityAssertionOAuthProvider( + server_url=MCP_SERVER_URL, + storage=tutorial001.InMemoryTokenStorage(), + client_id="finance-agent", + client_secret="", + issuer=tutorial002.ISSUER, + assertion_provider=tutorial001.fetch_id_jag, + ) + + +async def test_an_issuer_is_required() -> None: + """tutorial001: the authorization server is configuration, not discovery.""" + with pytest.raises(ValueError, match="issuer is required"): + IdentityAssertionOAuthProvider( + server_url=MCP_SERVER_URL, + storage=tutorial001.InMemoryTokenStorage(), + client_id="finance-agent", + client_secret="finance-agent-secret", + issuer="", + assertion_provider=tutorial001.fetch_id_jag, + ) + + +async def test_the_id_jag_is_a_typed_jwt_carrying_the_claims_the_page_lists() -> None: + """tutorial001: the stand-in IdP signs a real ID-JAG; its header `typ` and claim set are the extension's.""" + assertion = tutorial001.idp_issue_id_jag("alice@example.com", tutorial002.ISSUER, MCP_SERVER_URL) + assert jwt.get_unverified_header(assertion)["typ"] == "oauth-id-jag+jwt" + claims = jwt.decode(assertion, tutorial001.IDP_SIGNING_KEY, algorithms=["HS256"], audience=tutorial002.ISSUER) + assert list(claims) == snapshot(["iss", "sub", "aud", "client_id", "resource", "scope", "jti", "iat", "exp"]) + assert claims["client_id"] == "finance-agent" + assert claims["resource"] == MCP_SERVER_URL + + +async def test_a_forged_assertion_is_rejected() -> None: + """tutorial002: the signature check fails closed with `invalid_grant`.""" + client = tutorial002.REGISTERED_CLIENTS["finance-agent"] + with pytest.raises(TokenError) as exc_info: + await tutorial002.provider.exchange_identity_assertion( + client, IdentityAssertionParams(assertion="not-an-id-jag") + ) + assert exc_info.value.error == "invalid_grant" + assert exc_info.value.error_description == "the assertion did not verify" + + +async def test_an_assertion_for_another_audience_is_rejected() -> None: + """tutorial002: an ID-JAG whose `aud` is not this authorization server is `invalid_grant`.""" + client = tutorial002.REGISTERED_CLIENTS["finance-agent"] + assertion = tutorial001.idp_issue_id_jag("alice@example.com", "https://other.example.com/", MCP_SERVER_URL) + with pytest.raises(TokenError) as exc_info: + await tutorial002.provider.exchange_identity_assertion(client, IdentityAssertionParams(assertion=assertion)) + assert exc_info.value.error == "invalid_grant" + assert exc_info.value.error_description == "the assertion did not verify" + + +async def test_an_assertion_for_an_unknown_resource_is_rejected() -> None: + """tutorial002: an ID-JAG naming a resource this server does not serve is `invalid_target`.""" + client = tutorial002.REGISTERED_CLIENTS["finance-agent"] + assertion = tutorial001.idp_issue_id_jag("alice@example.com", tutorial002.ISSUER, "https://other.example.com/mcp") + with pytest.raises(TokenError) as exc_info: + await tutorial002.provider.exchange_identity_assertion(client, IdentityAssertionParams(assertion=assertion)) + assert exc_info.value.error == "invalid_target" + assert exc_info.value.error_description == "the assertion is for a resource this server does not serve" + + +async def test_a_replayed_assertion_is_rejected() -> None: + """tutorial002: `jti` is tracked, so presenting the same ID-JAG twice fails the second time.""" + client = tutorial002.REGISTERED_CLIENTS["finance-agent"] + assertion = tutorial001.idp_issue_id_jag("alice@example.com", tutorial002.ISSUER, MCP_SERVER_URL) + params = IdentityAssertionParams(assertion=assertion) + first = await tutorial002.provider.exchange_identity_assertion(client, params) + assert first.token_type == "Bearer" + with pytest.raises(TokenError) as exc_info: + await tutorial002.provider.exchange_identity_assertion(client, params) + assert exc_info.value.error == "invalid_grant" + assert exc_info.value.error_description == "the assertion has already been used" + + +async def test_the_metadata_advertises_the_grant_type_and_the_id_jag_profile() -> None: + """tutorial002: the flag turns on both the `jwt-bearer` grant type and the grant-profile advertisement.""" + transport = httpx.ASGITransport(app=tutorial002.auth_app) + async with httpx.AsyncClient(transport=transport, base_url="https://auth.example.com") as http_client: + response = await http_client.get("/.well-known/oauth-authorization-server") + assert response.status_code == 200 + metadata = response.json() + assert metadata["issuer"] == "https://auth.example.com/" + assert "urn:ietf:params:oauth:grant-type:jwt-bearer" in metadata["grant_types_supported"] + assert metadata["authorization_grant_profiles_supported"] == ["urn:ietf:params:oauth:grant-profile:id-jag"] + + +async def test_the_whole_grant_is_one_token_request() -> None: + """The `!!! check`: a 401, the well-known fetch, one `POST /token`, the retry; the subject reaches the tool.""" + mcp = MCPServer( + "Notes", + token_verifier=ProviderTokenVerifier(tutorial002.provider), + auth=AuthSettings( + issuer_url=AnyHttpUrl(tutorial002.ISSUER), + resource_server_url=AnyHttpUrl(MCP_SERVER_URL), + required_scopes=["notes:read"], + ), + ) + + @mcp.tool() + def whoami() -> str: + """Report which end user the ID-JAG named.""" + token = get_access_token() + assert token is not None + assert token.subject is not None + return f"{token.subject} ({', '.join(token.scopes)})" + + log: list[tuple[str, str, bytes]] = [] + transport = RecordingASGITransport(mcp.streamable_http_app(), log) + mounts = {"https://auth.example.com": RecordingASGITransport(tutorial002.auth_app, log)} + async with mcp.session_manager.run(): + async with ( + httpx.AsyncClient(auth=tutorial001.oauth, transport=transport, mounts=mounts) as http_client, + Client(streamable_http_client(MCP_SERVER_URL, http_client=http_client)) as client, + ): + result = await client.call_tool("whoami", {}) + assert result.structured_content == {"result": "alice@example.com (notes:read)"} + + assert [(method, path) for method, path, _ in log] == snapshot( + [ + ("POST", "/mcp"), + ("GET", "/.well-known/oauth-authorization-server"), + ("POST", "/token"), + ("POST", "/mcp"), + ("POST", "/mcp"), + ("POST", "/mcp"), + ] + ) + token_request = dict(parse_qsl(log[2][2].decode())) + assert sorted(token_request) == snapshot( + ["assertion", "client_id", "client_secret", "grant_type", "resource", "scope"] + ) + assert token_request["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer" + assert token_request["client_id"] == "finance-agent" + assert token_request["resource"] == MCP_SERVER_URL + assert token_request["scope"] == "notes:read" + assert jwt.get_unverified_header(token_request["assertion"]) == snapshot( + {"alg": "HS256", "typ": "oauth-id-jag+jwt"} + ) diff --git a/tests/docs_src/test_shape.py b/tests/docs_src/test_shape.py index 375f3e4581..98fb2503de 100644 --- a/tests/docs_src/test_shape.py +++ b/tests/docs_src/test_shape.py @@ -39,32 +39,6 @@ _INCLUDE_DIRECTIVE = re.compile(r"(?:--8<--\s*\"|` README marker.""" -_TYPOGRAPHIC_NON_ASCII = re.compile("[\u2014\u2013\u2192\u2026\u2018\u2019\u201c\u201d\u00a0\u2264\u2265\u00a7]") -"""Typographic characters the documentation never uses: em/en dash, arrow, ellipsis, curly -quotes, no-break space, comparison signs, section sign. They are written as escapes so this -file satisfies the very check it implements. - -Plain ASCII punctuation is a deliberate style rule, and one of these is also a real bug: a U+2026 -inside a fenced example breaks the fence linter on Windows, where the example source is piped to -ruff in the platform encoding rather than UTF-8. Emoji are not banned; this is about typography. -""" - -BOOK_PAGES = sorted( - { - REPO_ROOT / "README.v2.md", - REPO_ROOT / "docs" / "index.md", - REPO_ROOT / "docs" / "installation.md", - *(REPO_ROOT / "docs" / "tutorial").rglob("*.md"), - *(REPO_ROOT / "docs" / "run").rglob("*.md"), - *(REPO_ROOT / "docs" / "client").rglob("*.md"), - *(REPO_ROOT / "docs" / "advanced").rglob("*.md"), - } -) -"""Every page of the tutorial book plus the README: the files this directory's tests stand behind.""" - -DOCS_TEST_FILES = sorted(Path(__file__).parent.glob("*.py")) -"""This directory itself. The prose in these modules' docstrings follows the same typography rule.""" - def _rel(path: Path) -> str: """A repo-relative path, used as the parametrize id so failures name the file.""" @@ -92,11 +66,6 @@ def _retired_names_used(source: str) -> list[str]: return [name for name in RETIRED_NAMES if name in source] -def _typographic_chars(text: str) -> list[str]: - """Every banned typographic character in `text`, in order of appearance.""" - return _TYPOGRAPHIC_NON_ASCII.findall(text) - - def _referenced_examples() -> set[str]: """Every `docs_src/...` path that some docs page or the README actually includes. @@ -133,12 +102,6 @@ def test_retired_name_detector() -> None: assert _retired_names_used("from mcp.server import MCPServer") == [] -def test_typographic_char_detector() -> None: - """The detector flags banned typography and allows plain ASCII and emoji.""" - assert _typographic_chars("a \u2014 b \u2192 c\u2026") == ["\u2014", "\u2192", "\u2026"] - assert _typographic_chars("plain ASCII with a \u2728 emoji is fine") == [] - - @pytest.mark.parametrize("path", EXAMPLE_FILES, ids=_rel) def test_example_imports(path: Path) -> None: """The example imports cleanly against the current SDK. @@ -165,13 +128,6 @@ def test_example_avoids_retired_api(path: Path) -> None: assert not _retired_names_used(path.read_text(encoding="utf-8")), f"{_rel(path)} uses a retired API" -@pytest.mark.parametrize("path", [*BOOK_PAGES, *EXAMPLE_FILES, *DOCS_TEST_FILES], ids=_rel) -def test_page_uses_plain_ascii_punctuation(path: Path) -> None: - """A page, example, or docs test never uses em-dashes, arrows, ellipses, or other typographic non-ASCII.""" - found = _typographic_chars(path.read_text(encoding="utf-8")) - assert not found, f"{_rel(path)} contains non-ASCII typography: {sorted(set(found))}" - - def test_every_example_is_included_by_a_page() -> None: """Every `docs_src/` example is shown by at least one docs page or the README. From e942d00b981d51897df2bd76fc5ffee5c00c4d5b Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:15:34 +0200 Subject: [PATCH 020/100] Re-vendor 2026-07-28 schema at spec ead35b59 (SubscriptionsListenResult) (#3006) --- schema/2026-07-28.json | 33 +++++++++++++ schema/PINNED.json | 4 +- scripts/gen_surface_types.py | 10 +++- src/mcp-types/mcp_types/__init__.py | 2 + src/mcp-types/mcp_types/_types.py | 16 +++++++ src/mcp-types/mcp_types/methods.py | 4 +- .../mcp_types/v2026_07_28/__init__.py | 48 ++++++++++++++++++- src/mcp/server/lowlevel/server.py | 6 +-- tests/types/test_methods.py | 15 +++--- tests/types/test_parity.py | 1 + 10 files changed, 124 insertions(+), 15 deletions(-) diff --git a/schema/2026-07-28.json b/schema/2026-07-28.json index 7025ee7d48..87116a420e 100644 --- a/schema/2026-07-28.json +++ b/schema/2026-07-28.json @@ -3220,6 +3220,9 @@ { "$ref": "#/$defs/ReadResourceResult" }, + { + "$ref": "#/$defs/SubscriptionsListenResult" + }, { "$ref": "#/$defs/ListPromptsResult" }, @@ -3389,6 +3392,36 @@ ], "type": "object" }, + "SubscriptionsListenResult": { + "description": "The response to a {@link SubscriptionsListenRequestsubscriptions/listen}\nrequest, signalling that the subscription has ended gracefully (for example,\nduring server shutdown). Because the listen stream is long-lived, this result\nis sent only when the server tears the subscription down; an abrupt transport\nclose carries no response. The result body is otherwise empty.", + "properties": { + "_meta": { + "$ref": "#/$defs/SubscriptionsListenResultMeta" + }, + "resultType": { + "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", + "type": "string" + } + }, + "required": [ + "_meta", + "resultType" + ], + "type": "object" + }, + "SubscriptionsListenResultMeta": { + "description": "Extends {@link MetaObject} with the subscription-stream identifier carried by a\n{@link SubscriptionsListenResult}. All key naming rules from `MetaObject` apply.", + "properties": { + "io.modelcontextprotocol/subscriptionId": { + "$ref": "#/$defs/RequestId", + "description": "Identifies the subscription stream this response closes, so the client can\ncorrelate it with the originating subscription — mirroring the same key on\nthe stream's notifications. The value is the JSON-RPC ID of the\n`subscriptions/listen` request that opened the stream (and equals this\nresponse's `id`)." + } + }, + "required": [ + "io.modelcontextprotocol/subscriptionId" + ], + "type": "object" + }, "TextContent": { "description": "Text provided to or from an LLM.", "properties": { diff --git a/schema/PINNED.json b/schema/PINNED.json index e4022e86c4..9b1739d29d 100644 --- a/schema/PINNED.json +++ b/schema/PINNED.json @@ -8,7 +8,7 @@ { "protocol_version": "2026-07-28", "source_path_in_spec_repo": "schema/draft/schema.json", - "spec_commit": "2852f30e26ca5fb779565741ec042094cb110abd", - "sha256": "ed1ad4ba94aaeb2068b78969ef901b1150f7b2f06cf86472b3032abee1380b6a" + "spec_commit": "ead35b59b4fda8b32e276810025d8f92bdcec1b6", + "sha256": "e00f675287e8cf078688c26c8a89d283ff2613da3b76d5cd15aff9d189df639c" } ] diff --git a/scripts/gen_surface_types.py b/scripts/gen_surface_types.py index df99b33ebe..f338629095 100644 --- a/scripts/gen_surface_types.py +++ b/scripts/gen_surface_types.py @@ -85,7 +85,15 @@ OPEN_CLASSES: dict[str, frozenset[str]] = { "2025-11-25": frozenset({"Meta", "InputSchema", "OutputSchema", "Result", "GetTaskPayloadResult", "Data"}), "2026-07-28": frozenset( - {"MetaObject", "NotificationMetaObject", "RequestMetaObject", "InputSchema", "OutputSchema", "Result"} + { + "MetaObject", + "NotificationMetaObject", + "RequestMetaObject", + "SubscriptionsListenResultMeta", + "InputSchema", + "OutputSchema", + "Result", + } ), } diff --git a/src/mcp-types/mcp_types/__init__.py b/src/mcp-types/mcp_types/__init__.py index c90062e412..2ed97cba33 100644 --- a/src/mcp-types/mcp_types/__init__.py +++ b/src/mcp-types/mcp_types/__init__.py @@ -160,6 +160,7 @@ SubscriptionsAcknowledgedNotificationParams, SubscriptionsListenRequest, SubscriptionsListenRequestParams, + SubscriptionsListenResult, Task, TaskMetadata, TasksCallCapability, @@ -385,6 +386,7 @@ "ListTasksResult", "ListToolsResult", "ReadResourceResult", + "SubscriptionsListenResult", # Error data payloads "MissingRequiredClientCapabilityErrorData", "UnsupportedProtocolVersionErrorData", diff --git a/src/mcp-types/mcp_types/_types.py b/src/mcp-types/mcp_types/_types.py index 09bf94d22b..34dc10083b 100644 --- a/src/mcp-types/mcp_types/_types.py +++ b/src/mcp-types/mcp_types/_types.py @@ -1063,6 +1063,21 @@ class SubscriptionsAcknowledgedNotification( params: SubscriptionsAcknowledgedNotificationParams +class SubscriptionsListenResult(Result): + """Signals that a `subscriptions/listen` stream has ended gracefully (2026-07-28). + + Because the listen stream is long-lived, this result is sent only when the + server tears the subscription down (for example during shutdown); an abrupt + transport close carries no response. The body is otherwise empty: the + `_meta["io.modelcontextprotocol/subscriptionId"]` key is required on the + wire and equals the JSON-RPC id of the originating `subscriptions/listen` + request. + """ + + result_type: ResultType = "complete" + """See `ResultType`. Always serialized; older peers ignore it.""" + + class ListPromptsRequest(PaginatedRequest[Literal["prompts/list"]]): """Sent from the client to request a list of prompts and prompt templates the server has.""" @@ -2156,6 +2171,7 @@ def _require_one_field(self) -> Self: | ReadResourceResult | CallToolResult | ListToolsResult + | SubscriptionsListenResult | InputRequiredResult ) """Union of every result payload a server can return for a client request. diff --git a/src/mcp-types/mcp_types/methods.py b/src/mcp-types/mcp_types/methods.py index 5bc7c1ff96..824dcfdfe6 100644 --- a/src/mcp-types/mcp_types/methods.py +++ b/src/mcp-types/mcp_types/methods.py @@ -292,7 +292,7 @@ ("resources/read", "2026-07-28"): v2026.AnyReadResourceResult, ("resources/templates/list", "2026-07-28"): v2026.ListResourceTemplatesResult, ("server/discover", "2026-07-28"): v2026.DiscoverResult, - ("subscriptions/listen", "2026-07-28"): v2026.EmptyResult, + ("subscriptions/listen", "2026-07-28"): v2026.SubscriptionsListenResult, ("tools/call", "2026-07-28"): v2026.AnyCallToolResult, ("tools/list", "2026-07-28"): v2026.ListToolsResult, } @@ -396,7 +396,7 @@ # smart-union ties resolve leftmost. Pinned by tests/types/test_methods.py. "sampling/createMessage": types.CreateMessageResult | types.CreateMessageResultWithTools, "server/discover": types.DiscoverResult, - "subscriptions/listen": types.EmptyResult, + "subscriptions/listen": types.SubscriptionsListenResult, "tools/call": types.CallToolResult | types.InputRequiredResult, "tools/list": types.ListToolsResult, } diff --git a/src/mcp-types/mcp_types/v2026_07_28/__init__.py b/src/mcp-types/mcp_types/v2026_07_28/__init__.py index 9ab30a3469..2963c13232 100644 --- a/src/mcp-types/mcp_types/v2026_07_28/__init__.py +++ b/src/mcp-types/mcp_types/v2026_07_28/__init__.py @@ -1,7 +1,7 @@ """Internal wire-shape models for protocol 2026-07-28. Generated; do not edit. Regenerate with `scripts/gen_surface_types.py` from `schema/2026-07-28.json` -(sha256 `ed1ad4ba94aaeb2068b78969ef901b1150f7b2f06cf86472b3032abee1380b6a`).""" +(sha256 `e00f675287e8cf078688c26c8a89d283ff2613da3b76d5cd15aff9d189df639c`).""" # pyright: reportIncompatibleVariableOverride=false, reportGeneralTypeIssues=false from __future__ import annotations @@ -909,6 +909,25 @@ class SubscriptionFilter(WireModel): """ +class SubscriptionsListenResultMeta(WireModel): + """ + Extends {@link MetaObject} with the subscription-stream identifier carried by a + {@link SubscriptionsListenResult}. All key naming rules from `MetaObject` apply. + """ + + model_config = ConfigDict( + extra="allow", + ) + io_modelcontextprotocol_subscription_id: Annotated[RequestId, Field(alias="io.modelcontextprotocol/subscriptionId")] + """ + Identifies the subscription stream this response closes, so the client can + correlate it with the originating subscription — mirroring the same key on + the stream's notifications. The value is the JSON-RPC ID of the + `subscriptions/listen` request that opened the stream (and equals this + response's `id`). + """ + + class TextResourceContents(WireModel): model_config = ConfigDict( extra="ignore", @@ -2056,6 +2075,31 @@ class SubscriptionsAcknowledgedNotificationParams(WireModel): """ +class SubscriptionsListenResult(WireModel): + """ + The response to a {@link SubscriptionsListenRequestsubscriptions/listen} + request, signalling that the subscription has ended gracefully (for example, + during server shutdown). Because the listen stream is long-lived, this result + is sent only when the server tears the subscription down; an abrupt transport + close carries no response. The result body is otherwise empty. + """ + + model_config = ConfigDict( + extra="ignore", + ) + meta: Annotated[SubscriptionsListenResultMeta, Field(alias="_meta")] + result_type: Annotated[str, Field(alias="resultType")] + """ + Indicates the type of the result, which allows the client to determine + how to parse the result object. + + Servers implementing this protocol version MUST include this field. + For backward compatibility, when a client receives a result from a + server implementing an earlier protocol version (which does not include + `resultType`), the client MUST treat the absent field as `"complete"`. + """ + + class TextContent(WireModel): """ Text provided to or from an LLM. @@ -3544,6 +3588,7 @@ class ServerResult( | ListResourcesResult | ListResourceTemplatesResult | ReadResourceResult + | SubscriptionsListenResult | ListPromptsResult | GetPromptResult | ListToolsResult @@ -3558,6 +3603,7 @@ class ServerResult( | ListResourcesResult | ListResourceTemplatesResult | ReadResourceResult + | SubscriptionsListenResult | ListPromptsResult | GetPromptResult | ListToolsResult diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index bbd2ff3318..c10ff82f3a 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -182,7 +182,7 @@ def __init__( | None = None, on_subscriptions_listen: Callable[ [ServerRequestContext[LifespanResultT], types.SubscriptionsListenRequestParams], - Awaitable[types.EmptyResult], + Awaitable[types.SubscriptionsListenResult], ] | None = None, on_list_prompts: Callable[ @@ -264,7 +264,7 @@ def __init__( | None = None, on_subscriptions_listen: Callable[ [ServerRequestContext[LifespanResultT], types.SubscriptionsListenRequestParams], - Awaitable[types.EmptyResult], + Awaitable[types.SubscriptionsListenResult], ] | None = None, on_list_prompts: Callable[ @@ -355,7 +355,7 @@ def __init__( | None = None, on_subscriptions_listen: Callable[ [ServerRequestContext[LifespanResultT], types.SubscriptionsListenRequestParams], - Awaitable[types.EmptyResult], + Awaitable[types.SubscriptionsListenResult], ] | None = None, on_list_prompts: Callable[ diff --git a/tests/types/test_methods.py b/tests/types/test_methods.py index ab72aa389f..79ea067c6b 100644 --- a/tests/types/test_methods.py +++ b/tests/types/test_methods.py @@ -268,7 +268,7 @@ ("resources/read", "2026-07-28"): (v2026.ReadResourceResult, v2026.InputRequiredResult), ("resources/templates/list", "2026-07-28"): v2026.ListResourceTemplatesResult, ("server/discover", "2026-07-28"): v2026.DiscoverResult, - ("subscriptions/listen", "2026-07-28"): v2026.EmptyResult, + ("subscriptions/listen", "2026-07-28"): v2026.SubscriptionsListenResult, ("tools/call", "2026-07-28"): (v2026.CallToolResult, v2026.InputRequiredResult), ("tools/list", "2026-07-28"): v2026.ListToolsResult, } @@ -290,9 +290,7 @@ ("sampling/createMessage", "2025-11-25"): v2025.CreateMessageResult, } -EMPTY_SERVER_RESPONSE_METHODS = frozenset( - {"logging/setLevel", "ping", "resources/subscribe", "resources/unsubscribe", "subscriptions/listen"} -) +EMPTY_SERVER_RESPONSE_METHODS = frozenset({"logging/setLevel", "ping", "resources/subscribe", "resources/unsubscribe"}) EMPTY_CLIENT_RESPONSE_METHODS = frozenset({"ping"}) # Pre-2026 versions share the 2025-11-25 surface package. @@ -404,7 +402,10 @@ "ttlMs": 0, "cacheScope": "private", }, - v2026.EmptyResult: {"resultType": "complete"}, + v2026.SubscriptionsListenResult: { + "resultType": "complete", + "_meta": {"io.modelcontextprotocol/subscriptionId": 1}, + }, v2026.ListPromptsResult: {"prompts": [], "resultType": "complete", "ttlMs": 0, "cacheScope": "private"}, v2026.ListResourcesResult: {"resources": [], "resultType": "complete", "ttlMs": 0, "cacheScope": "private"}, v2026.ListResourceTemplatesResult: { @@ -844,7 +845,9 @@ def test_validate_functions_accept_reject_and_gate_like_their_parse_siblings(): ttl_ms=0, cache_scope="private", ), - "subscriptions/listen": types.EmptyResult(result_type="complete"), + "subscriptions/listen": types.SubscriptionsListenResult.model_validate( + {"_meta": {"io.modelcontextprotocol/subscriptionId": 1}} + ), "tools/call": types.CallToolResult(content=[]), "tools/list": types.ListToolsResult(tools=[], ttl_ms=0, cache_scope="private"), } diff --git a/tests/types/test_parity.py b/tests/types/test_parity.py index 8a06073acd..080f343c3d 100644 --- a/tests/types/test_parity.py +++ b/tests/types/test_parity.py @@ -122,6 +122,7 @@ "v2026_07_28.RequestedSchema", "v2026_07_28.ResourceRequestParams", "v2026_07_28.StringSchema", + "v2026_07_28.SubscriptionsListenResultMeta", "v2026_07_28.TitledMultiSelectEnumSchema", "v2026_07_28.TitledSingleSelectEnumSchema", "v2026_07_28.UnsupportedProtocolVersionError", From fe9723853ddc9ecd3b6915af98ddff0e8e014a30 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Mon, 29 Jun 2026 11:42:37 +0200 Subject: [PATCH 021/100] Wire SEP-990 enterprise-managed-authorization conformance fixture (#3007) --- .github/actions/conformance/client.py | 69 ++++++++++++++++++- .../expected-failures.2026-07-28.yml | 7 +- .../actions/conformance/expected-failures.yml | 6 +- 3 files changed, 70 insertions(+), 12 deletions(-) diff --git a/.github/actions/conformance/client.py b/.github/actions/conformance/client.py index d8dffb0d29..39150add5d 100644 --- a/.github/actions/conformance/client.py +++ b/.github/actions/conformance/client.py @@ -25,6 +25,7 @@ sep-2322-client-request-state - Drive the MRTR auto-loop (SEP-2322) auth/client-credentials-jwt - Client credentials with private_key_jwt auth/client-credentials-basic - Client credentials with client_secret_basic + auth/enterprise-managed-authorization - SEP-990 ID-JAG (RFC 8693 + RFC 7523 jwt-bearer) auth/* - Authorization code flow (default for auth scenarios) """ @@ -48,6 +49,8 @@ PrivateKeyJWTOAuthProvider, SignedJWTParameters, ) +from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider +from mcp.client.auth.utils import build_protected_resource_metadata_discovery_urls from mcp.client.client import Client from mcp.client.context import ClientRequestContext from mcp.client.streamable_http import streamable_http_client @@ -454,6 +457,70 @@ async def run_client_credentials_basic(server_url: str) -> None: await _run_auth_session(server_url, oauth_auth) +@register("auth/enterprise-managed-authorization") +async def run_enterprise_managed_authorization(server_url: str) -> None: + """SEP-990 enterprise-managed authorization: RFC 8693 token-exchange at the + enterprise IdP for an ID-JAG, then RFC 7523 jwt-bearer at the MCP + authorization server.""" + context = get_conformance_context() + client_id = context.get("client_id") + client_secret = context.get("client_secret") + idp_client_id = context.get("idp_client_id") + idp_id_token = context.get("idp_id_token") + idp_token_endpoint = context.get("idp_token_endpoint") + + if not client_id: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'") + if not client_secret: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_secret'") + if not idp_client_id: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_client_id'") + if not idp_id_token: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_id_token'") + if not idp_token_endpoint: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'idp_token_endpoint'") + + # IdentityAssertionOAuthProvider takes the AS issuer as configuration (the + # SEP-990 trust model: the resource server is never asked which AS to use). + # The harness does not put the issuer in context, so for conformance we + # learn it from the harness's PRM document (RFC 9728); production + # deployments would supply it as static configuration instead. + prm_url = build_protected_resource_metadata_discovery_urls(None, server_url)[0] + async with httpx.AsyncClient(timeout=30.0) as http: + prm = (await http.get(prm_url)).raise_for_status().json() + as_issuer = prm["authorization_servers"][0] + + async def fetch_id_jag(audience: str, resource: str) -> str: + """Leg 1 - RFC 8693 token-exchange at the enterprise IdP.""" + async with httpx.AsyncClient(timeout=30.0) as http: + resp = await http.post( + idp_token_endpoint, + data={ + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "requested_token_type": "urn:ietf:params:oauth:token-type:id-jag", + "subject_token": idp_id_token, + "subject_token_type": "urn:ietf:params:oauth:token-type:id_token", + "audience": audience, + "resource": resource, + "client_id": idp_client_id, + }, + ) + resp.raise_for_status() + return resp.json()["access_token"] + + oauth_auth = IdentityAssertionOAuthProvider( + server_url=server_url, + storage=InMemoryTokenStorage(), + client_id=client_id, + client_secret=client_secret, + issuer=as_issuer, + assertion_provider=fetch_id_jag, + token_endpoint_auth_method="client_secret_basic", + ) + + await _run_auth_session(server_url, oauth_auth) + + async def run_auth_code_client(server_url: str) -> None: """Authorization code flow (default for auth/* scenarios).""" callback_handler = ConformanceOAuthCallbackHandler() @@ -496,7 +563,7 @@ async def run_auth_code_client(server_url: str) -> None: await _run_auth_session(server_url, oauth_auth) -async def _run_auth_session(server_url: str, oauth_auth: OAuthClientProvider) -> None: +async def _run_auth_session(server_url: str, oauth_auth: httpx.Auth) -> None: """Common session logic for all OAuth flows.""" http_client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0) transport = streamable_http_client(url=server_url, http_client=http_client) diff --git a/.github/actions/conformance/expected-failures.2026-07-28.yml b/.github/actions/conformance/expected-failures.2026-07-28.yml index 702575ce41..82300dfeaf 100644 --- a/.github/actions/conformance/expected-failures.2026-07-28.yml +++ b/.github/actions/conformance/expected-failures.2026-07-28.yml @@ -20,12 +20,7 @@ # (the runner fails on stale entries), so the baseline burns down per # milestone. -client: - [] - # 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 - # neither run nor evaluated on this leg. +client: [] server: # SEP-2322 (multi-round-trip requests / IncompleteResult): the prompt pipeline diff --git a/.github/actions/conformance/expected-failures.yml b/.github/actions/conformance/expected-failures.yml index b8994f76b5..006f4e2ece 100644 --- a/.github/actions/conformance/expected-failures.yml +++ b/.github/actions/conformance/expected-failures.yml @@ -10,11 +10,7 @@ # scenarios start passing and MUST be removed from this list (the runner fails # on stale entries), so the baseline burns down per milestone. -client: - # --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 --- - # SEP-990 (enterprise-managed authorization extension): no fixture handler / - # client support for the token-exchange + JWT bearer flow. - - auth/enterprise-managed-authorization +client: [] server: # --- Draft-spec scenarios (in `--suite draft`; the `active` suite is green) --- From 4b519782f1967fd00f167b8390af2f6cd7fbdcdb Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Mon, 29 Jun 2026 11:58:05 +0200 Subject: [PATCH 022/100] Add a pluggable server extension API with MCP Apps (#3003) Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com> --- docs/advanced/apps.md | 160 +++++++ docs/advanced/extensions.md | 159 +++++++ docs/migration.md | 44 ++ docs_src/apps/__init__.py | 0 docs_src/apps/report.html | 3 + docs_src/apps/tutorial001.py | 38 ++ docs_src/apps/tutorial002.py | 25 + docs_src/apps/tutorial003.py | 20 + docs_src/extensions/__init__.py | 0 docs_src/extensions/tutorial001.py | 4 + docs_src/extensions/tutorial002.py | 5 + docs_src/extensions/tutorial003.py | 35 ++ docs_src/extensions/tutorial004.py | 58 +++ docs_src/extensions/tutorial005.py | 34 ++ examples/stories/apps/README.md | 38 +- examples/stories/apps/__init__.py | 0 examples/stories/apps/client.py | 35 ++ examples/stories/apps/server.py | 43 ++ examples/stories/extensions/README.md | 41 ++ examples/stories/extensions/__init__.py | 0 examples/stories/extensions/client.py | 54 +++ examples/stories/extensions/server.py | 64 +++ examples/stories/manifest.toml | 18 +- examples/stories/tasks/README.md | 26 +- mkdocs.yml | 2 + src/mcp/client/client.py | 5 + src/mcp/client/session.py | 6 +- src/mcp/server/apps.py | 242 ++++++++++ src/mcp/server/connection.py | 10 + src/mcp/server/extension.py | 195 ++++++++ src/mcp/server/lowlevel/server.py | 23 +- src/mcp/server/mcpserver/__init__.py | 9 +- src/mcp/server/mcpserver/resources/types.py | 2 +- src/mcp/server/mcpserver/server.py | 106 ++++- tests/docs_src/test_apps.py | 100 ++++ tests/docs_src/test_extensions.py | 97 ++++ tests/server/mcpserver/test_extension.py | 485 ++++++++++++++++++++ tests/server/test_apps.py | 302 ++++++++++++ tests/server/test_extensions_capability.py | 134 ++++++ 39 files changed, 2598 insertions(+), 24 deletions(-) create mode 100644 docs/advanced/apps.md create mode 100644 docs/advanced/extensions.md create mode 100644 docs_src/apps/__init__.py create mode 100644 docs_src/apps/report.html create mode 100644 docs_src/apps/tutorial001.py create mode 100644 docs_src/apps/tutorial002.py create mode 100644 docs_src/apps/tutorial003.py create mode 100644 docs_src/extensions/__init__.py create mode 100644 docs_src/extensions/tutorial001.py create mode 100644 docs_src/extensions/tutorial002.py create mode 100644 docs_src/extensions/tutorial003.py create mode 100644 docs_src/extensions/tutorial004.py create mode 100644 docs_src/extensions/tutorial005.py create mode 100644 examples/stories/apps/__init__.py create mode 100644 examples/stories/apps/client.py create mode 100644 examples/stories/apps/server.py create mode 100644 examples/stories/extensions/README.md create mode 100644 examples/stories/extensions/__init__.py create mode 100644 examples/stories/extensions/client.py create mode 100644 examples/stories/extensions/server.py create mode 100644 src/mcp/server/apps.py create mode 100644 src/mcp/server/extension.py create mode 100644 tests/docs_src/test_apps.py create mode 100644 tests/docs_src/test_extensions.py create mode 100644 tests/server/mcpserver/test_extension.py create mode 100644 tests/server/test_apps.py create mode 100644 tests/server/test_extensions_capability.py diff --git a/docs/advanced/apps.md b/docs/advanced/apps.md new file mode 100644 index 0000000000..87e260f014 --- /dev/null +++ b/docs/advanced/apps.md @@ -0,0 +1,160 @@ +# MCP Apps + +An **MCP App** is a tool with a face: alongside its data, the tool points at an HTML +document the host renders as an interactive surface. + +Two parts, always two parts: + +1. **A tool** that does the work and returns data, like any other tool. +2. **A `ui://` resource** containing the HTML the host shows for it. + +The tool carries a `_meta.ui.resourceUri` reference to the resource. The host fetches +it with `resources/read`, renders it in a **sandboxed iframe**, and pushes the tool's +result into that iframe via `postMessage`. Your server never sends or receives any +`ui/*` messages: that traffic is between the host and the iframe. You serve a tool +and an HTML document; the host does the theater. + +The SDK ships this as the built-in `Apps` extension (`io.modelcontextprotocol/ui`). +If [Extensions](extensions.md) are new to you, skim that page first. One minute, +then come back. + +## A clock with a face + +```python title="server.py" hl_lines="18 21 29 31" +--8<-- "docs_src/apps/tutorial001.py" +``` + +Four moves: + +* `Apps()`: one instance holds your UI-bound tools and their resources. +* `@apps.tool(resource_uri="ui://clock/app.html")`: a regular tool, plus the + `_meta.ui.resourceUri` stamp. Everything `@mcp.tool()` accepts (name, title, + description, ...) passes through. +* `apps.add_html_resource("ui://clock/app.html", CLOCK_HTML)`: the matching + resource, served as `text/html;profile=mcp-app`. That exact MIME type is what + tells a host "this is an app, render it". +* `MCPServer("clock", extensions=[apps])`: opt in. The server now advertises + `io.modelcontextprotocol/ui` under `capabilities.extensions`. + +The HTML itself listens for the host's `postMessage` and shows the result. For real +apps, use the official [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) +browser SDK inside your HTML. It gives you `ontoolresult`, `callServerTool`, +`getHostContext`, and `onhostcontextchanged` instead of raw message events. + +## Graceful degradation + +Not every client renders apps. The spec is blunt about what that means for you: + +> Tools **MUST** return a meaningful `content` array even when UI is available. + +The model reads `content`; the iframe is for humans. A UI-capable host still feeds +the text result to the model, and a text-only client gets *only* that. So the +canonical pattern is one tool, two answers. Look at `get_time` again: + +```python title="server.py" hl_lines="22-26" +--8<-- "docs_src/apps/tutorial001.py" +``` + +`client_supports_apps(ctx)` is `True` only when the client declared the +`io.modelcontextprotocol/ui` extension **and** listed `text/html;profile=mcp-app` +in its `mimeTypes` settings. The field is required, so a client that omits it +does not count. That is exactly what `main()` in the same file declares: the +client half of the negotiation, and the rich answer comes back. + +!!! warning + Never return a placeholder like `"[Rendered UI]"` as the only content. If the + fallback text is useless, the tool is useless to every text-only client and to + the model itself. Write the sentence. + +## Locking the iframe down + +The resource side carries the security metadata: what the iframe may load, which +browser permissions it wants, how it would like to be framed: + +```python title="server.py" hl_lines="9 19-22" +--8<-- "docs_src/apps/tutorial002.py" +``` + +`csp` and `permissions` are **requests to the host**, not server behaviour. The host +builds the iframe's Content-Security-Policy and Permissions-Policy from them, and it +may refuse. Feature-detect in your JS rather than assuming a grant. + +`ResourceCsp`, field by field (Python name, wire key, what the host does with it): + +| Python | Wire (`_meta.ui.csp`) | Controls | +|---|---|---| +| `connect_domains` | `connectDomains` | `connect-src`: where `fetch`/XHR may go | +| `resource_domains` | `resourceDomains` | `img-src`, `style-src`, ...: static assets | +| `frame_domains` | `frameDomains` | `frame-src`: nested iframes | +| `base_uri_domains` | `baseUriDomains` | `base-uri`: what `` may point at | + +`ResourcePermissions`: each field requests a browser permission for the iframe. + +| Python | Wire (`_meta.ui.permissions`) | +|---|---| +| `camera` | `camera` | +| `microphone` | `microphone` | +| `geolocation` | `geolocation` | +| `clipboard_write` | `clipboardWrite` | + +!!! note + CSP and permissions live on the **resource**, never on the tool. The spec's tool + metadata has no slot for them, and hosts ignore them there. The SDK makes the + mistake unrepresentable: `@apps.tool()` simply has no `csp` parameter. + +### Visibility + +`visibility=["app"]` on a tool says "this exists for the iframe, not the model": + +* `"model"`: the model may call it. +* `"app"`: the iframe may call it (via `callServerTool`). +* Omitted: both, which is the default. + +Filtering is the **host's** job. Your server lists app-only tools in `tools/list` +like any other; the host hides them from the model. Don't filter server-side. + +## The rules the SDK enforces + +All of these fail at startup, not in production: + +* A `resource_uri` or resource URI that isn't `ui://...` is a `ValueError` at + decoration/registration time. +* A tool bound to a URI with **no matching registered resource** is a `ValueError` + when `MCPServer(extensions=[apps])` consumes the extension. A tool advertising + HTML that 404s on `resources/read` is a misconfiguration, so it refuses to + construct. +* `meta={"ui": ...}` on `@apps.tool()` is a `ValueError`. The decorator owns + `_meta["ui"]`; say it with `resource_uri=` and `visibility=`. Other `meta=` keys + merge fine alongside. + +Neither the TypeScript ext-apps SDK nor FastMCP catches any of these today; we'd +rather you find out before a host does. + +## Beyond inline HTML + +`add_html_resource` covers the common case: a string of HTML. For anything else, +HTML on disk or generated content, build the resource yourself and hand it over: + +```python title="server.py" hl_lines="12 18" +--8<-- "docs_src/apps/tutorial003.py" +``` + +`add_resource` fills in the `text/html;profile=mcp-app` MIME type when the resource +doesn't set one explicitly, and rejects an explicit mismatch: a `ui://` resource +under any other MIME type is one no host will render. + +!!! tip + Targeting a pre-GA host that still reads the deprecated flat + `_meta["ui/resourceUri"]` key? Merge it yourself: + `@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})`. + The nested `ui` object is the spec shape; the flat key is on its way out. + +## See it run + +The `apps` story in `examples/stories/` is this page as a runnable pair: a server +with a UI-bound clock tool and a client that negotiates Apps, reads the tool's +`_meta.ui.resourceUri`, fetches the HTML, and calls the tool. + +```bash +uv run python -m stories.apps.client +``` diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md new file mode 100644 index 0000000000..6ca1642288 --- /dev/null +++ b/docs/advanced/extensions.md @@ -0,0 +1,159 @@ +# Extensions + +An **extension** is an opt-in bundle of MCP behaviour behind one identifier. + +It can contribute tools, resources, and new request methods, and it can wrap `tools/call`. +The server advertises it under `capabilities.extensions`, the client opts in the same way, +and nothing changes for anyone who didn't ask for it. That is the contract (SEP-2133), and +it has one golden rule: **extensions are off by default**. + +## Using an extension + +Pass instances at construction: + +```python title="server.py" +--8<-- "docs_src/extensions/tutorial001.py" +``` + +Done. The server now advertises `io.modelcontextprotocol/ui` under +`capabilities.extensions` and serves everything the extension contributes. + +`Apps` is the built-in reference extension, and it gets its own page: **[MCP Apps](apps.md)**. + +!!! note + Extensions are fixed at construction. There is no `add_extension` to call later: + a server's capability map should not change while clients are connected to it. + +The capability map rides `server/discover`, which is a **2026-07-28** path. A legacy +`initialize` handshake has nowhere to put it, so a legacy client simply doesn't see +the extension. Design for that: an extension *augments* a server, it must not be the +only way the server is usable. + +## Writing your own + +Subclass `Extension` and override only what you need. Every method has a default. + +### The identifier + +```python +--8<-- "docs_src/extensions/tutorial002.py" +``` + +The identifier is a `vendor-prefix/name` string following the spec's `_meta` key +grammar: dot-separated labels (each starts with a letter, ends with a letter or +digit), a slash, then the name. It is validated **when the class is defined**, so a +typo doesn't wait for a server to boot: + +```text +TypeError: Stamps.identifier must be a `vendor-prefix/name` string +(reverse-DNS prefix required), got 'stamps' +``` + +Use a domain you control as the prefix. `io.modelcontextprotocol/*` is for extensions +specified by the MCP project itself. + +### Contributing tools + +The smallest useful extension is one tool and a settings map: + +```python title="server.py" hl_lines="17 19-20 22-23 26" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +* `tools()` returns `ToolBinding`s. The server registers each one exactly as if you + had called `mcp.add_tool(...)` yourself: same schema generation, same `Context` + injection, same everything. +* `settings()` is the value advertised at `capabilities.extensions["com.example/stamps"]`. + Return `{}` (the default) to advertise the extension with no settings. +* The extension never receives the server. It declares contributions as data; + `MCPServer` consumes them. There is no `self.server` to mutate. + +And `main()` is the proof, an in-memory client straight against `mcp`: + +```python title="server.py" hl_lines="29-34" +--8<-- "docs_src/extensions/tutorial003.py" +``` + +### Serving your own methods + +An extension can register **new request methods**: its own verbs, served next to the +spec's: + +```python title="server.py" hl_lines="15-21 30 39-47" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `SearchParams` subclasses `RequestParams`, so the 2026 `_meta` envelope parses + uniformly and your handler gets validated params, never a raw dict. Bound what + the client controls: `Field(ge=1, le=100)` rejects an absurd `limit` before + your code allocates anything for it. +* `require_client_extension(ctx, EXTENSION_ID)` is the gate: a client that did not + declare the extension gets the `-32021` (missing required client capability) error, + with the machine-readable `requiredCapabilities` payload the spec asks for. +* `protocol_versions=frozenset({"2026-07-28"})` pins the method to one wire version. + At any other version the client gets `METHOD_NOT_FOUND`, exactly as if the method + didn't exist there. For that client, it doesn't. + +Methods are **strictly additive**. The SDK enforces this at construction, not at +runtime: + +* A `MethodBinding` for a spec-defined method (`tools/list`, `completion/complete`, ...) + raises `ValueError` when the binding is constructed. Core verbs belong to the server. +* Two extensions binding the same method raise when the second one registers. + Last-write-wins is how plugins corrupt each other; we don't do that. +* An empty `protocol_versions` set raises too: a method that can never be served + is a bug, not a configuration. + +### The client side + +The same file's `main()` is the whole client story, both halves of it: + +```python title="server.py" hl_lines="53-57" +--8<-- "docs_src/extensions/tutorial004.py" +``` + +* `Client(..., extensions={EXTENSION_ID: {}})` declares the extension. That map + becomes `ClientCapabilities.extensions`: on a 2026-07-28 connection it travels in + the per-request `_meta` envelope, so the server sees it on **every** request; on + a legacy connection it rides the `initialize` handshake. Server code doesn't care + which: `require_client_extension(ctx, ...)` and + `ctx.session.check_client_capability(...)` read the right source on both paths. +* Vendor methods drop one layer to `client.session.send_request(...)`; `Client` + only grows first-class methods for spec verbs. The `cast` is there because + `send_request` is typed against the spec's closed request union. + +### Intercepting `tools/call` + +The one interceptive hook. Override `intercept_tool_call` to observe, short-circuit, +or veto a tool call: + +```python title="server.py" hl_lines="18-25" +--8<-- "docs_src/extensions/tutorial005.py" +``` + +* `params` is the validated `CallToolRequestParams`: you get `params.name` and + `params.arguments` without touching raw JSON. +* `call_next(ctx)` runs the rest of the chain. Return its result unchanged (observe), + return something else (replace), or raise an `MCPError` (refuse). +* With several extensions, interceptors nest in registration order: the first + extension in `extensions=[...]` is outermost. +* The default implementation is a pass-through, and a server whose extensions never + override this hook installs **no** middleware at all. You don't pay for what + you don't use. + +The hook wraps `tools/call` and nothing else. For every-message concerns, use +[Middleware](middleware.md). That is what it is for. + +## What an extension cannot do + +The contribution surface is **closed** on purpose: settings, tools, resources, +methods, one `tools/call` interceptor. An extension cannot: + +* **Reach into the server.** It declares data; it holds no server reference. +* **Replace core behaviour.** Spec methods are rejected at construction, and + `initialize` is reserved by the runner outright. +* **Register late.** After `MCPServer(...)` returns, the extension set is what it is. + +If you are fighting these walls, you are not writing an extension. You are writing +a fork. The walls are the feature: a user reading `extensions=[Apps(), Stamps()]` +knows *everything* those two can have touched. diff --git a/docs/migration.md b/docs/migration.md index 42d420bf04..d94db1f60b 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -407,6 +407,50 @@ On `ClientSession`, `call_tool` / `get_prompt` / `read_resource` still return th For protocol 2026-07-28 over Streamable HTTP, a tool's input-schema property may carry an `x-mcp-header` annotation. When a tool the client has listed is called, each annotated argument is mirrored into an `Mcp-Param-` request header (string verbatim, integer as decimal, boolean as `true`/`false`, base64-sentinel-wrapped when not header-safe; `null`/absent arguments are omitted). The argument is also left in the request body. `list_tools` caches a tool's annotations, so list a tool before calling it to enable mirroring; a tool the client never listed emits no `Mcp-Param-*` headers. Other transports ignore the annotation. +### Server extensions API (SEP-2133) + +`MCPServer` now accepts opt-in extensions that bundle MCP behaviour behind a +reverse-DNS identifier and advertise it under `ServerCapabilities.extensions` +(the 2026-07-28 capability map). An extension subclasses `mcp.server.extension.Extension` +and overrides only the contribution methods it needs: `tools()`/`resources()`/`methods()` +(additive) and `intercept_tool_call()` (wraps `tools/call`). The `identifier` must be a +`vendor-prefix/name` string following the spec's `_meta` key grammar; a class-level +`identifier` is validated when the subclass is defined, one assigned in `__init__` when +the extension is registered. Pass instances at construction: + +```python +from mcp.server.mcpserver import MCPServer +from mcp.server.apps import Apps + +mcp = MCPServer("demo", extensions=[Apps()]) +``` + +The reference extension is `mcp.server.apps.Apps` (`io.modelcontextprotocol/ui`): +it binds a tool to a `ui://` UI resource via `_meta.ui.resourceUri`, and +`client_supports_apps(ctx)` gates the SEP-2133 text-only fallback — `True` only +when the client's ui-extension settings list the `text/html;profile=mcp-app` +MIME type, per the Apps spec's required `mimeTypes` field. Every +`@apps.tool(resource_uri=...)` must have a matching resource registered on the +same `Apps` instance (`add_html_resource` for inline HTML, `add_resource` for a +pre-built `Resource`); a tool bound to an unregistered URI raises at +`MCPServer(...)` construction rather than 404ing on `resources/read` at runtime. + +Extension methods are strictly additive: a `MethodBinding` cannot name a +spec-defined request method, and registering one whose method collides with +another handler raises at construction. A `MethodBinding` may set +`protocol_versions` to scope an extension method to specific wire versions +(`frozenset()` is rejected — use `None` to admit every version); a request at +any other version is `METHOD_NOT_FOUND`. An +extension handler can call `mcp.server.mcpserver.require_client_extension(ctx, identifier)` +to reject a request with the `-32021` (missing required client capability) error +when the client did not declare the extension. + +Clients advertise extension support with the new `Client(extensions=...)` / +`ClientSession(extensions=...)` argument, mirrored into `ClientCapabilities.extensions`. +The extensions capability map is negotiated over `server/discover` (modern path); +a legacy `initialize` handshake does not carry it. Extensions are off by default +and never alter behaviour unless registered. + ### `McpError` renamed to `MCPError` The `McpError` exception class has been renamed to `MCPError` for consistent naming with the MCP acronym style used throughout the SDK. diff --git a/docs_src/apps/__init__.py b/docs_src/apps/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/apps/report.html b/docs_src/apps/report.html new file mode 100644 index 0000000000..7c94deefec --- /dev/null +++ b/docs_src/apps/report.html @@ -0,0 +1,3 @@ + +Report +

Quarterly numbers render here.

diff --git a/docs_src/apps/tutorial001.py b/docs_src/apps/tutorial001.py new file mode 100644 index 0000000000..79721c597b --- /dev/null +++ b/docs_src/apps/tutorial001.py @@ -0,0 +1,38 @@ +from mcp import Client +from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID, Apps, client_supports_apps +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.context import Context + +CLOCK_HTML = """\ + +Clock +

...

+ +""" + +apps = Apps() + + +@apps.tool(resource_uri="ui://clock/app.html", description="The current time.") +def get_time(ctx: Context) -> str: + now = "2026-06-26T12:00:00Z" + if not client_supports_apps(ctx): + return f"The time is {now}." + return now + + +apps.add_html_resource("ui://clock/app.html", CLOCK_HTML, title="Clock") + +mcp = MCPServer("clock", extensions=[apps]) + + +async def main() -> None: + async with Client(mcp, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as client: + result = await client.call_tool("get_time", {}) + print(result.content) + # [TextContent(text='2026-06-26T12:00:00Z')] diff --git a/docs_src/apps/tutorial002.py b/docs_src/apps/tutorial002.py new file mode 100644 index 0000000000..11393285b0 --- /dev/null +++ b/docs_src/apps/tutorial002.py @@ -0,0 +1,25 @@ +from mcp.server.apps import Apps, ResourceCsp, ResourcePermissions +from mcp.server.mcpserver import MCPServer + +DASHBOARD_HTML = "Dashboard" + +apps = Apps() + + +@apps.tool(resource_uri="ui://dashboard/app.html", visibility=["app"]) +def refresh_dashboard() -> str: + """Refresh the dashboard data.""" + return "refreshed" + + +apps.add_html_resource( + "ui://dashboard/app.html", + DASHBOARD_HTML, + title="Dashboard", + csp=ResourceCsp(connect_domains=["https://api.example.com"]), + permissions=ResourcePermissions(clipboard_write={}), + domain="dashboard.example.com", + prefers_border=True, +) + +mcp = MCPServer("dashboard", extensions=[apps]) diff --git a/docs_src/apps/tutorial003.py b/docs_src/apps/tutorial003.py new file mode 100644 index 0000000000..e3aed3ef78 --- /dev/null +++ b/docs_src/apps/tutorial003.py @@ -0,0 +1,20 @@ +from pathlib import Path + +from mcp.server.apps import Apps +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.resources import FileResource + +REPORT_HTML = Path(__file__).parent / "report.html" + +apps = Apps() + + +@apps.tool(resource_uri="ui://report/app.html") +def refresh_report() -> str: + """Refresh the report data.""" + return "report refreshed" + + +apps.add_resource(FileResource(uri="ui://report/app.html", name="report", path=REPORT_HTML)) + +mcp = MCPServer("report", extensions=[apps]) diff --git a/docs_src/extensions/__init__.py b/docs_src/extensions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/extensions/tutorial001.py b/docs_src/extensions/tutorial001.py new file mode 100644 index 0000000000..1e5a1f9076 --- /dev/null +++ b/docs_src/extensions/tutorial001.py @@ -0,0 +1,4 @@ +from mcp.server.apps import Apps +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("demo", extensions=[Apps()]) diff --git a/docs_src/extensions/tutorial002.py b/docs_src/extensions/tutorial002.py new file mode 100644 index 0000000000..87b59bd23a --- /dev/null +++ b/docs_src/extensions/tutorial002.py @@ -0,0 +1,5 @@ +from mcp.server.extension import Extension + + +class Stamps(Extension): + identifier = "com.example/stamps" diff --git a/docs_src/extensions/tutorial003.py b/docs_src/extensions/tutorial003.py new file mode 100644 index 0000000000..312371bee4 --- /dev/null +++ b/docs_src/extensions/tutorial003.py @@ -0,0 +1,35 @@ +from collections.abc import Sequence +from typing import Any + +from mcp import Client +from mcp.server.extension import Extension, ToolBinding +from mcp.server.mcpserver import MCPServer + + +def stamp(text: str) -> str: + """Stamp a message with the office seal.""" + return f"[stamped] {text}" + + +class Stamps(Extension): + """A purely additive extension: one tool, one capability entry.""" + + identifier = "com.example/stamps" + + def settings(self) -> dict[str, Any]: + return {"sealed": True} + + def tools(self) -> Sequence[ToolBinding]: + return [ToolBinding(fn=stamp)] + + +mcp = MCPServer("post-office", extensions=[Stamps()]) + + +async def main() -> None: + async with Client(mcp) as client: + print(client.server_capabilities.extensions) + # {'com.example/stamps': {'sealed': True}} + result = await client.call_tool("stamp", {"text": "hello"}) + print(result.content) + # [TextContent(text='[stamped] hello')] diff --git a/docs_src/extensions/tutorial004.py b/docs_src/extensions/tutorial004.py new file mode 100644 index 0000000000..4a0a022af3 --- /dev/null +++ b/docs_src/extensions/tutorial004.py @@ -0,0 +1,58 @@ +from collections.abc import Sequence +from typing import Any, Literal, cast + +import mcp_types as types +from pydantic import Field + +from mcp import Client +from mcp.server.context import ServerRequestContext +from mcp.server.extension import Extension, MethodBinding +from mcp.server.mcpserver import MCPServer, require_client_extension + +EXTENSION_ID = "com.example/search" + + +class SearchParams(types.RequestParams): + query: str + limit: int = Field(default=10, ge=1, le=100) + + +class SearchResult(types.Result): + items: list[str] + + +class SearchRequest(types.Request[SearchParams, Literal["com.example/search"]]): + method: Literal["com.example/search"] = "com.example/search" + params: SearchParams + + +async def search(ctx: ServerRequestContext[Any, Any], params: SearchParams) -> SearchResult: + require_client_extension(ctx, EXTENSION_ID) + return SearchResult(items=[f"{params.query}-{n}" for n in range(params.limit)]) + + +class Search(Extension): + """An extension that serves its own request method.""" + + identifier = EXTENSION_ID + + def methods(self) -> Sequence[MethodBinding]: + return [ + MethodBinding( + "com.example/search", + SearchParams, + search, + protocol_versions=frozenset({"2026-07-28"}), + ) + ] + + +mcp = MCPServer("catalog", extensions=[Search()]) + + +async def main() -> None: + async with Client(mcp, extensions={EXTENSION_ID: {}}) as client: + request = SearchRequest(params=SearchParams(query="mcp", limit=3)) + result = await client.session.send_request(cast("types.ClientRequest", request), SearchResult) + print(result.items) + # ['mcp-0', 'mcp-1', 'mcp-2'] diff --git a/docs_src/extensions/tutorial005.py b/docs_src/extensions/tutorial005.py new file mode 100644 index 0000000000..61ec6c76bc --- /dev/null +++ b/docs_src/extensions/tutorial005.py @@ -0,0 +1,34 @@ +import logging +from typing import Any + +from mcp_types import CallToolRequestParams + +from mcp.server.context import CallNext, HandlerResult, ServerRequestContext +from mcp.server.extension import Extension +from mcp.server.mcpserver import MCPServer + +logger = logging.getLogger(__name__) + + +class AuditLog(Extension): + """Observe every tools/call without touching its result.""" + + identifier = "com.example/audit" + + async def intercept_tool_call( + self, + params: CallToolRequestParams, + ctx: ServerRequestContext[Any, Any], + call_next: CallNext, + ) -> HandlerResult: + logger.info("tool %r called", params.name) + return await call_next(ctx) + + +mcp = MCPServer("audited", extensions=[AuditLog()]) + + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b diff --git a/examples/stories/apps/README.md b/examples/stories/apps/README.md index b802525fa0..dc180a0d3d 100644 --- a/examples/stories/apps/README.md +++ b/examples/stories/apps/README.md @@ -1,14 +1,40 @@ # 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. +MCP Apps: a tool carries a `_meta.ui.resourceUri` reference to a `ui://` +resource that the host renders as an interactive surface. The server opts in via +the `Apps` extension (`io.modelcontextprotocol/ui`); the client negotiates it by +advertising the `text/html;profile=mcp-app` MIME type. -**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. +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.apps.client + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.apps.client --http +``` + +## What to look at + +- `server.py` `MCPServer("apps-example", extensions=[apps])` — the extension + advertises `io.modelcontextprotocol/ui` under `ServerCapabilities.extensions` + and contributes the UI-bound tool and its `ui://` resource. `MCPServer` itself + never learns about "ui"; it applies a closed set of contributions. +- `server.py` `@apps.tool(resource_uri=...)` — stamps `_meta.ui.resourceUri` on + the tool; `add_html_resource` registers the matching `ui://` resource at + `text/html;profile=mcp-app`. +- `server.py` `client_supports_apps(ctx)` — SEP-2133 graceful degradation: a + client that did not negotiate Apps gets a text-only result. +- `client.py` `Client(target, extensions={...})` — the client advertises Apps + support so the server returns the UI-enabled result, then reads the tool's + `_meta.ui.resourceUri` and fetches that resource. ## Spec [MCP Apps — extensions](https://modelcontextprotocol.io/specification/draft/extensions/apps) · [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133) + +## See also + +`custom_methods/` (registering a non-spec method without an extension). diff --git a/examples/stories/apps/__init__.py b/examples/stories/apps/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/apps/client.py b/examples/stories/apps/client.py new file mode 100644 index 0000000000..8a238f469e --- /dev/null +++ b/examples/stories/apps/client.py @@ -0,0 +1,35 @@ +"""Negotiate MCP Apps, discover a tool's `ui://` UI, fetch it, and call the tool.""" + +from mcp_types import TextContent, TextResourceContents + +from mcp.client import Client +from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID +from stories._harness import Target, run_client + + +async def main(target: Target, *, mode: str = "auto") -> None: + # Advertise MCP Apps support so the server returns the UI-enabled result; a + # client that omits this gets the text-only fallback (graceful degradation). + async with Client(target, mode=mode, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as client: + # The extensions capability map rides `server/discover` (modern only). On a + # legacy connection (today's stdio) it is absent, so assert it only when present. + if client.server_capabilities.extensions is not None: + assert client.server_capabilities.extensions == {EXTENSION_ID: {}}, client.server_capabilities.extensions + + listed = await client.list_tools() + tool = next(t for t in listed.tools if t.name == "get_time") + assert tool.meta is not None, tool + assert tool.meta["ui"]["resourceUri"] == "ui://get-time/app.html", tool.meta + + ui = await client.read_resource("ui://get-time/app.html") + contents = ui.contents[0] + assert isinstance(contents, TextResourceContents) + assert contents.mime_type == APP_MIME_TYPE, contents.mime_type + + result = await client.call_tool("get_time", {}) + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "2026-06-26T00:00:00Z", result.content[0].text + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/apps/server.py b/examples/stories/apps/server.py new file mode 100644 index 0000000000..74d412e02c --- /dev/null +++ b/examples/stories/apps/server.py @@ -0,0 +1,43 @@ +"""MCP Apps: a tool bound to a `ui://` resource the host renders as an interactive surface. + +`Apps` is an opt-in `Extension` passed to `MCPServer(extensions=[...])`. The +`@apps.tool(resource_uri=...)` decorator stamps `_meta.ui.resourceUri` onto the +tool; `add_html_resource` registers the matching `ui://` HTML resource. The tool +degrades gracefully: `client_supports_apps(ctx)` reports whether the client +negotiated Apps, so it returns text-only output otherwise. +""" + +from mcp.server.apps import Apps, client_supports_apps +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.context import Context +from stories._hosting import run_server_from_args + +RESOURCE_URI = "ui://get-time/app.html" +CLOCK_HTML = """ +Current time +

+ +""" + + +def build_server() -> MCPServer: + apps = Apps() + + @apps.tool(resource_uri=RESOURCE_URI, title="Get Time", description="Return the current time.") + def get_time(ctx: Context) -> str: + now = "2026-06-26T00:00:00Z" + if not client_supports_apps(ctx): + return f"The time is {now}." + return now + + apps.add_html_resource(RESOURCE_URI, CLOCK_HTML, title="Clock") + return MCPServer("apps-example", extensions=[apps]) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/extensions/README.md b/examples/stories/extensions/README.md new file mode 100644 index 0000000000..6d3da72c9f --- /dev/null +++ b/examples/stories/extensions/README.md @@ -0,0 +1,41 @@ +# extensions + +Writing your own extension (SEP-2133): one identifier bundles a settings entry +under `ServerCapabilities.extensions`, a contributed tool, and a vendor request +method gated on the client declaring the extension back. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.extensions.client + +# HTTP — the client self-hosts the server on a free port, runs, then tears it down +uv run python -m stories.extensions.client --http +``` + +## What to look at + +- `server.py` `class Catalog(Extension)` — the whole extension: `settings()` + becomes the advertised capability entry, `tools()` contributes a regular tool, + `methods()` registers a vendor verb. The extension never holds the server; it + declares contributions and `MCPServer(extensions=[...])` consumes them. +- `server.py` `require_client_extension(ctx, EXTENSION_ID)` — the vendor method + rejects clients that did not declare the extension with `-32021` (missing + required client capability) and a machine-readable `requiredCapabilities` + payload. +- `client.py` `Client(target, extensions={EXTENSION_ID: {}})` — the client-side + half of the negotiation; on 2026-07-28 it travels in the per-request `_meta` + envelope. +- `client.py` `client.session.send_request(...)` — vendor methods have no + `Client`-level helper; the session escape hatch sends them. + +## Spec + +[SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133) +· [Capabilities — `_meta` key grammar](https://modelcontextprotocol.io/specification/draft/basic/index) + +## See also + +`apps/` (the built-in MCP Apps extension) · `custom_methods/` (the same verb +registered on the lowlevel `Server` by hand, without an extension). diff --git a/examples/stories/extensions/__init__.py b/examples/stories/extensions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/extensions/client.py b/examples/stories/extensions/client.py new file mode 100644 index 0000000000..d3aacc140f --- /dev/null +++ b/examples/stories/extensions/client.py @@ -0,0 +1,54 @@ +"""Discover an extension's capability entry, call its tool, then send its vendor method.""" + +from typing import Literal, cast + +import mcp_types as types +from mcp_types import TextContent + +from mcp.client import Client +from stories._harness import Target, run_client + +EXTENSION_ID = "com.example/catalog" + + +class SearchParams(types.RequestParams): + query: str + limit: int = 3 + + +class SearchRequest(types.Request[SearchParams, Literal["com.example/search"]]): + method: Literal["com.example/search"] = "com.example/search" + params: SearchParams + + +class SearchResult(types.Result): + items: list[str] + + +async def main(target: Target, *, mode: str = "auto") -> None: + # Declare the extension client-side so the server's `require_client_extension` + # gate on `com.example/search` passes. + async with Client(target, mode=mode, extensions={EXTENSION_ID: {}}) as client: + # The extensions capability map rides `server/discover` (modern only). On a + # legacy connection it is absent, so assert it only when present. + if client.server_capabilities.extensions is not None: + assert client.server_capabilities.extensions == {EXTENSION_ID: {"suggest": True}}, ( + client.server_capabilities.extensions + ) + + # The extension's tool is a regular tool: listed and callable like any other. + listed = await client.list_tools() + assert [tool.name for tool in listed.tools] == ["suggest"], listed + result = await client.call_tool("suggest", {"prefix": "mcp"}) + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "mcp-suggestion", result.content[0].text + + # Vendor methods drop one layer to `client.session` (see custom_methods/); + # the cast is needed because `send_request` is typed against the spec union. + request = SearchRequest(params=SearchParams(query="mcp", limit=3)) + found = await client.session.send_request(cast("types.ClientRequest", request), SearchResult) + assert found.items == ["mcp-0", "mcp-1", "mcp-2"], found + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/extensions/server.py b/examples/stories/extensions/server.py new file mode 100644 index 0000000000..837c668dc5 --- /dev/null +++ b/examples/stories/extensions/server.py @@ -0,0 +1,64 @@ +"""Package a vendor verb and a tool as a reusable, advertised extension (SEP-2133). + +`custom_methods/` registers a verb on the lowlevel `Server` by hand; this story +bundles the same idea as an `Extension`: declared contributions, a settings entry +under `ServerCapabilities.extensions`, and a `require_client_extension` gate on +the vendor method. +""" + +from collections.abc import Sequence +from typing import Any + +import mcp_types as types +from pydantic import Field + +from mcp.server.context import ServerRequestContext +from mcp.server.extension import Extension, MethodBinding, ToolBinding +from mcp.server.mcpserver import MCPServer, require_client_extension +from stories._hosting import run_server_from_args + +EXTENSION_ID = "com.example/catalog" + + +class SearchParams(types.RequestParams): + """Subclass `RequestParams` so `_meta` (and the 2026 envelope keys) parse uniformly.""" + + query: str + limit: int = Field(default=3, ge=1, le=25) + + +class SearchResult(types.Result): + items: list[str] + + +def suggest(prefix: str) -> str: + """Suggest a catalog entry for a prefix.""" + return f"{prefix}-suggestion" + + +async def search(ctx: ServerRequestContext[Any, Any], params: SearchParams) -> SearchResult: + require_client_extension(ctx, EXTENSION_ID) + return SearchResult(items=[f"{params.query}-{n}" for n in range(params.limit)]) + + +class Catalog(Extension): + """One identifier, three contributions: settings, a tool, a vendor method.""" + + identifier = EXTENSION_ID + + def settings(self) -> dict[str, Any]: + return {"suggest": True} + + def tools(self) -> Sequence[ToolBinding]: + return [ToolBinding(fn=suggest)] + + def methods(self) -> Sequence[MethodBinding]: + return [MethodBinding("com.example/search", SearchParams, search)] + + +def build_server() -> MCPServer: + return MCPServer("extensions-example", extensions=[Catalog()]) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/manifest.toml b/examples/stories/manifest.toml index cd7c352dbb..0fb25a0f06 100644 --- a/examples/stories/manifest.toml +++ b/examples/stories/manifest.toml @@ -48,6 +48,21 @@ status = "deprecated" [story.custom_methods] lowlevel = false +[story.apps] +# Extension API is MCPServer-tier (Apps decorators + extensions=[...]); no lowlevel variant. +# The extensions capability map (SEP-2133) rides server/discover, a modern-only path, so +# `main` pins "auto" (legacy initialize cannot carry it) and the leg is http-asgi. +lowlevel = false +transports = ["in-memory", "http-asgi"] +era = "dual-in-body" + +[story.extensions] +# Same constraints as `apps`: MCPServer-tier extension API, capability map rides +# server/discover (modern only), client guards the capability assert by presence. +lowlevel = false +transports = ["in-memory", "http-asgi"] +era = "dual-in-body" + [story.schema_validators] [story.middleware] @@ -147,7 +162,6 @@ fixed_port = 8000 # issuer/PRM metadata bake in :8 [deferred] caching = "client honouring + per-result override unlanded" subscriptions = "#2901 — Client.listen / ServerEventBus" -tasks = "extensions capability map + tasks runtime" -apps = "#2896 — extensions capability map" +tasks = "SEP-2663 — tasks extension runtime (server-decided augmentation, CreateTaskResult)" skills = "#2896 — SEP-2640" events = "#2901 + #2896" diff --git a/examples/stories/tasks/README.md b/examples/stories/tasks/README.md index ef15ae63fc..d1956d1e33 100644 --- a/examples/stories/tasks/README.md +++ b/examples/stories/tasks/README.md @@ -1,16 +1,24 @@ # 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. +Task-augmented execution: a requestor augments a `tools/call` with a `task`, the +receiver returns a `CreateTaskResult` immediately, and the requestor polls +`tasks/get` and retrieves the deferred result. -**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. +**Status: deferred.** Tasks ship in 2026-07-28 as +[SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/seps/2663-tasks-extension.md), +an `io.modelcontextprotocol/tasks` extension that is wire-incompatible with the +2025-11-25 in-core design still carried (types-only) in `mcp_types`. The runtime +needs to be built to the SEP — server-decided augmentation (ignoring the legacy +`params.task`), the `{tasks/get, tasks/update, tasks/cancel}` method set, the +`resultType: "task"` envelope, `execution.taskSupport` gating, and `ttlMs` +fields — so it lands in a separate PR with the conformance `tasks-*` scenarios +wired in. ## Spec -[Tasks — basic utilities](https://modelcontextprotocol.io/specification/draft/basic/utilities/tasks) +[SEP-2663 — Tasks extension](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/seps/2663-tasks-extension.md) · [SEP-2133 — extensions capability](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2133) + +## See also + +`apps/` (the additive half of the extension API). diff --git a/mkdocs.yml b/mkdocs.yml index 63bf9aadc6..3e671da8c7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -43,6 +43,8 @@ nav: - URI templates: advanced/uri-templates.md - Pagination: advanced/pagination.md - Middleware: advanced/middleware.md + - Extensions: advanced/extensions.md + - MCP Apps: advanced/apps.md - OpenTelemetry: advanced/opentelemetry.md - Authorization: advanced/authorization.md - OAuth clients: advanced/oauth-clients.md diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index d6a6e4caae..d3290f3080 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -217,6 +217,10 @@ async def main(): `read_resource` give up. Use `client.session.(..., allow_input_required=True)` to drive the loop manually instead.""" + extensions: dict[str, dict[str, Any]] | None = None + """SEP-2133 extension support to advertise under `ClientCapabilities.extensions` + (identifier -> settings), e.g. `{"io.modelcontextprotocol/ui": {"mimeTypes": [...]}}`.""" + _entered: bool = field(init=False, default=False) _session: ClientSession | None = field(init=False, default=None) _exit_stack: AsyncExitStack | None = field(init=False, default=None) @@ -255,6 +259,7 @@ async def _build_session(self, exit_stack: AsyncExitStack) -> ClientSession: message_handler=self.message_handler, client_info=self.client_info, elicitation_callback=self.elicitation_callback, + extensions=self.extensions, ) async def __aenter__(self) -> Client: diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index fa71d1330d..3cebb569ec 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -224,12 +224,14 @@ def __init__( client_info: types.Implementation | None = None, *, sampling_capabilities: types.SamplingCapability | None = None, + extensions: dict[str, dict[str, Any]] | 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._sampling_callback = sampling_callback or _default_sampling_callback self._sampling_capabilities = sampling_capabilities + self._extensions = extensions self._elicitation_callback = elicitation_callback or _default_elicitation_callback self._list_roots_callback = list_roots_callback or _default_list_roots_callback self._logging_callback = logging_callback or _default_logging_callback @@ -369,7 +371,9 @@ def _build_capabilities(self) -> types.ClientCapabilities: if self._list_roots_callback is not _default_list_roots_callback else None ) - return types.ClientCapabilities(sampling=sampling, elicitation=elicitation, experimental=None, roots=roots) + return types.ClientCapabilities( + sampling=sampling, elicitation=elicitation, experimental=None, extensions=self._extensions, roots=roots + ) async def initialize(self) -> types.InitializeResult: if self._initialize_result is not None: diff --git a/src/mcp/server/apps.py b/src/mcp/server/apps.py new file mode 100644 index 0000000000..d5b9d9ed85 --- /dev/null +++ b/src/mcp/server/apps.py @@ -0,0 +1,242 @@ +"""MCP Apps extension (`io.modelcontextprotocol/ui`). + +MCP Apps lets a tool carry a reference to an interactive UI: the tool's +`_meta.ui.resourceUri` points at a `ui://` resource (an HTML document served +with the `text/html;profile=mcp-app` MIME type) that the host renders in a +sandboxed iframe. See https://modelcontextprotocol.io/specification/draft/extensions/apps +and the ext-apps spec for the wire format, and SEP-2133 for the extension framework. + +This is a self-contained, additive `Extension`: it contributes tools and +resources and advertises the capability, but does not intercept any core method. +A server opts in by passing an `Apps` instance to `MCPServer(extensions=[...])`. + + apps = Apps() + + @apps.tool(resource_uri="ui://clock/app.html", description="Current time") + def get_time(ctx: Context) -> str: + return datetime.now(timezone.utc).isoformat() + + apps.add_html_resource("ui://clock/app.html", CLOCK_HTML) + + mcp = MCPServer("clock", extensions=[apps]) + +Per SEP-2133, an extension MUST degrade gracefully: a UI-enabled tool should +still return meaningful text for clients that did not negotiate Apps. Use +`client_supports_apps(ctx)` to branch on the client's advertised support. (The SDK +keeps Apps in-core under `mcp.server.apps` rather than a separate package; the +TypeScript and C# SDKs ship it as a standalone package.) +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from typing import Any, Literal, TypeVar + +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel + +from mcp.server.context import ServerRequestContext +from mcp.server.extension import Extension, ResourceBinding, ToolBinding +from mcp.server.mcpserver.context import Context +from mcp.server.mcpserver.resources import Resource, TextResource + +EXTENSION_ID = "io.modelcontextprotocol/ui" +"""The MCP Apps extension identifier (the shipped TS/C# constant).""" + +APP_MIME_TYPE = "text/html;profile=mcp-app" +"""MIME type for a `ui://` app resource.""" + +Visibility = Literal["model", "app"] +"""Where a UI-bound tool is surfaced (`_meta.ui.visibility`).""" + +_CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) + + +class ResourcePermissions(BaseModel): + """Iframe permissions a `ui://` resource requests (`_meta.ui.permissions`).""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + camera: dict[str, Any] | None = None + microphone: dict[str, Any] | None = None + geolocation: dict[str, Any] | None = None + clipboard_write: dict[str, Any] | None = None + + +class ResourceCsp(BaseModel): + """Content-Security-Policy domains for a `ui://` resource (`_meta.ui.csp`).""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + connect_domains: list[str] | None = None + resource_domains: list[str] | None = None + frame_domains: list[str] | None = None + base_uri_domains: list[str] | None = None + + +class Apps(Extension): + """The MCP Apps extension: bind tools to `ui://` UI resources. + + Register UI-bound tools with `@apps.tool(resource_uri=...)` and their HTML + with `add_html_resource(...)`, then pass the instance to + `MCPServer(extensions=[apps])`. + """ + + identifier = EXTENSION_ID + + def __init__(self) -> None: + self._tools: list[tuple[ToolBinding, str]] = [] # (binding, bound resource_uri) + self._resources: list[ResourceBinding] = [] + + def tool( + self, + *, + resource_uri: str, + visibility: Sequence[Visibility] | None = None, + meta: dict[str, Any] | None = None, + **tool_kwargs: Any, + ) -> Callable[[_CallableT], _CallableT]: + """Decorator registering a tool bound to a `ui://` resource. + + Stamps `_meta.ui.resourceUri` (and `_meta.ui.visibility` when given) on the + tool. `tool_kwargs` are forwarded to `MCPServer.add_tool` (name, title, + description, annotations, ...); pass `meta=` to merge extra `_meta` keys + alongside the `ui` entry. + + Args: + resource_uri: The `ui://` URI of the UI resource this tool renders. + visibility: Where the tool is surfaced (`["model", "app"]`). + meta: Additional `_meta` keys to merge with the `ui` entry. + + Raises: + ValueError: If `resource_uri` does not use the `ui://` scheme, or + `meta` carries a `"ui"` key (the decorator owns `_meta["ui"]`). + """ + _require_ui_scheme(resource_uri) + if meta and "ui" in meta: + raise ValueError("Apps.tool() owns _meta['ui']; pass resource_uri=/visibility= instead of a 'ui' meta key") + ui: dict[str, Any] = {"resourceUri": resource_uri} + if visibility is not None: + ui["visibility"] = list(visibility) + + def decorator(fn: _CallableT) -> _CallableT: + binding = ToolBinding(fn=fn, meta={**(meta or {}), "ui": ui}, kwargs=tool_kwargs) + self._tools.append((binding, resource_uri)) + return fn + + return decorator + + def add_html_resource( + self, + uri: str, + html: str, + *, + name: str | None = None, + title: str | None = None, + description: str | None = None, + csp: ResourceCsp | None = None, + permissions: ResourcePermissions | None = None, + domain: str | None = None, + prefers_border: bool | None = None, + ) -> None: + """Register a `ui://` HTML resource served as `text/html;profile=mcp-app`. + + `csp`, `permissions`, `domain`, and `prefers_border` populate the + resource's `_meta.ui` per the ext-apps spec. + + Args: + uri: The `ui://` URI; a tool references it via `resource_uri`. + html: The HTML document the host renders. + + Raises: + ValueError: If `uri` does not use the `ui://` scheme. + """ + ui: dict[str, Any] = {} + if csp is not None: + ui["csp"] = csp.model_dump(by_alias=True, exclude_none=True) + if permissions is not None: + ui["permissions"] = permissions.model_dump(by_alias=True, exclude_none=True) + if domain is not None: + ui["domain"] = domain + if prefers_border is not None: + ui["prefersBorder"] = prefers_border + self.add_resource( + TextResource( + uri=uri, + name=name or uri, + title=title, + description=description, + mime_type=APP_MIME_TYPE, + meta={"ui": ui} if ui else None, + text=html, + ) + ) + + def add_resource(self, resource: Resource) -> None: + """Register a pre-built `ui://` resource. + + The escape hatch for resources `add_html_resource` cannot express (e.g. a + `FileResource` serving HTML from disk). A resource without an explicit + `mime_type` is served as `text/html;profile=mcp-app` — hosts will not + render a `ui://` resource under any other MIME type, so an explicit + mismatch is rejected. + + Raises: + ValueError: If the resource URI does not use the `ui://` scheme, or + its explicit `mime_type` is not `text/html;profile=mcp-app`. + """ + _require_ui_scheme(resource.uri) + if "mime_type" not in resource.model_fields_set: + resource = resource.model_copy(update={"mime_type": APP_MIME_TYPE}) + elif resource.mime_type != APP_MIME_TYPE: + raise ValueError(f"MCP Apps resources are served as {APP_MIME_TYPE!r}, got {resource.mime_type!r}") + self._resources.append(ResourceBinding(resource=resource)) + + def tools(self) -> Sequence[ToolBinding]: + """The bound tools. + + Raises: + ValueError: If a tool's `resource_uri` has no matching resource + registered on this instance — a tool advertising a + `_meta.ui.resourceUri` that 404s on `resources/read` is a + misconfiguration, caught when the server consumes the extension. + """ + registered = {binding.resource.uri for binding in self._resources} + for tool, uri in self._tools: + if uri not in registered: + raise ValueError( + f"Apps tool {tool.fn.__name__!r} binds resource_uri {uri!r}, but no such resource " + "is registered; add it with add_html_resource() or add_resource()" + ) + return [tool for tool, _ in self._tools] + + def resources(self) -> Sequence[ResourceBinding]: + return self._resources + + +def client_supports_apps(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> bool: + """Whether the connected client negotiated MCP Apps support. + + Returns `True` only when the client advertised the extension AND listed the + `text/html;profile=mcp-app` MIME type in its settings, so a UI-enabled tool + can fall back to text-only output otherwise. + """ + capabilities = _client_capabilities(ctx) + extensions = capabilities.extensions if capabilities else None + settings = extensions.get(EXTENSION_ID) if extensions else None + if settings is None: + return False + mime_types = settings.get("mimeTypes") + return isinstance(mime_types, list | tuple) and APP_MIME_TYPE in mime_types + + +def _client_capabilities(ctx: Context[Any] | ServerRequestContext[Any, Any]) -> Any: + if isinstance(ctx, Context): + return ctx.client_capabilities + client_params = ctx.session.client_params + return client_params.capabilities if client_params else None + + +def _require_ui_scheme(uri: str) -> None: + if not uri.startswith("ui://"): + raise ValueError(f"MCP Apps URIs must use the ui:// scheme, got {uri!r}") diff --git a/src/mcp/server/connection.py b/src/mcp/server/connection.py index 76917f8967..4d9496fef1 100644 --- a/src/mcp/server/connection.py +++ b/src/mcp/server/connection.py @@ -345,4 +345,14 @@ def check_capability(self, capability: ClientCapabilities) -> bool: for k, v in capability.experimental.items(): if k not in have.experimental or have.experimental[k] != v: return False + if capability.extensions is not None: + # SEP-2133: an extension is supported when the client declares its + # identifier. Settings are negotiated per-extension (the client may + # advertise more than the server asks for), so presence - not value + # equality - is the meaningful check. + if have.extensions is None: + return False + for identifier in capability.extensions: + if identifier not in have.extensions: + return False return True diff --git a/src/mcp/server/extension.py b/src/mcp/server/extension.py new file mode 100644 index 0000000000..e045e6f29d --- /dev/null +++ b/src/mcp/server/extension.py @@ -0,0 +1,195 @@ +"""Pluggable extension interface for MCP servers (SEP-2133). + +An extension is a self-contained, opt-in bundle of MCP behaviour, identified by +a reverse-DNS string (e.g. `io.modelcontextprotocol/ui`). It is passed to +`MCPServer(extensions=[...])`, and the server applies a *closed* set of +contribution kinds: tools, resources, new request methods, and one `tools/call` +interceptor. The server never hands itself to an extension; the extension +declares what it adds, and the server consumes it. + +The shape follows the HTTPX `Transport`/`Auth` pattern: a narrow base class whose +methods have sensible defaults, so an extension overrides only what it needs. A +purely additive extension (Apps) overrides `tools`/`resources`; an interceptive +one overrides `methods`/`intercept_tool_call`. + +This module lives at the `mcp.server` tier (not `mcp.server.mcpserver`) so the +base class itself never drags in the composition tier that consumes it; +extensions remain importable without constructing an `MCPServer`. +""" + +from __future__ import annotations + +import re +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from mcp_types import CallToolRequestParams +from mcp_types.methods import SPEC_CLIENT_METHODS +from pydantic import BaseModel + +from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext + +if TYPE_CHECKING: + from mcp.server.mcpserver.resources import Resource + +RequestHandler = Callable[[ServerRequestContext[Any, Any], Any], Awaitable[HandlerResult]] + +# Extension identifiers follow the `_meta` key grammar with a mandatory prefix +# (SEP-2133 / basic/index.mdx): dot-separated labels, each starting with a +# letter and ending with a letter or digit (hyphens interior), then `/`, then a +# name that starts and ends alphanumeric (`.`/`_`/`-` interior). +_LABEL = r"[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?" +_NAME = r"[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?" +_IDENTIFIER_RE = re.compile(rf"{_LABEL}(?:\.{_LABEL})*/{_NAME}") + + +def validate_extension_identifier(identifier: Any, *, owner: str) -> None: + """Raise `TypeError` unless `identifier` is a `vendor-prefix/name` string. + + SEP-2133 requires extension identifiers to carry a reverse-DNS prefix. + """ + if not isinstance(identifier, str) or not _IDENTIFIER_RE.fullmatch(identifier): + raise TypeError( + f"{owner}.identifier must be a `vendor-prefix/name` string " + f"(reverse-DNS prefix required), got {identifier!r}" + ) + + +@dataclass(frozen=True) +class ToolBinding: + """A tool an extension contributes, plus the `_meta` to stamp on it.""" + + fn: Callable[..., Any] + meta: dict[str, Any] | None = None + kwargs: dict[str, Any] = field(default_factory=lambda: {}) + + +@dataclass(frozen=True) +class ResourceBinding: + """A pre-built resource an extension contributes.""" + + resource: Resource + + +@dataclass(frozen=True) +class MethodBinding: + """A new request method an extension serves, e.g. `tasks/get`. + + `params_type` validates incoming params before `handler` runs; it should + subclass `RequestParams` so `_meta` parses uniformly. `protocol_versions`, + when set, restricts the method to those wire versions - a request for the + method at any other version is rejected as `METHOD_NOT_FOUND`, mirroring the + spec's `(method, version)` boundary table. `None` (the default) admits the + method at every version. + + Extension methods are additive: `method` must not name a spec-defined + request method (`tools/list`, `completion/complete`, ...) — those handlers + belong to the server, and an extension binding one would silently shadow or + be shadowed by it. Both constraints are enforced at construction. To + re-provide a spec method the 2026 revision removed (e.g. `logging/setLevel` + for legacy clients), use the lowlevel `Server.add_request_handler` API + instead — the runner's per-version surface gate would never route such a + method to an extension handler anyway. + """ + + method: str + params_type: type[BaseModel] + handler: RequestHandler + protocol_versions: frozenset[str] | None = None + + def __post_init__(self) -> None: + if self.method in SPEC_CLIENT_METHODS: + raise ValueError( + f"MethodBinding cannot bind spec method {self.method!r}; extension methods are " + "additive — use Extension.intercept_tool_call or Server.middleware to wrap core behaviour" + ) + if self.protocol_versions is not None and not self.protocol_versions: + raise ValueError( + f"MethodBinding for {self.method!r} has an empty protocol_versions set, so it could " + "never be served; use None to admit every version" + ) + + +class Extension: + """Base class for an opt-in MCP extension. Override only the methods you need. + + Subclass and set `identifier`, then override the contribution methods that + apply. Every method has a default, so a minimal extension overrides nothing + but `identifier` and one of `tools`/`resources`/`methods`. `identifier` is + enforced at subclass-definition time. + """ + + #: Reverse-DNS extension identifier, advertised under `ServerCapabilities.extensions`. + identifier: str + + def __init_subclass__(cls, **kwargs: Any) -> None: + super().__init_subclass__(**kwargs) + # Validate a class-level `identifier` at definition time. A subclass may + # instead assign `identifier` in `__init__` (per-instance ids); that case + # is validated when the extension is applied, since no class attribute + # exists to inspect here. + identifier = cls.__dict__.get("identifier") + if identifier is not None: + validate_extension_identifier(identifier, owner=cls.__name__) + + def settings(self) -> dict[str, Any]: + """Per-extension settings advertised at `capabilities.extensions[identifier]`. + + An empty dict (the default) advertises the extension with no settings. + """ + return {} + + def tools(self) -> Sequence[ToolBinding]: + """Tools this extension contributes (additive).""" + return () + + def resources(self) -> Sequence[ResourceBinding]: + """Resources this extension contributes (additive).""" + return () + + def methods(self) -> Sequence[MethodBinding]: + """New request methods this extension serves (additive).""" + return () + + async def intercept_tool_call( + self, + params: CallToolRequestParams, + ctx: ServerRequestContext[Any, Any], + call_next: CallNext, + ) -> HandlerResult: + """Wrap `tools/call`. Default: pass through unchanged. + + Override to short-circuit (return a result without calling `call_next`) + or to observe the call. `params` is the validated `tools/call` params; + `call_next(ctx)` runs the rest of the chain and the real handler. + """ + return await call_next(ctx) + + +def compose_tool_call_interceptor(extensions: Sequence[Extension]) -> ServerMiddleware[Any]: + """Fold every extension's `intercept_tool_call` into one `ServerMiddleware`. + + The returned middleware nests the interceptors (first extension outermost) + and is a no-op for any method other than `tools/call`. It validates the + `tools/call` params once and threads them to each interceptor. + """ + + async def middleware(ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult: + if ctx.method != "tools/call": + return await call_next(ctx) + params = CallToolRequestParams.model_validate({} if ctx.params is None else ctx.params, by_name=False) + + chain = call_next + for extension in reversed(extensions): + chain = _bind_interceptor(extension, params, chain) + return await chain(ctx) + + return middleware + + +def _bind_interceptor(extension: Extension, params: CallToolRequestParams, call_next: CallNext) -> CallNext: + async def call(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: + return await extension.intercept_tool_call(params, ctx, call_next) + + return call diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index c10ff82f3a..6f4d9f8124 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -434,6 +434,10 @@ def __init__( # Context/middleware rework (covariant `Context[L]`, outbound seam) before # v2 final. self.middleware: list[ServerMiddleware[LifespanResultT]] = [OpenTelemetryMiddleware()] + # SEP-2133 extension settings advertised under `ServerCapabilities.extensions` + # (identifier -> settings). Higher layers (e.g. `MCPServer(extensions=...)`) + # populate it; `get_capabilities` reads it when no explicit map is passed. + self.extensions: dict[str, dict[str, Any]] = {} logger.debug("Initializing server %r", name) _spec_requests: list[tuple[str, type[BaseModel], RequestHandler[LifespanResultT, Any] | None]] = [ @@ -521,8 +525,15 @@ def create_initialization_options( self, notification_options: NotificationOptions | None = None, experimental_capabilities: dict[str, dict[str, Any]] | None = None, + extensions: dict[str, dict[str, Any]] | None = None, ) -> InitializationOptions: - """Create initialization options from this server instance.""" + """Create initialization options from this server instance. + + `extensions` advertises SEP-2133 extension support under + `ServerCapabilities.extensions`; keys are extension identifiers (e.g. + `io.modelcontextprotocol/ui`), values are per-extension settings. + Defaults to `self.extensions`, which higher layers populate. + """ return InitializationOptions( server_name=self.name, server_version=self.version if self.version else _package_version("mcp"), @@ -531,6 +542,7 @@ def create_initialization_options( capabilities=self.get_capabilities( notification_options or NotificationOptions(), experimental_capabilities or {}, + extensions if extensions is not None else self.extensions, ), instructions=self.instructions, website_url=self.website_url, @@ -541,8 +553,14 @@ def get_capabilities( self, notification_options: NotificationOptions | None = None, experimental_capabilities: dict[str, dict[str, Any]] | None = None, + extensions: dict[str, dict[str, Any]] | None = None, ) -> types.ServerCapabilities: - """Convert existing handlers to a ServerCapabilities object.""" + """Convert existing handlers to a ServerCapabilities object. + + `extensions` is the SEP-2133 extension map (identifier -> settings) + advertised under `ServerCapabilities.extensions`; it defaults to + `self.extensions`. + """ notification_options = notification_options or NotificationOptions() prompts_capability = None resources_capability = None @@ -579,6 +597,7 @@ def get_capabilities( tools=tools_capability, logging=logging_capability, experimental=experimental_capabilities, + extensions=extensions if extensions is not None else (self.extensions or None), completions=completions_capability, ) return capabilities diff --git a/src/mcp/server/mcpserver/__init__.py b/src/mcp/server/mcpserver/__init__.py index 741f16beb1..7a8da42fef 100644 --- a/src/mcp/server/mcpserver/__init__.py +++ b/src/mcp/server/mcpserver/__init__.py @@ -2,9 +2,11 @@ from mcp_types import Icon +from mcp.server.extension import Extension, MethodBinding, ResourceBinding, ToolBinding + from .context import Context from .resources import DEFAULT_RESOURCE_SECURITY, ResourceSecurity -from .server import MCPServer +from .server import MCPServer, require_client_extension from .utilities.types import Audio, Image __all__ = [ @@ -13,6 +15,11 @@ "Image", "Audio", "Icon", + "Extension", + "ToolBinding", + "ResourceBinding", + "MethodBinding", + "require_client_extension", "ResourceSecurity", "DEFAULT_RESOURCE_SECURITY", ] diff --git a/src/mcp/server/mcpserver/resources/types.py b/src/mcp/server/mcpserver/resources/types.py index a25213e7bf..e295e21e02 100644 --- a/src/mcp/server/mcpserver/resources/types.py +++ b/src/mcp/server/mcpserver/resources/types.py @@ -26,7 +26,7 @@ class TextResource(Resource): async def read(self) -> str: """Read the text content.""" - return self.text # pragma: no cover + return self.text class BinaryResource(Resource): diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 029512a780..33348c0838 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -4,7 +4,7 @@ import base64 import inspect -from collections.abc import AsyncIterator, Awaitable, Callable, Iterable +from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence from contextlib import AbstractAsyncContextManager, asynccontextmanager from typing import Any, Generic, Literal, TypeVar, overload @@ -13,10 +13,13 @@ from mcp_types import ( INTERNAL_ERROR, INVALID_PARAMS, + METHOD_NOT_FOUND, + MISSING_REQUIRED_CLIENT_CAPABILITY, Annotations, BlobResourceContents, CallToolRequestParams, CallToolResult, + ClientCapabilities, CompleteRequestParams, CompleteResult, Completion, @@ -28,6 +31,7 @@ ListResourcesResult, ListResourceTemplatesResult, ListToolsResult, + MissingRequiredClientCapabilityErrorData, PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResult, @@ -54,7 +58,14 @@ from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier from mcp.server.auth.settings import AuthSettings -from mcp.server.context import ServerRequestContext +from mcp.server.context import HandlerResult, ServerRequestContext +from mcp.server.extension import ( + Extension, + MethodBinding, + RequestHandler, + compose_tool_call_interceptor, + validate_extension_identifier, +) from mcp.server.lowlevel.helper_types import ReadResourceContents from mcp.server.lowlevel.server import LifespanResultT, Server from mcp.server.lowlevel.server import lifespan as default_lifespan @@ -148,6 +159,7 @@ def __init__( *, tools: list[Tool] | None = None, resources: list[Resource] | None = None, + extensions: Sequence[Extension] | None = None, debug: bool = False, log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO", warn_on_duplicate_resources: bool = True, @@ -215,6 +227,11 @@ def __init__( # Configure logging configure_logging(self.settings.log_level) + self._extensions: list[Extension] = [] + for extension in extensions or (): + self._apply_extension(extension) + self._install_extension_interceptor() + @property def name(self) -> str: return self._lowlevel_server.name @@ -255,6 +272,44 @@ def session_manager(self) -> StreamableHTTPSessionManager: """ return self._lowlevel_server.session_manager + def _apply_extension(self, extension: Extension) -> None: + """Apply one opt-in extension's contributions through the public surface. + + Registers its tools/resources/methods and advertises its settings under + `ServerCapabilities.extensions[extension.identifier]`. Extensions are fixed + at construction, so this is private; the `tools/call` interceptor is + composed once afterwards by `_install_extension_interceptor`. + """ + identifier = getattr(extension, "identifier", None) + validate_extension_identifier(identifier, owner=type(extension).__name__) + if any(e.identifier == identifier for e in self._extensions): + raise ValueError(f"Extension {identifier!r} is already registered") + self._extensions.append(extension) + + for tool in extension.tools(): + self.add_tool(tool.fn, meta=tool.meta, **tool.kwargs) + for resource in extension.resources(): + self.add_resource(resource.resource) + for method in extension.methods(): + if self._lowlevel_server.get_request_handler(method.method) is not None: + raise ValueError( + f"Extension {identifier!r} binds method {method.method!r}, which is already " + "registered; extension methods are additive and cannot replace another handler" + ) + handler = _version_gated(method) if method.protocol_versions is not None else method.handler + self._lowlevel_server.add_request_handler(method.method, method.params_type, handler) + + self._lowlevel_server.extensions[extension.identifier] = extension.settings() + + def _install_extension_interceptor(self) -> None: + """Compose every extension's `tools/call` interceptor into one middleware. + + Installed only when at least one extension overrides `intercept_tool_call`, + so a server with purely additive extensions adds no middleware. + """ + if any(type(e).intercept_tool_call is not Extension.intercept_tool_call for e in self._extensions): + self._lowlevel_server.middleware.append(compose_tool_call_interceptor(self._extensions)) + @overload def run(self, transport: Literal["stdio"] = ...) -> None: ... @@ -1152,3 +1207,50 @@ async def get_prompt( except Exception as e: logger.exception(f"Error getting prompt {name}") raise ValueError(str(e)) from e + + +def _version_gated(method: MethodBinding) -> RequestHandler: + """Wrap a method handler so a request at a disallowed protocol version is rejected. + + The low-level `_request_handlers` dict is keyed by method only, so per-version + scoping is enforced here rather than at the runner's boundary table. + """ + versions = method.protocol_versions + assert versions is not None + + async def gated(ctx: ServerRequestContext[Any, Any], params: Any) -> HandlerResult: + if ctx.protocol_version not in versions: + raise MCPError(code=METHOD_NOT_FOUND, message="Method not found", data=method.method) + return await method.handler(ctx, params) + + return gated + + +def require_client_extension(ctx: ServerRequestContext[Any, Any], identifier: str) -> None: + """Assert the connected client declared support for `identifier`. + + Call this from an extension's handler or `intercept_tool_call` before + offering extension-specific behaviour. Raises `MCPError` with the + `-32021` (missing required client capability) code and a + `requiredCapabilities` payload when the client did not declare the + extension, per SEP-2133. + + Args: + ctx: The current request context. + identifier: The extension identifier the client must have declared. + + Raises: + MCPError: With code `MISSING_REQUIRED_CLIENT_CAPABILITY` if the client + did not advertise `identifier`. + """ + client_params = ctx.session.client_params + declared = client_params.capabilities.extensions if client_params else None + if not declared or identifier not in declared: + data = MissingRequiredClientCapabilityErrorData( + required_capabilities=ClientCapabilities(extensions={identifier: {}}) + ) + raise MCPError( + code=MISSING_REQUIRED_CLIENT_CAPABILITY, + message=f"Client did not declare required extension {identifier!r}", + data=data.model_dump(by_alias=True, mode="json", exclude_none=True), + ) diff --git a/tests/docs_src/test_apps.py b/tests/docs_src/test_apps.py new file mode 100644 index 0000000000..02375f97a3 --- /dev/null +++ b/tests/docs_src/test_apps.py @@ -0,0 +1,100 @@ +"""`docs/advanced/apps.md`: every claim the page makes, proved against the real SDK.""" + +from typing import Any + +import pytest +from mcp_types import TextContent, TextResourceContents + +from docs_src.apps import tutorial001, tutorial002, tutorial003 +from mcp import Client +from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_the_tool_carries_the_ui_resource_reference() -> None: + """tutorial001: `@apps.tool(resource_uri=...)` stamps `_meta.ui.resourceUri` on the tool.""" + async with Client(tutorial001.mcp) as client: + listed = await client.list_tools() + assert listed.tools[0].meta == {"ui": {"resourceUri": "ui://clock/app.html"}} + + +async def test_the_ui_resource_is_served_as_the_app_mime_type() -> None: + """tutorial001: `add_html_resource` serves the HTML at `text/html;profile=mcp-app`, + the MIME type that tells a host "this is an app, render it".""" + async with Client(tutorial001.mcp) as client: + result = await client.read_resource("ui://clock/app.html") + contents = result.contents[0] + assert isinstance(contents, TextResourceContents) + assert contents.mime_type == APP_MIME_TYPE + assert contents.text == tutorial001.CLOCK_HTML + + +async def test_one_tool_two_answers() -> None: + """tutorial001: the canonical degradation pattern: raw data for a client that + negotiated Apps, a human sentence for one that did not.""" + async with Client(tutorial001.mcp, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as ui_client: + rich = await ui_client.call_tool("get_time", {}) + async with Client(tutorial001.mcp) as text_client: + plain = await text_client.call_tool("get_time", {}) + assert rich.content == [TextContent(type="text", text="2026-06-26T12:00:00Z")] + assert plain.content == [TextContent(type="text", text="The time is 2026-06-26T12:00:00Z.")] + + +async def test_the_clock_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial001: `main()` declares Apps support with the required `mimeTypes` and + receives the rich answer the page promises.""" + await tutorial001.main() + assert "2026-06-26T12:00:00Z" in capsys.readouterr().out + + +async def test_capability_advertised_under_server_extensions() -> None: + """tutorial001: passing `extensions=[apps]` advertises `io.modelcontextprotocol/ui`.""" + async with Client(tutorial001.mcp) as client: + assert client.server_capabilities.extensions == {EXTENSION_ID: {}} + + +async def test_csp_permissions_domain_and_border_ride_the_resource_meta() -> None: + """tutorial002: the iframe lockdown fields land under `_meta.ui` on both the list + entry and the read content item, with the spec's camelCase wire keys.""" + expected: dict[str, Any] = { + "ui": { + "csp": {"connectDomains": ["https://api.example.com"]}, + "permissions": {"clipboardWrite": {}}, + "domain": "dashboard.example.com", + "prefersBorder": True, + } + } + async with Client(tutorial002.mcp) as client: + listed = await client.list_resources() + result = await client.read_resource("ui://dashboard/app.html") + assert listed.resources[0].meta == expected + contents = result.contents[0] + assert isinstance(contents, TextResourceContents) + assert contents.meta == expected + + +async def test_an_app_only_tool_is_still_listed_and_callable() -> None: + """tutorial002: `visibility=["app"]` is metadata for the host; the server lists the + tool like any other and serves its calls. Filtering is the host's job.""" + async with Client(tutorial002.mcp) as client: + listed = await client.list_tools() + result = await client.call_tool("refresh_dashboard", {}) + assert listed.tools[0].meta == {"ui": {"resourceUri": "ui://dashboard/app.html", "visibility": ["app"]}} + assert result.content == [TextContent(type="text", text="refreshed")] + + +async def test_a_file_resource_is_served_with_the_app_mime_type_filled_in() -> None: + """tutorial003: `add_resource` accepts a pre-built `FileResource` and fills in the + `text/html;profile=mcp-app` MIME type the resource didn't set explicitly.""" + async with Client(tutorial003.mcp) as client: + listed = await client.list_tools() + called = await client.call_tool("refresh_report", {}) + result = await client.read_resource("ui://report/app.html") + assert listed.tools[0].meta == {"ui": {"resourceUri": "ui://report/app.html"}} + assert called.content == [TextContent(type="text", text="report refreshed")] + contents = result.contents[0] + assert isinstance(contents, TextResourceContents) + assert contents.mime_type == APP_MIME_TYPE + assert contents.text == tutorial003.REPORT_HTML.read_text() diff --git a/tests/docs_src/test_extensions.py b/tests/docs_src/test_extensions.py new file mode 100644 index 0000000000..ebe00e5a88 --- /dev/null +++ b/tests/docs_src/test_extensions.py @@ -0,0 +1,97 @@ +"""`docs/advanced/extensions.md`: every claim the page makes, proved against the real SDK.""" + +import logging +from typing import cast + +import mcp_types as types +import pytest +from inline_snapshot import snapshot +from mcp_types import METHOD_NOT_FOUND, MISSING_REQUIRED_CLIENT_CAPABILITY, TextContent + +from docs_src.extensions import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005 +from mcp import Client, MCPError +from mcp.server.extension import Extension + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_using_an_extension_advertises_its_capability() -> None: + """tutorial001: `extensions=[Apps()]` is all it takes for the server to advertise + the extension under `capabilities.extensions`.""" + async with Client(tutorial001.mcp) as client: + assert client.server_capabilities.extensions == {"io.modelcontextprotocol/ui": {}} + + +def test_a_prefixless_identifier_fails_at_class_definition() -> None: + """tutorial002 + the page's TypeError block: the identifier is validated when the + subclass is defined, with the exact message the page shows.""" + assert tutorial002.Stamps.identifier == "com.example/stamps" + with pytest.raises(TypeError) as exc_info: + type("Stamps", (Extension,), {"identifier": "stamps"}) + assert str(exc_info.value) == snapshot( + "Stamps.identifier must be a `vendor-prefix/name` string (reverse-DNS prefix required), got 'stamps'" + ) + + +async def test_extension_settings_advertised_under_capabilities() -> None: + """tutorial003: `settings()` becomes the entry at `capabilities.extensions[identifier]`.""" + async with Client(tutorial003.mcp) as client: + assert client.server_capabilities.extensions == {"com.example/stamps": {"sealed": True}} + + +async def test_contributed_tool_is_listed_and_callable() -> None: + """tutorial003: a `ToolBinding` registers like any `add_tool` call: listed and callable.""" + async with Client(tutorial003.mcp) as client: + listed = await client.list_tools() + assert [tool.name for tool in listed.tools] == ["stamp"] + result = await client.call_tool("stamp", {"text": "hello"}) + assert result.content == [TextContent(type="text", text="[stamped] hello")] + + +async def test_the_stamps_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial003: `main()` is the literal client program on the page; both printed + lines match the page's comments.""" + await tutorial003.main() + out = capsys.readouterr().out + assert "{'com.example/stamps': {'sealed': True}}" in out + assert "[stamped] hello" in out + + +async def test_the_search_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial004: `main()` declares the extension and gets the vendor method's result.""" + await tutorial004.main() + assert "['mcp-0', 'mcp-1', 'mcp-2']" in capsys.readouterr().out + + +async def test_vendor_method_rejects_a_non_declaring_client_with_32021() -> None: + """tutorial004: `require_client_extension` answers a non-declaring client with `-32021` + and the machine-readable `requiredCapabilities` payload.""" + async with Client(tutorial004.mcp) as client: + request = tutorial004.SearchRequest(params=tutorial004.SearchParams(query="mcp")) + with pytest.raises(MCPError) as exc_info: + await client.session.send_request(cast("types.ClientRequest", request), tutorial004.SearchResult) + assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY + assert exc_info.value.error.data == {"requiredCapabilities": {"extensions": {"com.example/search": {}}}} + + +async def test_version_pinned_method_is_not_found_on_a_legacy_connection() -> None: + """tutorial004: `protocol_versions={"2026-07-28"}` makes the method METHOD_NOT_FOUND + at any other wire version; for a legacy client it doesn't exist.""" + async with Client(tutorial004.mcp, mode="legacy", extensions={tutorial004.EXTENSION_ID: {}}) as client: + request = tutorial004.SearchRequest(params=tutorial004.SearchParams(query="mcp")) + with pytest.raises(MCPError) as exc_info: + await client.session.send_request(cast("types.ClientRequest", request), tutorial004.SearchResult) + assert exc_info.value.code == METHOD_NOT_FOUND + + +async def test_interceptor_observes_the_call_and_passes_the_result_through( + caplog: pytest.LogCaptureFixture, +) -> None: + """tutorial005: the interceptor logs the tool name and returns `call_next`'s result unchanged.""" + with caplog.at_level(logging.INFO, logger=tutorial005.logger.name): + async with Client(tutorial005.mcp) as client: + result = await client.call_tool("add", {"a": 2, "b": 3}) + assert result.structured_content == {"result": 5} + messages = [record.getMessage() for record in caplog.records if record.name == tutorial005.logger.name] + assert messages == ["tool 'add' called"] diff --git a/tests/server/mcpserver/test_extension.py b/tests/server/mcpserver/test_extension.py new file mode 100644 index 0000000000..e2ec366b29 --- /dev/null +++ b/tests/server/mcpserver/test_extension.py @@ -0,0 +1,485 @@ +"""Tests for the core SEP-2133 extension API (`Extension`, `MCPServer` wiring). + +These exercise the closed set of extension contribution kinds - tools, +resources, request methods, and the single `tools/call` interceptor - through +the highest-level public surface (in-memory `Client`), plus the +`compose_tool_call_interceptor` helper directly. +""" + +from typing import Any, Literal, cast + +import mcp_types as types +import pytest +from inline_snapshot import snapshot +from mcp_types import ( + METHOD_NOT_FOUND, + MISSING_REQUIRED_CLIENT_CAPABILITY, + CallToolResult, + TextContent, +) + +from mcp.client.client import Client +from mcp.server.context import CallNext, HandlerResult, ServerRequestContext +from mcp.server.extension import ( + Extension, + MethodBinding, + ResourceBinding, + ToolBinding, + compose_tool_call_interceptor, + validate_extension_identifier, +) +from mcp.server.mcpserver import Context, MCPServer, require_client_extension +from mcp.server.mcpserver.resources import TextResource +from mcp.shared.exceptions import MCPError + +pytestmark = pytest.mark.anyio + +_TOOL_META: dict[str, Any] = {"com.example/marker": {"v": 1}} + + +class _AdditiveExt(Extension): + """Override `tools()`/`resources()` only - a purely additive extension.""" + + identifier = "com.example/additive" + + def tools(self): + def ping() -> str: + """Reply with pong.""" + return "pong" + + return [ToolBinding(fn=ping, meta=_TOOL_META)] + + def resources(self): + return [ResourceBinding(resource=TextResource(uri="ext://greeting", name="greeting", text="hello"))] + + +class _SettingsExt(Extension): + """Override `settings()` so the extension advertises a non-empty settings map.""" + + identifier = "com.example/settings" + + def settings(self) -> dict[str, Any]: + return {"feature": {"enabled": True}} + + +class _PingParams(types.RequestParams): + pass + + +class _PingResult(types.Result): + pong: bool + + +class _PingRequest(types.Request[_PingParams, Literal["com.example/ping"]]): + method: Literal["com.example/ping"] = "com.example/ping" + params: _PingParams + + +async def _pong_handler(ctx: ServerRequestContext[Any, Any], params: _PingParams) -> _PingResult: + """The shared `com.example/ping` handler (dispatched by the reachability test).""" + return _PingResult(pong=True) + + +class _MethodExt(Extension): + """Override `methods()` to serve a new vendor request verb.""" + + identifier = "com.example/method" + + def methods(self) -> list[MethodBinding]: + return [MethodBinding("com.example/ping", _PingParams, _pong_handler)] + + +class _ReplacingExt(Extension): + """Override `intercept_tool_call()` to short-circuit with a fixed result.""" + + identifier = "com.example/replacing" + + async def intercept_tool_call( + self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext + ) -> HandlerResult: + return CallToolResult(content=[TextContent(type="text", text="intercepted")]) + + +class _PassThroughExt(Extension): + """Override `intercept_tool_call()` but always delegate to `call_next` unchanged.""" + + identifier = "com.example/passthrough" + + async def intercept_tool_call( + self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext + ) -> HandlerResult: + return await call_next(ctx) + + +class _DefaultExt(Extension): + """Override nothing - relies on the base `intercept_tool_call` default (pass through).""" + + identifier = "com.example/default" + + +class _RecordingExt(Extension): + """Override `intercept_tool_call()` to record `(identifier, tool_name)` then pass through.""" + + def __init__(self, identifier: str, log: list[tuple[str, str]]) -> None: + self.identifier = identifier + self._log = log + + async def intercept_tool_call( + self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext + ) -> HandlerResult: + self._log.append((self.identifier, params.name)) + return await call_next(ctx) + + +def _echo(value: str) -> str: + """Echo the input value (shared tool body across interceptor tests).""" + return value + + +async def test_additive_extension_registers_its_tool_and_resource() -> None: + """SDK-defined: an `Extension` overriding `tools()`/`resources()` surfaces both + through `MCPServer`'s normal `list_tools`/`list_resources`, and the tool's + `_meta` round-trips equal to the exact dict the binding carried (identity can't + hold - the value is JSON-serialized over the transport).""" + server = MCPServer("test", extensions=[_AdditiveExt()]) + + async with Client(server) as client: + tools = await client.list_tools() + resources = await client.list_resources() + called = await client.call_tool("ping", {}) + + assert [t.name for t in tools.tools] == ["ping"] + assert tools.tools[0].meta == _TOOL_META + assert called == snapshot(CallToolResult(content=[TextContent(text="pong")], structured_content={"result": "pong"})) + assert resources == snapshot( + types.ListResourcesResult( + resources=[types.Resource(name="greeting", uri="ext://greeting", mime_type="text/plain")] + ) + ) + + +async def test_extension_settings_advertised_under_server_capabilities() -> None: + """SDK-defined: `settings()` rides `server/discover` and lands under + `server_capabilities.extensions[identifier]` on the modern (`auto`) path.""" + server = MCPServer("test", extensions=[_SettingsExt()]) + + async with Client(server, mode="auto") as client: + extensions = client.server_capabilities.extensions + + assert extensions == snapshot({"com.example/settings": {"feature": {"enabled": True}}}) + + +async def test_extension_settings_dropped_on_legacy_handshake() -> None: + """Pinned gap: the 2025 `ServerCapabilities` wire schema has no `extensions` + field, so a legacy `initialize` handshake drops the advertised extension even + though the modern `auto` path carries it.""" + server = MCPServer("test", extensions=[_SettingsExt()]) + + async with Client(server, mode="legacy") as client: + assert client.server_capabilities.extensions is None + + +def test_duplicate_extension_identifier_raises() -> None: + """SDK-defined: registering two extensions with the same `identifier` is a + construction error.""" + with pytest.raises(ValueError): + MCPServer("test", extensions=[_SettingsExt(), _SettingsExt()]) + + +async def test_extension_method_reachable_via_session_send_request() -> None: + """SDK-defined: an `Extension` overriding `methods()` wires a new request verb + onto the low-level server, reachable through `client.session.send_request`.""" + server = MCPServer("test", extensions=[_MethodExt()]) + + async with Client(server) as client: + request = _PingRequest(params=_PingParams()) + result = await client.session.send_request(cast("types.ClientRequest", request), _PingResult) + + assert result == snapshot(_PingResult(pong=True)) + + +async def test_pass_through_interceptor_leaves_tool_result_unchanged() -> None: + """SDK-defined: an extension whose `intercept_tool_call` delegates to + `call_next` does not alter the underlying tool's `CallToolResult`.""" + server = MCPServer("test", extensions=[_PassThroughExt()]) + server.tool(name="echo")(_echo) + + async with Client(server) as client: + result = await client.call_tool("echo", {"value": "hi"}) + + assert result == snapshot(CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"})) + + +async def test_short_circuiting_interceptor_replaces_tool_result() -> None: + """SDK-defined: an extension that returns from `intercept_tool_call` without + calling `call_next` replaces the tool's result wholesale (the tool never runs).""" + server = MCPServer("test", extensions=[_ReplacingExt()]) + server.tool(name="echo", structured_output=False)(_echo) + + async with Client(server) as client: + result = await client.call_tool("echo", {"value": "hi"}) + + assert result == snapshot(CallToolResult(content=[TextContent(text="intercepted")])) + + +def test_plain_extension_installs_no_tool_call_interceptor() -> None: + """SDK-defined: an extension that does not override `intercept_tool_call` adds no + middleware - the composed interceptor exists only when at least one extension + overrides it.""" + baseline = len(MCPServer("test")._lowlevel_server.middleware) + server = MCPServer("test", extensions=[_AdditiveExt()]) + + assert len(server._lowlevel_server.middleware) == baseline + + +def test_overriding_extension_installs_one_tool_call_interceptor() -> None: + """SDK-defined: an extension that overrides `intercept_tool_call` composes exactly + one additional `tools/call` middleware.""" + baseline = len(MCPServer("test")._lowlevel_server.middleware) + server = MCPServer("test", extensions=[_ReplacingExt()]) + + assert len(server._lowlevel_server.middleware) == baseline + 1 + + +async def test_default_interceptor_passes_through_alongside_an_overriding_one() -> None: + """SDK-defined: an extension that does not override `intercept_tool_call` runs the + base-class default (pass through) when another extension forces the composed + middleware to exist, leaving the tool result untouched.""" + server = MCPServer("test", extensions=[_DefaultExt(), _PassThroughExt()]) + server.tool(name="echo")(_echo) + + async with Client(server) as client: + result = await client.call_tool("echo", {"value": "hi"}) + + assert result == snapshot(CallToolResult(content=[TextContent(text="hi")], structured_content={"result": "hi"})) + + +async def test_interceptors_run_in_registration_order_with_threaded_params() -> None: + """SDK-defined: `compose_tool_call_interceptor` nests extensions first-outermost, so + two passing-through interceptors record in registration order, each seeing the + validated `tools/call` params (the real tool name).""" + log: list[tuple[str, str]] = [] + server = MCPServer( + "test", + extensions=[_RecordingExt("com.example/first", log), _RecordingExt("com.example/second", log)], + ) + server.tool(name="echo")(_echo) + + async with Client(server) as client: + await client.call_tool("echo", {"value": "hi"}) + + assert log == [("com.example/first", "echo"), ("com.example/second", "echo")] + + +async def test_compose_tool_call_interceptor_passes_through_non_tools_call() -> None: + """SDK-defined: the composed middleware is a no-op for any method other than + `tools/call` - it forwards to `call_next` without touching the interceptors.""" + sentinel = types.EmptyResult() + + async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: + return sentinel + + middleware = compose_tool_call_interceptor([_ReplacingExt()]) + ctx = ServerRequestContext( + session=cast("Any", None), + lifespan_context={}, + protocol_version="2026-07-28", + method="tasks/get", + params={"taskId": "t-1"}, + ) + + result = await middleware(ctx, call_next) + + assert result is sentinel + + +def test_extension_subclass_without_prefixed_identifier_is_rejected_at_definition() -> None: + """SDK-defined: SEP-2133 requires a `vendor-prefix/name` identifier, enforced when the + subclass is defined (a bare name with no prefix is a TypeError).""" + with pytest.raises(TypeError): + type("_BadExt", (Extension,), {"identifier": "noprefix"}) + + +def test_extension_without_identifier_is_rejected_at_registration() -> None: + """SDK-defined: a subclass that never sets `identifier` (neither class-level nor in + `__init__`) is rejected when the server applies it.""" + + class _NoIdExt(Extension): + pass + + with pytest.raises(TypeError): + MCPServer("test", extensions=[_NoIdExt()]) + + +class _VersionPinnedParams(types.RequestParams): + pass + + +class _VersionPinnedResult(types.Result): + ok: bool + + +class _VersionPinnedRequest(types.Request[_VersionPinnedParams, Literal["com.example/pinned"]]): + method: Literal["com.example/pinned"] = "com.example/pinned" + params: _VersionPinnedParams + + +class _VersionPinnedExt(Extension): + """A method scoped to 2026-07-28 only via `MethodBinding.protocol_versions`.""" + + identifier = "com.example/pinned" + + def methods(self): + async def handler(ctx: ServerRequestContext[Any, Any], params: _VersionPinnedParams) -> _VersionPinnedResult: + return _VersionPinnedResult(ok=True) + + return [MethodBinding("com.example/pinned", _VersionPinnedParams, handler, frozenset({"2026-07-28"}))] + + +async def test_version_pinned_method_is_served_at_an_allowed_version() -> None: + """SDK-defined: a `MethodBinding` with `protocol_versions` serves the method at a version + in the set.""" + server = MCPServer("test", extensions=[_VersionPinnedExt()]) + + async with Client(server, mode="2026-07-28") as client: + request = _VersionPinnedRequest(params=_VersionPinnedParams()) + result = await client.session.send_request(cast("types.ClientRequest", request), _VersionPinnedResult) + + assert result == snapshot(_VersionPinnedResult(ok=True)) + + +async def test_version_pinned_method_is_method_not_found_at_a_disallowed_version() -> None: + """SDK-defined: the same method at a version outside `protocol_versions` is rejected with + METHOD_NOT_FOUND, mirroring the spec's per-version boundary.""" + server = MCPServer("test", extensions=[_VersionPinnedExt()]) + + async with Client(server, mode="legacy") as client: + request = _VersionPinnedRequest(params=_VersionPinnedParams()) + with pytest.raises(MCPError) as exc_info: + await client.session.send_request(cast("types.ClientRequest", request), _VersionPinnedResult) + + assert exc_info.value.code == METHOD_NOT_FOUND + assert exc_info.value.error.data == "com.example/pinned" + + +@pytest.mark.parametrize( + "identifier", + [ + "io.modelcontextprotocol/ui", + "com.example/my_ext", + "com.x-y.z2/n.a-b_c", + "example/x", + "a/b", + "com.example/9start", + ], +) +def test_grammar_conformant_extension_identifiers_are_accepted(identifier: str) -> None: + """Spec `_meta` key grammar: dot-separated labels (letter start, letter/digit end, + hyphens interior), a slash, then a name that starts and ends alphanumeric.""" + validate_extension_identifier(identifier, owner="T") + + +@pytest.mark.parametrize( + "identifier", + [ + "noprefix", + "-foo/bar", + ".leading/x", + "a..b/x", + "foo-/x", + "9foo/x", + "foo/-bar", + "foo/bar-", + "foo/", + "/bar", + "foo/ba r", + "io.modelcontextprotocol/ui\n", + "", + None, + 42, + ], +) +def test_malformed_extension_identifiers_are_rejected(identifier: Any) -> None: + """Spec `_meta` key grammar: malformed prefixes (bad label start/end, empty labels) + and malformed names are rejected, as are non-strings.""" + with pytest.raises(TypeError): + validate_extension_identifier(identifier, owner="T") + + +@pytest.mark.parametrize("method", ["tools/list", "completion/complete"]) +def test_method_binding_rejects_spec_methods(method: str) -> None: + """SDK-defined: extension methods are additive — binding a spec-defined request method + would silently shadow (or be shadowed by) the server's own handler, so it is rejected + when the binding is constructed.""" + with pytest.raises(ValueError): + MethodBinding(method, _PingParams, _pong_handler) + + +def test_method_binding_rejects_empty_protocol_versions() -> None: + """SDK-defined: an empty `protocol_versions` set would make the method unreachable at + every version; `None` is the universal-version spelling.""" + with pytest.raises(ValueError) as exc_info: + MethodBinding("com.example/dead", _PingParams, _pong_handler, frozenset()) + assert str(exc_info.value) == snapshot( + "MethodBinding for 'com.example/dead' has an empty protocol_versions set, so it could " + "never be served; use None to admit every version" + ) + + +class _OtherMethodExt(Extension): + """A second extension binding the same verb as `_MethodExt`.""" + + identifier = "com.example/other-method" + + def methods(self) -> list[MethodBinding]: + return [MethodBinding("com.example/ping", _PingParams, _pong_handler)] + + +def test_colliding_extension_methods_are_rejected_at_registration() -> None: + """SDK-defined: two extensions binding the same method would silently last-write-win; + the collision is rejected when the second extension is applied.""" + with pytest.raises(ValueError) as exc_info: + MCPServer("test", extensions=[_MethodExt(), _OtherMethodExt()]) + assert str(exc_info.value) == snapshot( + "Extension 'com.example/other-method' binds method 'com.example/ping', which is already " + "registered; extension methods are additive and cannot replace another handler" + ) + + +_NEEDS_EXT = "com.example/needed" + + +class _RequiresExt(Extension): + """A tool that requires the client to have declared `com.example/needed`.""" + + identifier = _NEEDS_EXT + + def tools(self): + def guarded(ctx: Context) -> str: + require_client_extension(ctx.request_context, _NEEDS_EXT) + return "ok" + + return [ToolBinding(fn=guarded)] + + +async def test_require_client_extension_passes_when_client_declared_it() -> None: + """SDK-defined: `require_client_extension` is a no-op when the client advertised the id.""" + server = MCPServer("test", extensions=[_RequiresExt()]) + + async with Client(server, extensions={_NEEDS_EXT: {}}) as client: + result = await client.call_tool("guarded", {}) + + assert result == snapshot(CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"})) + + +async def test_require_client_extension_raises_minus_32021_when_client_did_not_declare_it() -> None: + """SDK-defined: `require_client_extension` raises the -32021 missing-required-capability + error when the client did not advertise the id.""" + server = MCPServer("test", extensions=[_RequiresExt()]) + + async with Client(server) as client: + with pytest.raises(MCPError) as exc_info: + await client.call_tool("guarded", {}) + + assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY + assert exc_info.value.error.data == snapshot({"requiredCapabilities": {"extensions": {_NEEDS_EXT: {}}}}) diff --git a/tests/server/test_apps.py b/tests/server/test_apps.py new file mode 100644 index 0000000000..65908309ad --- /dev/null +++ b/tests/server/test_apps.py @@ -0,0 +1,302 @@ +"""Tests for the MCP Apps extension (`io.modelcontextprotocol/ui`, SEP-2133). + +The headline property is SEP-2133 graceful degradation: a UI-bound tool returns +rich output to a client that negotiated Apps and text-only output to one that did +not. The remaining tests pin SDK-defined wiring (the `_meta.ui.resourceUri` stamp, +the `ui://` resource MIME type, capability advertisement, and `ui://`-scheme +validation). +""" + +from typing import Any + +import mcp_types as types +import pytest +from inline_snapshot import snapshot +from mcp_types import CallToolResult, ReadResourceResult, TextContent, TextResourceContents + +from mcp.client.client import Client +from mcp.server import Server, ServerRequestContext +from mcp.server.apps import ( + APP_MIME_TYPE, + EXTENSION_ID, + Apps, + ResourceCsp, + ResourcePermissions, + client_supports_apps, +) +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.context import Context +from mcp.server.mcpserver.resources import TextResource + +pytestmark = pytest.mark.anyio + + +def _clock_server() -> MCPServer: + apps = Apps() + + @apps.tool(resource_uri="ui://clock/app.html", title="Get Time", description="Return the current time.") + def get_time(ctx: Context) -> str: + if not client_supports_apps(ctx): + return "The time is 2026-06-26T00:00:00Z." + return "2026-06-26T00:00:00Z" + + apps.add_html_resource("ui://clock/app.html", "Clock", title="Clock") + return MCPServer("clock", extensions=[apps]) + + +async def test_apps_tool_stamps_ui_resource_uri_on_tool_meta() -> None: + """SDK-defined: `@apps.tool(resource_uri=...)` stamps `_meta.ui.resourceUri` on the + advertised tool, observed end-to-end through `list_tools`.""" + async with Client(_clock_server()) as client: + result = await client.list_tools() + assert [(t.name, t.meta) for t in result.tools] == snapshot( + [("get_time", {"ui": {"resourceUri": "ui://clock/app.html"}})] + ) + + +async def test_add_html_resource_serves_ui_resource_at_app_mime_type() -> None: + """SDK-defined: `add_html_resource` registers the `ui://` resource served as + `text/html;profile=mcp-app`, observed through `read_resource`.""" + async with Client(_clock_server()) as client: + result = await client.read_resource("ui://clock/app.html") + assert result == snapshot( + ReadResourceResult( + contents=[ + TextResourceContents( + uri="ui://clock/app.html", + mime_type="text/html;profile=mcp-app", + text="Clock", + ) + ] + ) + ) + assert isinstance(result.contents[0], TextResourceContents) + assert result.contents[0].mime_type == APP_MIME_TYPE + + +async def test_auto_mode_carries_apps_extension_under_server_capabilities() -> None: + """SDK-defined: the Apps extension rides `server/discover`, so a `mode='auto'` client + sees `EXTENSION_ID` under `server_capabilities.extensions`.""" + async with Client(_clock_server(), mode="auto") as client: + assert client.server_capabilities.extensions == snapshot({"io.modelcontextprotocol/ui": {}}) + + +async def test_legacy_handshake_drops_apps_extension_from_capabilities() -> None: + """Pinned gap: the 2025 `ServerCapabilities` wire schema has no `extensions` field, + so a `mode='legacy'` handshake cannot carry the Apps capability -- only `mode='auto'` + (server/discover) does. This pins the divergence rather than fixing it.""" + async with Client(_clock_server(), mode="legacy") as client: + assert client.server_capabilities.extensions is None + + +async def test_apps_tool_returns_rich_output_when_client_negotiated_apps() -> None: + """SEP-2133 graceful degradation: a client that advertised `EXTENSION_ID` gets the + rich (UI) path, while one that did not gets the text-only fallback. The same tool, + branching on `client_supports_apps(ctx)`, drives both halves.""" + server = _clock_server() + + async with Client(server, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as supports: + rich = await supports.call_tool("get_time", {}) + async with Client(server) as plain: + fallback = await plain.call_tool("get_time", {}) + + assert rich.content == snapshot([TextContent(text="2026-06-26T00:00:00Z")]) + assert fallback.content == snapshot([TextContent(text="The time is 2026-06-26T00:00:00Z.")]) + + +async def _observed_client_supports_apps(extensions: dict[str, dict[str, Any]] | None) -> bool: + """Run one probe `tools/call` and report what `client_supports_apps` saw server-side. + + Exercises the lowlevel `ServerRequestContext` form, which reads the client's + advertised extensions off `session.client_params`. + """ + observed: list[bool] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})]) + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + assert params.name == "probe" + observed.append(client_supports_apps(ctx)) + return CallToolResult(content=[TextContent(text="ok")]) + + server = Server("probe", on_list_tools=list_tools, on_call_tool=call_tool) + async with Client(server, extensions=extensions) as client: + await client.call_tool("probe", {}) + return observed[0] + + +@pytest.mark.parametrize( + ("extensions", "expected"), + [ + pytest.param({EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}, True, id="html-mime-listed"), + pytest.param({EXTENSION_ID: {"mimeTypes": (APP_MIME_TYPE,)}}, True, id="in-process-tuple-mime-types"), + pytest.param(None, False, id="extension-not-declared"), + pytest.param({EXTENSION_ID: {"mimeTypes": ["application/x-other"]}}, False, id="html-mime-not-offered"), + pytest.param({EXTENSION_ID: {}}, False, id="mime-types-key-missing"), + ], +) +async def test_client_supports_apps_from_lowlevel_request_context( + extensions: dict[str, dict[str, Any]] | None, expected: bool +) -> None: + """ext-apps: `client_supports_apps` is `True` only when the client declared the ui + extension AND listed `text/html;profile=mcp-app` in its `mimeTypes` settings — a + required field, so its absence means unsupported (the reference SDK's check is + `uiCap?.mimeTypes?.includes(...)`).""" + assert await _observed_client_supports_apps(extensions) is expected + + +def test_apps_tool_rejects_non_ui_resource_uri() -> None: + """SDK-defined: `@apps.tool` accepts only `ui://` URIs; any other scheme is a + programmer error raised at decoration time.""" + apps = Apps() + with pytest.raises(ValueError): + apps.tool(resource_uri="https://example.com/app.html") + + +def test_add_html_resource_rejects_non_ui_resource_uri() -> None: + """SDK-defined: `add_html_resource` accepts only `ui://` URIs; any other scheme is + a programmer error raised at registration time.""" + apps = Apps() + with pytest.raises(ValueError): + apps.add_html_resource("https://example.com/app.html", "x") + + +def _widget() -> str: + """A UI-bound tool body (shared so its one covered call serves both meta tests).""" + return "x" + + +async def test_apps_tool_stamps_visibility_when_given() -> None: + """SDK-defined: `@apps.tool(visibility=...)` is stamped into `_meta.ui.visibility`.""" + apps = Apps() + apps.tool(resource_uri="ui://v/app.html", visibility=["app"])(_widget) + apps.add_html_resource("ui://v/app.html", "v") + + async with Client(MCPServer("v", extensions=[apps])) as client: + result = await client.list_tools() + called = await client.call_tool("_widget", {}) + + assert result.tools[0].meta == snapshot({"ui": {"resourceUri": "ui://v/app.html", "visibility": ["app"]}}) + assert called.content == snapshot([TextContent(text="x")]) + + +async def test_apps_tool_merges_extra_meta_alongside_ui() -> None: + """SDK-defined: `@apps.tool(meta=...)` merges extra `_meta` keys with the `ui` entry + (previously a `meta=` argument raised a duplicate-keyword TypeError).""" + apps = Apps() + apps.tool(resource_uri="ui://m/app.html", meta={"com.example/k": 1})(_widget) + apps.add_html_resource("ui://m/app.html", "m") + + async with Client(MCPServer("m", extensions=[apps])) as client: + result = await client.list_tools() + + assert result.tools[0].meta == snapshot({"com.example/k": 1, "ui": {"resourceUri": "ui://m/app.html"}}) + + +async def test_add_html_resource_stamps_csp_and_permissions_on_resource_meta() -> None: + """SDK-defined: `csp`/`permissions` populate the resource's `_meta.ui` per ext-apps.""" + apps = Apps() + apps.add_html_resource( + "ui://r/app.html", + "r", + csp=ResourceCsp(connect_domains=["https://api.example.com"]), + permissions=ResourcePermissions(camera={}), + domain="r.example.com", + prefers_border=True, + ) + + async with Client(MCPServer("r", extensions=[apps])) as client: + listed = await client.list_resources() + result = await client.read_resource("ui://r/app.html") + + expected_ui_meta = snapshot( + { + "ui": { + "csp": {"connectDomains": ["https://api.example.com"]}, + "permissions": {"camera": {}}, + "domain": "r.example.com", + "prefersBorder": True, + } + } + ) + # Hosts read `_meta.ui` from the read content item, with the list entry as + # fallback — the SDK stamps the same value in both places. + assert isinstance(result.contents[0], TextResourceContents) + assert result.contents[0].meta == expected_ui_meta + assert listed.resources[0].meta == expected_ui_meta + + +def test_apps_tool_with_unregistered_resource_uri_is_rejected_at_construction() -> None: + """SDK-defined: a tool whose `resource_uri` has no matching registered resource would + advertise a `_meta.ui.resourceUri` that 404s on `resources/read`; the misconfiguration + is rejected when the server consumes the extension.""" + apps = Apps() + apps.tool(resource_uri="ui://missing/app.html")(_widget) + + with pytest.raises(ValueError) as exc_info: + MCPServer("broken", extensions=[apps]) + assert str(exc_info.value) == snapshot( + "Apps tool '_widget' binds resource_uri 'ui://missing/app.html', but no such resource " + "is registered; add it with add_html_resource() or add_resource()" + ) + + +async def test_add_resource_registers_a_prebuilt_ui_resource() -> None: + """SDK-defined: `add_resource` is the escape hatch for pre-built `ui://` resources + that `add_html_resource` cannot express; it satisfies a tool's `resource_uri` binding.""" + apps = Apps() + apps.tool(resource_uri="ui://prebuilt/app.html")(_widget) + apps.add_resource( + TextResource(uri="ui://prebuilt/app.html", name="prebuilt", mime_type=APP_MIME_TYPE, text="p") + ) + + async with Client(MCPServer("p", extensions=[apps])) as client: + result = await client.read_resource("ui://prebuilt/app.html") + + assert isinstance(result.contents[0], TextResourceContents) + assert result.contents[0].text == "p" + + +def test_add_resource_rejects_non_ui_resource_uri() -> None: + """SDK-defined: `add_resource` accepts only `ui://` URIs, like the other registrars.""" + apps = Apps() + with pytest.raises(ValueError): + apps.add_resource(TextResource(uri="https://example.com/app.html", name="x", text="x")) + + +def test_apps_tool_rejects_a_ui_meta_key() -> None: + """SDK-defined: the decorator owns `_meta['ui']` — a caller-supplied `'ui'` entry would be + silently clobbered, so it is rejected at decoration time (use `resource_uri=`/`visibility=`).""" + apps = Apps() + with pytest.raises(ValueError) as exc_info: + apps.tool(resource_uri="ui://c/app.html", meta={"ui": {"resourceUri": "ui://other.html"}}) + assert str(exc_info.value) == snapshot( + "Apps.tool() owns _meta['ui']; pass resource_uri=/visibility= instead of a 'ui' meta key" + ) + + +async def test_add_resource_defaults_the_mime_type_to_the_app_mime() -> None: + """ext-apps: hosts only render `ui://` resources served as `text/html;profile=mcp-app`, + so a resource registered without an explicit `mime_type` gets it by default.""" + apps = Apps() + apps.add_resource(TextResource(uri="ui://d/app.html", name="d", text="d")) + + async with Client(MCPServer("d", extensions=[apps])) as client: + result = await client.read_resource("ui://d/app.html") + + assert isinstance(result.contents[0], TextResourceContents) + assert result.contents[0].mime_type == APP_MIME_TYPE + + +def test_add_resource_rejects_an_explicit_non_app_mime_type() -> None: + """ext-apps: an explicit `mime_type` other than `text/html;profile=mcp-app` would make + the resource unrenderable; the mismatch is rejected at registration.""" + apps = Apps() + with pytest.raises(ValueError) as exc_info: + apps.add_resource(TextResource(uri="ui://e/app.html", name="e", mime_type="text/html", text="x")) + assert str(exc_info.value) == snapshot( + "MCP Apps resources are served as 'text/html;profile=mcp-app', got 'text/html'" + ) diff --git a/tests/server/test_extensions_capability.py b/tests/server/test_extensions_capability.py new file mode 100644 index 0000000000..90f24be2bc --- /dev/null +++ b/tests/server/test_extensions_capability.py @@ -0,0 +1,134 @@ +"""Tests for the SEP-2133 extensions capability negotiation plumbing. + +The extension-map negotiation is independent of any concrete extension (Apps, +Tasks): the lowlevel `Server` advertises `self.extensions` under +`ServerCapabilities.extensions`, a client mirrors its own support under +`ClientCapabilities.extensions`, and `Connection.check_capability` resolves the +server-side query. These tests pin that plumbing end-to-end and at the unit +level. Per-extension contribution wiring lives in `test_extension.py`; this file +covers only the capability advertisement and negotiation. +""" + +import mcp_types as types +import pytest +from inline_snapshot import snapshot + +from mcp.client.client import Client +from mcp.server import Server, ServerRequestContext +from mcp.server.extension import Extension +from mcp.server.mcpserver import MCPServer + +pytestmark = pytest.mark.anyio + +_EXTENSION_ID = "com.example/x" +_OTHER_EXTENSION_ID = "com.example/other" + + +class _Extension(Extension): + identifier = _EXTENSION_ID + + def settings(self) -> dict[str, object]: + return {"k": 1} + + +def test_get_capabilities_omits_extensions_when_none_registered() -> None: + """SDK-defined: a lowlevel `Server` with an empty `extensions` map advertises + `ServerCapabilities.extensions` as `None`, not an empty map.""" + server = Server("bare") + assert server.get_capabilities().extensions is None + + +def test_get_capabilities_advertises_populated_self_extensions() -> None: + """SDK-defined: `get_capabilities` reads `self.extensions` (the map higher + layers populate) and advertises it under `ServerCapabilities.extensions`.""" + server = Server("with-ext") + settings = {"k": 1} + server.extensions = {_EXTENSION_ID: settings} + assert server.get_capabilities().extensions == {_EXTENSION_ID: settings} + + +async def test_modern_connection_carries_the_advertised_extensions_map() -> None: + """SDK-defined: over a modern (`server/discover`) connection the client reads + the server's advertised extension map from `server_capabilities`.""" + server = MCPServer("host", extensions=[_Extension()]) + async with Client(server, mode="auto") as client: + assert client.server_capabilities.extensions == snapshot({"com.example/x": {"k": 1}}) + + +async def test_legacy_handshake_drops_the_extensions_map() -> None: + """Pinned gap: the handshake-era `initialize` result is serialized against the + 2025 wire schema, which has no `extensions` field, so a legacy handshake cannot + carry it; the client sees `None` even though the server advertised one.""" + server = MCPServer("host", extensions=[_Extension()]) + async with Client(server, mode="legacy") as client: + assert client.server_capabilities.extensions is None + + +async def test_server_accepts_capability_for_client_advertised_extension() -> None: + """SDK-defined: a client advertising `extensions={id: ...}` makes the + server-side `check_client_capability` return True when queried for that id. + Observed inside a tool handler.""" + queried = types.ClientCapabilities(extensions={_EXTENSION_ID: {}}) + supported: list[bool] = [] + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "probe" + supported.append(ctx.session.check_client_capability(queried)) + return types.CallToolResult(content=[]) + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})]) + + server = Server("checker", on_call_tool=call_tool, on_list_tools=list_tools) + async with Client(server, extensions={_EXTENSION_ID: {"mimeTypes": ["text/html"]}}) as client: + await client.call_tool("probe", {}) + + assert supported == [True] + + +async def test_server_rejects_capability_for_undeclared_extension() -> None: + """SDK-defined: when the client advertises one extension, a server query for a + *different* identifier returns False - presence, not value, is the check.""" + queried = types.ClientCapabilities(extensions={_OTHER_EXTENSION_ID: {}}) + supported: list[bool] = [] + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "probe" + supported.append(ctx.session.check_client_capability(queried)) + return types.CallToolResult(content=[]) + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})]) + + server = Server("checker", on_call_tool=call_tool, on_list_tools=list_tools) + async with Client(server, extensions={_EXTENSION_ID: {"mimeTypes": ["text/html"]}}) as client: + await client.call_tool("probe", {}) + + assert supported == [False] + + +async def test_server_rejects_capability_when_client_advertises_no_extensions() -> None: + """SDK-defined: a client that declares no extensions makes any server + `check_client_capability` query for an extension return False.""" + queried = types.ClientCapabilities(extensions={_EXTENSION_ID: {}}) + supported: list[bool] = [] + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + assert params.name == "probe" + supported.append(ctx.session.check_client_capability(queried)) + return types.CallToolResult(content=[]) + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})]) + + server = Server("checker", on_call_tool=call_tool, on_list_tools=list_tools) + async with Client(server) as client: + await client.call_tool("probe", {}) + + assert supported == [False] From f664db89522c3bda7c6a2b997f1a99ecc7e2f71c Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Mon, 29 Jun 2026 12:51:46 +0200 Subject: [PATCH 023/100] Add resolver dependency injection for MCPServer tools (#2969) Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com> --- docs/migration.md | 14 + docs/tutorial/context.md | 3 +- docs/tutorial/dependencies.md | 127 ++++ docs/tutorial/elicitation.md | 18 + docs_src/dependencies/__init__.py | 0 docs_src/dependencies/tutorial001.py | 27 + docs_src/dependencies/tutorial002.py | 35 ++ docs_src/dependencies/tutorial003.py | 46 ++ docs_src/elicitation/tutorial004.py | 47 ++ examples/stories/README.md | 1 + examples/stories/legacy_elicitation/README.md | 3 +- examples/stories/manifest.toml | 6 + examples/stories/mrtr/README.md | 3 +- examples/stories/refund_desk/README.md | 67 ++ examples/stories/refund_desk/__init__.py | 0 examples/stories/refund_desk/client.py | 103 ++++ examples/stories/refund_desk/server.py | 125 ++++ mkdocs.yml | 1 + src/mcp/server/elicitation.py | 7 +- src/mcp/server/mcpserver/__init__.py | 14 + src/mcp/server/mcpserver/context.py | 14 +- src/mcp/server/mcpserver/resolve.py | 324 ++++++++++ src/mcp/server/mcpserver/tools/base.py | 46 +- .../mcpserver/utilities/func_metadata.py | 25 +- tests/docs_src/test_dependencies.py | 129 ++++ tests/docs_src/test_elicitation.py | 53 +- tests/server/mcpserver/test_func_metadata.py | 22 + tests/server/mcpserver/test_resolve.py | 571 ++++++++++++++++++ tests/server/mcpserver/test_server.py | 28 + 29 files changed, 1844 insertions(+), 15 deletions(-) create mode 100644 docs/tutorial/dependencies.md create mode 100644 docs_src/dependencies/__init__.py create mode 100644 docs_src/dependencies/tutorial001.py create mode 100644 docs_src/dependencies/tutorial002.py create mode 100644 docs_src/dependencies/tutorial003.py create mode 100644 docs_src/elicitation/tutorial004.py create mode 100644 examples/stories/refund_desk/README.md create mode 100644 examples/stories/refund_desk/__init__.py create mode 100644 examples/stories/refund_desk/client.py create mode 100644 examples/stories/refund_desk/server.py create mode 100644 src/mcp/server/mcpserver/resolve.py create mode 100644 tests/docs_src/test_dependencies.py create mode 100644 tests/server/mcpserver/test_resolve.py diff --git a/docs/migration.md b/docs/migration.md index d94db1f60b..8c1378d118 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1624,6 +1624,20 @@ app = server.streamable_http_app( The lowlevel `Server` also now exposes a `session_manager` property to access the `StreamableHTTPSessionManager` after calling `streamable_http_app()`. +### `ElicitationResult` is now a subscriptable generic alias + +`ElicitationResult` is now a `TypeAliasType` instead of a plain union, so `ElicitationResult[Confirm]` works as an annotation (resolver dependency injection consumes it that way - see [Dependencies](tutorial/dependencies.md)). The members are unchanged: `AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation`. + +The one behavioral change: a runtime `isinstance(result, ElicitationResult)` now raises `TypeError`. Check against the member classes directly instead: + +```python +result = await ctx.elicit("Proceed?", Confirm) +if isinstance(result, AcceptedElicitation): + ... # result.data is a Confirm +``` + +Narrowing on `result.action` (`"accept"` / `"decline"` / `"cancel"`) is unaffected. + ## Need Help? If you encounter issues during migration: diff --git a/docs/tutorial/context.md b/docs/tutorial/context.md index 3a15e8fc82..17af592fb5 100644 --- a/docs/tutorial/context.md +++ b/docs/tutorial/context.md @@ -63,6 +63,7 @@ The injected object is small. Besides `request_id`: * `await ctx.report_progress(progress, total, message)`: stream progress back to the caller during a long call. The whole story is in **Progress**. * `await ctx.elicit(message, schema)` and `await ctx.elicit_url(...)`: pause the tool and ask the user a question. That's **Elicitation**. * `ctx.session`: the server's side of the conversation with this client. Notifications you send to the client live here; the last section uses it. +* `ctx.headers`: the request headers the transport carried, or `None` on stdio. Read a custom header with `(ctx.headers or {}).get("x-...")`. Headers are client-supplied input - fine for a locale or a feature flag, never an identity. * `ctx.request_context`: the raw per-request record. The field you'll reach for is `lifespan_context`, the object your startup code yielded (see **Lifespan**). Logging is deliberately not on that list. A server logs with Python's `logging` module, like any other Python program. **Logging** is the short chapter on why. @@ -123,4 +124,4 @@ The siblings are `send_resource_list_changed()`, `send_prompt_list_changed()`, a * `ctx.session` is the channel back to the client: `send_tool_list_changed()` and its siblings tell it to re-fetch a list you changed. * Progress reporting and elicitation also start at `Context`; each has its own chapter. -Next: what happens when your tool fails, and how to choose who finds out, in **Handling errors**. +Next: parameters the model never sees, filled by your own functions, in **Dependencies**. diff --git a/docs/tutorial/dependencies.md b/docs/tutorial/dependencies.md new file mode 100644 index 0000000000..0631ccd8f7 --- /dev/null +++ b/docs/tutorial/dependencies.md @@ -0,0 +1,127 @@ +# Dependencies + +A tool's arguments come from the model. Some values never should: a price looked up from your records, a confirmation only a person can give, anything the model could get wrong by inventing it. + +**Dependencies** are parameters filled by your own functions. You annotate the parameter, name the function, and the SDK calls it before your tool runs. + +## Declare one + +Wrap the parameter's type in `Annotated[...]` and add `Resolve(fn)`: + +```python title="server.py" hl_lines="18-19 23" +--8<-- "docs_src/dependencies/tutorial001.py" +``` + +* `check_stock` is a **resolver**: a plain function the SDK runs before `reserve_book`, whose return value becomes the `stock` argument. +* Its `title` parameter is the tool's own `title` argument, matched **by name**. The resolver sees exactly the validated value the tool body will see. +* The tool body starts from a `Stock` that already exists. No lookup code in the tool, no "what if it's missing" preamble. + +!!! info + If you've used FastAPI, this is `Depends`. Same move, same reason: the function declares what + it needs, the framework supplies it, and the wiring lives in the type annotation. + +### Invisible to the model + +Here is the input schema `tools/list` reports for `reserve_book`: + +```json +{ + "type": "object", + "properties": { + "title": {"title": "Title", "type": "string"} + }, + "required": ["title"], + "title": "reserve_bookArguments" +} +``` + +One property. Like the `Context` in **The Context**, a resolved parameter is a contract between you and the SDK: `stock` is not in the schema, the model is never told about it, and a client that sends a `stock` value anyway is ignored. The resolver's value is the only one your tool can receive. + +That last part is the point. A parameter the model cannot supply is a parameter the model cannot get wrong. + +### Try it + +Run the server with the MCP Inspector: + +```console +uv run mcp dev server.py +``` + +The form for `reserve_book` has a single `title` field. `stock` is nowhere on it. Call it with `Dune`: + +```text +Reserved 'Dune' (6 copies left). +``` + +The tool body never looked anything up: `check_stock` ran first, and the `Stock` it returned arrived as an argument. Try `Neuromancer` and the same resolver hands the tool a zero. + +!!! tip + You could just call `check_stock(title)` in the tool body. Declare it as a dependency when the + value deserves more than a helper call: every tool that needs stock declares the same parameter, + and the SDK runs the resolver at most once per call, no matter how many declare it. The next + sections add the rest: resolvers that depend on each other, and resolvers that ask the user. + +## Dependencies of dependencies + +A resolver can declare its own dependencies, with the same annotation: + +```python title="server.py" hl_lines="22 29-30" +--8<-- "docs_src/dependencies/tutorial002.py" +``` + +* `estimate_delivery` depends on `check_stock`. The SDK runs the graph in order: stock first, then the estimate, then the tool. +* Both `stock` and `delivery` ultimately need `check_stock`, but it runs **once per call**. One inventory lookup, two consumers. +* There is nothing to register. The graph *is* the annotations. + +!!! check + Don't take once-per-call on faith. Put a `print` in `check_stock` and call `order_book` from the + Inspector: one line per call. Two consumers, one lookup. + +The SDK analyses the graph when the tool is registered, not when it is called. A parameter it can't classify - not a `Context`, not a `Resolve(...)`, not a tool argument's name - and a cycle of resolvers both raise `InvalidSignature` at startup. Your server fails before a client ever connects, with the offending parameter or resolver named in the error. + +A resolver's parameters resolve exactly like a tool's: another `Resolve(...)`, the tool's own arguments by name, or the `Context` - `ctx.headers`, the lifespan object, all of it. + +!!! warning + On HTTP transports the `Context` includes `ctx.headers`. Headers are **client-supplied input**, + like any tool argument: fine for a locale or a feature flag, never an identity. Who the caller + is comes from your authorization layer (**Authorization**), not from a header anyone can set. + +!!! tip + *Once per call* means exactly that: the next `tools/call` runs `check_stock` again. A resource + that should outlive a request - a database pool, an HTTP client - belongs in **Lifespan**, and + a resolver can reach it through `ctx.request_context.lifespan_context`. + +## Ask when you must + +A resolver doesn't have to know the answer. It can return `Elicit(message, Model)` and the SDK asks the user - the **Elicitation** machinery, run for you: + +```python title="server.py" hl_lines="26-32 39" +--8<-- "docs_src/dependencies/tutorial003.py" +``` + +* In stock: `confirm_backorder` returns a `Backorder` directly. **No question, no round-trip.** The user is only interrupted when their answer matters. +* Out of stock: the SDK sends the elicitation, validates the answer against `Backorder`, and injects it. Your resolver never touches the protocol. +* The tool reads `backorder.confirm` like any other argument. Answering **no** is still an answer: the elicitation is accepted with `confirm=False`, the tool runs, and no order is placed. Asking became a precondition, not plumbing in the tool body. + +And if the user won't answer at all - declines the question, or cancels it? + +!!! check + Run `order_book` for `Neuromancer` and decline the question. With the annotation written as + `Annotated[Backorder, Resolve(...)]` the tool body never runs; the call fails with an error + result the model can read: + + ```text + Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline + ``` + +That's the right default for a precondition: no answer, no order. When declining is an outcome your tool wants to handle - skip the backorder but still suggest another title - annotate `ElicitationResult[Backorder]` instead and the tool receives the full accept/decline/cancel outcome to branch on. **Elicitation** shows that form, and everything else about asking: the schema rules, the three answers, the client's side of the conversation. + +## Recap + +* `Annotated[T, Resolve(fn)]` on a tool parameter: the SDK runs `fn` and injects its return value. +* A resolved parameter is invisible to the model and cannot be supplied by a client. Values the model must not invent - prices, identities, permissions - belong here. +* A resolver's parameters are resolved the same way: the `Context`, another `Resolve(...)`, or a tool argument by name. The graph runs each resolver at most once per call. +* Bad graphs fail at registration with `InvalidSignature`, not mid-call. +* Return `Elicit(message, Model)` to ask the user, only when you have to. Unwrapped annotations abort on decline; `ElicitationResult[T]` lets the tool branch. + +Next: what happens when your tool fails, and how to choose who finds out, in **Handling errors**. diff --git a/docs/tutorial/elicitation.md b/docs/tutorial/elicitation.md index df7ae477f2..8a7b4c335f 100644 --- a/docs/tutorial/elicitation.md +++ b/docs/tutorial/elicitation.md @@ -79,6 +79,24 @@ A refusal is not an error. The tool decides what declining means (here, no booki `"maybe"` for a `bool` doesn't corrupt your booking: the call fails with the `ValidationError`, your `if` never runs. +## Ask before the tool runs + +The booking tool above weaves the question into its own body. When the question is really a *precondition* - confirm before deleting, authenticate before acting - you can lift it out of the tool into a **resolver** and let the framework ask for you. + +A parameter annotated `Annotated[T, Resolve(fn)]` is filled by running `fn` before the tool body. The resolver returns the value directly when it already knows it, or returns `Elicit(...)` to have the framework ask: + +```python title="server.py" hl_lines="24-30 35-36" +--8<-- "docs_src/elicitation/tutorial004.py" +``` + +* `confirm_delete` reads the tool's own `path` argument by name, lists the folder, and **only elicits when it must** - an empty folder resolves to `Confirm(ok=True)` with no round-trip to the client. +* `delete_folder` annotates `ElicitationResult[Confirm]`, so the framework injects the whole outcome and the tool `match`es every case: accept-and-confirm, accept-but-keep (`ok=False`), decline, cancel. +* The `confirm` parameter never appears in the tool's input schema - the client supplies `path`, the resolver supplies `confirm`. + +Annotate the unwrapped model (`Annotated[Confirm, Resolve(confirm_delete)]`) instead when the tool doesn't need to branch: it receives the model on accept and the call aborts with an error on decline or cancel. + +Asking is only one thing a resolver can do. The general mechanism - dependencies that compute without asking, dependencies of dependencies, what the model can and cannot supply - is the **Dependencies** chapter. + ## Send the user to a URL Some things must not go through the model or the client: credentials, card numbers, OAuth consent. For those you don't ask for data; you ask the user to go somewhere: diff --git a/docs_src/dependencies/__init__.py b/docs_src/dependencies/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/dependencies/tutorial001.py b/docs_src/dependencies/tutorial001.py new file mode 100644 index 0000000000..182b54414e --- /dev/null +++ b/docs_src/dependencies/tutorial001.py @@ -0,0 +1,27 @@ +from typing import Annotated + +from pydantic import BaseModel + +from mcp.server import MCPServer +from mcp.server.mcpserver import Resolve + +mcp = MCPServer("Bookshop") + +INVENTORY = {"Dune": 7, "Neuromancer": 0} + + +class Stock(BaseModel): + title: str + copies: int + + +async def check_stock(title: str) -> Stock: + return Stock(title=title, copies=INVENTORY.get(title, 0)) + + +@mcp.tool() +async def reserve_book(title: str, stock: Annotated[Stock, Resolve(check_stock)]) -> str: + """Reserve a copy of a book.""" + if stock.copies == 0: + return f"{title!r} is out of stock." + return f"Reserved {title!r} ({stock.copies - 1} copies left)." diff --git a/docs_src/dependencies/tutorial002.py b/docs_src/dependencies/tutorial002.py new file mode 100644 index 0000000000..3f24e2ceb5 --- /dev/null +++ b/docs_src/dependencies/tutorial002.py @@ -0,0 +1,35 @@ +from typing import Annotated + +from pydantic import BaseModel + +from mcp.server import MCPServer +from mcp.server.mcpserver import Resolve + +mcp = MCPServer("Bookshop") + +INVENTORY = {"Dune": 7, "Neuromancer": 0} + + +class Stock(BaseModel): + title: str + copies: int + + +async def check_stock(title: str) -> Stock: + return Stock(title=title, copies=INVENTORY.get(title, 0)) + + +async def estimate_delivery(stock: Annotated[Stock, Resolve(check_stock)]) -> str: + return "tomorrow" if stock.copies > 0 else "in 2-3 weeks" + + +@mcp.tool() +async def order_book( + title: str, + stock: Annotated[Stock, Resolve(check_stock)], + delivery: Annotated[str, Resolve(estimate_delivery)], +) -> str: + """Order a book from the shop.""" + if stock.copies == 0: + return f"{title!r} is on backorder; it would arrive {delivery}." + return f"Ordered {title!r}; it arrives {delivery}." diff --git a/docs_src/dependencies/tutorial003.py b/docs_src/dependencies/tutorial003.py new file mode 100644 index 0000000000..51252668ec --- /dev/null +++ b/docs_src/dependencies/tutorial003.py @@ -0,0 +1,46 @@ +from typing import Annotated + +from pydantic import BaseModel, Field + +from mcp.server import MCPServer +from mcp.server.mcpserver import Elicit, Resolve + +mcp = MCPServer("Bookshop") + +INVENTORY = {"Dune": 7, "Neuromancer": 0} + + +class Stock(BaseModel): + title: str + copies: int + + +class Backorder(BaseModel): + confirm: bool = Field(description="Order anyway and wait?") + + +async def check_stock(title: str) -> Stock: + return Stock(title=title, copies=INVENTORY.get(title, 0)) + + +async def confirm_backorder( + title: str, + stock: Annotated[Stock, Resolve(check_stock)], +) -> Backorder | Elicit[Backorder]: + if stock.copies > 0: + return Backorder(confirm=True) # in stock: nothing to ask + return Elicit(f"{title!r} is out of stock (2-3 weeks). Order anyway?", Backorder) + + +@mcp.tool() +async def order_book( + title: str, + stock: Annotated[Stock, Resolve(check_stock)], + backorder: Annotated[Backorder, Resolve(confirm_backorder)], +) -> str: + """Order a book from the shop.""" + if not backorder.confirm: + return "No order placed." + if stock.copies == 0: + return f"Backordered {title!r}; it ships in 2-3 weeks." + return f"Ordered {title!r}." diff --git a/docs_src/elicitation/tutorial004.py b/docs_src/elicitation/tutorial004.py new file mode 100644 index 0000000000..1edec06cf4 --- /dev/null +++ b/docs_src/elicitation/tutorial004.py @@ -0,0 +1,47 @@ +from typing import Annotated + +from pydantic import BaseModel + +from mcp.server import MCPServer +from mcp.server.mcpserver import ( + AcceptedElicitation, + CancelledElicitation, + DeclinedElicitation, + Elicit, + ElicitationResult, + Resolve, +) + +mcp = MCPServer("Files") + +_FOLDERS: dict[str, list[str]] = {"/tmp/empty": [], "/tmp/project": ["main.py", "README.md"]} + + +class Confirm(BaseModel): + ok: bool + + +async def confirm_delete(path: str) -> Confirm | Elicit[Confirm]: + """Resolver: ask for confirmation only when the folder is not empty.""" + file_count = len(_FOLDERS.get(path, [])) + if file_count == 0: + return Confirm(ok=True) # nothing to confirm, no round-trip to the client + return Elicit(f"{path} has {file_count} file(s). Delete anyway?", Confirm) + + +@mcp.tool() +async def delete_folder( + path: str, + confirm: Annotated[ElicitationResult[Confirm], Resolve(confirm_delete)], +) -> str: + """Delete a folder, asking for confirmation when it is not empty.""" + match confirm: + case AcceptedElicitation(data=Confirm(ok=True)): + _FOLDERS.pop(path, None) + return f"deleted {path}" + case AcceptedElicitation(): + return "kept the folder" + case DeclinedElicitation(): + return "declined: folder not deleted" + case CancelledElicitation(): + return "cancelled: folder not deleted" diff --git a/examples/stories/README.md b/examples/stories/README.md index 8b267f3925..8c1cceb5b6 100644 --- a/examples/stories/README.md +++ b/examples/stories/README.md @@ -130,6 +130,7 @@ opens with a banner saying what replaces it. | [`streaming`](streaming/) | progress notifications, in-flight logging, cancellation | current | | [`mrtr`](mrtr/) | `InputRequiredResult` round-trip: the `Client` auto-loop and a manual session-level loop | current | | [`legacy_elicitation`](legacy_elicitation/) | server pauses a tool to ask the user (form + url) via a push request | legacy | +| [`refund_desk`](refund_desk/) | resolver DI: `Annotated[T, Resolve(fn)]` params filled server-side, hidden from the input schema | current | | [`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 | diff --git a/examples/stories/legacy_elicitation/README.md b/examples/stories/legacy_elicitation/README.md index 62f4379c3c..1a9d48e606 100644 --- a/examples/stories/legacy_elicitation/README.md +++ b/examples/stories/legacy_elicitation/README.md @@ -69,4 +69,5 @@ uv run python -m stories.legacy_elicitation.client --http --legacy --server serv `sampling/` (same push-request shape, deprecated per SEP-2577), `mrtr/` (planned — the 2026-era carrier), `error_handling/` -(`UrlElicitationRequiredError`). +(`UrlElicitationRequiredError`), `refund_desk/` (resolver DI rides this push +mechanism today). diff --git a/examples/stories/manifest.toml b/examples/stories/manifest.toml index 0fb25a0f06..57ec0e8a4e 100644 --- a/examples/stories/manifest.toml +++ b/examples/stories/manifest.toml @@ -39,6 +39,12 @@ era = "modern" era = "legacy" status = "legacy" +[story.refund_desk] +# Resolver DI rides push elicitation (ctx.elicit) today; era flips to "dual" once +# the SDK carries resolver elicitation over the 2026 input_required round-trip. +era = "legacy" +lowlevel = false + [story.sampling] era = "legacy" status = "deprecated" diff --git a/examples/stories/mrtr/README.md b/examples/stories/mrtr/README.md index d801b8ff0f..de214988d7 100644 --- a/examples/stories/mrtr/README.md +++ b/examples/stories/mrtr/README.md @@ -51,4 +51,5 @@ uv run python -m stories.mrtr.client --http --server server_lowlevel ## See also `legacy_elicitation/` and `sampling/` — the handshake-era push equivalents this -mechanism replaces on the 2026 protocol. +mechanism replaces on the 2026 protocol. `refund_desk/` — resolver DI at the +MCPServer tier: the questions a tool can declare instead of pushing by hand. diff --git a/examples/stories/refund_desk/README.md b/examples/stories/refund_desk/README.md new file mode 100644 index 0000000000..0a77dd5806 --- /dev/null +++ b/examples/stories/refund_desk/README.md @@ -0,0 +1,67 @@ +# refund-desk + +Resolver dependency injection: a tool parameter annotated `Annotated[T, +Resolve(fn)]` is filled by running the resolver `fn` before the tool body, +instead of from the LLM-supplied arguments. Here `refund_order(order_id, +reason)` refunds what the order record says — `cents` is resolver-computed and +does not appear in the input schema at all, so the model cannot supply or +inflate the amount. Resolvers form a DAG (`load_order` → `refund_scope` → +`refund_amount` / `ask_restock`), may return `Elicit[...]` to ask the human, +and run at most once per call. A resolver's own plain parameters are filled +from the tool's arguments by name — `load_order(order_id)` receives the +`order_id` the model passed to `refund_order`. + +## Run it + +```bash +# stdio (default — the client spawns the server as a subprocess) +uv run python -m stories.refund_desk.client + +# HTTP — the client self-hosts the server on a free port, runs, then tears it +# down (--legacy: resolver elicitation rides the push request today; the +# manifest pins this era, so bare --http runs the same leg) +uv run python -m stories.refund_desk.client --http --legacy +``` + +## What to look at + +- `server.py` `refund_order` — the signature is the whole story: `order_id` and + `reason` are model-facing; `cents` and `restock` carry `Resolve(...)` markers + and never reach the input schema. `client.py` asserts `properties` and + `required` are exactly `{order_id, reason}`. +- `server.py` `refund_scope` — the no-round-trip fast path: a one-line order + returns `Scope(full=True)` directly; only a multi-line order returns + `Elicit(...)`. The ORD-7001 call completes with zero elicitations. +- `server.py` `_scoped` — the elicited SKU is human-typed free text; it is + validated against the order (`ToolError` on a miss) before any amount is + computed. +- The decline contrast: `refund_amount` takes `scope` **unwrapped**, so + declining the scope question aborts the whole `cents` chain with an error + containing the framework's + `Resolver for parameter 'scope' could not resolve: elicitation was decline` + (the client sees it behind the usual `Error executing tool refund_order:` + prefix); `restock` keeps the `ElicitationResult` union, so declining restock + still refunds — just with `restocked: false`. +- `client.py` — the scope counter proves memoization from outside: one call + consumes `refund_scope` from two resolvers but the question fires once. + +## Caveats + +- **Decline order.** A declined unwrapped dependency aborts resolution in + tool-signature order — `cents` resolves before `restock`, so `ask_restock` + never runs. Don't rely on a later resolver's side effects after an earlier + consumer can abort. +- **Memoization scope.** Each resolver runs at most once per `tools/call`, + keyed by function identity; nothing is cached across calls or connections. +- **Validate elicited values.** Elicited answers are human-typed; check them + against your records (as `_scoped` does) before acting on them. + +## Spec + +[Elicitation — client features](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) + +## See also + +`legacy_elicitation/` (the push mechanism resolver elicitation rides on today), +`mrtr/` (the 2026 `input_required` carrier; resolver DI will ride it once the +SDK wires them together). diff --git a/examples/stories/refund_desk/__init__.py b/examples/stories/refund_desk/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/refund_desk/client.py b/examples/stories/refund_desk/client.py new file mode 100644 index 0000000000..ee86d94b40 --- /dev/null +++ b/examples/stories/refund_desk/client.py @@ -0,0 +1,103 @@ +"""Prove the refund amount is schema-hidden, resolvers memoize per call, and decline semantics differ per consumer.""" + +import mcp_types as types + +from mcp.client import Client, ClientRequestContext +from stories._harness import Target, run_client + + +async def main(target: Target, *, mode: str = "auto") -> None: + # Scripted answers + per-topic counters; topics in `declines` are refused. + counts = {"scope": 0, "restock": 0} + answers: dict[str, dict[str, str | int | float | bool | list[str] | None]] = { + "scope": {"full": True}, + "restock": {"restock": True}, + } + declines: set[str] = set() + + async def on_elicit(context: ClientRequestContext, params: types.ElicitRequestParams) -> types.ElicitResult: + assert isinstance(params, types.ElicitRequestFormParams) + topic = "scope" if "full" in params.requested_schema["properties"] else "restock" + counts[topic] += 1 + if topic in declines: + return types.ElicitResult(action="decline") + return types.ElicitResult(action="accept", content=answers[topic]) + + async with Client(target, mode=mode, elicitation_callback=on_elicit) as client: + # The model-facing contract is order_id + reason only; cents and restock are resolver-filled. + listed = await client.list_tools() + (tool,) = listed.tools + assert set(tool.input_schema["properties"]) == {"order_id", "reason"}, tool.input_schema + assert set(tool.input_schema.get("required", ())) == {"order_id", "reason"}, tool.input_schema + + # One digital line: scope auto-fills (full), restock auto-fills (False) — zero round-trips. + receipt = await client.call_tool("refund_order", {"order_id": "ORD-7001", "reason": "download corrupted"}) + assert receipt.structured_content == { + "order_id": "ORD-7001", + "refunded_cents": 1500, + "restocked": False, + "reason": "download corrupted", + }, receipt.structured_content + assert counts == {"scope": 0, "restock": 0}, counts + + # Full refund of a three-line order. The scope question fires exactly ONCE even though + # both refund_amount and ask_restock consume it — memoized within the call. + receipt = await client.call_tool("refund_order", {"order_id": "ORD-7002", "reason": "arrived broken"}) + assert receipt.structured_content == { + "order_id": "ORD-7002", + "refunded_cents": 4800, + "restocked": True, + "reason": "arrived broken", + }, receipt.structured_content + assert counts == {"scope": 1, "restock": 1}, counts + + # Declining restock still refunds: the tool keeps the ElicitationResult union for + # `restock`, sees the decline, and just skips the restock. The scope counter moves + # again — the memo cache is per tools/call, not per connection. + declines.add("restock") + answers["scope"] = {"full": False, "sku": "canvas-tote"} + receipt = await client.call_tool("refund_order", {"order_id": "ORD-7002", "reason": "wrong colour"}) + assert receipt.structured_content == { + "order_id": "ORD-7002", + "refunded_cents": 2400, + "restocked": False, + "reason": "wrong colour", + }, receipt.structured_content + assert counts == {"scope": 2, "restock": 2}, counts + declines.clear() + + # An elicited SKU is human-typed: the server validates it against the order before + # any money is computed. + answers["scope"] = {"full": False, "sku": "mystery-hat"} + result = await client.call_tool("refund_order", {"order_id": "ORD-7002", "reason": "lost parcel"}) + assert result.is_error, result + assert isinstance(result.content[0], types.TextContent) + assert "order has no item 'mystery-hat'" in result.content[0].text, result.content[0].text + + # Declining scope aborts the whole call: refund_amount and ask_restock both consume scope + # unwrapped, so whichever resolves first (`cents`, in signature order) aborts, and + # ask_restock never runs under any order. + declines.add("scope") + restock_before = counts["restock"] + result = await client.call_tool("refund_order", {"order_id": "ORD-7002", "reason": "changed mind"}) + assert result.is_error, result + assert isinstance(result.content[0], types.TextContent) + assert "Resolver for parameter 'scope' could not resolve: elicitation was decline" in result.content[0].text, ( + result.content[0].text + ) + assert counts["restock"] == restock_before, counts + declines.clear() + + # A ToolError raised inside a resolver surfaces exactly like one from the tool body. + result = await client.call_tool("refund_order", {"order_id": "ORD-9999", "reason": "typo"}) + assert result.is_error, result + assert isinstance(result.content[0], types.TextContent) + assert "unknown order 'ORD-9999'" in result.content[0].text, result.content[0].text + + # Full elicitation trajectory: scope fired in legs 2-5 (memoized within each call), + # restock only in the two calls that reached it. + assert counts == {"scope": 4, "restock": 2}, counts + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/refund_desk/server.py b/examples/stories/refund_desk/server.py new file mode 100644 index 0000000000..f29a266f0b --- /dev/null +++ b/examples/stories/refund_desk/server.py @@ -0,0 +1,125 @@ +"""Resolver DI: the refund amount is computed by resolvers from the order record — `cents` never appears in the +tool's input schema, so the model cannot supply or inflate it.""" + +from dataclasses import dataclass +from typing import Annotated + +from pydantic import BaseModel + +from mcp.server.mcpserver import ( + AcceptedElicitation, + Elicit, + ElicitationResult, + MCPServer, + Resolve, +) +from mcp.server.mcpserver.exceptions import ToolError +from stories._hosting import run_server_from_args + + +@dataclass(frozen=True) +class Line: + sku: str + cents: int + physical: bool + + +@dataclass(frozen=True) +class Order: + order_id: str + lines: tuple[Line, ...] + + +ORDERS: dict[str, Order] = { + "ORD-7001": Order("ORD-7001", (Line("ebook-fieldnotes", 1500, physical=False),)), + "ORD-7002": Order( + "ORD-7002", + ( + Line("enamel-mug", 1800, physical=True), + Line("canvas-tote", 2400, physical=True), + Line("sticker-pack", 600, physical=False), + ), + ), +} + + +class Scope(BaseModel): + """Which items to refund: the whole order, or a single SKU.""" + + full: bool + sku: str = "" + + +class RestockChoice(BaseModel): + restock: bool + + +class Receipt(BaseModel): + order_id: str + refunded_cents: int + restocked: bool + reason: str + + +def load_order(order_id: str) -> Order: + order = ORDERS.get(order_id) + if order is None: + raise ToolError(f"unknown order {order_id!r}") + return order + + +def refund_scope(order_id: str, order: Annotated[Order, Resolve(load_order)]) -> Scope | Elicit[Scope]: + if len(order.lines) == 1: + return Scope(full=True) + skus = ", ".join(line.sku for line in order.lines) + return Elicit(f"{order_id} has several items ({skus}). Refund the whole order, or one SKU?", Scope) + + +def _scoped(order: Order, scope: Scope) -> tuple[Line, ...]: + """The lines a scope covers. The SKU was typed by a human — validate it against the order.""" + if scope.full: + return order.lines + lines = tuple(line for line in order.lines if line.sku == scope.sku) + if not lines: + raise ToolError(f"order has no item {scope.sku!r}") + return lines + + +def refund_amount( + order: Annotated[Order, Resolve(load_order)], + scope: Annotated[Scope, Resolve(refund_scope)], +) -> int: + return sum(line.cents for line in _scoped(order, scope)) + + +def ask_restock( + order: Annotated[Order, Resolve(load_order)], + scope: Annotated[Scope, Resolve(refund_scope)], +) -> RestockChoice | Elicit[RestockChoice]: + physical = [line.sku for line in _scoped(order, scope) if line.physical] + if not physical: + return RestockChoice(restock=False) + return Elicit(f"The refund includes physical items ({', '.join(physical)}). Return them to stock?", RestockChoice) + + +def build_server() -> MCPServer: + mcp = MCPServer("refund-desk") + + @mcp.tool(description="Refund an order. The amount comes from the order record, not from the caller.") + def refund_order( + order_id: str, + reason: str, + cents: Annotated[int, Resolve(refund_amount)], + restock: Annotated[ElicitationResult[RestockChoice], Resolve(ask_restock)], + ) -> Receipt: + # `restock` keeps the full elicitation outcome: a declined restock still refunds. A plain + # (non-Elicit) resolver return arrives wrapped as an accepted outcome, so the fast path + # lands in the same `AcceptedElicitation` branch. + restocked = isinstance(restock, AcceptedElicitation) and restock.data.restock + return Receipt(order_id=order_id, refunded_cents=cents, restocked=restocked, reason=reason) + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/mkdocs.yml b/mkdocs.yml index 3e671da8c7..7acee7d5de 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -21,6 +21,7 @@ nav: - Resources: tutorial/resources.md - Prompts: tutorial/prompts.md - The Context: tutorial/context.md + - Dependencies: tutorial/dependencies.md - Handling errors: tutorial/handling-errors.md - Lifespan: tutorial/lifespan.md - Media: tutorial/media.md diff --git a/src/mcp/server/elicitation.py b/src/mcp/server/elicitation.py index dc0e669c8b..c6faf0065e 100644 --- a/src/mcp/server/elicitation.py +++ b/src/mcp/server/elicitation.py @@ -11,6 +11,7 @@ from pydantic import BaseModel, ValidationError from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue from pydantic_core import core_schema +from typing_extensions import TypeAliasType from mcp.server.session import ServerSession @@ -36,7 +37,11 @@ class CancelledElicitation(BaseModel): action: Literal["cancel"] = "cancel" -ElicitationResult = AcceptedElicitation[ElicitSchemaModelT] | DeclinedElicitation | CancelledElicitation +ElicitationResult = TypeAliasType( + "ElicitationResult", + AcceptedElicitation[ElicitSchemaModelT] | DeclinedElicitation | CancelledElicitation, + type_params=(ElicitSchemaModelT,), +) class AcceptedUrlElicitation(BaseModel): diff --git a/src/mcp/server/mcpserver/__init__.py b/src/mcp/server/mcpserver/__init__.py index 7a8da42fef..8ee6c4e4e2 100644 --- a/src/mcp/server/mcpserver/__init__.py +++ b/src/mcp/server/mcpserver/__init__.py @@ -5,6 +5,14 @@ from mcp.server.extension import Extension, MethodBinding, ResourceBinding, ToolBinding from .context import Context +from .resolve import ( + AcceptedElicitation, + CancelledElicitation, + DeclinedElicitation, + Elicit, + ElicitationResult, + Resolve, +) from .resources import DEFAULT_RESOURCE_SECURITY, ResourceSecurity from .server import MCPServer, require_client_extension from .utilities.types import Audio, Image @@ -15,6 +23,12 @@ "Image", "Audio", "Icon", + "Resolve", + "Elicit", + "ElicitationResult", + "AcceptedElicitation", + "DeclinedElicitation", + "CancelledElicitation", "Extension", "ToolBinding", "ResourceBinding", diff --git a/src/mcp/server/mcpserver/context.py b/src/mcp/server/mcpserver/context.py index 15b6fd4ad4..4d494db6ed 100644 --- a/src/mcp/server/mcpserver/context.py +++ b/src/mcp/server/mcpserver/context.py @@ -1,7 +1,7 @@ from __future__ import annotations -from collections.abc import Iterable -from typing import TYPE_CHECKING, Any, Generic +from collections.abc import Iterable, Mapping +from typing import TYPE_CHECKING, Any, Generic, cast from mcp_types import ClientCapabilities, InputResponseRequestParams, InputResponses, LoggingLevel from pydantic import AnyUrl, BaseModel @@ -217,6 +217,16 @@ def client_id(self) -> str | None: """ return self.request_context.meta.get("client_id") if self.request_context.meta else None # pragma: no cover + @property + def headers(self) -> Mapping[str, str] | None: + """Request headers carried by this message, when the transport has them. + + Populated by HTTP-based transports; `None` on stdio or when the + transport's request object carries no headers. Headers are + client-supplied input - never treat one as an identity assertion. + """ + return cast("Mapping[str, str] | None", getattr(self.request_context.request, "headers", None)) + @property def request_id(self) -> str: """Get the unique ID for this request.""" diff --git a/src/mcp/server/mcpserver/resolve.py b/src/mcp/server/mcpserver/resolve.py new file mode 100644 index 0000000000..89843a7169 --- /dev/null +++ b/src/mcp/server/mcpserver/resolve.py @@ -0,0 +1,324 @@ +"""Resolver dependency injection for MCPServer tools. + +A tool parameter annotated `Annotated[T, Resolve(fn)]` is filled by running the +resolver `fn` before the tool body, instead of from the LLM-supplied arguments. +Resolvers form a DAG: a resolver may declare its own `Resolve(...)` dependencies, +take tool arguments by name, and take the `Context`. A resolver may return +`Elicit[T]` to ask the client; the framework runs the elicitation and injects the +answer. + +Whether the consumer receives the unwrapped model or the full +`ElicitationResult` union is decided by the consumer's annotation: + +- `Annotated[T, Resolve(fn)]` -> unwrapped `T`; decline/cancel aborts the call. +- `Annotated[ElicitationResult[T], Resolve(fn)]` (or a specific member) -> the + full outcome; the consumer branches on accept/decline/cancel. + +Each resolver runs at most once per `tools/call` (memoized by function identity). +""" + +from __future__ import annotations + +import inspect +import typing +from collections.abc import Callable, Hashable, Mapping +from typing import Annotated, Any, Generic, cast, get_args, get_origin + +import anyio.to_thread +from pydantic import BaseModel +from typing_extensions import TypeVar + +from mcp.server.elicitation import ( + AcceptedElicitation, + CancelledElicitation, + DeclinedElicitation, + ElicitationResult, +) +from mcp.server.mcpserver.context import Context +from mcp.server.mcpserver.exceptions import InvalidSignature, ToolError +from mcp.shared._callable_inspection import is_async_callable + +T = TypeVar("T", bound=BaseModel) + +# The union members the framework injects when a consumer opts into the outcome. +_ELICITATION_RESULT_MEMBERS = (AcceptedElicitation, DeclinedElicitation, CancelledElicitation) + + +class Resolve: + """Marker for `Annotated[T, Resolve(fn)]`: fill the parameter by running `fn`.""" + + def __init__(self, fn: Callable[..., Any]) -> None: + self.fn = fn + + +class Elicit(Generic[T]): + """A resolver's request to ask the client. + + Returned from a resolver to signal that the value must be elicited. The + framework runs `ctx.elicit(message, schema)` and injects the outcome. + """ + + def __init__(self, message: str, schema: type[T]) -> None: + self.message = message + self.schema = schema + + +class _ParamPlan: + """How to fill one resolver parameter, decided once at registration.""" + + kind: str # "context" | "resolve" | "by_name" + resolve: Resolve | None + wants_union: bool + + def __init__(self, kind: str, resolve: Resolve | None = None, wants_union: bool = False) -> None: + self.kind = kind + self.resolve = resolve + self.wants_union = wants_union + + +class _ResolverPlan: + """A resolver's parameters and whether it is async, analyzed once.""" + + def __init__(self, fn: Callable[..., Any], params: dict[str, _ParamPlan], is_async: bool) -> None: + self.fn = fn + self.params = params + self.is_async = is_async + + +def _type_hints(fn: Callable[..., Any]) -> dict[str, Any]: + """Resolve type hints for a function or a callable object. + + `typing.get_type_hints` raises on a callable *instance*; fall back to its + `__call__`. Returns an empty mapping when hints cannot be resolved, matching + `find_context_parameter`'s tolerance so callables without annotations (or with + unresolvable ones) simply have no resolved parameters. + """ + target = fn if inspect.isroutine(fn) else getattr(type(fn), "__call__", fn) + try: + return typing.get_type_hints(target, include_extras=True) + except Exception: + return {} + + +def _resolver_name(fn: Callable[..., Any]) -> str: + """Best-effort display name for error messages (callable objects lack `__name__`).""" + return getattr(fn, "__name__", None) or type(fn).__name__ + + +def find_resolved_parameters(fn: Callable[..., Any]) -> dict[str, tuple[Resolve, bool]]: + """Find parameters of `fn` annotated `Annotated[_, Resolve(...)]`. + + Returns a mapping of parameter name to `(Resolve, wants_union)`, where + `wants_union` is True when the annotated type is an `ElicitationResult` member + (the consumer wants the full outcome rather than the unwrapped model). + """ + hints = _type_hints(fn) + resolved: dict[str, tuple[Resolve, bool]] = {} + for name in inspect.signature(fn).parameters: + annotation = hints.get(name) + if get_origin(annotation) is not Annotated: + # A `Resolve` marker is only honored at the top level; flag (rather than + # silently drop) one buried in a union, e.g. `Annotated[T, Resolve(f)] | None`. + if _contains_resolve(annotation): + raise InvalidSignature( + f"Parameter {name!r} of {_resolver_name(fn)!r} wraps `Resolve(...)` in a " + "union; annotate the parameter directly as `Annotated[T, Resolve(...)]`" + ) + continue + type_arg, *metadata = get_args(annotation) + marker = next((m for m in metadata if isinstance(m, Resolve)), None) + if marker is not None: + resolved[name] = (marker, _wants_union(type_arg)) + return resolved + + +def _contains_resolve(annotation: Any) -> bool: + """True when a `Resolve` marker is nested inside `annotation` (e.g. a union member).""" + if get_origin(annotation) is Annotated: + return any(isinstance(m, Resolve) for m in get_args(annotation)[1:]) + return any(_contains_resolve(arg) for arg in get_args(annotation)) + + +def _wants_union(type_arg: Any) -> bool: + """True when `type_arg` is an `ElicitationResult` member (or a union of them). + + Handles the subscripted `ElicitationResult[T]` alias (a `TypeAliasType` whose + union is on the origin's `__value__`), the bare `ElicitationResult` alias (the + `__value__` is on `type_arg` itself), an explicit `AcceptedElicitation[T] | ...` + union, and a single member. + """ + # Unwrap the `ElicitationResult` alias whether it is bare or subscripted. + value = getattr(type_arg, "__value__", None) or getattr(get_origin(type_arg), "__value__", None) + if value is not None: + type_arg = value + members = get_args(type_arg) if get_origin(type_arg) is not None else (type_arg,) + return any(isinstance(m, type) and issubclass(m, _ELICITATION_RESULT_MEMBERS) for m in members) + + +def _resolver_key(fn: Callable[..., Any]) -> Hashable: + """Identity key for memoizing a resolver. + + A bound method - pure-python (`inspect.ismethod`) or built-in (e.g. `obj.meth` + on a C-extension type) - is recreated on each attribute access, so `id(fn)` + differs every time. Key it by its underlying function (or name) plus its + `__self__` identity so `auth.login` referenced in two places memoizes to one + call. Everything else keys by `id`, so two distinct callables never collide + even if they compare equal. + """ + bound_self = getattr(fn, "__self__", None) + if bound_self is not None: + # `__func__` (pure-python) has a stable identity; built-ins expose only a + # stable `__name__`. Use the function's id or the name's value accordingly. + func = getattr(fn, "__func__", None) + underlying: Hashable = id(func) if func is not None else getattr(fn, "__name__", id(fn)) + return (underlying, id(bound_self)) + return id(fn) + + +def build_resolver_plans( + resolved_params: Mapping[str, tuple[Resolve, bool]], + tool_arg_names: set[str], +) -> dict[Hashable, _ResolverPlan]: + """Statically analyze the resolver DAG rooted at a tool's resolved parameters. + + Raises: + InvalidSignature: If a resolver has a cyclic dependency, or a resolver + parameter cannot be classified (not a `Context`, a nested `Resolve`, + or a tool argument by name). + """ + plans: dict[Hashable, _ResolverPlan] = {} + + def analyze(fn: Callable[..., Any], stack: tuple[Hashable, ...]) -> None: + key = _resolver_key(fn) + if key in stack: + raise InvalidSignature(f"Resolver {_resolver_name(fn)!r} has a cyclic dependency") + if key in plans: + return + + hints = _type_hints(fn) + sig = inspect.signature(fn) + params: dict[str, _ParamPlan] = {} + nested: list[Callable[..., Any]] = [] + for param_name in sig.parameters: + annotation = hints.get(param_name) + if annotation is not None and _is_context_annotation(annotation): + params[param_name] = _ParamPlan("context") + continue + marker, wants_union = _resolve_marker(annotation) + if marker is not None: + params[param_name] = _ParamPlan("resolve", marker, wants_union) + nested.append(marker.fn) + continue + if param_name in tool_arg_names: + params[param_name] = _ParamPlan("by_name") + continue + raise InvalidSignature( + f"Resolver {_resolver_name(fn)!r} parameter {param_name!r} cannot be resolved: " + "expected a Context, an Annotated[_, Resolve(...)], or a tool argument by name" + ) + + plans[key] = _ResolverPlan(fn, params, is_async_callable(fn)) + for dep in nested: + analyze(dep, stack + (key,)) + + for marker, _ in resolved_params.values(): + analyze(marker.fn, ()) + return plans + + +def _resolve_marker(annotation: Any) -> tuple[Resolve | None, bool]: + if get_origin(annotation) is not Annotated: + return None, False + type_arg, *metadata = get_args(annotation) + marker = next((m for m in metadata if isinstance(m, Resolve)), None) + return marker, (_wants_union(type_arg) if marker is not None else False) + + +def _is_context_annotation(annotation: Any) -> bool: + if get_origin(annotation) is Annotated: + annotation = get_args(annotation)[0] + candidates = get_args(annotation) if get_origin(annotation) is not None else (annotation,) + return any(isinstance(c, type) and issubclass(c, Context) for c in candidates) + + +async def resolve_arguments( + resolved_params: Mapping[str, tuple[Resolve, bool]], + plans: Mapping[Hashable, _ResolverPlan], + tool_args: Mapping[str, Any], + context: Context[Any, Any], +) -> dict[str, Any]: + """Resolve every `Resolve`-marked tool parameter into a concrete value. + + Each resolver runs at most once (memoized by function identity). Returns a + mapping of tool parameter name to the value to inject. + + Raises: + ToolError: If an elicited value is declined or cancelled and the consumer + asked for the unwrapped model (rather than the result union). + """ + cache: dict[Hashable, ElicitationResult[Any]] = {} + injected: dict[str, Any] = {} + for name, (marker, wants_union) in resolved_params.items(): + outcome = await _resolve(marker.fn, plans, tool_args, context, cache) + injected[name] = outcome if wants_union else _unwrap(outcome, name) + return injected + + +async def _resolve( + fn: Callable[..., Any], + plans: Mapping[Hashable, _ResolverPlan], + tool_args: Mapping[str, Any], + context: Context[Any, Any], + cache: dict[Hashable, ElicitationResult[Any]], +) -> ElicitationResult[Any]: + key = _resolver_key(fn) + if key in cache: + return cache[key] + + plan = plans[key] + kwargs: dict[str, Any] = {} + for param_name, param_plan in plan.params.items(): + if param_plan.kind == "context": + kwargs[param_name] = context + elif param_plan.kind == "by_name": + kwargs[param_name] = tool_args[param_name] + else: + assert param_plan.resolve is not None + dep_outcome = await _resolve(param_plan.resolve.fn, plans, tool_args, context, cache) + kwargs[param_name] = dep_outcome if param_plan.wants_union else _unwrap(dep_outcome, param_name) + + if plan.is_async: + result = await fn(**kwargs) + else: + result = await anyio.to_thread.run_sync(lambda: fn(**kwargs)) + + outcome: ElicitationResult[Any] + if isinstance(result, Elicit): + elicit = cast("Elicit[BaseModel]", result) + outcome = await context.elicit(elicit.message, elicit.schema) + else: + # A resolver may return any type (not just `BaseModel`); `model_construct` + # wraps it as an accepted result without validating against the schema bound. + outcome = cast("AcceptedElicitation[Any]", AcceptedElicitation.model_construct(data=result)) + + cache[key] = outcome + return outcome + + +def _unwrap(outcome: ElicitationResult[Any], name: str) -> Any: + if isinstance(outcome, AcceptedElicitation): + return outcome.data + raise ToolError(f"Resolver for parameter {name!r} could not resolve: elicitation was {outcome.action}") + + +__all__ = [ + "Resolve", + "Elicit", + "ElicitationResult", + "AcceptedElicitation", + "DeclinedElicitation", + "CancelledElicitation", + "find_resolved_parameters", + "build_resolver_plans", + "resolve_arguments", +] diff --git a/src/mcp/server/mcpserver/tools/base.py b/src/mcp/server/mcpserver/tools/base.py index 7eb87eed03..6aab3c7771 100644 --- a/src/mcp/server/mcpserver/tools/base.py +++ b/src/mcp/server/mcpserver/tools/base.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Hashable from functools import cached_property from typing import TYPE_CHECKING, Any @@ -8,6 +8,11 @@ from pydantic import BaseModel, Field from mcp.server.mcpserver.exceptions import ToolError +from mcp.server.mcpserver.resolve import ( + build_resolver_plans, + find_resolved_parameters, + resolve_arguments, +) from mcp.server.mcpserver.utilities.context_injection import find_context_parameter from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata from mcp.shared._callable_inspection import is_async_callable @@ -32,6 +37,14 @@ class Tool(BaseModel): ) is_async: bool = Field(description="Whether the tool is async") context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context") + resolved_params: dict[str, Any] = Field( + default_factory=lambda: {}, + exclude=True, + description="Parameters filled by resolvers, mapped to (Resolve, wants_union)", + ) + resolver_plans: dict[Hashable, Any] = Field( + default_factory=lambda: {}, exclude=True, description="Static per-resolver parameter plans" + ) annotations: ToolAnnotations | None = Field(None, description="Optional annotations for the tool") icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this tool") meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this tool") @@ -67,13 +80,23 @@ def from_function( if context_kwarg is None: # pragma: no branch context_kwarg = find_context_parameter(fn) + resolved_params = find_resolved_parameters(fn) + + skip_names = [context_kwarg] if context_kwarg is not None else [] + skip_names.extend(resolved_params) + func_arg_metadata = func_metadata( fn, - skip_names=[context_kwarg] if context_kwarg is not None else [], + skip_names=skip_names, structured_output=structured_output, ) parameters = func_arg_metadata.arg_model.model_json_schema(by_alias=True) + # Match `model_dump_one_level`'s kwarg keys (alias when present, else field name) + # so a by-name resolver param resolves to a key that exists at call time. + tool_arg_names = {field.alias or name for name, field in func_arg_metadata.arg_model.model_fields.items()} + resolver_plans = build_resolver_plans(resolved_params, tool_arg_names) + return cls( fn=fn, name=func_name, @@ -83,6 +106,8 @@ def from_function( fn_metadata=func_arg_metadata, is_async=is_async, context_kwarg=context_kwarg, + resolved_params=dict(resolved_params), + resolver_plans=resolver_plans, annotations=annotations, icons=icons, meta=meta, @@ -100,11 +125,26 @@ async def run( ToolError: If the tool function raises during execution. """ try: + pass_directly: dict[str, Any] = {} + if self.context_kwarg is not None: + pass_directly[self.context_kwarg] = context + + # Resolvers see the same validated arguments the tool body receives: + # validate once and reuse it, so a `default_factory`/stateful validator + # can't hand a by-name resolver a different value than the body. + pre_validated: dict[str, Any] | None = None + if self.resolved_params: + pre_validated = self.fn_metadata.validate_arguments(arguments) + pass_directly |= await resolve_arguments( + self.resolved_params, self.resolver_plans, pre_validated, context + ) + result = await self.fn_metadata.call_fn_with_arg_validation( self.fn, self.is_async, arguments, - {self.context_kwarg: context} if self.context_kwarg is not None else None, + pass_directly or None, + pre_validated=pre_validated, ) if convert_result: diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index 97eb3909ed..be4afb4e9b 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -69,21 +69,36 @@ class FuncMetadata(BaseModel): output_model: Annotated[type[BaseModel], WithJsonSchema(None)] | None = None wrap_output: bool = False + def validate_arguments(self, arguments_to_validate: dict[str, Any]) -> dict[str, Any]: + """Validate raw arguments into a one-level kwargs dict (no function call). + + Used to feed resolver dependency injection the validated tool arguments + before the tool function itself runs. + """ + arguments_pre_parsed = self.pre_parse_json(arguments_to_validate) + arguments_parsed_model = self.arg_model.model_validate(arguments_pre_parsed) + return arguments_parsed_model.model_dump_one_level() + async def call_fn_with_arg_validation( self, fn: Callable[..., Any | Awaitable[Any]], fn_is_async: bool, arguments_to_validate: dict[str, Any], arguments_to_pass_directly: dict[str, Any] | None, + pre_validated: dict[str, Any] | None = None, ) -> Any: """Call the given function with arguments validated and injected. Arguments are first attempted to be parsed from JSON, then validated against - the argument model, before being passed to the function. + the argument model, before being passed to the function. Pass `pre_validated` + (the output of `validate_arguments`) to reuse an earlier validation pass - + validating twice can re-run `default_factory`/stateful validators and hand the + function different values than a caller already observed. """ - arguments_pre_parsed = self.pre_parse_json(arguments_to_validate) - arguments_parsed_model = self.arg_model.model_validate(arguments_pre_parsed) - arguments_parsed_dict = arguments_parsed_model.model_dump_one_level() + # Copy so a caller-provided `pre_validated` dict is never mutated in place. + arguments_parsed_dict = dict( + pre_validated if pre_validated is not None else self.validate_arguments(arguments_to_validate) + ) arguments_parsed_dict |= arguments_to_pass_directly or {} @@ -150,7 +165,7 @@ def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]: key_to_field_info[field_info.alias] = field_info for data_key, data_value in data.items(): - if data_key not in key_to_field_info: # pragma: no cover + if data_key not in key_to_field_info: continue field_info = key_to_field_info[data_key] diff --git a/tests/docs_src/test_dependencies.py b/tests/docs_src/test_dependencies.py new file mode 100644 index 0000000000..73355a8920 --- /dev/null +++ b/tests/docs_src/test_dependencies.py @@ -0,0 +1,129 @@ +"""`docs/tutorial/dependencies.md`: every claim the page makes, proved against the real SDK.""" + +import pytest +from inline_snapshot import snapshot +from mcp_types import ElicitRequestParams, ElicitResult, TextContent + +from docs_src.dependencies import tutorial001, tutorial002, tutorial003 +from mcp import Client +from mcp.client import ClientRequestContext + +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_the_resolver_fills_the_parameter_from_the_tools_own_argument() -> None: + """tutorial001: `check_stock` receives `title` by name and its return value becomes `stock`.""" + async with Client(tutorial001.mcp) as client: + in_stock = await client.call_tool("reserve_book", {"title": "Dune"}) + sold_out = await client.call_tool("reserve_book", {"title": "Neuromancer"}) + + assert in_stock.content == [TextContent(type="text", text="Reserved 'Dune' (6 copies left).")] + assert sold_out.content == [TextContent(type="text", text="'Neuromancer' is out of stock.")] + + +async def test_the_resolved_parameter_is_invisible_to_the_model() -> None: + """tutorial001: the input schema shown on the page is exactly what `tools/list` reports.""" + async with Client(tutorial001.mcp) as client: + (tool,) = (await client.list_tools()).tools + + assert tool.input_schema == snapshot( + { + "type": "object", + "properties": {"title": {"title": "Title", "type": "string"}}, + "required": ["title"], + "title": "reserve_bookArguments", + } + ) + + +async def test_a_client_supplied_value_for_a_resolved_parameter_is_ignored() -> None: + """tutorial001: the resolver's value is the only one the tool can receive.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("reserve_book", {"title": "Dune", "stock": {"title": "Dune", "copies": 999}}) + + assert result.content == [TextContent(type="text", text="Reserved 'Dune' (6 copies left).")] + + +async def test_a_resolver_can_depend_on_another_resolver() -> None: + """tutorial002: `estimate_delivery` consumes `check_stock`'s result, and the tool gets both.""" + async with Client(tutorial002.mcp) as client: + in_stock = await client.call_tool("order_book", {"title": "Dune"}) + backorder = await client.call_tool("order_book", {"title": "Neuromancer"}) + + assert in_stock.content == [TextContent(type="text", text="Ordered 'Dune'; it arrives tomorrow.")] + assert backorder.content == [ + TextContent(type="text", text="'Neuromancer' is on backorder; it would arrive in 2-3 weeks.") + ] + + +async def test_a_shared_dependency_runs_once_per_call(monkeypatch: pytest.MonkeyPatch) -> None: + """tutorial002: `stock` and `delivery` both need `check_stock`; one call, one inventory lookup.""" + + class CountingInventory: + def __init__(self, data: dict[str, int]) -> None: + self.data = data + self.lookups: list[str] = [] + + def get(self, key: str, default: int) -> int: + self.lookups.append(key) + return self.data.get(key, default) + + inventory = CountingInventory(dict(tutorial002.INVENTORY)) + monkeypatch.setattr(tutorial002, "INVENTORY", inventory) + + async with Client(tutorial002.mcp) as client: + await client.call_tool("order_book", {"title": "Dune"}) + assert inventory.lookups == ["Dune"] + # Memoization is per call, not per server: the next call looks the title up again. + await client.call_tool("order_book", {"title": "Dune"}) + assert inventory.lookups == ["Dune", "Dune"] + + +async def test_an_in_stock_order_asks_no_question() -> None: + """tutorial003: `confirm_backorder` returns directly when stock exists - no round-trip.""" + + async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover + raise AssertionError("an in-stock order must not elicit") + + async with Client(tutorial003.mcp, mode="legacy", elicitation_callback=never) as client: + result = await client.call_tool("order_book", {"title": "Dune"}) + + assert result.content == [TextContent(type="text", text="Ordered 'Dune'.")] + + +@pytest.mark.parametrize( + ("confirm", "expected"), + [ + (True, "Backordered 'Neuromancer'; it ships in 2-3 weeks."), + (False, "No order placed."), + ], +) +async def test_an_out_of_stock_order_asks_and_honours_the_answer(confirm: bool, expected: str) -> None: + """tutorial003: the resolver elicits, the SDK validates the answer, the tool reads it.""" + asked: list[str] = [] + + async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + asked.append(params.message) + return ElicitResult(action="accept", content={"confirm": confirm}) + + async with Client(tutorial003.mcp, mode="legacy", elicitation_callback=on_elicit) as client: + result = await client.call_tool("order_book", {"title": "Neuromancer"}) + + assert result.content == [TextContent(type="text", text=expected)] + assert asked == ["'Neuromancer' is out of stock (2-3 weeks). Order anyway?"] + + +async def test_declining_an_unwrapped_dependency_aborts_the_call() -> None: + """tutorial003: no answer, no order - the error text on the page is the real one.""" + + async def decline(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + return ElicitResult(action="decline") + + async with Client(tutorial003.mcp, mode="legacy", elicitation_callback=decline) as client: + result = await client.call_tool("order_book", {"title": "Neuromancer"}) + + assert result.is_error + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == ( + "Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline" + ) diff --git a/tests/docs_src/test_elicitation.py b/tests/docs_src/test_elicitation.py index 44523a141f..4c9bb40367 100644 --- a/tests/docs_src/test_elicitation.py +++ b/tests/docs_src/test_elicitation.py @@ -14,7 +14,7 @@ ) from pydantic import BaseModel -from docs_src.elicitation import tutorial001, tutorial002, tutorial003 +from docs_src.elicitation import tutorial001, tutorial002, tutorial003, tutorial004 from mcp import Client, MCPError from mcp.client import ClientRequestContext from mcp.server import MCPServer @@ -246,3 +246,54 @@ async def test_a_client_without_the_callback_cannot_be_asked() -> None: async with Client(tutorial001.mcp, mode="legacy") as client: with pytest.raises(MCPError, match="Elicitation not supported"): await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2}) + + +async def test_resolver_asks_only_when_the_folder_is_not_empty() -> None: + """tutorial004: `confirm_delete` resolves an empty folder directly and elicits otherwise.""" + tutorial004._FOLDERS.update({"/tmp/empty": [], "/tmp/project": ["main.py", "README.md"]}) + asked: list[str] = [] + + async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + assert isinstance(params, ElicitRequestFormParams) + asked.append(params.message) + return ElicitResult(action="accept", content={"ok": True}) + + async with Client(tutorial004.mcp, mode="legacy", elicitation_callback=on_elicit) as client: + empty = await client.call_tool("delete_folder", {"path": "/tmp/empty"}) + non_empty = await client.call_tool("delete_folder", {"path": "/tmp/project"}) + + assert empty.content == [TextContent(type="text", text="deleted /tmp/empty")] + assert non_empty.content == [TextContent(type="text", text="deleted /tmp/project")] + assert asked == ["/tmp/project has 2 file(s). Delete anyway?"] # the empty folder was not queried + + +async def test_the_resolved_parameter_is_hidden_from_the_tool_schema() -> None: + """tutorial004: the `Resolve`-filled parameter never appears in the client-facing input schema.""" + async with Client(tutorial004.mcp, mode="legacy") as client: + (tool,) = (await client.list_tools()).tools + assert tool.name == "delete_folder" + assert set(tool.input_schema["properties"]) == {"path"} + + +@pytest.mark.parametrize( + ("action", "content", "expected"), + [ + ("accept", {"ok": False}, "kept the folder"), + ("decline", None, "declined: folder not deleted"), + ("cancel", None, "cancelled: folder not deleted"), + ], +) +async def test_the_tool_branches_on_every_elicitation_outcome( + action: Literal["accept", "decline", "cancel"], + content: dict[str, str | int | float | bool | list[str] | None] | None, + expected: str, +) -> None: + """tutorial004: annotating the result union lets the tool handle accept/decline/cancel.""" + tutorial004._FOLDERS["/tmp/project"] = ["main.py", "README.md"] + + async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + return ElicitResult(action=action, content=content) + + async with Client(tutorial004.mcp, mode="legacy", elicitation_callback=on_elicit) as client: + result = await client.call_tool("delete_folder", {"path": "/tmp/project"}) + assert result.content == [TextContent(type="text", text=expected)] diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index edc3decbd4..62a9612b95 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -155,6 +155,28 @@ async def test_complex_function_runtime_arg_validation_with_json(): assert result == "ok!" +@pytest.mark.anyio +async def test_call_fn_does_not_mutate_pre_validated(): + """A caller-provided `pre_validated` dict must not be mutated by the call.""" + + def fn(x: int, ctx: str) -> str: + return f"{x}:{ctx}" + + meta = func_metadata(fn, skip_names=["ctx"]) + pre_validated = meta.validate_arguments({"x": 1}) + snapshot = dict(pre_validated) + + result = await meta.call_fn_with_arg_validation( + fn, + fn_is_async=False, + arguments_to_validate={"x": 1}, + arguments_to_pass_directly={"ctx": "injected"}, + pre_validated=pre_validated, + ) + assert result == "1:injected" + assert pre_validated == snapshot # `ctx` was not leaked into the caller's dict + + def test_str_vs_list_str(): """Test handling of string vs list[str] type annotations. diff --git a/tests/server/mcpserver/test_resolve.py b/tests/server/mcpserver/test_resolve.py new file mode 100644 index 0000000000..1f4f724080 --- /dev/null +++ b/tests/server/mcpserver/test_resolve.py @@ -0,0 +1,571 @@ +"""Tests for resolver dependency injection (MRTR) on MCPServer tools.""" + +from typing import Annotated, Any, Literal + +import pytest +from mcp_types import ElicitRequestParams, ElicitResult, TextContent +from pydantic import BaseModel, Field + +from mcp import Client +from mcp.client import ClientRequestContext +from mcp.server.mcpserver import ( + AcceptedElicitation, + CancelledElicitation, + Context, + DeclinedElicitation, + Elicit, + ElicitationResult, + MCPServer, + Resolve, +) +from mcp.server.mcpserver.exceptions import InvalidSignature +from mcp.server.mcpserver.resolve import _resolver_key, find_resolved_parameters +from mcp.server.mcpserver.tools.base import Tool + + +class Login(BaseModel): + username: str + + +class Confirm(BaseModel): + ok: bool + + +async def _alias_login(ctx: Context) -> Login: + return Login(username="x") # pragma: no cover - only the signature is inspected + + +def _accept(content: dict[str, str | int | float | bool | list[str] | None]): + async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + return ElicitResult(action="accept", content=content) + + return callback + + +async def _decline(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + return ElicitResult(action="decline") + + +async def _text(client: Client, tool: str, args: dict[str, object]) -> str: + result = await client.call_tool(tool, args) + assert len(result.content) == 1 + assert isinstance(result.content[0], TextContent) + return result.content[0].text + + +@pytest.mark.anyio +async def test_resolver_returns_value_directly_without_eliciting(): + mcp = MCPServer(name="Direct") + + async def login(ctx: Context) -> Login | Elicit[Login]: + username = (ctx.headers or {}).get("x-github-user") + if username: # pragma: no cover - no headers on in-memory transport + return Login(username=username) + return Login(username="from-resolver") + + @mcp.tool() + async def whoami(login: Annotated[Login, Resolve(login)]) -> str: + return login.username + + async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover + raise AssertionError("should not elicit") + + async with Client(mcp, mode="legacy", elicitation_callback=never) as client: + assert await _text(client, "whoami", {}) == "from-resolver" + + +@pytest.mark.anyio +async def test_resolver_elicits_and_injects_unwrapped_model_on_accept(): + mcp = MCPServer(name="Accept") + + async def login(ctx: Context) -> Login | Elicit[Login]: + return Elicit("GitHub username?", Login) + + @mcp.tool() + async def whoami(login: Annotated[Login, Resolve(login)]) -> str: + return login.username + + async with Client(mcp, mode="legacy", elicitation_callback=_accept({"username": "octocat"})) as client: + assert await _text(client, "whoami", {}) == "octocat" + + +@pytest.mark.anyio +async def test_consumer_receives_result_union_and_branches(): + mcp = MCPServer(name="Union") + + async def login(ctx: Context) -> Login | Elicit[Login]: + return Elicit("GitHub username?", Login) + + @mcp.tool() + async def whoami(login: Annotated[ElicitationResult[Login], Resolve(login)]) -> str: + match login: + case AcceptedElicitation(data=data): + return f"hi {data.username}" + case _: # pragma: no cover - accepted in this test + return "no username" + + async with Client(mcp, mode="legacy", elicitation_callback=_accept({"username": "octocat"})) as client: + assert await _text(client, "whoami", {}) == "hi octocat" + + +@pytest.mark.anyio +async def test_decline_reaches_union_consumer_without_aborting(): + mcp = MCPServer(name="UnionDecline") + + async def login(ctx: Context) -> Login | Elicit[Login]: + return Elicit("GitHub username?", Login) + + @mcp.tool() + async def whoami( + login: Annotated[AcceptedElicitation[Login] | DeclinedElicitation | CancelledElicitation, Resolve(login)], + ) -> str: + if isinstance(login, DeclinedElicitation): + return "declined gracefully" + raise NotImplementedError + + async with Client(mcp, mode="legacy", elicitation_callback=_decline) as client: + assert await _text(client, "whoami", {}) == "declined gracefully" + + +@pytest.mark.anyio +async def test_decline_aborts_when_consumer_wants_unwrapped(): + mcp = MCPServer(name="UnwrappedDecline") + + async def login(ctx: Context) -> Login | Elicit[Login]: + return Elicit("GitHub username?", Login) + + @mcp.tool() + async def whoami(login: Annotated[Login, Resolve(login)]) -> str: + raise NotImplementedError # pragma: no cover - never reached + + async with Client(mcp, mode="legacy", elicitation_callback=_decline) as client: + result = await client.call_tool("whoami", {}) + assert result.is_error + assert isinstance(result.content[0], TextContent) + assert "decline" in result.content[0].text + + +@pytest.mark.anyio +async def test_nested_resolver_sees_dependency_and_tool_args(): + mcp = MCPServer(name="Nested") + + async def login(ctx: Context) -> Login | Elicit[Login]: + return Elicit("GitHub username?", Login) + + async def confirm(repo: str, login: Annotated[Login, Resolve(login)]) -> Elicit[Confirm]: + return Elicit(f"Star {repo} as {login.username}?", Confirm) + + @mcp.tool() + async def star_repo( + repo: str, + login: Annotated[Login, Resolve(login)], + confirm: Annotated[Confirm, Resolve(confirm)], + ) -> str: + if confirm.ok: + return f"starred {repo} as {login.username}" + raise NotImplementedError + + async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + if "username" in params.message: + return ElicitResult(action="accept", content={"username": "octocat"}) + assert "Star modelcontextprotocol/python-sdk as octocat?" in params.message + return ElicitResult(action="accept", content={"ok": True}) + + async with Client(mcp, mode="legacy", elicitation_callback=callback) as client: + text = await _text(client, "star_repo", {"repo": "modelcontextprotocol/python-sdk"}) + assert text == "starred modelcontextprotocol/python-sdk as octocat" + + +@pytest.mark.anyio +async def test_resolver_runs_once_for_two_consumers(): + mcp = MCPServer(name="ExactlyOnce") + elicit_count = 0 + + async def login(ctx: Context) -> Login | Elicit[Login]: + return Elicit("GitHub username?", Login) + + async def confirm(login: Annotated[Login, Resolve(login)]) -> Elicit[Confirm]: + return Elicit(f"As {login.username}?", Confirm) + + @mcp.tool() + async def star_repo( + login: Annotated[Login, Resolve(login)], + confirm: Annotated[Confirm, Resolve(confirm)], + ) -> str: + return f"{login.username}:{confirm.ok}" + + async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + nonlocal elicit_count + if "username" in params.message: + elicit_count += 1 + return ElicitResult(action="accept", content={"username": "octocat"}) + return ElicitResult(action="accept", content={"ok": True}) + + async with Client(mcp, mode="legacy", elicitation_callback=callback) as client: + assert await _text(client, "star_repo", {}) == "octocat:True" + assert elicit_count == 1 + + +@pytest.mark.anyio +async def test_sync_resolver(): + mcp = MCPServer(name="Sync") + + def login(ctx: Context) -> Login: + return Login(username="sync-user") + + @mcp.tool() + async def whoami(login: Annotated[Login, Resolve(login)]) -> str: + return login.username + + async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover + raise AssertionError("should not elicit") + + async with Client(mcp, mode="legacy", elicitation_callback=never) as client: + assert await _text(client, "whoami", {}) == "sync-user" + + +def test_resolved_params_absent_from_input_schema(): + async def login(ctx: Context) -> Login: + return Login(username="x") # pragma: no cover - only the schema is inspected + + async def tool( + repo: Annotated[str, Field(description="repo name")], + login: Annotated[Login, Resolve(login)], + ) -> str: + return repo # pragma: no cover - only the schema is inspected + + built = Tool.from_function(tool) + properties = built.parameters["properties"] + assert "repo" in properties + assert "login" not in properties + + +def test_cycle_detection_raises_at_registration(): + async def a(dep: Login) -> Login: + return dep # pragma: no cover + + async def b(dep: Login) -> Login: + return dep # pragma: no cover + + # Close the loop after both exist: a depends on b, b depends on a. + a.__annotations__["dep"] = Annotated[Login, Resolve(b)] + b.__annotations__["dep"] = Annotated[Login, Resolve(a)] + + async def tool(value: Annotated[Login, Resolve(a)]) -> str: + return value.username # pragma: no cover + + with pytest.raises(InvalidSignature, match="cyclic"): + Tool.from_function(tool) + + +def test_find_resolved_parameters_tolerates_unresolvable_hints(): + def fn(x: int) -> int: + return x # pragma: no cover + + fn.__annotations__["x"] = "DoesNotExist" + assert find_resolved_parameters(fn) == {} + + +def test_elicitation_result_alias_resolves_under_postponed_annotations(): + # Reproduces the case where `from __future__ import annotations` stringifies + # `Annotated[ElicitationResult[Login], Resolve(_alias_login)]`: the alias must be + # subscriptable so the resolver is detected (not silently dropped) and the + # consumer is recognized as wanting the result union. + def tool(login: str) -> str: + return login # pragma: no cover + + tool.__annotations__["login"] = "Annotated[ElicitationResult[Login], Resolve(_alias_login)]" + resolved = find_resolved_parameters(tool) + assert "login" in resolved + assert resolved["login"][1] is True # wants_union + + +def test_unresolvable_resolver_param_raises_at_registration(): + async def login(mystery: int) -> Login: + return Login(username="x") # pragma: no cover + + async def tool(login: Annotated[Login, Resolve(login)]) -> str: + return login.username # pragma: no cover + + with pytest.raises(InvalidSignature, match="cannot be resolved"): + Tool.from_function(tool) + + +def test_resolve_marker_inside_a_union_raises_at_registration(): + async def login(ctx: Context) -> Login: + return Login(username="x") # pragma: no cover + + async def tool(login: Annotated[Login, Resolve(login)] | None = None) -> str: + return login.username if login else "" # pragma: no cover + + with pytest.raises(InvalidSignature, match="wraps `Resolve"): + Tool.from_function(tool) + + +def test_bare_elicitation_result_alias_wants_the_outcome_union(): + # The bare `ElicitationResult` alias (no `[T]` subscription) must still opt into + # the result union, not be treated as wanting the unwrapped model. + async def login(ctx: Context) -> Login: + return Login(username="x") # pragma: no cover + + async def tool(login: object) -> str: + return "x" # pragma: no cover + + bare_alias: Any = ElicitationResult + tool.__annotations__["login"] = Annotated[bare_alias, Resolve(login)] + (_, wants_union) = find_resolved_parameters(tool)["login"] + assert wants_union is True + + +def test_resolve_marker_on_return_annotation_is_ignored(): + async def login(ctx: Context) -> Login: + return Login(username="x") # pragma: no cover + + async def tool(repo: str) -> Annotated[str, Resolve(login)]: + return repo # pragma: no cover + + assert find_resolved_parameters(tool) == {} + + +def test_callable_object_resolver_error_uses_type_name(): + class BadResolver: + async def __call__(self, mystery: int) -> Login: + return Login(username="x") # pragma: no cover + + async def tool(login: Annotated[Login, Resolve(BadResolver())]) -> str: + return login.username # pragma: no cover + + with pytest.raises(InvalidSignature, match="'BadResolver'"): + Tool.from_function(tool) + + +@pytest.mark.anyio +async def test_by_name_resolver_param_uses_aliased_tool_arg(): + mcp = MCPServer(name="Aliased") + + # `schema` collides with a BaseModel attribute, so func_metadata aliases the field; + # the runtime kwarg key is the alias, which is what a by-name resolver must match. + async def upper(schema: str) -> Login: + return Login(username=schema.upper()) + + @mcp.tool() + async def run(schema: str, shouted: Annotated[Login, Resolve(upper)]) -> str: + return shouted.username + + async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover + raise AssertionError("should not elicit") + + async with Client(mcp, mode="legacy", elicitation_callback=never) as client: + assert await _text(client, "run", {"schema": "gpt"}) == "GPT" + + +@pytest.mark.anyio +async def test_resolver_may_return_non_basemodel_value(): + mcp = MCPServer(name="NonModel") + + async def get_token(ctx: Context) -> str: + return "secret-token" + + @mcp.tool() + async def use_token(token: Annotated[str, Resolve(get_token)]) -> str: + return token + + async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover + raise AssertionError("should not elicit") + + async with Client(mcp, mode="legacy", elicitation_callback=never) as client: + assert await _text(client, "use_token", {}) == "secret-token" + + +@pytest.mark.anyio +async def test_resolver_accepts_optional_context_annotation(): + mcp = MCPServer(name="OptionalContext") + + async def whoami(ctx: Context | None) -> str: + assert ctx is not None + return "has-context" + + @mcp.tool() + async def run(who: Annotated[str, Resolve(whoami)]) -> str: + return who + + async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover + raise AssertionError("should not elicit") + + async with Client(mcp, mode="legacy", elicitation_callback=never) as client: + assert await _text(client, "run", {}) == "has-context" + + +@pytest.mark.anyio +async def test_bound_method_resolver_runs_once_across_references(): + mcp = MCPServer(name="BoundMethod") + calls = 0 + + class Service: + async def token(self, ctx: Context) -> str: + nonlocal calls + calls += 1 + return "tok" + + service = Service() + + # Each `service.token` access is a fresh bound-method object; keying by the + # callable (not id) keeps the resolver memoized to a single call. + async def downstream(token: Annotated[str, Resolve(service.token)]) -> str: + return token.upper() + + @mcp.tool() + async def run( + token: Annotated[str, Resolve(service.token)], + shouted: Annotated[str, Resolve(downstream)], + ) -> str: + return f"{token}:{shouted}" + + async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover + raise AssertionError("should not elicit") + + async with Client(mcp, mode="legacy", elicitation_callback=never) as client: + assert await _text(client, "run", {}) == "tok:TOK" + assert calls == 1 + + +def test_bound_method_cycle_is_detected(): + class Service: + async def a(self, dep: Login) -> Login: + return dep # pragma: no cover + + async def b(self, dep: Login) -> Login: + return dep # pragma: no cover + + service = Service() + service.a.__func__.__annotations__["dep"] = Annotated[Login, Resolve(service.b)] + service.b.__func__.__annotations__["dep"] = Annotated[Login, Resolve(service.a)] + + async def tool(value: Annotated[Login, Resolve(service.a)]) -> str: + return value.username # pragma: no cover + + with pytest.raises(InvalidSignature, match="cyclic"): + Tool.from_function(tool) + + +@pytest.mark.anyio +async def test_resolver_and_body_see_the_same_validated_default(): + mcp = MCPServer(name="DefaultFactory") + counter = {"n": 0} + + def next_id() -> int: + counter["n"] += 1 + return counter["n"] + + # A by-name resolver and the tool body must observe one validation pass, so the + # `default_factory` runs once and both see the same generated value. + async def echo_id(request_id: int) -> int: + return request_id + + @mcp.tool() + async def run( + request_id: Annotated[int, Field(default_factory=next_id)], + resolved_id: Annotated[int, Resolve(echo_id)], + ) -> str: + return f"{request_id}:{resolved_id}" + + async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover + raise AssertionError("should not elicit") + + async with Client(mcp, mode="legacy", elicitation_callback=never) as client: + assert await _text(client, "run", {}) == "1:1" + assert counter["n"] == 1 + + +def test_resolver_key_is_stable_for_methods_and_distinct_callables(): + class Service: + def handler(self) -> None: ... # pragma: no cover + + a, b = Service(), Service() + + # Pure-python bound methods: stable across accesses, distinct per instance. + assert _resolver_key(a.handler) == _resolver_key(a.handler) + assert _resolver_key(a.handler) != _resolver_key(b.handler) + + # Built-in bound methods (no `__func__`): fresh object each access, but the key + # is stable and keyed to `__self__`. + items: list[int] = [] + others: list[int] = [] + assert _resolver_key(items.append) == _resolver_key(items.append) + assert _resolver_key(items.append) != _resolver_key(others.append) + assert _resolver_key(items.append) != _resolver_key(items.pop) + + # Plain functions key by identity. + def fn() -> None: ... # pragma: no cover + + assert _resolver_key(fn) == _resolver_key(fn) + + +def _delete_folder_server() -> tuple[MCPServer, dict[str, list[str]]]: + """The `delete_folder` example from docs/migration.md, wired to an in-memory fs.""" + mcp = MCPServer(name="files") + fs: dict[str, list[str]] = {} + + async def confirm_delete(path: str) -> Confirm | Elicit[Confirm]: + file_count = len(fs.get(path, [])) + if file_count == 0: + return Confirm(ok=True) + return Elicit(f"{path} has {file_count} file(s). Delete anyway?", Confirm) + + @mcp.tool() + async def delete_folder( + path: str, + confirm: Annotated[ElicitationResult[Confirm], Resolve(confirm_delete)], + ) -> str: + match confirm: + case AcceptedElicitation(data=Confirm(ok=True)): + fs.pop(path, None) + return f"deleted {path}" + case AcceptedElicitation(): + return "kept the folder" + case DeclinedElicitation(): + return "declined: folder not deleted" + case CancelledElicitation(): # pragma: no branch + return "cancelled: folder not deleted" + + return mcp, fs + + +@pytest.mark.anyio +async def test_delete_empty_folder_does_not_elicit(): + mcp, fs = _delete_folder_server() + fs["/empty"] = [] + + async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover + raise AssertionError("should not elicit for an empty folder") + + async with Client(mcp, mode="legacy", elicitation_callback=never) as client: + assert await _text(client, "delete_folder", {"path": "/empty"}) == "deleted /empty" + assert "/empty" not in fs + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("action", "content", "expected"), + [ + ("accept", {"ok": True}, "deleted /docs"), + ("accept", {"ok": False}, "kept the folder"), + ("decline", None, "declined: folder not deleted"), + ("cancel", None, "cancelled: folder not deleted"), + ], +) +async def test_delete_non_empty_folder_handles_every_outcome( + action: Literal["accept", "decline", "cancel"], + content: dict[str, str | int | float | bool | list[str] | None] | None, + expected: str, +): + mcp, fs = _delete_folder_server() + fs["/docs"] = ["a.txt", "b.txt"] + + async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + assert "/docs has 2 file(s)" in params.message + return ElicitResult(action=action, content=content) + + async with Client(mcp, mode="legacy", elicitation_callback=callback) as client: + assert await _text(client, "delete_folder", {"path": "/docs"}) == expected + assert ("/docs" in fs) is (expected != "deleted /docs") diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 70855f44b2..d92ed5eaad 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -1,5 +1,6 @@ import base64 from pathlib import Path +from types import SimpleNamespace from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -1801,6 +1802,33 @@ async def test_report_progress_delegates_to_session_report_progress(): mock_session.report_progress.assert_awaited_once_with(50, 100, "halfway") +def _request_context(request: object | None) -> ServerRequestContext[None, object]: + return ServerRequestContext( + session=AsyncMock(), + method="tools/call", + lifespan_context=None, + protocol_version="2025-11-25", + request=request, + ) + + +def test_context_headers_returns_request_headers(): + request = SimpleNamespace(headers={"x-github-user": "octocat"}) + ctx = Context(request_context=_request_context(request), mcp_server=MagicMock()) + assert ctx.headers == {"x-github-user": "octocat"} + + +def test_context_headers_is_none_without_request(): + ctx = Context(request_context=_request_context(None), mcp_server=MagicMock()) + assert ctx.headers is None + + +def test_context_headers_is_none_when_request_carries_no_headers(): + """A transport may attach a custom request object that has no headers attribute.""" + ctx = Context(request_context=_request_context(object()), mcp_server=MagicMock()) + assert ctx.headers is None + + async def test_read_resource_template_error(): """Template-creation failure must surface as INTERNAL_ERROR, not INVALID_PARAMS (not-found).""" mcp = MCPServer() From f2e63c979a2feff5c19914396699135f86b7001f Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:01:54 +0200 Subject: [PATCH 024/100] Promote the v2 README to README.md ahead of the first v2 beta (#3014) --- .github/workflows/shared.yml | 17 +- .pre-commit-config.yaml | 9 +- AGENTS.md | 3 +- CONTRIBUTING.md | 2 +- README.md | 2556 +---------------------------- README.v2.md | 132 -- RELEASE.md | 16 +- examples/README.md | 10 +- pyproject.toml | 3 +- scripts/update_readme_snippets.py | 8 +- tests/docs_src/test_shape.py | 8 +- tests/test_examples.py | 3 +- 12 files changed, 84 insertions(+), 2683 deletions(-) delete mode 100644 README.v2.md diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index 3ab4753568..1dc2692eb1 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -27,8 +27,6 @@ jobs: - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 with: extra_args: --all-files --verbose - env: - SKIP: no-commit-to-branch,readme-v1-frozen - name: Surface types match vendored schema run: | @@ -42,19 +40,6 @@ jobs: uv run --isolated --no-project --with ./src/mcp-types python -c \ "import mcp_types, mcp_types.jsonrpc, mcp_types.methods, mcp_types.version, mcp_types.v2025_11_25, mcp_types.v2026_07_28" - # TODO(Max): Drop this in v2. Deliberate updates (e.g. the v2 status - # banner) go through the 'override-readme-freeze' label. - - name: Check README.md is not modified - if: github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, 'override-readme-freeze') - run: | - git fetch --no-tags --depth=1 origin "$BASE_SHA" - if git diff --name-only "$BASE_SHA" -- README.md | grep -q .; then - echo "::error::README.md is frozen at v1. Edit README.v2.md instead." - exit 1 - fi - env: - BASE_SHA: ${{ github.event.pull_request.base.sha }} - test: name: test (${{ matrix.python-version }}, ${{ matrix.dep-resolution.name }}, ${{ matrix.os }}) runs-on: ${{ matrix.os }} @@ -116,7 +101,7 @@ jobs: run: uv sync --frozen --all-extras --python 3.10 - name: Check README snippets are up to date - run: uv run --frozen scripts/update_readme_snippets.py --check --readme README.v2.md + run: uv run --frozen scripts/update_readme_snippets.py --check # `mkdocs.yml` sets `strict: true` and `pymdownx.snippets: check_paths: true`, # but until this job existed the docs were only ever built post-merge by diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f88f229ed5..321b60bc52 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -42,7 +42,6 @@ repos: types: [python] language: system pass_filenames: false - exclude: ^README(\.v2)?\.md$ - id: pyright name: pyright entry: uv run --frozen pyright @@ -55,15 +54,9 @@ repos: language: system files: ^(pyproject\.toml|uv\.lock)$ pass_filenames: false - # TODO(Max): Drop this in v2. - - id: readme-v1-frozen - name: README.md is frozen (v1 docs) - entry: README.md is frozen at v1. Edit README.v2.md instead. - language: fail - files: ^README\.md$ - id: readme-snippets name: Check README snippets are up to date entry: uv run --frozen python scripts/update_readme_snippets.py --check language: system - files: ^(README\.v2\.md|docs_src/.*\.py|examples/.*\.py|scripts/update_readme_snippets\.py)$ + files: ^(README\.md|docs_src/.*\.py|scripts/update_readme_snippets\.py)$ pass_filenames: false diff --git a/AGENTS.md b/AGENTS.md index efe321db00..6c51e89819 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,8 +12,7 @@ shim) must be documented in `docs/migration.md`. - `v1.x` is the release branch for the current stable line. Backport PRs target this branch and use a `[v1.x]` title prefix. -- `README.md` is frozen at v1 (a pre-commit hook rejects edits). Edit - `README.v2.md` instead. +- `README.md` documents v2. The v1 README lives on the `v1.x` branch. ## Package Management diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ff66e6c41..a36dedd8da 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -105,7 +105,7 @@ uv run ruff check . uv run ruff format . ``` -7. Update README snippets if you modified example code: +7. Update README snippets if you modified `docs_src/` code embedded in the README: ```bash uv run scripts/update_readme_snippets.py diff --git a/README.md b/README.md index 319ad6a115..88b74ad97c 100644 --- a/README.md +++ b/README.md @@ -13,2559 +13,117 @@ - - -> [!NOTE] -> **This README documents v1.x of the MCP Python SDK (the current stable release).** +> [!CAUTION] +> **This README documents v2 of the MCP Python SDK — a pre-release (alpha/beta) line under active development. Do not use v2 in production.** Pre-releases are published to PyPI as `2.0.0aN` / `2.0.0bN`, and **each pre-release may contain breaking changes from the previous one**. Pin an exact version and expect to update your code when you bump the pin. > -> **v2 is in alpha.** Pre-releases are published to PyPI as `2.0.0aN` and can be installed with an explicit pin, for example `pip install mcp==2.0.0a1`. See [`README.v2.md`](README.v2.md) for the v2 documentation and the [migration guide](docs/migration.md) for what's changed. We're targeting a beta on 2026-06-30 and a stable v2 on 2026-07-27. If your package depends on `mcp`, add a `<2` upper bound to your version constraint (for example `mcp>=1.27,<2`) before the stable release lands. +> **v1.x is the only stable release line and remains recommended for production.** It lives on the [`v1.x` branch](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x) and continues to receive critical bug fixes and security patches; see [the v1.x README](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/README.md) for its documentation. `pip` and `uv` don't select a pre-release unless you explicitly request one, so existing installs are unaffected. **If your package depends on `mcp`, add a `<2` upper bound to your version constraint (for example `mcp>=1.27,<2`) before the stable release lands.** > -> For v1.x code and documentation, see the [`v1.x` branch](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x). v1.x is in maintenance mode and continues to receive critical bug fixes and security patches. - - -## Table of Contents - -- [MCP Python SDK](#mcp-python-sdk) - - [Overview](#overview) - - [Installation](#installation) - - [Adding MCP to your python project](#adding-mcp-to-your-python-project) - - [Running the standalone MCP development tools](#running-the-standalone-mcp-development-tools) - - [Quickstart](#quickstart) - - [What is MCP?](#what-is-mcp) - - [Core Concepts](#core-concepts) - - [Server](#server) - - [Resources](#resources) - - [Tools](#tools) - - [Structured Output](#structured-output) - - [Prompts](#prompts) - - [Images](#images) - - [Context](#context) - - [Getting Context in Functions](#getting-context-in-functions) - - [Context Properties and Methods](#context-properties-and-methods) - - [Completions](#completions) - - [Elicitation](#elicitation) - - [Sampling](#sampling) - - [Logging and Notifications](#logging-and-notifications) - - [Authentication](#authentication) - - [FastMCP Properties](#fastmcp-properties) - - [Session Properties and Methods](#session-properties-and-methods) - - [Request Context Properties](#request-context-properties) - - [Running Your Server](#running-your-server) - - [Development Mode](#development-mode) - - [Claude Desktop Integration](#claude-desktop-integration) - - [Direct Execution](#direct-execution) - - [Streamable HTTP Transport](#streamable-http-transport) - - [CORS Configuration for Browser-Based Clients](#cors-configuration-for-browser-based-clients) - - [Mounting to an Existing ASGI Server](#mounting-to-an-existing-asgi-server) - - [StreamableHTTP servers](#streamablehttp-servers) - - [Basic mounting](#basic-mounting) - - [Host-based routing](#host-based-routing) - - [Multiple servers with path configuration](#multiple-servers-with-path-configuration) - - [Path configuration at initialization](#path-configuration-at-initialization) - - [SSE servers](#sse-servers) - - [Advanced Usage](#advanced-usage) - - [Low-Level Server](#low-level-server) - - [Structured Output Support](#structured-output-support) - - [Pagination (Advanced)](#pagination-advanced) - - [Writing MCP Clients](#writing-mcp-clients) - - [Client Display Utilities](#client-display-utilities) - - [OAuth Authentication for Clients](#oauth-authentication-for-clients) - - [Parsing Tool Results](#parsing-tool-results) - - [MCP Primitives](#mcp-primitives) - - [Server Capabilities](#server-capabilities) - - [Documentation](#documentation) - - [Contributing](#contributing) - - [License](#license) - -[pypi-badge]: https://img.shields.io/pypi/v/mcp.svg -[pypi-url]: https://pypi.org/project/mcp/ -[mit-badge]: https://img.shields.io/pypi/l/mcp.svg -[mit-url]: https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE -[python-badge]: https://img.shields.io/pypi/pyversions/mcp.svg -[python-url]: https://www.python.org/downloads/ -[docs-badge]: https://img.shields.io/badge/docs-python--sdk-blue.svg -[docs-url]: https://modelcontextprotocol.github.io/python-sdk/ -[protocol-badge]: https://img.shields.io/badge/protocol-modelcontextprotocol.io-blue.svg -[protocol-url]: https://modelcontextprotocol.io -[spec-badge]: https://img.shields.io/badge/spec-spec.modelcontextprotocol.io-blue.svg -[spec-url]: https://modelcontextprotocol.io/specification/latest - -## Overview +> v2 is a major rework of the SDK, both to support the [2026-07-28 MCP specification release](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) and to fix long-standing architectural issues. See the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/) for what's changed. Stable v2 is targeted for 2026-07-27, alongside the spec release. Try the pre-releases and tell us what breaks: [#python-sdk-dev on the MCP Contributors Discord](https://discord.gg/6CSzBmMkjX). -The Model Context Protocol allows applications to provide context for LLMs in a standardized way, separating the concerns of providing context from the actual LLM interaction. This Python SDK implements the full MCP specification, making it easy to: - -- Build MCP clients that can connect to any MCP server -- Create MCP servers that expose resources, prompts and tools -- Use standard transports like stdio, SSE, and Streamable HTTP -- Handle all MCP protocol messages and lifecycle events +## Documentation -## Installation +**The documentation lives at .** -### Adding MCP to your python project +It has the full [tutorial](https://py.sdk.modelcontextprotocol.io/v2/tutorial/), the [API reference](https://py.sdk.modelcontextprotocol.io/v2/api/mcp/), and the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/). -We recommend using [uv](https://docs.astral.sh/uv/) to manage your Python projects. +## What is MCP? -If you haven't created a uv-managed project yet, create one: +The [Model Context Protocol](https://modelcontextprotocol.io) lets you build servers that expose data and functionality to LLM applications in a secure, standardized way. Think of it like a web API, but designed for LLM interactions. With this SDK you can: - ```bash - uv init mcp-server-demo - cd mcp-server-demo - ``` +- **Build MCP servers** that expose tools, resources, and prompts to any MCP host +- **Build MCP clients** that connect to any MCP server +- Speak every standard transport: stdio, Streamable HTTP, and SSE - Then add MCP to your project dependencies: +## Requirements - ```bash - uv add "mcp[cli]" - ``` +Python 3.10+. -Alternatively, for projects using pip for dependencies: +## Installation ```bash -pip install "mcp[cli]" +uv add "mcp[cli]==2.0.0a3" # or: pip install "mcp[cli]==2.0.0a3" ``` -### Running the standalone MCP development tools - -To run the mcp command with uv: - -```bash -uv run mcp -``` +The pin matters while v2 is in pre-release: an unpinned install resolves to the latest stable v1.x, which this README does not describe. Check [PyPI](https://pypi.org/project/mcp/#history) for the newest pre-release, and use `uv run --with "mcp==2.0.0a3"` for one-off commands. -## Quickstart +## A server in 15 lines -Let's create a simple MCP server that exposes a calculator tool and some data: +Create a `server.py`: - + ```python -""" -FastMCP quickstart example. - -Run from the repository root: - uv run examples/snippets/servers/fastmcp_quickstart.py -""" +from mcp.server import MCPServer -from mcp.server.fastmcp import FastMCP +mcp = MCPServer("Demo") -# Create an MCP server -mcp = FastMCP("Demo", json_response=True) - -# Add an addition tool @mcp.tool() def add(a: int, b: int) -> int: - """Add two numbers""" + """Add two numbers.""" return a + b -# Add a dynamic greeting resource @mcp.resource("greeting://{name}") -def get_greeting(name: str) -> str: - """Get a personalized greeting""" +def greeting(name: str) -> str: + """Greet someone by name.""" return f"Hello, {name}!" - - -# Add a prompt -@mcp.prompt() -def greet_user(name: str, style: str = "friendly") -> str: - """Generate a greeting prompt""" - styles = { - "friendly": "Please write a warm, friendly greeting", - "formal": "Please write a formal, professional greeting", - "casual": "Please write a casual, relaxed greeting", - } - - return f"{styles.get(style, styles['friendly'])} for someone named {name}." - - -# Run with streamable HTTP transport -if __name__ == "__main__": - mcp.run(transport="streamable-http") ``` -_Full example: [examples/snippets/servers/fastmcp_quickstart.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/fastmcp_quickstart.py)_ +_Full example: [docs_src/index/tutorial001.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/docs_src/index/tutorial001.py)_ -You can install this server in [Claude Code](https://docs.claude.com/en/docs/claude-code/mcp) and interact with it right away. First, run the server: - -```bash -uv run --with mcp examples/snippets/servers/fastmcp_quickstart.py -``` - -Then add it to Claude Code: - -```bash -claude mcp add --transport http my-server http://localhost:8000/mcp -``` - -Alternatively, you can test it with the MCP Inspector. Start the server as above, then in a separate terminal: +That's a complete MCP server: one tool, one templated resource. Open it in the [MCP Inspector](https://github.com/modelcontextprotocol/inspector): ```bash -npx -y @modelcontextprotocol/inspector -``` - -In the inspector UI, connect to `http://localhost:8000/mcp`. - -## What is MCP? - -The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) lets you build servers that expose data and functionality to LLM applications in a secure, standardized way. Think of it like a web API, but specifically designed for LLM interactions. MCP servers can: - -- Expose data through **Resources** (think of these sort of like GET endpoints; they are used to load information into the LLM's context) -- Provide functionality through **Tools** (sort of like POST endpoints; they are used to execute code or otherwise produce a side effect) -- Define interaction patterns through **Prompts** (reusable templates for LLM interactions) -- And more! - -## Core Concepts - -### Server - -The FastMCP server is your core interface to the MCP protocol. It handles connection management, protocol compliance, and message routing: - - -```python -"""Example showing lifespan support for startup/shutdown with strong typing.""" - -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from dataclasses import dataclass - -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - - -# Mock database class for example -class Database: - """Mock database class for example.""" - - @classmethod - async def connect(cls) -> "Database": - """Connect to database.""" - return cls() - - async def disconnect(self) -> None: - """Disconnect from database.""" - pass - - def query(self) -> str: - """Execute a query.""" - return "Query result" - - -@dataclass -class AppContext: - """Application context with typed dependencies.""" - - db: Database - - -@asynccontextmanager -async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: - """Manage application lifecycle with type-safe context.""" - # Initialize on startup - db = await Database.connect() - try: - yield AppContext(db=db) - finally: - # Cleanup on shutdown - await db.disconnect() - - -# Pass lifespan to server -mcp = FastMCP("My App", lifespan=app_lifespan) - - -# Access type-safe lifespan context in tools -@mcp.tool() -def query_db(ctx: Context[ServerSession, AppContext]) -> str: - """Tool that uses initialized resources.""" - db = ctx.request_context.lifespan_context.db - return db.query() -``` - -_Full example: [examples/snippets/servers/lifespan_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/lifespan_example.py)_ - - -### Resources - -Resources are how you expose data to LLMs. They're similar to GET endpoints in a REST API - they provide data but shouldn't perform significant computation or have side effects: - - -```python -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP(name="Resource Example") - - -@mcp.resource("file://documents/{name}") -def read_document(name: str) -> str: - """Read a document by name.""" - # This would normally read from disk - return f"Content of {name}" - - -@mcp.resource("config://settings") -def get_settings() -> str: - """Get application settings.""" - return """{ - "theme": "dark", - "language": "en", - "debug": false -}""" -``` - -_Full example: [examples/snippets/servers/basic_resource.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/basic_resource.py)_ - - -### Tools - -Tools let LLMs take actions through your server. Unlike resources, tools are expected to perform computation and have side effects: - - -```python -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP(name="Tool Example") - - -@mcp.tool() -def sum(a: int, b: int) -> int: - """Add two numbers together.""" - return a + b - - -@mcp.tool() -def get_weather(city: str, unit: str = "celsius") -> str: - """Get weather for a city.""" - # This would normally call a weather API - return f"Weather in {city}: 22degrees{unit[0].upper()}" -``` - -_Full example: [examples/snippets/servers/basic_tool.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/basic_tool.py)_ - - -Tools can optionally receive a Context object by including a parameter with the `Context` type annotation. This context is automatically injected by the FastMCP framework and provides access to MCP capabilities: - - -```python -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - -mcp = FastMCP(name="Progress Example") - - -@mcp.tool() -async def long_running_task(task_name: str, ctx: Context[ServerSession, None], steps: int = 5) -> str: - """Execute a task with progress updates.""" - await ctx.info(f"Starting: {task_name}") - - for i in range(steps): - progress = (i + 1) / steps - await ctx.report_progress( - progress=progress, - total=1.0, - message=f"Step {i + 1}/{steps}", - ) - await ctx.debug(f"Completed step {i + 1}") - - return f"Task '{task_name}' completed" -``` - -_Full example: [examples/snippets/servers/tool_progress.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/tool_progress.py)_ - - -#### Structured Output - -Tools will return structured results by default, if their return type -annotation is compatible. Otherwise, they will return unstructured results. - -Structured output supports these return types: - -- Pydantic models (BaseModel subclasses) -- TypedDicts -- Dataclasses and other classes with type hints -- `dict[str, T]` (where T is any JSON-serializable type) -- Primitive types (str, int, float, bool, bytes, None) - wrapped in `{"result": value}` -- Generic types (list, tuple, Union, Optional, etc.) - wrapped in `{"result": value}` - -Classes without type hints cannot be serialized for structured output. Only -classes with properly annotated attributes will be converted to Pydantic models -for schema generation and validation. - -Structured results are automatically validated against the output schema -generated from the annotation. This ensures the tool returns well-typed, -validated data that clients can easily process. - -**Note:** For backward compatibility, unstructured results are also -returned. Unstructured results are provided for backward compatibility -with previous versions of the MCP specification, and are quirks-compatible -with previous versions of FastMCP in the current version of the SDK. - -**Note:** In cases where a tool function's return type annotation -causes the tool to be classified as structured _and this is undesirable_, -the classification can be suppressed by passing `structured_output=False` -to the `@tool` decorator. - -##### Advanced: Direct CallToolResult - -For full control over tool responses including the `_meta` field (for passing data to client applications without exposing it to the model), you can return `CallToolResult` directly: - - -```python -"""Example showing direct CallToolResult return for advanced control.""" - -from typing import Annotated - -from pydantic import BaseModel - -from mcp.server.fastmcp import FastMCP -from mcp.types import CallToolResult, TextContent - -mcp = FastMCP("CallToolResult Example") - - -class ValidationModel(BaseModel): - """Model for validating structured output.""" - - status: str - data: dict[str, int] - - -@mcp.tool() -def advanced_tool() -> CallToolResult: - """Return CallToolResult directly for full control including _meta field.""" - return CallToolResult( - content=[TextContent(type="text", text="Response visible to the model")], - _meta={"hidden": "data for client applications only"}, - ) - - -@mcp.tool() -def validated_tool() -> Annotated[CallToolResult, ValidationModel]: - """Return CallToolResult with structured output validation.""" - return CallToolResult( - content=[TextContent(type="text", text="Validated response")], - structuredContent={"status": "success", "data": {"result": 42}}, - _meta={"internal": "metadata"}, - ) - - -@mcp.tool() -def empty_result_tool() -> CallToolResult: - """For empty results, return CallToolResult with empty content.""" - return CallToolResult(content=[]) -``` - -_Full example: [examples/snippets/servers/direct_call_tool_result.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/direct_call_tool_result.py)_ - - -**Important:** `CallToolResult` must always be returned (no `Optional` or `Union`). For empty results, use `CallToolResult(content=[])`. For optional simple types, use `str | None` without `CallToolResult`. - - -```python -"""Example showing structured output with tools.""" - -from typing import TypedDict - -from pydantic import BaseModel, Field - -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP("Structured Output Example") - - -# Using Pydantic models for rich structured data -class WeatherData(BaseModel): - """Weather information structure.""" - - temperature: float = Field(description="Temperature in Celsius") - humidity: float = Field(description="Humidity percentage") - condition: str - wind_speed: float - - -@mcp.tool() -def get_weather(city: str) -> WeatherData: - """Get weather for a city - returns structured data.""" - # Simulated weather data - return WeatherData( - temperature=22.5, - humidity=45.0, - condition="sunny", - wind_speed=5.2, - ) - - -# Using TypedDict for simpler structures -class LocationInfo(TypedDict): - latitude: float - longitude: float - name: str - - -@mcp.tool() -def get_location(address: str) -> LocationInfo: - """Get location coordinates""" - return LocationInfo(latitude=51.5074, longitude=-0.1278, name="London, UK") - - -# Using dict[str, Any] for flexible schemas -@mcp.tool() -def get_statistics(data_type: str) -> dict[str, float]: - """Get various statistics""" - return {"mean": 42.5, "median": 40.0, "std_dev": 5.2} - - -# Ordinary classes with type hints work for structured output -class UserProfile: - name: str - age: int - email: str | None = None - - def __init__(self, name: str, age: int, email: str | None = None): - self.name = name - self.age = age - self.email = email - - -@mcp.tool() -def get_user(user_id: str) -> UserProfile: - """Get user profile - returns structured data""" - return UserProfile(name="Alice", age=30, email="alice@example.com") - - -# Classes WITHOUT type hints cannot be used for structured output -class UntypedConfig: - def __init__(self, setting1, setting2): # type: ignore[reportMissingParameterType] - self.setting1 = setting1 - self.setting2 = setting2 - - -@mcp.tool() -def get_config() -> UntypedConfig: - """This returns unstructured output - no schema generated""" - return UntypedConfig("value1", "value2") - - -# Lists and other types are wrapped automatically -@mcp.tool() -def list_cities() -> list[str]: - """Get a list of cities""" - return ["London", "Paris", "Tokyo"] - # Returns: {"result": ["London", "Paris", "Tokyo"]} - - -@mcp.tool() -def get_temperature(city: str) -> float: - """Get temperature as a simple float""" - return 22.5 - # Returns: {"result": 22.5} -``` - -_Full example: [examples/snippets/servers/structured_output.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/structured_output.py)_ - - -### Prompts - -Prompts are reusable templates that help LLMs interact with your server effectively: - - -```python -from mcp.server.fastmcp import FastMCP -from mcp.server.fastmcp.prompts import base - -mcp = FastMCP(name="Prompt Example") - - -@mcp.prompt(title="Code Review") -def review_code(code: str) -> str: - return f"Please review this code:\n\n{code}" - - -@mcp.prompt(title="Debug Assistant") -def debug_error(error: str) -> list[base.Message]: - return [ - base.UserMessage("I'm seeing this error:"), - base.UserMessage(error), - base.AssistantMessage("I'll help debug that. What have you tried so far?"), - ] -``` - -_Full example: [examples/snippets/servers/basic_prompt.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/basic_prompt.py)_ - - -### Icons - -MCP servers can provide icons for UI display. Icons can be added to the server implementation, tools, resources, and prompts: - -```python -from mcp.server.fastmcp import FastMCP, Icon - -# Create an icon from a file path or URL -icon = Icon( - src="icon.png", - mimeType="image/png", - sizes="64x64" -) - -# Add icons to server -mcp = FastMCP( - "My Server", - website_url="https://example.com", - icons=[icon] -) - -# Add icons to tools, resources, and prompts -@mcp.tool(icons=[icon]) -def my_tool(): - """Tool with an icon.""" - return "result" - -@mcp.resource("demo://resource", icons=[icon]) -def my_resource(): - """Resource with an icon.""" - return "content" -``` - -_Full example: [examples/fastmcp/icons_demo.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/fastmcp/icons_demo.py)_ - -### Images - -FastMCP provides an `Image` class that automatically handles image data: - - -```python -"""Example showing image handling with FastMCP.""" - -from PIL import Image as PILImage - -from mcp.server.fastmcp import FastMCP, Image - -mcp = FastMCP("Image Example") - - -@mcp.tool() -def create_thumbnail(image_path: str) -> Image: - """Create a thumbnail from an image""" - img = PILImage.open(image_path) - img.thumbnail((100, 100)) - return Image(data=img.tobytes(), format="png") -``` - -_Full example: [examples/snippets/servers/images.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/images.py)_ - - -### Context - -The Context object is automatically injected into tool and resource functions that request it via type hints. It provides access to MCP capabilities like logging, progress reporting, resource reading, user interaction, and request metadata. - -#### Getting Context in Functions - -To use context in a tool or resource function, add a parameter with the `Context` type annotation: - -```python -from mcp.server.fastmcp import Context, FastMCP - -mcp = FastMCP(name="Context Example") - - -@mcp.tool() -async def my_tool(x: int, ctx: Context) -> str: - """Tool that uses context capabilities.""" - # The context parameter can have any name as long as it's type-annotated - return await process_with_context(x, ctx) +uv run mcp dev server.py ``` -#### Context Properties and Methods - -The Context object provides the following capabilities: - -- `ctx.request_id` - Unique ID for the current request -- `ctx.client_id` - Client ID if available -- `ctx.fastmcp` - Access to the FastMCP server instance (see [FastMCP Properties](#fastmcp-properties)) -- `ctx.session` - Access to the underlying session for advanced communication (see [Session Properties and Methods](#session-properties-and-methods)) -- `ctx.request_context` - Access to request-specific data and lifespan resources (see [Request Context Properties](#request-context-properties)) -- `await ctx.debug(message)` - Send debug log message -- `await ctx.info(message)` - Send info log message -- `await ctx.warning(message)` - Send warning log message -- `await ctx.error(message)` - Send error log message -- `await ctx.log(level, message, logger_name=None)` - Send log with custom level -- `await ctx.report_progress(progress, total=None, message=None)` - Report operation progress -- `await ctx.read_resource(uri)` - Read a resource by URI -- `await ctx.elicit(message, schema)` - Request additional information from user with validation - - -```python -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - -mcp = FastMCP(name="Progress Example") - - -@mcp.tool() -async def long_running_task(task_name: str, ctx: Context[ServerSession, None], steps: int = 5) -> str: - """Execute a task with progress updates.""" - await ctx.info(f"Starting: {task_name}") - - for i in range(steps): - progress = (i + 1) / steps - await ctx.report_progress( - progress=progress, - total=1.0, - message=f"Step {i + 1}/{steps}", - ) - await ctx.debug(f"Completed step {i + 1}") - - return f"Task '{task_name}' completed" -``` +Call `add` with `a=1`, `b=2` and you get `3` back. -_Full example: [examples/snippets/servers/tool_progress.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/tool_progress.py)_ - +Notice what you did **not** write: no JSON Schema (`a: int, b: int` _is_ the schema), no request parsing, no validation code, no protocol handling. Two type-hinted Python functions and a docstring. -### Completions +[The tutorial](https://py.sdk.modelcontextprotocol.io/v2/tutorial/) takes it from here. -MCP supports providing completion suggestions for prompt arguments and resource template parameters. With the context parameter, servers can provide completions based on previously resolved values: +## A client in 10 lines -Client usage: +The same package is a full MCP **client**. `Client` connects to a URL, a stdio subprocess, a custom transport, or (for tests) straight to a server object in memory with no transport at all: - ```python -""" -cd to the `examples/snippets` directory and run: - uv run completion-client -""" - import asyncio -import os - -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client -from mcp.types import PromptReference, ResourceTemplateReference - -# Create server parameters for stdio connection -server_params = StdioServerParameters( - command="uv", # Using uv to run the server - args=["run", "server", "completion", "stdio"], # Server with completion support - env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, -) - - -async def run(): - """Run the completion client example.""" - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - # Initialize the connection - await session.initialize() - - # List available resource templates - templates = await session.list_resource_templates() - print("Available resource templates:") - for template in templates.resourceTemplates: - print(f" - {template.uriTemplate}") - - # List available prompts - prompts = await session.list_prompts() - print("\nAvailable prompts:") - for prompt in prompts.prompts: - print(f" - {prompt.name}") - # Complete resource template arguments - if templates.resourceTemplates: - template = templates.resourceTemplates[0] - print(f"\nCompleting arguments for resource template: {template.uriTemplate}") +from mcp import Client - # Complete without context - result = await session.complete( - ref=ResourceTemplateReference(type="ref/resource", uri=template.uriTemplate), - argument={"name": "owner", "value": "model"}, - ) - print(f"Completions for 'owner' starting with 'model': {result.completion.values}") +from server import mcp - # Complete with context - repo suggestions based on owner - result = await session.complete( - ref=ResourceTemplateReference(type="ref/resource", uri=template.uriTemplate), - argument={"name": "repo", "value": ""}, - context_arguments={"owner": "modelcontextprotocol"}, - ) - print(f"Completions for 'repo' with owner='modelcontextprotocol': {result.completion.values}") - # Complete prompt arguments - if prompts.prompts: - prompt_name = prompts.prompts[0].name - print(f"\nCompleting arguments for prompt: {prompt_name}") +async def main() -> None: + async with Client(mcp) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + print(result.structured_content) # {'result': 3} - result = await session.complete( - ref=PromptReference(type="ref/prompt", name=prompt_name), - argument={"name": "style", "value": ""}, - ) - print(f"Completions for 'style' argument: {result.completion.values}") - -def main(): - """Entry point for the completion client.""" - asyncio.run(run()) - - -if __name__ == "__main__": - main() +asyncio.run(main()) ``` -_Full example: [examples/snippets/clients/completion_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/completion_client.py)_ - -### Elicitation - -Request additional information from users. This example shows an Elicitation during a Tool Call: +Swap `mcp` for `"http://localhost:8000/mcp"` and the exact same code talks to a remote server. - -```python -"""Elicitation examples demonstrating form and URL mode elicitation. +## Contributing -Form mode elicitation collects structured, non-sensitive data through a schema. -URL mode elicitation directs users to external URLs for sensitive operations -like OAuth flows, credential collection, or payment processing. -""" +We are passionate about supporting contributors of all levels of experience and would love to see you get involved in the project. See the [contributing guide](https://github.com/modelcontextprotocol/python-sdk/blob/main/CONTRIBUTING.md) to get started. -import uuid +## License -from pydantic import BaseModel, Field +This project is licensed under the MIT License. See the [LICENSE](https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE) file for details. -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession -from mcp.shared.exceptions import UrlElicitationRequiredError -from mcp.types import ElicitRequestURLParams - -mcp = FastMCP(name="Elicitation Example") - - -class BookingPreferences(BaseModel): - """Schema for collecting user preferences.""" - - checkAlternative: bool = Field(description="Would you like to check another date?") - alternativeDate: str = Field( - default="2024-12-26", - description="Alternative date (YYYY-MM-DD)", - ) - - -@mcp.tool() -async def book_table(date: str, time: str, party_size: int, ctx: Context[ServerSession, None]) -> str: - """Book a table with date availability check. - - This demonstrates form mode elicitation for collecting non-sensitive user input. - """ - # Check if date is available - if date == "2024-12-25": - # Date unavailable - ask user for alternative - result = await ctx.elicit( - message=(f"No tables available for {party_size} on {date}. Would you like to try another date?"), - schema=BookingPreferences, - ) - - if result.action == "accept" and result.data: - if result.data.checkAlternative: - return f"[SUCCESS] Booked for {result.data.alternativeDate}" - return "[CANCELLED] No booking made" - return "[CANCELLED] Booking cancelled" - - # Date available - return f"[SUCCESS] Booked for {date} at {time}" - - -@mcp.tool() -async def secure_payment(amount: float, ctx: Context[ServerSession, None]) -> str: - """Process a secure payment requiring URL confirmation. - - This demonstrates URL mode elicitation using ctx.elicit_url() for - operations that require out-of-band user interaction. - """ - elicitation_id = str(uuid.uuid4()) - - result = await ctx.elicit_url( - message=f"Please confirm payment of ${amount:.2f}", - url=f"https://payments.example.com/confirm?amount={amount}&id={elicitation_id}", - elicitation_id=elicitation_id, - ) - - if result.action == "accept": - # In a real app, the payment confirmation would happen out-of-band - # and you'd verify the payment status from your backend - return f"Payment of ${amount:.2f} initiated - check your browser to complete" - elif result.action == "decline": - return "Payment declined by user" - return "Payment cancelled" - - -@mcp.tool() -async def connect_service(service_name: str, ctx: Context[ServerSession, None]) -> str: - """Connect to a third-party service requiring OAuth authorization. - - This demonstrates the "throw error" pattern using UrlElicitationRequiredError. - Use this pattern when the tool cannot proceed without user authorization. - """ - elicitation_id = str(uuid.uuid4()) - - # Raise UrlElicitationRequiredError to signal that the client must complete - # a URL elicitation before this request can be processed. - # The MCP framework will convert this to a -32042 error response. - raise UrlElicitationRequiredError( - [ - ElicitRequestURLParams( - mode="url", - message=f"Authorization required to connect to {service_name}", - url=f"https://{service_name}.example.com/oauth/authorize?elicit={elicitation_id}", - elicitationId=elicitation_id, - ) - ] - ) -``` - -_Full example: [examples/snippets/servers/elicitation.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/elicitation.py)_ - - -Elicitation schemas support default values for all field types. Default values are automatically included in the JSON schema sent to clients, allowing them to pre-populate forms. - -The `elicit()` method returns an `ElicitationResult` with: - -- `action`: "accept", "decline", or "cancel" -- `data`: The validated response (only when accepted) -- `validation_error`: Any validation error message - -### Sampling - -Tools can interact with LLMs through sampling (generating text): - - -```python -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession -from mcp.types import SamplingMessage, TextContent - -mcp = FastMCP(name="Sampling Example") - - -@mcp.tool() -async def generate_poem(topic: str, ctx: Context[ServerSession, None]) -> str: - """Generate a poem using LLM sampling.""" - prompt = f"Write a short poem about {topic}" - - result = await ctx.session.create_message( - messages=[ - SamplingMessage( - role="user", - content=TextContent(type="text", text=prompt), - ) - ], - max_tokens=100, - ) - - # Since we're not passing tools param, result.content is single content - if result.content.type == "text": - return result.content.text - return str(result.content) -``` - -_Full example: [examples/snippets/servers/sampling.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/sampling.py)_ - - -### Logging and Notifications - -Tools can send logs and notifications through the context: - - -```python -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - -mcp = FastMCP(name="Notifications Example") - - -@mcp.tool() -async def process_data(data: str, ctx: Context[ServerSession, None]) -> str: - """Process data with logging.""" - # Different log levels - await ctx.debug(f"Debug: Processing '{data}'") - await ctx.info("Info: Starting processing") - await ctx.warning("Warning: This is experimental") - await ctx.error("Error: (This is just a demo)") - - # Notify about resource changes - await ctx.session.send_resource_list_changed() - - return f"Processed: {data}" -``` - -_Full example: [examples/snippets/servers/notifications.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/notifications.py)_ - - -### Authentication - -Authentication can be used by servers that want to expose tools accessing protected resources. - -`mcp.server.auth` implements OAuth 2.1 resource server functionality, where MCP servers act as Resource Servers (RS) that validate tokens issued by separate Authorization Servers (AS). This follows the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) and implements RFC 9728 (Protected Resource Metadata) for AS discovery. - -MCP servers can use authentication by providing an implementation of the `TokenVerifier` protocol: - - -```python -""" -Run from the repository root: - uv run examples/snippets/servers/oauth_server.py -""" - -from pydantic import AnyHttpUrl - -from mcp.server.auth.provider import AccessToken, TokenVerifier -from mcp.server.auth.settings import AuthSettings -from mcp.server.fastmcp import FastMCP - - -class SimpleTokenVerifier(TokenVerifier): - """Simple token verifier for demonstration.""" - - async def verify_token(self, token: str) -> AccessToken | None: - pass # This is where you would implement actual token validation - - -# Create FastMCP instance as a Resource Server -mcp = FastMCP( - "Weather Service", - json_response=True, - # Token verifier for authentication - token_verifier=SimpleTokenVerifier(), - # Auth settings for RFC 9728 Protected Resource Metadata - auth=AuthSettings( - issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL - resource_server_url=AnyHttpUrl("http://localhost:3001"), # This server's URL - required_scopes=["user"], - ), -) - - -@mcp.tool() -async def get_weather(city: str = "London") -> dict[str, str]: - """Get weather data for a city""" - return { - "city": city, - "temperature": "22", - "condition": "Partly cloudy", - "humidity": "65%", - } - - -if __name__ == "__main__": - mcp.run(transport="streamable-http") -``` - -_Full example: [examples/snippets/servers/oauth_server.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/oauth_server.py)_ - - -For a complete example with separate Authorization Server and Resource Server implementations, see [`examples/servers/simple-auth/`](examples/servers/simple-auth/). - -**Architecture:** - -- **Authorization Server (AS)**: Handles OAuth flows, user authentication, and token issuance -- **Resource Server (RS)**: Your MCP server that validates tokens and serves protected resources -- **Client**: Discovers AS through RFC 9728, obtains tokens, and uses them with the MCP server - -See [TokenVerifier](src/mcp/server/auth/provider.py) for more details on implementing token validation. - -### FastMCP Properties - -The FastMCP server instance accessible via `ctx.fastmcp` provides access to server configuration and metadata: - -- `ctx.fastmcp.name` - The server's name as defined during initialization -- `ctx.fastmcp.instructions` - Server instructions/description provided to clients -- `ctx.fastmcp.website_url` - Optional website URL for the server -- `ctx.fastmcp.icons` - Optional list of icons for UI display -- `ctx.fastmcp.settings` - Complete server configuration object containing: - - `debug` - Debug mode flag - - `log_level` - Current logging level - - `host` and `port` - Server network configuration - - `mount_path`, `sse_path`, `streamable_http_path` - Transport paths - - `stateless_http` - Whether the server operates in stateless mode - - And other configuration options - -```python -@mcp.tool() -def server_info(ctx: Context) -> dict: - """Get information about the current server.""" - return { - "name": ctx.fastmcp.name, - "instructions": ctx.fastmcp.instructions, - "debug_mode": ctx.fastmcp.settings.debug, - "log_level": ctx.fastmcp.settings.log_level, - "host": ctx.fastmcp.settings.host, - "port": ctx.fastmcp.settings.port, - } -``` - -### Session Properties and Methods - -The session object accessible via `ctx.session` provides advanced control over client communication: - -- `ctx.session.client_params` - Client initialization parameters and declared capabilities -- `await ctx.session.send_log_message(level, data, logger)` - Send log messages with full control -- `await ctx.session.create_message(messages, max_tokens)` - Request LLM sampling/completion -- `await ctx.session.send_progress_notification(token, progress, total, message)` - Direct progress updates -- `await ctx.session.send_resource_updated(uri)` - Notify clients that a specific resource changed -- `await ctx.session.send_resource_list_changed()` - Notify clients that the resource list changed -- `await ctx.session.send_tool_list_changed()` - Notify clients that the tool list changed -- `await ctx.session.send_prompt_list_changed()` - Notify clients that the prompt list changed - -```python -@mcp.tool() -async def notify_data_update(resource_uri: str, ctx: Context) -> str: - """Update data and notify clients of the change.""" - # Perform data update logic here - - # Notify clients that this specific resource changed - await ctx.session.send_resource_updated(AnyUrl(resource_uri)) - - # If this affects the overall resource list, notify about that too - await ctx.session.send_resource_list_changed() - - return f"Updated {resource_uri} and notified clients" -``` - -### Request Context Properties - -The request context accessible via `ctx.request_context` contains request-specific information and resources: - -- `ctx.request_context.lifespan_context` - Access to resources initialized during server startup - - Database connections, configuration objects, shared services - - Type-safe access to resources defined in your server's lifespan function -- `ctx.request_context.meta` - Request metadata from the client including: - - `progressToken` - Token for progress notifications - - Other client-provided metadata -- `ctx.request_context.request` - The original MCP request object for advanced processing -- `ctx.request_context.request_id` - Unique identifier for this request - -```python -# Example with typed lifespan context -@dataclass -class AppContext: - db: Database - config: AppConfig - -@mcp.tool() -def query_with_config(query: str, ctx: Context) -> str: - """Execute a query using shared database and configuration.""" - # Access typed lifespan context - app_ctx: AppContext = ctx.request_context.lifespan_context - - # Use shared resources - connection = app_ctx.db - settings = app_ctx.config - - # Execute query with configuration - result = connection.execute(query, timeout=settings.query_timeout) - return str(result) -``` - -_Full lifespan example: [examples/snippets/servers/lifespan_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/lifespan_example.py)_ - -## Running Your Server - -### Development Mode - -The fastest way to test and debug your server is with the MCP Inspector: - -```bash -uv run mcp dev server.py - -# Add dependencies -uv run mcp dev server.py --with pandas --with numpy - -# Mount local code -uv run mcp dev server.py --with-editable . -``` - -### Claude Desktop Integration - -Once your server is ready, install it in Claude Desktop: - -```bash -uv run mcp install server.py - -# Custom name -uv run mcp install server.py --name "My Analytics Server" - -# Environment variables -uv run mcp install server.py -v API_KEY=abc123 -v DB_URL=postgres://... -uv run mcp install server.py -f .env -``` - -### Direct Execution - -For advanced scenarios like custom deployments: - - -```python -"""Example showing direct execution of an MCP server. - -This is the simplest way to run an MCP server directly. -cd to the `examples/snippets` directory and run: - uv run direct-execution-server - or - python servers/direct_execution.py -""" - -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP("My App") - - -@mcp.tool() -def hello(name: str = "World") -> str: - """Say hello to someone.""" - return f"Hello, {name}!" - - -def main(): - """Entry point for the direct execution server.""" - mcp.run() - - -if __name__ == "__main__": - main() -``` - -_Full example: [examples/snippets/servers/direct_execution.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/direct_execution.py)_ - - -Run it with: - -```bash -python servers/direct_execution.py -# or -uv run mcp run servers/direct_execution.py -``` - -Note that `uv run mcp run` or `uv run mcp dev` only supports server using FastMCP and not the low-level server variant. - -### Streamable HTTP Transport - -> **Note**: Streamable HTTP transport is the recommended transport for production deployments. Use `stateless_http=True` and `json_response=True` for optimal scalability. - - -```python -""" -Run from the repository root: - uv run examples/snippets/servers/streamable_config.py -""" - -from mcp.server.fastmcp import FastMCP - -# Stateless server with JSON responses (recommended) -mcp = FastMCP("StatelessServer", stateless_http=True, json_response=True) - -# Other configuration options: -# Stateless server with SSE streaming responses -# mcp = FastMCP("StatelessServer", stateless_http=True) - -# Stateful server with session persistence -# mcp = FastMCP("StatefulServer") - - -# Add a simple tool to demonstrate the server -@mcp.tool() -def greet(name: str = "World") -> str: - """Greet someone by name.""" - return f"Hello, {name}!" - - -# Run server with streamable_http transport -if __name__ == "__main__": - mcp.run(transport="streamable-http") -``` - -_Full example: [examples/snippets/servers/streamable_config.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/streamable_config.py)_ - - -You can mount multiple FastMCP servers in a Starlette application: - - -```python -""" -Run from the repository root: - uvicorn examples.snippets.servers.streamable_starlette_mount:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.fastmcp import FastMCP - -# Create the Echo server -echo_mcp = FastMCP(name="EchoServer", stateless_http=True, json_response=True) - - -@echo_mcp.tool() -def echo(message: str) -> str: - """A simple echo tool""" - return f"Echo: {message}" - - -# Create the Math server -math_mcp = FastMCP(name="MathServer", stateless_http=True, json_response=True) - - -@math_mcp.tool() -def add_two(n: int) -> int: - """Tool to add two to the input""" - return n + 2 - - -# Create a combined lifespan to manage both session managers -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with contextlib.AsyncExitStack() as stack: - await stack.enter_async_context(echo_mcp.session_manager.run()) - await stack.enter_async_context(math_mcp.session_manager.run()) - yield - - -# Create the Starlette app and mount the MCP servers -app = Starlette( - routes=[ - Mount("/echo", echo_mcp.streamable_http_app()), - Mount("/math", math_mcp.streamable_http_app()), - ], - lifespan=lifespan, -) - -# Note: Clients connect to http://localhost:8000/echo/mcp and http://localhost:8000/math/mcp -# To mount at the root of each path (e.g., /echo instead of /echo/mcp): -# echo_mcp.settings.streamable_http_path = "/" -# math_mcp.settings.streamable_http_path = "/" -``` - -_Full example: [examples/snippets/servers/streamable_starlette_mount.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/streamable_starlette_mount.py)_ - - -For low level server with Streamable HTTP implementations, see: - -- Stateful server: [`examples/servers/simple-streamablehttp/`](examples/servers/simple-streamablehttp/) -- Stateless server: [`examples/servers/simple-streamablehttp-stateless/`](examples/servers/simple-streamablehttp-stateless/) - -The streamable HTTP transport supports: - -- Stateful and stateless operation modes -- Resumability with event stores -- JSON or SSE response formats -- Better scalability for multi-node deployments - -#### CORS Configuration for Browser-Based Clients - -If you'd like your server to be accessible by browser-based MCP clients, you'll need to configure CORS headers. The `Mcp-Session-Id` header must be exposed for browser clients to access it: - -```python -from starlette.applications import Starlette -from starlette.middleware.cors import CORSMiddleware - -# Create your Starlette app first -starlette_app = Starlette(routes=[...]) - -# Then wrap it with CORS middleware -starlette_app = CORSMiddleware( - starlette_app, - allow_origins=["*"], # Configure appropriately for production - allow_methods=["GET", "POST", "DELETE"], # MCP streamable HTTP methods - expose_headers=["Mcp-Session-Id"], -) -``` - -This configuration is necessary because: - -- The MCP streamable HTTP transport uses the `Mcp-Session-Id` header for session management -- Browsers restrict access to response headers unless explicitly exposed via CORS -- Without this configuration, browser-based clients won't be able to read the session ID from initialization responses - -### Mounting to an Existing ASGI Server - -By default, SSE servers are mounted at `/sse` and Streamable HTTP servers are mounted at `/mcp`. You can customize these paths using the methods described below. - -For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes). - -#### StreamableHTTP servers - -You can mount the StreamableHTTP server to an existing ASGI server using the `streamable_http_app` method. This allows you to integrate the StreamableHTTP server with other ASGI applications. - -##### Basic mounting - - -```python -""" -Basic example showing how to mount StreamableHTTP server in Starlette. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_basic_mounting:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.fastmcp import FastMCP - -# Create MCP server -mcp = FastMCP("My App", json_response=True) - - -@mcp.tool() -def hello() -> str: - """A simple hello tool""" - return "Hello from MCP!" - - -# Create a lifespan context manager to run the session manager -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with mcp.session_manager.run(): - yield - - -# Mount the StreamableHTTP server to the existing ASGI server -app = Starlette( - routes=[ - Mount("/", app=mcp.streamable_http_app()), - ], - lifespan=lifespan, -) -``` - -_Full example: [examples/snippets/servers/streamable_http_basic_mounting.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/streamable_http_basic_mounting.py)_ - - -##### Host-based routing - - -```python -""" -Example showing how to mount StreamableHTTP server using Host-based routing. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_host_mounting:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Host - -from mcp.server.fastmcp import FastMCP - -# Create MCP server -mcp = FastMCP("MCP Host App", json_response=True) - - -@mcp.tool() -def domain_info() -> str: - """Get domain-specific information""" - return "This is served from mcp.acme.corp" - - -# Create a lifespan context manager to run the session manager -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with mcp.session_manager.run(): - yield - - -# Mount using Host-based routing -app = Starlette( - routes=[ - Host("mcp.acme.corp", app=mcp.streamable_http_app()), - ], - lifespan=lifespan, -) -``` - -_Full example: [examples/snippets/servers/streamable_http_host_mounting.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/streamable_http_host_mounting.py)_ - - -##### Multiple servers with path configuration - - -```python -""" -Example showing how to mount multiple StreamableHTTP servers with path configuration. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_multiple_servers:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.fastmcp import FastMCP - -# Create multiple MCP servers -api_mcp = FastMCP("API Server", json_response=True) -chat_mcp = FastMCP("Chat Server", json_response=True) - - -@api_mcp.tool() -def api_status() -> str: - """Get API status""" - return "API is running" - - -@chat_mcp.tool() -def send_message(message: str) -> str: - """Send a chat message""" - return f"Message sent: {message}" - - -# Configure servers to mount at the root of each path -# This means endpoints will be at /api and /chat instead of /api/mcp and /chat/mcp -api_mcp.settings.streamable_http_path = "/" -chat_mcp.settings.streamable_http_path = "/" - - -# Create a combined lifespan to manage both session managers -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with contextlib.AsyncExitStack() as stack: - await stack.enter_async_context(api_mcp.session_manager.run()) - await stack.enter_async_context(chat_mcp.session_manager.run()) - yield - - -# Mount the servers -app = Starlette( - routes=[ - Mount("/api", app=api_mcp.streamable_http_app()), - Mount("/chat", app=chat_mcp.streamable_http_app()), - ], - lifespan=lifespan, -) -``` - -_Full example: [examples/snippets/servers/streamable_http_multiple_servers.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/streamable_http_multiple_servers.py)_ - - -##### Path configuration at initialization - - -```python -""" -Example showing path configuration during FastMCP initialization. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_path_config:app --reload -""" - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.fastmcp import FastMCP - -# Configure streamable_http_path during initialization -# This server will mount at the root of wherever it's mounted -mcp_at_root = FastMCP( - "My Server", - json_response=True, - streamable_http_path="/", -) - - -@mcp_at_root.tool() -def process_data(data: str) -> str: - """Process some data""" - return f"Processed: {data}" - - -# Mount at /process - endpoints will be at /process instead of /process/mcp -app = Starlette( - routes=[ - Mount("/process", app=mcp_at_root.streamable_http_app()), - ] -) -``` - -_Full example: [examples/snippets/servers/streamable_http_path_config.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/streamable_http_path_config.py)_ - - -#### SSE servers - -> **Note**: SSE transport is being superseded by [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http). - -You can mount the SSE server to an existing ASGI server using the `sse_app` method. This allows you to integrate the SSE server with other ASGI applications. - -```python -from starlette.applications import Starlette -from starlette.routing import Mount, Host -from mcp.server.fastmcp import FastMCP - - -mcp = FastMCP("My App") - -# Mount the SSE server to the existing ASGI server -app = Starlette( - routes=[ - Mount('/', app=mcp.sse_app()), - ] -) - -# or dynamically mount as host -app.router.routes.append(Host('mcp.acme.corp', app=mcp.sse_app())) -``` - -When mounting multiple MCP servers under different paths, you can configure the mount path in several ways: - -```python -from starlette.applications import Starlette -from starlette.routing import Mount -from mcp.server.fastmcp import FastMCP - -# Create multiple MCP servers -github_mcp = FastMCP("GitHub API") -browser_mcp = FastMCP("Browser") -curl_mcp = FastMCP("Curl") -search_mcp = FastMCP("Search") - -# Method 1: Configure mount paths via settings (recommended for persistent configuration) -github_mcp.settings.mount_path = "/github" -browser_mcp.settings.mount_path = "/browser" - -# Method 2: Pass mount path directly to sse_app (preferred for ad-hoc mounting) -# This approach doesn't modify the server's settings permanently - -# Create Starlette app with multiple mounted servers -app = Starlette( - routes=[ - # Using settings-based configuration - Mount("/github", app=github_mcp.sse_app()), - Mount("/browser", app=browser_mcp.sse_app()), - # Using direct mount path parameter - Mount("/curl", app=curl_mcp.sse_app("/curl")), - Mount("/search", app=search_mcp.sse_app("/search")), - ] -) - -# Method 3: For direct execution, you can also pass the mount path to run() -if __name__ == "__main__": - search_mcp.run(transport="sse", mount_path="/search") -``` - -For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes). - -## Advanced Usage - -### Low-Level Server - -For more control, you can use the low-level server implementation directly. This gives you full access to the protocol and allows you to customize every aspect of your server, including lifecycle management through the lifespan API: - - -```python -""" -Run from the repository root: - uv run examples/snippets/servers/lowlevel/lifespan.py -""" - -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from typing import Any - -import mcp.server.stdio -import mcp.types as types -from mcp.server.lowlevel import NotificationOptions, Server -from mcp.server.models import InitializationOptions - - -# Mock database class for example -class Database: - """Mock database class for example.""" - - @classmethod - async def connect(cls) -> "Database": - """Connect to database.""" - print("Database connected") - return cls() - - async def disconnect(self) -> None: - """Disconnect from database.""" - print("Database disconnected") - - async def query(self, query_str: str) -> list[dict[str, str]]: - """Execute a query.""" - # Simulate database query - return [{"id": "1", "name": "Example", "query": query_str}] - - -@asynccontextmanager -async def server_lifespan(_server: Server) -> AsyncIterator[dict[str, Any]]: - """Manage server startup and shutdown lifecycle.""" - # Initialize resources on startup - db = await Database.connect() - try: - yield {"db": db} - finally: - # Clean up on shutdown - await db.disconnect() - - -# Pass lifespan to server -server = Server("example-server", lifespan=server_lifespan) - - -@server.list_tools() -async def handle_list_tools() -> list[types.Tool]: - """List available tools.""" - return [ - types.Tool( - name="query_db", - description="Query the database", - inputSchema={ - "type": "object", - "properties": {"query": {"type": "string", "description": "SQL query to execute"}}, - "required": ["query"], - }, - ) - ] - - -@server.call_tool() -async def query_db(name: str, arguments: dict[str, Any]) -> list[types.TextContent]: - """Handle database query tool call.""" - if name != "query_db": - raise ValueError(f"Unknown tool: {name}") - - # Access lifespan context - ctx = server.request_context - db = ctx.lifespan_context["db"] - - # Execute query - results = await db.query(arguments["query"]) - - return [types.TextContent(type="text", text=f"Query results: {results}")] - - -async def run(): - """Run the server with lifespan management.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - InitializationOptions( - server_name="example-server", - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) - - -if __name__ == "__main__": - import asyncio - - asyncio.run(run()) -``` - -_Full example: [examples/snippets/servers/lowlevel/lifespan.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/lowlevel/lifespan.py)_ - - -The lifespan API provides: - -- A way to initialize resources when the server starts and clean them up when it stops -- Access to initialized resources through the request context in handlers -- Type-safe context passing between lifespan and request handlers - - -```python -""" -Run from the repository root: -uv run examples/snippets/servers/lowlevel/basic.py -""" - -import asyncio - -import mcp.server.stdio -import mcp.types as types -from mcp.server.lowlevel import NotificationOptions, Server -from mcp.server.models import InitializationOptions - -# Create a server instance -server = Server("example-server") - - -@server.list_prompts() -async def handle_list_prompts() -> list[types.Prompt]: - """List available prompts.""" - return [ - types.Prompt( - name="example-prompt", - description="An example prompt template", - arguments=[types.PromptArgument(name="arg1", description="Example argument", required=True)], - ) - ] - - -@server.get_prompt() -async def handle_get_prompt(name: str, arguments: dict[str, str] | None) -> types.GetPromptResult: - """Get a specific prompt by name.""" - if name != "example-prompt": - raise ValueError(f"Unknown prompt: {name}") - - arg1_value = (arguments or {}).get("arg1", "default") - - return types.GetPromptResult( - description="Example prompt", - messages=[ - types.PromptMessage( - role="user", - content=types.TextContent(type="text", text=f"Example prompt text with argument: {arg1_value}"), - ) - ], - ) - - -async def run(): - """Run the basic low-level server.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - InitializationOptions( - server_name="example", - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) - - -if __name__ == "__main__": - asyncio.run(run()) -``` - -_Full example: [examples/snippets/servers/lowlevel/basic.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/lowlevel/basic.py)_ - - -Caution: The `uv run mcp run` and `uv run mcp dev` tool doesn't support low-level server. - -#### Structured Output Support - -The low-level server supports structured output for tools, allowing you to return both human-readable content and machine-readable structured data. Tools can define an `outputSchema` to validate their structured output: - - -```python -""" -Run from the repository root: - uv run examples/snippets/servers/lowlevel/structured_output.py -""" - -import asyncio -from typing import Any - -import mcp.server.stdio -import mcp.types as types -from mcp.server.lowlevel import NotificationOptions, Server -from mcp.server.models import InitializationOptions - -server = Server("example-server") - - -@server.list_tools() -async def list_tools() -> list[types.Tool]: - """List available tools with structured output schemas.""" - return [ - types.Tool( - name="get_weather", - description="Get current weather for a city", - inputSchema={ - "type": "object", - "properties": {"city": {"type": "string", "description": "City name"}}, - "required": ["city"], - }, - outputSchema={ - "type": "object", - "properties": { - "temperature": {"type": "number", "description": "Temperature in Celsius"}, - "condition": {"type": "string", "description": "Weather condition"}, - "humidity": {"type": "number", "description": "Humidity percentage"}, - "city": {"type": "string", "description": "City name"}, - }, - "required": ["temperature", "condition", "humidity", "city"], - }, - ) - ] - - -@server.call_tool() -async def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]: - """Handle tool calls with structured output.""" - if name == "get_weather": - city = arguments["city"] - - # Simulated weather data - in production, call a weather API - weather_data = { - "temperature": 22.5, - "condition": "partly cloudy", - "humidity": 65, - "city": city, # Include the requested city - } - - # low-level server will validate structured output against the tool's - # output schema, and additionally serialize it into a TextContent block - # for backwards compatibility with pre-2025-06-18 clients. - return weather_data - else: - raise ValueError(f"Unknown tool: {name}") - - -async def run(): - """Run the structured output server.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - InitializationOptions( - server_name="structured-output-example", - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) - - -if __name__ == "__main__": - asyncio.run(run()) -``` - -_Full example: [examples/snippets/servers/lowlevel/structured_output.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/lowlevel/structured_output.py)_ - - -Tools can return data in four ways: - -1. **Content only**: Return a list of content blocks (default behavior before spec revision 2025-06-18) -2. **Structured data only**: Return a dictionary that will be serialized to JSON (Introduced in spec revision 2025-06-18) -3. **Both**: Return a tuple of (content, structured_data) preferred option to use for backwards compatibility -4. **Direct CallToolResult**: Return `CallToolResult` directly for full control (including `_meta` field) - -When an `outputSchema` is defined, the server automatically validates the structured output against the schema. This ensures type safety and helps catch errors early. - -##### Returning CallToolResult Directly - -For full control over the response including the `_meta` field (for passing data to client applications without exposing it to the model), return `CallToolResult` directly: - - -```python -""" -Run from the repository root: - uv run examples/snippets/servers/lowlevel/direct_call_tool_result.py -""" - -import asyncio -from typing import Any - -import mcp.server.stdio -import mcp.types as types -from mcp.server.lowlevel import NotificationOptions, Server -from mcp.server.models import InitializationOptions - -server = Server("example-server") - - -@server.list_tools() -async def list_tools() -> list[types.Tool]: - """List available tools.""" - return [ - types.Tool( - name="advanced_tool", - description="Tool with full control including _meta field", - inputSchema={ - "type": "object", - "properties": {"message": {"type": "string"}}, - "required": ["message"], - }, - ) - ] - - -@server.call_tool() -async def handle_call_tool(name: str, arguments: dict[str, Any]) -> types.CallToolResult: - """Handle tool calls by returning CallToolResult directly.""" - if name == "advanced_tool": - message = str(arguments.get("message", "")) - return types.CallToolResult( - content=[types.TextContent(type="text", text=f"Processed: {message}")], - structuredContent={"result": "success", "message": message}, - _meta={"hidden": "data for client applications only"}, - ) - - raise ValueError(f"Unknown tool: {name}") - - -async def run(): - """Run the server.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - InitializationOptions( - server_name="example", - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) - - -if __name__ == "__main__": - asyncio.run(run()) -``` - -_Full example: [examples/snippets/servers/lowlevel/direct_call_tool_result.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/lowlevel/direct_call_tool_result.py)_ - - -**Note:** When returning `CallToolResult`, you bypass the automatic content/structured conversion. You must construct the complete response yourself. - -### Pagination (Advanced) - -For servers that need to handle large datasets, the low-level server provides paginated versions of list operations. This is an optional optimization - most servers won't need pagination unless they're dealing with hundreds or thousands of items. - -#### Server-side Implementation - - -```python -""" -Example of implementing pagination with MCP server decorators. -""" - -from pydantic import AnyUrl - -import mcp.types as types -from mcp.server.lowlevel import Server - -# Initialize the server -server = Server("paginated-server") - -# Sample data to paginate -ITEMS = [f"Item {i}" for i in range(1, 101)] # 100 items - - -@server.list_resources() -async def list_resources_paginated(request: types.ListResourcesRequest) -> types.ListResourcesResult: - """List resources with pagination support.""" - page_size = 10 - - # Extract cursor from request params - cursor = request.params.cursor if request.params is not None else None - - # Parse cursor to get offset - start = 0 if cursor is None else int(cursor) - end = start + page_size - - # Get page of resources - page_items = [ - types.Resource(uri=AnyUrl(f"resource://items/{item}"), name=item, description=f"Description for {item}") - for item in ITEMS[start:end] - ] - - # Determine next cursor - next_cursor = str(end) if end < len(ITEMS) else None - - return types.ListResourcesResult(resources=page_items, nextCursor=next_cursor) -``` - -_Full example: [examples/snippets/servers/pagination_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/servers/pagination_example.py)_ - - -#### Client-side Consumption - - -```python -""" -Example of consuming paginated MCP endpoints from a client. -""" - -import asyncio - -from mcp.client.session import ClientSession -from mcp.client.stdio import StdioServerParameters, stdio_client -from mcp.types import PaginatedRequestParams, Resource - - -async def list_all_resources() -> None: - """Fetch all resources using pagination.""" - async with stdio_client(StdioServerParameters(command="uv", args=["run", "mcp-simple-pagination"])) as ( - read, - write, - ): - async with ClientSession(read, write) as session: - await session.initialize() - - all_resources: list[Resource] = [] - cursor = None - - while True: - # Fetch a page of resources - result = await session.list_resources(params=PaginatedRequestParams(cursor=cursor)) - all_resources.extend(result.resources) - - print(f"Fetched {len(result.resources)} resources") - - # Check if there are more pages - if result.nextCursor: - cursor = result.nextCursor - else: - break - - print(f"Total resources: {len(all_resources)}") - - -if __name__ == "__main__": - asyncio.run(list_all_resources()) -``` - -_Full example: [examples/snippets/clients/pagination_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/pagination_client.py)_ - - -#### Key Points - -- **Cursors are opaque strings** - the server defines the format (numeric offsets, timestamps, etc.) -- **Return `nextCursor=None`** when there are no more pages -- **Backward compatible** - clients that don't support pagination will still work (they'll just get the first page) -- **Flexible page sizes** - Each endpoint can define its own page size based on data characteristics - -See the [simple-pagination example](examples/servers/simple-pagination) for a complete implementation. - -### Writing MCP Clients - -The SDK provides a high-level client interface for connecting to MCP servers using various [transports](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports): - - -```python -""" -cd to the `examples/snippets/clients` directory and run: - uv run client -""" - -import asyncio -import os - -from pydantic import AnyUrl - -from mcp import ClientSession, StdioServerParameters, types -from mcp.client.stdio import stdio_client -from mcp.shared.context import RequestContext - -# Create server parameters for stdio connection -server_params = StdioServerParameters( - command="uv", # Using uv to run the server - args=["run", "server", "fastmcp_quickstart", "stdio"], # We're already in snippets dir - env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, -) - - -# Optional: create a sampling callback -async def handle_sampling_message( - context: RequestContext[ClientSession, None], params: types.CreateMessageRequestParams -) -> types.CreateMessageResult: - print(f"Sampling request: {params.messages}") - return types.CreateMessageResult( - role="assistant", - content=types.TextContent( - type="text", - text="Hello, world! from model", - ), - model="gpt-3.5-turbo", - stopReason="endTurn", - ) - - -async def run(): - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write, sampling_callback=handle_sampling_message) as session: - # Initialize the connection - await session.initialize() - - # List available prompts - prompts = await session.list_prompts() - print(f"Available prompts: {[p.name for p in prompts.prompts]}") - - # Get a prompt (greet_user prompt from fastmcp_quickstart) - if prompts.prompts: - prompt = await session.get_prompt("greet_user", arguments={"name": "Alice", "style": "friendly"}) - print(f"Prompt result: {prompt.messages[0].content}") - - # List available resources - resources = await session.list_resources() - print(f"Available resources: {[r.uri for r in resources.resources]}") - - # List available tools - tools = await session.list_tools() - print(f"Available tools: {[t.name for t in tools.tools]}") - - # Read a resource (greeting resource from fastmcp_quickstart) - resource_content = await session.read_resource(AnyUrl("greeting://World")) - content_block = resource_content.contents[0] - if isinstance(content_block, types.TextContent): - print(f"Resource content: {content_block.text}") - - # Call a tool (add tool from fastmcp_quickstart) - result = await session.call_tool("add", arguments={"a": 5, "b": 3}) - result_unstructured = result.content[0] - if isinstance(result_unstructured, types.TextContent): - print(f"Tool result: {result_unstructured.text}") - result_structured = result.structuredContent - print(f"Structured tool result: {result_structured}") - - -def main(): - """Entry point for the client script.""" - asyncio.run(run()) - - -if __name__ == "__main__": - main() -``` - -_Full example: [examples/snippets/clients/stdio_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/stdio_client.py)_ - - -Clients can also connect using [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http): - - -```python -""" -Run from the repository root: - uv run examples/snippets/clients/streamable_basic.py -""" - -import asyncio - -from mcp import ClientSession -from mcp.client.streamable_http import streamable_http_client - - -async def main(): - # Connect to a streamable HTTP server - async with streamable_http_client("http://localhost:8000/mcp") as ( - read_stream, - write_stream, - _, - ): - # Create a session using the client streams - async with ClientSession(read_stream, write_stream) as session: - # Initialize the connection - await session.initialize() - # List available tools - tools = await session.list_tools() - print(f"Available tools: {[tool.name for tool in tools.tools]}") - - -if __name__ == "__main__": - asyncio.run(main()) -``` - -_Full example: [examples/snippets/clients/streamable_basic.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/streamable_basic.py)_ - - -### Client Display Utilities - -When building MCP clients, the SDK provides utilities to help display human-readable names for tools, resources, and prompts: - - -```python -""" -cd to the `examples/snippets` directory and run: - uv run display-utilities-client -""" - -import asyncio -import os - -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client -from mcp.shared.metadata_utils import get_display_name - -# Create server parameters for stdio connection -server_params = StdioServerParameters( - command="uv", # Using uv to run the server - args=["run", "server", "fastmcp_quickstart", "stdio"], - env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, -) - - -async def display_tools(session: ClientSession): - """Display available tools with human-readable names""" - tools_response = await session.list_tools() - - for tool in tools_response.tools: - # get_display_name() returns the title if available, otherwise the name - display_name = get_display_name(tool) - print(f"Tool: {display_name}") - if tool.description: - print(f" {tool.description}") - - -async def display_resources(session: ClientSession): - """Display available resources with human-readable names""" - resources_response = await session.list_resources() - - for resource in resources_response.resources: - display_name = get_display_name(resource) - print(f"Resource: {display_name} ({resource.uri})") - - templates_response = await session.list_resource_templates() - for template in templates_response.resourceTemplates: - display_name = get_display_name(template) - print(f"Resource Template: {display_name}") - - -async def run(): - """Run the display utilities example.""" - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - # Initialize the connection - await session.initialize() - - print("=== Available Tools ===") - await display_tools(session) - - print("\n=== Available Resources ===") - await display_resources(session) - - -def main(): - """Entry point for the display utilities client.""" - asyncio.run(run()) - - -if __name__ == "__main__": - main() -``` - -_Full example: [examples/snippets/clients/display_utilities.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/display_utilities.py)_ - - -The `get_display_name()` function implements the proper precedence rules for displaying names: - -- For tools: `title` > `annotations.title` > `name` -- For other objects: `title` > `name` - -This ensures your client UI shows the most user-friendly names that servers provide. - -### OAuth Authentication for Clients - -The SDK includes [authorization support](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) for connecting to protected MCP servers: - - -```python -""" -Before running, specify running MCP RS server URL. -To spin up RS server locally, see - examples/servers/simple-auth/README.md - -cd to the `examples/snippets` directory and run: - uv run oauth-client -""" - -import asyncio -from urllib.parse import parse_qs, urlparse - -import httpx -from pydantic import AnyUrl - -from mcp import ClientSession -from mcp.client.auth import OAuthClientProvider, TokenStorage -from mcp.client.streamable_http import streamable_http_client -from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken - - -class InMemoryTokenStorage(TokenStorage): - """Demo In-memory token storage implementation.""" - - def __init__(self): - self.tokens: OAuthToken | None = None - self.client_info: OAuthClientInformationFull | None = None - - async def get_tokens(self) -> OAuthToken | None: - """Get stored tokens.""" - return self.tokens - - async def set_tokens(self, tokens: OAuthToken) -> None: - """Store tokens.""" - self.tokens = tokens - - async def get_client_info(self) -> OAuthClientInformationFull | None: - """Get stored client information.""" - return self.client_info - - async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: - """Store client information.""" - self.client_info = client_info - - -async def handle_redirect(auth_url: str) -> None: - print(f"Visit: {auth_url}") - - -async def handle_callback() -> tuple[str, str | None]: - callback_url = input("Paste callback URL: ") - params = parse_qs(urlparse(callback_url).query) - return params["code"][0], params.get("state", [None])[0] - - -async def main(): - """Run the OAuth client example.""" - oauth_auth = OAuthClientProvider( - server_url="http://localhost:8001", - client_metadata=OAuthClientMetadata( - client_name="Example MCP Client", - redirect_uris=[AnyUrl("http://localhost:3000/callback")], - grant_types=["authorization_code", "refresh_token"], - response_types=["code"], - scope="user", - ), - storage=InMemoryTokenStorage(), - redirect_handler=handle_redirect, - callback_handler=handle_callback, - ) - - async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: - async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write, _): - async with ClientSession(read, write) as session: - await session.initialize() - - tools = await session.list_tools() - print(f"Available tools: {[tool.name for tool in tools.tools]}") - - resources = await session.list_resources() - print(f"Available resources: {[r.uri for r in resources.resources]}") - - -def run(): - asyncio.run(main()) - - -if __name__ == "__main__": - run() -``` - -_Full example: [examples/snippets/clients/oauth_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/examples/snippets/clients/oauth_client.py)_ - - -For a complete working example, see [`examples/clients/simple-auth-client/`](examples/clients/simple-auth-client/). - -### Parsing Tool Results - -When calling tools through MCP, the `CallToolResult` object contains the tool's response in a structured format. Understanding how to parse this result is essential for properly handling tool outputs. - -```python -"""examples/snippets/clients/parsing_tool_results.py""" - -import asyncio - -from mcp import ClientSession, StdioServerParameters, types -from mcp.client.stdio import stdio_client - - -async def parse_tool_results(): - """Demonstrates how to parse different types of content in CallToolResult.""" - server_params = StdioServerParameters( - command="python", args=["path/to/mcp_server.py"] - ) - - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - # Example 1: Parsing text content - result = await session.call_tool("get_data", {"format": "text"}) - for content in result.content: - if isinstance(content, types.TextContent): - print(f"Text: {content.text}") - - # Example 2: Parsing structured content from JSON tools - result = await session.call_tool("get_user", {"id": "123"}) - if hasattr(result, "structuredContent") and result.structuredContent: - # Access structured data directly - user_data = result.structuredContent - print(f"User: {user_data.get('name')}, Age: {user_data.get('age')}") - - # Example 3: Parsing embedded resources - result = await session.call_tool("read_config", {}) - for content in result.content: - if isinstance(content, types.EmbeddedResource): - resource = content.resource - if isinstance(resource, types.TextResourceContents): - print(f"Config from {resource.uri}: {resource.text}") - elif isinstance(resource, types.BlobResourceContents): - print(f"Binary data from {resource.uri}") - - # Example 4: Parsing image content - result = await session.call_tool("generate_chart", {"data": [1, 2, 3]}) - for content in result.content: - if isinstance(content, types.ImageContent): - print(f"Image ({content.mimeType}): {len(content.data)} bytes") - - # Example 5: Handling errors - result = await session.call_tool("failing_tool", {}) - if result.isError: - print("Tool execution failed!") - for content in result.content: - if isinstance(content, types.TextContent): - print(f"Error: {content.text}") - - -async def main(): - await parse_tool_results() - - -if __name__ == "__main__": - asyncio.run(main()) -``` - -### MCP Primitives - -The MCP protocol defines three core primitives that servers can implement: - -| Primitive | Control | Description | Example Use | -|-----------|-----------------------|-----------------------------------------------------|------------------------------| -| Prompts | User-controlled | Interactive templates invoked by user choice | Slash commands, menu options | -| Resources | Application-controlled| Contextual data managed by the client application | File contents, API responses | -| Tools | Model-controlled | Functions exposed to the LLM to take actions | API calls, data updates | - -### Server Capabilities - -MCP servers declare capabilities during initialization: - -| Capability | Feature Flag | Description | -|--------------|------------------------------|------------------------------------| -| `prompts` | `listChanged` | Prompt template management | -| `resources` | `subscribe`
`listChanged`| Resource exposure and updates | -| `tools` | `listChanged` | Tool discovery and execution | -| `logging` | - | Server logging configuration | -| `completions`| - | Argument completion suggestions | - -## Documentation - -- [API Reference](https://modelcontextprotocol.github.io/python-sdk/api/) -- [Experimental Features (Tasks)](https://modelcontextprotocol.github.io/python-sdk/experimental/tasks/) -- [Model Context Protocol documentation](https://modelcontextprotocol.io) -- [Model Context Protocol specification](https://modelcontextprotocol.io/specification/latest) -- [Officially supported servers](https://github.com/modelcontextprotocol/servers) - -## Contributing - -We are passionate about supporting contributors of all levels of experience and would love to see you get involved in the project. See the [contributing guide](CONTRIBUTING.md) to get started. - -## License - -This project is licensed under the MIT License - see the LICENSE file for details. +[pypi-badge]: https://img.shields.io/pypi/v/mcp.svg +[pypi-url]: https://pypi.org/project/mcp/ +[mit-badge]: https://img.shields.io/pypi/l/mcp.svg +[mit-url]: https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE +[python-badge]: https://img.shields.io/pypi/pyversions/mcp.svg +[python-url]: https://www.python.org/downloads/ +[docs-badge]: https://img.shields.io/badge/docs-python--sdk-blue.svg +[docs-url]: https://py.sdk.modelcontextprotocol.io/v2/ +[protocol-badge]: https://img.shields.io/badge/protocol-modelcontextprotocol.io-blue.svg +[protocol-url]: https://modelcontextprotocol.io +[spec-badge]: https://img.shields.io/badge/spec-spec.modelcontextprotocol.io-blue.svg +[spec-url]: https://modelcontextprotocol.io/specification/latest diff --git a/README.v2.md b/README.v2.md deleted file mode 100644 index 9b9971ec32..0000000000 --- a/README.v2.md +++ /dev/null @@ -1,132 +0,0 @@ -# MCP Python SDK - -
- -Python implementation of the Model Context Protocol (MCP) - -[![PyPI][pypi-badge]][pypi-url] -[![MIT licensed][mit-badge]][mit-url] -[![Python Version][python-badge]][python-url] -[![Documentation][docs-badge]][docs-url] -[![Protocol][protocol-badge]][protocol-url] -[![Specification][spec-badge]][spec-url] - -
- - - -> **Important: this documents v2 of the SDK, which is in alpha.** Pre-releases are published to PyPI as `2.0.0aN`, and each alpha may contain breaking changes from the previous one. -> -> v2 is a major rework of the SDK, both to support the [2026-07-28 MCP specification release](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) and to fix long-standing architectural issues. See the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/) for what's changed. We're targeting a beta on 2026-06-30 and a stable v2 on 2026-07-27, alongside the spec release. Before stable, we plan to add a significant set of backwards compatibility shims so the final upgrade is much smaller than today's diff. -> -> **v1.x is the only stable release line and remains recommended for production.** It is in maintenance mode and continues to receive critical bug fixes and security patches. Installers never select a pre-release unless you opt in (for example `pip install mcp==2.0.0a3`), so existing installs are unaffected. **If your package depends on `mcp`, add a `<2` upper bound to your version constraint (for example `mcp>=1.27,<2`) before the stable release lands.** -> -> Try the alpha and tell us what breaks: [#python-sdk-dev on the MCP Contributors Discord](https://discord.gg/6CSzBmMkjX). For v1 documentation, see [the v1.x README](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/README.md). - -## Documentation - -**The documentation lives at .** - -It has the full [tutorial](https://py.sdk.modelcontextprotocol.io/v2/tutorial/), the [API reference](https://py.sdk.modelcontextprotocol.io/v2/api/mcp/), and the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/). - -## What is MCP? - -The [Model Context Protocol](https://modelcontextprotocol.io) lets you build servers that expose data and functionality to LLM applications in a secure, standardized way. Think of it like a web API, but designed for LLM interactions. With this SDK you can: - -- **Build MCP servers** that expose tools, resources, and prompts to any MCP host -- **Build MCP clients** that connect to any MCP server -- Speak every standard transport: stdio, Streamable HTTP, and SSE - -## Requirements - -Python 3.10+. - -## Installation - -```bash -uv add "mcp[cli]==2.0.0a3" # or: pip install "mcp[cli]==2.0.0a3" -``` - -The pin matters while v2 is in pre-release: an unpinned install resolves to the latest stable v1.x, which this README does not describe. Check [PyPI](https://pypi.org/project/mcp/#history) for the newest pre-release, and use `uv run --with "mcp==2.0.0a3"` for one-off commands. - -## A server in 15 lines - -Create a `server.py`: - - -```python -from mcp.server import MCPServer - -mcp = MCPServer("Demo") - - -@mcp.tool() -def add(a: int, b: int) -> int: - """Add two numbers.""" - return a + b - - -@mcp.resource("greeting://{name}") -def greeting(name: str) -> str: - """Greet someone by name.""" - return f"Hello, {name}!" -``` - -_Full example: [docs_src/index/tutorial001.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/docs_src/index/tutorial001.py)_ - - -That's a complete MCP server: one tool, one templated resource. Open it in the [MCP Inspector](https://github.com/modelcontextprotocol/inspector): - -```bash -uv run mcp dev server.py -``` - -Call `add` with `a=1`, `b=2` and you get `3` back. - -Notice what you did **not** write: no JSON Schema (`a: int, b: int` _is_ the schema), no request parsing, no validation code, no protocol handling. Two type-hinted Python functions and a docstring. - -[The tutorial](https://py.sdk.modelcontextprotocol.io/v2/tutorial/) takes it from here. - -## A client in 10 lines - -The same package is a full MCP **client**. `Client` connects to a URL, a stdio subprocess, a custom transport, or (for tests) straight to a server object in memory with no transport at all: - -```python -import asyncio - -from mcp import Client - -from server import mcp - - -async def main() -> None: - async with Client(mcp) as client: - result = await client.call_tool("add", {"a": 1, "b": 2}) - print(result.structured_content) # {'result': 3} - - -asyncio.run(main()) -``` - -Swap `mcp` for `"http://localhost:8000/mcp"` and the exact same code talks to a remote server. - -## Contributing - -We are passionate about supporting contributors of all levels of experience and would love to see you get involved in the project. See the [contributing guide](https://github.com/modelcontextprotocol/python-sdk/blob/main/CONTRIBUTING.md) to get started. - -## License - -This project is licensed under the MIT License. See the [LICENSE](https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE) file for details. - -[pypi-badge]: https://img.shields.io/pypi/v/mcp.svg -[pypi-url]: https://pypi.org/project/mcp/ -[mit-badge]: https://img.shields.io/pypi/l/mcp.svg -[mit-url]: https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE -[python-badge]: https://img.shields.io/pypi/pyversions/mcp.svg -[python-url]: https://www.python.org/downloads/ -[docs-badge]: https://img.shields.io/badge/docs-python--sdk-blue.svg -[docs-url]: https://py.sdk.modelcontextprotocol.io/v2/ -[protocol-badge]: https://img.shields.io/badge/protocol-modelcontextprotocol.io-blue.svg -[protocol-url]: https://modelcontextprotocol.io -[spec-badge]: https://img.shields.io/badge/spec-spec.modelcontextprotocol.io-blue.svg -[spec-url]: https://modelcontextprotocol.io/specification/latest diff --git a/RELEASE.md b/RELEASE.md index fba7115bbc..cfd4d927cb 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -33,11 +33,17 @@ the publish job — `skip-existing` makes it skip whatever already landed. The `Development Status` classifier in both `pyproject.toml` files is permanently `5 - Production/Stable`; it is not bumped as part of any release. -1. Check the full test matrix is green on the release commit. The matrix runs +1. Update the pre-release version examples in `README.md` and the docs + (grep the outgoing version — the pins live in the README Installation + section, `docs/index.md`, and `docs/installation.md`) so the tagged + commit — and therefore the README PyPI publishes — names the version + being released. When entering a new phase (alpha → beta → rc), update + the banner wording too. +2. Check the full test matrix is green on the release commit. The matrix runs with `continue-on-error`, so a green workflow run does not mean the tests passed — check the individual jobs. -2. Create the release as a pre-release, passing the exact commit verified in - step 1 as `--target` (otherwise the tag is created from whatever `main`'s +3. Create the release as a pre-release, passing the exact commit verified in + step 2 as `--target` (otherwise the tag is created from whatever `main`'s HEAD is by then). The tagged commit determines everything about the release — the workflows that run and the package metadata (readme, classifiers) that gets published — so it must contain the current release @@ -50,13 +56,13 @@ the publish job — `skip-existing` makes it skip whatever already landed. The gh release create v2.0.0aN --prerelease --title v2.0.0aN --target ``` -3. Curate the release notes instead of relying on auto-generated ones: what +4. Curate the release notes instead of relying on auto-generated ones: what changed since the previous pre-release, what is known-incomplete, the install line (`pip install mcp==2.0.0aN`), and a link to the migration guide. Use the absolute URL (`https://github.com/modelcontextprotocol/python-sdk/blob/main/docs/migration.md`) because relative links don't resolve in GitHub release bodies. -4. If a pre-release turns out to be broken, yank it on PyPI and cut the next +5. If a pre-release turns out to be broken, yank it on PyPI and cut the next one. Never delete a release from PyPI — version numbers cannot be reused. Yanking doesn't stop `==` pins from installing the broken version, so set the yank reason (and edit the GitHub release notes) to point at the diff --git a/examples/README.md b/examples/README.md index 0a283e1356..4bfa140bdf 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,9 +4,9 @@ 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. +- [`snippets/`](snippets/) — short extracts that were embedded into the v1 + README (now on the `v1.x` branch); superseded by `docs_src/`, which the docs + and README embed today. Retained pending consolidation into `stories/`. - [`servers/everything-server/`](servers/everything-server/) — the conformance target for the cross-SDK [conformance suite](https://github.com/modelcontextprotocol/conformance). @@ -15,8 +15,8 @@ migration guide; superseded by `stories/` and slated for removal. - [`clients/`](clients/) and the remaining [`servers/`](servers/) directories (`simple-*`, `sse-polling-demo`, `structured-output-lowlevel`) — standalone - v1-era projects still linked from `README.v2.md`; retained pending - consolidation into `stories/`. + v1-era projects retained pending consolidation into `stories/` (the + `simple-auth` pair is still linked from `docs/advanced/`). For real-world servers see the [servers repository](https://github.com/modelcontextprotocol/servers). diff --git a/pyproject.toml b/pyproject.toml index 22ba4d4f4c..7b947588fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "mcp" dynamic = ["version", "dependencies"] description = "Model Context Protocol SDK" -readme = "README.v2.md" +readme = "README.md" requires-python = ">=3.10" authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] maintainers = [ @@ -175,7 +175,6 @@ executionEnvironments = [ [tool.ruff] line-length = 120 target-version = "py310" -extend-exclude = ["README.md", "README.v2.md"] [tool.ruff.lint] select = [ diff --git a/scripts/update_readme_snippets.py b/scripts/update_readme_snippets.py index 413c980175..99e9237a4c 100755 --- a/scripts/update_readme_snippets.py +++ b/scripts/update_readme_snippets.py @@ -93,16 +93,16 @@ def process_snippet_block(match: re.Match[str], check_mode: bool = False) -> str return full_match -def update_readme_snippets(readme_path: Path = Path("README.md"), check_mode: bool = False) -> bool: +def update_readme_snippets(check_mode: bool = False) -> bool: """Update code snippets in README.md with live code from source files. Args: - readme_path: Path to the README file check_mode: If True, only check if updates are needed without modifying Returns: True if file is up to date or was updated, False if check failed """ + readme_path = Path("README.md") if not readme_path.exists(): print(f"Error: README file not found: {readme_path}") return False @@ -146,12 +146,10 @@ def main(): parser.add_argument( "--check", action="store_true", help="Check mode - verify snippets are up to date without modifying" ) - # TODO(v2): Drop the `--readme` argument when v2 is released, and set to `README.md`. - parser.add_argument("--readme", default="README.v2.md", help="Path to README file (default: README.v2.md)") args = parser.parse_args() - success = update_readme_snippets(Path(args.readme), check_mode=args.check) + success = update_readme_snippets(check_mode=args.check) if not success: sys.exit(1) diff --git a/tests/docs_src/test_shape.py b/tests/docs_src/test_shape.py index 98fb2503de..1636bd825e 100644 --- a/tests/docs_src/test_shape.py +++ b/tests/docs_src/test_shape.py @@ -67,12 +67,8 @@ def _retired_names_used(source: str) -> list[str]: def _referenced_examples() -> set[str]: - """Every `docs_src/...` path that some docs page or the README actually includes. - - The README is globbed rather than named so this survives the planned - `README.v2.md` -> `README.md` rename instead of crashing on a missing file. - """ - pages = [*sorted((REPO_ROOT / "docs").rglob("*.md")), *sorted(REPO_ROOT.glob("README*.md"))] + """Every `docs_src/...` path that some docs page or the README actually includes.""" + pages = [*sorted((REPO_ROOT / "docs").rglob("*.md")), REPO_ROOT / "README.md"] return {ref for page in pages for ref in _INCLUDE_DIRECTIVE.findall(page.read_text(encoding="utf-8"))} diff --git a/tests/test_examples.py b/tests/test_examples.py index f24e932bed..f139f418a1 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -93,7 +93,6 @@ async def test_desktop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): assert "file2.txt" in content.text -# TODO(v2): Change back to README.md when v2 is released. # `--8<--` include directives lint clean as Python, so pages built from # `docs_src/` includes cost nothing here; the real validation of those files is # pyright + ruff + tests/docs_src/. @@ -101,7 +100,7 @@ async def test_desktop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): "example", list( find_examples( - "README.v2.md", + "README.md", "docs/index.md", "docs/installation.md", "docs/tutorial", From 24fdd909ac794b55977c29f51cb24a0324c20461 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:07:26 +0200 Subject: [PATCH 025/100] docs: convert bold cross-references into links, link SEP and RFC mentions (#3017) --- docs/advanced/authorization.md | 12 +++++----- docs/advanced/deprecated.md | 14 +++++------ docs/advanced/extensions.md | 2 +- docs/advanced/identity-assertion.md | 36 ++++++++++++++--------------- docs/advanced/low-level-server.md | 20 ++++++++-------- docs/advanced/middleware.md | 8 +++---- docs/advanced/multi-round-trip.md | 6 ++--- docs/advanced/oauth-clients.md | 10 ++++---- docs/advanced/opentelemetry.md | 2 +- docs/advanced/pagination.md | 6 ++--- docs/advanced/session-groups.md | 4 ++-- docs/advanced/uri-templates.md | 10 ++++---- docs/client/callbacks.md | 14 +++++------ docs/client/index.md | 18 +++++++-------- docs/client/protocol-versions.md | 6 ++--- docs/client/transports.md | 8 +++---- docs/installation.md | 6 ++--- docs/migration.md | 28 +++++++++++----------- docs/run/asgi.md | 4 ++-- docs/run/index.md | 10 ++++---- docs/tutorial/completions.md | 4 ++-- docs/tutorial/context.md | 10 ++++---- docs/tutorial/dependencies.md | 12 +++++----- docs/tutorial/elicitation.md | 16 ++++++------- docs/tutorial/first-steps.md | 8 +++---- docs/tutorial/handling-errors.md | 8 +++---- docs/tutorial/lifespan.md | 4 ++-- docs/tutorial/logging.md | 6 ++--- docs/tutorial/media.md | 6 ++--- docs/tutorial/progress.md | 6 ++--- docs/tutorial/prompts.md | 6 ++--- docs/tutorial/resources.md | 8 +++---- docs/tutorial/structured-output.md | 8 +++---- docs/tutorial/testing.md | 4 ++-- docs/tutorial/tools.md | 4 ++-- 35 files changed, 167 insertions(+), 167 deletions(-) diff --git a/docs/advanced/authorization.md b/docs/advanced/authorization.md index 2afb3d5a07..9b5a32a4ec 100644 --- a/docs/advanced/authorization.md +++ b/docs/advanced/authorization.md @@ -32,11 +32,11 @@ The SDK has no opinion about what a valid token looks like. You tell it, by impl !!! tip `examples/servers/simple-auth/` in the SDK repository has an `IntrospectionTokenVerifier` that calls - a real authorization server's RFC 7662 endpoint. It's the shape most production verifiers take. + a real authorization server's [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) endpoint. It's the shape most production verifiers take. ## What you get over HTTP -Authorization lives in HTTP headers, so it exists only on the HTTP transports. Run it on the one you deploy: `mcp.run(transport="streamable-http")` puts it on `http://127.0.0.1:8000/mcp`, and **Running your server** has the rest. The app now has two routes: +Authorization lives in HTTP headers, so it exists only on the HTTP transports. Run it on the one you deploy: `mcp.run(transport="streamable-http")` puts it on `http://127.0.0.1:8000/mcp`, and **[Running your server](../run/index.md)** has the rest. The app now has two routes: ```text /mcp @@ -47,7 +47,7 @@ You registered one tool. The second route is the SDK's. ### Discovery -`GET` that well-known path and you get **RFC 9728 Protected Resource Metadata**, built straight from your `AuthSettings`: +`GET` that well-known path and you get **[RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata**, built straight from your `AuthSettings`: ```json { @@ -109,15 +109,15 @@ To watch all three parties move, run `examples/servers/simple-auth/` from the SD server inside your MCP server. It predates the AS/RS separation that the MCP authorization spec is built around. New servers should not reach for it. -An authorization server can also accept an enterprise identity provider's signed assertion in place of a user clicking through a consent screen, and the SDK supports both sides of that exchange. The grant, and the client that presents it, is **Identity assertion**. +An authorization server can also accept an enterprise identity provider's signed assertion in place of a user clicking through a consent screen, and the SDK supports both sides of that exchange. The grant, and the client that presents it, is **[Identity assertion](identity-assertion.md)**. ## Recap * Over Streamable HTTP your server is an OAuth 2.1 **resource server**: it verifies tokens, it never issues them. * `TokenVerifier` is the whole integration surface: one async method, token in, `AccessToken | None` out. * `token_verifier=` and `auth=AuthSettings(issuer_url=..., resource_server_url=..., required_scopes=[...])` always travel together. -* The SDK publishes RFC 9728 Protected Resource Metadata at `/.well-known/oauth-protected-resource/...` and answers unauthenticated requests with a 401 whose `WWW-Authenticate` header points at it. That is the entire discovery story. +* The SDK publishes [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) Protected Resource Metadata at `/.well-known/oauth-protected-resource/...` and answers unauthenticated requests with a 401 whose `WWW-Authenticate` header points at it. That is the entire discovery story. * `get_access_token()` in any handler is who's calling. * Authorization is an HTTP concern. `stdio` and the in-memory client never see it. -The other side of the handshake, a client that discovers your authorization server and fetches the token for you, is **OAuth clients**. +The other side of the handshake, a client that discovers your authorization server and fetches the token for you, is **[OAuth clients](oauth-clients.md)**. diff --git a/docs/advanced/deprecated.md b/docs/advanced/deprecated.md index 5bff0e9553..18bcc79463 100644 --- a/docs/advanced/deprecated.md +++ b/docs/advanced/deprecated.md @@ -8,16 +8,16 @@ The table below names each deprecated feature, why it is going away, and the rep | Deprecated | Why | What you do instead | |---|---|---| -| **Roots**: `ctx.session.list_roots()`, `client.send_roots_list_changed()`, the `list_roots_callback=` you pass to `Client(...)` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) retires the capability. | Take the paths as ordinary tool arguments or resource URIs, or embed a `ListRootsRequest` in an `InputRequiredResult` (see **Multi-round-trip requests**). | -| **Server-initiated sampling**: `ctx.session.create_message()`, the `sampling_callback=` you pass to `Client(...)` | SEP-2577 retires the capability. | Return `InputRequiredResult` and let the client retry the call (see **Multi-round-trip requests**). | -| **Protocol logging**: `ctx.log()`, `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`, `ctx.session.send_log_message()`, `client.set_logging_level()` | SEP-2577 retires the capability. Nothing in-protocol replaces it. | Ordinary `import logging` to stderr (see **Logging**). | +| **Roots**: `ctx.session.list_roots()`, `client.send_roots_list_changed()`, the `list_roots_callback=` you pass to `Client(...)` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) retires the capability. | Take the paths as ordinary tool arguments or resource URIs, or embed a `ListRootsRequest` in an `InputRequiredResult` (see **[Multi-round-trip requests](multi-round-trip.md)**). | +| **Server-initiated sampling**: `ctx.session.create_message()`, the `sampling_callback=` you pass to `Client(...)` | SEP-2577 retires the capability. | Return `InputRequiredResult` and let the client retry the call (see **[Multi-round-trip requests](multi-round-trip.md)**). | +| **Protocol logging**: `ctx.log()`, `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`, `ctx.session.send_log_message()`, `client.set_logging_level()` | SEP-2577 retires the capability. Nothing in-protocol replaces it. | Ordinary `import logging` to stderr (see **[Logging](../tutorial/logging.md)**). | | **`ping`**: `client.send_ping()` | **Removed** from the protocol, not merely deprecated. There is no `ping` method in 2026-07-28. | Nothing. It only works against a `mode="legacy"` connection. | -| **Client->server progress**: `client.send_progress_notification()` | 2026-07-28 makes progress server->client only. | Nothing to send. Your *server* reports progress with `ctx.report_progress()` (see **Progress**). | +| **Client->server progress**: `client.send_progress_notification()` | 2026-07-28 makes progress server->client only. | Nothing to send. Your *server* reports progress with `ctx.report_progress()` (see **[Progress](../tutorial/progress.md)**). | Three things fall out of that table: * Roots, sampling, and logging go together. One proposal, **SEP-2577**, deprecates all three capabilities at once. -* Sampling and roots share a deeper problem: they are places a **server** sends a **request** to the **client**. That whole direction is what 2026-07-28 replaces with **Multi-round-trip requests**. It is the standalone RPC methods (`sampling/createMessage`, `roots/list`, and push-style `elicitation/create`) that are gone; the `CreateMessageRequest` / `ListRootsRequest` / `ElicitRequest` payload types survive, embedded in `InputRequiredResult.input_requests`, and on the client they hit the same callbacks. +* Sampling and roots share a deeper problem: they are places a **server** sends a **request** to the **client**. That whole direction is what 2026-07-28 replaces with **[Multi-round-trip requests](multi-round-trip.md)**. It is the standalone RPC methods (`sampling/createMessage`, `roots/list`, and push-style `elicitation/create`) that are gone; the `CreateMessageRequest` / `ListRootsRequest` / `ElicitRequest` payload types survive, embedded in `InputRequiredResult.input_requests`, and on the client they hit the same callbacks. * `ping` is the odd one out. The protocol does not deprecate it, it removes it. The SDK method still warns (its message says *removed*, not *deprecated*) and calling it on a modern connection answers with *"Method not found"*. ## Deprecated is advisory @@ -81,8 +81,8 @@ That is the whole API. There is no per-method switch, and you don't want one: th ## Recap -* The 2026-07-28 spec deprecates **roots**, server-initiated **sampling**, and protocol **logging** (all SEP-2577), restricts **progress** to server-to-client, and removes **`ping`**. -* The replacement column points you onward: **Multi-round-trip requests** for sampling and roots, **Logging** for logging, **Progress** for progress. `ping` needs nothing at all. +* The 2026-07-28 spec deprecates **roots**, server-initiated **sampling**, and protocol **logging** (all [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), restricts **progress** to server-to-client, and removes **`ping`**. +* The replacement column points you onward: **[Multi-round-trip requests](multi-round-trip.md)** for sampling and roots, **[Logging](../tutorial/logging.md)** for logging, **[Progress](../tutorial/progress.md)** for progress. `ping` needs nothing at all. * Deprecated is advisory: no wire changes, everything keeps working against pre-2026 sessions, and you get a visible `MCPDeprecationWarning` (a `UserWarning`, so it is on by default). * Sampling and roots additionally need a back-channel that a 2026-07-28 session does not have. On a modern connection they warn and then they raise. * `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` silences the whole category; `"error::mcp.MCPDeprecationWarning"` in pytest turns it into a test failure. diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md index 6ca1642288..5a6d7d5244 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -4,7 +4,7 @@ An **extension** is an opt-in bundle of MCP behaviour behind one identifier. It can contribute tools, resources, and new request methods, and it can wrap `tools/call`. The server advertises it under `capabilities.extensions`, the client opts in the same way, -and nothing changes for anyone who didn't ask for it. That is the contract (SEP-2133), and +and nothing changes for anyone who didn't ask for it. That is the contract ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)), and it has one golden rule: **extensions are off by default**. ## Using an extension diff --git a/docs/advanced/identity-assertion.md b/docs/advanced/identity-assertion.md index 5a48c13c74..7e73183616 100644 --- a/docs/advanced/identity-assertion.md +++ b/docs/advanced/identity-assertion.md @@ -1,25 +1,25 @@ # Identity assertion -Every provider in **OAuth clients** starts by asking the MCP server a question: *which authorization server do you trust?* It follows the answer wherever it points, and then either a person signs in or a pre-shared secret stands in for one. +Every provider in **[OAuth clients](oauth-clients.md)** starts by asking the MCP server a question: *which authorization server do you trust?* It follows the answer wherever it points, and then either a person signs in or a pre-shared secret stands in for one. An enterprise wants neither decided per server. It already runs an identity provider (Okta, Microsoft Entra ID, your own); the user already signed in to it this morning; and it is the one place the security team wants to decide who may reach what. [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990), the **Enterprise-Managed Authorization** extension, moves the decision there. The IdP signs a short-lived JWT, an **Identity Assertion JWT Authorization Grant**, the **ID-JAG**: a statement that *this user*, through *this client*, may reach *this MCP server*. The client trades it for an ordinary access token. No browser, no consent screen, no dynamic registration. -This chapter is both ends of that trade. The MCP server itself never changes: it is still the resource server from **Authorization**, checking whatever token shows up. +This chapter is both ends of that trade. The MCP server itself never changes: it is still the resource server from **[Authorization](authorization.md)**, checking whatever token shows up. ## Two token requests -Two different authorities are in play, and naming them apart is most of understanding this page. The **enterprise IdP** is your organization's identity provider: it knows who the employee is, it is where policy lives, and it issues the ID-JAG. The SDK never talks to it. The **MCP authorization server** is the same party it was in **Authorization**: the issuer named in the MCP server's metadata, the thing that mints the tokens that MCP server accepts. In the flows you already know, those two roles are usually one box. Here they are two, and the whole grant is the second agreeing to trust the first. +Two different authorities are in play, and naming them apart is most of understanding this page. The **enterprise IdP** is your organization's identity provider: it knows who the employee is, it is where policy lives, and it issues the ID-JAG. The SDK never talks to it. The **MCP authorization server** is the same party it was in **[Authorization](authorization.md)**: the issuer named in the MCP server's metadata, the thing that mints the tokens that MCP server accepts. In the flows you already know, those two roles are usually one box. Here they are two, and the whole grant is the second agreeing to trust the first. The client makes one token request to each. -1. **To the enterprise IdP.** The client trades the user's sign-in (their OpenID Connect ID token) for the ID-JAG. This is an RFC 8693 token exchange, it is entirely your IdP's API, and **the SDK does not make it**. You do, inside one async callback. It is also where the policy decision happens: an IdP that says no never issues the ID-JAG, and there is nothing to present. -2. **To the MCP authorization server.** The client presents the ID-JAG under the RFC 7523 `jwt-bearer` grant (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, the ID-JAG as `assertion`) and receives the access token. **This is the request the SDK makes**, and accepting it is the one thing this page adds to an authorization server. +1. **To the enterprise IdP.** The client trades the user's sign-in (their OpenID Connect ID token) for the ID-JAG. This is an [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchange, it is entirely your IdP's API, and **the SDK does not make it**. You do, inside one async callback. It is also where the policy decision happens: an IdP that says no never issues the ID-JAG, and there is nothing to present. +2. **To the MCP authorization server.** The client presents the ID-JAG under the [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) `jwt-bearer` grant (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, the ID-JAG as `assertion`) and receives the access token. **This is the request the SDK makes**, and accepting it is the one thing this page adds to an authorization server. Everything below is the second request: the client that sends it and the authorization server that answers it. ## The client -**`IdentityAssertionOAuthProvider`** lives in `mcp.client.auth.extensions.identity_assertion`. Like every provider in **OAuth clients** it is an `httpx.Auth`: construct one, put it on `auth=`, hand the `httpx.AsyncClient` to the transport. +**`IdentityAssertionOAuthProvider`** lives in `mcp.client.auth.extensions.identity_assertion`. Like every provider in **[OAuth clients](oauth-clients.md)** it is an `httpx.Auth`: construct one, put it on `auth=`, hand the `httpx.AsyncClient` to the transport. ```python title="client.py" hl_lines="49-50 53-61" --8<-- "docs_src/identity_assertion/tutorial001.py" @@ -27,7 +27,7 @@ Everything below is the second request: the client that sends it and the authori Read it from the bottom. -* `main()` is the `main()` from **OAuth clients**, line for line. That is the point: once the provider exists, nothing downstream knows which grant produced the token. +* `main()` is the `main()` from **[OAuth clients](oauth-clients.md)**, line for line. That is the point: once the provider exists, nothing downstream knows which grant produced the token. * The provider takes what the other providers cannot discover: a `client_id` and `client_secret` somebody **pre-registered** with the authorization server, that authorization server's `issuer`, and `assertion_provider`, an async callback that returns a fresh ID-JAG on demand. * `storage` is the same `TokenStorage` protocol. Only the two token methods are ever called; there is no dynamic registration here, so there is no `client_info` to remember. @@ -35,7 +35,7 @@ Read it from the bottom. `fetch_id_jag(audience, resource)` is the only code you write. It is awaited once per token exchange, never at construction, and only *after* the authorization server's metadata has been fetched and validated, so a misconfigured issuer never leaks an assertion. Its two arguments are two of the claims the ID-JAG must be minted with: `audience` is the authorization server's issuer (the ID-JAG `aud`) and `resource` is the MCP server's canonical identifier (the ID-JAG `resource`). The third is one you already hold: the ID-JAG's `client_id` claim must name the `client_id` you gave the provider, or the authorization server refuses the exchange. -`idp_issue_id_jag` above it is **not your code**. It stands in for the identity provider, signing the assertion in-process so the file is complete and you can read every claim an ID-JAG carries. A real `fetch_id_jag` makes the first token request of the previous section instead: an RFC 8693 token exchange against your IdP, defined by the Identity Assertion JWT Authorization Grant draft that SEP-990 profiles. The signed-in user's ID token goes in as the `subject_token`, the `requested_token_type` is the ID-JAG's own URN (`urn:ietf:params:oauth:token-type:id-jag`), `audience` and `resource` pass straight through, and the response carries the ID-JAG. That exchange, under those names, is what to look for in your IdP's documentation. +`idp_issue_id_jag` above it is **not your code**. It stands in for the identity provider, signing the assertion in-process so the file is complete and you can read every claim an ID-JAG carries. A real `fetch_id_jag` makes the first token request of the previous section instead: an [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchange against your IdP, defined by the Identity Assertion JWT Authorization Grant draft that [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) profiles. The signed-in user's ID token goes in as the `subject_token`, the `requested_token_type` is the ID-JAG's own URN (`urn:ietf:params:oauth:token-type:id-jag`), `audience` and `resource` pass straight through, and the response carries the ID-JAG. That exchange, under those names, is what to look for in your IdP's documentation. !!! tip A fresh ID-JAG is requested for every exchange, and that is the point: it is a single-use, @@ -44,7 +44,7 @@ Read it from the bottom. ### The issuer is configuration -Here is the inversion. `OAuthClientProvider` asks the resource server which authorization server to use and follows the answer wherever it points. This provider refuses to: `issuer` is required, the RFC 8414 metadata is fetched from that issuer's own well-known path, the token endpoint must be on that issuer's origin, and the resource server is never asked anything. +Here is the inversion. `OAuthClientProvider` asks the resource server which authorization server to use and follows the answer wherever it points. This provider refuses to: `issuer` is required, the [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) metadata is fetched from that issuer's own well-known path, the token endpoint must be on that issuer's origin, and the resource server is never asked anything. The extension does not demand this; it is a deliberately stricter choice. This client carries two things worth stealing, a pre-registered secret and an audience-bound assertion, and a client that let a compromised MCP server steer it to an attacker's authorization server would post both to it. Pinning the issuer at construction deletes that conversation. @@ -59,7 +59,7 @@ The extension does not demand this; it is a deliberately stricter choice. This c ### A confidential client -`client_secret` is required; the constructor raises `ValueError` without one. The IETF profile underneath SEP-990 reserves this grant for confidential clients, SEP-990 requires the client to authenticate, and this SDK enforces both by insisting on a shared secret. `token_endpoint_auth_method` picks where it travels: `client_secret_post` (the default, in the form body) or `client_secret_basic` (an HTTP Basic header). The profile also permits `private_key_jwt`; this provider does not support it. +`client_secret` is required; the constructor raises `ValueError` without one. The IETF profile underneath [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) reserves this grant for confidential clients, SEP-990 requires the client to authenticate, and this SDK enforces both by insisting on a shared secret. `token_endpoint_auth_method` picks where it travels: `client_secret_post` (the default, in the form body) or `client_secret_basic` (an HTTP Basic header). The profile also permits `private_key_jwt`; this provider does not support it. !!! tip Read `client_secret` from the environment or a secret manager, never from source control. @@ -68,15 +68,15 @@ The extension does not demand this; it is a deliberately stricter choice. This c The first request goes out unauthenticated, and the server's `401` starts the flow. -1. **Discovery.** It fetches the authorization server metadata from the configured issuer's RFC 8414 well-known path, checks the document's `issuer` matches, and checks the token endpoint is on the issuer's origin. +1. **Discovery.** It fetches the authorization server metadata from the configured issuer's [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) well-known path, checks the document's `issuer` matches, and checks the token endpoint is on the issuer's origin. 2. **The assertion.** It awaits your `assertion_provider`. 3. **Exchange.** It POSTs the `jwt-bearer` grant to the token endpoint, stores the `OAuthToken`, and replays your original request with `Authorization: Bearer ...`. -A `403` whose `WWW-Authenticate` names `insufficient_scope` runs steps 2 and 3 again with the union of your `scope` and the challenged one. (`scope` is only ever a request; this page's authorization server grants what the ID-JAG says and nothing else.) There is no refresh token anywhere in this: when the access token expires, the next `401` mints a fresh ID-JAG and exchanges again, and *that* is the lever the IdP holds. Failures are the same two exceptions as the rest of **OAuth clients**: `OAuthFlowError` for discovery and validation, its subclass `OAuthTokenError` when the token endpoint says no. +A `403` whose `WWW-Authenticate` names `insufficient_scope` runs steps 2 and 3 again with the union of your `scope` and the challenged one. (`scope` is only ever a request; this page's authorization server grants what the ID-JAG says and nothing else.) There is no refresh token anywhere in this: when the access token expires, the next `401` mints a fresh ID-JAG and exchanges again, and *that* is the lever the IdP holds. Failures are the same two exceptions as the rest of **[OAuth clients](oauth-clients.md)**: `OAuthFlowError` for discovery and validation, its subclass `OAuthTokenError` when the token endpoint says no. ## The authorization server -Most of the time you stop here. The MCP authorization server is somebody else's product, accepting ID-JAGs is its configuration to turn on, and the SDK's half of SEP-990 is the client above. +Most of the time you stop here. The MCP authorization server is somebody else's product, accepting ID-JAGs is its configuration to turn on, and the SDK's half of [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) is the client above. The SDK can also *be* the authorization server: `create_auth_routes` returns the authorization server's routes as a list any Starlette app can mount, which is how `examples/servers/simple-auth/` in the repository runs one. SEP-990 adds one flag and one method to that surface: @@ -93,7 +93,7 @@ The SDK can also *be* the authorization server: `create_auth_routes` returns the The SDK never decodes the assertion: only your deployment knows which IdP it trusts and which keys that IdP publishes, so everything inside `exchange_identity_assertion` is load-bearing. Verify the signature against the IdP's published keys (its JWKS; the shared secret here is the - demo's), and `iss` and `exp`, per RFC 7523 §3. Require the JWT header's `typ` to be + demo's), and `iss` and `exp`, per [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) §3. Require the JWT header's `typ` to be `oauth-id-jag+jwt`, the profile's guard against some other JWT being replayed as a grant. Require `aud` to be your own issuer. Require the ID-JAG's `client_id` claim to equal the client the handler authenticated, and its `resource` claim to name a resource you actually serve. @@ -108,7 +108,7 @@ And notice what the returned `OAuthToken` does not carry: a refresh token. The I !!! info A server that still embeds its authorization server with `auth_server_provider=` reaches the same - code through `AuthSettings(identity_assertion_enabled=True)`. **Authorization** explains why new + code through `AuthSettings(identity_assertion_enabled=True)`. **[Authorization](authorization.md)** explains why new servers should not start there. !!! check @@ -137,10 +137,10 @@ And notice what the returned `OAuthToken` does not carry: a refresh token. The I ## Recap -* SEP-990 lets the enterprise identity provider, not the end user, decide which MCP servers a client may reach. The IdP signs that decision into an **ID-JAG**. -* Obtaining the ID-JAG is an RFC 8693 token exchange against *your IdP*, and the SDK does not make it. Presenting it to the MCP authorization server is the RFC 7523 `jwt-bearer` grant, and the SDK does both sides of that. +* [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) lets the enterprise identity provider, not the end user, decide which MCP servers a client may reach. The IdP signs that decision into an **ID-JAG**. +* Obtaining the ID-JAG is an [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) token exchange against *your IdP*, and the SDK does not make it. Presenting it to the MCP authorization server is the [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) `jwt-bearer` grant, and the SDK does both sides of that. * `IdentityAssertionOAuthProvider` is another `httpx.Auth`: a pre-registered confidential client, a pinned `issuer`, and one `assertion_provider(audience, resource)` callback. No browser, no registration, no refresh token. * The authorization server is never discovered from the resource server. Configure `issuer` to exactly the string its metadata document serves; the comparison is character for character. * Server side, `identity_assertion_enabled=True` plus `exchange_identity_assertion`. The SDK authenticates the client and gates the grant; validating the ID-JAG is entirely yours, and the issued token is bound to the ID-JAG's `resource`, not the request's. -The one party this page never touched is the MCP server. What it does with the token you just minted, it was already doing in **Authorization**. +The one party this page never touched is the MCP server. What it does with the token you just minted, it was already doing in **[Authorization](authorization.md)**. diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index 8473495ce0..2220151db5 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -12,7 +12,7 @@ For everything else, stay on `MCPServer`. ## The same tool, by hand -This is `search_books` from **Tools** (the nine-line `@mcp.tool()` file) with the sugar removed: +This is `search_books` from **[Tools](../tutorial/tools.md)** (the nine-line `@mcp.tool()` file) with the sugar removed: ```python title="server.py" hl_lines="23 27 33" --8<-- "docs_src/lowlevel/tutorial001.py" @@ -61,7 +61,7 @@ The same text the `@mcp.tool()` version produced. Two honest differences: ## Nothing is checked for you -In **Tools** you saw a bad argument get rejected before your function ran. That was `MCPServer` validating the call against the schema it generated. +In **[Tools](../tutorial/tools.md)** you saw a bad argument get rejected before your function ran. That was `MCPServer` validating the call against the schema it generated. `Server` does not do that. Your `input_schema` is *advertised* to the client; it is never *applied* to `params.arguments`. @@ -72,9 +72,9 @@ In **Tools** you saw a bad argument get rejected before your function ran. That MCPError: Internal server error ``` - A JSON-RPC error, code `-32603`, with a deliberately generic message: the SDK won't leak your traceback to a remote caller. The model never finds out what it did wrong, so it can't retry. (In a test, `raise_exceptions=True` surfaces the real exception instead; see **Testing**.) + A JSON-RPC error, code `-32603`, with a deliberately generic message: the SDK won't leak your traceback to a remote caller. The model never finds out what it did wrong, so it can't retry. (In a test, `raise_exceptions=True` surfaces the real exception instead; see **[Testing](../tutorial/testing.md)**.) -That generalises. An exception raised from a low-level handler is **always** a protocol error, never an `is_error=True` tool result. If you want the model to read the failure and recover, validate `params.arguments` yourself and return `CallToolResult(content=[TextContent(...)], is_error=True)`. The two kinds of failure are the subject of **Handling errors**. +That generalises. An exception raised from a low-level handler is **always** a protocol error, never an `is_error=True` tool result. If you want the model to read the failure and recover, validate `params.arguments` yourself and return `CallToolResult(content=[TextContent(...)], is_error=True)`. The two kinds of failure are the subject of **[Handling errors](../tutorial/handling-errors.md)**. ## Two tools, one handler @@ -106,7 +106,7 @@ Call it and the result carries both representations: } ``` -The server never compares the two fields. This SDK's `Client` does: return `structured_content` that doesn't satisfy the `output_schema` you declared and `call_tool` raises a `RuntimeError` that starts with `Invalid structured content returned by tool search_books` and goes on to quote the `jsonschema` failure. Promising a schema is cheap; keeping it is on you. The whole ladder of return types and schemas is in **Structured Output**. +The server never compares the two fields. This SDK's `Client` does: return `structured_content` that doesn't satisfy the `output_schema` you declared and `call_tool` raises a `RuntimeError` that starts with `Invalid structured content returned by tool search_books` and goes on to quote the `jsonschema` failure. Promising a schema is cheap; keeping it is on you. The whole ladder of return types and schemas is in **[Structured Output](../tutorial/structured-output.md)**. ## `_meta`: for the application, not the model @@ -147,7 +147,7 @@ No `resources`, no `prompts`: there is nothing to back them. Pass `on_list_promp * The lifespan is a `Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]]`; `@asynccontextmanager` on an `async` generator gives you exactly that. * Whatever it `yield`s becomes `ctx.lifespan_context`, and because the handlers are annotated `ServerRequestContext[Catalog]`, `.search(...)` autocompletes and type-checks. -* It is entered once when the server starts and exited once when it stops. Startup, teardown, and `MCPServer`'s version of the same idea are in **Lifespan**. +* It is entered once when the server starts and exited once when it stops. Startup, teardown, and `MCPServer`'s version of the same idea are in **[Lifespan](../tutorial/lifespan.md)**. Without a `lifespan=`, `ctx.lifespan_context` is an empty `dict`. @@ -175,15 +175,15 @@ use Server.middleware to observe or wrap initialization The handshake belongs to the runner. `server/discover`, `ping`, and every other built-in are yours to replace. !!! tip - `Server.middleware`, mentioned in that error, wraps **every** inbound message, including `initialize`. If what you want is to observe or rewrite traffic rather than answer a new method, start at **Middleware**. + `Server.middleware`, mentioned in that error, wraps **every** inbound message, including `initialize`. If what you want is to observe or rewrite traffic rather than answer a new method, start at **[Middleware](middleware.md)**. ## The other handlers Each of these is one idea you now have the vocabulary for; each has its own chapter. -* `on_call_tool` may return an `InputRequiredResult` instead of a `CallToolResult` to pause the call and ask the client for input; see **Multi-round-trip requests**. +* `on_call_tool` may return an `InputRequiredResult` instead of a `CallToolResult` to pause the call and ask the client for input; see **[Multi-round-trip requests](multi-round-trip.md)**. * `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` are the same `(ctx, params) -> result` shape for the other primitives. -* `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **Running your server** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story. +* `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **[Running your server](../run/index.md)** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story. ## Recap @@ -195,4 +195,4 @@ Each of these is one idea you now have the vocabulary for; each has its own chap * `add_request_handler(method, params_type, handler)` serves any method. `initialize` is reserved. * The capabilities a `Server` advertises are derived from which handlers you registered. -`Client(server)` treated both servers identically because they *are* the same protocol, which is the whole point. The next layer down isn't a class at all: it's **Middleware**. +`Client(server)` treated both servers identically because they *are* the same protocol, which is the whole point. The next layer down isn't a class at all: it's **[Middleware](middleware.md)**. diff --git a/docs/advanced/middleware.md b/docs/advanced/middleware.md index cb10c6cf1c..7cc15ce3c6 100644 --- a/docs/advanced/middleware.md +++ b/docs/advanced/middleware.md @@ -10,7 +10,7 @@ You write it as `async (ctx, call_next)` and append it to `server.middleware`. T Do not make it the foundation your server stands on. This is a **low-level `Server`** feature. `MCPServer` does not expose a middleware list. -If `Server(name, on_call_tool=...)` is new to you, read **The low-level Server** first. +If `Server(name, on_call_tool=...)` is new to you, read **[The low-level Server](low-level-server.md)** first. ## A timing middleware @@ -84,7 +84,7 @@ In increasing order of how much you should hesitate: The SDK ships exactly one middleware, and it is already on your server's list: the one that emits an OpenTelemetry span for every message. You don't append it, and most of the time you don't think about it. It is a no-op until you install an exporter, and it has its own page: -**OpenTelemetry**. +**[OpenTelemetry](opentelemetry.md)**. !!! info If you have written ASGI middleware, you already know this shape. Starlette's @@ -101,8 +101,8 @@ don't think about it. It is a no-op until you install an exporter, and it has it * `ctx.request_id is None` is how you tell a notification from a request. * Raise instead of calling `call_next` to refuse one message; the connection survives. * The SDK's own OpenTelemetry tracing is a middleware too, already on the list. See - **OpenTelemetry**. + **[OpenTelemetry](opentelemetry.md)**. * The whole surface is provisional. Observe with it; don't build on it. -That is everything that wraps a request. **Authorization** is what decides whether the request +That is everything that wraps a request. **[Authorization](authorization.md)** is what decides whether the request gets to run at all. diff --git a/docs/advanced/multi-round-trip.md b/docs/advanced/multi-round-trip.md index a90cb5e980..de11a8db88 100644 --- a/docs/advanced/multi-round-trip.md +++ b/docs/advanced/multi-round-trip.md @@ -29,7 +29,7 @@ The high-level `@mcp.tool()` decorator has no sugar for this yet. Today you writ * On the first call `params.input_responses` is `None`, so the guard fires and the handler asks instead of answering. * On the retry, the `ElicitResult` the client sent is sitting under the **same key** (`"region"`) that the server used in `input_requests`. -Everything else in that file (the explicit `input_schema`, the hand-built `CallToolResult`) is the ordinary low-level `Server`, covered in **The low-level Server**. This page only adds the second return type. +Everything else in that file (the explicit `input_schema`, the hand-built `CallToolResult`) is the ordinary low-level `Server`, covered in **[The low-level Server](low-level-server.md)**. This page only adds the second return type. ## The client side @@ -85,7 +85,7 @@ Drop to the underlying session, where `allow_input_required=True` hands you the **URL-mode elicitation** rides this exact mechanism on a 2026 connection. The entry in `input_requests` is an `ElicitRequest` whose params are `ElicitRequestURLParams`; the user finishes the out-of-band flow and your client retries the call. Same loop, no new API. The - high-level server half is in **Elicitation**. + high-level server half is in **[Elicitation](../tutorial/elicitation.md)**. ## Recap @@ -95,4 +95,4 @@ Drop to the underlying session, where `allow_input_required=True` hands you the * To inspect or persist rounds, use `client.session.call_tool(..., allow_input_required=True)` and own the `while isinstance(result, InputRequiredResult)` loop yourself. * The server side is the **low-level** `Server` only; `@mcp.tool()` has no sugar for this yet. -This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **Deprecated features**. +This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **[Deprecated features](deprecated.md)**. diff --git a/docs/advanced/oauth-clients.md b/docs/advanced/oauth-clients.md index 3407f02666..698a08f4f1 100644 --- a/docs/advanced/oauth-clients.md +++ b/docs/advanced/oauth-clients.md @@ -4,7 +4,7 @@ Some MCP servers are protected. Send them a request without a token and they ans **`OAuthClientProvider`** is how you get the token. It is not an MCP object at all. It is an `httpx.Auth`, the standard httpx hook for "do something to every request". You attach it to an `httpx.AsyncClient`, hand that client to the Streamable HTTP transport, and stop thinking about it. -This chapter is the client side. Making your own server demand a token is **Authorization**. +This chapter is the client side. Making your own server demand a token is **[Authorization](authorization.md)**. ## The provider @@ -23,7 +23,7 @@ Nothing else in the file mentions OAuth. `main()` never sees a token. ### Client metadata -`OAuthClientMetadata` is the real RFC 7591 registration document, as a Pydantic model. +`OAuthClientMetadata` is the real [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591) registration document, as a Pydantic model. You set three fields. The defaults fill in the rest: `grant_types` is already `["authorization_code", "refresh_token"]` and `response_types` is already `["code"]`, which is exactly the flow this provider runs. @@ -70,7 +70,7 @@ A real client runs a small local HTTP server on the redirect URI instead of call Look at `main()`. The provider goes on the **httpx client**, the httpx client goes into `streamable_http_client(url, http_client=...)`, and that transport goes into `Client`. -`streamable_http_client` has no `auth=` keyword. Anything HTTP-level (auth, headers, timeouts, proxies) belongs on the `httpx.AsyncClient` you bring. That layering is **Client transports**. +`streamable_http_client` has no `auth=` keyword. Anything HTTP-level (auth, headers, timeouts, proxies) belongs on the `httpx.AsyncClient` you bring. That layering is **[Client transports](../client/transports.md)**. ## What the provider does for you @@ -119,7 +119,7 @@ By default the secret travels as HTTP Basic auth on the token request (`client_s the same pattern: construct one, put it on `auth=`. The same module ships `SignedJWTParameters` and `static_assertion_provider`, two helpers that build its assertion. -There is one more no-human situation: the client belongs to an enterprise whose identity provider, not the user, decides which MCP servers it may reach. That is a different grant with its own trust model and its own chapter, **Identity assertion**. +There is one more no-human situation: the client belongs to an enterprise whose identity provider, not the user, decides which MCP servers it may reach. That is a different grant with its own trust model and its own chapter, **[Identity assertion](identity-assertion.md)**. ## When it fails @@ -136,4 +136,4 @@ Not everything is a flow error. The network can still fail; those are ordinary ` * `ClientCredentialsOAuthProvider` is the no-human version: `client_id` + `client_secret`, no handlers, no browser. * Every OAuth failure is an `OAuthFlowError`; `OAuthRegistrationError` and `OAuthTokenError` are its subclasses. -The other half of this handshake, making your *server* demand the token, is **Authorization**. +The other half of this handshake, making your *server* demand the token, is **[Authorization](authorization.md)**. diff --git a/docs/advanced/opentelemetry.md b/docs/advanced/opentelemetry.md index 0d971d6e08..80fb83f04d 100644 --- a/docs/advanced/opentelemetry.md +++ b/docs/advanced/opentelemetry.md @@ -104,4 +104,4 @@ mcp._lowlevel_server.middleware[:] = [ with no change to your server. * Client-to-server trace context propagates automatically when both sides run the SDK. -Next, the thing that decides whether a request runs at all: **Authorization**. +Next, the thing that decides whether a request runs at all: **[Authorization](authorization.md)**. diff --git a/docs/advanced/pagination.md b/docs/advanced/pagination.md index ef33fa0c32..aac63f4c71 100644 --- a/docs/advanced/pagination.md +++ b/docs/advanced/pagination.md @@ -6,7 +6,7 @@ Most servers never need this. Pagination is for the server whose resource list is really a database: thousands of rows it refuses to serialize in one response. The protocol's answer is a **cursor**: the server returns a page plus an opaque token, and the client sends that token back to get the next page. -`@mcp.resource()` has no hook for any of that. To page, you write the list handler yourself, on the **low-level Server**. +`@mcp.resource()` has no hook for any of that. To page, you write the list handler yourself, on the **[low-level Server](low-level-server.md)**. ## A server that pages @@ -48,7 +48,7 @@ Every `list_*` method on `Client` (`list_tools`, `list_resources`, `list_resourc Run its `main()` and it prints `100 resources`: ten pages of ten, stitched together by a loop that never knew there were ten pages. -This is the same loop **The Client** chapter showed you, and it costs nothing against a server that doesn't page: `next_cursor` is `None` on the first response and the loop runs once. +This is the same loop **[The Client](../client/index.md)** chapter showed you, and it costs nothing against a server that doesn't page: `next_cursor` is `None` on the first response and the loop runs once. ## The three rules @@ -77,4 +77,4 @@ This is the same loop **The Client** chapter showed you, and it costs nothing ag * The client loop: pass `cursor=`, accumulate, repeat until `next_cursor is None`. * Cursors are opaque, the server owns the page size, and a non-paging client still gets page one. -The rest of the hand-written `Server` API (`on_call_tool`, `input_schema` dicts, `_meta`) is **The low-level Server**. +The rest of the hand-written `Server` API (`on_call_tool`, `input_schema` dicts, `_meta`) is **[The low-level Server](low-level-server.md)**. diff --git a/docs/advanced/session-groups.md b/docs/advanced/session-groups.md index e33004c47d..952231b842 100644 --- a/docs/advanced/session-groups.md +++ b/docs/advanced/session-groups.md @@ -68,7 +68,7 @@ If you already hold a connected `ClientSession` (`Client.session` is one), hand ## The classic handshake -`ClientSessionGroup` is built on `ClientSession`, not on `Client`. Each `connect_to_server` runs the classic `initialize` handshake. It never sends the `server/discover` probe described in **Protocol versions**. Every MCP server understands that handshake, so this costs you compatibility with nothing; it only means a group takes the older, slower path to a server that could do better. +`ClientSessionGroup` is built on `ClientSession`, not on `Client`. Each `connect_to_server` runs the classic `initialize` handshake. It never sends the `server/discover` probe described in **[Protocol versions](../client/protocol-versions.md)**. Every MCP server understands that handshake, so this costs you compatibility with nothing; it only means a group takes the older, slower path to a server that could do better. ## Recap @@ -79,4 +79,4 @@ If you already hold a connected `ClientSession` (`Client.session` is one), hand * `component_name_hook=` rewrites every registered name. The dict key changes, the wire name does not. * `connect_with_session` adds a session you already hold; `disconnect_from_server` removes one. -The handshake a group speaks (and the faster one a `Client` prefers) is the subject of **Protocol versions**. +The handshake a group speaks (and the faster one a `Client` prefers) is the subject of **[Protocol versions](../client/protocol-versions.md)**. diff --git a/docs/advanced/uri-templates.md b/docs/advanced/uri-templates.md index 32560f8ecd..51208d7250 100644 --- a/docs/advanced/uri-templates.md +++ b/docs/advanced/uri-templates.md @@ -4,7 +4,7 @@ This is the reference for the URI-template syntax that [`@mcp.resource`](../tutorial/resources.md) accepts, and for the path-safety policy the SDK applies to extracted values. For an introduction to what resources are and when to use them, start with -**Resources**; this page assumes you're already comfortable declaring a +**[Resources](../tutorial/resources.md)**; this page assumes you're already comfortable declaring a resource and want the full operator set, the security knobs, or the low-level wiring. @@ -17,7 +17,7 @@ details (message formats, lifecycle, pagination) see the ## The full operator set -**Resources** showed one placeholder, `{user_id}`. There are four more +**[Resources](../tutorial/resources.md)** showed one placeholder, `{user_id}`. There are four more operator forms; here they are on one server so you can see them next to each other: @@ -201,13 +201,13 @@ These checks are a heuristic pre-filter; for filesystem access, !!! tip If your handler can't fulfil the request (the file doesn't exist, the id is unknown), raise an exception. The SDK turns it into an - error response. See **Handling errors** for the difference between a + error response. See **[Handling errors](../tutorial/handling-errors.md)** for the difference between a protocol error and a tool error. ## Resources on the low-level Server -If you're building on the low-level `Server` (see **The low-level -Server**), you register handlers for the `resources/list` and +If you're building on the low-level `Server` (see **[The low-level +Server](low-level-server.md)**), you register handlers for the `resources/list` and `resources/read` protocol methods directly. There's no decorator; you return the protocol types yourself. diff --git a/docs/client/callbacks.md b/docs/client/callbacks.md index db2c4d7cd0..31a4d635ba 100644 --- a/docs/client/callbacks.md +++ b/docs/client/callbacks.md @@ -15,7 +15,7 @@ Here is a server whose tool can't finish on its own: * `ctx.elicit(...)` sends an `elicitation/create` request **to the client** and waits. * The tool doesn't return until somebody (a person in a form, or your code) supplies a `name`. -That is the server half, and the **Elicitation** chapter owns it. This chapter is the other end of the wire. +That is the server half, and the **[Elicitation](../tutorial/elicitation.md)** chapter owns it. This chapter is the other end of the wire. ## The elicitation callback @@ -31,7 +31,7 @@ That is the server half, and the **Elicitation** chapter owns it. This chapter i !!! tip `params` is a union of the two elicitation modes. Here `params.mode` is `"form"`; a `"url"` request carries `params.url` instead of a schema. One callback handles both; branch on `params.mode`. - **Elicitation** shows the full pattern. + **[Elicitation](../tutorial/elicitation.md)** shows the full pattern. ### Try it @@ -59,11 +59,11 @@ One `tools/call` from you, one `elicitation/create` back from the server, answer protocol path, and that path has no back-channel for server-to-client requests: `ctx.elicit` fails before your callback ever runs. The transport doesn't decide that; the negotiated protocol does, in-memory and over a URL alike. Pin `mode="legacy"` whenever your client has - to answer one; every test behind this page does. **Protocol versions** has the whole story. + to answer one; every test behind this page does. **[Protocol versions](protocol-versions.md)** has the whole story. On a 2026-07-28 session the callback isn't dead, it's fed differently: when a tool returns an `InputRequiredResult` carrying an `ElicitRequest`, `Client` dispatches that entry to the same - `elicitation_callback` and retries the call for you. That flow is **Multi-round-trip requests**. + `elicitation_callback` and retries the call for you. That flow is **[Multi-round-trip requests](../advanced/multi-round-trip.md)**. ## A callback is a capability @@ -113,7 +113,7 @@ Pass all three callbacks and you get `['elicitation', 'sampling', 'roots']`. Pas `sampling_callback` answers `sampling/createMessage`: the server asking *your* model to complete something. `list_roots_callback` answers `roots/list`: the server asking which directories it may work in. -Both work. Both follow the rule above. And both serve RPCs the **2026-07-28 spec removes**: a modern server doesn't call back into your client mid-request, it hands the request back to you as part of the tool result (**Multi-round-trip requests**). The callbacks themselves are not dead. When an `InputRequiredResult` carries a `CreateMessageRequest` or a `ListRootsRequest`, `Client`'s auto-loop dispatches it to the same `sampling_callback` or `list_roots_callback` you registered here. The whole list is in **Deprecated features**. +Both work. Both follow the rule above. And both serve RPCs the **2026-07-28 spec removes**: a modern server doesn't call back into your client mid-request, it hands the request back to you as part of the tool result (**[Multi-round-trip requests](../advanced/multi-round-trip.md)**). The callbacks themselves are not dead. When an `InputRequiredResult` carries a `CreateMessageRequest` or a `ListRootsRequest`, `Client`'s auto-loop dispatches it to the same `sampling_callback` or `list_roots_callback` you registered here. The whole list is in **[Deprecated features](../advanced/deprecated.md)**. You still need the callbacks to talk to servers that haven't moved. The signatures: @@ -131,7 +131,7 @@ Pass them to `Client(...)` exactly like `elicitation_callback`. Two more. Neither declares anything. -`logging_callback` receives every `notifications/message` a server sends, as `LoggingMessageNotificationParams` (`level`, `logger`, `data`). Protocol logging is itself deprecated by the 2026-07-28 spec (**Logging** has what to do instead), so this callback exists for the servers that still emit it. +`logging_callback` receives every `notifications/message` a server sends, as `LoggingMessageNotificationParams` (`level`, `logger`, `data`). Protocol logging is itself deprecated by the 2026-07-28 spec (**[Logging](../tutorial/logging.md)** has what to do instead), so this callback exists for the servers that still emit it. `message_handler` is the catch-all: every server notification reaches it (as well as its specific callback), and on a stream-backed transport so does every transport-level `Exception`. The one pattern worth knowing is `if isinstance(message, Exception): raise message`, so a broken connection fails loudly instead of vanishing. @@ -144,4 +144,4 @@ Two more. Neither declares anything. * `sampling_callback` and `list_roots_callback` work the same way but serve deprecated features; modern servers use multi-round-trip requests instead. * `logging_callback` and `message_handler` receive notifications. They declare nothing. -Next: the first argument you've been passing to `Client(...)` all along, **Client transports**. +Next: the first argument you've been passing to `Client(...)` all along, **[Client transports](transports.md)**. diff --git a/docs/client/index.md b/docs/client/index.md index 38efa72b69..a8026b5b90 100644 --- a/docs/client/index.md +++ b/docs/client/index.md @@ -24,7 +24,7 @@ The server at the top is only there so you have something to connect to. The cli * A URL string (`Client("http://localhost:8000/mcp")`): Streamable HTTP, the production path. * A **transport**: anything you can `async with ... as (read, write)`, such as `stdio_client(...)` wrapping a subprocess. -Everything else on this page is identical across all three. Headers, subprocesses, timeouts, and the `Transport` protocol get their own chapter: **Client transports**. +Everything else on this page is identical across all three. Headers, subprocesses, timeouts, and the `Transport` protocol get their own chapter: **[Client transports](transports.md)**. ### What's on a connected client @@ -35,7 +35,7 @@ Four read-only properties, populated the moment you enter the block: * `client.protocol_version`: the protocol version the two sides agreed on. Here it is `"2026-07-28"`. * `client.instructions`: the server's `instructions=` string, or `None` if it didn't set one. -You never picked a protocol version. By default the `Client` probes the server and falls back to the classic handshake on older ones, so one client works against any era of server. When you need to control that, **Protocol versions** has the whole story. +You never picked a protocol version. By default the `Client` probes the server and falls back to the classic handshake on older ones, so one client works against any era of server. When you need to control that, **[Protocol versions](protocol-versions.md)** has the whole story. !!! tip `client.session` is the underlying `ClientSession`, the low-level escape hatch. @@ -104,7 +104,7 @@ That is why `main` narrows with `isinstance(block, TextContent)` before touching `structured_content` is the tool's return value as JSON, matching the tool's declared `output_schema`. No string parsing, no guessing. -When both are present they say the same thing twice on purpose: `content` is for a model, `structured_content` is for code. Where the structured half comes from, and how to control it, is the **Structured Output** chapter. +When both are present they say the same thing twice on purpose: `content` is for a model, `structured_content` is for code. Where the structured half comes from, and how to control it, is the **[Structured Output](../tutorial/structured-output.md)** chapter. ### `is_error`: whether the tool failed @@ -129,7 +129,7 @@ A tool that raises does **not** raise in your client. It comes back as an ordina (`call_tool("does_not_exist", {})`) and nothing raises. You get the same shape back, `is_error=True` with `Unknown tool: does_not_exist` in `content`. A `Client` method raises `MCPError` only when the server answers with a JSON-RPC **error** instead of a result, and - **Handling errors** covers when a server produces which. + **[Handling errors](../tutorial/handling-errors.md)** covers when a server produces which. ## Resources @@ -145,7 +145,7 @@ The resource verbs come in pairs: two ways to list, one way to read. `read_resource` returns `contents`, a list of `TextResourceContents` or `BlobResourceContents`. Same idea as tool content: narrow with `isinstance`, then read `.text` (or `.blob`). -A client can also **subscribe** to a resource and be told when it changes: `subscribe_resource(uri)` and `unsubscribe_resource(uri)`, same shape as everything else here. `MCPServer` doesn't implement that half. It says so up front (`server_capabilities.resources.subscribe` is `False`) and answers the request with an `MCPError`: `-32601`, *Method not found*. A server that does support subscriptions is built on the low-level `Server` (**The low-level Server**). +A client can also **subscribe** to a resource and be told when it changes: `subscribe_resource(uri)` and `unsubscribe_resource(uri)`, same shape as everything else here. `MCPServer` doesn't implement that half. It says so up front (`server_capabilities.resources.subscribe` is `False`) and answers the request with an `MCPError`: `-32601`, *Method not found*. A server that does support subscriptions is built on the low-level `Server` (**[The low-level Server](../advanced/low-level-server.md)**). ## Prompts @@ -181,7 +181,7 @@ A server with a completion handler can autocomplete prompt and resource-template * `ref` says *which* prompt or template you're filling in: a `PromptReference` or a `ResourceTemplateReference`. * `argument` is `{"name": ..., "value": ...}`: the argument and what the user has typed so far. -The answer is in `result.completion.values`. Type `"p"` and the server comes back with `['poetry']`. The server side, and how a handler uses the *other* already-filled arguments to narrow its suggestions, is the **Completions** chapter. +The answer is in `result.completion.values`. Type `"p"` and the server comes back with `['poetry']`. The server side, and how a handler uses the *other* already-filled arguments to narrow its suggestions, is the **[Completions](../tutorial/completions.md)** chapter. ## Pagination @@ -191,13 +191,13 @@ Every `list_*` method takes a `cursor=` keyword and every result carries a `next --8<-- "docs_src/client/tutorial007.py" ``` -This loop is correct against every server. `MCPServer` returns everything in one page, so `next_cursor` is `None` and the loop runs once, which is why most code never writes it. Servers that genuinely page, and the rules cursors obey, are in **Pagination**. +This loop is correct against every server. `MCPServer` returns everything in one page, so `next_cursor` is `None` and the loop runs once, which is why most code never writes it. Servers that genuinely page, and the rules cursors obey, are in **[Pagination](../advanced/pagination.md)**. ## In tests `Client(mcp)` with no process and no port is already a test harness for your server. -There is one constructor flag built for that: `Client(mcp, raise_exceptions=True)`. It only has an effect on in-memory connections, and **Testing** is the chapter that explains it and builds the whole pattern around it. +There is one constructor flag built for that: `Client(mcp, raise_exceptions=True)`. It only has an effect on in-memory connections, and **[Testing](../tutorial/testing.md)** is the chapter that explains it and builds the whole pattern around it. ## Recap @@ -209,4 +209,4 @@ There is one constructor flag built for that: `Client(mcp, raise_exceptions=True * `list_resources` / `list_resource_templates` / `read_resource`, `list_prompts` / `get_prompt`, and `complete` round out the verbs. * Every `list_*` takes `cursor=`; loop until `next_cursor` is `None`. -Next: the things a server can ask the *client* for, and how you answer, in **Client callbacks**. +Next: the things a server can ask the *client* for, and how you answer, in **[Client callbacks](callbacks.md)**. diff --git a/docs/client/protocol-versions.md b/docs/client/protocol-versions.md index 323cc9cd48..0d4b9ab974 100644 --- a/docs/client/protocol-versions.md +++ b/docs/client/protocol-versions.md @@ -48,9 +48,9 @@ You want this for the **push-style** features. A server-initiated request is the server calling *you*: `ctx.elicit(...)` putting a form in front of your user, sampling asking your model for a completion mid-tool-call. That channel only exists on a handshake-era session. -At 2026-07-28 it is gone. The server *returns* its questions and you retry the call with the answers (**Multi-round-trip requests**). +At 2026-07-28 it is gone. The server *returns* its questions and you retry the call with the answers (**[Multi-round-trip requests](../advanced/multi-round-trip.md)**). -`mode="auto"` only gives you a handshake when the server is too old for anything else. `mode="legacy"` guarantees one. Reach for it whenever you hand `Client(...)` a `sampling_callback`, an `elicitation_callback` you want driven as a request, or a `message_handler`. **Client callbacks** goes through each. +`mode="auto"` only gives you a handshake when the server is too old for anything else. `mode="legacy"` guarantees one. Reach for it whenever you hand `Client(...)` a `sampling_callback`, an `elicitation_callback` you want driven as a request, or a `message_handler`. **[Client callbacks](callbacks.md)** goes through each. ## Pinning a version @@ -124,4 +124,4 @@ The second connection made **zero** negotiation round trips and still knows exac * A version pin (`mode="2026-07-28"`) sends no negotiation traffic at all, at the cost of a blank `server_info`. * `prior_discover=` pays that cost back: save `client.session.discover_result`, reconnect with it, get both. -A modern connection has no push channel, so how does a 2026 server ask you a question mid-call? It returns it: **Multi-round-trip requests**. +A modern connection has no push channel, so how does a 2026 server ask you a question mid-call? It returns it: **[Multi-round-trip requests](../advanced/multi-round-trip.md)**. diff --git a/docs/client/transports.md b/docs/client/transports.md index c47669267a..1503979a3a 100644 --- a/docs/client/transports.md +++ b/docs/client/transports.md @@ -4,7 +4,7 @@ Every `Client` talks to its server over a **transport**: the thing that actually You never configure one separately. `Client` takes a single positional argument and works the transport out from its type. -The *server* side of each (what `mcp.run()` does and what you deploy) is **Running your server**. +The *server* side of each (what `mcp.run()` does and what you deploy) is **[Running your server](../run/index.md)**. ## In memory @@ -18,7 +18,7 @@ No subprocess, no port, no bytes on a wire. The client and the server are two ob That makes it two things at once: -* **A test harness.** Every example in this documentation is exercised this way, and the **Testing** chapter builds the whole pattern around it. +* **A test harness.** Every example in this documentation is exercised this way, and the **[Testing](../tutorial/testing.md)** chapter builds the whole pattern around it. * **An embedding API.** An application that constructs the server doesn't need a network hop to call its tools. ## Streamable HTTP @@ -68,7 +68,7 @@ Two things to notice: !!! info If you know `httpx`, you already know how to do auth, proxies, event hooks, retries and connection limits here. The SDK adds nothing on top and takes nothing away. It is also where OAuth plugs in: - `httpx.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **OAuth clients**. + `httpx.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **[OAuth clients](../advanced/oauth-clients.md)**. ## stdio @@ -112,4 +112,4 @@ A **transport** is any async context manager that yields a `(read, write)` pair * A transport is anything you can `async with x as (read, write)`. `Client` hands anything that isn't a server object or a URL straight to that protocol. * Constructing a `Client` picks the transport. `async with` opens it. -Once the transport is open the two sides have to agree on a protocol version. You normally never think about it; when you do, **Protocol versions** is the page. +Once the transport is open the two sides have to agree on a protocol version. You normally never think about it; when you do, **[Protocol versions](protocol-versions.md)** is the page. diff --git a/docs/installation.md b/docs/installation.md index 13f56feecb..bc2a8281cf 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -40,10 +40,10 @@ You don't need to know any of this to use the SDK, but if you're wondering what * [`jsonschema`](https://pypi.org/project/jsonschema/): validates a tool's structured output against its declared output schema. * [`pyjwt[crypto]`](https://pyjwt.readthedocs.io/): OAuth token handling for authorization. * [`opentelemetry-api`](https://opentelemetry-python.readthedocs.io/): just the lightweight API, so the SDK's tracing middleware costs nothing unless you install an OpenTelemetry SDK and exporter yourself. -* [`typing-extensions`](https://typing-extensions.readthedocs.io/) and `typing-inspection`: modern typing features on Python 3.10. -* `pywin32`: Windows only, used for `stdio` subprocess management. +* [`typing-extensions`](https://typing-extensions.readthedocs.io/) and [`typing-inspection`](https://pypi.org/project/typing-inspection/): modern typing features on Python 3.10. +* [`pywin32`](https://pypi.org/project/pywin32/): Windows only, used for `stdio` subprocess management. ## Optional extras -* `mcp[cli]` adds [`typer`](https://typer.tiangolo.com/) and `python-dotenv` for the `mcp` command-line tool (`mcp dev`, `mcp run`, `mcp install`). You'll want this during development; you may not need it in a deployed server. +* `mcp[cli]` adds [`typer`](https://typer.tiangolo.com/) and [`python-dotenv`](https://pypi.org/project/python-dotenv/) for the `mcp` command-line tool (`mcp dev`, `mcp run`, `mcp install`). You'll want this during development; you may not need it in a deployed server. * `mcp[rich]` adds [`rich`](https://rich.readthedocs.io/) for nicer server logs. diff --git a/docs/migration.md b/docs/migration.md index 8c1378d118..79c15d91f6 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -79,7 +79,7 @@ v1's internal client set `follow_redirects=True`; set it explicitly when supplyi ### OAuth `callback_handler` returns `AuthorizationCodeResult` -The `callback_handler` passed to `OAuthClientProvider` now returns an `AuthorizationCodeResult` instead of a `tuple[str, str | None]` of `(code, state)`. The new object adds an `iss` field so the client can validate the RFC 9207 authorization-response issuer (SEP-2468): when the redirect carries an `iss` query parameter it must match the authorization server's issuer, and a missing `iss` is rejected when the server advertised `authorization_response_iss_parameter_supported`. +The `callback_handler` passed to `OAuthClientProvider` now returns an `AuthorizationCodeResult` instead of a `tuple[str, str | None]` of `(code, state)`. The new object adds an `iss` field so the client can validate the [RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207) authorization-response issuer ([SEP-2468](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468)): when the redirect carries an `iss` query parameter it must match the authorization server's issuer, and a missing `iss` is rejected when the server advertised `authorization_response_iss_parameter_supported`. **Before (v1):** @@ -403,11 +403,11 @@ On the high-level `Client`, `call_tool`, `get_prompt`, and `read_resource` resol On `ClientSession`, `call_tool` / `get_prompt` / `read_resource` still return the bare result and raise `RuntimeError` if the server requests input. Pass `allow_input_required=True` to receive the `InputRequiredResult` instead, then drive the loop yourself with `input_responses=` / `request_state=`. `ClientSessionGroup.call_tool` accepts the same flag. -### `call_tool` mirrors `x-mcp-header` arguments into `Mcp-Param-*` headers (SEP-2243) +### `call_tool` mirrors `x-mcp-header` arguments into `Mcp-Param-*` headers ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)) For protocol 2026-07-28 over Streamable HTTP, a tool's input-schema property may carry an `x-mcp-header` annotation. When a tool the client has listed is called, each annotated argument is mirrored into an `Mcp-Param-` request header (string verbatim, integer as decimal, boolean as `true`/`false`, base64-sentinel-wrapped when not header-safe; `null`/absent arguments are omitted). The argument is also left in the request body. `list_tools` caches a tool's annotations, so list a tool before calling it to enable mirroring; a tool the client never listed emits no `Mcp-Param-*` headers. Other transports ignore the annotation. -### Server extensions API (SEP-2133) +### Server extensions API ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)) `MCPServer` now accepts opt-in extensions that bundle MCP behaviour behind a reverse-DNS identifier and advertise it under `ServerCapabilities.extensions` @@ -655,7 +655,7 @@ The underlying lookups now raise typed exceptions instead of `ValueError`. `Reso ### Resource templates: matching behavior changes -Resource template matching has been rewritten with RFC 6570 support. +Resource template matching has been rewritten with [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) support. Several behaviors have changed: **Path-safety checks applied by default.** Extracted parameter values @@ -768,7 +768,7 @@ On the high-level `Context` object (`mcp.server.mcpserver.Context`), `log()`, `. The lowlevel `ServerSession.send_log_message(data: Any)` already accepted arbitrary data and is unchanged. -`Context.log()` also now accepts all eight RFC-5424 log levels (`debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, `emergency`) via the `LoggingLevel` type, not just the four it previously allowed. +`Context.log()` also now accepts all eight [RFC-5424](https://datatracker.ietf.org/doc/html/rfc5424) log levels (`debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, `emergency`) via the `LoggingLevel` type, not just the four it previously allowed. ```python # Before @@ -1443,7 +1443,7 @@ Behavior changes: ### Experimental Tasks support removed -Tasks (SEP-1686) have been removed from the MCP specification and are no longer part of this SDK. The `mcp.client.experimental`, `mcp.server.experimental`, `mcp.shared.experimental`, and `mcp.server.lowlevel.experimental` modules have been removed, along with the `experimental` properties on `ClientSession`, `ServerSession`, `Server`, and `ServerRequestContext`. The corresponding `Task*` types remain in `mcp_types` as types-only definitions. +Tasks ([SEP-1686](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1686)) have been removed from the MCP specification and are no longer part of this SDK. The `mcp.client.experimental`, `mcp.server.experimental`, `mcp.shared.experimental`, and `mcp.server.lowlevel.experimental` modules have been removed, along with the `experimental` properties on `ClientSession`, `ServerSession`, `Server`, and `ServerRequestContext`. The corresponding `Task*` types remain in `mcp_types` as types-only definitions. Tasks are expected to return as a separate MCP extension in a future release. @@ -1486,14 +1486,14 @@ On the server side, prefer the new dispatcher-agnostic `ServerSession.report_pro `url_preserve_empty_path=True` (Pydantic 2.12+). A path-less URL parsed from the wire keeps its empty path instead of acquiring a trailing slash, so e.g. an `issuer` of `https://as.example.com` round-trips as `https://as.example.com` rather than `https://as.example.com/`. This matters for -RFC 9207 / RFC 8414 issuer comparisons, which require simple string comparison (RFC 3986 §6.2.1). +[RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207) / [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) issuer comparisons, which require simple string comparison ([RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986) §6.2.1). URLs constructed in Python from an already-built `AnyHttpUrl` object are unaffected (they were normalized at construction); only values parsed from strings/JSON change. This also changes the wire form of `OAuthClientMetadata.redirect_uris`: a path-less redirect URI passed as a string (e.g. `redirect_uris=['http://localhost:8080']`) now serializes as `http://localhost:8080` instead of `http://localhost:8080/`, and the client sends it verbatim in -the `/authorize` and token-exchange requests. RFC 6749 §3.1.2.3 requires authorization servers to +the `/authorize` and token-exchange requests. [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) §3.1.2.3 requires authorization servers to match redirect URIs by exact string comparison, so if you registered such a URI with a previous SDK release (with the trailing slash) and the registration is persisted in `TokenStorage`, re-register the client so the stored value matches what the SDK now transmits. @@ -1542,15 +1542,15 @@ If you relied on extra fields round-tripping through MCP types, move that data i ## New Features -### OAuth client credentials are bound to their authorization server (SEP-2352) +### OAuth client credentials are bound to their authorization server ([SEP-2352](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2352)) Persisted OAuth client credentials are now bound to the authorization server that issued them: `OAuthClientInformationFull` records an `issuer`, set by the SDK after registration. When a server's protected resource metadata later points at a different authorization server, the client discards the bound credentials (and the old tokens) and re-registers with the new server instead of presenting one server's `client_id` to another. URL-based client IDs (CIMD) are portable and unaffected; credentials with no recorded issuer (pre-registered, or stored before this change) are left as-is. No API change for existing `TokenStorage` implementations - the `issuer` round-trips through the unchanged `get_client_info`/`set_client_info`. -### Step-up authorization unions previously requested scopes (SEP-2350) +### Step-up authorization unions previously requested scopes ([SEP-2350](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2350)) When a `403 insufficient_scope` challenge triggers step-up re-authorization, the OAuth client now requests the union of the previously requested scopes and the newly challenged scopes, instead of replacing the scope with only the challenged ones. This keeps permissions granted for earlier operations from being dropped when a later operation escalates. No API change; the wider scope is sent automatically on the re-authorization request. -### OAuth Dynamic Client Registration sends `application_type` (SEP-837) +### OAuth Dynamic Client Registration sends `application_type` ([SEP-837](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/837)) `OAuthClientMetadata` now carries an `application_type` field that is sent during Dynamic Client Registration. It defaults to `"native"`, which suits MCP clients that use loopback redirect URIs (CLI and desktop apps); browser-based clients served from a non-local host should set it to `"web"`: @@ -1567,7 +1567,7 @@ Under OIDC, omitting `application_type` defaults to `"web"`, which an authorizat ### Identity Assertion Authorization Grant for enterprise IdP flows (SEP-990) -The SDK now supports SEP-990's enterprise identity-provider policy controls. The client presents an Identity Assertion Authorization Grant (ID-JAG) - a signed JWT issued by the enterprise IdP - to the MCP authorization server using the RFC 7523 jwt-bearer grant (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, the ID-JAG as `assertion`), and receives an MCP access token. This matches the SEP-990 normative profile and interoperates with the other MCP SDKs. (Leg 1 - exchanging the user's IdP ID token for the ID-JAG against the IdP - is deployment-specific and out of scope for the SDK.) This is additive and opt-in on both sides; existing flows are unchanged. +The SDK now supports [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990)'s enterprise identity-provider policy controls. The client presents an Identity Assertion Authorization Grant (ID-JAG) - a signed JWT issued by the enterprise IdP - to the MCP authorization server using the [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) jwt-bearer grant (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, the ID-JAG as `assertion`), and receives an MCP access token. This matches the SEP-990 normative profile and interoperates with the other MCP SDKs. (Leg 1 - exchanging the user's IdP ID token for the ID-JAG against the IdP - is deployment-specific and out of scope for the SDK.) This is additive and opt-in on both sides; existing flows are unchanged. On the client, `IdentityAssertionOAuthProvider` (in `mcp.client.auth.extensions.identity_assertion`) is an `httpx.Auth` that posts the jwt-bearer request. The ID-JAG is supplied lazily through an async `assertion_provider(audience, resource)` callback - `audience` is the authorization server's issuer (the ID-JAG `aud`) and `resource` is the MCP server's identifier (the ID-JAG `resource` claim): @@ -1590,9 +1590,9 @@ provider = IdentityAssertionOAuthProvider( ) ``` -SEP-990 §5.1 requires the client to authenticate; this SDK currently requires a shared secret, so `client_secret` is mandatory (`token_endpoint_auth_method` chooses `client_secret_post` (default) or `client_secret_basic`; the spec also permits `private_key_jwt`). The authorization server is configuration, not discovery: `issuer` is the AS the client is provisioned for, authorization-server metadata is fetched from that issuer's RFC 8414 well-known, and the resource server is never asked which AS to use - so a hostile resource server cannot redirect the ID-JAG or secret. +SEP-990 §5.1 requires the client to authenticate; this SDK currently requires a shared secret, so `client_secret` is mandatory (`token_endpoint_auth_method` chooses `client_secret_post` (default) or `client_secret_basic`; the spec also permits `private_key_jwt`). The authorization server is configuration, not discovery: `issuer` is the AS the client is provisioned for, authorization-server metadata is fetched from that issuer's [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) well-known, and the resource server is never asked which AS to use - so a hostile resource server cannot redirect the ID-JAG or secret. -On the authorization server, set `AuthSettings(identity_assertion_enabled=True)` (or pass `identity_assertion_enabled=True` to `create_auth_routes`) and implement `exchange_identity_assertion` on your `OAuthAuthorizationServerProvider`. The method receives an `IdentityAssertionParams` (the ID-JAG `assertion`, requested scopes, and request `resource`) and returns a plain RFC 6749 `OAuthToken`. The flag gates both metadata advertisement and the token endpoint: when off, `/token` rejects the grant with `unsupported_grant_type` even if the provider implements the hook. When on, the metadata advertises the jwt-bearer grant and the `urn:ietf:params:oauth:grant-profile:id-jag` profile in `authorization_grant_profiles_supported` (the discovery mechanism per ext-auth §6). +On the authorization server, set `AuthSettings(identity_assertion_enabled=True)` (or pass `identity_assertion_enabled=True` to `create_auth_routes`) and implement `exchange_identity_assertion` on your `OAuthAuthorizationServerProvider`. The method receives an `IdentityAssertionParams` (the ID-JAG `assertion`, requested scopes, and request `resource`) and returns a plain [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) `OAuthToken`. The flag gates both metadata advertisement and the token endpoint: when off, `/token` rejects the grant with `unsupported_grant_type` even if the provider implements the hook. When on, the metadata advertises the jwt-bearer grant and the `urn:ietf:params:oauth:grant-profile:id-jag` profile in `authorization_grant_profiles_supported` (the discovery mechanism per ext-auth §6). The implementation is responsible for validating the assertion per RFC 7523 §3 and SEP-990 §5.1 - verify the signature/`iss`/`exp`/`typ`, require `aud` to be this AS, require the ID-JAG's `client_id` claim to match the authenticated client, audience-restrict the issued token to the ID-JAG's `resource` claim (not the client-controlled request `resource`), and derive scopes from the ID-JAG rather than granting the request verbatim. See `examples/snippets/servers/identity_assertion_server.py`, which fails closed. Two hardening points are enforced by the SDK: the handler rejects clients without a stored secret before calling the hook (and `ClientAuthenticator` itself now refuses a secret-based auth method registered without a secret), and Dynamic Client Registration refuses the jwt-bearer grant so the ID-JAG flow requires a pre-registered confidential client. diff --git a/docs/run/asgi.md b/docs/run/asgi.md index 2a21489a16..c72becb888 100644 --- a/docs/run/asgi.md +++ b/docs/run/asgi.md @@ -30,7 +30,7 @@ Run the app on its own (`uvicorn server:app`) and you never think about either. !!! tip `streamable_http_app()` takes the same keyword arguments as `mcp.run("streamable-http", ...)`, minus `port`: the port belongs to whatever serves the app. `host` is still accepted but binds - nothing here; the next section is what it actually controls. **Running your server** covers the + nothing here; the next section is what it actually controls. **[Running your server](index.md)** covers the options themselves. `mcp.sse_app()` does the same for the superseded SSE transport. @@ -168,4 +168,4 @@ A browser-based client needs two permissions from you: to **send** its MCP reque * Browser clients need CORS: `allow_headers` for the `Mcp-*` request headers, `expose_headers=["Mcp-Session-Id"]` for the response. * `@mcp.custom_route()` adds plain, unauthenticated HTTP endpoints next to `/mcp`. -Once the server is reachable at a real URL, **The Client** connects to it with that URL instead of a server object. +Once the server is reachable at a real URL, **[The Client](../client/index.md)** connects to it with that URL instead of a server object. diff --git a/docs/run/index.md b/docs/run/index.md index da6bb2bfd1..aafb1f3330 100644 --- a/docs/run/index.md +++ b/docs/run/index.md @@ -39,7 +39,7 @@ python server.py Nothing prints, and it doesn't return. It is waiting on stdin for a host to speak first. -That also means stdout **is the wire**. A stray `print()` corrupts the stream; the `logging` module writes to stderr and is the right tool. That story is in **Logging**. +That also means stdout **is the wire**. A stray `print()` corrupts the stream; the `logging` module writes to stderr and is the right tool. That story is in **[Logging](../tutorial/logging.md)**. ### Try it @@ -67,7 +67,7 @@ Each transport has its own keyword arguments, all on `run()`: * `streamable_http_path`: where the MCP endpoint lives. Default `/mcp`. * `json_response=True`: answer with plain JSON instead of an SSE stream. * `stateless_http=True`: a fresh transport per request, no session tracking. -* `event_store`, `retry_interval`, `transport_security`: resumability and DNS-rebinding protection. They can wait, until you deploy somewhere other than localhost; **ASGI** covers `transport_security`. +* `event_store`, `retry_interval`, `transport_security`: resumability and DNS-rebinding protection. They can wait, until you deploy somewhere other than localhost; **[ASGI](asgi.md)** covers `transport_security`. !!! warning Transport options go to `run()`, **not** to `MCPServer(...)`. The constructor describes what @@ -78,7 +78,7 @@ Each transport has its own keyword arguments, all on `run()`: TypeError: MCPServer.__init__() got an unexpected keyword argument 'port' ``` -`run()` is the short road. The moment you need more (your server mounted inside an existing app, two servers in one process, CORS for browser clients), you build the ASGI app yourself and hand it to any ASGI host. That is **ASGI**. +`run()` is the short road. The moment you need more (your server mounted inside an existing app, two servers in one process, CORS for browser clients), you build the ASGI app yourself and hand it to any ASGI host. That is **[ASGI](asgi.md)**. ## Server settings @@ -131,7 +131,7 @@ uv run mcp install server.py -v API_KEY=abc123 -f .env !!! tip `mcp dev` and `mcp run` only understand `MCPServer`. If you build with the low-level `Server`, - you run it yourself. See **The low-level Server**. + you run it yourself. See **[The low-level Server](../advanced/low-level-server.md)**. ## Recap @@ -143,4 +143,4 @@ uv run mcp install server.py -v API_KEY=abc123 -f .env * `mcp dev` for the Inspector, `mcp run` to execute a file, `mcp install` for Claude Desktop, `mcp version` for the version. * The transport never changes what your server *is*: all three files on this page expose the identical tool. -When `run()` itself is the limit (your server inside an app that already exists), the next step is **ASGI**. +When `run()` itself is the limit (your server inside an app that already exists), the next step is **[ASGI](asgi.md)**. diff --git a/docs/tutorial/completions.md b/docs/tutorial/completions.md index e1d1815a13..31cc8f0820 100644 --- a/docs/tutorial/completions.md +++ b/docs/tutorial/completions.md @@ -39,7 +39,7 @@ Add **one** function decorated with `@mcp.completion()`: ### Try it -Drive it with the in-memory `Client`, the same one you use in **Testing**. Call +Drive it with the in-memory `Client`, the same one you use in **[Testing](testing.md)**. Call `client.complete()` with `ref=PromptReference(name="review_code")` and `argument={"name": "language", "value": "py"}`: @@ -122,4 +122,4 @@ Drop `context_arguments=` and the same call returns `[]`. The handler can't know * `context.arguments` holds the already-resolved values; the client supplies them as `context_arguments=`. * The `completions` capability appears the moment you register the handler. Without it, the request is `Method not found`. -Suggestions help *before* a tool runs. To ask the user a question in the *middle* of one, you want **Elicitation**. +Suggestions help *before* a tool runs. To ask the user a question in the *middle* of one, you want **[Elicitation](elicitation.md)**. diff --git a/docs/tutorial/context.md b/docs/tutorial/context.md index 17af592fb5..c2d43c4726 100644 --- a/docs/tutorial/context.md +++ b/docs/tutorial/context.md @@ -60,13 +60,13 @@ The number is whichever request this happened to be. Call the tool again and it The injected object is small. Besides `request_id`: * `await ctx.read_resource(uri)`: read one of the server's **own** resources from inside a tool. The next section. -* `await ctx.report_progress(progress, total, message)`: stream progress back to the caller during a long call. The whole story is in **Progress**. -* `await ctx.elicit(message, schema)` and `await ctx.elicit_url(...)`: pause the tool and ask the user a question. That's **Elicitation**. +* `await ctx.report_progress(progress, total, message)`: stream progress back to the caller during a long call. The whole story is in **[Progress](progress.md)**. +* `await ctx.elicit(message, schema)` and `await ctx.elicit_url(...)`: pause the tool and ask the user a question. That's **[Elicitation](elicitation.md)**. * `ctx.session`: the server's side of the conversation with this client. Notifications you send to the client live here; the last section uses it. * `ctx.headers`: the request headers the transport carried, or `None` on stdio. Read a custom header with `(ctx.headers or {}).get("x-...")`. Headers are client-supplied input - fine for a locale or a feature flag, never an identity. -* `ctx.request_context`: the raw per-request record. The field you'll reach for is `lifespan_context`, the object your startup code yielded (see **Lifespan**). +* `ctx.request_context`: the raw per-request record. The field you'll reach for is `lifespan_context`, the object your startup code yielded (see **[Lifespan](lifespan.md)**). -Logging is deliberately not on that list. A server logs with Python's `logging` module, like any other Python program. **Logging** is the short chapter on why. +Logging is deliberately not on that list. A server logs with Python's `logging` module, like any other Python program. **[Logging](logging.md)** is the short chapter on why. !!! tip Injection only happens for the function you registered. A helper that your tool calls doesn't get @@ -124,4 +124,4 @@ The siblings are `send_resource_list_changed()`, `send_prompt_list_changed()`, a * `ctx.session` is the channel back to the client: `send_tool_list_changed()` and its siblings tell it to re-fetch a list you changed. * Progress reporting and elicitation also start at `Context`; each has its own chapter. -Next: parameters the model never sees, filled by your own functions, in **Dependencies**. +Next: parameters the model never sees, filled by your own functions, in **[Dependencies](dependencies.md)**. diff --git a/docs/tutorial/dependencies.md b/docs/tutorial/dependencies.md index 0631ccd8f7..e9b4c789bc 100644 --- a/docs/tutorial/dependencies.md +++ b/docs/tutorial/dependencies.md @@ -35,7 +35,7 @@ Here is the input schema `tools/list` reports for `reserve_book`: } ``` -One property. Like the `Context` in **The Context**, a resolved parameter is a contract between you and the SDK: `stock` is not in the schema, the model is never told about it, and a client that sends a `stock` value anyway is ignored. The resolver's value is the only one your tool can receive. +One property. Like the `Context` in **[The Context](context.md)**, a resolved parameter is a contract between you and the SDK: `stock` is not in the schema, the model is never told about it, and a client that sends a `stock` value anyway is ignored. The resolver's value is the only one your tool can receive. That last part is the point. A parameter the model cannot supply is a parameter the model cannot get wrong. @@ -84,16 +84,16 @@ A resolver's parameters resolve exactly like a tool's: another `Resolve(...)`, t !!! warning On HTTP transports the `Context` includes `ctx.headers`. Headers are **client-supplied input**, like any tool argument: fine for a locale or a feature flag, never an identity. Who the caller - is comes from your authorization layer (**Authorization**), not from a header anyone can set. + is comes from your authorization layer (**[Authorization](../advanced/authorization.md)**), not from a header anyone can set. !!! tip *Once per call* means exactly that: the next `tools/call` runs `check_stock` again. A resource - that should outlive a request - a database pool, an HTTP client - belongs in **Lifespan**, and + that should outlive a request - a database pool, an HTTP client - belongs in **[Lifespan](lifespan.md)**, and a resolver can reach it through `ctx.request_context.lifespan_context`. ## Ask when you must -A resolver doesn't have to know the answer. It can return `Elicit(message, Model)` and the SDK asks the user - the **Elicitation** machinery, run for you: +A resolver doesn't have to know the answer. It can return `Elicit(message, Model)` and the SDK asks the user - the **[Elicitation](elicitation.md)** machinery, run for you: ```python title="server.py" hl_lines="26-32 39" --8<-- "docs_src/dependencies/tutorial003.py" @@ -114,7 +114,7 @@ And if the user won't answer at all - declines the question, or cancels it? Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline ``` -That's the right default for a precondition: no answer, no order. When declining is an outcome your tool wants to handle - skip the backorder but still suggest another title - annotate `ElicitationResult[Backorder]` instead and the tool receives the full accept/decline/cancel outcome to branch on. **Elicitation** shows that form, and everything else about asking: the schema rules, the three answers, the client's side of the conversation. +That's the right default for a precondition: no answer, no order. When declining is an outcome your tool wants to handle - skip the backorder but still suggest another title - annotate `ElicitationResult[Backorder]` instead and the tool receives the full accept/decline/cancel outcome to branch on. **[Elicitation](elicitation.md)** shows that form, and everything else about asking: the schema rules, the three answers, the client's side of the conversation. ## Recap @@ -124,4 +124,4 @@ That's the right default for a precondition: no answer, no order. When declining * Bad graphs fail at registration with `InvalidSignature`, not mid-call. * Return `Elicit(message, Model)` to ask the user, only when you have to. Unwrapped annotations abort on decline; `ElicitationResult[T]` lets the tool branch. -Next: what happens when your tool fails, and how to choose who finds out, in **Handling errors**. +Next: what happens when your tool fails, and how to choose who finds out, in **[Handling errors](handling-errors.md)**. diff --git a/docs/tutorial/elicitation.md b/docs/tutorial/elicitation.md index 8a7b4c335f..aa4f16820f 100644 --- a/docs/tutorial/elicitation.md +++ b/docs/tutorial/elicitation.md @@ -17,7 +17,7 @@ There are two modes: --8<-- "docs_src/elicitation/tutorial001.py" ``` -* The **`Context`** parameter is what gives you `ctx.elicit`; any tool can take one. That object has its own chapter: **The Context**. +* The **`Context`** parameter is what gives you `ctx.elicit`; any tool can take one. That object has its own chapter: **[The Context](context.md)**. * `AlternativeDate` is the **schema** of the answer you want. * The tool is `async def`. It has to be: it stops in the middle and waits for a person. * On any other date the tool returns straight away. It only asks when it has to. @@ -48,7 +48,7 @@ The client gets your message and, next to it, a JSON Schema generated from the m } ``` -That schema is the form. `Field(description=...)` is the label; a default pre-fills the input and makes the field optional. It's the same Pydantic-to-JSON-Schema machinery you already used for a tool's arguments in **Tools**. +That schema is the form. `Field(description=...)` is the label; a default pre-fills the input and makes the field optional. It's the same Pydantic-to-JSON-Schema machinery you already used for a tool's arguments in **[Tools](tools.md)**. !!! warning An elicitation schema is not as expressive as a tool's input schema. Flat, primitive fields @@ -95,7 +95,7 @@ A parameter annotated `Annotated[T, Resolve(fn)]` is filled by running `fn` befo Annotate the unwrapped model (`Annotated[Confirm, Resolve(confirm_delete)]`) instead when the tool doesn't need to branch: it receives the model on accept and the call aborts with an error on decline or cancel. -Asking is only one thing a resolver can do. The general mechanism - dependencies that compute without asking, dependencies of dependencies, what the model can and cannot supply - is the **Dependencies** chapter. +Asking is only one thing a resolver can do. The general mechanism - dependencies that compute without asking, dependencies of dependencies, what the model can and cannot supply - is the **[Dependencies](dependencies.md)** chapter. ## Send the user to a URL @@ -122,17 +122,17 @@ Servers ask. Clients answer by passing an **`elicitation_callback`** to `Client( * One callback handles both modes. `params` is a union of `ElicitRequestFormParams` and `ElicitRequestURLParams`; `isinstance` is the branch. * For a URL, you show `params.url` to the user and return the action they chose. Never any `content`. * For a form, a real application renders `params.requested_schema` and returns the user's input as `content`. This one always says yes with a canned answer, which is exactly the callback you want in a test. -* Passing the callback is also the **capability declaration**: it's how the server learns this client can be asked. The other things a client can answer for a server live in **Client callbacks**. +* Passing the callback is also the **capability declaration**: it's how the server learns this client can be asked. The other things a client can answer for a server live in **[Client callbacks](../client/callbacks.md)**. !!! info Elicitation is a request from the *server* to the *client*, and those only exist on a classic-handshake session, which is why this client passes `mode="legacy"`. On a **2026-07-28** connection a tool asks by *returning* the question from the call - instead; that flow is **Multi-round-trip requests**. + instead; that flow is **[Multi-round-trip requests](../advanced/multi-round-trip.md)**. ### Try it -Start the form-mode `server.py` (the first one on this page) on Streamable HTTP (**Running your server** has the one-liner), then run the client's `main()` and ask `book_table` for Christmas day. +Start the form-mode `server.py` (the first one on this page) on Streamable HTTP (**[Running your server](../run/index.md)** has the one-liner), then run the client's `main()` and ask `book_table` for Christmas day. The callback prints the question it was sent: @@ -167,6 +167,6 @@ Now swap in the URL-mode `server.py` and point the same `main()` at `pay_deposit * `result.action` is `"accept"`, `"decline"` or `"cancel"`; `result.data` exists only on accept. * `await ctx.elicit_url(message, url, elicitation_id)` is for everything that must not pass through the model; `ctx.session.send_elicit_complete(elicitation_id)` says the out-of-band part is done. * The client answers with one `elicitation_callback`, branching on the params type; registering it is what declares the capability. -* On a 2026-07-28 connection the server returns the question instead of pushing it; the same callback is fed by **Multi-round-trip requests**. +* On a 2026-07-28 connection the server returns the question instead of pushing it; the same callback is fed by **[Multi-round-trip requests](../advanced/multi-round-trip.md)**. -A tool that can ask is good. A tool that says how far along it is (**Progress**) is next. +A tool that can ask is good. A tool that says how far along it is (**[Progress](progress.md)**) is next. diff --git a/docs/tutorial/first-steps.md b/docs/tutorial/first-steps.md index ccf1a32b50..ba59c64870 100644 --- a/docs/tutorial/first-steps.md +++ b/docs/tutorial/first-steps.md @@ -70,7 +70,7 @@ Hello, World! **Prompts.** One entry: `summarize`, with a single required `text` argument. Get it with some text and you receive one message with `role: user` and your rendered string as the content. That's all a prompt is: a function that builds messages. -The Inspector ran your server over **stdio**, one of the transports an MCP server can speak. You don't pick one yet; **Running your server** is the chapter for that. +The Inspector ran your server over **stdio**, one of the transports an MCP server can speak. You don't pick one yet; **[Running your server](../run/index.md)** is the chapter for that. ## Capabilities @@ -110,11 +110,11 @@ That dictionary is the server's half of the handshake: `MCPServer` serves all three primitives, so all three are always declared. -Notice what isn't there. `completions` (argument autocomplete for resource templates and prompts) needs a handler you write, this server doesn't have one, so the capability is absent and a well-behaved client won't ask. That's the rule for everything optional: register the thing and the capability appears; **Completions** proves it. +Notice what isn't there. `completions` (argument autocomplete for resource templates and prompts) needs a handler you write, this server doesn't have one, so the capability is absent and a well-behaved client won't ask. That's the rule for everything optional: register the thing and the capability appears; **[Completions](completions.md)** proves it. !!! info `Client(mcp)` is the same in-memory client every example in this tutorial is tested with, and - it's how you'll test yours. It gets a whole chapter: **Testing**. + it's how you'll test yours. It gets a whole chapter: **[Testing](testing.md)**. ## What you did not write @@ -136,4 +136,4 @@ That ratio is the whole point of the SDK. * The server's **capabilities** are declared for you, and a client only asks for what a server declares. * `Client(mcp)` connects to the server object in memory: your test harness from day one. -Each primitive now gets its own chapter, starting with the one the model drives: **Tools**. +Each primitive now gets its own chapter, starting with the one the model drives: **[Tools](tools.md)**. diff --git a/docs/tutorial/handling-errors.md b/docs/tutorial/handling-errors.md index 9ee6dd9817..90efddc243 100644 --- a/docs/tutorial/handling-errors.md +++ b/docs/tutorial/handling-errors.md @@ -104,13 +104,13 @@ When it can't, raise `ResourceNotFoundError`. The SDK turns it into the protocol } ``` -Notice there is no `is_error=True` half-result here. A resource read either returns contents or fails: resources have only the protocol path. Templates and everything else about resources live in **Resources**. +Notice there is no `is_error=True` half-result here. A resource read either returns contents or fails: resources have only the protocol path. Templates and everything else about resources live in **[Resources](resources.md)**. ## Errors you never raise A bad argument never reaches your function. -Send `get_author` a `title` that isn't a string and the SDK rejects it against the input schema **before** calling you, as the same kind of `is_error=True` tool error the model can read and correct. You saw this in **Tools** with `Field(le=50)`. +Send `get_author` a `title` that isn't a string and the SDK rejects it against the input schema **before** calling you, as the same kind of `is_error=True` tool error the model can read and correct. You saw this in **[Tools](tools.md)** with `Field(le=50)`. It means a whole class of `raise` statements you don't write: don't re-validate your own type hints. @@ -118,7 +118,7 @@ It means a whole class of `raise` statements you don't write: don't re-validate Everything on this page is what a **client** sees, and the in-memory `Client` you'll write tests with sees exactly the same thing. Even `raise_exceptions=True` doesn't turn a tool error back into a traceback: by the time that flag could act, your exception is already the - `is_error=True` result. Assert on the result. **Testing** covers the pattern. + `is_error=True` result. Assert on the result. **[Testing](testing.md)** covers the pattern. ## Recap @@ -129,4 +129,4 @@ It means a whole class of `raise` statements you don't write: don't re-validate * Bad arguments are rejected against the schema before your function runs; you don't `raise` for those. * `from mcp import MCPError`; the error-code constants come from `mcp_types`. -Errors handled. Next: the things your server sets up once, before the first call ever arrives, the **Lifespan**. +Errors handled. Next: the things your server sets up once, before the first call ever arrives, the **[Lifespan](lifespan.md)**. diff --git a/docs/tutorial/lifespan.md b/docs/tutorial/lifespan.md index 97ea2d0964..796462f935 100644 --- a/docs/tutorial/lifespan.md +++ b/docs/tutorial/lifespan.md @@ -41,7 +41,7 @@ Nothing new. `ctx` is a **Context** parameter, so the SDK injects it and it neve `genre` is the only argument the model can pass. The lifespan is your server's business. -`@mcp.resource()` and `@mcp.prompt()` functions can take a `ctx` parameter too, written as a bare `Context` for a reason the next section gets to. Everything `ctx` carries is in **The Context**. +`@mcp.resource()` and `@mcp.prompt()` functions can take a `ctx` parameter too, written as a bare `Context` for a reason the next section gets to. Everything `ctx` carries is in **[The Context](context.md)**. ### It really is typed @@ -99,4 +99,4 @@ Strip the server down to the lifecycle: give `Database` a `connected` flag, flip * `ctx: Context[AppContext]` makes that access fully typed in tools. Resources and prompts take the bare `Context`. * No `lifespan=` means an empty `dict`, never `None`. -Next: tools that return more than text, **Media**. +Next: tools that return more than text, **[Media](media.md)**. diff --git a/docs/tutorial/logging.md b/docs/tutorial/logging.md index 628c4ce268..bea34f1c9d 100644 --- a/docs/tutorial/logging.md +++ b/docs/tutorial/logging.md @@ -2,7 +2,7 @@ Log from a tool the way you log from any other Python function: with the standard library. -MCP has a protocol-level **logging capability**: a server could push its log messages to the client as notifications, through methods on the `Context` object. The 2026-07-28 revision of the spec **deprecates that capability and does not replace it**, so this tutorial doesn't teach it. The full list of what's deprecated and what to do instead is in **Deprecated features**. +MCP has a protocol-level **logging capability**: a server could push its log messages to the client as notifications, through methods on the `Context` object. The 2026-07-28 revision of the spec **deprecates that capability and does not replace it**, so this tutorial doesn't teach it. The full list of what's deprecated and what to do instead is in **[Deprecated features](../advanced/deprecated.md)**. What you do instead is what you do in every other Python program: the standard library. @@ -65,7 +65,7 @@ went to standard error: the terminal, not the wire. !!! info If what you actually want is *tracing* (every request, how long it took, whether it failed), you don't want log lines, you want spans. Your server already emits them: the SDK traces every - message with OpenTelemetry out of the box. See **OpenTelemetry**. + message with OpenTelemetry out of the box. See **[OpenTelemetry](../advanced/opentelemetry.md)**. ## Recap @@ -75,4 +75,4 @@ went to standard error: the terminal, not the wire. * Standard error is yours; stdout belongs to the protocol. Never `print()` in a stdio server. * `MCPServer(..., log_level="DEBUG")` sets the level, and a logging configuration you made first is left alone. -Next: the in-memory client that has been running every example on these pages, and how to point it at your own server, in **Testing**. +Next: the in-memory client that has been running every example on these pages, and how to point it at your own server, in **[Testing](testing.md)**. diff --git a/docs/tutorial/media.md b/docs/tutorial/media.md index a473c0bba2..06fde16082 100644 --- a/docs/tutorial/media.md +++ b/docs/tutorial/media.md @@ -26,11 +26,11 @@ result.structured_content # None Two things to notice: * `data` is base64. You returned raw `bytes`; the SDK did the encoding. -* `structured_content` is `None`. An `Image` is content for the model to look at, not data for the application to parse: there is no output schema. (Contrast **Structured Output**, where the return annotation *is* the schema.) +* `structured_content` is `None`. An `Image` is content for the model to look at, not data for the application to parse: there is no output schema. (Contrast **[Structured Output](structured-output.md)**, where the return annotation *is* the schema.) !!! info `ImageContent` and `AudioContent` live in `mcp_types`, right next to the `TextContent` - you met in **Tools**. A tool result is a list of content blocks; `Image` and `Audio` are + you met in **[Tools](tools.md)**. A tool result is a list of content blocks; `Image` and `Audio` are the shortest way to produce the two binary kinds. ### Try it @@ -105,4 +105,4 @@ A tool's icons are on the `Tool` object from `tools/list`, a resource's on the ` * An `Icon` is a pointer: a `src` URI plus optional `mime_type`, `sizes`, and `theme`. * `icons=[...]` works on the server, on tools, on resources, and on prompts, and clients find them on the matching objects. -That is everything a tool can put *into* a result. Helping the user fill in a prompt's or a resource template's arguments *before* anything runs is **Completions**. +That is everything a tool can put *into* a result. Helping the user fill in a prompt's or a resource template's arguments *before* anything runs is **[Completions](completions.md)**. diff --git a/docs/tutorial/progress.md b/docs/tutorial/progress.md index 3267e89193..d553de4735 100644 --- a/docs/tutorial/progress.md +++ b/docs/tutorial/progress.md @@ -18,7 +18,7 @@ Three arguments, and you decide what they mean: * `total`: how much there is in total, if you know. Optional. * `message`: one human-readable line about *this* step. Optional. -`ctx` is injected because of its type hint and the model never sees it: `import_catalog`'s input schema has a single property, `urls`. **The Context** chapter is all about that object; progress is one of the things it gives you. +`ctx` is injected because of its type hint and the model never sees it: `import_catalog`'s input schema has a single property, `urls`. **[The Context](context.md)** chapter is all about that object; progress is one of the things it gives you. ## Listen for it from the client @@ -51,7 +51,7 @@ anyio.run(main) The callback is an `async` function taking exactly what the server reported: `progress`, `total`, `message`. !!! info - `Client(mcp)` connects straight to the server object, in memory, the same client the **Testing** + `Client(mcp)` connects straight to the server object, in memory, the same client the **[Testing](testing.md)** chapter is built on. `progress_callback` is the same parameter whatever transport the `Client` uses; the *timing* you are about to see is the in-memory connection's. It runs your callback inline, so every report lands before `call_tool` returns. Over a real transport the @@ -114,4 +114,4 @@ The callback receives `total=None`. A client can still show *activity* ("3 impor * No callback on the call means `report_progress` does nothing. Report unconditionally. * Omit `total` when you don't know it; the callback gets `None`. -Progress is what a running tool shows the *user*. The lines it logs for *you*, the person operating the server, are a different channel: **Logging** is next. +Progress is what a running tool shows the *user*. The lines it logs for *you*, the person operating the server, are a different channel: **[Logging](logging.md)** is next. diff --git a/docs/tutorial/prompts.md b/docs/tutorial/prompts.md index 44c23fa2e2..c512e96ff9 100644 --- a/docs/tutorial/prompts.md +++ b/docs/tutorial/prompts.md @@ -116,7 +116,7 @@ Notice the last one. Pre-filling an `assistant` turn is how you steer the model' ``` * `title="Code review"` is the human-readable name, exactly like a tool's `title`. -* `Annotated[str, Field(description=...)]` is the same pattern you used in **Tools**. Here the description lands on the argument instead of in a schema. +* `Annotated[str, Field(description=...)]` is the same pattern you used in **[Tools](tools.md)**. Here the description lands on the argument instead of in a schema. * `language` has a default, so it stops being required. The `prompts/list` entry now carries everything a client needs to draw a good form: @@ -134,7 +134,7 @@ The `prompts/list` entry now carries everything a client needs to draw a good fo ``` !!! info - If you have read **Tools**, you already know everything on this page. Same decorator, same + If you have read **[Tools](tools.md)**, you already know everything on this page. Same decorator, same docstring-as-description, same `Annotated`/`Field`. The only things that change are who triggers it (the user) and where the result goes (into the conversation). @@ -147,4 +147,4 @@ The `prompts/list` entry now carries everything a client needs to draw a good fo * `title=` and `Field(description=...)` are what a client puts in its UI. * A missing required argument fails the whole request. There is no per-prompt error result. -Next up: the one extra parameter a tool, resource or prompt can ask the SDK for, **The Context**. +Next up: the one extra parameter a tool, resource or prompt can ask the SDK for, **[The Context](context.md)**. diff --git a/docs/tutorial/resources.md b/docs/tutorial/resources.md index 749b8227d6..8c63053a13 100644 --- a/docs/tutorial/resources.md +++ b/docs/tutorial/resources.md @@ -92,9 +92,9 @@ Notice the `uri` in the result. It is the **concrete** URI the client asked for, A mismatch can only ever be a bug, so the SDK makes it impossible to start the server with one. -The placeholder syntax is RFC 6570: `{+path}` for multi-segment values, `{?q,lang}` for optional query parameters, and more. The SDK also applies path-safety checks to extracted values by default. See **[URI templates and path safety](../advanced/uri-templates.md)** for the full reference. +The placeholder syntax is [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570): `{+path}` for multi-segment values, `{?q,lang}` for optional query parameters, and more. The SDK also applies path-safety checks to extracted values by default. See **[URI templates and path safety](../advanced/uri-templates.md)** for the full reference. -`get_user_profile` can also take a parameter annotated `Context`. The SDK injects it without ever treating it as a URI parameter, and **The Context** chapter covers what it gives you. +`get_user_profile` can also take a parameter annotated `Context`. The SDK injects it without ever treating it as a URI parameter, and **[The Context](context.md)** chapter covers what it gives you. ## What you return @@ -127,7 +127,7 @@ The same rule applies to anything else JSON-serialisable: a list, a Pydantic mod `BinaryResource`, `FileResource`, `HttpResource`, `DirectoryResource`) that you register with `mcp.add_resource(...)`. -A client can also **subscribe** to a resource and be notified when it changes; that's the client's half of the story and it lives in **The Client**. +A client can also **subscribe** to a resource and be notified when it changes; that's the client's half of the story and it lives in **[The Client](../client/index.md)**. ## Recap @@ -138,4 +138,4 @@ A client can also **subscribe** to a resource and be notified when it changes; t * `str` becomes text, `bytes` becomes a base64 blob, anything else becomes JSON text. `mime_type=` is how you label it. * Tools are for the model to act. Resources are for the application to read. -Next: the third primitive, the one a person picks from a menu, **Prompts**. +Next: the third primitive, the one a person picks from a menu, **[Prompts](prompts.md)**. diff --git a/docs/tutorial/structured-output.md b/docs/tutorial/structured-output.md index 7f20b670ad..65ab1794e4 100644 --- a/docs/tutorial/structured-output.md +++ b/docs/tutorial/structured-output.md @@ -1,6 +1,6 @@ # Structured Output -In **Tools** you returned a `str` and the result came back twice: as text in `content`, and as `{"result": "..."}` in `structured_content`. +In **[Tools](tools.md)** you returned a `str` and the result came back twice: as text in `content`, and as `{"result": "..."}` in `structured_content`. This chapter is about that second channel: where it comes from, every shape it can take, and how the SDK keeps it honest. @@ -14,7 +14,7 @@ The short version: **the return type annotation is the output schema**. You alre The line that matters is the signature: `-> int`. -Because of it, the tool the SDK sends during `tools/list` carries an `output_schema` next to the input schema you met in **Tools**: +Because of it, the tool the SDK sends during `tools/list` carries an `output_schema` next to the input schema you met in **[Tools](tools.md)**: ```json { @@ -232,7 +232,7 @@ There is one way to end up unstructured without asking for it: return a class th !!! tip Need full control (building the `CallToolResult` yourself, or attaching `_meta` that the - application can see but the model can't)? That's **The low-level Server**. + application can see but the model can't)? That's **[The low-level Server](../advanced/low-level-server.md)**. ## Recap @@ -242,4 +242,4 @@ There is one way to end up unstructured without asking for it: return a class th * What you return is validated against the schema. A mismatch is a tool error, not a corrupt result. * `structured_output=False` opts a tool out. A class without type hints opts out silently; watch for it. -You now own everything a tool can say back. Next, the second primitive: **Resources**. +You now own everything a tool can say back. Next, the second primitive: **[Resources](resources.md)**. diff --git a/docs/tutorial/testing.md b/docs/tutorial/testing.md index 9e31aa095f..f5fe16765c 100644 --- a/docs/tutorial/testing.md +++ b/docs/tutorial/testing.md @@ -79,7 +79,7 @@ Two different things can go wrong, and this flag only touches one of them. An exception inside one of **your tools** is not a protocol failure. It becomes a normal result with `is_error=True`, and the model reads the message. `raise_exceptions` doesn't change that: with or without it, `call_tool` returns the same `is_error=True` result. There's a whole chapter on it: -**Handling errors**. +**[Handling errors](handling-errors.md)**. A failure **outside** a tool body is different. On the connection `Client(mcp)` gives you, the server sanitises it into a generic `"Internal server error"` before the client sees it. You should @@ -103,4 +103,4 @@ example file is exercised by the SDK's own test suite through exactly this clien same tool the SDK uses on itself. The tutorial ends here. Putting your tested server in front of a real client, over a real -transport, is **Running your server**. +transport, is **[Running your server](../run/index.md)**. diff --git a/docs/tutorial/tools.md b/docs/tutorial/tools.md index 774638856d..120b96e005 100644 --- a/docs/tutorial/tools.md +++ b/docs/tutorial/tools.md @@ -49,7 +49,7 @@ result.structured_content # {'result': "Found 3 books matching 'dune' (showing `content` is the text the **model** reads. `structured_content` is typed data for the **client application**. It's there because you declared the return type as `-> str`. -Don't worry about `structured_content` yet. Return real Python objects from your tools and the right thing happens; the **Structured Output** chapter is all about it. +Don't worry about `structured_content` yet. Return real Python objects from your tools and the right thing happens; the **[Structured Output](structured-output.md)** chapter is all about it. ### Try it @@ -169,4 +169,4 @@ A well-behaved client uses them to decide things like *"do I need to ask the use * Bad arguments are rejected for you, with an error the model can read and recover from. * `async def` for I/O, plain `def` for everything else. -Next up, **Structured Output**: what happens to the value you `return`. +Next up, **[Structured Output](structured-output.md)**: what happens to the value you `return`. From c85836a0817fbd03322f05ef7de7e9ee55dca876 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Mon, 29 Jun 2026 15:39:43 +0200 Subject: [PATCH 026/100] Drive resolver elicitation over the 2026-07-28 input_required flow (#2986) Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com> --- docs/advanced/multi-round-trip.md | 4 +- docs/migration.md | 2 + docs/tutorial/dependencies.md | 17 +- docs/tutorial/elicitation.md | 4 +- examples/stories/legacy_elicitation/README.md | 4 +- examples/stories/manifest.toml | 5 +- examples/stories/mrtr/README.md | 2 +- examples/stories/refund_desk/README.md | 42 +- examples/stories/refund_desk/client.py | 6 +- src/mcp/server/elicitation.py | 39 +- src/mcp/server/mcpserver/context.py | 5 + src/mcp/server/mcpserver/resolve.py | 410 ++++++- src/mcp/server/mcpserver/tools/base.py | 11 +- tests/docs_src/test_dependencies.py | 23 +- tests/docs_src/test_elicitation.py | 2 +- tests/server/mcpserver/test_resolve.py | 1028 ++++++++++++++++- 16 files changed, 1521 insertions(+), 83 deletions(-) diff --git a/docs/advanced/multi-round-trip.md b/docs/advanced/multi-round-trip.md index de11a8db88..665808a5dc 100644 --- a/docs/advanced/multi-round-trip.md +++ b/docs/advanced/multi-round-trip.md @@ -19,7 +19,7 @@ That's the whole protocol. Every leg is an ordinary request from the client to t ## The server side -The high-level `@mcp.tool()` decorator has no sugar for this yet. Today you write it on the **low-level** `Server`, whose `on_call_tool` handler is allowed to return either result type: +On `@mcp.tool()` you rarely build this by hand: declare a dependency that asks the user and the SDK returns the `InputRequiredResult` for you - that form is the **[Dependencies](../tutorial/dependencies.md)** tutorial. The manual form is the **low-level** `Server`, whose `on_call_tool` handler is allowed to return either result type: ```python title="server.py" hl_lines="44-47" --8<-- "docs_src/mrtr/tutorial001.py" @@ -93,6 +93,6 @@ Drop to the underlying session, where `allow_input_required=True` hands you the * `input_requests` is what it needs. `request_state` is an opaque resume token only the server reads. * `Client` runs the retry loop for you: register `elicitation_callback` / `sampling_callback` / `list_roots_callback` and `call_tool` returns a plain `CallToolResult`. `input_required_max_rounds` (default 10) bounds it. * To inspect or persist rounds, use `client.session.call_tool(..., allow_input_required=True)` and own the `while isinstance(result, InputRequiredResult)` loop yourself. -* The server side is the **low-level** `Server` only; `@mcp.tool()` has no sugar for this yet. +* On `@mcp.tool()`, a dependency that asks the user produces this result for you (**[Dependencies](../tutorial/dependencies.md)**); the **low-level** `Server` is the manual form. This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **[Deprecated features](deprecated.md)**. diff --git a/docs/migration.md b/docs/migration.md index 79c15d91f6..fd76d8a4f7 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -786,6 +786,8 @@ Positional calls (`await ctx.info("hello")`) are unaffected. `Context.elicit()` (and `elicit_with_validation()`) now render the schema first and validate each property against the spec's `PrimitiveSchemaDefinition`, raising `TypeError` at the call site for anything outside it. `Optional[T]` fields render as `{"type": ...}` with the field omitted from `required` (previously the non-spec `anyOf` shape). A bare `list[str]` field is rejected because it renders without the required enum items; use `list[Literal[...]]` or `list[str]` with `json_schema_extra` supplying the items. Unions of multiple primitives (e.g. `int | str`) and nested models are rejected. +A schema-mismatched *accepted* answer also fails differently: the call now raises `ValueError` with a stable message ("Received an accepted elicitation whose content does not match the requested schema") instead of letting pydantic's `ValidationError` escape with its internals. Code that caught `ValidationError` around `ctx.elicit()` should catch `ValueError` (or rely on the tool's error result). + ### Replace `RootModel` by union types with `TypeAdapter` validation The following union types are no longer `RootModel` subclasses: diff --git a/docs/tutorial/dependencies.md b/docs/tutorial/dependencies.md index e9b4c789bc..4e91515efb 100644 --- a/docs/tutorial/dependencies.md +++ b/docs/tutorial/dependencies.md @@ -116,11 +116,26 @@ And if the user won't answer at all - declines the question, or cancels it? That's the right default for a precondition: no answer, no order. When declining is an outcome your tool wants to handle - skip the backorder but still suggest another title - annotate `ElicitationResult[Backorder]` instead and the tool receives the full accept/decline/cancel outcome to branch on. **[Elicitation](elicitation.md)** shows that form, and everything else about asking: the schema rules, the three answers, the client's side of the conversation. +!!! info + The framework picks the question's transport from the negotiated protocol version; the code + above is identical on both. On **2026-07-28** and later the question rides inside a + multi-round-trip `tools/call` - the server returns it, the client's `elicitation_callback` + answers it, and the `Client` retries the call for you (**[Multi-round-trip requests](../advanced/multi-round-trip.md)**). On + **2025-11-25** and earlier it is a synchronous elicitation request mid-call. Each question is + asked exactly once per call - a guarantee about the question, not the resolver. In the + multi-round-trip form an eliciting resolver runs again to consume its answer, so code before + its `return Elicit(...)` runs on the asking round and again on the answering one; a resolver + that answered *without* asking, like `check_stock`, may run again whenever the call resumes + after a question. When it resumes, each answer is matched back to its question, so an + eliciting resolver must derive its question deterministically from the tool's arguments and + earlier answers - a per-call generated value (a `default_factory` id, a timestamp) is + re-derived on each round and must not appear in a question the answer is meant to bind to. + ## Recap * `Annotated[T, Resolve(fn)]` on a tool parameter: the SDK runs `fn` and injects its return value. * A resolved parameter is invisible to the model and cannot be supplied by a client. Values the model must not invent - prices, identities, permissions - belong here. -* A resolver's parameters are resolved the same way: the `Context`, another `Resolve(...)`, or a tool argument by name. The graph runs each resolver at most once per call. +* A resolver's parameters are resolved the same way: the `Context`, another `Resolve(...)`, or a tool argument by name. The graph runs each resolver at most once per round, however many consumers it has; each question is asked exactly once, an eliciting resolver runs again to consume its answer, and a resolver that never asked may run again when a call resumes. * Bad graphs fail at registration with `InvalidSignature`, not mid-call. * Return `Elicit(message, Model)` to ask the user, only when you have to. Unwrapped annotations abort on decline; `ElicitationResult[T]` lets the tool branch. diff --git a/docs/tutorial/elicitation.md b/docs/tutorial/elicitation.md index aa4f16820f..7bd27a78a0 100644 --- a/docs/tutorial/elicitation.md +++ b/docs/tutorial/elicitation.md @@ -76,8 +76,8 @@ A refusal is not an error. The tool decides what declining means (here, no booki !!! tip The answer is validated against your model before your code sees it. A client that sends - `"maybe"` for a `bool` doesn't corrupt your booking: the call fails with the - `ValidationError`, your `if` never runs. + `"maybe"` for a `bool` doesn't corrupt your booking: the call fails with a + schema-mismatch error, your `if` never runs. ## Ask before the tool runs diff --git a/examples/stories/legacy_elicitation/README.md b/examples/stories/legacy_elicitation/README.md index 1a9d48e606..e9812acedd 100644 --- a/examples/stories/legacy_elicitation/README.md +++ b/examples/stories/legacy_elicitation/README.md @@ -68,6 +68,6 @@ uv run python -m stories.legacy_elicitation.client --http --legacy --server serv ## See also `sampling/` (same push-request shape, deprecated per SEP-2577), `mrtr/` -(planned — the 2026-era carrier), `error_handling/` +(the 2026-era carrier), `error_handling/` (`UrlElicitationRequiredError`), `refund_desk/` (resolver DI rides this push -mechanism today). +mechanism on handshake-era connections). diff --git a/examples/stories/manifest.toml b/examples/stories/manifest.toml index 57ec0e8a4e..1ba2fe862a 100644 --- a/examples/stories/manifest.toml +++ b/examples/stories/manifest.toml @@ -40,9 +40,8 @@ era = "legacy" status = "legacy" [story.refund_desk] -# Resolver DI rides push elicitation (ctx.elicit) today; era flips to "dual" once -# the SDK carries resolver elicitation over the 2026 input_required round-trip. -era = "legacy" +# Resolver elicitation picks its transport per era: input_required round-trips on +# the modern leg, push elicitation (ctx.elicit) on the legacy one. lowlevel = false [story.sampling] diff --git a/examples/stories/mrtr/README.md b/examples/stories/mrtr/README.md index de214988d7..aaad86ca9d 100644 --- a/examples/stories/mrtr/README.md +++ b/examples/stories/mrtr/README.md @@ -46,7 +46,7 @@ uv run python -m stories.mrtr.client --http --server server_lowlevel ## Spec -[Multi-round results — server features](https://modelcontextprotocol.io/specification/draft/server/tools#multi-round-results) +[Input required tool results — server features](https://modelcontextprotocol.io/specification/draft/server/tools#input-required-tool-results) ## See also diff --git a/examples/stories/refund_desk/README.md b/examples/stories/refund_desk/README.md index 0a77dd5806..1535040415 100644 --- a/examples/stories/refund_desk/README.md +++ b/examples/stories/refund_desk/README.md @@ -7,9 +7,10 @@ reason)` refunds what the order record says — `cents` is resolver-computed and does not appear in the input schema at all, so the model cannot supply or inflate the amount. Resolvers form a DAG (`load_order` → `refund_scope` → `refund_amount` / `ask_restock`), may return `Elicit[...]` to ask the human, -and run at most once per call. A resolver's own plain parameters are filled -from the tool's arguments by name — `load_order(order_id)` receives the -`order_id` the model passed to `refund_order`. +and ask each question at most once per call. A resolver's own plain +parameters are filled from the tool's arguments by name — +`load_order(order_id)` receives the `order_id` the model passed to +`refund_order`. ## Run it @@ -18,9 +19,9 @@ from the tool's arguments by name — `load_order(order_id)` receives the uv run python -m stories.refund_desk.client # HTTP — the client self-hosts the server on a free port, runs, then tears it -# down (--legacy: resolver elicitation rides the push request today; the -# manifest pins this era, so bare --http runs the same leg) -uv run python -m stories.refund_desk.client --http --legacy +# down (2026 protocol: the questions ride embedded input_required round-trips; +# add --legacy to ride synchronous push elicitation instead) +uv run python -m stories.refund_desk.client --http ``` ## What to look at @@ -47,21 +48,38 @@ uv run python -m stories.refund_desk.client --http --legacy ## Caveats +- **Transport per era.** The framework picks the elicitation transport from + the negotiated protocol: at >= 2026-07-28 the questions ride embedded + `input_required` round-trips (a resolver that depends on another's answer is + asked in a later round); at <= 2025-11-25 each is a synchronous + `elicitation/create` push request mid-call. Author code is identical on + both — this client runs unchanged on either era. - **Decline order.** A declined unwrapped dependency aborts resolution in tool-signature order — `cents` resolves before `restock`, so `ask_restock` never runs. Don't rely on a later resolver's side effects after an earlier consumer can abort. -- **Memoization scope.** Each resolver runs at most once per `tools/call`, - keyed by function identity; nothing is cached across calls or connections. +- **Memoization scope.** Each question is asked at most once per call, and + within a round each resolver runs at most once, keyed by function identity. + Across 2026 rounds only *elicited* outcomes persist (in `requestState`); a + resolver that resolves without eliciting is pure and may re-run each round. + An eliciting resolver's body runs again too — once to ask, once more to + consume its answer. + An answer is matched back to its question when the call resumes, so an + eliciting resolver must derive its question deterministically from the + tool's arguments and earlier answers; a per-call generated value (a + `default_factory` id, a timestamp) is re-derived each round and must not + appear in a question the answer is meant to bind to. Nothing is cached + across calls or connections. - **Validate elicited values.** Elicited answers are human-typed; check them against your records (as `_scoped` does) before acting on them. ## Spec -[Elicitation — client features](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation) +[Elicitation — client features](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation), +[Input required tool results — server features](https://modelcontextprotocol.io/specification/draft/server/tools#input-required-tool-results) ## See also -`legacy_elicitation/` (the push mechanism resolver elicitation rides on today), -`mrtr/` (the 2026 `input_required` carrier; resolver DI will ride it once the -SDK wires them together). +`mrtr/` (the 2026 `input_required` carrier these questions ride at +>= 2026-07-28), `legacy_elicitation/` (the push mechanism they ride on +handshake-era connections). diff --git a/examples/stories/refund_desk/client.py b/examples/stories/refund_desk/client.py index ee86d94b40..0ff8d28fca 100644 --- a/examples/stories/refund_desk/client.py +++ b/examples/stories/refund_desk/client.py @@ -41,7 +41,9 @@ async def on_elicit(context: ClientRequestContext, params: types.ElicitRequestPa assert counts == {"scope": 0, "restock": 0}, counts # Full refund of a three-line order. The scope question fires exactly ONCE even though - # both refund_amount and ask_restock consume it — memoized within the call. + # both refund_amount and ask_restock consume it — asked at most once per call on either + # era. ask_restock needs the scope ANSWER, so at 2026 the two questions land in + # successive rounds, never one concurrent batch: counts and order are era-independent. receipt = await client.call_tool("refund_order", {"order_id": "ORD-7002", "reason": "arrived broken"}) assert receipt.structured_content == { "order_id": "ORD-7002", @@ -53,7 +55,7 @@ async def on_elicit(context: ClientRequestContext, params: types.ElicitRequestPa # Declining restock still refunds: the tool keeps the ElicitationResult union for # `restock`, sees the decline, and just skips the restock. The scope counter moves - # again — the memo cache is per tools/call, not per connection. + # again — questions are deduped per call, not per connection. declines.add("restock") answers["scope"] = {"full": False, "sku": "canvas-tote"} receipt = await client.call_tool("refund_order", {"order_id": "ORD-7002", "reason": "wrong colour"}) diff --git a/src/mcp/server/elicitation.py b/src/mcp/server/elicitation.py index c6faf0065e..5a4acdd6c3 100644 --- a/src/mcp/server/elicitation.py +++ b/src/mcp/server/elicitation.py @@ -87,6 +87,18 @@ def _validate_rendered_properties(json_schema: dict[str, Any]) -> None: ) from None +def render_elicitation_schema(schema: type[BaseModel]) -> dict[str, Any]: + """Render a model as the spec-valid `requested_schema` for an elicitation. + + Raises: + TypeError: If a field renders as something the spec's + `PrimitiveSchemaDefinition` does not accept. + """ + json_schema = schema.model_json_schema(schema_generator=_ElicitationJsonSchema) + _validate_rendered_properties(json_schema) + return json_schema + + async def elicit_with_validation( session: ServerSession, message: str, @@ -102,9 +114,12 @@ async def elicit_with_validation( the user or automatically generating a response. For sensitive data like credentials or OAuth flows, use elicit_url() instead. + + Raises: + ValueError: If the client accepted the elicitation without supplying + content, or with content that does not match the requested schema. """ - json_schema = schema.model_json_schema(schema_generator=_ElicitationJsonSchema) - _validate_rendered_properties(json_schema) + json_schema = render_elicitation_schema(schema) result = await session.elicit_form( message=message, @@ -112,17 +127,19 @@ async def elicit_with_validation( related_request_id=related_request_id, ) - if result.action == "accept" and result.content is not None: - # Validate and parse the content using the schema - validated_data = schema.model_validate(result.content) + if result.action == "accept": + if result.content is None: + raise ValueError("Received an accepted elicitation with no content") + try: + validated_data = schema.model_validate(result.content) + except ValidationError as e: + raise ValueError( + "Received an accepted elicitation whose content does not match the requested schema" + ) from e return AcceptedElicitation(data=validated_data) - elif result.action == "decline": + if result.action == "decline": return DeclinedElicitation() - elif result.action == "cancel": - return CancelledElicitation() - else: # pragma: no cover - # This should never happen, but handle it just in case - raise ValueError(f"Unexpected elicitation action: {result.action}") + return CancelledElicitation() async def elicit_url( diff --git a/src/mcp/server/mcpserver/context.py b/src/mcp/server/mcpserver/context.py index 4d494db6ed..82a6fa2b6e 100644 --- a/src/mcp/server/mcpserver/context.py +++ b/src/mcp/server/mcpserver/context.py @@ -232,6 +232,11 @@ def request_id(self) -> str: """Get the unique ID for this request.""" return str(self.request_context.request_id) + @property + def protocol_version(self) -> str | None: + """The negotiated protocol version, or `None` outside of an active request.""" + return self._request_context.protocol_version if self._request_context is not None else None + @property def input_responses(self) -> InputResponses | None: """Client responses to a prior `InputRequiredResult.input_requests`. diff --git a/src/mcp/server/mcpserver/resolve.py b/src/mcp/server/mcpserver/resolve.py index 89843a7169..323ce5cddb 100644 --- a/src/mcp/server/mcpserver/resolve.py +++ b/src/mcp/server/mcpserver/resolve.py @@ -7,25 +7,47 @@ `Elicit[T]` to ask the client; the framework runs the elicitation and injects the answer. +The framework picks the elicitation transport from the negotiated protocol. At +>= 2026-07-28 it returns an `InputRequiredResult` carrying the batched questions +and resumes when the client retries with `input_responses`/`request_state` +(independent resolvers are asked in one round; a resolver depending on another's +answer is asked in a later round). At <= 2025-11-25 it issues a synchronous +`elicitation/create` request mid-call. Only *elicited* outcomes are carried in +`request_state` across rounds (so the user is asked each question once); a +resolver that returns a value without eliciting is pure and may re-run each round. + Whether the consumer receives the unwrapped model or the full `ElicitationResult` union is decided by the consumer's annotation: - `Annotated[T, Resolve(fn)]` -> unwrapped `T`; decline/cancel aborts the call. - `Annotated[ElicitationResult[T], Resolve(fn)]` (or a specific member) -> the full outcome; the consumer branches on accept/decline/cancel. - -Each resolver runs at most once per `tools/call` (memoized by function identity). """ from __future__ import annotations import inspect +import types import typing from collections.abc import Callable, Hashable, Mapping -from typing import Annotated, Any, Generic, cast, get_args, get_origin +from typing import Annotated, Any, Generic, Literal, TypeGuard, get_args, get_origin import anyio.to_thread -from pydantic import BaseModel +from mcp_types import ( + MISSING_REQUIRED_CLIENT_CAPABILITY, + ClientCapabilities, + ElicitationCapability, + ElicitRequest, + ElicitRequestFormParams, + ElicitResult, + FormElicitationCapability, + InputRequests, + InputRequiredResult, + InputResponses, + MissingRequiredClientCapabilityErrorData, +) +from mcp_types.version import is_version_at_least +from pydantic import BaseModel, ValidationError from typing_extensions import TypeVar from mcp.server.elicitation import ( @@ -33,16 +55,24 @@ CancelledElicitation, DeclinedElicitation, ElicitationResult, + render_elicitation_schema, ) from mcp.server.mcpserver.context import Context from mcp.server.mcpserver.exceptions import InvalidSignature, ToolError from mcp.shared._callable_inspection import is_async_callable +from mcp.shared.exceptions import MCPError T = TypeVar("T", bound=BaseModel) # The union members the framework injects when a consumer opts into the outcome. _ELICITATION_RESULT_MEMBERS = (AcceptedElicitation, DeclinedElicitation, CancelledElicitation) +# First protocol revision whose `tools/call` carries elicitation inside +# `InputRequiredResult` rather than as a standalone server-to-client request. +# Pinned (not `LATEST_MODERN_VERSION`, which moves when newer revisions are added). +_INPUT_REQUIRED_VERSION = "2026-07-28" +_STATE_VERSION = 1 + class Resolve: """Marker for `Annotated[T, Resolve(fn)]`: fill the parameter by running `fn`.""" @@ -79,10 +109,24 @@ def __init__(self, kind: str, resolve: Resolve | None = None, wants_union: bool class _ResolverPlan: """A resolver's parameters and whether it is async, analyzed once.""" - def __init__(self, fn: Callable[..., Any], params: dict[str, _ParamPlan], is_async: bool) -> None: + def __init__( + self, + fn: Callable[..., Any], + params: dict[str, _ParamPlan], + is_async: bool, + elicit_schema: type[BaseModel] | None, + wire_key: str, + ) -> None: self.fn = fn self.params = params self.is_async = is_async + # The `T` from the resolver's `Elicit[T]` return arm, if annotated. Used to + # re-validate an outcome restored from `request_state` into a model. + self.elicit_schema = elicit_schema + # Deterministic, collision-free key for this resolver's elicitation on the + # wire (`input_requests`/`request_state`). Assigned at registration so it is + # stable across rounds even when `module:qualname` collides (closures). + self.wire_key = wire_key def _type_hints(fn: Callable[..., Any]) -> dict[str, Any]: @@ -139,6 +183,36 @@ def _contains_resolve(annotation: Any) -> bool: return any(_contains_resolve(arg) for arg in get_args(annotation)) +def _elicit_return_schema(return_annotation: Any, name: str) -> type[BaseModel] | None: + """Extract `T` from a resolver return type's `Elicit[T]` arm, if present. + + Handles a bare `-> Elicit[T]` and a `-> T | Elicit[T]` union. Lets an elicited + outcome restored from `request_state` (a plain dict) be re-validated into its + model so dependent resolvers and tools receive a typed value. + + Raises: + InvalidSignature: If the annotation has more than one `Elicit[...]` arm; + the runtime can honor only one static question schema per resolver. + """ + # A bare `Elicit[T]` is itself a candidate; a union contributes its members. + candidates = get_args(return_annotation) if _is_union(return_annotation) else (return_annotation,) + # Typing dedupes equal union members, so two arms here are genuinely distinct. + arms = [c for c in candidates if get_origin(c) is Elicit] + if len(arms) > 1: + raise InvalidSignature( + f"Resolver {name!r} return annotation has multiple Elicit arms; " + "a resolver asks one question - split it into separate resolvers" + ) + if not arms: + return None + schema = get_args(arms[0])[0] + return schema if isinstance(schema, type) and issubclass(schema, BaseModel) else None + + +def _is_union(annotation: Any) -> bool: + return get_origin(annotation) in (typing.Union, types.UnionType) + + def _wants_union(type_arg: Any) -> bool: """True when `type_arg` is an `ElicitationResult` member (or a union of them). @@ -187,6 +261,9 @@ def build_resolver_plans( or a tool argument by name). """ plans: dict[Hashable, _ResolverPlan] = {} + # Count how many distinct resolvers share each `module:qualname` base so closures + # from one factory get distinct, deterministic wire keys (`base`, `base#1`, ...). + base_counts: dict[str, int] = {} def analyze(fn: Callable[..., Any], stack: tuple[Hashable, ...]) -> None: key = _resolver_key(fn) @@ -195,6 +272,11 @@ def analyze(fn: Callable[..., Any], stack: tuple[Hashable, ...]) -> None: if key in plans: return + base = _state_key(fn) + seen = base_counts.get(base, 0) + base_counts[base] = seen + 1 + wire_key = base if seen == 0 else f"{base}#{seen}" + hints = _type_hints(fn) sig = inspect.signature(fn) params: dict[str, _ParamPlan] = {} @@ -217,7 +299,8 @@ def analyze(fn: Callable[..., Any], stack: tuple[Hashable, ...]) -> None: "expected a Context, an Annotated[_, Resolve(...)], or a tool argument by name" ) - plans[key] = _ResolverPlan(fn, params, is_async_callable(fn)) + elicit_schema = _elicit_return_schema(hints.get("return"), _resolver_name(fn)) + plans[key] = _ResolverPlan(fn, params, is_async_callable(fn), elicit_schema, wire_key) for dep in nested: analyze(dep, stack + (key,)) @@ -241,76 +324,337 @@ def _is_context_annotation(annotation: Any) -> bool: return any(isinstance(c, type) and issubclass(c, Context) for c in candidates) +class _Pending(Exception): + """Internal: a resolver needs client input not yet available this round.""" + + +class _Resolution: + """Per-`tools/call` resolution state, shared across the DAG walk. + + `input_required` selects the transport: at >= 2026-07-28 elicitations are + batched into `pending` and surfaced as an `InputRequiredResult`; at older + revisions each `Elicit` is answered synchronously via `ctx.elicit`. + """ + + def __init__( + self, + plans: Mapping[Hashable, _ResolverPlan], + tool_args: Mapping[str, Any], + context: Context[Any, Any], + input_required: bool, + ) -> None: + self.plans = plans + self.tool_args = tool_args + self.context = context + self.input_required = input_required + self.answers: InputResponses = context.input_responses or {} if input_required else {} + self.state = _decode_state(context.request_state) if input_required else {} + # In-call dedup keyed by resolver identity (distinguishes two instances of + # the same bound method); `persist` holds the wire-shaped record of each + # elicited outcome, keyed by its wire key - exactly what the next round's + # `request_state` carries. Entries are the client's own (validated) wire + # data, never re-derived from a model, so encode-restore is the identity. + # Pure resolvers are cheap to re-run each round and are not persisted. + self.cache: dict[Hashable, ElicitationResult[Any]] = {} + self.persist: dict[str, _StateEntry] = {} + self.pending: InputRequests = {} + + +def _state_key(fn: Callable[..., Any]) -> str: + """Worker-stable base wire key for a resolver, derived only from registration data. + + `input_requests`/`request_state` must round-trip through the client and resume on + any worker (stateless HTTP), so the key carries no `id(...)`: it is the resolver's + `module:qualname` (a callable object uses its type's). Distinct resolvers that + share this base - two instances of one method, two closures from one factory - are + disambiguated deterministically by `build_resolver_plans` (`base`, `base#1`, ...). + """ + qualname = getattr(fn, "__qualname__", None) or type(fn).__qualname__ + module = getattr(fn, "__module__", None) or type(fn).__module__ + return f"{module}:{qualname}" + + async def resolve_arguments( resolved_params: Mapping[str, tuple[Resolve, bool]], plans: Mapping[Hashable, _ResolverPlan], tool_args: Mapping[str, Any], context: Context[Any, Any], -) -> dict[str, Any]: +) -> dict[str, Any] | InputRequiredResult: """Resolve every `Resolve`-marked tool parameter into a concrete value. - Each resolver runs at most once (memoized by function identity). Returns a - mapping of tool parameter name to the value to inject. + Returns the mapping of tool parameter name to injected value when every + resolver is satisfied. When a resolver still needs client input (and the + negotiated protocol is >= 2026-07-28), returns an `InputRequiredResult` + carrying the batched questions instead; the tool body is not run. + + An eliciting resolver asks its question once - its answer is carried in + `request_state` across rounds - while a resolver that resolves without + eliciting is pure and may re-run on each round. Raises: ToolError: If an elicited value is declined or cancelled and the consumer asked for the unwrapped model (rather than the result union). """ - cache: dict[Hashable, ElicitationResult[Any]] = {} + # `ctx.protocol_version` is `None` outside an active request: `MCPServer.call_tool()` + # called directly builds such a `Context`, and a tool whose resolvers never elicit + # must still work there. A missing version means the synchronous (non-input_required) + # transport, which never reaches a server-to-client request anyway. + res = _Resolution(plans, tool_args, context, _uses_input_required(context.protocol_version)) injected: dict[str, Any] = {} for name, (marker, wants_union) in resolved_params.items(): - outcome = await _resolve(marker.fn, plans, tool_args, context, cache) + try: + outcome = await _resolve(marker.fn, res) + except _Pending: + continue injected[name] = outcome if wants_union else _unwrap(outcome, name) + + if res.pending: + return InputRequiredResult(input_requests=res.pending, request_state=_encode_state(res.persist)) return injected -async def _resolve( - fn: Callable[..., Any], - plans: Mapping[Hashable, _ResolverPlan], - tool_args: Mapping[str, Any], - context: Context[Any, Any], - cache: dict[Hashable, ElicitationResult[Any]], -) -> ElicitationResult[Any]: - key = _resolver_key(fn) - if key in cache: - return cache[key] +async def _resolve(fn: Callable[..., Any], res: _Resolution) -> ElicitationResult[Any]: + """Resolve one resolver, deduped within the call by its resolver identity. + + Raises `_Pending` when the resolver (or one of its dependencies) needs client + input that has not arrived yet. + """ + cache_key = _resolver_key(fn) + if cache_key in res.cache: + return res.cache[cache_key] + + plan = res.plans[cache_key] + wire_key = plan.wire_key + if wire_key in res.pending: + # Already asked this round by another consumer; don't run the resolver again. + raise _Pending + # Restore a prior round's outcome directly only when its model is known from the + # `Elicit[T]` return arm. Without that (a resolver that elicits but isn't annotated + # `-> ... Elicit[T]`), fall through and re-run the resolver so `_elicit` can + # re-validate the stored answer against the live `Elicit.schema`. + if wire_key in res.state and (plan.elicit_schema is not None or res.state[wire_key].action != "accept"): + outcome = _restore_outcome(res, wire_key, plan.elicit_schema) + if outcome is not None: + res.cache[cache_key] = outcome + return outcome - plan = plans[key] kwargs: dict[str, Any] = {} + dep_pending = False for param_name, param_plan in plan.params.items(): if param_plan.kind == "context": - kwargs[param_name] = context + kwargs[param_name] = res.context elif param_plan.kind == "by_name": - kwargs[param_name] = tool_args[param_name] + kwargs[param_name] = res.tool_args[param_name] else: assert param_plan.resolve is not None - dep_outcome = await _resolve(param_plan.resolve.fn, plans, tool_args, context, cache) + try: + # Visit every dependency so independent ones that need input are all + # collected into `res.pending` and batched into a single round. + dep_outcome = await _resolve(param_plan.resolve.fn, res) + except _Pending: + dep_pending = True + continue kwargs[param_name] = dep_outcome if param_plan.wants_union else _unwrap(dep_outcome, param_name) + if dep_pending: + raise _Pending + result: Any if plan.is_async: result = await fn(**kwargs) else: result = await anyio.to_thread.run_sync(lambda: fn(**kwargs)) - outcome: ElicitationResult[Any] - if isinstance(result, Elicit): - elicit = cast("Elicit[BaseModel]", result) - outcome = await context.elicit(elicit.message, elicit.schema) + if _is_elicit(result): + outcome = await _elicit(result, wire_key, res) else: - # A resolver may return any type (not just `BaseModel`); `model_construct` - # wraps it as an accepted result without validating against the schema bound. - outcome = cast("AcceptedElicitation[Any]", AcceptedElicitation.model_construct(data=result)) + # A resolver may return any type (not just `BaseModel`), so accept it as the + # outcome without validating against the schema bound. Plain outcomes are not + # persisted in `request_state`; the resolver re-runs next round instead. + outcome = _accepted(result) - cache[key] = outcome + res.cache[cache_key] = outcome return outcome +async def _elicit(elicit: Elicit[Any], key: str, res: _Resolution) -> ElicitationResult[Any]: + """Turn a resolver's `Elicit` into an outcome via the negotiated transport.""" + if not res.input_required: + return await res.context.elicit(elicit.message, elicit.schema) + + # Answered in a prior round (restored without a known schema, e.g. an unannotated + # resolver): re-validate the stored entry against the live `Elicit.schema`. A + # recorded outcome wins over a re-sent answer; an invalid entry self-deletes and + # falls through to the fresh answer (or to re-asking). + outcome = _restore_outcome(res, key, elicit.schema) + if outcome is not None: + return outcome + + answer = res.answers.get(key) + if answer is None: + _require_form_elicitation(res.context, key) + res.pending[key] = _elicit_request(elicit) + raise _Pending + if not isinstance(answer, ElicitResult): + raise ToolError(f"Resolver {key!r} received a non-elicitation response") + if answer.action == "accept": + if answer.content is None: + raise ToolError(f"Resolver {key!r} received an accepted elicitation with no content") + try: + data = elicit.schema.model_validate(answer.content) + except ValidationError as e: + raise ToolError( + f"Resolver {key!r} received an accepted elicitation whose content does not match the requested schema" + ) from e + # Persist the exact wire content that just passed validation - never the + # model - so restoring next round revalidates the same bytes the client sent. + res.persist[key] = _StateEntry(action="accept", data=answer.content) + return AcceptedElicitation(data=data) + if answer.action == "decline": + res.persist[key] = _StateEntry(action="decline") + return DeclinedElicitation() + res.persist[key] = _StateEntry(action="cancel") + return CancelledElicitation() + + def _unwrap(outcome: ElicitationResult[Any], name: str) -> Any: if isinstance(outcome, AcceptedElicitation): return outcome.data raise ToolError(f"Resolver for parameter {name!r} could not resolve: elicitation was {outcome.action}") +def _is_elicit(value: Any) -> TypeGuard[Elicit[Any]]: + """Runtime narrow of a resolver's return value to a (parameter-erased) `Elicit`.""" + return isinstance(value, Elicit) + + +def _accepted(data: Any) -> AcceptedElicitation[Any]: + """Wrap a resolved value as an accepted outcome without schema validation. + + A resolver may return any type (the schema bound only constrains `Elicit[T]`), + and a value restored from `request_state` is already validated. + """ + return AcceptedElicitation[Any].model_construct(data=data) + + +def _uses_input_required(protocol_version: str | None) -> bool: + """True when this request must elicit via `InputRequiredResult` (>= 2026-07-28). + + Older revisions still carry a standalone `elicitation/create` server-to-client + request, so the framework keeps the synchronous `ctx.elicit()` path for them. + """ + return protocol_version is not None and is_version_at_least(protocol_version, _INPUT_REQUIRED_VERSION) + + +def _require_form_elicitation(context: Context[Any, Any], key: str) -> None: + """Assert the client declared form elicitation before queueing a question for it. + + The spec forbids sending an `input_requests` entry the client has not declared a + capability for. A bare `elicitation: {}` declaration (the only shape before modes + existed) counts as form support; an explicit url-only declaration does not. + + Raises: + MCPError: With code `MISSING_REQUIRED_CLIENT_CAPABILITY` and a + `requiredCapabilities` payload when form elicitation is not declared. + """ + capabilities = context.client_capabilities + elicitation = capabilities.elicitation if capabilities is not None else None + if elicitation is not None and (elicitation.form is not None or elicitation.url is None): + return + data = MissingRequiredClientCapabilityErrorData( + required_capabilities=ClientCapabilities(elicitation=ElicitationCapability(form=FormElicitationCapability())) + ) + raise MCPError( + code=MISSING_REQUIRED_CLIENT_CAPABILITY, + message=f"Client did not declare the form elicitation capability required by resolver {key!r}", + data=data.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + + +def _elicit_request(elicit: Elicit[Any]) -> ElicitRequest: + """Render an `Elicit[T]` as the embedded `elicitation/create` request for `input_requests`.""" + json_schema = render_elicitation_schema(elicit.schema) + return ElicitRequest(params=ElicitRequestFormParams(message=elicit.message, requested_schema=json_schema)) + + +class _StateEntry(BaseModel): + """One resolver's recorded outcome inside `request_state`.""" + + action: Literal["accept", "decline", "cancel"] + data: Any = None + + +class _State(BaseModel): + """The decoded `request_state`: resolver outcomes from earlier rounds.""" + + v: int + outcomes: dict[str, _StateEntry] = {} + + +def _decode_state(request_state: str | None) -> dict[str, _StateEntry]: + """Decode the per-call resolution progress from `request_state`. + + `request_state` is client-trusted (integrity sealing is a follow-up); validate + it through `_State` and treat anything malformed as "no progress yet". + """ + if not request_state: + return {} + try: + state = _State.model_validate_json(request_state) + except ValidationError: + return {} + return state.outcomes if state.v == _STATE_VERSION else {} + + +def _encode_state(outcomes: Mapping[str, _StateEntry]) -> str: + """Encode recorded elicitation outcomes (keyed by wire key) for the next round. + + Entries already hold the client's wire-shaped data exactly as it was sent (and + validated), so encoding is pure wrapping: encode-restore is the identity. + """ + return _State(v=_STATE_VERSION, outcomes=dict(outcomes)).model_dump_json() + + +def _outcome_from_state(entry: _StateEntry, schema: type[BaseModel] | None) -> ElicitationResult[Any]: + """Rebuild an `ElicitationResult` from a decoded `request_state` entry. + + Raises: + ValidationError: If `schema` is known and the entry's data does not + validate against it. + """ + if entry.action == "decline": + return DeclinedElicitation() + if entry.action == "cancel": + return CancelledElicitation() + data = entry.data + if schema is not None: + data = schema.model_validate(data) + return _accepted(data) + + +def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel] | None) -> ElicitationResult[Any] | None: + """Restore `key`'s recorded outcome from a prior round, or `None` when absent. + + `request_state` is client-trusted, so an entry whose data fails validation gets + the `_decode_state` treatment - dropped as if no progress was recorded, so the + question is asked again - rather than surfacing a validation error. + + Carries the original decoded entry forward unchanged in `res.persist`: if a + later resolver is still pending, the next round's `request_state` is built from + `res.persist`, so an earlier answer must stay there - byte-identical, never + re-derived - or it would be dropped and re-asked. + """ + entry = res.state.get(key) + if entry is None: + return None + try: + outcome = _outcome_from_state(entry, schema) + except ValidationError: + del res.state[key] + return None + res.persist[key] = entry + return outcome + + __all__ = [ "Resolve", "Elicit", diff --git a/src/mcp/server/mcpserver/tools/base.py b/src/mcp/server/mcpserver/tools/base.py index 6aab3c7771..50d28f574b 100644 --- a/src/mcp/server/mcpserver/tools/base.py +++ b/src/mcp/server/mcpserver/tools/base.py @@ -4,7 +4,7 @@ from functools import cached_property from typing import TYPE_CHECKING, Any -from mcp_types import Icon, ToolAnnotations +from mcp_types import Icon, InputRequiredResult, ToolAnnotations from pydantic import BaseModel, Field from mcp.server.mcpserver.exceptions import ToolError @@ -135,9 +135,12 @@ async def run( pre_validated: dict[str, Any] | None = None if self.resolved_params: pre_validated = self.fn_metadata.validate_arguments(arguments) - pass_directly |= await resolve_arguments( - self.resolved_params, self.resolver_plans, pre_validated, context - ) + resolved = await resolve_arguments(self.resolved_params, self.resolver_plans, pre_validated, context) + if isinstance(resolved, InputRequiredResult): + # A resolver still needs client input (>= 2026-07-28): surface the + # batched questions instead of running the tool body this round. + return self.fn_metadata.convert_result(resolved) if convert_result else resolved + pass_directly |= resolved result = await self.fn_metadata.call_fn_with_arg_validation( self.fn, diff --git a/tests/docs_src/test_dependencies.py b/tests/docs_src/test_dependencies.py index 73355a8920..06d8935853 100644 --- a/tests/docs_src/test_dependencies.py +++ b/tests/docs_src/test_dependencies.py @@ -1,5 +1,7 @@ """`docs/tutorial/dependencies.md`: every claim the page makes, proved against the real SDK.""" +from typing import Literal + import pytest from inline_snapshot import snapshot from mcp_types import ElicitRequestParams, ElicitResult, TextContent @@ -79,18 +81,24 @@ def get(self, key: str, default: int) -> int: assert inventory.lookups == ["Dune", "Dune"] -async def test_an_in_stock_order_asks_no_question() -> None: +# The `!!! info` claims the tutorial003 behaviour is transport-independent, so each claim is +# proved on both: mode="legacy" elicits synchronously mid-call (2025-11-25 and earlier), while +# mode="auto" negotiates 2026-07-28, where the question rides a multi-round-trip `tools/call` +# and `Client` drives the retries. +@pytest.mark.parametrize("mode", ["legacy", "auto"]) +async def test_an_in_stock_order_asks_no_question(mode: Literal["legacy", "auto"]) -> None: """tutorial003: `confirm_backorder` returns directly when stock exists - no round-trip.""" async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover raise AssertionError("an in-stock order must not elicit") - async with Client(tutorial003.mcp, mode="legacy", elicitation_callback=never) as client: + async with Client(tutorial003.mcp, mode=mode, elicitation_callback=never) as client: result = await client.call_tool("order_book", {"title": "Dune"}) assert result.content == [TextContent(type="text", text="Ordered 'Dune'.")] +@pytest.mark.parametrize("mode", ["legacy", "auto"]) @pytest.mark.parametrize( ("confirm", "expected"), [ @@ -98,7 +106,9 @@ async def never(context: ClientRequestContext, params: ElicitRequestParams) -> E (False, "No order placed."), ], ) -async def test_an_out_of_stock_order_asks_and_honours_the_answer(confirm: bool, expected: str) -> None: +async def test_an_out_of_stock_order_asks_and_honours_the_answer( + mode: Literal["legacy", "auto"], confirm: bool, expected: str +) -> None: """tutorial003: the resolver elicits, the SDK validates the answer, the tool reads it.""" asked: list[str] = [] @@ -106,20 +116,21 @@ async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) asked.append(params.message) return ElicitResult(action="accept", content={"confirm": confirm}) - async with Client(tutorial003.mcp, mode="legacy", elicitation_callback=on_elicit) as client: + async with Client(tutorial003.mcp, mode=mode, elicitation_callback=on_elicit) as client: result = await client.call_tool("order_book", {"title": "Neuromancer"}) assert result.content == [TextContent(type="text", text=expected)] assert asked == ["'Neuromancer' is out of stock (2-3 weeks). Order anyway?"] -async def test_declining_an_unwrapped_dependency_aborts_the_call() -> None: +@pytest.mark.parametrize("mode", ["legacy", "auto"]) +async def test_declining_an_unwrapped_dependency_aborts_the_call(mode: Literal["legacy", "auto"]) -> None: """tutorial003: no answer, no order - the error text on the page is the real one.""" async def decline(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: return ElicitResult(action="decline") - async with Client(tutorial003.mcp, mode="legacy", elicitation_callback=decline) as client: + async with Client(tutorial003.mcp, mode=mode, elicitation_callback=decline) as client: result = await client.call_tool("order_book", {"title": "Neuromancer"}) assert result.is_error diff --git a/tests/docs_src/test_elicitation.py b/tests/docs_src/test_elicitation.py index 4c9bb40367..a28f1087fc 100644 --- a/tests/docs_src/test_elicitation.py +++ b/tests/docs_src/test_elicitation.py @@ -124,7 +124,7 @@ async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) result = await client.call_tool("book_table", {"date": "2025-12-25", "party_size": 2}) assert result.is_error assert isinstance(result.content[0], TextContent) - assert "Input should be a valid boolean" in result.content[0].text + assert "does not match the requested schema" in result.content[0].text class Address(BaseModel): diff --git a/tests/server/mcpserver/test_resolve.py b/tests/server/mcpserver/test_resolve.py index 1f4f724080..7e92f1c4ef 100644 --- a/tests/server/mcpserver/test_resolve.py +++ b/tests/server/mcpserver/test_resolve.py @@ -1,12 +1,26 @@ """Tests for resolver dependency injection (MRTR) on MCPServer tools.""" +import json +from collections.abc import Callable +from datetime import datetime from typing import Annotated, Any, Literal +import anyio import pytest -from mcp_types import ElicitRequestParams, ElicitResult, TextContent +from mcp_types import ( + MISSING_REQUIRED_CLIENT_CAPABILITY, + CallToolResult, + CreateMessageResult, + ElicitRequestFormParams, + ElicitRequestParams, + ElicitResult, + InputRequiredResult, + InputResponses, + TextContent, +) from pydantic import BaseModel, Field -from mcp import Client +from mcp import Client, InputRequiredRoundsExceededError from mcp.client import ClientRequestContext from mcp.server.mcpserver import ( AcceptedElicitation, @@ -19,8 +33,19 @@ Resolve, ) from mcp.server.mcpserver.exceptions import InvalidSignature -from mcp.server.mcpserver.resolve import _resolver_key, find_resolved_parameters +from mcp.server.mcpserver.resolve import ( + _decode_state, + _elicit_return_schema, + _encode_state, + _outcome_from_state, + _resolver_key, + _state_key, + _StateEntry, + _uses_input_required, + find_resolved_parameters, +) from mcp.server.mcpserver.tools.base import Tool +from mcp.shared.exceptions import MCPError class Login(BaseModel): @@ -31,6 +56,14 @@ class Confirm(BaseModel): ok: bool +class Handle(BaseModel): + user_name: str = Field(alias="userName") + + +class Account(BaseModel): + user_name: str = Field(validation_alias="vUser", serialization_alias="sUser") + + async def _alias_login(ctx: Context) -> Login: return Login(username="x") # pragma: no cover - only the signature is inspected @@ -46,6 +79,12 @@ async def _decline(context: ClientRequestContext, params: ElicitRequestParams) - return ElicitResult(action="decline") +async def _never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover + # Declares the form elicitation capability for clients that drive the + # input_required loop manually; the auto-driver never invokes it. + raise AssertionError("should not be called") + + async def _text(client: Client, tool: str, args: dict[str, object]) -> str: result = await client.call_tool(tool, args) assert len(result.content) == 1 @@ -53,6 +92,18 @@ async def _text(client: Client, tool: str, args: dict[str, object]) -> str: return result.content[0].text +def _answer_round( + result: InputRequiredResult, answer: Callable[[str, ElicitRequestFormParams], ElicitResult] +) -> InputResponses: + """Fulfil every question in one `InputRequiredResult` round via `answer(key, request_params)`.""" + assert result.input_requests is not None + responses: InputResponses = {} + for key, req in result.input_requests.items(): + assert isinstance(req.params, ElicitRequestFormParams) + responses[key] = answer(key, req.params) + return responses + + @pytest.mark.anyio async def test_resolver_returns_value_directly_without_eliciting(): mcp = MCPServer(name="Direct") @@ -291,6 +342,20 @@ async def tool(login: Annotated[Login, Resolve(login)]) -> str: Tool.from_function(tool) +def test_multiple_elicit_arms_raise_at_registration(): + # The runtime can honor only one static question schema per resolver, so an + # ambiguous `-> Elicit[A] | Elicit[B]` must not register (the second arm used + # to be silently ignored). + async def ambiguous(ctx: Context) -> Elicit[Login] | Elicit[Confirm]: + raise NotImplementedError # pragma: no cover + + async def tool(login: Annotated[Login, Resolve(ambiguous)]) -> str: + return login.username # pragma: no cover + + with pytest.raises(InvalidSignature, match="multiple Elicit arms"): + Tool.from_function(tool) + + def test_resolve_marker_inside_a_union_raises_at_registration(): async def login(ctx: Context) -> Login: return Login(username="x") # pragma: no cover @@ -569,3 +634,960 @@ async def callback(context: ClientRequestContext, params: ElicitRequestParams) - async with Client(mcp, mode="legacy", elicitation_callback=callback) as client: assert await _text(client, "delete_folder", {"path": "/docs"}) == expected assert ("/docs" in fs) is (expected != "deleted /docs") + + +@pytest.mark.anyio +async def test_input_required_first_round_returns_the_question(): + mcp, fs = _delete_folder_server() + fs["/docs"] = ["a.txt", "b.txt"] + + async with Client(mcp, elicitation_callback=_never) as client: # mode="auto" negotiates 2026-07-28 + assert client.session.protocol_version == "2026-07-28" + result = await client.session.call_tool("delete_folder", {"path": "/docs"}, allow_input_required=True) + assert isinstance(result, InputRequiredResult) + assert result.input_requests is not None + (request,) = result.input_requests.values() + assert request.method == "elicitation/create" + assert "/docs has 2 file(s)" in request.params.message + assert result.request_state is not None + assert "/docs" in fs # nothing deleted before the answer arrives + + +@pytest.mark.anyio +@pytest.mark.parametrize( + ("action", "content", "expected"), + [ + ("accept", {"ok": True}, "deleted /docs"), + ("accept", {"ok": False}, "kept the folder"), + ("decline", None, "declined: folder not deleted"), + ("cancel", None, "cancelled: folder not deleted"), + ], +) +async def test_input_required_loop_handles_every_outcome( + action: Literal["accept", "decline", "cancel"], + content: dict[str, str | int | float | bool | list[str] | None] | None, + expected: str, +): + # End-to-end at 2026-07-28: the client's auto-driver answers the embedded + # elicitation through the ordinary `elicitation_callback` and retries. + mcp, fs = _delete_folder_server() + fs["/docs"] = ["a.txt", "b.txt"] + + async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + assert "/docs has 2 file(s)" in params.message + return ElicitResult(action=action, content=content) + + async with Client(mcp, elicitation_callback=callback) as client: # mode="auto" negotiates 2026-07-28 + result = await client.call_tool("delete_folder", {"path": "/docs"}) + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == expected + assert ("/docs" in fs) is (expected != "deleted /docs") + + +@pytest.mark.anyio +async def test_input_required_empty_folder_completes_without_eliciting(): + mcp, fs = _delete_folder_server() + fs["/empty"] = [] + + async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover + raise AssertionError("should not elicit for an empty folder") + + async with Client(mcp, elicitation_callback=never) as client: + result = await client.call_tool("delete_folder", {"path": "/empty"}) + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "deleted /empty" + assert "/empty" not in fs + + +@pytest.mark.anyio +async def test_input_required_resolver_asks_and_consumes_then_never_reruns(): + mcp = MCPServer(name="ExactlyOnceMRTR") + counts = {"login": 0, "confirm": 0} + + async def login(ctx: Context) -> Login | Elicit[Login]: + counts["login"] += 1 + return Elicit("Username?", Login) + + async def confirm(login: Annotated[Login, Resolve(login)]) -> Elicit[Confirm]: + counts["confirm"] += 1 + return Elicit(f"As {login.username}?", Confirm) + + @mcp.tool() + async def act( + login: Annotated[Login, Resolve(login)], + confirm: Annotated[Confirm, Resolve(confirm)], + ) -> str: + return f"{login.username}:{confirm.ok}" + + asked: list[str] = [] + + async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + asked.append(params.message) + if "Username" in params.message: + return ElicitResult(action="accept", content={"username": "octocat"}) + return ElicitResult(action="accept", content={"ok": True}) + + async with Client(mcp, elicitation_callback=callback) as client: + result = await client.call_tool("act", {}) + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "octocat:True" + + # `confirm` can only form its question from `login`'s answer, so the auto-driver + # sees the questions in two successive rounds and answers each exactly once. + assert asked == ["Username?", "As octocat?"] + # An eliciting resolver runs twice - once to ask, once to consume the answer - + # then its outcome is carried in `request_state` and it never runs again. `login` + # asks in round 1 and is consumed in round 2; `confirm` (which depends on + # `login`) only forms its question once `login` is known, so it asks in round 2 + # and is consumed in round 3. Neither re-runs beyond consuming its own answer. + assert counts == {"login": 2, "confirm": 2} + + +@pytest.mark.anyio +async def test_input_required_batches_independent_elicits_in_one_round(): + mcp = MCPServer(name="BatchedMRTR") + + async def ask_name(ctx: Context) -> Elicit[Login]: + return Elicit("Name?", Login) + + async def ask_confirm(ctx: Context) -> Elicit[Confirm]: + return Elicit("Confirm?", Confirm) + + @mcp.tool() + async def both( + name: Annotated[Login, Resolve(ask_name)], + confirm: Annotated[Confirm, Resolve(ask_confirm)], + ) -> str: + return f"{name.username}:{confirm.ok}" + + def answer(key: str, params: ElicitRequestFormParams) -> ElicitResult: + if "Name" in params.message: + return ElicitResult(action="accept", content={"username": "octocat"}) + return ElicitResult(action="accept", content={"ok": True}) + + async with Client(mcp, elicitation_callback=_never) as client: + # Both independent resolvers are asked together in the first round. + first = await client.session.call_tool("both", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.input_requests is not None + assert len(first.input_requests) == 2 + + # Answering both and echoing `request_state` completes in a single retry. + final = await client.session.call_tool( + "both", + {}, + input_responses=_answer_round(first, answer), + request_state=first.request_state, + allow_input_required=True, + ) + assert isinstance(final, CallToolResult) + assert isinstance(final.content[0], TextContent) + assert final.content[0].text == "octocat:True" + + +@pytest.mark.anyio +async def test_auto_driver_answers_independent_questions_in_a_single_round(): + # The pure `count_round` resolver is never persisted in `request_state`, so it + # re-runs on every round: its run count is the number of rounds the call took. + mcp = MCPServer(name="AutoBatch") + rounds = 0 + + async def count_round(ctx: Context) -> int: + nonlocal rounds + rounds += 1 + return rounds + + async def ask_name(ctx: Context) -> Elicit[Login]: + return Elicit("Name?", Login) + + async def ask_confirm(ctx: Context) -> Elicit[Confirm]: + return Elicit("Confirm?", Confirm) + + @mcp.tool() + async def both( + round_no: Annotated[int, Resolve(count_round)], + name: Annotated[Login, Resolve(ask_name)], + confirm: Annotated[Confirm, Resolve(ask_confirm)], + ) -> str: + return f"{name.username}:{confirm.ok}" + + asked: list[str] = [] + + async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + asked.append(params.message) + if "Name" in params.message: + return ElicitResult(action="accept", content={"username": "octocat"}) + return ElicitResult(action="accept", content={"ok": True}) + + async with Client(mcp, elicitation_callback=callback) as client: + result = await client.call_tool("both", {}) + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "octocat:True" + + # The driver dispatches batched questions concurrently, so order is unspecified. + assert sorted(asked) == ["Confirm?", "Name?"] # both questions, each exactly once + assert rounds == 2 # one question round, then the completing round + + +def test_uses_input_required_version_gate(): + assert _uses_input_required("2026-07-28") is True + assert _uses_input_required("2025-11-25") is False + assert _uses_input_required(None) is False + + +@pytest.mark.parametrize( + "request_state", + [ + None, + "", + "not json", + '{"v": 99, "outcomes": {}}', # wrong version + '{"v": 1}', # missing outcomes + '{"v": 1, "outcomes": []}', # outcomes not a dict + "[1, 2, 3]", # not an object + ], +) +def test_decode_state_tolerates_malformed_request_state(request_state: str | None): + assert _decode_state(request_state) == {} + + +def test_state_round_trips_accept_decline_cancel(): + entries = { + "a": _StateEntry(action="accept", data={"username": "octocat"}), + "b": _StateEntry(action="decline"), + "c": _StateEntry(action="cancel"), + "d": _StateEntry(action="accept", data="raw-token"), # non-dict wire value + } + decoded = _decode_state(_encode_state(entries)) + assert decoded == entries # encode-restore is the identity on the stored entries + + accepted = _outcome_from_state(decoded["a"], Login) + assert isinstance(accepted, AcceptedElicitation) and accepted.data == Login(username="octocat") + assert isinstance(_outcome_from_state(decoded["b"], None), DeclinedElicitation) + assert isinstance(_outcome_from_state(decoded["c"], None), CancelledElicitation) + raw = _outcome_from_state(decoded["d"], None) + assert isinstance(raw, AcceptedElicitation) and raw.data == "raw-token" + + +def test_elicit_return_schema_extraction(): + assert _elicit_return_schema(Elicit[Login], "r") is Login # bare Elicit[T] + assert _elicit_return_schema(Login | Elicit[Login], "r") is Login # union arm + assert _elicit_return_schema(Login, "r") is None # no Elicit arm + assert _elicit_return_schema(None, "r") is None + # The bound on `Elicit`'s parameter is unenforced at runtime, so a non-model + # subscription is constructible and must yield no schema rather than crash. + unbounded_elicit: Any = Elicit + assert _elicit_return_schema(unbounded_elicit[int], "r") is None + # Two distinct Elicit arms are ambiguous: the runtime can honor only one schema. + with pytest.raises(InvalidSignature, match="'r' return annotation has multiple Elicit arms"): + _elicit_return_schema(Elicit[Login] | Elicit[Confirm], "r") + + +@pytest.mark.anyio +async def test_non_elicitation_response_raises(): + mcp = MCPServer(name="WrongResponse") + + async def ask(ctx: Context) -> Elicit[Login]: + return Elicit("Name?", Login) + + @mcp.tool() + async def tool(name: Annotated[Login, Resolve(ask)]) -> str: + return name.username # pragma: no cover + + async with Client(mcp, elicitation_callback=_never) as client: + r1 = await client.session.call_tool("tool", {}, allow_input_required=True) + assert isinstance(r1, InputRequiredResult) + assert r1.input_requests is not None + (key,) = r1.input_requests + # Answer with a sampling result instead of an elicitation result. + r2 = await client.session.call_tool( + "tool", + {}, + input_responses={ + key: CreateMessageResult(role="assistant", content=TextContent(type="text", text="x"), model="m") + }, + request_state=r1.request_state, + allow_input_required=True, + ) + assert isinstance(r2, CallToolResult) + assert r2.is_error + assert isinstance(r2.content[0], TextContent) + assert "non-elicitation response" in r2.content[0].text + + +@pytest.mark.anyio +async def test_direct_call_tool_with_non_eliciting_resolver(): + # `MCPServer.call_tool()` called directly builds a Context with no request, so + # `ctx.protocol_version` is None. A tool whose resolvers never elicit must still + # work there (regression: it used to raise "Context is not available"). + mcp = MCPServer(name="Direct") + + async def whoami(ctx: Context) -> Login: + return Login(username="direct") + + @mcp.tool() + async def tool(login: Annotated[Login, Resolve(whoami)]) -> str: + return login.username + + result = await mcp.call_tool("tool", {}, Context(mcp_server=mcp)) + assert isinstance(result, CallToolResult) + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "direct" + + +@pytest.mark.anyio +async def test_two_instances_of_one_method_do_not_collide(): + mcp = MCPServer(name="Instances") + + class Service: + def __init__(self, name: str) -> None: + self.name = name + + async def who(self, ctx: Context) -> Login: + return Login(username=self.name) + + alice, bob = Service("alice"), Service("bob") + + @mcp.tool() + async def both( + a: Annotated[Login, Resolve(alice.who)], + b: Annotated[Login, Resolve(bob.who)], + ) -> str: + return f"{a.username},{b.username}" + + result = await mcp.call_tool("both", {}, Context(mcp_server=mcp)) + assert isinstance(result, CallToolResult) + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "alice,bob" + + +@pytest.mark.anyio +async def test_non_serializable_sibling_resolver_does_not_break_rounds(): + mcp = MCPServer(name="NonSerializable") + + async def clock(ctx: Context) -> datetime: + return datetime(2026, 1, 1) + + async def ask(ctx: Context) -> Elicit[Confirm]: + return Elicit("ok?", Confirm) + + @mcp.tool() + async def act( + when: Annotated[datetime, Resolve(clock)], + confirm: Annotated[Confirm, Resolve(ask)], + ) -> str: + return f"{when.year}:{confirm.ok}" + + async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + return ElicitResult(action="accept", content={"ok": True}) + + async with Client(mcp, elicitation_callback=callback) as client: + result = await client.call_tool("act", {}) + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "2026:True" + + +@pytest.mark.anyio +async def test_bare_elicit_dependency_restored_as_model(): + # A `-> Elicit[Login]` (bare, no union) resolver feeds a dependent resolver. After + # the round-trip the dependency must come back as a Login model, not a raw dict. + mcp = MCPServer(name="BareElicitDep") + + async def login(ctx: Context) -> Elicit[Login]: + return Elicit("user?", Login) + + async def confirm(login: Annotated[Login, Resolve(login)]) -> Elicit[Confirm]: + return Elicit(f"as {login.username}?", Confirm) + + @mcp.tool() + async def act( + login: Annotated[Login, Resolve(login)], + confirm: Annotated[Confirm, Resolve(confirm)], + ) -> str: + return f"{login.username}:{confirm.ok}" + + async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + if "user" in params.message: + return ElicitResult(action="accept", content={"username": "octocat"}) + assert "as octocat?" in params.message # proves login was a real model + return ElicitResult(action="accept", content={"ok": True}) + + async with Client(mcp, elicitation_callback=callback) as client: + result = await client.call_tool("act", {}) + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "octocat:True" + + +@pytest.mark.anyio +@pytest.mark.parametrize("mode", ["legacy", "auto"]) +async def test_accept_with_no_content_is_an_error_not_a_cancel(mode: Literal["legacy", "auto"]): + # Both transports must agree: mode="legacy" elicits synchronously mid-call, + # mode="auto" rides the 2026-07-28 input_required loop. + mcp = MCPServer(name="AcceptNoContent") + + async def ask(ctx: Context) -> Elicit[Login]: + return Elicit("user?", Login) + + @mcp.tool() + async def tool(login: Annotated[Login, Resolve(ask)]) -> str: + return login.username # pragma: no cover + + async def empty_accept(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + return ElicitResult(action="accept", content=None) + + async with Client(mcp, mode=mode, elicitation_callback=empty_accept) as client: + result = await client.call_tool("tool", {}) + assert result.is_error + assert isinstance(result.content[0], TextContent) + assert "no content" in result.content[0].text + + +@pytest.mark.anyio +async def test_eliciting_tool_without_client_capability_is_a_protocol_error(): + # The server must not send an `input_requests` entry the client has not declared + # capability for: with no `elicitation` declared (no callback), the call fails as + # a -32021 protocol error, not a CallToolResult execution failure. + mcp = MCPServer(name="NoElicitationCapability") + + async def ask(ctx: Context) -> Elicit[Login]: + return Elicit("user?", Login) + + @mcp.tool() + async def tool(login: Annotated[Login, Resolve(ask)]) -> str: + return login.username # pragma: no cover + + async with Client(mcp) as client: + with pytest.raises(MCPError) as exc_info: + await client.session.call_tool("tool", {}, allow_input_required=True) + assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY + assert exc_info.value.error.data is not None + assert "elicitation" in exc_info.value.error.data["requiredCapabilities"] + + +@pytest.mark.anyio +async def test_independent_nested_deps_batch_into_one_round(): + mcp = MCPServer(name="NestedBatch") + + async def ask_a(ctx: Context) -> Elicit[Login]: + return Elicit("A name?", Login) + + async def ask_b(ctx: Context) -> Elicit[Confirm]: + return Elicit("B confirm?", Confirm) + + # `combine` depends on two independent eliciting resolvers; both must be asked + # in the same round, not serialized across two InputRequiredResult rounds. + async def combine( + a: Annotated[Login, Resolve(ask_a)], + b: Annotated[Confirm, Resolve(ask_b)], + ) -> Login: + return Login(username=f"{a.username}:{b.ok}") + + @mcp.tool() + async def tool(combined: Annotated[Login, Resolve(combine)]) -> str: + return combined.username + + def answer(key: str, params: ElicitRequestFormParams) -> ElicitResult: + if "name" in params.message: + return ElicitResult(action="accept", content={"username": "octocat"}) + return ElicitResult(action="accept", content={"ok": True}) + + async with Client(mcp, elicitation_callback=_never) as client: + first = await client.session.call_tool("tool", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.input_requests is not None + assert len(first.input_requests) == 2 # batched, not serialized + + final = await client.session.call_tool( + "tool", + {}, + input_responses=_answer_round(first, answer), + request_state=first.request_state, + allow_input_required=True, + ) + assert isinstance(final, CallToolResult) + assert isinstance(final.content[0], TextContent) + assert final.content[0].text == "octocat:True" + + +@pytest.mark.anyio +async def test_deep_chain_keeps_early_answers_across_rounds(): + # A 4-round dependency chain where an early answer (A) must survive in + # request_state while later resolvers are asked. It must be asked exactly once. + mcp = MCPServer(name="DeepChain") + + async def ra(ctx: Context) -> Elicit[Login]: + return Elicit("A name?", Login) + + async def rb(a: Annotated[Login, Resolve(ra)]) -> Elicit[Confirm]: + return Elicit("B?", Confirm) + + async def rc(b: Annotated[Confirm, Resolve(rb)]) -> Elicit[Confirm]: + return Elicit("C?", Confirm) + + async def rd(c: Annotated[Confirm, Resolve(rc)]) -> Elicit[Confirm]: + return Elicit("D?", Confirm) + + # Depends on `ra` directly AND on `rd` (which transitively needs ra->rb->rc). + @mcp.tool() + async def tool( + a: Annotated[Login, Resolve(ra)], + d: Annotated[Confirm, Resolve(rd)], + ) -> str: + return f"{a.username}:{d.ok}" + + a_asks = 0 + + async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + nonlocal a_asks + if "name" in params.message: + a_asks += 1 + return ElicitResult(action="accept", content={"username": "octocat"}) + return ElicitResult(action="accept", content={"ok": True}) + + async with Client(mcp, elicitation_callback=callback) as client: + result = await client.call_tool("tool", {}) + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "octocat:True" + assert a_asks == 1 # ra's answer survived in request_state; never re-asked + + +@pytest.mark.anyio +async def test_factory_closures_get_distinct_wire_keys(): + # Two resolvers from one factory share module:qualname; they must still get + # distinct questions and their own values (regression: they collided on the wire). + mcp = MCPServer(name="FactoryClosures") + + def make(label: str): + async def resolver(ctx: Context) -> Elicit[Login]: + return Elicit(f"{label}?", Login) + + return resolver + + ask_a, ask_b = make("A"), make("B") + + @mcp.tool() + async def tool( + a: Annotated[Login, Resolve(ask_a)], + b: Annotated[Login, Resolve(ask_b)], + ) -> str: + return f"{a.username},{b.username}" + + def answer(key: str, params: ElicitRequestFormParams) -> ElicitResult: + return ElicitResult(action="accept", content={"username": params.message[0]}) + + async with Client(mcp, elicitation_callback=_never) as client: + first = await client.session.call_tool("tool", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.input_requests is not None + assert len(first.input_requests) == 2 # distinct keys, not collapsed to one + + final = await client.session.call_tool( + "tool", + {}, + input_responses=_answer_round(first, answer), + request_state=first.request_state, + allow_input_required=True, + ) + assert isinstance(final, CallToolResult) + assert isinstance(final.content[0], TextContent) + assert final.content[0].text == "A,B" + + +@pytest.mark.anyio +async def test_eliciting_resolver_without_elicit_arm_restores_a_typed_model(): + # A resolver annotated `-> Login` that actually returns `Elicit(...)` has no + # `Elicit[T]` return arm, so `elicit_schema` is None. Its answer, restored from + # request_state in a 3+ round flow, must still come back as a Login model (not a + # raw dict) so a dependent resolver/tool can use its attributes. + mcp = MCPServer(name="LyingAnnotation") + + # Annotated without an `Elicit[T]` return arm, so `elicit_schema` is None. + async def login(ctx: Context) -> object: + return Elicit("user?", Login) + + async def confirm(login: Annotated[Login, Resolve(login)]) -> Elicit[Confirm]: + return Elicit(f"as {login.username}?", Confirm) + + @mcp.tool() + async def act( + login: Annotated[Login, Resolve(login)], + confirm: Annotated[Confirm, Resolve(confirm)], + ) -> str: + return f"{login.username}:{confirm.ok}" + + async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + if "user" in params.message: + return ElicitResult(action="accept", content={"username": "octocat"}) + assert "as octocat?" in params.message # login restored as a real model + return ElicitResult(action="accept", content={"ok": True}) + + async with Client(mcp, elicitation_callback=callback) as client: + result = await client.call_tool("act", {}) + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "octocat:True" + + +def test_wire_key_is_worker_stable_for_methods_and_callable_objects(): + class Service: + async def token(self, ctx: Context) -> Login: + return Login(username="x") # pragma: no cover + + class CallableResolver: + async def __call__(self, ctx: Context) -> Login: + return Login(username="x") # pragma: no cover + + a, b = Service(), Service() + # No id(...) in the key: two instances of one method get the same base (they are + # disambiguated at registration, not here), and the key carries no memory address. + assert _state_key(a.token) == _state_key(b.token) + assert "#" not in _state_key(a.token) + assert _state_key(a.token).endswith("Service.token") + # Callable objects key by their type's qualname (they have no `__qualname__`). + assert _state_key(CallableResolver()).endswith("CallableResolver") + + +@pytest.mark.anyio +async def test_declined_outcome_persists_in_request_state_and_is_not_reasked(): + # A decline is recorded in `request_state` just like an accept: RB elicits only + # after seeing RA's decline, so RA's outcome must survive into the round that + # answers RB without RA being asked again. + mcp = MCPServer(name="DeclinePersists") + + async def ra(ctx: Context) -> Elicit[Login]: + return Elicit("user?", Login) + + async def rb(a: Annotated[ElicitationResult[Login], Resolve(ra)]) -> Elicit[Confirm]: + assert isinstance(a, DeclinedElicitation) + return Elicit("proceed anonymously?", Confirm) + + @mcp.tool() + async def act( + a: Annotated[ElicitationResult[Login], Resolve(ra)], + c: Annotated[Confirm, Resolve(rb)], + ) -> str: + assert isinstance(a, DeclinedElicitation) + return f"anonymous:{c.ok}" + + async with Client(mcp, elicitation_callback=_never) as client: + first = await client.session.call_tool("act", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.input_requests is not None + (ra_key,) = first.input_requests + + second = await client.session.call_tool( + "act", + {}, + input_responses={ra_key: ElicitResult(action="decline")}, + request_state=first.request_state, + allow_input_required=True, + ) + assert isinstance(second, InputRequiredResult) + assert second.input_requests is not None + (rb_key,) = second.input_requests # only RB's question; RA is not re-asked + assert rb_key != ra_key + assert _decode_state(second.request_state)[ra_key].action == "decline" + + final = await client.session.call_tool( + "act", + {}, + input_responses={rb_key: ElicitResult(action="accept", content={"ok": True})}, + request_state=second.request_state, + allow_input_required=True, + ) + assert isinstance(final, CallToolResult) + assert isinstance(final.content[0], TextContent) + assert final.content[0].text == "anonymous:True" + + +@pytest.mark.anyio +async def test_unknown_response_keys_and_ghost_state_entries_are_ignored(): + # `input_responses` keys the server never asked for and `request_state` outcome + # entries matching no resolver are tolerated (both are client-supplied), and the + # ghost state entry is not echoed into any later round's `request_state`. + mcp = MCPServer(name="GhostKeys") + + async def ra(ctx: Context) -> Elicit[Login]: + return Elicit("user?", Login) + + async def rb(a: Annotated[Login, Resolve(ra)]) -> Elicit[Confirm]: + return Elicit(f"as {a.username}?", Confirm) + + @mcp.tool() + async def act( + a: Annotated[Login, Resolve(ra)], + c: Annotated[Confirm, Resolve(rb)], + ) -> str: + return f"{a.username}:{c.ok}" + + async with Client(mcp, elicitation_callback=_never) as client: + first = await client.session.call_tool("act", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.input_requests is not None + assert first.request_state is not None + (ra_key,) = first.input_requests + + spliced = json.loads(first.request_state) + spliced["outcomes"]["ghost"] = {"action": "accept", "data": {"username": "spooky"}} + second = await client.session.call_tool( + "act", + {}, + input_responses={ + ra_key: ElicitResult(action="accept", content={"username": "octocat"}), + "ghost": ElicitResult(action="accept", content={"username": "spooky"}), + }, + request_state=json.dumps(spliced), + allow_input_required=True, + ) + assert isinstance(second, InputRequiredResult) + assert second.input_requests is not None + (rb_key,) = second.input_requests + outcomes = _decode_state(second.request_state) + assert ra_key in outcomes + assert "ghost" not in outcomes # the spliced entry is dropped, not carried onward + + final = await client.session.call_tool( + "act", + {}, + input_responses={rb_key: ElicitResult(action="accept", content={"ok": True})}, + request_state=second.request_state, + allow_input_required=True, + ) + assert isinstance(final, CallToolResult) + assert isinstance(final.content[0], TextContent) + assert final.content[0].text == "octocat:True" + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "forged_data", + [ + pytest.param("not-a-dict", id="non-dict-data"), + pytest.param({"hacked": True}, id="dict-failing-schema"), + ], +) +async def test_forged_state_entry_failing_the_schema_is_reasked_not_an_error(forged_data: str | dict[str, bool]): + # `request_state` is client-trusted JSON: an accept entry whose data does not + # validate against the resolver's schema reads as no recorded progress, so the + # question is asked again (not an error) and a proper answer completes the call. + mcp = MCPServer(name="ForgedState") + + async def ask(ctx: Context) -> Elicit[Login]: + return Elicit("user?", Login) + + @mcp.tool() + async def whoami(login: Annotated[Login, Resolve(ask)]) -> str: + return login.username + + async with Client(mcp, elicitation_callback=_never) as client: + first = await client.session.call_tool("whoami", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.input_requests is not None + assert first.request_state is not None + (key,) = first.input_requests + + forged = json.loads(first.request_state) + forged["outcomes"][key] = {"action": "accept", "data": forged_data} + second = await client.session.call_tool( + "whoami", {}, request_state=json.dumps(forged), allow_input_required=True + ) + assert isinstance(second, InputRequiredResult) # re-asked, not an error + assert second.input_requests is not None + assert set(second.input_requests) == {key} + assert _decode_state(second.request_state) == {} # the forged entry is dropped + + final = await client.session.call_tool( + "whoami", + {}, + input_responses={key: ElicitResult(action="accept", content={"username": "octocat"})}, + request_state=second.request_state, + allow_input_required=True, + ) + assert isinstance(final, CallToolResult) + assert isinstance(final.content[0], TextContent) + assert final.content[0].text == "octocat" + + +@pytest.mark.anyio +@pytest.mark.parametrize("mode", ["legacy", "auto"]) +async def test_schema_mismatched_fresh_answer_fails_the_call_without_pydantic_leakage(mode: Literal["legacy", "auto"]): + # An accepted answer whose content fails the requested schema fails the call + # with the framework's own message on both transports; pydantic's error text + # (which carries an "errors.pydantic.dev" link) must not leak to the client. + mcp = MCPServer(name="MismatchedAnswer") + + async def ask(ctx: Context) -> Elicit[Login]: + return Elicit("user?", Login) + + @mcp.tool() + async def whoami(login: Annotated[Login, Resolve(ask)]) -> str: + raise NotImplementedError # pragma: no cover - the mismatched answer never reaches the body + + async with Client(mcp, mode=mode, elicitation_callback=_accept({"nope": "x"})) as client: + result = await client.call_tool("whoami", {}) + assert result.is_error + assert isinstance(result.content[0], TextContent) + text = result.content[0].text + assert "does not match the requested schema" in text + assert "errors.pydantic.dev" not in text + if mode == "auto": + assert "Resolver" in text # the input_required transport names the offending resolver key + else: + assert "Received an accepted elicitation" in text # the legacy path has no wire key to name + + +@pytest.mark.anyio +async def test_auto_driver_gives_up_when_the_chain_outlasts_its_round_budget(): + # A dependency chain of 11 eliciting resolvers needs 11 retry rounds, one more + # than the default `input_required_max_rounds`, so `client.call_tool` must raise + # rather than loop on. The pure `count_leg` resolver is never persisted, so it + # re-runs on every server leg: its final value is the exact number of legs. + mcp = MCPServer(name="TooDeep") + legs = 0 + + async def count_leg(ctx: Context) -> int: + nonlocal legs + legs += 1 + return legs + + async def root(ctx: Context) -> Elicit[Confirm]: + return Elicit("Q1?", Confirm) + + def extend(dep: Callable[..., Any], n: int) -> Callable[..., Any]: + async def link(prev: Annotated[Confirm, Resolve(dep)]) -> Elicit[Confirm]: + return Elicit(f"Q{n}?", Confirm) + + return link + + chain: Callable[..., Any] = root + for n in range(2, 12): # 11 eliciting resolvers in total + chain = extend(chain, n) + + @mcp.tool() + async def long_haul( + leg: Annotated[int, Resolve(count_leg)], + last: Annotated[Confirm, Resolve(chain)], + ) -> str: + raise NotImplementedError # pragma: no cover - the driver gives up first + + answered = 0 + + async def callback(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + nonlocal answered + answered += 1 + return ElicitResult(action="accept", content={"ok": True}) + + async with Client(mcp, elicitation_callback=callback) as client: + with anyio.fail_after(5): # the loop must end by raising, not spin on retries + with pytest.raises(InputRequiredRoundsExceededError) as exc_info: + await client.call_tool("long_haul", {}) + assert exc_info.value.max_rounds == client.input_required_max_rounds + assert answered == client.input_required_max_rounds # one question answered per retry round + assert legs == client.input_required_max_rounds + 1 # the initial call plus one leg per retry + + +@pytest.mark.anyio +async def test_aliased_elicitation_model_round_trips_through_request_state(): + # The stored entry is the client's raw wire content, so it restores through + # the same validation the answer originally passed - aliases and all. A + # re-derived (field-name) shape would fail validation on the round after + # next, drop the stored answer, and re-ask the user forever. + mcp = MCPServer(name="AliasState") + + async def who(ctx: Context) -> Elicit[Handle]: + return Elicit("handle?", Handle) + + async def confirm(h: Annotated[Handle, Resolve(who)]) -> Elicit[Confirm]: + return Elicit(f"go as {h.user_name}?", Confirm) + + @mcp.tool() + async def act( + h: Annotated[Handle, Resolve(who)], + c: Annotated[Confirm, Resolve(confirm)], + ) -> str: + return f"{h.user_name}:{c.ok}" + + async with Client(mcp, elicitation_callback=_never) as client: + first = await client.session.call_tool("act", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.input_requests is not None + (who_key,) = first.input_requests + + second = await client.session.call_tool( + "act", + {}, + input_responses={who_key: ElicitResult(action="accept", content={"userName": "octocat"})}, + request_state=first.request_state, + allow_input_required=True, + ) + assert isinstance(second, InputRequiredResult) + assert second.input_requests is not None + (confirm_key,) = second.input_requests # only the dependent question; the stored answer holds + assert confirm_key != who_key + + final = await client.session.call_tool( + "act", + {}, + input_responses={confirm_key: ElicitResult(action="accept", content={"ok": True})}, + request_state=second.request_state, + allow_input_required=True, + ) + assert isinstance(final, CallToolResult) + assert isinstance(final.content[0], TextContent) + assert final.content[0].text == "octocat:True" + + +@pytest.mark.anyio +async def test_divergent_validation_and_serialization_aliases_round_trip(): + # `request_state` must carry the client's answer exactly as it was sent: the + # rendered question is validation-aliased, so re-deriving the stored shape from + # the validated model (which serializes under the *serialization* alias) would + # produce data the schema's own validation rejects, dropping the stored answer + # on the round after next and re-asking the user. + mcp = MCPServer(name="DivergentAliases") + + async def who(ctx: Context) -> Elicit[Account]: + return Elicit("account?", Account) + + async def confirm(a: Annotated[Account, Resolve(who)]) -> Elicit[Confirm]: + return Elicit(f"go as {a.user_name}?", Confirm) + + @mcp.tool() + async def act( + a: Annotated[Account, Resolve(who)], + c: Annotated[Confirm, Resolve(confirm)], + ) -> str: + return f"{a.user_name}:{c.ok}" + + async with Client(mcp, elicitation_callback=_never) as client: + first = await client.session.call_tool("act", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.input_requests is not None + (who_key,) = first.input_requests + question = first.input_requests[who_key].params + assert isinstance(question, ElicitRequestFormParams) + assert "vUser" in question.requested_schema["properties"] # the client answers validation-aliased + + second = await client.session.call_tool( + "act", + {}, + input_responses={who_key: ElicitResult(action="accept", content={"vUser": "octocat"})}, + request_state=first.request_state, + allow_input_required=True, + ) + assert isinstance(second, InputRequiredResult) + assert second.input_requests is not None + (go_key,) = second.input_requests # only the dependent question; the stored answer holds + assert go_key != who_key + # The stored entry is the client's wire content, not a re-serialization of it. + assert _decode_state(second.request_state)[who_key].data == {"vUser": "octocat"} + + final = await client.session.call_tool( + "act", + {}, + input_responses={go_key: ElicitResult(action="accept", content={"ok": True})}, + request_state=second.request_state, + allow_input_required=True, + ) + assert isinstance(final, CallToolResult) + assert isinstance(final.content[0], TextContent) + assert final.content[0].text == "octocat:True" From 533c6a82266df505cf902d139df8f6026a9d72d9 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:11:15 +0200 Subject: [PATCH 027/100] Add cache_hints constructor map for SEP-2549 caching hints (#3015) --- docs/advanced/caching.md | 64 ++++++++++ docs/migration.md | 2 +- docs_src/caching/__init__.py | 0 docs_src/caching/tutorial001.py | 19 +++ docs_src/caching/tutorial002.py | 18 +++ docs_src/caching/tutorial003.py | 15 +++ mkdocs.yml | 1 + src/mcp/server/__init__.py | 3 +- src/mcp/server/caching.py | 98 +++++++++++++++ src/mcp/server/lowlevel/server.py | 9 +- src/mcp/server/mcpserver/server.py | 5 +- src/mcp/server/runner.py | 8 ++ tests/docs_src/test_caching.py | 70 +++++++++++ tests/server/test_caching.py | 190 +++++++++++++++++++++++++++++ tests/server/test_runner.py | 21 ++++ 15 files changed, 519 insertions(+), 4 deletions(-) create mode 100644 docs/advanced/caching.md create mode 100644 docs_src/caching/__init__.py create mode 100644 docs_src/caching/tutorial001.py create mode 100644 docs_src/caching/tutorial002.py create mode 100644 docs_src/caching/tutorial003.py create mode 100644 src/mcp/server/caching.py create mode 100644 tests/docs_src/test_caching.py create mode 100644 tests/server/test_caching.py diff --git a/docs/advanced/caching.md b/docs/advanced/caching.md new file mode 100644 index 0000000000..f53a3096bf --- /dev/null +++ b/docs/advanced/caching.md @@ -0,0 +1,64 @@ +# Caching hints + +Every result a server returns for `tools/list`, `prompts/list`, `resources/list`, `resources/templates/list`, `resources/read` and `server/discover` carries two fields on the 2026-07-28 protocol: `ttlMs`, how many milliseconds a client may treat the result as fresh, and `cacheScope`, whether a cached result may be shared across users (`"public"`) or belongs to one authorization context (`"private"`). + +The server doesn't cache anything. The fields are a *declaration*: "this tool list is the same for everyone and won't change for a minute." A client (or a gateway in front of you) may then skip the round trip. Honoring the hints is the client's choice; emitting them is the server's job, and the SDK does it for you. + +Out of the box every result says `ttlMs: 0, cacheScope: "private"` — immediately stale, never shared. That is always safe and always conformant. If your lists really are stable and identical for all callers, say so at construction: + +```python title="server.py" hl_lines="5-8" +--8<-- "docs_src/caching/tutorial001.py" +``` + +* The map is keyed by **method name** — the six cacheable methods are the only legal keys. The parameter is typed `Mapping[CacheableMethod, CacheHint]`, so your editor autocompletes the keys and flags a typo before you run; anything that slips past the type checker raises at construction. +* A method you don't mention keeps the defaults. The map is a set of overrides, not a manifest. +* `CacheHint(ttl_ms=5_000)` left `scope` unset, so it stays `"private"`: five seconds of freshness, per caller. Scope and TTL are independent decisions. +* `"server/discover"` is a legal key too — the handshake result is cacheable like any list. + +!!! warning + `cacheScope: "public"` means *anyone* may be served your cached response — a shared + gateway will happily hand one user's result to another, even when the request was + authenticated. Mark a result `"public"` only when it is identical for every caller, and + never use `cacheScope` as access control: it is a label, not a lock. + +## Per-handler override + +On the low-level `Server`, handlers build their results by hand — and `ttl_ms` / `cache_scope` are just fields on the result models. A handler that sets them explicitly always wins over the constructor map, field by field: + +```python title="server.py" hl_lines="11 17" +--8<-- "docs_src/caching/tutorial002.py" +``` + +The handler said `ttl_ms=1_000` and nothing about scope. On the wire: `ttlMs: 1000` (the handler's, not the map's `60_000`) and `cacheScope: "public"` (the map's — the handler left it unset). Explicit beats configured, configured beats default — per field, so a handler can pin one field and leave the other to the server-wide policy. + +This is also the escape hatch for dynamics the constructor can't know: a handler that filters `resources/read` per user can return `cache_scope="private"` for one URI from an otherwise-public server. + +One caveat on paginated lists: the protocol requires the **same `cacheScope` on every page** of one list. The constructor map satisfies that by construction — it's keyed by method, not by page. But a handler that overrides the scope itself owns that consistency: override it on *every* page, never only when a cursor is present, or page one and page two will disagree. + +## What the client sees + +On the client, the hints arrive as plain fields on every cacheable result — `ttl_ms` and `cache_scope`, already parsed: + +```python title="client.py" hl_lines="15" +--8<-- "docs_src/caching/tutorial003.py" +``` + +The SDK parses; it does not (yet) act. There is no built-in response cache: calling `list_tools()` twice makes two round trips, whatever the TTL said. The spec makes honoring optional — a client that ignores the hints entirely is fully conformant — so until the SDK grows a response cache, the supported path is to read the fields and do your own bookkeeping: + +* **Freshness** is `now < t_received + ttl_ms / 1000`: record the clock when the response arrives, and treat the result as reusable until the TTL runs out. `ttl_ms == 0` means *immediately stale* — don't reuse it at all. +* **Scope is a sharing rule, not a suggestion.** A `"private"` result may be reused only within the same authorization context — same access token, same cache. Never put `"private"` results in a cache shared across users. +* **Notifications beat TTL.** If the server sends `list_changed` while your copy is still fresh, the copy is stale now — re-fetch. + +Against an **older server** (pre-2026 protocol), the fields are simply absent from the wire, and the models show their conservative defaults: `ttl_ms == 0`, `cache_scope == "private"` — stale and unshared, the right assumption for a server that declared nothing. If you need to distinguish "the server said 0" from "the server said nothing", check `"ttl_ms" in result.model_fields_set`: it's only set when the field actually arrived. + +## Older clients + +Clients on pre-2026 protocol versions never see either field — the SDK strips them at serialization for those connections. Configure your hints once; there is nothing version-specific to write. + +## Recap + +* Six methods carry `ttlMs`/`cacheScope`; the SDK defaults them to `0`/`"private"` — stale and unshared, always safe. +* `cache_hints={method: CacheHint(...)}` at construction (both `MCPServer` and `Server`) sets server-wide values per method. +* A handler that sets the fields on its result overrides the map, per field. +* `"public"` is a promise that the result is identical for every caller. It is not access control. +* Clients read the hints as `result.ttl_ms` / `result.cache_scope` and own the caching decision themselves — the SDK has no built-in response cache yet. diff --git a/docs/migration.md b/docs/migration.md index fd76d8a4f7..68155560d9 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1600,7 +1600,7 @@ The implementation is responsible for validating the assertion per RFC 7523 §3 ### 2025-11-25 and 2026-07-28 protocol fields modeled -`mcp_types` models the 2025-11-25 and 2026-07-28 protocol fields (e.g. `resultType`, `ttlMs`/`cacheScope` on cacheable results, `inputResponses`/`requestState` on retried requests), so inbound payloads carrying these keys parse into typed fields and round-trip. `ttlMs`/`cacheScope` default to `0`/`"private"` (immediately stale, not shared-cacheable); `resultType` defaults to `"complete"` on concrete results (`None` on `EmptyResult`); the server strips all of them from the wire at pre-2026 versions. +`mcp_types` models the 2025-11-25 and 2026-07-28 protocol fields (e.g. `resultType`, `ttlMs`/`cacheScope` on cacheable results, `inputResponses`/`requestState` on retried requests), so inbound payloads carrying these keys parse into typed fields and round-trip. `ttlMs`/`cacheScope` default to `0`/`"private"` (immediately stale, not shared-cacheable); `resultType` defaults to `"complete"` on concrete results (`None` on `EmptyResult`); the server strips all of them from the wire at pre-2026 versions. Servers set per-method values with `cache_hints={method: CacheHint(...)}` on the `Server`/`MCPServer` constructor — see [Caching hints](advanced/caching.md). ### `streamable_http_app()` available on lowlevel Server diff --git a/docs_src/caching/__init__.py b/docs_src/caching/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/caching/tutorial001.py b/docs_src/caching/tutorial001.py new file mode 100644 index 0000000000..73e686db9f --- /dev/null +++ b/docs_src/caching/tutorial001.py @@ -0,0 +1,19 @@ +from mcp.server import CacheHint, MCPServer + +mcp = MCPServer( + "Weather", + cache_hints={ + "tools/list": CacheHint(ttl_ms=60_000, scope="public"), + "resources/read": CacheHint(ttl_ms=5_000), + }, +) + + +@mcp.tool() +def forecast(city: str) -> str: + return f"Sunny in {city}" + + +@mcp.resource("config://units") +def units() -> str: + return "metric" diff --git a/docs_src/caching/tutorial002.py b/docs_src/caching/tutorial002.py new file mode 100644 index 0000000000..6bbfec9e27 --- /dev/null +++ b/docs_src/caching/tutorial002.py @@ -0,0 +1,18 @@ +from typing import Any + +from mcp_types import ListToolsResult, PaginatedRequestParams, Tool + +from mcp.server import CacheHint, Server, ServerRequestContext + +TOOLS = [Tool(name="forecast", input_schema={"type": "object"})] + + +async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=TOOLS, ttl_ms=1_000) + + +server = Server( + "Weather", + on_list_tools=list_tools, + cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")}, +) diff --git a/docs_src/caching/tutorial003.py b/docs_src/caching/tutorial003.py new file mode 100644 index 0000000000..77ade546b7 --- /dev/null +++ b/docs_src/caching/tutorial003.py @@ -0,0 +1,15 @@ +from mcp import Client +from mcp.server import CacheHint, MCPServer + +mcp = MCPServer("Weather", cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")}) + + +@mcp.tool() +def forecast(city: str) -> str: + return f"Sunny in {city}" + + +async def main() -> None: + async with Client(mcp) as client: + tools = await client.list_tools() + print(f"{len(tools.tools)} tools, fresh for {tools.ttl_ms / 1000:.0f}s, scope={tools.cache_scope}") diff --git a/mkdocs.yml b/mkdocs.yml index 7acee7d5de..83d3a268ae 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -43,6 +43,7 @@ nav: - The low-level Server: advanced/low-level-server.md - URI templates: advanced/uri-templates.md - Pagination: advanced/pagination.md + - Caching hints: advanced/caching.md - Middleware: advanced/middleware.md - Extensions: advanced/extensions.md - MCP Apps: advanced/apps.md diff --git a/src/mcp/server/__init__.py b/src/mcp/server/__init__.py index aab5c33f7d..5897e8aa8b 100644 --- a/src/mcp/server/__init__.py +++ b/src/mcp/server/__init__.py @@ -1,6 +1,7 @@ +from .caching import CacheHint from .context import ServerRequestContext from .lowlevel import NotificationOptions, Server from .mcpserver import MCPServer from .models import InitializationOptions -__all__ = ["Server", "ServerRequestContext", "MCPServer", "NotificationOptions", "InitializationOptions"] +__all__ = ["CacheHint", "Server", "ServerRequestContext", "MCPServer", "NotificationOptions", "InitializationOptions"] diff --git a/src/mcp/server/caching.py b/src/mcp/server/caching.py new file mode 100644 index 0000000000..a8a2a470c6 --- /dev/null +++ b/src/mcp/server/caching.py @@ -0,0 +1,98 @@ +"""Server-side caching hints (SEP-2549, protocol revision 2026-07-28). + +Results for the cacheable methods carry `ttlMs`/`cacheScope` freshness hints. +A handler sets them by returning a result with explicit `ttl_ms`/`cache_scope` +values; `Server(cache_hints={method: CacheHint(...)})` fills them for handlers +that don't. Fields the handler set win, per field, so a server-wide hint never +overrides a handler's explicit choice. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, Final, Literal, TypeVar, get_args + +import mcp_types as types + +__all__ = ["CACHEABLE_METHODS", "CacheHint", "CacheableMethod", "apply_cache_hint", "validate_cache_hints"] + +CacheableMethod = Literal[ + "prompts/list", + "resources/list", + "resources/read", + "resources/templates/list", + "server/discover", + "tools/list", +] +"""The methods whose results carry `ttlMs`/`cacheScope`. Closed set: the spec +defines caching hints on exactly these six (tests pin it to which result models +mix in `CacheableResult`).""" + +CACHEABLE_METHODS: Final[frozenset[str]] = frozenset(get_args(CacheableMethod)) +"""Runtime mirror of `CacheableMethod`, for callers the type checker can't see.""" + + +@dataclass(frozen=True, slots=True) +class CacheHint: + """Freshness hint for one cacheable method's results. + + `ttl_ms` is how long, in milliseconds, a client may consider the result + fresh (`0` means immediately stale). `scope` is whether a cached result may + be shared across authorization contexts (`"public"`) or only reused within + the one that produced it (`"private"`). + """ + + ttl_ms: int = 0 + scope: Literal["public", "private"] = "private" + + def __post_init__(self) -> None: + if self.ttl_ms < 0: + raise ValueError(f"ttl_ms must be >= 0, got {self.ttl_ms}") + if self.scope not in ("public", "private"): + raise ValueError(f"scope must be 'public' or 'private', got {self.scope!r}") + + +CacheableResultT = TypeVar("CacheableResultT", bound=types.CacheableResult) + + +def apply_cache_hint(result: CacheableResultT, hint: CacheHint) -> CacheableResultT: + """Fill `ttl_ms`/`cache_scope` on `result` from `hint`. + + Per-field: a field the handler set explicitly - even to its default value, + tracked via `model_fields_set` - is left alone; only unset fields take the + hint. A handler constructing results with `model_construct` bypasses that + tracking and is treated as having set nothing. + """ + update: dict[str, int | str] = {} + if "ttl_ms" not in result.model_fields_set: + update["ttl_ms"] = hint.ttl_ms + if "cache_scope" not in result.model_fields_set: + update["cache_scope"] = hint.scope + return result.model_copy(update=update) if update else result + + +def validate_cache_hints(cache_hints: Mapping[Any, Any] | None) -> dict[str, CacheHint]: + """Validate a `cache_hints` constructor argument into a plain dict. + + The `Server`/`MCPServer` signatures already close the key set and value + type for type-checked callers; this runtime gate is deliberately loose in + its parameter so it covers everyone else (e.g. a map deserialized from + config) - a bad entry fails at construction, not on the first request to + that method. + + Raises: + ValueError: If a key is not a cacheable method. + TypeError: If a value is not a `CacheHint`. + """ + if cache_hints is None: + return {} + unknown = sorted(method for method in cache_hints if method not in CACHEABLE_METHODS) + if unknown: + raise ValueError(f"cache_hints keys must be cacheable methods (see CacheableMethod); got: {', '.join(unknown)}") + validated: dict[str, CacheHint] = {} + for method, hint in cache_hints.items(): + if not isinstance(hint, CacheHint): + raise TypeError(f"cache_hints[{method!r}] must be a CacheHint, got {type(hint).__name__}") + validated[method] = hint + return validated diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 6f4d9f8124..97b5557e20 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -38,7 +38,7 @@ async def main(): import logging import warnings -from collections.abc import AsyncIterator, Awaitable, Callable +from collections.abc import AsyncIterator, Awaitable, Callable, Mapping from contextlib import AbstractAsyncContextManager, asynccontextmanager from dataclasses import dataclass from importlib.metadata import version as importlib_version @@ -59,6 +59,7 @@ async def main(): from mcp.server.auth.provider import OAuthAuthorizationServerProvider, TokenVerifier from mcp.server.auth.routes import build_resource_metadata_url, create_auth_routes, create_protected_resource_routes from mcp.server.auth.settings import AuthSettings +from mcp.server.caching import CacheableMethod, CacheHint, validate_cache_hints from mcp.server.context import HandlerResult, ServerMiddleware, ServerRequestContext from mcp.server.models import InitializationOptions from mcp.server.runner import serve_loop @@ -140,6 +141,7 @@ def __init__( instructions: str | None = None, website_url: str | None = None, icons: list[types.Icon] | None = None, + cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, lifespan: Callable[ [Server[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT], @@ -222,6 +224,7 @@ def __init__( instructions: str | None = None, website_url: str | None = None, icons: list[types.Icon] | None = None, + cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, lifespan: Callable[ [Server[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT], @@ -313,6 +316,7 @@ def __init__( instructions: str | None = None, website_url: str | None = None, icons: list[types.Icon] | None = None, + cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, lifespan: Callable[ [Server[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT], @@ -420,6 +424,9 @@ def __init__( self.instructions = instructions self.website_url = website_url self.icons = icons + # Per-method `ttl_ms`/`cache_scope` fills, applied by `ServerRunner` + # after the handler returns; fields the handler set explicitly win. + self.cache_hints: dict[str, CacheHint] = validate_cache_hints(cache_hints) self.lifespan = lifespan self._request_handlers: dict[str, HandlerEntry[LifespanResultT]] = {} self._notification_handlers: dict[str, HandlerEntry[LifespanResultT]] = {} diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 33348c0838..888eae6541 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -4,7 +4,7 @@ import base64 import inspect -from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence +from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Mapping, Sequence from contextlib import AbstractAsyncContextManager, asynccontextmanager from typing import Any, Generic, Literal, TypeVar, overload @@ -58,6 +58,7 @@ from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend, RequireAuthMiddleware from mcp.server.auth.provider import OAuthAuthorizationServerProvider, ProviderTokenVerifier, TokenVerifier from mcp.server.auth.settings import AuthSettings +from mcp.server.caching import CacheableMethod, CacheHint from mcp.server.context import HandlerResult, ServerRequestContext from mcp.server.extension import ( Extension, @@ -169,6 +170,7 @@ def __init__( lifespan: Callable[[MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None = None, auth: AuthSettings | None = None, resource_security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY, + cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, ): self._resource_security = resource_security self.settings = Settings( @@ -196,6 +198,7 @@ def __init__( website_url=website_url, icons=icons, version=version, + cache_hints=cache_hints, on_list_tools=self._handle_list_tools, on_call_tool=self._handle_call_tool, on_list_resources=self._handle_list_resources, diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index 0b57c3e5c2..4c25a8a5bc 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -28,6 +28,7 @@ INVALID_PARAMS, METHOD_NOT_FOUND, PROTOCOL_VERSION_META_KEY, + CacheableResult, ErrorData, Implementation, InitializeRequestParams, @@ -40,6 +41,7 @@ from pydantic import BaseModel, ValidationError from typing_extensions import TypeVar +from mcp.server.caching import apply_cache_hint from mcp.server.connection import Connection from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext from mcp.server.models import InitializationOptions @@ -196,6 +198,12 @@ async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> HandlerResult: if isinstance(result, ErrorData): # Raise inside the chain so middleware observes the failure. raise MCPError.from_error_data(result) + # Fill cache hints on the typed result, before the serialize sieve + # decides whether the negotiated version carries the fields at all. + # `input_required` interim results are not `CacheableResult` models, + # so the MRTR carve-out (no hints on them) holds by shape. + if isinstance(result, CacheableResult) and (hint := self.server.cache_hints.get(method)) is not None: + result = apply_cache_hint(result, hint) # Dump and serialize inside the chain so the OpenTelemetry span (the # outermost middleware) records a failing handler return shape too. return self._serialize(method, version, result) diff --git a/tests/docs_src/test_caching.py b/tests/docs_src/test_caching.py new file mode 100644 index 0000000000..bc2feb9ac0 --- /dev/null +++ b/tests/docs_src/test_caching.py @@ -0,0 +1,70 @@ +"""`docs/advanced/caching.md`: every claim the page makes, proved against the real SDK.""" + +from typing import Any, cast + +import pytest +from inline_snapshot import snapshot + +from docs_src.caching import tutorial001, tutorial002, tutorial003 +from mcp import Client +from mcp.server import CacheHint, MCPServer + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_a_mapped_method_carries_the_configured_hint() -> None: + """tutorial001: `tools/list` is in the map, so clients see one minute, public.""" + async with Client(tutorial001.mcp) as client: + tools = await client.list_tools() + assert tools.ttl_ms == 60_000 + assert tools.cache_scope == "public" + + +async def test_a_hint_without_a_scope_stays_private() -> None: + """tutorial001: `resources/read` set only `ttl_ms`; scope keeps the conservative default.""" + async with Client(tutorial001.mcp) as client: + result = await client.read_resource("config://units") + assert result.ttl_ms == 5_000 + assert result.cache_scope == "private" + + +async def test_an_unmapped_method_stays_immediately_stale_and_private() -> None: + """tutorial001: `resources/list` is not in the map - the defaults hold.""" + async with Client(tutorial001.mcp) as client: + resources = await client.list_resources() + assert resources.ttl_ms == 0 + assert resources.cache_scope == "private" + + +async def test_a_non_cacheable_method_is_rejected_at_construction() -> None: + """The page's claim: anything but the six cacheable methods raises at construction.""" + with pytest.raises(ValueError) as exc: + MCPServer("Weather", cache_hints=cast(Any, {"tools/call": CacheHint(ttl_ms=1_000)})) + assert str(exc.value) == snapshot( + "cache_hints keys must be cacheable methods (see CacheableMethod); got: tools/call" + ) + + +async def test_the_handler_value_wins_over_the_map_per_field() -> None: + """tutorial002: the handler's `ttl_ms=1_000` beats the map's `60_000`; the scope + the handler left unset takes the map's `"public"`.""" + async with Client(tutorial002.server) as client: + tools = await client.list_tools() + assert tools.ttl_ms == 1_000 + assert tools.cache_scope == "public" + + +async def test_the_client_program_on_the_page_reads_the_hints(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial003: `main()` is the literal client program on the page - the hints + arrive as parsed fields on the result.""" + await tutorial003.main() + assert capsys.readouterr().out == "1 tools, fresh for 60s, scope=public\n" + + +async def test_the_wire_presence_check_the_page_recommends_works() -> None: + """The page's claim: `"ttl_ms" in result.model_fields_set` distinguishes a + server that sent the field from one that said nothing (model defaults).""" + async with Client(tutorial003.mcp) as client: + tools = await client.list_tools() + assert "ttl_ms" in tools.model_fields_set diff --git a/tests/server/test_caching.py b/tests/server/test_caching.py new file mode 100644 index 0000000000..46701d6599 --- /dev/null +++ b/tests/server/test_caching.py @@ -0,0 +1,190 @@ +"""`mcp.server.caching`: `CacheHint` validation, per-field fills, and the +`cache_hints` constructor map reaching the wire on both server tiers.""" + +from types import UnionType +from typing import Any, cast, get_args + +import pytest +from inline_snapshot import snapshot +from mcp_types import ( + CacheableResult, + ListResourcesResult, + ListToolsResult, + PaginatedRequestParams, + Resource, + Tool, + methods, +) + +from mcp import Client +from mcp.server import CacheHint, MCPServer, Server, ServerRequestContext +from mcp.server.caching import CACHEABLE_METHODS, apply_cache_hint + +pytestmark = pytest.mark.anyio + + +def test_cacheable_methods_match_the_result_models() -> None: + """Spec-mandated set (SEP-2549): `CACHEABLE_METHODS` mirrors exactly the + methods whose monolith result models mix in `CacheableResult` - if the + schema gains or loses a cacheable result, this weld breaks.""" + derived: set[str] = set() + for method, model in methods.MONOLITH_RESULTS.items(): + arms = get_args(model) if isinstance(model, UnionType) else (model,) + if any(isinstance(arm, type) and issubclass(arm, CacheableResult) for arm in arms): + derived.add(method) + assert CACHEABLE_METHODS == derived + + +def test_cache_hint_defaults_match_the_conservative_model_defaults() -> None: + """SDK-defined: an unconfigured hint fills the same values the result models + already default to - immediately stale, not shared - so stamping it is + indistinguishable from not stamping at all.""" + hint = CacheHint() + model = ListToolsResult(tools=[]) + assert (hint.ttl_ms, hint.scope) == (model.ttl_ms, model.cache_scope) + + +def test_a_negative_ttl_is_rejected_at_hint_construction() -> None: + """Spec-mandated: servers MUST provide `ttlMs >= 0`, so a negative value + fails at `CacheHint` construction rather than reaching the wire.""" + with pytest.raises(ValueError) as exc: + CacheHint(ttl_ms=-1) + assert str(exc.value) == snapshot("ttl_ms must be >= 0, got -1") + + +def test_an_unknown_scope_is_rejected_at_hint_construction() -> None: + """Spec-mandated: `cacheScope` is a closed enum, enforced for untyped callers + the type checker cannot see.""" + with pytest.raises(ValueError) as exc: + CacheHint(scope=cast(Any, "shared")) + assert str(exc.value) == snapshot("scope must be 'public' or 'private', got 'shared'") + + +def test_apply_cache_hint_fills_only_the_fields_the_handler_left_unset() -> None: + """SDK-defined precedence, per field: the handler's explicit `ttl_ms` stays, + the unset `cache_scope` takes the hint's value.""" + result = ListToolsResult(tools=[], ttl_ms=10) + filled = apply_cache_hint(result, CacheHint(ttl_ms=60_000, scope="public")) + assert filled.ttl_ms == 10 + assert filled.cache_scope == "public" + + +def test_apply_cache_hint_never_overrides_explicit_fields_even_at_default_values() -> None: + """SDK-defined: an explicit `ttl_ms=0, cache_scope="private"` is a handler + decision, not an absence - the hint must not replace it (`model_fields_set` + distinguishes the two).""" + result = ListToolsResult(tools=[], ttl_ms=0, cache_scope="private") + assert apply_cache_hint(result, CacheHint(ttl_ms=60_000, scope="public")) is result + + +def test_a_non_cacheable_method_in_cache_hints_is_rejected_at_server_construction() -> None: + """SDK-defined: only the six cacheable methods take hints; a typo or a + non-cacheable method fails at `Server(...)` time, not silently at runtime.""" + with pytest.raises(ValueError) as exc: + Server("srv", cache_hints=cast(Any, {"tools/call": CacheHint()})) + assert str(exc.value) == snapshot( + "cache_hints keys must be cacheable methods (see CacheableMethod); got: tools/call" + ) + + +def test_a_non_cache_hint_value_is_rejected_at_server_construction() -> None: + """SDK-defined: a config-shaped value (a plain dict instead of a `CacheHint`) + fails at `Server(...)` time too - not with an `AttributeError` on the first + request to that method.""" + with pytest.raises(TypeError) as exc: + Server("srv", cache_hints=cast(Any, {"tools/list": {"ttl_ms": 60_000}})) + assert str(exc.value) == snapshot("cache_hints['tools/list'] must be a CacheHint, got dict") + + +async def test_server_cache_hints_reach_the_wire_for_a_bare_handler_result() -> None: + """SDK-defined: a lowlevel handler that never thinks about caching emits the + server-wide hint configured at construction.""" + hint = CacheHint(ttl_ms=60_000, scope="public") + + async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})]) + + server = Server("srv", on_list_tools=list_tools, cache_hints={"tools/list": hint}) + async with Client(server) as client: + result = await client.list_tools() + assert result.ttl_ms == hint.ttl_ms + assert result.cache_scope == hint.scope + + +async def test_every_page_of_a_paginated_list_carries_the_configured_scope() -> None: + """Spec-mandated: the same `cacheScope` MUST apply to all pages of one list. + The map is keyed by method, not cursor, so a handler that leaves scope unset + gets the same scope on every page. (A handler that overrides the scope owns + that consistency itself - see `docs/advanced/caching.md`.)""" + names = [f"r-{n}" for n in range(4)] + + async def list_resources( + ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None + ) -> ListResourcesResult: + start = 0 if params is None or params.cursor is None else int(params.cursor) + page = [Resource(uri=f"res://{name}", name=name) for name in names[start : start + 2]] + next_cursor = str(start + 2) if start + 2 < len(names) else None + return ListResourcesResult(resources=page, next_cursor=next_cursor) + + server = Server( + "srv", + on_list_resources=list_resources, + cache_hints={"resources/list": CacheHint(ttl_ms=30_000, scope="public")}, + ) + async with Client(server) as client: + first = await client.list_resources() + assert first.next_cursor is not None + second = await client.list_resources(cursor=first.next_cursor) + assert (first.cache_scope, second.cache_scope) == ("public", "public") + assert (first.ttl_ms, second.ttl_ms) == (30_000, 30_000) + + +async def test_the_default_discover_handler_takes_the_server_discover_hint() -> None: + """SDK-defined: the auto-derived `server/discover` result is stamped from the + map like any other cacheable result - no separate discover-specific knob.""" + server = Server("srv", cache_hints={"server/discover": CacheHint(ttl_ms=300_000, scope="public")}) + async with Client(server) as client: + discovered = await client.session.discover() + assert discovered.ttl_ms == 300_000 + assert discovered.cache_scope == "public" + + +async def test_mcpserver_cache_hints_cover_every_high_level_handler() -> None: + """SDK-defined: the `MCPServer` constructor map reaches all six cacheable + methods. Each method gets a distinct `ttl_ms` so a failure names the handler + that lost its hint.""" + mcp = MCPServer( + "demo", + cache_hints={ + "tools/list": CacheHint(ttl_ms=1_000, scope="public"), + "resources/list": CacheHint(ttl_ms=2_000, scope="public"), + "resources/templates/list": CacheHint(ttl_ms=3_000, scope="public"), + "prompts/list": CacheHint(ttl_ms=4_000, scope="public"), + "resources/read": CacheHint(ttl_ms=5_000, scope="public"), + "server/discover": CacheHint(ttl_ms=6_000, scope="public"), + }, + ) + + @mcp.tool() + def add(a: int, b: int) -> int: + raise NotImplementedError + + @mcp.resource("config://app") + def config() -> str: + return "cfg" + + @mcp.resource("greeting://{name}") + def greeting(name: str) -> str: + raise NotImplementedError + + @mcp.prompt() + def hello() -> str: + raise NotImplementedError + + async with Client(mcp) as client: + assert (await client.list_tools()).ttl_ms == 1_000 + assert (await client.list_resources()).ttl_ms == 2_000 + assert (await client.list_resource_templates()).ttl_ms == 3_000 + assert (await client.list_prompts()).ttl_ms == 4_000 + assert (await client.read_resource("config://app")).ttl_ms == 5_000 + assert (await client.session.discover()).ttl_ms == 6_000 diff --git a/tests/server/test_runner.py b/tests/server/test_runner.py index ed9662f08d..9200158459 100644 --- a/tests/server/test_runner.py +++ b/tests/server/test_runner.py @@ -36,6 +36,7 @@ from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION, OLDEST_SUPPORTED_VERSION import mcp.server.runner +from mcp.server.caching import CacheHint from mcp.server.connection import Connection from mcp.server.context import ServerRequestContext from mcp.server.lowlevel.server import NotificationOptions, Server @@ -861,6 +862,26 @@ async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToo assert result == {"tools": [{"name": "t", "inputSchema": {"type": "object"}}]} +@pytest.mark.anyio +async def test_runner_outbound_sieve_drops_configured_cache_hints_at_a_pre_2026_version(): + """A `cache_hints` map fills the typed result before serialization, so the + same sieve that strips handler-set fields strips configured ones too - a + 2025 client never sees `ttlMs`/`cacheScope`.""" + + async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})]) + + server: SrvT = Server( + "test-server", + on_list_tools=list_tools, + cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")}, + ) + async with connected_runner(server) as (client, runner): + assert runner.connection.protocol_version == "2025-11-25" + result = await client.send_raw_request("tools/list", None) + assert result == {"tools": [{"name": "t", "inputSchema": {"type": "object"}}]} + + @pytest.mark.anyio async def test_runner_server_direction_spec_method_routes_to_a_registered_handler(server: SrvT): """`roots/list` is a spec method but server-to-client only; on a server it From 8f2c97b76983cd1598df058878aa3b2f2b5fc401 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:44:05 +0200 Subject: [PATCH 028/100] Consult request_state only for the question a resolver is asking (#3019) --- docs/advanced/multi-round-trip.md | 2 +- docs/tutorial/dependencies.md | 18 +- examples/stories/refund_desk/README.md | 9 +- src/mcp/server/mcpserver/resolve.py | 98 +++++---- src/mcp/server/mcpserver/tools/base.py | 18 +- tests/server/mcpserver/test_resolve.py | 265 ++++++++++++++++++++++--- 6 files changed, 321 insertions(+), 89 deletions(-) diff --git a/docs/advanced/multi-round-trip.md b/docs/advanced/multi-round-trip.md index 665808a5dc..883a594e23 100644 --- a/docs/advanced/multi-round-trip.md +++ b/docs/advanced/multi-round-trip.md @@ -19,7 +19,7 @@ That's the whole protocol. Every leg is an ordinary request from the client to t ## The server side -On `@mcp.tool()` you rarely build this by hand: declare a dependency that asks the user and the SDK returns the `InputRequiredResult` for you - that form is the **[Dependencies](../tutorial/dependencies.md)** tutorial. The manual form is the **low-level** `Server`, whose `on_call_tool` handler is allowed to return either result type: +On `@mcp.tool()` you rarely build this by hand: declare a dependency that asks the user and the SDK returns the `InputRequiredResult` for you - that form is the **[Dependencies](../tutorial/dependencies.md)** tutorial. The two forms don't mix: a call has one `input_responses`/`request_state` channel, so a tool that uses `Resolve(...)` parameters cannot also return `InputRequiredResult` from its body. A declared `InputRequiredResult` return is rejected at registration (`InvalidSignature`), and an undeclared one fails the call at runtime. The manual form is the **low-level** `Server`, whose `on_call_tool` handler is allowed to return either result type: ```python title="server.py" hl_lines="44-47" --8<-- "docs_src/mrtr/tutorial001.py" diff --git a/docs/tutorial/dependencies.md b/docs/tutorial/dependencies.md index 4e91515efb..b7b18fe763 100644 --- a/docs/tutorial/dependencies.md +++ b/docs/tutorial/dependencies.md @@ -123,19 +123,21 @@ That's the right default for a precondition: no answer, no order. When declining answers it, and the `Client` retries the call for you (**[Multi-round-trip requests](../advanced/multi-round-trip.md)**). On **2025-11-25** and earlier it is a synchronous elicitation request mid-call. Each question is asked exactly once per call - a guarantee about the question, not the resolver. In the - multi-round-trip form an eliciting resolver runs again to consume its answer, so code before - its `return Elicit(...)` runs on the asking round and again on the answering one; a resolver - that answered *without* asking, like `check_stock`, may run again whenever the call resumes - after a question. When it resumes, each answer is matched back to its question, so an - eliciting resolver must derive its question deterministically from the tool's arguments and - earlier answers - a per-call generated value (a `default_factory` id, a timestamp) is - re-derived on each round and must not appear in a question the answer is meant to bind to. + multi-round-trip form any resolver may run again whenever the call resumes after a question, + so code before a `return Elicit(...)` runs on each of those rounds; the recorded answer then + satisfies the repeated question without prompting the user again. A recorded answer is only + ever consulted when the resolver asks; a resolver that answers *without* asking, like + `check_stock`, always supplies its own computed value. Because each answer is matched back to + its question, an eliciting resolver must derive its question deterministically from the + tool's arguments and earlier answers. A per-call generated value (a `default_factory` id, a + timestamp) is re-derived on each round and must not appear in a question the answer is meant + to bind to. ## Recap * `Annotated[T, Resolve(fn)]` on a tool parameter: the SDK runs `fn` and injects its return value. * A resolved parameter is invisible to the model and cannot be supplied by a client. Values the model must not invent - prices, identities, permissions - belong here. -* A resolver's parameters are resolved the same way: the `Context`, another `Resolve(...)`, or a tool argument by name. The graph runs each resolver at most once per round, however many consumers it has; each question is asked exactly once, an eliciting resolver runs again to consume its answer, and a resolver that never asked may run again when a call resumes. +* A resolver's parameters are resolved the same way: the `Context`, another `Resolve(...)`, or a tool argument by name. The graph runs each resolver at most once per round, however many consumers it has; each question is asked exactly once, and any resolver may run again when a call resumes after a question. * Bad graphs fail at registration with `InvalidSignature`, not mid-call. * Return `Elicit(message, Model)` to ask the user, only when you have to. Unwrapped annotations abort on decline; `ElicitationResult[T]` lets the tool branch. diff --git a/examples/stories/refund_desk/README.md b/examples/stories/refund_desk/README.md index 1535040415..5b5bb55327 100644 --- a/examples/stories/refund_desk/README.md +++ b/examples/stories/refund_desk/README.md @@ -60,10 +60,11 @@ uv run python -m stories.refund_desk.client --http consumer can abort. - **Memoization scope.** Each question is asked at most once per call, and within a round each resolver runs at most once, keyed by function identity. - Across 2026 rounds only *elicited* outcomes persist (in `requestState`); a - resolver that resolves without eliciting is pure and may re-run each round. - An eliciting resolver's body runs again too — once to ask, once more to - consume its answer. + Across 2026 rounds only *elicited* outcomes persist (in `requestState`); any + resolver's body may run again on each round the call passes through. A + recorded answer is consulted only when the resolver asks its question again: + it satisfies the question without re-prompting the user, and it never stands + in for a value the resolver computes itself. An answer is matched back to its question when the call resumes, so an eliciting resolver must derive its question deterministically from the tool's arguments and earlier answers; a per-call generated value (a diff --git a/src/mcp/server/mcpserver/resolve.py b/src/mcp/server/mcpserver/resolve.py index 323ce5cddb..9ff8dfeed5 100644 --- a/src/mcp/server/mcpserver/resolve.py +++ b/src/mcp/server/mcpserver/resolve.py @@ -13,8 +13,10 @@ (independent resolvers are asked in one round; a resolver depending on another's answer is asked in a later round). At <= 2025-11-25 it issues a synchronous `elicitation/create` request mid-call. Only *elicited* outcomes are carried in -`request_state` across rounds (so the user is asked each question once); a -resolver that returns a value without eliciting is pure and may re-run each round. +`request_state` across rounds (so the user is asked each question once). Resolver +bodies may re-run on every round; a recorded outcome is consulted only when the +body asks its question again, so a resolver's own computation always wins over +anything the client echoes back in `request_state`. Whether the consumer receives the unwrapped model or the full `ElicitationResult` union is decided by the consumer's annotation: @@ -114,15 +116,11 @@ def __init__( fn: Callable[..., Any], params: dict[str, _ParamPlan], is_async: bool, - elicit_schema: type[BaseModel] | None, wire_key: str, ) -> None: self.fn = fn self.params = params self.is_async = is_async - # The `T` from the resolver's `Elicit[T]` return arm, if annotated. Used to - # re-validate an outcome restored from `request_state` into a model. - self.elicit_schema = elicit_schema # Deterministic, collision-free key for this resolver's elicitation on the # wire (`input_requests`/`request_state`). Assigned at registration so it is # stable across rounds even when `module:qualname` collides (closures). @@ -176,6 +174,37 @@ def find_resolved_parameters(fn: Callable[..., Any]) -> dict[str, tuple[Resolve, return resolved +def returns_input_required(fn: Callable[..., Any]) -> bool: + """True when `fn`'s return annotation carries an `InputRequiredResult` arm. + + Used at tool registration to reject combining `Resolve(...)` parameters with a + hand-rolled `InputRequiredResult` flow: a call has a single + `input_responses`/`request_state` channel, so the two flows would overwrite + each other's state and the call could never converge. + """ + return _has_input_required_arm(_type_hints(fn).get("return")) + + +def _has_input_required_arm(annotation: Any) -> bool: + """Walk an annotation's arms through `Annotated`, type aliases, and unions.""" + if get_origin(annotation) is Annotated: + return _has_input_required_arm(get_args(annotation)[0]) + # A `type X = ...` / `TypeAliasType` alias carries its target on `__value__` (a + # subscripted alias forwards the attribute to its origin). The access evaluates + # a PEP 695 alias lazily, so an alias naming things unavailable at runtime + # (TYPE_CHECKING-only imports) raises NameError; such an alias declares no arm + # this check can see, and the in-call guard in `Tool.run` still covers it. + try: + value = getattr(annotation, "__value__", None) + except NameError: + return False + if value is not None: + return _has_input_required_arm(value) + if _is_union(annotation): + return any(_has_input_required_arm(arg) for arg in get_args(annotation)) + return isinstance(annotation, type) and issubclass(annotation, InputRequiredResult) + + def _contains_resolve(annotation: Any) -> bool: """True when a `Resolve` marker is nested inside `annotation` (e.g. a union member).""" if get_origin(annotation) is Annotated: @@ -183,16 +212,12 @@ def _contains_resolve(annotation: Any) -> bool: return any(_contains_resolve(arg) for arg in get_args(annotation)) -def _elicit_return_schema(return_annotation: Any, name: str) -> type[BaseModel] | None: - """Extract `T` from a resolver return type's `Elicit[T]` arm, if present. - - Handles a bare `-> Elicit[T]` and a `-> T | Elicit[T]` union. Lets an elicited - outcome restored from `request_state` (a plain dict) be re-validated into its - model so dependent resolvers and tools receive a typed value. +def _check_elicit_return(return_annotation: Any, name: str) -> None: + """Validate the `Elicit[...]` arms of a resolver's return annotation. Raises: InvalidSignature: If the annotation has more than one `Elicit[...]` arm; - the runtime can honor only one static question schema per resolver. + a resolver asks one question - a second arm means it should be split. """ # A bare `Elicit[T]` is itself a candidate; a union contributes its members. candidates = get_args(return_annotation) if _is_union(return_annotation) else (return_annotation,) @@ -203,10 +228,6 @@ def _elicit_return_schema(return_annotation: Any, name: str) -> type[BaseModel] f"Resolver {name!r} return annotation has multiple Elicit arms; " "a resolver asks one question - split it into separate resolvers" ) - if not arms: - return None - schema = get_args(arms[0])[0] - return schema if isinstance(schema, type) and issubclass(schema, BaseModel) else None def _is_union(annotation: Any) -> bool: @@ -299,8 +320,8 @@ def analyze(fn: Callable[..., Any], stack: tuple[Hashable, ...]) -> None: "expected a Context, an Annotated[_, Resolve(...)], or a tool argument by name" ) - elicit_schema = _elicit_return_schema(hints.get("return"), _resolver_name(fn)) - plans[key] = _ResolverPlan(fn, params, is_async_callable(fn), elicit_schema, wire_key) + _check_elicit_return(hints.get("return"), _resolver_name(fn)) + plans[key] = _ResolverPlan(fn, params, is_async_callable(fn), wire_key) for dep in nested: analyze(dep, stack + (key,)) @@ -387,9 +408,10 @@ async def resolve_arguments( negotiated protocol is >= 2026-07-28), returns an `InputRequiredResult` carrying the batched questions instead; the tool body is not run. - An eliciting resolver asks its question once - its answer is carried in - `request_state` across rounds - while a resolver that resolves without - eliciting is pure and may re-run on each round. + Each question is asked once - its answer is carried in `request_state` across + rounds and satisfies the question when the resolver asks it again. Resolver + bodies themselves may re-run on each round; a recorded answer is consulted + only when the body asks, never in place of running it. Raises: ToolError: If an elicited value is declined or cancelled and the consumer @@ -428,15 +450,6 @@ async def _resolve(fn: Callable[..., Any], res: _Resolution) -> ElicitationResul if wire_key in res.pending: # Already asked this round by another consumer; don't run the resolver again. raise _Pending - # Restore a prior round's outcome directly only when its model is known from the - # `Elicit[T]` return arm. Without that (a resolver that elicits but isn't annotated - # `-> ... Elicit[T]`), fall through and re-run the resolver so `_elicit` can - # re-validate the stored answer against the live `Elicit.schema`. - if wire_key in res.state and (plan.elicit_schema is not None or res.state[wire_key].action != "accept"): - outcome = _restore_outcome(res, wire_key, plan.elicit_schema) - if outcome is not None: - res.cache[cache_key] = outcome - return outcome kwargs: dict[str, Any] = {} dep_pending = False @@ -481,10 +494,11 @@ async def _elicit(elicit: Elicit[Any], key: str, res: _Resolution) -> Elicitatio if not res.input_required: return await res.context.elicit(elicit.message, elicit.schema) - # Answered in a prior round (restored without a known schema, e.g. an unannotated - # resolver): re-validate the stored entry against the live `Elicit.schema`. A - # recorded outcome wins over a re-sent answer; an invalid entry self-deletes and - # falls through to the fresh answer (or to re-asking). + # A recorded outcome from a prior round is consulted only here, after the body + # decided to ask, so a `request_state` entry can never stand in for a resolver's + # own computation. Re-validate it against the live `Elicit.schema`. A recorded + # outcome wins over a re-sent answer; an invalid entry self-deletes and falls + # through to the fresh answer (or to re-asking). outcome = _restore_outcome(res, key, elicit.schema) if outcome is not None: return outcome @@ -614,24 +628,21 @@ def _encode_state(outcomes: Mapping[str, _StateEntry]) -> str: return _State(v=_STATE_VERSION, outcomes=dict(outcomes)).model_dump_json() -def _outcome_from_state(entry: _StateEntry, schema: type[BaseModel] | None) -> ElicitationResult[Any]: +def _outcome_from_state(entry: _StateEntry, schema: type[BaseModel]) -> ElicitationResult[Any]: """Rebuild an `ElicitationResult` from a decoded `request_state` entry. Raises: - ValidationError: If `schema` is known and the entry's data does not - validate against it. + ValidationError: If an accepted entry's data does not validate against + `schema` (the live `Elicit.schema` of the question being asked). """ if entry.action == "decline": return DeclinedElicitation() if entry.action == "cancel": return CancelledElicitation() - data = entry.data - if schema is not None: - data = schema.model_validate(data) - return _accepted(data) + return _accepted(schema.model_validate(entry.data)) -def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel] | None) -> ElicitationResult[Any] | None: +def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel]) -> ElicitationResult[Any] | None: """Restore `key`'s recorded outcome from a prior round, or `None` when absent. `request_state` is client-trusted, so an entry whose data fails validation gets @@ -665,4 +676,5 @@ def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel] | None) "find_resolved_parameters", "build_resolver_plans", "resolve_arguments", + "returns_input_required", ] diff --git a/src/mcp/server/mcpserver/tools/base.py b/src/mcp/server/mcpserver/tools/base.py index 50d28f574b..23248707a3 100644 --- a/src/mcp/server/mcpserver/tools/base.py +++ b/src/mcp/server/mcpserver/tools/base.py @@ -7,11 +7,12 @@ from mcp_types import Icon, InputRequiredResult, ToolAnnotations from pydantic import BaseModel, Field -from mcp.server.mcpserver.exceptions import ToolError +from mcp.server.mcpserver.exceptions import InvalidSignature, ToolError from mcp.server.mcpserver.resolve import ( build_resolver_plans, find_resolved_parameters, resolve_arguments, + returns_input_required, ) from mcp.server.mcpserver.utilities.context_injection import find_context_parameter from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata @@ -81,6 +82,12 @@ def from_function( context_kwarg = find_context_parameter(fn) resolved_params = find_resolved_parameters(fn) + if resolved_params and returns_input_required(fn): + raise InvalidSignature( + f"Tool {func_name!r} combines Resolve(...) parameters with an InputRequiredResult " + "return; a call has one input_required channel, so the multi-round flow is driven " + "either by resolvers or by the tool body, not both" + ) skip_names = [context_kwarg] if context_kwarg is not None else [] skip_names.extend(resolved_params) @@ -150,6 +157,15 @@ async def run( pre_validated=pre_validated, ) + # Registration rejects the annotated form of this combination; this covers + # a body that returns an InputRequiredResult without declaring it. + if self.resolved_params and isinstance(result, InputRequiredResult): + raise ToolError( + "the tool returned an InputRequiredResult but its parameters use Resolve(...); " + "a call has one input_required channel, so the multi-round flow is driven " + "either by resolvers or by the tool body, not both" + ) + if convert_result: result = self.fn_metadata.convert_result(result) diff --git a/tests/server/mcpserver/test_resolve.py b/tests/server/mcpserver/test_resolve.py index 7e92f1c4ef..571cefcb6c 100644 --- a/tests/server/mcpserver/test_resolve.py +++ b/tests/server/mcpserver/test_resolve.py @@ -3,7 +3,7 @@ import json from collections.abc import Callable from datetime import datetime -from typing import Annotated, Any, Literal +from typing import Annotated, Any, Literal, TypeVar import anyio import pytest @@ -18,7 +18,8 @@ InputResponses, TextContent, ) -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ValidationError, create_model +from typing_extensions import TypeAliasType from mcp import Client, InputRequiredRoundsExceededError from mcp.client import ClientRequestContext @@ -34,8 +35,8 @@ ) from mcp.server.mcpserver.exceptions import InvalidSignature from mcp.server.mcpserver.resolve import ( + _check_elicit_return, _decode_state, - _elicit_return_schema, _encode_state, _outcome_from_state, _resolver_key, @@ -43,6 +44,7 @@ _StateEntry, _uses_input_required, find_resolved_parameters, + returns_input_required, ) from mcp.server.mcpserver.tools.base import Tool from mcp.shared.exceptions import MCPError @@ -56,6 +58,26 @@ class Confirm(BaseModel): ok: bool +class Restock(BaseModel): + needed: bool + + +# The `type X = ...` spelling of an InputRequiredResult-bearing return annotation, +# bare and generic (a subscripted alias forwards `__value__` to its origin). +IRRAlias = TypeAliasType("IRRAlias", InputRequiredResult | str) +T_alias = TypeVar("T_alias") +IRRAliasGeneric = TypeAliasType("IRRAliasGeneric", InputRequiredResult | T_alias, type_params=(T_alias,)) + + +class _UnevaluableAlias: + """Stand-in for `type X = GhostType | str` whose names exist only under + TYPE_CHECKING: accessing `__value__` evaluates the alias and raises.""" + + @property + def __value__(self) -> Any: + raise NameError("name 'GhostType' is not defined") + + class Handle(BaseModel): user_name: str = Field(alias="userName") @@ -700,7 +722,7 @@ async def never(context: ClientRequestContext, params: ElicitRequestParams) -> E @pytest.mark.anyio -async def test_input_required_resolver_asks_and_consumes_then_never_reruns(): +async def test_input_required_asks_each_question_once_while_bodies_rerun(): mcp = MCPServer(name="ExactlyOnceMRTR") counts = {"login": 0, "confirm": 0} @@ -735,12 +757,13 @@ async def callback(context: ClientRequestContext, params: ElicitRequestParams) - # `confirm` can only form its question from `login`'s answer, so the auto-driver # sees the questions in two successive rounds and answers each exactly once. assert asked == ["Username?", "As octocat?"] - # An eliciting resolver runs twice - once to ask, once to consume the answer - - # then its outcome is carried in `request_state` and it never runs again. `login` - # asks in round 1 and is consumed in round 2; `confirm` (which depends on - # `login`) only forms its question once `login` is known, so it asks in round 2 - # and is consumed in round 3. Neither re-runs beyond consuming its own answer. - assert counts == {"login": 2, "confirm": 2} + # The once-per-call guarantee is about the question, not the body: a recorded + # answer is consulted only after the body asks again, so `login` runs on every + # round the call passes through (asks in round 1, consumes its answer in round 2, + # re-asks-and-restores in round 3) while the user is prompted exactly once. + # `confirm` only forms its question once `login` is known: it asks in round 2 + # and consumes in round 3. + assert counts == {"login": 3, "confirm": 2} @pytest.mark.anyio @@ -863,24 +886,23 @@ def test_state_round_trips_accept_decline_cancel(): accepted = _outcome_from_state(decoded["a"], Login) assert isinstance(accepted, AcceptedElicitation) and accepted.data == Login(username="octocat") - assert isinstance(_outcome_from_state(decoded["b"], None), DeclinedElicitation) - assert isinstance(_outcome_from_state(decoded["c"], None), CancelledElicitation) - raw = _outcome_from_state(decoded["d"], None) - assert isinstance(raw, AcceptedElicitation) and raw.data == "raw-token" - - -def test_elicit_return_schema_extraction(): - assert _elicit_return_schema(Elicit[Login], "r") is Login # bare Elicit[T] - assert _elicit_return_schema(Login | Elicit[Login], "r") is Login # union arm - assert _elicit_return_schema(Login, "r") is None # no Elicit arm - assert _elicit_return_schema(None, "r") is None - # The bound on `Elicit`'s parameter is unenforced at runtime, so a non-model - # subscription is constructible and must yield no schema rather than crash. - unbounded_elicit: Any = Elicit - assert _elicit_return_schema(unbounded_elicit[int], "r") is None - # Two distinct Elicit arms are ambiguous: the runtime can honor only one schema. + # Decline/cancel entries carry no data; the schema is not consulted for them. + assert isinstance(_outcome_from_state(decoded["b"], Login), DeclinedElicitation) + assert isinstance(_outcome_from_state(decoded["c"], Login), CancelledElicitation) + # An accepted restore always validates against the question's live schema - + # data that doesn't fit is rejected, never passed through raw. + with pytest.raises(ValidationError): + _outcome_from_state(decoded["d"], Login) + + +def test_check_elicit_return_allows_one_arm_and_rejects_two(): + _check_elicit_return(Elicit[Login], "r") # bare Elicit[T] + _check_elicit_return(Login | Elicit[Login], "r") # union arm + _check_elicit_return(Login, "r") # no Elicit arm + _check_elicit_return(None, "r") # unannotated + # A resolver asks one question: two distinct Elicit arms mean it should be split. with pytest.raises(InvalidSignature, match="'r' return annotation has multiple Elicit arms"): - _elicit_return_schema(Elicit[Login] | Elicit[Confirm], "r") + _check_elicit_return(Elicit[Login] | Elicit[Confirm], "r") @pytest.mark.anyio @@ -1195,13 +1217,14 @@ def answer(key: str, params: ElicitRequestFormParams) -> ElicitResult: @pytest.mark.anyio async def test_eliciting_resolver_without_elicit_arm_restores_a_typed_model(): - # A resolver annotated `-> Login` that actually returns `Elicit(...)` has no - # `Elicit[T]` return arm, so `elicit_schema` is None. Its answer, restored from - # request_state in a 3+ round flow, must still come back as a Login model (not a - # raw dict) so a dependent resolver/tool can use its attributes. + # A resolver annotated `-> object` that actually returns `Elicit(...)` declares + # no `Elicit[T]` return arm. Its answer, restored from request_state in a 3+ + # round flow, must still come back as a Login model (not a raw dict): restore + # validates against the live `Elicit.schema` the body produced, not the lying + # annotation, so a dependent resolver/tool can use its attributes. mcp = MCPServer(name="LyingAnnotation") - # Annotated without an `Elicit[T]` return arm, so `elicit_schema` is None. + # Annotated without an `Elicit[T]` return arm; the body asks anyway. async def login(ctx: Context) -> object: return Elicit("user?", Login) @@ -1591,3 +1614,181 @@ async def act( assert isinstance(final, CallToolResult) assert isinstance(final.content[0], TextContent) assert final.content[0].text == "octocat:True" + + +@pytest.mark.anyio +async def test_state_entry_never_replaces_a_resolver_computed_value(): + # `request_state` is client-echoed: an accept entry under a resolver's wire key + # must only satisfy a question the resolver is actually asking, never stand in + # for the body's own computation on a branch that does not ask. + mcp = MCPServer(name="StateVsBody") + calls = {"decide": 0} + + async def decide(ctx: Context) -> Restock | Elicit[Restock]: + calls["decide"] += 1 + return Restock(needed=False) # this branch computes server-side; no question + + @mcp.tool() + async def plan_restock(restock: Annotated[Restock, Resolve(decide)]) -> str: + return str(restock.needed) + + wire_key = f"{decide.__module__}:{decide.__qualname__}" + crafted = json.dumps({"v": 1, "outcomes": {wire_key: {"action": "accept", "data": {"needed": True}}}}) + + async with Client(mcp, elicitation_callback=_never) as client: + result = await client.session.call_tool("plan_restock", {}, request_state=crafted, allow_input_required=True) + assert isinstance(result, CallToolResult) + assert isinstance(result.content[0], TextContent) + # The body ran and its computation won; the crafted entry was never consulted. + assert result.content[0].text == "False" + assert calls["decide"] == 1 + + +@pytest.mark.anyio +async def test_state_decline_entry_for_a_pure_resolver_is_ignored(): + # A decline/cancel entry can only answer a question; a resolver with no Elicit + # arm never asks one, so such an entry cannot suppress its computed value. + mcp = MCPServer(name="PureVsDecline") + + async def lookup(ctx: Context) -> Login: + return Login(username="server-side") + + @mcp.tool() + async def whoami(login: Annotated[Login, Resolve(lookup)]) -> str: + return login.username + + wire_key = f"{lookup.__module__}:{lookup.__qualname__}" + crafted = json.dumps({"v": 1, "outcomes": {wire_key: {"action": "decline"}}}) + + async with Client(mcp, elicitation_callback=_never) as client: + result = await client.session.call_tool("whoami", {}, request_state=crafted, allow_input_required=True) + assert isinstance(result, CallToolResult) + assert not result.is_error + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == "server-side" + + +@pytest.mark.anyio +async def test_dynamic_schema_resolver_restores_across_rounds(): + # `-> Elicit[BaseModel]` is the natural annotation for `create_model(...)` + # schemas; the restored answer must validate against the live question's + # schema, so the dynamic shape works across a multi-question chain. + mcp = MCPServer(name="DynamicSchema") + dyn = create_model("Dyn", token=(str, ...)) + + async def first(ctx: Context) -> Elicit[BaseModel]: + return Elicit("Q1?", dyn) + + async def second(f: Annotated[BaseModel, Resolve(first)], ctx: Context) -> Elicit[Confirm]: + return Elicit("Q2?", Confirm) + + @mcp.tool() + async def chain(c: Annotated[Confirm, Resolve(second)]) -> str: + return str(c.ok) + + def answer(key: str, params: ElicitRequestFormParams) -> ElicitResult: + if "Q1" in params.message: + return ElicitResult(action="accept", content={"token": "t"}) + return ElicitResult(action="accept", content={"ok": True}) + + async with Client(mcp, elicitation_callback=_never) as client: + one = await client.session.call_tool("chain", {}, allow_input_required=True) + assert isinstance(one, InputRequiredResult) + two = await client.session.call_tool( + "chain", + {}, + input_responses=_answer_round(one, answer), + request_state=one.request_state, + allow_input_required=True, + ) + assert isinstance(two, InputRequiredResult) # Q1 consumed, Q2 asked + final = await client.session.call_tool( + "chain", + {}, + input_responses=_answer_round(two, answer), + request_state=two.request_state, + allow_input_required=True, + ) + # Round 3 restores Q1's answer against the live dynamic schema and completes. + assert isinstance(final, CallToolResult) + assert isinstance(final.content[0], TextContent) + assert final.content[0].text == "True" + + +@pytest.mark.parametrize( + "annotation", + [ + InputRequiredResult, + InputRequiredResult | str, + Annotated[InputRequiredResult | str, "meta"], + str | Annotated[InputRequiredResult, "meta"], # Annotated as a union member + IRRAlias, # `type X = ...` alias + IRRAliasGeneric[str], # subscripted generic alias + ], +) +def test_tool_combining_resolvers_with_input_required_return_is_rejected(annotation: Any): + # A call has one input_responses/request_state channel: resolver elicitation + # and a hand-rolled InputRequiredResult body cannot share it. + mcp = MCPServer(name="ChannelOwnership") + + async def lookup(ctx: Context) -> Login: + return Login(username="x") # pragma: no cover - registration is rejected + + async def combo(login: Annotated[Login, Resolve(lookup)]): + raise NotImplementedError # pragma: no cover + + combo.__annotations__["return"] = annotation + with pytest.raises(InvalidSignature, match="combines Resolve\\(\\.\\.\\.\\) parameters"): + mcp.tool()(combo) + + # Without resolver parameters the hand-rolled form remains available. + @mcp.tool() + async def manual() -> InputRequiredResult: + raise NotImplementedError # pragma: no cover - only registration is exercised + + assert returns_input_required(manual) + + +def test_unevaluable_alias_and_parameterized_generics_declare_no_arm(): + # A `type X = ...` alias is evaluated lazily, so one naming TYPE_CHECKING-only + # imports raises NameError on `__value__` access: it declares no arm the check + # can see and must not break registration (the in-call guard still covers a + # body that returns an InputRequiredResult anyway). A parameterized generic + # return is never the InputRequiredResult class either. + mcp = MCPServer(name="RegistrationTolerance") + + async def lookup(ctx: Context) -> Login: + return Login(username="x") # pragma: no cover - only registration is exercised + + async def lazy(login: Annotated[Login, Resolve(lookup)]): + raise NotImplementedError # pragma: no cover + + lazy.__annotations__["return"] = _UnevaluableAlias() + assert not returns_input_required(lazy) + + @mcp.tool() + async def listy(login: Annotated[Login, Resolve(lookup)]) -> list[str]: + raise NotImplementedError # pragma: no cover + + assert not returns_input_required(listy) + + +@pytest.mark.anyio +async def test_tool_returning_input_required_dynamically_with_resolvers_is_an_error(): + # The annotated form of this combination is rejected at registration; a body + # that returns an InputRequiredResult without declaring it fails loudly at the + # same boundary instead of silently fighting the resolvers for the channel. + mcp = MCPServer(name="DynamicChannelClash") + + async def lookup(ctx: Context) -> Login: + return Login(username="x") + + @mcp.tool() + async def sneaky(login: Annotated[Login, Resolve(lookup)]): + return InputRequiredResult(input_requests={}, request_state="opaque") + + async with Client(mcp) as client: + result = await client.call_tool("sneaky", {}) + assert result.is_error + assert isinstance(result.content[0], TextContent) + assert "the multi-round flow is driven either by resolvers or by the tool body" in result.content[0].text From 8d0f928e400465ef514a8e7ea5aa68aa73921c0c Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:50:58 +0200 Subject: [PATCH 029/100] Pass InputRequiredResult through the MCPServer prompt and resource pipelines (#3020) --- .../expected-failures.2026-07-28.yml | 5 +- .../actions/conformance/expected-failures.yml | 6 +- docs/advanced/low-level-server.md | 2 +- docs/advanced/multi-round-trip.md | 14 + docs/migration.md | 20 ++ docs_src/mrtr/tutorial004.py | 26 ++ .../mcp_everything_server/server.py | 24 ++ src/mcp/server/mcpserver/context.py | 32 +- src/mcp/server/mcpserver/prompts/base.py | 17 +- src/mcp/server/mcpserver/prompts/manager.py | 4 +- .../mcpserver/resources/resource_manager.py | 10 +- .../server/mcpserver/resources/templates.py | 15 +- src/mcp/server/mcpserver/resources/types.py | 13 +- src/mcp/server/mcpserver/server.py | 43 ++- tests/docs_src/test_mrtr.py | 54 +++- tests/issues/test_141_resource_templates.py | 2 + tests/server/mcpserver/prompts/test_base.py | 36 ++- .../resources/test_function_resources.py | 19 ++ .../resources/test_resource_template.py | 30 +- .../mcpserver/servers/test_file_server.py | 4 + tests/server/mcpserver/test_server.py | 283 ++++++++++++++++++ 21 files changed, 627 insertions(+), 32 deletions(-) create mode 100644 docs_src/mrtr/tutorial004.py diff --git a/.github/actions/conformance/expected-failures.2026-07-28.yml b/.github/actions/conformance/expected-failures.2026-07-28.yml index 82300dfeaf..504b463856 100644 --- a/.github/actions/conformance/expected-failures.2026-07-28.yml +++ b/.github/actions/conformance/expected-failures.2026-07-28.yml @@ -22,7 +22,4 @@ client: [] -server: - # SEP-2322 (multi-round-trip requests / IncompleteResult): the prompt pipeline - # cannot return InputRequiredResult from MCPServer yet (tools/call can). - - input-required-result-non-tool-request +server: [] diff --git a/.github/actions/conformance/expected-failures.yml b/.github/actions/conformance/expected-failures.yml index 006f4e2ece..4ad4123d02 100644 --- a/.github/actions/conformance/expected-failures.yml +++ b/.github/actions/conformance/expected-failures.yml @@ -12,8 +12,4 @@ client: [] -server: - # --- Draft-spec scenarios (in `--suite draft`; the `active` suite is green) --- - # SEP-2322 (multi-round-trip requests / IncompleteResult): the prompt pipeline - # cannot return InputRequiredResult from MCPServer yet (tools/call can). - - input-required-result-non-tool-request +server: [] diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index 2220151db5..12c4532949 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -181,7 +181,7 @@ The handshake belongs to the runner. `server/discover`, `ping`, and every other Each of these is one idea you now have the vocabulary for; each has its own chapter. -* `on_call_tool` may return an `InputRequiredResult` instead of a `CallToolResult` to pause the call and ask the client for input; see **[Multi-round-trip requests](multi-round-trip.md)**. +* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](multi-round-trip.md)**. * `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` are the same `(ctx, params) -> result` shape for the other primitives. * `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **[Running your server](../run/index.md)** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story. diff --git a/docs/advanced/multi-round-trip.md b/docs/advanced/multi-round-trip.md index 883a594e23..78e567e9d0 100644 --- a/docs/advanced/multi-round-trip.md +++ b/docs/advanced/multi-round-trip.md @@ -31,6 +31,19 @@ On `@mcp.tool()` you rarely build this by hand: declare a dependency that asks t Everything else in that file (the explicit `input_schema`, the hand-built `CallToolResult`) is the ordinary low-level `Server`, covered in **[The low-level Server](low-level-server.md)**. This page only adds the second return type. +## Beyond tools + +`tools/call` is not special: at 2026-07-28 a server may answer `prompts/get` and `resources/read` the same way. On `MCPServer`, an `@mcp.prompt()` function — or an `@mcp.resource()` **template** function — returns the `InputRequiredResult` itself and reads the retry's answers off the context: + +```python title="server.py" hl_lines="21 23 25" +--8<-- "docs_src/mrtr/tutorial004.py" +``` + +* The first round returns the `InputRequiredResult`. On the retry, `ctx.input_responses` holds the answers under the same keys and the function returns its ordinary result — prompt messages here, resource content for a template resource. +* An `@mcp.tool()` function can return the result directly the same way, when the dependency form doesn't fit. +* Static `@mcp.resource()` functions don't participate: they take no `Context`, so they could never read the retry. Only template resources can ask. +* The era rules below apply unchanged: returning an `InputRequiredResult` on a pre-2026 session is the same `-32603` the warning describes. + ## The client side `Client` runs the loop for you. @@ -94,5 +107,6 @@ Drop to the underlying session, where `allow_input_required=True` hands you the * `Client` runs the retry loop for you: register `elicitation_callback` / `sampling_callback` / `list_roots_callback` and `call_tool` returns a plain `CallToolResult`. `input_required_max_rounds` (default 10) bounds it. * To inspect or persist rounds, use `client.session.call_tool(..., allow_input_required=True)` and own the `while isinstance(result, InputRequiredResult)` loop yourself. * On `@mcp.tool()`, a dependency that asks the user produces this result for you (**[Dependencies](../tutorial/dependencies.md)**); the **low-level** `Server` is the manual form. +* Prompts and resources participate too: an `@mcp.prompt()` or template `@mcp.resource()` function returns the `InputRequiredResult` itself and reads `ctx.input_responses` on the retry. This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **[Deprecated features](deprecated.md)**. diff --git a/docs/migration.md b/docs/migration.md index 68155560d9..516cd8b18a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -20,6 +20,26 @@ If you call `MCPServer.call_tool()` directly, read `.content` and `.structured_content` off the returned `CallToolResult` instead of branching on the result type. +### `MCPServer.get_prompt()` and `read_resource()` may return `InputRequiredResult` + +Like `call_tool()` above, `MCPServer.get_prompt()` now returns +`GetPromptResult | InputRequiredResult` and `MCPServer.read_resource()` returns +`Iterable[ReadResourceContents] | InputRequiredResult`: at 2026-07-28 an +`@mcp.prompt()` function or an `@mcp.resource()` template function may answer +with an `InputRequiredResult` to request client input first (see +[Multi-round-trip requests](advanced/multi-round-trip.md)). If you call these +methods directly, narrow with `isinstance` (or +`assert not isinstance(result, InputRequiredResult)` when your prompt and +resource functions never return one). `Prompt.render()` and +`ResourceTemplate.create_resource()` carry the same union. + +`ctx.read_resource()` inside a handler is unchanged: it still returns content, +and raises `RuntimeError` if the resource requests input. A handler that wants +to receive the `InputRequiredResult` and forward it as its own result calls +`MCPServer.read_resource(uri, context)` directly — but not from a tool whose +dependencies elicit via `Resolve(...)`: the resolver owns that tool's +`request_state` channel, and a forwarded result's state would clobber it. + ### `MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error Raising `MCPError` (or a subclass such as `UrlElicitationRequiredError`) inside diff --git a/docs_src/mrtr/tutorial004.py b/docs_src/mrtr/tutorial004.py new file mode 100644 index 0000000000..05b945935f --- /dev/null +++ b/docs_src/mrtr/tutorial004.py @@ -0,0 +1,26 @@ +from mcp_types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult + +from mcp.server.mcpserver import Context, MCPServer +from mcp.server.mcpserver.prompts.base import UserMessage + +mcp = MCPServer("Briefing") + +ASK_AUDIENCE = ElicitRequest( + params=ElicitRequestFormParams( + message="Who is the briefing for?", + requested_schema={ + "type": "object", + "properties": {"audience": {"type": "string"}}, + "required": ["audience"], + }, + ) +) + + +@mcp.prompt() +async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult: + """Draft a briefing tuned to its audience.""" + answer = (ctx.input_responses or {}).get("audience") + if not isinstance(answer, ElicitResult) or answer.content is None: + return InputRequiredResult(input_requests={"audience": ASK_AUDIENCE}) + return [UserMessage(f"Write a briefing for {answer.content['audience']}.")] diff --git a/examples/servers/everything-server/mcp_everything_server/server.py b/examples/servers/everything-server/mcp_everything_server/server.py index f622aac7a3..e4f5db84f6 100644 --- a/examples/servers/everything-server/mcp_everything_server/server.py +++ b/examples/servers/everything-server/mcp_everything_server/server.py @@ -655,6 +655,30 @@ def test_prompt_with_image() -> list[UserMessage]: ] +@mcp.prompt() +async def test_input_required_result_prompt(ctx: Context) -> list[UserMessage] | InputRequiredResult: + """Tests InputRequiredResult from prompts/get (SEP-2322 non-tool request)""" + responses = ctx.input_responses + if responses and "user_context" in responses: + answer = responses["user_context"] + text = answer.content.get("context", "?") if isinstance(answer, ElicitResult) and answer.content else "?" + return [UserMessage(role="user", content=TextContent(type="text", text=f"Use the following context: {text}"))] + return InputRequiredResult( + input_requests={ + "user_context": ElicitRequest( + params=ElicitRequestFormParams( + message="What context should the prompt use?", + requested_schema={ + "type": "object", + "properties": {"context": {"type": "string"}}, + "required": ["context"], + }, + ) + ) + } + ) + + # Custom request handlers # TODO(felix): Add public APIs to MCPServer for subscribe_resource, unsubscribe_resource, # and set_logging_level to avoid accessing protected _lowlevel_server attribute. diff --git a/src/mcp/server/mcpserver/context.py b/src/mcp/server/mcpserver/context.py index 82a6fa2b6e..6640467411 100644 --- a/src/mcp/server/mcpserver/context.py +++ b/src/mcp/server/mcpserver/context.py @@ -3,7 +3,7 @@ from collections.abc import Iterable, Mapping from typing import TYPE_CHECKING, Any, Generic, cast -from mcp_types import ClientCapabilities, InputResponseRequestParams, InputResponses, LoggingLevel +from mcp_types import ClientCapabilities, InputRequiredResult, InputResponseRequestParams, InputResponses, LoggingLevel from pydantic import AnyUrl, BaseModel from typing_extensions import deprecated @@ -89,6 +89,16 @@ def request_context(self) -> ServerRequestContext[LifespanContextT, RequestT]: raise ValueError("Context is not available outside of a request") return self._request_context + def _nested_invocation(self) -> Context[LifespanContextT, RequestT]: + """A Context for invoking another handler's function from inside this request. + + Shares the request infrastructure (session, request metadata, lifespan) but + carries no `input_responses`/`request_state`: those are addressed to the wire + request's own target — their keys are ones that handler minted — so a nested + invocation always starts on round one. + """ + return Context(request_context=self._request_context, mcp_server=self._mcp_server) + async def report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: """Report progress for the current operation. @@ -102,6 +112,17 @@ async def report_progress(self, progress: float, total: float | None = None, mes async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]: """Read a resource by URI. + This is a content reader: an `InputRequiredResult` returned by a + resource template function (the 2026-07-28 multi-round-trip flow) + raises here, and the nested template never sees this request's + `input_responses`/`request_state` — those answer the outer handler's + own questions, so the template always behaves as round one. A handler + that wants to receive and forward an `InputRequiredResult` as its own + result calls `MCPServer.read_resource(uri, context)` instead — but + not from a tool whose dependencies elicit via `Resolve(...)`: the + resolver owns that tool's `request_state` channel, and a forwarded + result's state would clobber it. + Args: uri: Resource URI to read @@ -111,9 +132,16 @@ async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContent Raises: ResourceNotFoundError: If no resource or template matches the URI. ResourceError: If template creation or resource reading fails. + RuntimeError: If the resource returned an `InputRequiredResult`. """ assert self._mcp_server is not None, "Context is not available outside of a request" - return await self._mcp_server.read_resource(uri, self) + result = await self._mcp_server.read_resource(uri, self._nested_invocation()) + if isinstance(result, InputRequiredResult): + raise RuntimeError( + "Resource returned InputRequiredResult; ctx.read_resource() only returns " + "content — use MCPServer.read_resource(uri, context) to receive and forward it." + ) + return result async def elicit( self, diff --git a/src/mcp/server/mcpserver/prompts/base.py b/src/mcp/server/mcpserver/prompts/base.py index 338cb1f870..0a010de7d2 100644 --- a/src/mcp/server/mcpserver/prompts/base.py +++ b/src/mcp/server/mcpserver/prompts/base.py @@ -8,12 +8,13 @@ import anyio.to_thread import pydantic_core -from mcp_types import ContentBlock, Icon, TextContent +from mcp_types import ContentBlock, Icon, InputRequiredResult, TextContent from pydantic import BaseModel, Field, TypeAdapter, validate_call from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context from mcp.server.mcpserver.utilities.func_metadata import func_metadata from mcp.shared._callable_inspection import is_async_callable +from mcp.shared.exceptions import MCPError if TYPE_CHECKING: from mcp.server.context import LifespanContextT, RequestT @@ -52,7 +53,7 @@ def __init__(self, content: str | ContentBlock, **kwargs: Any): message_validator = TypeAdapter[UserMessage | AssistantMessage](UserMessage | AssistantMessage) -SyncPromptResult = str | Message | dict[str, Any] | Sequence[str | Message | dict[str, Any]] +SyncPromptResult = str | Message | dict[str, Any] | InputRequiredResult | Sequence[str | Message | dict[str, Any]] PromptResult = SyncPromptResult | Awaitable[SyncPromptResult] @@ -92,6 +93,8 @@ def from_function( - A Message object - A dict (converted to a message) - A sequence of any of the above + - An InputRequiredResult (passed through unchanged; the 2026-07-28 + multi-round-trip flow — read `ctx.input_responses` on the retry) """ func_name = name or fn.__name__ @@ -139,9 +142,12 @@ async def render( self, arguments: dict[str, Any] | None, context: Context[LifespanContextT, RequestT], - ) -> list[Message]: + ) -> list[Message] | InputRequiredResult: """Render the prompt with arguments. + An `InputRequiredResult` returned by the prompt function is passed + through unchanged so the multi-round-trip flow reaches the client. + Raises: ValueError: If required arguments are missing, or if rendering fails. """ @@ -163,6 +169,9 @@ async def render( else: result = await anyio.to_thread.run_sync(functools.partial(self.fn, **call_args)) + if isinstance(result, InputRequiredResult): + return result + # Validate messages if not isinstance(result, list | tuple): result = [result] @@ -185,5 +194,7 @@ async def render( raise ValueError(f"Could not convert prompt result to message: {msg}") return messages + except MCPError: + raise except Exception as e: raise ValueError(f"Error rendering prompt {self.name}: {e}") diff --git a/src/mcp/server/mcpserver/prompts/manager.py b/src/mcp/server/mcpserver/prompts/manager.py index 28a7a6e98c..7e7f350787 100644 --- a/src/mcp/server/mcpserver/prompts/manager.py +++ b/src/mcp/server/mcpserver/prompts/manager.py @@ -4,6 +4,8 @@ from typing import TYPE_CHECKING, Any +from mcp_types import InputRequiredResult + from mcp.server.mcpserver.prompts.base import Message, Prompt from mcp.server.mcpserver.utilities.logging import get_logger @@ -50,7 +52,7 @@ async def render_prompt( name: str, arguments: dict[str, Any] | None, context: Context[LifespanContextT, RequestT], - ) -> list[Message]: + ) -> list[Message] | InputRequiredResult: """Render a prompt by name with arguments.""" prompt = self.get_prompt(name) if not prompt: diff --git a/src/mcp/server/mcpserver/resources/resource_manager.py b/src/mcp/server/mcpserver/resources/resource_manager.py index 41d3d7bb37..e56e3ba177 100644 --- a/src/mcp/server/mcpserver/resources/resource_manager.py +++ b/src/mcp/server/mcpserver/resources/resource_manager.py @@ -5,7 +5,7 @@ from collections.abc import Callable from typing import TYPE_CHECKING, Any -from mcp_types import Annotations, Icon +from mcp_types import Annotations, Icon, InputRequiredResult from pydantic import AnyUrl from mcp.server.mcpserver.exceptions import ResourceNotFoundError @@ -86,9 +86,15 @@ def add_template( self._templates[template.uri_template] = template return template - async def get_resource(self, uri: AnyUrl | str, context: Context[LifespanContextT, RequestT]) -> Resource: + async def get_resource( + self, uri: AnyUrl | str, context: Context[LifespanContextT, RequestT] + ) -> Resource | InputRequiredResult: """Get resource by URI, checking concrete resources first, then templates. + A template function may return an `InputRequiredResult` instead of + resource content (the 2026-07-28 multi-round-trip flow); it is passed + through unchanged. + Raises: ResourceNotFoundError: If no resource or template matches the URI. ResourceError: If a matching template fails to create the resource. diff --git a/src/mcp/server/mcpserver/resources/templates.py b/src/mcp/server/mcpserver/resources/templates.py index f78b5ec666..096e821d81 100644 --- a/src/mcp/server/mcpserver/resources/templates.py +++ b/src/mcp/server/mcpserver/resources/templates.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any import anyio.to_thread -from mcp_types import Annotations, Icon +from mcp_types import Annotations, Icon, InputRequiredResult from pydantic import BaseModel, Field, validate_call from mcp.server.mcpserver.exceptions import ResourceError @@ -17,6 +17,7 @@ from mcp.server.mcpserver.utilities.func_metadata import func_metadata from mcp.server.mcpserver.utilities.logging import get_logger from mcp.shared._callable_inspection import is_async_callable +from mcp.shared.exceptions import MCPError from mcp.shared.path_security import contains_path_traversal, is_absolute_path from mcp.shared.uri_template import UriTemplate @@ -208,9 +209,14 @@ async def create_resource( uri: str, params: dict[str, Any], context: Context[LifespanContextT, RequestT], - ) -> Resource: + ) -> Resource | InputRequiredResult: """Create a resource from the template with the given parameters. + An `InputRequiredResult` returned by the template function is passed + through unchanged (the 2026-07-28 multi-round-trip flow); the retry's + answers arrive on `ctx.input_responses`, with `ctx.request_state` + carrying the echoed opaque state. + Raises: ResourceError: If creating the resource fails. """ @@ -224,6 +230,9 @@ async def create_resource( else: result = await anyio.to_thread.run_sync(functools.partial(self.fn, **params)) + if isinstance(result, InputRequiredResult): + return result + return FunctionResource( uri=uri, # type: ignore name=self.name, @@ -235,7 +244,7 @@ async def create_resource( meta=self.meta, fn=lambda: result, # Capture result in closure ) - except ResourceError: + except (ResourceError, MCPError): raise except Exception as exc: logger.exception(f"Error creating resource from template {uri}") diff --git a/src/mcp/server/mcpserver/resources/types.py b/src/mcp/server/mcpserver/resources/types.py index e295e21e02..689e0ff6fc 100644 --- a/src/mcp/server/mcpserver/resources/types.py +++ b/src/mcp/server/mcpserver/resources/types.py @@ -12,11 +12,12 @@ import httpx import pydantic import pydantic_core -from mcp_types import Annotations, Icon +from mcp_types import Annotations, Icon, InputRequiredResult from pydantic import Field, ValidationInfo, validate_call from mcp.server.mcpserver.resources.base import Resource from mcp.shared._callable_inspection import is_async_callable +from mcp.shared.exceptions import MCPError class TextResource(Resource): @@ -63,6 +64,14 @@ async def read(self) -> str | bytes: else: result = await anyio.to_thread.run_sync(self.fn) + if isinstance(result, InputRequiredResult): + # A static resource function can never read the retry's + # input_responses (it takes no Context), so this can only be a + # mistake — reject it instead of JSON-dumping it as content. + raise ValueError( + "static resources cannot return InputRequiredResult; only resource " + "template functions participate in the multi-round-trip flow" + ) if isinstance(result, Resource): # pragma: no cover return await result.read() elif isinstance(result, bytes): @@ -71,6 +80,8 @@ async def read(self) -> str | bytes: return result else: return pydantic_core.to_json(result, fallback=str, indent=2).decode() + except MCPError: + raise except Exception as e: raise ValueError(f"Error reading resource {self.uri}: {e}") diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 888eae6541..6764709806 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -389,7 +389,7 @@ async def _handle_list_resources( async def _handle_read_resource( self, ctx: ServerRequestContext[LifespanResultT], params: ReadResourceRequestParams - ) -> ReadResourceResult: + ) -> ReadResourceResult | InputRequiredResult: context = Context(request_context=ctx, mcp_server=self, input_params=params) try: results = await self.read_resource(params.uri, context) @@ -397,6 +397,8 @@ async def _handle_read_resource( raise MCPError(code=INVALID_PARAMS, message=str(err), data={"uri": str(params.uri)}) except ResourceError as err: raise MCPError(code=INTERNAL_ERROR, message=str(err), data={"uri": str(params.uri)}) + if isinstance(results, InputRequiredResult): + return results contents: list[TextResourceContents | BlobResourceContents] = [] for item in results: if isinstance(item.content, bytes): @@ -431,7 +433,7 @@ async def _handle_list_prompts( async def _handle_get_prompt( self, ctx: ServerRequestContext[LifespanResultT], params: GetPromptRequestParams - ) -> GetPromptResult: + ) -> GetPromptResult | InputRequiredResult: context = Context(request_context=ctx, mcp_server=self, input_params=params) return await self.get_prompt(params.name, params.arguments, context) @@ -496,9 +498,14 @@ async def list_resource_templates(self) -> list[MCPResourceTemplate]: async def read_resource( self, uri: AnyUrl | str, context: Context[LifespanResultT, Any] | None = None - ) -> Iterable[ReadResourceContents]: + ) -> Iterable[ReadResourceContents] | InputRequiredResult: """Read a resource by URI. + An `InputRequiredResult` returned by a resource template function is + passed through unchanged (the 2026-07-28 multi-round-trip flow); the + retry's answers arrive on `ctx.input_responses`, with + `ctx.request_state` carrying the echoed opaque state. + Raises: ResourceNotFoundError: If no resource or template matches the URI. ResourceError: If template creation or resource reading fails. @@ -506,10 +513,14 @@ async def read_resource( if context is None: context = Context(mcp_server=self) resource = await self._resource_manager.get_resource(uri, context) + if isinstance(resource, InputRequiredResult): + return resource try: content = await resource.read() return [ReadResourceContents(content=content, mime_type=resource.mime_type, meta=resource.meta)] + except MCPError: + raise except Exception as exc: logger.exception(f"Error getting resource {uri}") # If an exception happens when reading the resource, we should not leak the exception to the client. @@ -696,6 +707,9 @@ def resource( The function can return: - str for text content - bytes for binary content + - an InputRequiredResult (template resources only; passed through + unchanged for the 2026-07-28 multi-round-trip flow — read + `ctx.input_responses` on the retry) - other types will be converted to JSON If the URI contains parameters (e.g. "resource://{param}"), it is @@ -852,6 +866,11 @@ def prompt( ) -> Callable[[_CallableT], _CallableT]: """Decorator to register a prompt. + The function returns the prompt messages (a string, `Message`, dict, + or a sequence of these), or an `InputRequiredResult` to request + client input first (the 2026-07-28 multi-round-trip flow — read + `ctx.input_responses` on the retry). + Args: name: Optional name for the prompt (defaults to function name) title: Optional human-readable title for the prompt @@ -1192,8 +1211,14 @@ async def list_prompts(self) -> list[MCPPrompt]: async def get_prompt( self, name: str, arguments: dict[str, Any] | None = None, context: Context[LifespanResultT, Any] | None = None - ) -> GetPromptResult: - """Get a prompt by name with arguments.""" + ) -> GetPromptResult | InputRequiredResult: + """Get a prompt by name with arguments. + + An `InputRequiredResult` returned by the prompt function is passed + through unchanged (the 2026-07-28 multi-round-trip flow); the retry's + answers arrive on `ctx.input_responses`, with `ctx.request_state` + carrying the echoed opaque state. + """ if context is None: context = Context(mcp_server=self) try: @@ -1201,12 +1226,16 @@ async def get_prompt( if not prompt: raise ValueError(f"Unknown prompt: {name}") - messages = await prompt.render(arguments, context) + rendered = await prompt.render(arguments, context) + if isinstance(rendered, InputRequiredResult): + return rendered return GetPromptResult( description=prompt.description, - messages=pydantic_core.to_jsonable_python(messages), + messages=pydantic_core.to_jsonable_python(rendered), ) + except MCPError: + raise except Exception as e: logger.exception(f"Error getting prompt {name}") raise ValueError(str(e)) from e diff --git a/tests/docs_src/test_mrtr.py b/tests/docs_src/test_mrtr.py index 4be449edc0..110bd8f781 100644 --- a/tests/docs_src/test_mrtr.py +++ b/tests/docs_src/test_mrtr.py @@ -10,13 +10,17 @@ CreateMessageRequestParams, ElicitRequest, ElicitRequestFormParams, + ElicitRequestParams, ElicitResult, + GetPromptResult, InputRequiredResult, + PromptMessage, TextContent, ) -from docs_src.mrtr import tutorial001, tutorial002, tutorial003 +from docs_src.mrtr import tutorial001, tutorial002, tutorial003, tutorial004 from mcp import Client, MCPError +from mcp.client import ClientRequestContext # See test_index.py for why this is a per-module mark and not a conftest hook. pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] @@ -109,3 +113,51 @@ def test_fulfil_refuses_a_request_it_cannot_answer() -> None: request = CreateMessageRequest(params=CreateMessageRequestParams(messages=[], max_tokens=64)) with pytest.raises(NotImplementedError, match="sampling/createMessage"): tutorial002.fulfil(request) + + +async def test_a_prompt_returns_an_input_required_result_on_the_first_round() -> None: + """tutorial004: `prompts/get` participates in the same flow — the `@mcp.prompt()` function + returns the `InputRequiredResult` itself.""" + async with Client(tutorial004.mcp) as client: + result = await client.session.get_prompt("briefing", allow_input_required=True) + assert result == snapshot( + InputRequiredResult( + result_type="input_required", + input_requests={ + "audience": ElicitRequest( + method="elicitation/create", + params=ElicitRequestFormParams( + mode="form", + message="Who is the briefing for?", + requested_schema={ + "type": "object", + "properties": {"audience": {"type": "string"}}, + "required": ["audience"], + }, + ), + ) + }, + ) + ) + + +async def _answer_audience(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + return ElicitResult(action="accept", content={"audience": "the board"}) + + +async def test_the_prompt_auto_loop_returns_the_final_messages() -> None: + """tutorial004 + the page's client-side claim: `get_prompt` drives the same loop, so the + caller sees only the complete `GetPromptResult`.""" + async with Client(tutorial004.mcp, elicitation_callback=_answer_audience) as client: + result = await client.get_prompt("briefing") + assert result == snapshot( + GetPromptResult( + description="Draft a briefing tuned to its audience.", + messages=[ + PromptMessage( + role="user", + content=TextContent(type="text", text="Write a briefing for the board."), + ) + ], + ) + ) diff --git a/tests/issues/test_141_resource_templates.py b/tests/issues/test_141_resource_templates.py index e955ab0afb..9ec4f94a3e 100644 --- a/tests/issues/test_141_resource_templates.py +++ b/tests/issues/test_141_resource_templates.py @@ -1,5 +1,6 @@ import pytest from mcp_types import ( + InputRequiredResult, ListResourceTemplatesResult, TextResourceContents, ) @@ -49,6 +50,7 @@ def get_user_profile_missing(user_id: str) -> str: # pragma: no cover # Verify valid template works result = await mcp.read_resource("resource://users/123/posts/456") + assert not isinstance(result, InputRequiredResult) result_list = list(result) assert len(result_list) == 1 assert result_list[0].content == "Post 456 by user 123" diff --git a/tests/server/mcpserver/prompts/test_base.py b/tests/server/mcpserver/prompts/test_base.py index ef795777ea..e88a096ba8 100644 --- a/tests/server/mcpserver/prompts/test_base.py +++ b/tests/server/mcpserver/prompts/test_base.py @@ -2,7 +2,14 @@ from typing import Any import pytest -from mcp_types import EmbeddedResource, TextContent, TextResourceContents +from mcp_types import ( + ElicitRequest, + ElicitRequestFormParams, + EmbeddedResource, + InputRequiredResult, + TextContent, + TextResourceContents, +) from mcp.server.mcpserver import Context from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, Prompt, UserMessage @@ -209,3 +216,30 @@ def blocking_fn() -> str: assert messages == [UserMessage(content=TextContent(type="text", text="hello"))] assert fn_thread[0] != main_thread + + +@pytest.mark.anyio +async def test_render_passes_input_required_result_through_unchanged(): + """Prompt.render returns the InputRequiredResult the function returned, bypassing + message conversion entirely (SEP-2322 multi-round-trip pass-through).""" + sentinel = InputRequiredResult( + input_requests={ + "who": ElicitRequest( + params=ElicitRequestFormParams( + message="Who is this for?", + requested_schema={ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + }, + ) + ) + } + ) + + def asking_prompt() -> InputRequiredResult: + return sentinel + + prompt = Prompt.from_function(asking_prompt) + result = await prompt.render(None, Context()) + assert result is sentinel diff --git a/tests/server/mcpserver/resources/test_function_resources.py b/tests/server/mcpserver/resources/test_function_resources.py index c1ff960617..5a5c5c48dd 100644 --- a/tests/server/mcpserver/resources/test_function_resources.py +++ b/tests/server/mcpserver/resources/test_function_resources.py @@ -3,6 +3,8 @@ import anyio import anyio.from_thread import pytest +from inline_snapshot import snapshot +from mcp_types import InputRequiredResult from pydantic import BaseModel from mcp.server.mcpserver.resources import FunctionResource @@ -242,3 +244,20 @@ async def run() -> None: release.set() assert result == ["done"] + + +@pytest.mark.anyio +async def test_read_rejects_an_input_required_result_from_a_static_function(): + """A static resource function returning an InputRequiredResult is a mistake (it can + never read the retry's input_responses), so read() raises instead of JSON-dumping it.""" + + def ask() -> InputRequiredResult: + return InputRequiredResult(request_state="round-1") + + resource = FunctionResource(uri="resource://ask", name="ask", fn=ask) + with pytest.raises(ValueError) as exc: + await resource.read() + assert str(exc.value) == snapshot( + "Error reading resource resource://ask: static resources cannot return " + "InputRequiredResult; only resource template functions participate in the multi-round-trip flow" + ) diff --git a/tests/server/mcpserver/resources/test_resource_template.py b/tests/server/mcpserver/resources/test_resource_template.py index 58c072ae32..42a1099537 100644 --- a/tests/server/mcpserver/resources/test_resource_template.py +++ b/tests/server/mcpserver/resources/test_resource_template.py @@ -3,7 +3,7 @@ from typing import Any import pytest -from mcp_types import Annotations +from mcp_types import Annotations, ElicitRequest, ElicitRequestFormParams, InputRequiredResult from pydantic import BaseModel from mcp.server.mcpserver import Context, MCPServer @@ -403,6 +403,7 @@ def get_item(item_id: str) -> str: # Create a resource from the template resource = await template.create_resource("resource://items/123", {"item_id": "123"}, Context()) + assert not isinstance(resource, InputRequiredResult) # The resource should inherit the template's annotations assert resource.annotations is not None @@ -477,3 +478,30 @@ def blocking_fn(name: str) -> str: assert isinstance(resource, FunctionResource) assert await resource.read() == "hello world" assert fn_thread[0] != main_thread + + +@pytest.mark.anyio +async def test_create_resource_passes_input_required_result_through_unchanged(): + """create_resource returns the InputRequiredResult the template function returned + instead of wrapping it in a FunctionResource (SEP-2322 multi-round-trip pass-through).""" + sentinel = InputRequiredResult( + input_requests={ + "who": ElicitRequest( + params=ElicitRequestFormParams( + message="Who is this for?", + requested_schema={ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + }, + ) + ) + } + ) + + def ask(topic: str) -> InputRequiredResult: + return sentinel + + template = ResourceTemplate.from_function(fn=ask, uri_template="ask://{topic}") + result = await template.create_resource("ask://databases", {"topic": "databases"}, Context()) + assert result is sentinel diff --git a/tests/server/mcpserver/servers/test_file_server.py b/tests/server/mcpserver/servers/test_file_server.py index 9c3fe265c2..e06d0bfe2e 100644 --- a/tests/server/mcpserver/servers/test_file_server.py +++ b/tests/server/mcpserver/servers/test_file_server.py @@ -2,6 +2,7 @@ from pathlib import Path import pytest +from mcp_types import InputRequiredResult from mcp.server.mcpserver import MCPServer @@ -89,6 +90,7 @@ async def test_list_resources(mcp: MCPServer): @pytest.mark.anyio async def test_read_resource_dir(mcp: MCPServer): res_iter = await mcp.read_resource("dir://test_dir") + assert not isinstance(res_iter, InputRequiredResult) res_list = list(res_iter) assert len(res_list) == 1 res = res_list[0] @@ -106,6 +108,7 @@ async def test_read_resource_dir(mcp: MCPServer): @pytest.mark.anyio async def test_read_resource_file(mcp: MCPServer): res_iter = await mcp.read_resource("file://test_dir/example.py") + assert not isinstance(res_iter, InputRequiredResult) res_list = list(res_iter) assert len(res_list) == 1 res = res_list[0] @@ -122,6 +125,7 @@ async def test_delete_file(mcp: MCPServer, test_dir: Path): async def test_delete_file_and_check_resources(mcp: MCPServer, test_dir: Path): await mcp.call_tool("delete_file", arguments={"path": str(test_dir / "example.py")}) res_iter = await mcp.read_resource("file://test_dir/example.py") + assert not isinstance(res_iter, InputRequiredResult) res_list = list(res_iter) assert len(res_list) == 1 res = res_list[0] diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index d92ed5eaad..b4a1184580 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -10,6 +10,7 @@ from mcp_types import ( INTERNAL_ERROR, INVALID_PARAMS, + MISSING_REQUIRED_CLIENT_CAPABILITY, AudioContent, BlobResourceContents, CallToolResult, @@ -26,6 +27,7 @@ Icon, ImageContent, InputRequiredResult, + InputResponses, ListPromptsResult, ListRootsRequest, Prompt, @@ -1311,6 +1313,7 @@ def fn() -> str: return "Hello, world!" result = await mcp.get_prompt("fn") + assert not isinstance(result, InputRequiredResult) content = result.messages[0].content assert isinstance(content, TextContent) assert content.text == "Hello, world!" @@ -1328,6 +1331,7 @@ def fn() -> str: assert prompts[0].name == "fn" # Don't compare functions directly since validate_call wraps them content = await prompts[0].render(None, Context()) + assert not isinstance(content, InputRequiredResult) assert isinstance(content[0].content, TextContent) assert content[0].content.text == "Hello, world!" @@ -1343,6 +1347,7 @@ def fn() -> str: assert len(prompts) == 1 assert prompts[0].name == "custom_name" content = await prompts[0].render(None, Context()) + assert not isinstance(content, InputRequiredResult) assert isinstance(content[0].content, TextContent) assert content[0].content.text == "Hello, world!" @@ -1358,6 +1363,7 @@ def fn() -> str: assert len(prompts) == 1 assert prompts[0].description == "A custom description" content = await prompts[0].render(None, Context()) + assert not isinstance(content, InputRequiredResult) assert isinstance(content[0].content, TextContent) assert content[0].content.text == "Hello, world!" @@ -1921,6 +1927,283 @@ async def greet(ctx: Context) -> str | InputRequiredResult: assert block.text == "Hello, Alice! (state=r1)" +def _ask_who() -> ElicitRequest: + return ElicitRequest( + params=ElicitRequestFormParams( + message="Who is this for?", + requested_schema={ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + }, + ) + ) + + +async def test_prompt_returning_input_required_result_reaches_client_unchanged(): + """A prompt function may return an InputRequiredResult and the pipeline passes it + through to the client (spec-mandated: SEP-2322 allows it on prompts/get).""" + mcp = MCPServer() + + @mcp.prompt() + async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult: + return InputRequiredResult(input_requests={"who": _ask_who()}, request_state="round-1") + + with anyio.fail_after(5): + async with Client(mcp, mode="2026-07-28") as client: + result = await client.session.get_prompt("briefing", allow_input_required=True) + + assert isinstance(result, InputRequiredResult) + assert result.request_state == "round-1" + assert result.input_requests is not None + assert result.input_requests["who"].method == "elicitation/create" + + +async def test_prompt_reads_input_responses_and_request_state_from_context_on_retry(): + """The prompts/get retry carries input_responses and request_state to the prompt + function via the Context, completing the SEP-2322 multi-round-trip flow.""" + mcp = MCPServer() + + @mcp.prompt() + async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult: + responses = ctx.input_responses + if responses and "who" in responses: + who = responses["who"] + assert isinstance(who, ElicitResult) and who.content is not None + return [UserMessage(content=f"Brief {who.content['name']} (state={ctx.request_state})")] + return InputRequiredResult(input_requests={"who": _ask_who()}, request_state="r1") + + with anyio.fail_after(5): + async with Client(mcp, mode="2026-07-28") as client: + r1 = await client.session.get_prompt("briefing", allow_input_required=True) + assert isinstance(r1, InputRequiredResult) + assert r1.input_requests is not None and "who" in r1.input_requests + + r2 = await client.session.get_prompt( + "briefing", + input_responses={"who": ElicitResult(action="accept", content={"name": "Alice"})}, + request_state=r1.request_state, + allow_input_required=True, + ) + assert isinstance(r2, GetPromptResult) + block = r2.messages[0].content + assert isinstance(block, TextContent) + assert block.text == "Brief Alice (state=r1)" + + +async def test_prompt_input_required_result_on_legacy_session_is_a_serialization_error(): + """Pins the shared era gate: a pre-2026 session has no input_required vocabulary, so + the runner rejects the frame with -32603 — the same posture the tools path has.""" + mcp = MCPServer() + + @mcp.prompt() + async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult: + return InputRequiredResult(input_requests={"who": _ask_who()}) + + async with Client(mcp, mode="legacy") as client: + with pytest.raises(MCPError) as exc: + await client.get_prompt("briefing") + assert exc.value.error.code == INTERNAL_ERROR + assert exc.value.error.message == "Handler returned an invalid result" + + +async def test_resource_template_input_required_result_on_legacy_session_is_a_serialization_error(): + """Pins the shared era gate for resources/read: a pre-2026 session has no + input_required vocabulary, so the runner rejects the frame with -32603.""" + mcp = MCPServer() + + @mcp.resource("ask://{topic}") + async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: + return InputRequiredResult(input_requests={"who": _ask_who()}) + + async with Client(mcp, mode="legacy") as client: + with pytest.raises(MCPError) as exc: + await client.read_resource("ask://databases") + assert exc.value.error.code == INTERNAL_ERROR + assert exc.value.error.message == "Handler returned an invalid result" + + +async def test_resource_template_returning_input_required_result_reaches_client_unchanged(): + """A resource template function may return an InputRequiredResult and the pipeline + passes it through to the client (spec-mandated: SEP-2322 allows it on resources/read).""" + mcp = MCPServer() + + @mcp.resource("ask://{topic}") + async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: + return InputRequiredResult(input_requests={"who": _ask_who()}, request_state="round-1") + + with anyio.fail_after(5): + async with Client(mcp, mode="2026-07-28") as client: + result = await client.session.read_resource("ask://databases", allow_input_required=True) + + assert isinstance(result, InputRequiredResult) + assert result.request_state == "round-1" + assert result.input_requests is not None + assert result.input_requests["who"].method == "elicitation/create" + + +async def test_resource_template_reads_input_responses_from_context_on_retry(): + """The resources/read retry carries input_responses to the template function via the + Context, completing the SEP-2322 multi-round-trip flow.""" + mcp = MCPServer() + + @mcp.resource("ask://{topic}") + async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: + responses = ctx.input_responses + if responses and "who" in responses: + who = responses["who"] + assert isinstance(who, ElicitResult) and who.content is not None + return f"{topic} notes for {who.content['name']}" + return InputRequiredResult(input_requests={"who": _ask_who()}) + + with anyio.fail_after(5): + async with Client(mcp, mode="2026-07-28") as client: + r1 = await client.session.read_resource("ask://databases", allow_input_required=True) + assert isinstance(r1, InputRequiredResult) + assert r1.input_requests is not None and "who" in r1.input_requests + + r2 = await client.session.read_resource( + "ask://databases", + input_responses={"who": ElicitResult(action="accept", content={"name": "Alice"})}, + allow_input_required=True, + ) + assert isinstance(r2, ReadResourceResult) + contents = r2.contents[0] + assert isinstance(contents, TextResourceContents) + assert contents.text == "databases notes for Alice" + + +async def test_context_read_resource_raises_on_input_required_result(): + """ctx.read_resource is a content reader: an InputRequiredResult from the template + raises with a pointer at the forwarding path instead of widening every caller.""" + mcp = MCPServer() + + @mcp.resource("ask://{topic}") + async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: + return InputRequiredResult(input_requests={"who": _ask_who()}) + + context = Context(mcp_server=mcp) + with pytest.raises(RuntimeError) as exc: + await context.read_resource("ask://databases") + assert str(exc.value) == snapshot( + "Resource returned InputRequiredResult; ctx.read_resource() only returns " + "content — use MCPServer.read_resource(uri, context) to receive and forward it." + ) + + +async def test_mcpserver_read_resource_returns_input_required_result_for_handler_forwarding(): + """MCPServer.read_resource hands the template's InputRequiredResult to a direct caller + unchanged — the composition path for a handler that forwards it as its own result.""" + mcp = MCPServer() + sentinel = InputRequiredResult(input_requests={"who": _ask_who()}) + + @mcp.resource("ask://{topic}") + async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: + return sentinel + + context = Context(mcp_server=mcp) + result = await mcp.read_resource("ask://databases", context) + assert result is sentinel + + +async def test_context_read_resource_keeps_outer_input_responses_from_the_nested_template(): + """ctx.read_resource never participates in the multi-round-trip flow, so the nested + template must not see the outer request's input_responses/request_state — a colliding + key would otherwise consume an answer meant for the outer handler's own question.""" + mcp = MCPServer() + seen_responses: list[InputResponses | None] = [] + seen_state: list[str | None] = [] + + @mcp.resource("ask://{topic}") + async def ask(topic: str, ctx: Context) -> str: + seen_responses.append(ctx.input_responses) + seen_state.append(ctx.request_state) + return f"{topic} content" + + @mcp.tool() + async def outer(ctx: Context) -> str: + contents = list(await ctx.read_resource("ask://databases")) + assert isinstance(contents[0].content, str) + return contents[0].content + + with anyio.fail_after(5): + async with Client(mcp, mode="2026-07-28") as client: + result = await client.session.call_tool( + "outer", + input_responses={"who": ElicitResult(action="accept", content={"name": "Alice"})}, + request_state="outer-state", + ) + assert isinstance(result, CallToolResult) + block = result.content[0] + assert isinstance(block, TextContent) + assert block.text == "databases content" + assert seen_responses == [None] + assert seen_state == [None] + + +async def test_prompt_raising_mcp_error_surfaces_code_and_data_to_client(): + """A handler-raised MCPError keeps its code and data through the prompt pipeline — + the same parity tools/call has, needed for self-service capability rejection.""" + mcp = MCPServer() + + @mcp.prompt() + async def briefing(ctx: Context) -> str: + raise MCPError( + code=MISSING_REQUIRED_CLIENT_CAPABILITY, + message="needs elicitation", + data={"requiredCapabilities": ["elicitation"]}, + ) + + async with Client(mcp) as client: + with pytest.raises(MCPError) as exc: + await client.get_prompt("briefing") + assert exc.value.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY + assert exc.value.error.message == "needs elicitation" + assert exc.value.error.data == {"requiredCapabilities": ["elicitation"]} + + +async def test_resource_template_raising_mcp_error_surfaces_code_and_data_to_client(): + """A handler-raised MCPError keeps its code and data through the resource template + pipeline instead of being wrapped into a generic ResourceError.""" + mcp = MCPServer() + + @mcp.resource("ask://{topic}") + async def ask(topic: str, ctx: Context) -> str: + raise MCPError( + code=MISSING_REQUIRED_CLIENT_CAPABILITY, + message="needs elicitation", + data={"requiredCapabilities": ["elicitation"]}, + ) + + async with Client(mcp) as client: + with pytest.raises(MCPError) as exc: + await client.read_resource("ask://databases") + assert exc.value.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY + assert exc.value.error.message == "needs elicitation" + assert exc.value.error.data == {"requiredCapabilities": ["elicitation"]} + + +async def test_static_resource_raising_mcp_error_surfaces_code_and_data_to_client(): + """A handler-raised MCPError keeps its code and data through the static resource + read path too — parity with the template path above.""" + mcp = MCPServer() + + @mcp.resource("static://thing") + def thing() -> str: + raise MCPError( + code=MISSING_REQUIRED_CLIENT_CAPABILITY, + message="needs elicitation", + data={"requiredCapabilities": ["elicitation"]}, + ) + + async with Client(mcp) as client: + with pytest.raises(MCPError) as exc: + await client.read_resource("static://thing") + assert exc.value.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY + assert exc.value.error.message == "needs elicitation" + assert exc.value.error.data == {"requiredCapabilities": ["elicitation"]} + + async def test_context_exposes_client_capabilities_from_connection(): mcp = MCPServer() seen: list[ClientCapabilities | None] = [] From 67d7593df1d58ca368e3db3e9651a0443739af8e Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:30:55 +0100 Subject: [PATCH 030/100] docs: publish llms.txt and markdown renditions of the docs (#3024) --- docs/hooks/llms_txt.py | 184 +++++++++++++++++++++++++++++++++++++++++ docs/index.md | 3 + mkdocs.yml | 7 +- 3 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 docs/hooks/llms_txt.py diff --git a/docs/hooks/llms_txt.py b/docs/hooks/llms_txt.py new file mode 100644 index 0000000000..d8ac13eb20 --- /dev/null +++ b/docs/hooks/llms_txt.py @@ -0,0 +1,184 @@ +"""Generate llms.txt, llms-full.txt, and per-page markdown (https://llmstxt.org/). + +The hook publishes three artifacts into the built site: + +- `llms.txt`: a markdown index of the documentation, one link per page, + grouped by nav section. +- a `.md` rendition of every prose page next to its HTML (e.g. + `tutorial/tools/index.md`), which is what the llms.txt links point at. +- `llms-full.txt`: every prose page concatenated for single-fetch consumption. + +Page markdown is the source markdown with `--8<--` snippet includes resolved +(so the `docs_src/` code examples appear inline) and relative links rewritten +to absolute URLs. The API reference pages under `api/` are mkdocstrings stubs +with no markdown source, so they are linked as rendered HTML from an Optional +section instead of being embedded. + +Incremental builds (`mkdocs build --dirty`) are rejected: they skip unmodified +pages, which would silently truncate the generated artifacts. +""" + +from __future__ import annotations + +import posixpath +import re +from dataclasses import dataclass, field +from pathlib import Path + +from mkdocs.config.defaults import MkDocsConfig +from mkdocs.exceptions import PluginError +from mkdocs.structure.files import File, Files +from mkdocs.structure.nav import Navigation, Section +from mkdocs.structure.pages import Page + +# Pages with no markdown source, linked as HTML under "## Optional". +_OPTIONAL_PAGES = [ + ("api/mcp/index.md", "mcp API reference", "Auto-generated API reference for the mcp package (rendered HTML)"), + ( + "api/mcp_types/index.md", + "mcp-types API reference", + "Auto-generated API reference for the mcp-types package (rendered HTML)", + ), +] + +_SNIPPET_LINE = re.compile(r'^(?P[ \t]*)--8<-- "(?P[^"\n]+)"$', flags=re.MULTILINE) +_MD_LINK = re.compile(r'(\]\()([^)\s]+\.md)(#[^)\s]*)?( +"[^"]*")?(\))') + + +@dataclass +class _State: + page_markdown: dict[str, str] = field(default_factory=dict) + rendition_uris: set[str] = field(default_factory=set) + nav: Navigation | None = None + files: Files | None = None + + +_state = _State() + + +def _site_url(config: MkDocsConfig) -> str: + assert config.site_url is not None + return config.site_url.rstrip("/") + "/" + + +def _md_uri(file: File) -> str: + return re.sub(r"\.html$", ".md", file.dest_uri) + + +def on_config(config: MkDocsConfig) -> None: + # `mkdocs serve` rebuilds reuse the imported module; start each build clean. + _state.page_markdown.clear() + _state.rendition_uris.clear() + _state.nav = _state.files = None + + +def on_nav(nav: Navigation, config: MkDocsConfig, files: Files) -> None: + _state.nav = nav + _state.files = files + _state.rendition_uris.update(page.file.src_uri for page in nav.pages if not page.file.src_uri.startswith("api/")) + + +def on_page_markdown(markdown: str, page: Page, config: MkDocsConfig, files: Files) -> str | None: + if page.file.src_uri not in _state.rendition_uris: + return None + + # Same anchor as the pymdownx.snippets `base_path` in mkdocs.yml. + repo_root = Path(config.config_file_path).parent + + def include(match: re.Match[str]) -> str: + indent, path = match["indent"], match["path"] + # Mirror the snippets extension's restrict_base_path: reject paths + # that resolve outside the repo root. + resolved_path = (repo_root / path).resolve() + if not resolved_path.is_relative_to(repo_root.resolve()): + raise PluginError(f"llms_txt: snippet path {path!r} in {page.file.src_uri} escapes the repo root") + try: + content = resolved_path.read_text(encoding="utf-8").rstrip("\n") + except OSError as exc: + raise PluginError(f"llms_txt: cannot read snippet {path!r} in {page.file.src_uri}") from exc + # Keep a pointer to the embedded file so readers can find it on disk. + if path.endswith(".py"): + content = f"# {path}\n{content}" + if indent: + content = "\n".join(indent + line if line else line for line in content.split("\n")) + return content + + resolved, substitutions = _SNIPPET_LINE.subn(include, markdown) + if substitutions != sum("--8<--" in line for line in markdown.splitlines()): + raise PluginError(f"llms_txt: unresolved snippet include in {page.file.src_uri}") + + site_url = _site_url(config) + src_dir = posixpath.dirname(page.file.src_uri) + + def rewrite(match: re.Match[str]) -> str: + opening, target, anchor, title, closing = match.groups() + if "://" in target: + return match.group(0) + linked = files.get_file_from_path(posixpath.normpath(posixpath.join(src_dir, target))) + if linked is None: + raise PluginError(f"llms_txt: cannot resolve link target {target!r} in {page.file.src_uri}") + # Pages without a markdown rendition (the api/ stubs) link to their HTML instead. + url = _md_uri(linked) if linked.src_uri in _state.rendition_uris else linked.url + return f"{opening}{site_url}{url}{anchor or ''}{title or ''}{closing}" + + _state.page_markdown[page.file.src_uri] = _MD_LINK.sub(rewrite, resolved) + return None + + +def _section_pages(section: Section) -> list[Page]: + pages: list[Page] = [] + for child in section.children: + if isinstance(child, Page) and child.file.src_uri in _state.rendition_uris: + pages.append(child) + elif isinstance(child, Section): + pages.extend(_section_pages(child)) + return pages + + +def on_post_build(config: MkDocsConfig) -> None: + assert _state.nav is not None and _state.files is not None + missing = _state.rendition_uris - _state.page_markdown.keys() + if missing: + raise PluginError(f"llms_txt: pages skipped this build (is this a --dirty build?): {sorted(missing)}") + + site_dir = Path(config.site_dir) + site_url = _site_url(config) + + top_level = [ + item for item in _state.nav.items if isinstance(item, Page) and item.file.src_uri in _state.rendition_uris + ] + sections: list[tuple[str, list[Page]]] = [("Docs", top_level)] if top_level else [] + for item in _state.nav.items: + if isinstance(item, Section): + pages = _section_pages(item) + if pages: + sections.append((item.title, pages)) + + index = [f"# {config.site_name}", "", f"> {config.site_description}", ""] + full: list[str] = [] + for title, pages in sections: + index += [f"## {title}", ""] + for page in pages: + markdown = _state.page_markdown[page.file.src_uri] + (site_dir / _md_uri(page.file)).write_text(markdown, encoding="utf-8") + + description = page.meta.get("description") + tail = f": {description}" if description else "" + index.append(f"- [{page.title}]({site_url}{_md_uri(page.file)}){tail}") + + body, h1_found = re.subn(r"\A\s*# .+\n", "", markdown) + if not h1_found: + raise PluginError(f"llms_txt: page {page.file.src_uri} does not start with an H1") + full += [f"# {page.title}", "", f"Source: {page.canonical_url}", "", body.strip(), ""] + index.append("") + + index += ["## Optional", ""] + for src_uri, title, description in _OPTIONAL_PAGES: + linked = _state.files.get_file_from_path(src_uri) + if linked is None: + raise PluginError(f"llms_txt: optional page {src_uri} not found") + index.append(f"- [{title}]({site_url}{linked.url}): {description}") + index.append("") + + (site_dir / "llms.txt").write_text("\n".join(index), encoding="utf-8") + (site_dir / "llms-full.txt").write_text("\n".join(full), encoding="utf-8") diff --git a/docs/index.md b/docs/index.md index 48c22e03f5..e0b82f8b08 100644 --- a/docs/index.md +++ b/docs/index.md @@ -91,3 +91,6 @@ You wrote two Python functions with type hints and a docstring. The SDK does the * The **[Tutorial](tutorial/index.md)** walks through everything a server can do, one small step at a time. * Migrating from v1? Start with the **[Migration Guide](migration.md)**. * Hunting for an exact signature? The **[API Reference](api/mcp/index.md)** is generated from the source. +* Reading with an LLM? This documentation is also published in the [llms.txt](https://llmstxt.org/) format: + [llms.txt](https://py.sdk.modelcontextprotocol.io/v2/llms.txt) is an index of the pages, and + [llms-full.txt](https://py.sdk.modelcontextprotocol.io/v2/llms-full.txt) contains every page in a single file. diff --git a/mkdocs.yml b/mkdocs.yml index 83d3a268ae..a00a982be2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -115,10 +115,12 @@ markdown_extensions: - pymdownx.superfences # Code examples live as complete, importable, tested files under `docs_src/` # and are included into pages with `--8<-- "docs_src//tutorialNNN.py"` - # (resolved against the repo root, the extension's default base_path). + # (resolved against the repo root regardless of the build's working + # directory; the extension's default base_path is the CWD). # `check_paths: true` + `strict: true` turn a renamed/deleted example into a # build failure instead of a silently empty code block. - pymdownx.snippets: + base_path: !relative $config_dir check_paths: true - pymdownx.tilde - pymdownx.inlinehilite @@ -146,6 +148,9 @@ watch: - src - docs_src +hooks: + - docs/hooks/llms_txt.py + plugins: - search - social: From b15b1d5f07f10b17baa5d8d4f9322a0ccc9ed92b Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Tue, 30 Jun 2026 11:31:06 +0100 Subject: [PATCH 031/100] Add a client-side response cache honoring SEP-2549 caching hints (#3023) --- docs/advanced/caching.md | 87 +- docs/migration.md | 4 + docs_src/caching/tutorial003.py | 41 +- src/mcp-types/mcp_types/methods.py | 22 +- src/mcp/client/__init__.py | 22 +- src/mcp/client/caching.py | 387 ++++ src/mcp/client/client.py | 210 ++- src/mcp/client/session.py | 31 +- src/mcp/server/caching.py | 21 +- src/mcp/server/runner.py | 13 +- tests/client/test_caching.py | 1087 ++++++++++++ tests/client/test_client.py | 94 + tests/client/test_client_caching.py | 1579 +++++++++++++++++ tests/client/test_session.py | 27 + tests/docs_src/test_caching.py | 155 +- .../transports/test_hosting_http_modern.py | 3 +- tests/server/test_caching.py | 89 +- tests/types/test_methods.py | 5 + 18 files changed, 3789 insertions(+), 88 deletions(-) create mode 100644 src/mcp/client/caching.py create mode 100644 tests/client/test_caching.py create mode 100644 tests/client/test_client_caching.py diff --git a/docs/advanced/caching.md b/docs/advanced/caching.md index f53a3096bf..ba979ccc13 100644 --- a/docs/advanced/caching.md +++ b/docs/advanced/caching.md @@ -4,61 +4,114 @@ Every result a server returns for `tools/list`, `prompts/list`, `resources/list` The server doesn't cache anything. The fields are a *declaration*: "this tool list is the same for everyone and won't change for a minute." A client (or a gateway in front of you) may then skip the round trip. Honoring the hints is the client's choice; emitting them is the server's job, and the SDK does it for you. -Out of the box every result says `ttlMs: 0, cacheScope: "private"` — immediately stale, never shared. That is always safe and always conformant. If your lists really are stable and identical for all callers, say so at construction: +Out of the box every result says `ttlMs: 0, cacheScope: "private"`: immediately stale, never shared. That is always safe and always conformant. If your lists really are stable and identical for all callers, say so at construction: ```python title="server.py" hl_lines="5-8" --8<-- "docs_src/caching/tutorial001.py" ``` -* The map is keyed by **method name** — the six cacheable methods are the only legal keys. The parameter is typed `Mapping[CacheableMethod, CacheHint]`, so your editor autocompletes the keys and flags a typo before you run; anything that slips past the type checker raises at construction. +* The map is keyed by **method name**, and the six cacheable methods are the only legal keys. The parameter is typed `Mapping[CacheableMethod, CacheHint]`, so your editor autocompletes the keys and flags a typo before you run; anything that slips past the type checker raises at construction. * A method you don't mention keeps the defaults. The map is a set of overrides, not a manifest. * `CacheHint(ttl_ms=5_000)` left `scope` unset, so it stays `"private"`: five seconds of freshness, per caller. Scope and TTL are independent decisions. -* `"server/discover"` is a legal key too — the handshake result is cacheable like any list. +* `"server/discover"` is a legal key too, since the handshake result is cacheable like any list. !!! warning - `cacheScope: "public"` means *anyone* may be served your cached response — a shared + `cacheScope: "public"` means *anyone* may be served your cached response. A shared gateway will happily hand one user's result to another, even when the request was authenticated. Mark a result `"public"` only when it is identical for every caller, and never use `cacheScope` as access control: it is a label, not a lock. ## Per-handler override -On the low-level `Server`, handlers build their results by hand — and `ttl_ms` / `cache_scope` are just fields on the result models. A handler that sets them explicitly always wins over the constructor map, field by field: +On the low-level `Server`, handlers build their results by hand, and `ttl_ms` / `cache_scope` are just fields on the result models. A handler that sets them explicitly always wins over the constructor map, field by field: ```python title="server.py" hl_lines="11 17" --8<-- "docs_src/caching/tutorial002.py" ``` -The handler said `ttl_ms=1_000` and nothing about scope. On the wire: `ttlMs: 1000` (the handler's, not the map's `60_000`) and `cacheScope: "public"` (the map's — the handler left it unset). Explicit beats configured, configured beats default — per field, so a handler can pin one field and leave the other to the server-wide policy. +The handler said `ttl_ms=1_000` and nothing about scope. On the wire: `ttlMs: 1000` (the handler's, not the map's `60_000`) and `cacheScope: "public"` (the map's, because the handler left it unset). Explicit beats configured, and configured beats default. This holds per field, so a handler can pin one field and leave the other to the server-wide policy. This is also the escape hatch for dynamics the constructor can't know: a handler that filters `resources/read` per user can return `cache_scope="private"` for one URI from an otherwise-public server. -One caveat on paginated lists: the protocol requires the **same `cacheScope` on every page** of one list. The constructor map satisfies that by construction — it's keyed by method, not by page. But a handler that overrides the scope itself owns that consistency: override it on *every* page, never only when a cursor is present, or page one and page two will disagree. +One caveat on paginated lists: the protocol requires the **same `cacheScope` on every page** of one list. The constructor map satisfies that by construction, since it's keyed by method, not by page. But a handler that overrides the scope itself owns that consistency: override it on *every* page, never only when a cursor is present, or page one and page two will disagree. ## What the client sees -On the client, the hints arrive as plain fields on every cacheable result — `ttl_ms` and `cache_scope`, already parsed: +On a 2026-07-28 session, `Client` honors the hints for you: it has a built-in response cache, on by default. A result that arrives carrying a `ttlMs` is stored, and an identical call within that TTL is served from the cache with no round trip. A result that carries *no* hint is not cached: hint-less results get `CacheConfig.default_ttl_ms`, which defaults to `0` (immediately stale), so a server that declares nothing sees exactly the call-for-call traffic it always did. -```python title="client.py" hl_lines="15" +```python title="client.py" hl_lines="34 36 39" --8<-- "docs_src/caching/tutorial003.py" ``` -The SDK parses; it does not (yet) act. There is no built-in response cache: calling `list_tools()` twice makes two round trips, whatever the TTL said. The spec makes honoring optional — a client that ignores the hints entirely is fully conformant — so until the SDK grows a response cache, the supported path is to read the fields and do your own bookkeeping: +Four calls, three fetches. The second call found a fresh entry and never reached the server; advancing the (injected) clock past the TTL made the third fetch again; the fourth said `cache_mode="refresh"`. That kwarg exists on the five caching verbs (`list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`): -* **Freshness** is `now < t_received + ttl_ms / 1000`: record the clock when the response arrives, and treat the result as reusable until the TTL runs out. `ttl_ms == 0` means *immediately stale* — don't reuse it at all. -* **Scope is a sharing rule, not a suggestion.** A `"private"` result may be reused only within the same authorization context — same access token, same cache. Never put `"private"` results in a cache shared across users. -* **Notifications beat TTL.** If the server sends `list_changed` while your copy is still fresh, the copy is stale now — re-fetch. +* `"use"` (the default) serves a fresh entry if there is one, and stores the fetch if not. +* `"refresh"` never serves: it fetches and stores the result, replacing whatever was cached. +* `"bypass"` makes the round trip without touching the cache at all: no read, no write. -Against an **older server** (pre-2026 protocol), the fields are simply absent from the wire, and the models show their conservative defaults: `ttl_ms == 0`, `cache_scope == "private"` — stale and unshared, the right assumption for a server that declared nothing. If you need to distinguish "the server said 0" from "the server said nothing", check `"ttl_ms" in result.model_fields_set`: it's only set when the field actually arrived. +One rule sits above `"use"`: **calls carrying `meta` always reach the server.** A request with `meta` set (a progress token, tracing fields) expects a wire request, so under `cache_mode="use"` it is treated as `"refresh"`: the cache read is skipped, and the fetched result still replaces the cached entry. `"bypass"` and an explicit `"refresh"` behave as they always do. + +To turn caching off entirely, construct with `Client(server, cache=False)`: every call is a round trip again, and `cache_mode`, while still accepted, does nothing. + +Scope is honored automatically too: `"private"` entries are keyed to the cache's *partition* (below), while `"public"` ones may opt into wider sharing. And **notifications beat TTL** for the exact entries they name: a `list_changed` notification evicts the matching cached listing, and `resources/updated` evicts the cached read stored under exactly its URI, however fresh they were. + +One caveat on `resources/updated`: eviction is exact-URI only. The store contract has no enumerate or scan operation (same as the reference TypeScript implementation), so a notification carrying a *sub*-resource URI does not evict a cached read of its parent. If your server signals sub-resources this way, refetch the parent with `cache_mode="refresh"`. + +### Configuring it: `CacheConfig` + +```python +from mcp.client import CacheConfig + +client = Client("https://api.example.com/mcp", cache=CacheConfig(default_ttl_ms=5_000)) +``` + +* `store`: where entries live. The default is a fresh in-memory store per client; pass your own `ResponseCacheStore` implementation (Redis-backed, say) to share a cache across clients or processes. The contract types (`ResponseCacheStore`, `CacheKey`, `CacheEntry`, and the default `InMemoryResponseCacheStore`) are importable from `mcp.client`. A lookup may issue up to two sequential store `get`s (the private arm, then the public one), so size a remote store's latency expectations accordingly. A custom store **requires** an explicit `partition`. +* `partition`: the authorization-context label that keeps one principal's `"private"` entries from being served to another within a shared store. +* `target_id`: explicit server identity, for custom transports and in-process servers (below). +* `default_ttl_ms`: TTL applied to results that carry no `ttlMs` hint. The default `0` leaves hint-less results uncached. +* `share_public`: serve server-asserted-`"public"` entries across partitions (below). Off by default. +* `clock`: the wall-clock source, in epoch seconds. Inject one, as the example above does, and expiry tests need no sleeping. + +!!! warning "Partition = verified principal" + Derive `partition` from a **verified credential**, such as a validated token's subject. Never derive it from request-supplied data, and never from the server URL (server identity is a separate key axis). The SDK is a library with no authentication of its own: the trust anchor is whoever constructs the `CacheConfig`, which is the deployment, not the tenant. A multi-tenant gateway mints one `CacheConfig` per authenticated principal. + + The partition is also fixed for the `Client`'s lifetime. If the connection's authorization context changes mid-session (a re-authentication as a different principal, say), the cache does not follow; construct a new `Client` for the new principal. + +Cache keys also carry the **server's identity**: the URL string you dialed, with any `user:pass@` userinfo stripped and otherwise byte-exact. No case folding, no query reordering, no trailing-slash cleanup. Under-normalizing only costs sharing, while over-normalizing could merge two tenants (`?tenant=a` vs `?tenant=b`), so superficially different URLs simply don't share entries. When there is no URL (an in-process server, or a `Transport` instance), the client gets a random per-instance identity instead; set `CacheConfig.target_id` to name the server (with a custom store this is required, and construction says so). The identity is sha256-hashed before it enters key material, so a URL carrying secrets in its query string never appears in store keys. Don't log the pre-hash form yourself, either. + +!!! warning "`share_public` trusts the server, fleet-wide" + By default even `"public"` entries stay within their partition. `share_public=True` serves entries the server marked `cacheScope: "public"` to **every** partition using the store, trusting the server's classification on behalf of all of them. A server that stamps `"public"` on per-tenant data (by bug or by malice) then leaks one tenant's response to the others. The flag is deliberately constructor-level only: the per-call `cache_mode` can narrow caching, but nothing per-call can widen sharing. + +### What the cache never does + +* **Session-tier calls bypass it.** `client.session.list_tools()` and friends always make the round trip; the cache lives on the `Client` verbs. +* **`server/discover` stays out of it.** The discover result is delivered once, at connect, and never enters the response cache, even when it carries a `ttlMs`. If you persist one yourself to skip the reconnect probe ([`prior_discover`](../client/protocol-versions.md#reconnecting-with-prior_discover)), its freshness is your bookkeeping: `DiscoverResult` carries `ttl_ms` and `cache_scope`, already parsed, for exactly that purpose. +* **Continuation pages are never cached.** Only cursor-less calls participate. A continuation page rejected for an expired cursor does *evict* the cached listing, because the listing changed under it. +* **Multi-round-trip reads are never cached.** A `read_resource` seeded with `input_responses`/`request_state`, or one that resolves through input rounds, never enters the cache (a spec MUST). +* **Notification eviction needs notifications.** Eviction is only as good as the transport's delivery, and the modern in-process path (`Client(server)` with the default `mode="auto"`) does not deliver standalone notifications today. +* **Eviction is eventual, not instantaneous.** Wire-path notifications are dispatched from spawned tasks, so a call racing a notification's arrival may be served the pre-eviction entry once more; the window is bounded by dispatch latency, and the eviction still lands. +* **No stale-if-error.** An expired entry is never served because the refetch failed; the error propagates. +* **No early re-fetch.** A stored entry is served until its TTL expires and the next call after that pays the round trip; nothing refreshes in the background. +* **No coalescing.** Two concurrent identical calls are two fetches. +* **No TTL beyond 24 hours.** A larger `ttlMs`, whether server-sent or configured, is clamped down on store (`mcp.client.caching.MAX_TTL_MS`), bounding how long any entry, however generously hinted, can be served. +* On a **shared store**, clients race each other. Each client drops its own write when an eviction overtook the fetch in flight, but a *co-tenant* client can still write back an entry that an eviction it never saw had removed; and that race bookkeeping is itself bounded: past 4096 tracked keys the oldest key's guard is dropped first. Both windows are accepted, and closed by the TTL cap above. +* **No serving across protocol eras.** Entries are scoped to the negotiated protocol version: on a shared persistent store, a session never serves an entry written under a different negotiated version (the same listing genuinely differs by era, since the SDK strips the 2026 fields for older sessions). Eviction likewise touches only the current era's entries; another era's entries simply age out by TTL. + +### Reading the hints yourself + +The hints are also plain fields on every cacheable result (`result.ttl_ms` and `result.cache_scope`, already parsed), in case you want to layer your own bookkeeping on top of (or instead of) the built-in cache. + +Against an **older server** (pre-2026 protocol), the fields are simply absent from the wire, and the models show their conservative defaults: `ttl_ms == 0` and `cache_scope == "private"`, stale and unshared, the right assumption for a server that declared nothing. The cache treats a legacy session the same way: hints are never consulted there (whatever keys appear on the wire), only `default_ttl_ms` applies, and its default of `0` caches nothing, so a pre-2026 connection behaves exactly as it did before the cache existed. If you need to distinguish "the server said 0" from "the server said nothing", check `"ttl_ms" in result.model_fields_set`: it's only set when the field actually arrived. ## Older clients -Clients on pre-2026 protocol versions never see either field — the SDK strips them at serialization for those connections. Configure your hints once; there is nothing version-specific to write. +Clients on pre-2026 protocol versions never see either field; the SDK strips them at serialization for those connections. Configure your hints once; there is nothing version-specific to write. ## Recap -* Six methods carry `ttlMs`/`cacheScope`; the SDK defaults them to `0`/`"private"` — stale and unshared, always safe. +* Six methods carry `ttlMs`/`cacheScope`; the SDK defaults them to `0`/`"private"`, stale and unshared, always safe. * `cache_hints={method: CacheHint(...)}` at construction (both `MCPServer` and `Server`) sets server-wide values per method. * A handler that sets the fields on its result overrides the map, per field. * `"public"` is a promise that the result is identical for every caller. It is not access control. -* Clients read the hints as `result.ttl_ms` / `result.cache_scope` and own the caching decision themselves — the SDK has no built-in response cache yet. +* `Client` honors the hints automatically: its response cache is on by default, serves fresh entries instead of refetching, and caches nothing for servers (or sessions) that provide no hints. +* Per call, `cache_mode="refresh"` refetches and `"bypass"` skips the cache; `cache=False` at construction turns it off entirely. diff --git a/docs/migration.md b/docs/migration.md index 516cd8b18a..047626ee21 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -427,6 +427,10 @@ On `ClientSession`, `call_tool` / `get_prompt` / `read_resource` still return th For protocol 2026-07-28 over Streamable HTTP, a tool's input-schema property may carry an `x-mcp-header` annotation. When a tool the client has listed is called, each annotated argument is mirrored into an `Mcp-Param-` request header (string verbatim, integer as decimal, boolean as `true`/`false`, base64-sentinel-wrapped when not header-safe; `null`/absent arguments are omitted). The argument is also left in the request body. `list_tools` caches a tool's annotations, so list a tool before calling it to enable mirroring; a tool the client never listed emits no `Mcp-Param-*` headers. Other transports ignore the annotation. +### `Client` verbs may serve cached responses ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)) + +On protocol 2026-07-28, servers attach caching hints (`ttlMs`, `cacheScope`) to the cacheable results, and `Client` now honors them: `list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, and `read_resource` may serve a cached response instead of making a round trip, for as long as the server's `ttlMs` says the result is fresh. With the default configuration, servers that send no hints, including every pre-2026 server, see identical call-for-call behavior, because hint-less results are not cached (a `CacheConfig.default_ttl_ms` above zero caches them too). Pass `Client(..., cache=False)` to disable the cache and restore v1 behavior exactly; per-call control (`cache_mode`) and configuration (`CacheConfig`) are described in [Caching hints](advanced/caching.md). + ### Server extensions API ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)) `MCPServer` now accepts opt-in extensions that bundle MCP behaviour behind a diff --git a/docs_src/caching/tutorial003.py b/docs_src/caching/tutorial003.py index 77ade546b7..29c168c9f6 100644 --- a/docs_src/caching/tutorial003.py +++ b/docs_src/caching/tutorial003.py @@ -1,15 +1,40 @@ +from dataclasses import dataclass +from typing import Any + +from mcp_types import ListToolsResult, PaginatedRequestParams, Tool + from mcp import Client -from mcp.server import CacheHint, MCPServer +from mcp.client import CacheConfig +from mcp.server import CacheHint, Server, ServerRequestContext + + +@dataclass +class DemoState: + fetches: int = 0 + now: float = 1_000_000.0 + + +state = DemoState() + -mcp = MCPServer("Weather", cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")}) +async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult: + state.fetches += 1 + return ListToolsResult(tools=[Tool(name="forecast", input_schema={"type": "object"})]) -@mcp.tool() -def forecast(city: str) -> str: - return f"Sunny in {city}" +server = Server( + "Weather", + on_list_tools=list_tools, + cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")}, +) async def main() -> None: - async with Client(mcp) as client: - tools = await client.list_tools() - print(f"{len(tools.tools)} tools, fresh for {tools.ttl_ms / 1000:.0f}s, scope={tools.cache_scope}") + start = state.fetches + async with Client(server, cache=CacheConfig(clock=lambda: state.now)) as client: + await client.list_tools() # fetch 1 + await client.list_tools() # fresh for 60s: served from the cache + state.now += 60.0 + await client.list_tools() # the TTL ran out: fetch 2 + await client.list_tools(cache_mode="refresh") # skip the cache read: fetch 3 + print(f"4 calls, {state.fetches - start} fetches") diff --git a/src/mcp-types/mcp_types/methods.py b/src/mcp-types/mcp_types/methods.py index 824dcfdfe6..f49c158d92 100644 --- a/src/mcp-types/mcp_types/methods.py +++ b/src/mcp-types/mcp_types/methods.py @@ -13,7 +13,7 @@ from collections.abc import Mapping from functools import cache from types import MappingProxyType, UnionType -from typing import Any, Final, TypeVar +from typing import Any, Final, Literal, TypeVar, get_args from pydantic import BaseModel, TypeAdapter @@ -23,9 +23,11 @@ from mcp_types.version import KNOWN_PROTOCOL_VERSIONS __all__ = [ + "CACHEABLE_METHODS", "CLIENT_NOTIFICATIONS", "CLIENT_REQUESTS", "CLIENT_RESULTS", + "CacheableMethod", "MONOLITH_NOTIFICATIONS", "MONOLITH_REQUESTS", "MONOLITH_RESULTS", @@ -404,6 +406,24 @@ """Monolith result model (or two-arm union) per request method.""" +CacheableMethod = Literal[ + "prompts/list", + "resources/list", + "resources/read", + "resources/templates/list", + "server/discover", + "tools/list", +] +"""Methods whose results carry `ttlMs`/`cacheScope`; hand-written Literal, welded to `CACHEABLE_METHODS` by tests.""" + +CACHEABLE_METHODS: Final[frozenset[str]] = frozenset( + method + for method, row in MONOLITH_RESULTS.items() + if any(issubclass(arm, types.CacheableResult) for arm in (get_args(row) if isinstance(row, UnionType) else (row,))) +) +"""Runtime mirror of `CacheableMethod`, derived from `MONOLITH_RESULTS`.""" + + # --- Parse functions --- # Envelope stubs merged into bodies for surface validation (surface classes are full frames). diff --git a/src/mcp/client/__init__.py b/src/mcp/client/__init__.py index f9f732ad9e..b7823f5efe 100644 --- a/src/mcp/client/__init__.py +++ b/src/mcp/client/__init__.py @@ -2,8 +2,28 @@ from mcp.client._input_required import InputRequiredRoundsExceededError from mcp.client._transport import Transport +from mcp.client.caching import ( + CacheConfig, + CacheEntry, + CacheKey, + CacheMode, + InMemoryResponseCacheStore, + ResponseCacheStore, +) from mcp.client.client import Client from mcp.client.context import ClientRequestContext from mcp.client.session import ClientSession -__all__ = ["Client", "ClientRequestContext", "ClientSession", "InputRequiredRoundsExceededError", "Transport"] +__all__ = [ + "CacheConfig", + "CacheEntry", + "CacheKey", + "CacheMode", + "Client", + "ClientRequestContext", + "ClientSession", + "InMemoryResponseCacheStore", + "InputRequiredRoundsExceededError", + "ResponseCacheStore", + "Transport", +] diff --git a/src/mcp/client/caching.py b/src/mcp/client/caching.py new file mode 100644 index 0000000000..a464accd16 --- /dev/null +++ b/src/mcp/client/caching.py @@ -0,0 +1,387 @@ +"""Client-side response caching primitives (SEP-2549, protocol revision 2026-07-28).""" + +from __future__ import annotations + +import json +import logging +import time +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Final, Literal, Protocol + +import anyio +import anyio.lowlevel +from mcp_types import ( + CacheableResult, + PromptListChangedNotification, + ResourceListChangedNotification, + ResourceUpdatedNotification, + ServerNotification, + ToolListChangedNotification, +) +from mcp_types.version import MODERN_PROTOCOL_VERSIONS + +__all__ = [ + "MAX_TTL_MS", + "CacheConfig", + "CacheEntry", + "CacheKey", + "CacheMode", + "InMemoryResponseCacheStore", + "ResponseCacheStore", +] + +logger = logging.getLogger(__name__) + +CacheMode = Literal["use", "refresh", "bypass"] +"""Per-call cache behavior: `"use"` serves and stores, `"refresh"` stores +without serving, `"bypass"` skips the cache entirely.""" + +MAX_TTL_MS: Final[int] = 24 * 60 * 60 * 1000 +"""Cap on any entry's time-to-live (24 hours, in milliseconds); larger `ttlMs` values are clamped down.""" + + +@dataclass(frozen=True, slots=True) +class CacheKey: + """Identity of one cached response; compare as the field tuple, never a flattened string (collision hazard).""" + + method: str + + params_key: str = "" + """Result-affecting params discriminator: the uri for `resources/read`, `""` for the list methods.""" + + partition: str = "" + """Coordinator-computed arm identifier; opaque to stores.""" + + +@dataclass(frozen=True, slots=True) +class CacheEntry: + """One cached response with its freshness and sharing metadata.""" + + value: Any + """The cached result; the SDK deep-copies on write and on serve, so a store may hold it as-is.""" + + scope: Literal["public", "private"] + """Server-asserted `cacheScope`: only `"public"` entries may be shared across authorization contexts.""" + + expires_at: float | None + """Epoch seconds after which the entry is stale; `None` is never fresh.""" + + +class ResponseCacheStore(Protocol): + """Storage contract for the client response cache. + + Each `Client` calls its store from a single event loop; per-operation + atomicity is the implementation's responsibility. Operations may raise - + the SDK degrades to a miss rather than failing the call. A serializing + store must round-trip `value` back to the result model object (a + wrong-shape entry is a miss, never an error). A lookup may issue two + sequential `get` calls (private arm, then public). + """ + + async def get(self, key: CacheKey) -> CacheEntry | None: ... + + async def set(self, key: CacheKey, entry: CacheEntry) -> None: ... + + async def delete(self, key: CacheKey) -> None: ... + + async def clear(self) -> None: ... + + +@dataclass(frozen=True, slots=True) +class CacheConfig: + """Configuration for a `Client`'s response cache. + + Raises: + ValueError: On a custom `store` without `partition`, an empty `target_id`, or a negative `default_ttl_ms`. + """ + + store: ResponseCacheStore | None = None + """Backing store; `None` means a per-client `InMemoryResponseCacheStore`. + A custom store requires an explicit `partition`.""" + + partition: str = "" + """Authorization-context identifier isolating `"private"`-scoped entries + within a shared store. Derive it from a verified credential - never from + request-supplied data or the server URL. Fixed for the `Client`'s + lifetime: construct a new `Client` when the principal changes.""" + + target_id: str | None = None + """Server-identity override for custom transports and proxies where the + SDK cannot derive one from a URL; must be non-empty when provided.""" + + default_ttl_ms: int = 0 + """TTL in milliseconds for results carrying no `ttlMs` hint; the default `0` leaves them uncached.""" + + clock: Callable[[], float] = time.time + """Wall-clock source returning epoch seconds; injectable for expiry tests.""" + + share_public: bool = False + """Serve server-marked `"public"` entries across every partition in the store. + + WARNING: this trusts the server's `"public"` classification for every + principal sharing the store - a mislabeled response leaks across tenants. + Constructor-level only: the per-call `cache_mode` can never widen sharing.""" + + def __post_init__(self) -> None: + if self.store is not None and not self.partition: + raise ValueError("a custom store requires an explicit partition") + if self.target_id == "": + raise ValueError("target_id must be a non-empty string or omitted") + if self.default_ttl_ms < 0: + raise ValueError(f"default_ttl_ms must be >= 0, got {self.default_ttl_ms}") + + +class InMemoryResponseCacheStore: + """Default in-process `ResponseCacheStore`. + + Method bodies are synchronous, so concurrent tasks never observe a torn + write. `max_entries` caps the whole store, evicting least-recently-used + at the cap (`0` disables it); `get` and `set` both refresh recency, so a + hot entry survives churn from other keys. + + Raises: + ValueError: If `max_entries` is negative. + """ + + def __init__(self, *, max_entries: int = 1024) -> None: + if max_entries < 0: + raise ValueError(f"max_entries must be >= 0, got {max_entries}") + self._max_entries = max_entries + self._entries: dict[CacheKey, CacheEntry] = {} + + async def get(self, key: CacheKey) -> CacheEntry | None: + entry = self._entries.get(key) + if entry is not None: + # Pop-and-reinsert moves the key to the back: the dict's insertion order is the LRU ledger. + self._entries[key] = self._entries.pop(key) + return entry + + async def set(self, key: CacheKey, entry: CacheEntry) -> None: + self._entries.pop(key, None) + self._entries[key] = entry + if self._max_entries and len(self._entries) > self._max_entries: + del self._entries[next(iter(self._entries))] + + async def delete(self, key: CacheKey) -> None: + self._entries.pop(key, None) + + async def clear(self) -> None: + self._entries.clear() + + +_GENERATION_MAP_CAP: Final[int] = 4096 +"""Cap on the generation map; at the cap the oldest key's eviction-race guard is dropped (FIFO).""" + +_STORE_CLEANUP_TIMEOUT: Final[float] = 5 +"""Bound for must-complete store cleanup deletes (mirrors the dispatcher's final-write bound); +a wedged store delete must not hold client teardown uncancellably.""" + + +class ClientResponseCache: + """Coordinates the `Client` caching verbs with a `ResponseCacheStore`: keys, era gate, TTL/scope, eviction.""" + + def __init__( + self, + *, + store: ResponseCacheStore, + partition: str, + arm_id: str, + default_ttl_ms: int, + clock: Callable[[], float], + share_public: bool, + negotiated_version: Callable[[], str | None], + generation_map_cap: int = _GENERATION_MAP_CAP, + store_cleanup_timeout: float = _STORE_CLEANUP_TIMEOUT, + ) -> None: + self._store = store + self._partition = partition + self._arm_id = arm_id + self._share_public = share_public + self._default_ttl_ms = default_ttl_ms + self._clock = clock + self._negotiated_version = negotiated_version + # A key is eviction-race-guarded iff registered here. + self._generations: dict[tuple[str, str], int] = {} + self._generation_map_cap = generation_map_cap + self._store_cleanup_timeout = store_cleanup_timeout + self._warned_store_ops: set[str] = set() + + def _arm(self, scope: Literal["public", "private"]) -> str: + # JSON arrays so crafted arm_id/partition values cannot collide across field boundaries. + # The negotiated version era-scopes every arm: a session never serves an entry written + # under a different protocol era (its content differs - sieve-stripped fields, header + # filtering). Every caller runs post-connect; were that ever untrue, the supplier's + # None still partitions harmlessly. + fields: list[str | None] = [scope, self._negotiated_version(), self._arm_id] + if scope == "private" or not self._share_public: + fields.append(self._partition) + return json.dumps(fields) + + async def read(self, method: str, params_key: str) -> CacheableResult | None: + """Serve a fresh entry for the key, or `None`; the served result is a deep copy.""" + # A hit completes without any other yielding await, so checkpoint here: a poll + # loop over a fresh entry must not starve spawned tasks (eviction dispatch). + await anyio.lowlevel.checkpoint() + # A wrong-shape entry raises as late as the copy, so the boundary wraps the whole read path. + try: + entry = await self._get_fresh(CacheKey(method, params_key, self._arm("private"))) + if entry is None: + # After a scope flip, a stale private entry must not shadow a fresh public one. + entry = await self._get_fresh(CacheKey(method, params_key, self._arm("public"))) + if entry is not None and entry.scope != "public": + # Never serve an entry the server scoped "private" out of the shared arm. + entry = None + copied: CacheableResult | None = None if entry is None else entry.value.model_copy(deep=True) + except Exception: # boundary around user store code: any read-path failure is a miss, never a failed call + self._warn_store_failure("get") + return None + self._warned_store_ops.discard("get") + return copied + + async def _get_fresh(self, key: CacheKey) -> CacheEntry | None: + entry = await self._store.get(key) + if entry is None or entry.expires_at is None or entry.expires_at <= self._clock(): + return None + return entry + + def capture(self, method: str, params_key: str) -> int: + """Register the key for eviction-race detection before the fetch; `write` takes the returned generation.""" + gen_key = (method, params_key) + if gen_key not in self._generations: + if len(self._generations) >= self._generation_map_cap: + # FIFO overflow: the dropped key's race guard degrades to the accepted co-tenant class. + del self._generations[next(iter(self._generations))] + self._generations[gen_key] = 0 + return self._generations[gen_key] + + async def write( + self, + method: str, + params_key: str, + result: CacheableResult, + gen_at_capture: int, + mode: Literal["use", "refresh"], + ) -> None: + """Store a fetched result under the arm its resolved scope selects.""" + gen_key = (method, params_key) + if self._generation_moved(gen_key, gen_at_capture): + return # the key was evicted while the fetch was in flight + ttl_ms, scope = self._resolve(result) + private_key = CacheKey(method, params_key, self._arm("private")) + public_key = CacheKey(method, params_key, self._arm("public")) + if ttl_ms <= 0: + if mode == "refresh": + # The refetch superseded the warm entry, which a cancellation must not leave serving. + await self._cleanup_delete(private_key, public_key) + return + own, opposite = (public_key, private_key) if scope == "public" else (private_key, public_key) + # Opposite arm first: a failed delete aborts before the set - never two arms answering for one key. + if not await self._delete(opposite): + # The own arm's entry is superseded too: best-effort delete, degrading to a full miss. + await self._cleanup_delete(own) + return + entry = CacheEntry(value=result.model_copy(deep=True), scope=scope, expires_at=self._clock() + ttl_ms / 1000) + try: + if not await self._set(own, entry): + # The fetch superseded any pre-existing own-arm entry, and the failed set + # left it in place: purge it (mirrors the opposite-arm-failure path). + await self._cleanup_delete(own) + finally: + # An eviction can land while the set commits - even when the await + # is cancelled - so re-check on every exit; the delete must complete + # so the pending cancellation cannot resurrect the evicted entry. + if self._generation_moved(gen_key, gen_at_capture): + await self._cleanup_delete(own) + + async def evict_method(self, method: str) -> None: + """Evict the method's cursor-less entry.""" + await self.evict_key(method, "") + + async def evict_key(self, method: str, params_key: str) -> None: + """Evict one key from both arms. + + Only the current era's arms are touched; other-era entries in a persistent store age out by TTL. + """ + gen_key = (method, params_key) + # Bump first so an in-flight fetch cannot write the evicted entry back. + # Unregistered keys skip the bump (uris must not grow the map) but not + # the deletes - a persistent store may hold uncaptured entries. + if gen_key in self._generations: + self._generations[gen_key] += 1 + # Must complete: a cancellation between the deletes would leave one arm serving the evicted entry. + await self._cleanup_delete( + CacheKey(method, params_key, self._arm("private")), + CacheKey(method, params_key, self._arm("public")), + ) + + async def evict_for_notification(self, notification: ServerNotification) -> None: + """Map a server notification to the entries it makes stale. + + Eviction is eventual (spawned-task dispatch): the generation bump closes + the write-back race; a racing read may briefly serve the old entry. + """ + match notification: + case ToolListChangedNotification(): + await self.evict_method("tools/list") + case PromptListChangedNotification(): + await self.evict_method("prompts/list") + case ResourceListChangedNotification(): + # Templates enumerate the same changed resource space. + await self.evict_method("resources/list") + await self.evict_method("resources/templates/list") + case ResourceUpdatedNotification(): + await self.evict_key("resources/read", notification.params.uri) + case _: + pass + + def _resolve(self, result: CacheableResult) -> tuple[int, Literal["public", "private"]]: + # A legacy peer can also put `ttlMs`/`cacheScope` keys on the wire, so + # wire presence is not a peer-era signal - hints count only when modern. + modern = self._negotiated_version() in MODERN_PROTOCOL_VERSIONS + if modern and "ttl_ms" in result.model_fields_set: + # An explicit `ttlMs: 0` stays 0, and negatives are unconstructible + # upstream (model ge=0, parse-seam floor) - only the cap applies. + ttl_ms = result.ttl_ms + else: + ttl_ms = self._default_ttl_ms + scope: Literal["public", "private"] = "public" if modern and result.cache_scope == "public" else "private" + return min(ttl_ms, MAX_TTL_MS), scope + + def _generation_moved(self, gen_key: tuple[str, str], gen_at_capture: int) -> bool: + # A FIFO-dropped key fails open (the accepted co-tenant race) rather than discarding the fetch. + return self._generations.get(gen_key, gen_at_capture) != gen_at_capture + + async def _set(self, key: CacheKey, entry: CacheEntry) -> bool: + try: + await self._store.set(key, entry) + except Exception: # boundary around user store code: nothing cached, the fetch already succeeded + self._warn_store_failure("set") + return False + self._warned_store_ops.discard("set") + return True + + async def _cleanup_delete(self, *keys: CacheKey) -> None: + # Must-complete cleanup: shielded so a pending cancellation cannot skip the deletes, + # bounded so a wedged store delete cannot hold client teardown uncancellably. + with anyio.move_on_after(self._store_cleanup_timeout, shield=True) as scope: + for key in keys: + await self._delete(key) + if scope.cancelled_caught: + logger.warning("Response cache store delete timed out; the entry will age out by TTL") + + async def _delete(self, key: CacheKey) -> bool: + try: + await self._store.delete(key) + except Exception: # boundary around user store code: callers decide whether a failed delete aborts + self._warn_store_failure("delete") + return False + self._warned_store_ops.discard("delete") + return True + + def _warn_store_failure(self, kind: Literal["get", "set", "delete"]) -> None: + # One warning per failure burst, per op kind; re-armed only when that + # same kind succeeds, so a healthy delete cannot re-arm a broken set. + if kind not in self._warned_store_ops: + self._warned_store_ops.add(kind) + logger.warning("Response cache store operation failed; continuing without the cache", exc_info=True) diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index d3290f3080..638ea63a9d 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -2,14 +2,20 @@ from __future__ import annotations +import hashlib +import logging +import uuid from collections.abc import Awaitable, Callable, Mapping from contextlib import AsyncExitStack from dataclasses import KW_ONLY, dataclass, field -from typing import Any, Literal, TypeVar +from typing import Any, Literal, TypeVar, cast import anyio +import anyio.lowlevel import mcp_types as types from mcp_types import ( + INVALID_PARAMS, + CacheableResult, CallToolResult, CompleteResult, EmptyResult, @@ -39,6 +45,7 @@ from mcp.client._memory import InMemoryTransport from mcp.client._probe import negotiate_auto from mcp.client._transport import Transport +from mcp.client.caching import CacheConfig, CacheMode, ClientResponseCache, InMemoryResponseCacheStore from mcp.client.session import ( ClientRequestContext, ClientSession, @@ -54,8 +61,11 @@ 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 +from mcp.shared.exceptions import MCPDeprecationWarning, MCPError from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher +from mcp.shared.session import RequestResponder + +logger = logging.getLogger(__name__) ConnectMode = Literal["legacy", "auto"] | str """``mode=`` value: ``"legacy"`` (initialize handshake), ``"auto"`` (discover, fall back to @@ -64,6 +74,7 @@ _T = TypeVar("_T") _ResultT = TypeVar("_ResultT") +_CacheableT = TypeVar("_CacheableT", bound=CacheableResult) _Connector = Callable[[AsyncExitStack, ConnectMode, bool], Awaitable["Dispatcher[Any]"]] """Resolved at ``__post_init__`` from the shape of ``server`` alone: enter whatever resources @@ -115,6 +126,46 @@ def _connected(value: _T | None) -> _T: return value +def _strip_userinfo(url: str) -> str: + """Drop any userinfo from the URL's authority component; byte-exact otherwise. + + Credentials must not enter cache-key material; any further normalization could merge distinct servers. + """ + # Pure text, no urlsplit: it strips embedded tab/CR/LF before parsing, which would misalign slices. + sep = url.find("//") + if sep == -1: + return url + start = sep + 2 + end = len(url) + for delimiter in "/?#": + if (found := url.find(delimiter, start)) != -1: + end = min(end, found) + authority = url[start:end] + if "@" not in authority: + return url + return url[:start] + authority.rpartition("@")[2] + url[end:] + + +def _evicting_message_handler(cache: ClientResponseCache, user_handler: MessageHandlerFnT | None) -> MessageHandlerFnT: + """Wrap the session message handler with cache eviction on server notifications.""" + + async def handler( + message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception, + ) -> None: + if isinstance(message, types.ServerNotification): + try: + await cache.evict_for_notification(message) + except Exception: # boundary: eviction reaches user store code; a cache fault must not block delivery + logger.exception("Response cache eviction failed; the notification is still delivered") + if user_handler is not None: + await user_handler(message) + else: + # Mirrors ClientSession's default handler (session._default_message_handler). + await anyio.lowlevel.checkpoint() + + return handler + + def _synthesize_discover(protocol_version: str) -> types.DiscoverResult: return types.DiscoverResult( supported_versions=[protocol_version], @@ -221,10 +272,20 @@ async def main(): """SEP-2133 extension support to advertise under `ClientCapabilities.extensions` (identifier -> settings), e.g. `{"io.modelcontextprotocol/ui": {"mimeTypes": [...]}}`.""" + cache: CacheConfig | Literal[False] | None = None + """Client-side response caching for the SEP-2549 cacheable methods (2026-07-28). + + `None` (the default) honors server `ttlMs`/`cacheScope` hints with a per-client + in-memory store; pass a `CacheConfig` to customize, or `False` to disable. The + cacheable verbs take a per-call `cache_mode` (see `CacheMode`); calls carrying + `meta` always reach the server. A `CacheConfig` with a custom `store` requires + `target_id` when the server is not a URL (no identity can be derived).""" + _entered: bool = field(init=False, default=False) _session: ClientSession | None = field(init=False, default=None) _exit_stack: AsyncExitStack | None = field(init=False, default=None) _connect: _Connector = field(init=False, repr=False, compare=False) + _response_cache: ClientResponseCache | None = field(init=False, default=None, repr=False, compare=False) def __post_init__(self) -> None: if self.mode not in ("legacy", "auto") and self.mode not in MODERN_PROTOCOL_VERSIONS: @@ -247,16 +308,44 @@ def __post_init__(self) -> None: else: self._connect = _connect_transport(srv) + if self.cache is not False: + config = self.cache if self.cache is not None else CacheConfig() + # Only the hash below leaves this scope - the raw identity may carry credentials; never log or store it. + target_id = config.target_id + if target_id is None and isinstance(self.server, str): + target_id = _strip_userinfo(self.server) + if target_id is None: + if config.store is not None: + raise ValueError( + "a custom cache store requires CacheConfig.target_id when the server is not a URL: " + "in-process servers and Transport instances get a random per-client identity, so " + "their entries in a shared store could never be served to another client" + ) + target_id = uuid.uuid4().hex + self._response_cache = ClientResponseCache( + store=config.store if config.store is not None else InMemoryResponseCacheStore(), + partition=config.partition, + arm_id=hashlib.sha256(target_id.encode()).hexdigest(), + default_ttl_ms=config.default_ttl_ms, + clock=config.clock, + share_public=config.share_public, + # Lazy: the negotiated version is unknown until __aenter__'s handshake. + negotiated_version=lambda: self._session.protocol_version if self._session is not None else None, + ) + 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) + message_handler = self.message_handler + if self._response_cache is not None: + message_handler = _evicting_message_handler(self._response_cache, self.message_handler) 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, + message_handler=message_handler, client_info=self.client_info, elicitation_callback=self.elicitation_callback, extensions=self.extensions, @@ -361,23 +450,76 @@ async def set_logging_level(self, level: LoggingLevel, *, meta: RequestParamsMet """Set the logging level on the server.""" return await self.session.set_logging_level(level=level, meta=meta) # pyright: ignore[reportDeprecated] + async def _cached_fetch( + self, + method: str, + *, + cursor: str | None, + meta: RequestParamsMeta | None, + cache_mode: CacheMode, + send: Callable[[], Awaitable[_CacheableT]], + absorb: Callable[[_CacheableT], _CacheableT] | None = None, + ) -> _CacheableT: + """Serve one of the four list verbs through the response cache. + + `absorb` (tools/list only) re-applies session-side derived state to a served cache hit. + """ + cache = self._response_cache + if cache is None or cache_mode == "bypass": + return await send() + # A closed (or never-entered) client must raise, never serve cached entries. + _ = self.session + if meta is not None and cache_mode == "use": + # meta (a progress token, tracing fields) expects a wire request; fetch and replace the entry. + cache_mode = "refresh" + if cursor is not None: + # Continuation pages skip the cache, but an expired cursor means the listing changed (spec SHOULD evict). + try: + return await send() + except MCPError as e: + if e.code == INVALID_PARAMS: + await cache.evict_method(method) + raise + if cache_mode == "use" and (hit := await cache.read(method, "")) is not None: + # The hit is a private deep copy, so absorption may mutate it freely. + served = cast(_CacheableT, hit) + return served if absorb is None else absorb(served) + gen = cache.capture(method, "") + result = await send() + await cache.write(method, "", result, gen, cache_mode) + return result + async def list_resources( self, *, cursor: str | None = None, meta: RequestParamsMeta | None = None, + cache_mode: CacheMode = "use", ) -> ListResourcesResult: """List available resources from the server.""" - return await self.session.list_resources(params=PaginatedRequestParams(cursor=cursor, _meta=meta)) + return await self._cached_fetch( + "resources/list", + cursor=cursor, + meta=meta, + cache_mode=cache_mode, + send=lambda: self.session.list_resources(params=PaginatedRequestParams(cursor=cursor, _meta=meta)), + ) async def list_resource_templates( self, *, cursor: str | None = None, meta: RequestParamsMeta | None = None, + cache_mode: CacheMode = "use", ) -> ListResourceTemplatesResult: """List available resource templates from the server.""" - return await self.session.list_resource_templates(params=PaginatedRequestParams(cursor=cursor, _meta=meta)) + return await self._cached_fetch( + "resources/templates/list", + cursor=cursor, + meta=meta, + cache_mode=cache_mode, + send=lambda: self.session.list_resource_templates(params=PaginatedRequestParams(cursor=cursor, _meta=meta)), + ) async def read_resource( self, @@ -386,6 +528,7 @@ async def read_resource( input_responses: InputResponses | None = None, request_state: str | None = None, meta: RequestParamsMeta | None = None, + cache_mode: CacheMode = "use", ) -> ReadResourceResult: """Read a resource from the server. @@ -400,6 +543,8 @@ async def read_resource( resuming from a persisted `InputRequiredResult`). request_state: Opaque state to seed the first call with. meta: Additional metadata for the request. + cache_mode: Cache behavior for this call (see `CacheMode`); seeded + calls (`input_responses` or `request_state` set) ignore it. Returns: The resource content. @@ -414,7 +559,29 @@ async def retry(r: InputResponses | None, s: str | None) -> ReadResourceResult | uri, input_responses=r, request_state=s, meta=meta, allow_input_required=True ) - return await self._drive_input_required(await retry(input_responses, request_state), retry) + # Seeded calls resume a specific exchange and must never be cached (spec MUST). + seeded = input_responses is not None or request_state is not None + cache = None if seeded else self._response_cache + if cache is None or cache_mode == "bypass": + return await self._drive_input_required(await retry(input_responses, request_state), retry) + # A closed (or never-entered) client must raise, never serve cached entries. + _ = self.session + if meta is not None and cache_mode == "use": + # Calls carrying meta always reach the server (mirrors `_cached_fetch`). + cache_mode = "refresh" + if cache_mode == "use" and (hit := await cache.read("resources/read", uri)) is not None: + # Only terminal first-round results are stored, so a hit legitimately skips the driver. + return cast(ReadResourceResult, hit) + gen = cache.capture("resources/read", uri) + first = await retry(None, None) + if not isinstance(first, InputRequiredResult): + await cache.write("resources/read", uri, first, gen, cache_mode) + elif cache_mode == "refresh": + # The refresh superseded whatever was cached, but an input_required resolution + # cannot be stored: purge the warm entry so it cannot be served again. + await cache.evict_key("resources/read", uri) + # Driver rounds carry inputResponses, so a terminal result reached through them is never cached (spec MUST). + return await self._drive_input_required(first, retry) async def subscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> EmptyResult: """Subscribe to resource updates.""" @@ -481,9 +648,16 @@ async def list_prompts( *, cursor: str | None = None, meta: RequestParamsMeta | None = None, + cache_mode: CacheMode = "use", ) -> ListPromptsResult: """List available prompts from the server.""" - return await self.session.list_prompts(params=PaginatedRequestParams(cursor=cursor, _meta=meta)) + return await self._cached_fetch( + "prompts/list", + cursor=cursor, + meta=meta, + cache_mode=cache_mode, + send=lambda: self.session.list_prompts(params=PaginatedRequestParams(cursor=cursor, _meta=meta)), + ) async def get_prompt( self, @@ -565,9 +739,27 @@ async def complete( """ return await self.session.complete(ref=ref, argument=argument, context_arguments=context_arguments) - async def list_tools(self, *, cursor: str | None = None, meta: RequestParamsMeta | None = None) -> ListToolsResult: + async def list_tools( + self, + *, + cursor: str | None = None, + meta: RequestParamsMeta | None = None, + cache_mode: CacheMode = "use", + ) -> ListToolsResult: """List available tools from the server.""" - return await self.session.list_tools(params=PaginatedRequestParams(cursor=cursor, _meta=meta)) + return await self._cached_fetch( + "tools/list", + cursor=cursor, + meta=meta, + cache_mode=cache_mode, + send=lambda: self.session.list_tools(params=PaginatedRequestParams(cursor=cursor, _meta=meta)), + # A cache hit skips session.list_tools, so the session re-absorbs the served + # listing to rebuild its derived per-tool state. Hits are cursorless, but a + # cached page 1 can carry next_cursor - never prune on a partial listing. + absorb=lambda hit: self.session._absorb_tool_listing( # pyright: ignore[reportPrivateUsage] + hit, complete=hit.next_cursor is None + ), + ) @deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) async def send_roots_list_changed(self) -> None: diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 3cebb569ec..6a2298ad93 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -55,6 +55,13 @@ logger = logging.getLogger("client") +def _clamp_inbound_ttl(raw: dict[str, Any]) -> None: + """Floor a negative inbound `ttlMs` to 0 before `ge=0` validation fails the call (2026-07-28 caching SHOULD).""" + ttl = raw.get("ttlMs") + if isinstance(ttl, int | float) and not isinstance(ttl, bool) and ttl < 0: + raw["ttlMs"] = 0 + + def _preconnect_stamp(data: dict[str, Any], opts: CallOptions) -> None: # initialize/discover forbid cancellation; other pre-handshake requests (lowlevel # ClientSession callers may skip the handshake entirely) keep the courtesy cancel. @@ -331,6 +338,7 @@ async def send_request( if metadata.on_resumption_token_update is not None: opts["on_resumption_token"] = metadata.on_resumption_token_update raw = await self._dispatcher.send_raw_request(method, data.get("params"), opts) + _clamp_inbound_ttl(raw) # Literal fallback covers pre-handshake and stateless; matches runner.py. version = self._negotiated_version or "2025-11-25" try: @@ -458,7 +466,10 @@ async def send_discover(self, version: str) -> dict[str, Any]: "cancel_on_abandon": False, "headers": {MCP_PROTOCOL_VERSION_HEADER: version, MCP_METHOD_HEADER: data["method"]}, } - return await self._dispatcher.send_raw_request(data["method"], data.get("params"), opts) + raw = await self._dispatcher.send_raw_request(data["method"], data.get("params"), opts) + # Un-floored, a negative ttl fails the mode='auto' probe's validation and silently downgrades the handshake. + _clamp_inbound_ttl(raw) + return raw async def discover(self) -> types.DiscoverResult: """Probe `server/discover` and adopt the result. @@ -895,7 +906,15 @@ async def list_tools(self, *, params: types.PaginatedRequestParams | None = None types.ListToolsRequest(params=params), types.ListToolsResult, ) + complete = (params is None or params.cursor is None) and result.next_cursor is None + return self._absorb_tool_listing(result, complete=complete) + + def _absorb_tool_listing(self, result: types.ListToolsResult, *, complete: bool) -> types.ListToolsResult: + """Filter the listing per the 2026 x-mcp-header MUST and rebuild derived per-tool state, in place. + Idempotent: cached values are already post-filter, so the response cache can re-absorb a served listing. + `complete` (an uncursored single-page listing) prunes per-tool state down to the listing's tools. + """ if self._negotiated_version in MODERN_PROTOCOL_VERSIONS: # 2026-07-28: clients MUST drop tools whose x-mcp-header annotations are invalid. kept: list[types.Tool] = [] @@ -911,11 +930,17 @@ async def list_tools(self, *, params: types.PaginatedRequestParams | None = None kept.append(tool) result.tools = kept - # Cache tool output schemas for future validation - # Note: don't clear the cache, as we may be using a cursor + # Cache tool output schemas for future validation; cursor pages only ever add. for tool in result.tools: self._tool_output_schemas[tool.name] = tool.output_schema + if complete: + # The listing is the full tool universe, so state for unlisted tools is stale + # (the server dropped them, or a shared-cache writer's filter did). + names = {tool.name for tool in result.tools} + self._x_mcp_header_maps = {k: v for k, v in self._x_mcp_header_maps.items() if k in names} + self._tool_output_schemas = {k: v for k, v in self._tool_output_schemas.items() if k in names} + return result @deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) diff --git a/src/mcp/server/caching.py b/src/mcp/server/caching.py index a8a2a470c6..5e99303159 100644 --- a/src/mcp/server/caching.py +++ b/src/mcp/server/caching.py @@ -11,27 +11,13 @@ from collections.abc import Mapping from dataclasses import dataclass -from typing import Any, Final, Literal, TypeVar, get_args +from typing import Any, Literal, TypeVar import mcp_types as types +from mcp_types.methods import CACHEABLE_METHODS, CacheableMethod __all__ = ["CACHEABLE_METHODS", "CacheHint", "CacheableMethod", "apply_cache_hint", "validate_cache_hints"] -CacheableMethod = Literal[ - "prompts/list", - "resources/list", - "resources/read", - "resources/templates/list", - "server/discover", - "tools/list", -] -"""The methods whose results carry `ttlMs`/`cacheScope`. Closed set: the spec -defines caching hints on exactly these six (tests pin it to which result models -mix in `CacheableResult`).""" - -CACHEABLE_METHODS: Final[frozenset[str]] = frozenset(get_args(CacheableMethod)) -"""Runtime mirror of `CacheableMethod`, for callers the type checker can't see.""" - @dataclass(frozen=True, slots=True) class CacheHint: @@ -87,7 +73,8 @@ def validate_cache_hints(cache_hints: Mapping[Any, Any] | None) -> dict[str, Cac """ if cache_hints is None: return {} - unknown = sorted(method for method in cache_hints if method not in CACHEABLE_METHODS) + # repr-format keys so a non-string key raises this ValueError, not a TypeError from sorted/join. + unknown = sorted(repr(method) for method in cache_hints if method not in CACHEABLE_METHODS) if unknown: raise ValueError(f"cache_hints keys must be cacheable methods (see CacheableMethod); got: {', '.join(unknown)}") validated: dict[str, CacheHint] = {} diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index 4c25a8a5bc..6773fd4de8 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -198,12 +198,15 @@ async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> HandlerResult: if isinstance(result, ErrorData): # Raise inside the chain so middleware observes the failure. raise MCPError.from_error_data(result) - # Fill cache hints on the typed result, before the serialize sieve + # Fill cache hints on the handler result, before the serialize sieve # decides whether the negotiated version carries the fields at all. - # `input_required` interim results are not `CacheableResult` models, - # so the MRTR carve-out (no hints on them) holds by shape. - if isinstance(result, CacheableResult) and (hint := self.server.cache_hints.get(method)) is not None: - result = apply_cache_hint(result, hint) + # MRTR carve-out: `input_required` interim results, typed or mapping, never get hints. + if (hint := self.server.cache_hints.get(method)) is not None: + if isinstance(result, CacheableResult): + result = apply_cache_hint(result, hint) + elif isinstance(result, Mapping) and result.get("resultType") != "input_required": + # Hint keys first so wire keys the handler set win, matching `apply_cache_hint` precedence. + result = {"ttlMs": hint.ttl_ms, "cacheScope": hint.scope, **result} # Dump and serialize inside the chain so the OpenTelemetry span (the # outermost middleware) records a failing handler return shape too. return self._serialize(method, version, result) diff --git a/tests/client/test_caching.py b/tests/client/test_caching.py new file mode 100644 index 0000000000..dc445a6ec8 --- /dev/null +++ b/tests/client/test_caching.py @@ -0,0 +1,1087 @@ +"""Tests for `mcp.client.caching`. The store-contract tests are parametrized +over `STORE_FACTORIES`; a third-party store can be run against the same +contract by adding its factory.""" + +import json +import logging +import time +from collections.abc import Awaitable, Callable +from typing import Any + +import anyio +import anyio.lowlevel +import pytest +from inline_snapshot import snapshot +from mcp_types import ( + ListPromptsResult, + ListToolsResult, + LoggingMessageNotification, + LoggingMessageNotificationParams, + PromptListChangedNotification, + ReadResourceResult, + ResourceListChangedNotification, + ResourceUpdatedNotification, + ResourceUpdatedNotificationParams, + ServerNotification, + ToolListChangedNotification, +) + +from mcp.client.caching import ( + MAX_TTL_MS, + CacheConfig, + CacheEntry, + CacheKey, + ClientResponseCache, + InMemoryResponseCacheStore, + ResponseCacheStore, +) + +pytestmark = pytest.mark.anyio + +STORE_FACTORIES: list[Callable[[], ResponseCacheStore]] = [InMemoryResponseCacheStore] + +store_contract = pytest.mark.parametrize("make_store", STORE_FACTORIES, ids=["InMemoryResponseCacheStore"]) + + +def _entry(value: Any = "cached") -> CacheEntry: + """Entries are opaque payloads at the store layer; only the key matters here.""" + return CacheEntry(value=value, scope="private", expires_at=None) + + +def _read_key(uri: str) -> CacheKey: + return CacheKey("resources/read", uri) + + +# --- Store contract --- + + +@store_contract +async def test_a_set_entry_round_trips_through_get(make_store: Callable[[], ResponseCacheStore]) -> None: + store = make_store() + key = CacheKey("tools/list", "", "partition-1") + entry = CacheEntry(value={"tools": []}, scope="public", expires_at=1700000000.0) + await store.set(key, entry) + assert await store.get(key) == entry + + +@store_contract +async def test_get_misses_for_a_key_never_set(make_store: Callable[[], ResponseCacheStore]) -> None: + store = make_store() + assert await store.get(CacheKey("tools/list")) is None + + +@store_contract +async def test_keys_differing_in_only_one_field_do_not_collide( + make_store: Callable[[], ResponseCacheStore], +) -> None: + """Spec-mandated: collapsing any key field would serve responses across method, params, or principal boundaries.""" + store = make_store() + base = CacheKey("resources/read", "file:///a", "partition-1") + keys = [ + base, + CacheKey("resources/list", base.params_key, base.partition), + CacheKey(base.method, "file:///b", base.partition), + CacheKey(base.method, base.params_key, "partition-2"), + ] + for i, key in enumerate(keys): + await store.set(key, _entry(i)) + for i, key in enumerate(keys): + assert await store.get(key) == _entry(i) + + +@store_contract +async def test_swapped_params_key_and_partition_values_are_distinct_keys( + make_store: Callable[[], ResponseCacheStore], +) -> None: + store = make_store() + await store.set(CacheKey("m", "a", "b"), _entry("params=a")) + await store.set(CacheKey("m", "b", "a"), _entry("params=b")) + assert await store.get(CacheKey("m", "a", "b")) == _entry("params=a") + assert await store.get(CacheKey("m", "b", "a")) == _entry("params=b") + + +@store_contract +async def test_keys_with_field_values_that_concatenate_identically_do_not_collide( + make_store: Callable[[], ResponseCacheStore], +) -> None: + """Keys compare as the field tuple - flattening would let crafted values collide across boundaries.""" + store = make_store() + keys = [ + CacheKey("a", "b.c", "p"), + CacheKey("a.b", "c", "p"), + CacheKey("m", "x", "y:z"), + CacheKey("m", "x:y", "z"), + CacheKey("m", "u/v", ""), + CacheKey("m/u", "v", ""), + CacheKey("ab", "", ""), + CacheKey("a", "b", ""), + CacheKey("", "ab", ""), + ] + for i, key in enumerate(keys): + await store.set(key, _entry(i)) + for i, key in enumerate(keys): + assert await store.get(key) == _entry(i) + + +@store_contract +async def test_set_replaces_the_entry_for_an_existing_key(make_store: Callable[[], ResponseCacheStore]) -> None: + store = make_store() + key = CacheKey("tools/list") + await store.set(key, _entry("first")) + await store.set(key, _entry("second")) + assert await store.get(key) == _entry("second") + + +@store_contract +async def test_delete_removes_only_the_given_key(make_store: Callable[[], ResponseCacheStore]) -> None: + store = make_store() + doomed = CacheKey("tools/list", "", "partition-1") + survivor = CacheKey("tools/list", "", "partition-2") + await store.set(doomed, _entry("doomed")) + await store.set(survivor, _entry("survivor")) + await store.delete(doomed) + assert await store.get(doomed) is None + assert await store.get(survivor) == _entry("survivor") + + +@store_contract +async def test_delete_is_idempotent(make_store: Callable[[], ResponseCacheStore]) -> None: + """The SDK issues unconditional deletes during eviction, so deleting an absent key must be a no-op.""" + store = make_store() + key = CacheKey("prompts/list") + await store.delete(key) + await store.set(key, _entry()) + await store.delete(key) + await store.delete(key) + assert await store.get(key) is None + + +@store_contract +async def test_clear_removes_every_entry_across_methods_and_partitions( + make_store: Callable[[], ResponseCacheStore], +) -> None: + store = make_store() + keys = [ + CacheKey("tools/list", "", "partition-1"), + CacheKey("prompts/list", "", "partition-2"), + CacheKey("resources/read", "file:///a", "partition-1"), + ] + for key in keys: + await store.set(key, _entry()) + await store.clear() + for key in keys: + assert await store.get(key) is None + + +# --- CacheConfig guards --- + + +def test_cache_config_defaults_construct_an_unshared_zero_ttl_config() -> None: + config = CacheConfig() + assert config.store is None + assert config.partition == "" + assert config.target_id is None + assert config.default_ttl_ms == 0 + assert config.clock is time.time + assert config.share_public is False + + +def test_a_custom_store_without_a_partition_is_rejected_at_construction() -> None: + """A custom store is shareable, so a missing partition would let private entries cross principals.""" + with pytest.raises(ValueError) as exc: + CacheConfig(store=InMemoryResponseCacheStore()) + assert str(exc.value) == snapshot("a custom store requires an explicit partition") + + +def test_a_custom_store_with_an_explicit_partition_constructs() -> None: + store = InMemoryResponseCacheStore() + config = CacheConfig(store=store, partition="token-subject-1") + assert config.store is store + assert config.partition == "token-subject-1" + + +def test_an_empty_target_id_is_rejected_at_construction() -> None: + """An empty target_id would collapse distinct servers onto the one shared sha256("") identity.""" + with pytest.raises(ValueError) as exc: + CacheConfig(target_id="") + assert str(exc.value) == snapshot("target_id must be a non-empty string or omitted") + + +def test_a_negative_default_ttl_is_rejected_at_construction() -> None: + """A configured negative TTL is a programming error; negative wire ttlMs is tolerated as 0 at the parse seam.""" + with pytest.raises(ValueError) as exc: + CacheConfig(default_ttl_ms=-1) + assert str(exc.value) == snapshot("default_ttl_ms must be >= 0, got -1") + + +# --- InMemoryResponseCacheStore LRU cap --- + + +async def test_a_new_entry_past_the_cap_evicts_the_least_recently_used_one() -> None: + store = InMemoryResponseCacheStore(max_entries=2) + await store.set(_read_key("file:///a"), _entry("a")) + await store.set(_read_key("file:///b"), _entry("b")) + await store.set(_read_key("file:///c"), _entry("c")) + assert await store.get(_read_key("file:///a")) is None + assert await store.get(_read_key("file:///b")) == _entry("b") + assert await store.get(_read_key("file:///c")) == _entry("c") + + +async def test_a_get_refreshes_an_entrys_recency() -> None: + """Eviction order is recency (LRU), not insertion order: serving an entry keeps it alive.""" + store = InMemoryResponseCacheStore(max_entries=2) + await store.set(_read_key("file:///a"), _entry("a")) + await store.set(_read_key("file:///b"), _entry("b")) + assert await store.get(_read_key("file:///a")) == _entry("a") # a is now the most recent + await store.set(_read_key("file:///c"), _entry("c")) # evicts b, not a + assert await store.get(_read_key("file:///a")) == _entry("a") + assert await store.get(_read_key("file:///b")) is None + assert await store.get(_read_key("file:///c")) == _entry("c") + + +async def test_replacing_an_entry_at_the_cap_refreshes_its_recency_without_evicting() -> None: + store = InMemoryResponseCacheStore(max_entries=2) + await store.set(_read_key("file:///a"), _entry("a")) + await store.set(_read_key("file:///b"), _entry("b")) + await store.set(_read_key("file:///a"), _entry("a-replaced")) # still two entries; a is now the most recent + await store.set(_read_key("file:///c"), _entry("c")) # evicts b + assert await store.get(_read_key("file:///a")) == _entry("a-replaced") + assert await store.get(_read_key("file:///b")) is None + assert await store.get(_read_key("file:///c")) == _entry("c") + + +async def test_a_touched_list_entry_survives_read_key_churn_through_the_cap() -> None: + """The reason the cap is LRU over all entries: a hot list singleton each principal + keeps re-reading must survive churn from per-uri resources/read keys.""" + store = InMemoryResponseCacheStore(max_entries=3) + await store.set(CacheKey("tools/list"), _entry("tools")) + for i in range(10): + assert await store.get(CacheKey("tools/list")) == _entry("tools") # each serve re-touches it + await store.set(_read_key(f"file:///{i}"), _entry(i)) + assert await store.get(CacheKey("tools/list")) == _entry("tools") + + +async def test_a_zero_cap_disables_eviction() -> None: + store = InMemoryResponseCacheStore(max_entries=0) + uris = [f"file:///{i}" for i in range(5)] + for uri in uris: + await store.set(_read_key(uri), _entry(uri)) + for uri in uris: + assert await store.get(_read_key(uri)) == _entry(uri) + + +async def test_deleting_an_entry_frees_its_cap_slot() -> None: + store = InMemoryResponseCacheStore(max_entries=1) + await store.set(_read_key("file:///a"), _entry("a")) + await store.delete(_read_key("file:///a")) + await store.set(_read_key("file:///b"), _entry("b")) + assert await store.get(_read_key("file:///b")) == _entry("b") + + +def test_a_negative_cap_is_rejected_at_construction() -> None: + with pytest.raises(ValueError) as exc: + InMemoryResponseCacheStore(max_entries=-1) + assert str(exc.value) == snapshot("max_entries must be >= 0, got -1") + + +# --- ClientResponseCache coordinator --- + +MODERN_VERSION = "2026-07-28" +LEGACY_VERSION = "2025-11-25" + + +class _ManualClock: + """Injected wall clock: tests advance `now` instead of sleeping.""" + + def __init__(self) -> None: + self.now = 1_000_000.0 + + def __call__(self) -> float: + return self.now + + +def _coordinator( + store: ResponseCacheStore, + *, + partition: str = "", + arm_id: str = "arm", + default_ttl_ms: int = 0, + clock: _ManualClock | None = None, + share_public: bool = False, + version: str | None = MODERN_VERSION, + generation_map_cap: int = 4096, + store_cleanup_timeout: float = 5, +) -> ClientResponseCache: + return ClientResponseCache( + store=store, + partition=partition, + arm_id=arm_id, + default_ttl_ms=default_ttl_ms, + clock=clock or _ManualClock(), + share_public=share_public, + negotiated_version=lambda: version, + generation_map_cap=generation_map_cap, + store_cleanup_timeout=store_cleanup_timeout, + ) + + +def _private_arm(arm_id: str = "arm", partition: str = "", era: str | None = MODERN_VERSION) -> str: + return json.dumps(["private", era, arm_id, partition]) + + +def _public_arm(arm_id: str = "arm", partition: str = "", era: str | None = MODERN_VERSION) -> str: + return json.dumps(["public", era, arm_id, partition]) + + +def _wire_result(ttl_ms: int | None = None, cache_scope: str | None = None) -> ListToolsResult: + """A wire-parsed `tools/list` result; `None` keeps the hint out of `model_fields_set`.""" + payload: dict[str, Any] = {"tools": []} + if ttl_ms is not None: + payload["ttlMs"] = ttl_ms + if cache_scope is not None: + payload["cacheScope"] = cache_scope + return ListToolsResult.model_validate(payload) + + +def _read_result(ttl_ms: int) -> ReadResourceResult: + return ReadResourceResult.model_validate({"contents": [], "ttlMs": ttl_ms}) + + +class _ScriptedStore: + """Logs `(op, key)` and awaits one-shot hooks around commits, modelling an async store mid-commit.""" + + def __init__(self) -> None: + self.inner = InMemoryResponseCacheStore() + self.ops: list[tuple[str, CacheKey]] = [] + self.before_set_commits: Callable[[], Awaitable[None]] | None = None + self.after_set_commits: Callable[[], Awaitable[None]] | None = None + self.after_delete_commits: Callable[[], Awaitable[None]] | None = None + + async def get(self, key: CacheKey) -> CacheEntry | None: + self.ops.append(("get", key)) + return await self.inner.get(key) + + async def set(self, key: CacheKey, entry: CacheEntry) -> None: + self.ops.append(("set", key)) + if self.before_set_commits is not None: + hook, self.before_set_commits = self.before_set_commits, None + await hook() + await self.inner.set(key, entry) + if self.after_set_commits is not None: + hook, self.after_set_commits = self.after_set_commits, None + await hook() + + async def delete(self, key: CacheKey) -> None: + self.ops.append(("delete", key)) + await self.inner.delete(key) + if self.after_delete_commits is not None: + hook, self.after_delete_commits = self.after_delete_commits, None + await hook() + + async def clear(self) -> None: + raise NotImplementedError + + +class _FailingStore: + """Operations raise while their flag is set; toggling a flag models recovery.""" + + def __init__(self, *, fail_get: bool = False, fail_set: bool = False, fail_delete: bool = False) -> None: + self.inner = InMemoryResponseCacheStore() + self.fail_get = fail_get + self.fail_set = fail_set + self.fail_delete = fail_delete + + async def get(self, key: CacheKey) -> CacheEntry | None: + if self.fail_get: + raise RuntimeError("store get failed") + return await self.inner.get(key) + + async def set(self, key: CacheKey, entry: CacheEntry) -> None: + if self.fail_set: + raise RuntimeError("store set failed") + await self.inner.set(key, entry) + + async def delete(self, key: CacheKey) -> None: + if self.fail_delete: + raise RuntimeError("store delete failed") + await self.inner.delete(key) + + async def clear(self) -> None: + raise NotImplementedError + + +class _ArmDeleteFailingStore: + """`delete` raises only for keys on the given arm, modelling a failed opposite-arm cleanup.""" + + def __init__(self, failing_arm: str) -> None: + self.inner = InMemoryResponseCacheStore() + self.failing_arm = failing_arm + + async def get(self, key: CacheKey) -> CacheEntry | None: + return await self.inner.get(key) + + async def set(self, key: CacheKey, entry: CacheEntry) -> None: + raise NotImplementedError + + async def delete(self, key: CacheKey) -> None: + if key.partition == self.failing_arm: + raise RuntimeError("store delete failed") + await self.inner.delete(key) + + async def clear(self) -> None: + raise NotImplementedError + + +# The lax pragmas here and in the wedged-store tests: 3.11's settrace-based coverage loses +# tracing in frames resumed after the coordinator's bounded-shield cleanup cancellation. +class _WedgingDeleteStore: + """Once `wedged` flips, every `delete` blocks forever (an Event nothing sets), + modelling a remote store with no socket timeout of its own.""" + + before_set_commits: Callable[[], Awaitable[None]] + """Awaited before `set` commits; assigned by the one test whose write reaches `set`.""" + + def __init__(self, *, wedged: bool = False) -> None: + self.inner = InMemoryResponseCacheStore() + self.wedged = wedged + self.deletes_started = 0 + + async def get(self, key: CacheKey) -> CacheEntry | None: + raise NotImplementedError + + async def set(self, key: CacheKey, entry: CacheEntry) -> None: + await self.before_set_commits() + await self.inner.set(key, entry) # pragma: lax no cover + + async def delete(self, key: CacheKey) -> None: + self.deletes_started += 1 + if self.wedged: + await anyio.Event().wait() + await self.inner.delete(key) + + async def clear(self) -> None: + raise NotImplementedError + + +class _RehydratingStore: + """`get` returns whatever a persistent store's deserializer produced - not necessarily what `set` received.""" + + def __init__(self, rehydrated: Any) -> None: + self.rehydrated = rehydrated + + async def get(self, key: CacheKey) -> CacheEntry | None: + return self.rehydrated + + async def set(self, key: CacheKey, entry: CacheEntry) -> None: + raise NotImplementedError + + async def delete(self, key: CacheKey) -> None: + raise NotImplementedError + + async def clear(self) -> None: + raise NotImplementedError + + +# --- Coordinator: era gate --- + + +@pytest.mark.parametrize("version", [LEGACY_VERSION, None], ids=["legacy", "pre-negotiation"]) +async def test_hints_from_a_non_modern_session_are_ignored(version: str | None) -> None: + """The hints are 2026-07-28 assertions a legacy peer can still inject onto the wire (unknown keys + reach `model_fields_set`), so on a non-modern session every result is treated as hint-absent.""" + store = InMemoryResponseCacheStore() + cache = _coordinator(store, version=version) + gen = cache.capture("tools/list", "") + await cache.write("tools/list", "", _wire_result(ttl_ms=60_000, cache_scope="public"), gen, "use") + assert await cache.read("tools/list", "") is None + assert await store.get(CacheKey("tools/list", "", _private_arm(era=version))) is None + assert await store.get(CacheKey("tools/list", "", _public_arm(era=version))) is None + + +async def test_a_legacy_session_with_a_default_ttl_caches_on_the_private_arm_only() -> None: + """The operator's default TTL still applies on legacy sessions; injected hints cannot promote or re-clock.""" + store = InMemoryResponseCacheStore() + clock = _ManualClock() + cache = _coordinator(store, version=LEGACY_VERSION, default_ttl_ms=60_000, clock=clock) + gen = cache.capture("tools/list", "") + await cache.write("tools/list", "", _wire_result(ttl_ms=5, cache_scope="public"), gen, "use") + private_entry = await store.get(CacheKey("tools/list", "", _private_arm(era=LEGACY_VERSION))) + assert private_entry is not None + assert private_entry.scope == "private" + assert await store.get(CacheKey("tools/list", "", _public_arm(era=LEGACY_VERSION))) is None + clock.now += 1.0 # well past the injected 5ms; the default 60s governs + assert await cache.read("tools/list", "") == _wire_result(ttl_ms=5, cache_scope="public") + + +async def test_entries_never_cross_negotiated_eras_on_a_shared_store() -> None: + """Arms fold in the negotiated version: the same listing genuinely differs by era + (the SDK strips the 2026 fields for legacy sessions), so a 2025-negotiated session + is never served an entry a 2026 session wrote - on either arm - nor vice versa.""" + store = InMemoryResponseCacheStore() + modern = _coordinator(store, partition="p", default_ttl_ms=60_000) + legacy = _coordinator(store, partition="p", version=LEGACY_VERSION, default_ttl_ms=60_000) + + gen = modern.capture("tools/list", "") + await modern.write("tools/list", "", _wire_result(ttl_ms=60_000, cache_scope="public"), gen, "use") # public arm + private_result = ListPromptsResult.model_validate({"prompts": [], "ttlMs": 60_000}) + gen = modern.capture("prompts/list", "") + await modern.write("prompts/list", "", private_result, gen, "use") # private arm + assert await legacy.read("tools/list", "") is None + assert await legacy.read("prompts/list", "") is None + + gen = legacy.capture("resources/read", "file:///a") + await legacy.write("resources/read", "file:///a", _read_result(ttl_ms=60_000), gen, "use") + assert await legacy.read("resources/read", "file:///a") is not None # cached for legacy itself... + assert await modern.read("resources/read", "file:///a") is None # ...but invisible across the era boundary + + +async def test_coordinators_negotiating_the_same_era_share_entries_through_the_store() -> None: + """Era scoping splits eras only: same-era clients sharing a store still share both arms.""" + store = InMemoryResponseCacheStore() + writer = _coordinator(store, partition="p") + reader = _coordinator(store, partition="p") + + gen = writer.capture("tools/list", "") + await writer.write("tools/list", "", _wire_result(ttl_ms=60_000, cache_scope="public"), gen, "use") + private_result = ListPromptsResult.model_validate({"prompts": [], "ttlMs": 60_000}) + gen = writer.capture("prompts/list", "") + await writer.write("prompts/list", "", private_result, gen, "use") + + assert await reader.read("tools/list", "") == _wire_result(ttl_ms=60_000, cache_scope="public") + assert await reader.read("prompts/list", "") == private_result + + +# --- Coordinator: TTL and scope resolution --- + + +async def test_an_explicit_zero_ttl_is_not_overridden_by_the_default_ttl() -> None: + """Spec-mandated: ttlMs 0 means immediately stale; the default fills in only for hint-absent results.""" + store = InMemoryResponseCacheStore() + cache = _coordinator(store, default_ttl_ms=60_000) + gen = cache.capture("tools/list", "") + await cache.write("tools/list", "", _wire_result(ttl_ms=0), gen, "use") + assert await store.get(CacheKey("tools/list", "", _private_arm())) is None + assert await store.get(CacheKey("tools/list", "", _public_arm())) is None + + +async def test_a_hint_absent_modern_result_uses_the_default_ttl_privately() -> None: + store = InMemoryResponseCacheStore() + clock = _ManualClock() + cache = _coordinator(store, default_ttl_ms=60_000, clock=clock) + gen = cache.capture("tools/list", "") + await cache.write("tools/list", "", _wire_result(), gen, "use") + entry = await store.get(CacheKey("tools/list", "", _private_arm())) + assert entry is not None + assert entry.scope == "private" + assert entry.expires_at == clock.now + 60.0 + assert await cache.read("tools/list", "") == _wire_result() + clock.now += 60.0 + assert await cache.read("tools/list", "") is None + + +async def test_a_ttl_above_24_hours_is_clamped_to_the_cap() -> None: + """SEP-2549 hardening: a server cannot pin an entry beyond `MAX_TTL_MS`.""" + store = InMemoryResponseCacheStore() + clock = _ManualClock() + cache = _coordinator(store, clock=clock) + gen = cache.capture("tools/list", "") + await cache.write("tools/list", "", _wire_result(ttl_ms=7 * MAX_TTL_MS), gen, "use") + entry = await store.get(CacheKey("tools/list", "", _private_arm())) + assert entry is not None + assert entry.expires_at == clock.now + MAX_TTL_MS / 1000 + + +async def test_a_public_result_lands_on_the_public_arm_and_clears_the_private_arm() -> None: + """On a scope flip, writing the new arm deletes the other so the two arms never both answer.""" + store = InMemoryResponseCacheStore() + cache = _coordinator(store) + gen = cache.capture("tools/list", "") + await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use") + assert await store.get(CacheKey("tools/list", "", _private_arm())) is not None + await cache.write("tools/list", "", _wire_result(ttl_ms=60_000, cache_scope="public"), gen, "use") + public_entry = await store.get(CacheKey("tools/list", "", _public_arm())) + assert public_entry is not None + assert public_entry.scope == "public" + assert await store.get(CacheKey("tools/list", "", _private_arm())) is None + + +# --- Coordinator: partition arms and the scope guard --- + + +async def test_arm_key_layout_is_pinned_for_shared_store_compatibility() -> None: + """Arm strings are cross-process store key material; changing their layout breaks shared stores.""" + store = InMemoryResponseCacheStore() + cache = _coordinator(store, partition="tenant-a", arm_id="abc123", default_ttl_ms=60_000) + assert cache._arm("private") == snapshot('["private", "2026-07-28", "abc123", "tenant-a"]') + assert cache._arm("public") == snapshot('["public", "2026-07-28", "abc123", "tenant-a"]') + shared = _coordinator(store, partition="tenant-a", arm_id="abc123", share_public=True) + assert shared._arm("public") == snapshot('["public", "2026-07-28", "abc123"]') + # And entries genuinely land under those strings. + gen = cache.capture("tools/list", "") + await cache.write("tools/list", "", _wire_result(), gen, "use") + assert await store.get(CacheKey("tools/list", "", '["private", "2026-07-28", "abc123", "tenant-a"]')) is not None + await cache.write("tools/list", "", _wire_result(ttl_ms=60_000, cache_scope="public"), gen, "use") + assert await store.get(CacheKey("tools/list", "", '["public", "2026-07-28", "abc123", "tenant-a"]')) is not None + gen = shared.capture("tools/list", "") + await shared.write("tools/list", "", _wire_result(ttl_ms=60_000, cache_scope="public"), gen, "use") + assert await store.get(CacheKey("tools/list", "", '["public", "2026-07-28", "abc123"]')) is not None + + +async def test_public_entries_do_not_cross_partitions_by_default() -> None: + """Security default (deviates from the TypeScript SDK): a server stamping per-tenant data public + (bug or malice) cannot leak one tenant's response to another through a shared store.""" + store = InMemoryResponseCacheStore() + tenant_a = _coordinator(store, partition="tenant-a") + tenant_b = _coordinator(store, partition="tenant-b") + gen = tenant_a.capture("tools/list", "") + await tenant_a.write("tools/list", "", _wire_result(ttl_ms=60_000, cache_scope="public"), gen, "use") + assert await tenant_a.read("tools/list", "") == _wire_result(ttl_ms=60_000, cache_scope="public") + assert await tenant_b.read("tools/list", "") is None + + +async def test_share_public_serves_public_entries_across_partitions_but_never_private_ones() -> None: + store = InMemoryResponseCacheStore() + tenant_a = _coordinator(store, partition="tenant-a", share_public=True) + tenant_b = _coordinator(store, partition="tenant-b", share_public=True) + gen = tenant_a.capture("tools/list", "") + await tenant_a.write("tools/list", "", _wire_result(ttl_ms=60_000, cache_scope="public"), gen, "use") + assert await tenant_b.read("tools/list", "") == _wire_result(ttl_ms=60_000, cache_scope="public") + private_result = ListPromptsResult.model_validate({"prompts": [], "ttlMs": 60_000}) + gen = tenant_a.capture("prompts/list", "") + await tenant_a.write("prompts/list", "", private_result, gen, "use") + assert await tenant_b.read("prompts/list", "") is None + + +async def test_a_private_scoped_entry_under_the_public_arm_is_not_served() -> None: + """Defense in depth against a corrupted or pre-seeded store: the arm routes, the entry's scope verifies.""" + store = InMemoryResponseCacheStore() + cache = _coordinator(store) + await store.set( + CacheKey("tools/list", "", _public_arm()), + CacheEntry(value=_wire_result(), scope="private", expires_at=2_000_000.0), + ) + assert await cache.read("tools/list", "") is None + + +async def test_a_stale_private_entry_does_not_shadow_a_fresh_public_one() -> None: + """A stale private entry is an arm-probe miss, so the fall-through finds a public entry seeded by + another client after a server scope flip.""" + store = InMemoryResponseCacheStore() + clock = _ManualClock() + cache = _coordinator(store, clock=clock) + await store.set( + CacheKey("tools/list", "", _private_arm()), + CacheEntry(value=_wire_result(), scope="private", expires_at=clock.now - 1.0), + ) + public_result = _wire_result(ttl_ms=60_000, cache_scope="public") + await store.set( + CacheKey("tools/list", "", _public_arm()), + CacheEntry(value=public_result, scope="public", expires_at=clock.now + 60.0), + ) + assert await cache.read("tools/list", "") == public_result + + +async def test_an_entry_without_an_expiry_is_never_fresh() -> None: + """Entries rehydrated without expiry metadata are misses, not immortal.""" + store = InMemoryResponseCacheStore() + cache = _coordinator(store) + await store.set( + CacheKey("tools/list", "", _private_arm()), + CacheEntry(value=_wire_result(), scope="private", expires_at=None), + ) + assert await cache.read("tools/list", "") is None + + +# --- Coordinator: write ordering --- + + +async def test_write_deletes_the_opposite_arm_before_setting_its_own() -> None: + """Delete-then-set: a cancellation between the two operations leaves a miss, never two answering arms.""" + store = _ScriptedStore() + cache = _coordinator(store) + gen = cache.capture("tools/list", "") + await cache.write("tools/list", "", _wire_result(ttl_ms=60_000, cache_scope="public"), gen, "use") + assert store.ops == [ + ("delete", CacheKey("tools/list", "", _private_arm())), + ("set", CacheKey("tools/list", "", _public_arm())), + ] + + +async def test_an_eviction_landing_during_an_async_set_is_compensated() -> None: + """TOCTOU re-check: the eviction's deletes see nothing (the set has not committed yet), so the + post-set generation re-check must fire a compensating delete.""" + store = _ScriptedStore() + cache = _coordinator(store) + gen = cache.capture("tools/list", "") + + async def evict_mid_commit() -> None: + await cache.evict_method("tools/list") + + store.before_set_commits = evict_mid_commit + await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use") + private_key = CacheKey("tools/list", "", _private_arm()) + public_key = CacheKey("tools/list", "", _public_arm()) + assert store.ops == [ + ("delete", public_key), # write: opposite arm first + ("set", private_key), # write: own arm, commit still pending + ("delete", private_key), # eviction (sees nothing - not committed yet) + ("delete", public_key), # eviction + ("delete", private_key), # post-set re-check compensation + ] + assert await store.inner.get(private_key) is None + assert await cache.read("tools/list", "") is None + + +async def test_a_cancellation_landing_as_the_set_commits_still_compensates_an_eviction() -> None: + """The compensating delete is shielded: a timeout firing while the store's set is already on the + wire must not resurrect the evicted entry for its full TTL.""" + store = _ScriptedStore() + cache = _coordinator(store) + gen = cache.capture("tools/list", "") + private_key = CacheKey("tools/list", "", _private_arm()) + public_key = CacheKey("tools/list", "", _public_arm()) + with anyio.CancelScope() as scope: + + async def evict_then_cancel() -> None: + await cache.evict_method("tools/list") + scope.cancel() + + store.before_set_commits = evict_then_cancel + store.after_set_commits = anyio.lowlevel.checkpoint # first checkpoint after the commit + await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use") + assert scope.cancelled_caught + assert store.ops == [ + ("delete", public_key), # write: opposite arm first + ("set", private_key), # write: own arm, commit still pending + ("delete", private_key), # eviction (sees nothing - not committed yet) + ("delete", public_key), # eviction + ("delete", private_key), # post-set re-check compensation, shielded + ] + assert await store.inner.get(private_key) is None + + +async def test_a_cancellation_during_the_refresh_purge_still_purges_both_arms() -> None: + """The refresh purge is shielded - a mid-purge cancellation must not leave the superseded opposite arm.""" + store = _ScriptedStore() + cache = _coordinator(store) + gen = cache.capture("tools/list", "") + await cache.write("tools/list", "", _wire_result(ttl_ms=60_000, cache_scope="public"), gen, "use") + public_key = CacheKey("tools/list", "", _public_arm()) + assert await store.inner.get(public_key) is not None + with anyio.CancelScope() as scope: + scope.cancel() + # Delivers at the first checkpoint after the private-arm delete commits. + store.after_delete_commits = anyio.lowlevel.checkpoint + await cache.write("tools/list", "", _wire_result(ttl_ms=0), gen, "refresh") + assert await store.inner.get(public_key) is None + + +async def test_a_cancellation_during_an_eviction_still_evicts_both_arms() -> None: + """Eviction's arm deletes are shielded - a notification task cancelled mid-eviction (session + teardown) must not leave one arm serving the evicted entry.""" + store = _ScriptedStore() + cache = _coordinator(store) + gen = cache.capture("tools/list", "") + await cache.write("tools/list", "", _wire_result(ttl_ms=60_000, cache_scope="public"), gen, "use") + public_key = CacheKey("tools/list", "", _public_arm()) + with anyio.CancelScope() as scope: + scope.cancel() + # Delivers at the first checkpoint after the private-arm delete commits. + store.after_delete_commits = anyio.lowlevel.checkpoint + await cache.evict_method("tools/list") + assert await store.inner.get(public_key) is None + + +# --- Coordinator: bounded must-complete cleanup --- +# These tests inject a tiny `store_cleanup_timeout` because the bound itself is the +# behavior under test; the wedged delete only ever blocks for that injected bound. + + +async def test_evict_key_with_a_wedged_store_delete_returns_at_the_cleanup_bound( + caplog: pytest.LogCaptureFixture, +) -> None: + """A store delete that never completes cannot make eviction - and with it client + teardown - hang uncancellably: the must-complete cleanup is bounded, the remaining + deletes are abandoned, and the unreaped entries age out by TTL.""" + store = _WedgingDeleteStore(wedged=True) + cache = _coordinator(store, store_cleanup_timeout=0.01) + with caplog.at_level(logging.WARNING, logger="mcp.client.caching"), anyio.fail_after(5): + await cache.evict_key("tools/list", "") + assert store.deletes_started == 1 # pragma: lax no cover # the second arm's delete was abandoned with the first + assert caplog.messages == snapshot( # pragma: lax no cover + ["Response cache store delete timed out; the entry will age out by TTL"] + ) + + +async def test_a_refresh_purge_with_a_wedged_store_delete_returns_at_the_cleanup_bound() -> None: + store = _WedgingDeleteStore(wedged=True) + cache = _coordinator(store, store_cleanup_timeout=0.01) + gen = cache.capture("tools/list", "") + with anyio.fail_after(5): + await cache.write("tools/list", "", _wire_result(ttl_ms=0), gen, "refresh") + assert store.deletes_started == 1 # pragma: lax no cover + + +async def test_an_eviction_mid_set_with_a_wedged_store_delete_returns_at_the_cleanup_bound() -> None: + """The post-set compensating delete is bounded like every other must-complete delete; + the entry it could not reap stays in the store and ages out by TTL.""" + store = _WedgingDeleteStore() + cache = _coordinator(store, store_cleanup_timeout=0.01) + gen = cache.capture("tools/list", "") + + async def wedge_then_evict() -> None: + store.wedged = True + await cache.evict_method("tools/list") # its own cleanup hits the bound too + + store.before_set_commits = wedge_then_evict + with anyio.fail_after(5): + await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use") + # Opposite-arm delete, the eviction's first delete, the compensating delete. + assert store.deletes_started == 3 # pragma: lax no cover + # The accepted degradation: the unreaped entry stays until its TTL expires. + assert await store.inner.get(CacheKey("tools/list", "", _private_arm())) is not None # pragma: lax no cover + + +# --- Coordinator: store error discipline --- + + +async def test_a_raising_store_get_is_a_cache_miss() -> None: + store = _FailingStore(fail_get=True) + cache = _coordinator(store) + assert await cache.read("tools/list", "") is None + + +@pytest.mark.parametrize( + "rehydrated", + [ + CacheEntry(value={"tools": []}, scope="private", expires_at=2_000_000.0), + {"value": {"tools": []}, "scope": "private", "expires_at": 2_000_000.0}, + ], + ids=["dict-value", "dict-entry"], +) +async def test_an_entry_rehydrated_into_the_wrong_shape_is_a_warned_miss( + rehydrated: Any, caplog: pytest.LogCaptureFixture +) -> None: + """A persistent store has no method-to-model mapping, so its `get` may return serialized shapes; + the warned miss is one burst, not one warning per cached read.""" + cache = _coordinator(_RehydratingStore(rehydrated)) + with caplog.at_level(logging.WARNING, logger="mcp.client.caching"): + assert await cache.read("tools/list", "") is None + assert await cache.read("tools/list", "") is None + assert len(caplog.records) == 1 + + +async def test_a_raising_opposite_arm_delete_aborts_the_write() -> None: + """Setting after a failed opposite-arm delete could leave both arms populated.""" + store = _FailingStore(fail_delete=True) + cache = _coordinator(store) + gen = cache.capture("tools/list", "") + await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use") + assert await store.inner.get(CacheKey("tools/list", "", _private_arm())) is None + assert await store.inner.get(CacheKey("tools/list", "", _public_arm())) is None + + +async def test_a_failed_opposite_arm_delete_degrades_the_key_to_a_full_miss() -> None: + """The fetch superseded the warm own-arm entry, so it is best-effort deleted too; the write never raises.""" + store = _ArmDeleteFailingStore(failing_arm=_public_arm()) + cache = _coordinator(store) + await store.inner.set( + CacheKey("tools/list", "", _private_arm()), + CacheEntry(value=_wire_result(), scope="private", expires_at=2_000_000.0), + ) + assert await cache.read("tools/list", "") is not None # the warm own-arm entry + gen = cache.capture("tools/list", "") + await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use") + assert await store.inner.get(CacheKey("tools/list", "", _private_arm())) is None + assert await store.inner.get(CacheKey("tools/list", "", _public_arm())) is None + assert await cache.read("tools/list", "") is None + + +async def test_a_raising_store_set_caches_nothing_and_does_not_raise() -> None: + store = _FailingStore(fail_set=True) + cache = _coordinator(store) + gen = cache.capture("tools/list", "") + await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use") + assert await cache.read("tools/list", "") is None + + +async def test_a_failed_set_purges_the_pre_existing_own_arm_entry() -> None: + """The fetch superseded the warm own-arm entry, and the failed set left it in place: + without the purge it would keep serving the superseded value for its full TTL.""" + store = _FailingStore() + cache = _coordinator(store) + gen = cache.capture("tools/list", "") + await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use") + assert await cache.read("tools/list", "") is not None # the warm own-arm entry + store.fail_set = True + gen = cache.capture("tools/list", "") + await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use") # the caller's fetch is unaffected + assert await store.inner.get(CacheKey("tools/list", "", _private_arm())) is None + assert await store.inner.get(CacheKey("tools/list", "", _public_arm())) is None + assert await cache.read("tools/list", "") is None + + +async def test_eviction_with_a_raising_delete_still_bumps_the_generation() -> None: + """Bump-first: a fetch captured before the eviction cannot write back even when the deletes raise.""" + store = _FailingStore() + cache = _coordinator(store) + stale_gen = cache.capture("tools/list", "") # fetch in flight when the eviction lands + store.fail_delete = True + await cache.evict_method("tools/list") # deletes raise; the bump already happened + store.fail_delete = False + await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), stale_gen, "use") + assert await store.inner.get(CacheKey("tools/list", "", _private_arm())) is None + fresh_gen = cache.capture("tools/list", "") + await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), fresh_gen, "use") + assert await cache.read("tools/list", "") == _wire_result(ttl_ms=60_000) + + +async def test_store_failures_warn_once_per_burst(caplog: pytest.LogCaptureFixture) -> None: + store = _FailingStore(fail_get=True) + cache = _coordinator(store) + with caplog.at_level(logging.WARNING, logger="mcp.client.caching"): + await cache.read("tools/list", "") # consecutive failing reads, one burst + await cache.read("tools/list", "") + assert len(caplog.records) == 1 + store.fail_get = False + await cache.read("tools/list", "") # success re-arms the warning + store.fail_get = True + await cache.read("tools/list", "") + assert len(caplog.records) == 2 + assert caplog.messages[0] == snapshot("Response cache store operation failed; continuing without the cache") + + +async def test_a_set_only_store_failure_warns_once_across_write_cycles(caplog: pytest.LogCaptureFixture) -> None: + """Bursts are tracked per operation kind - the healthy deletes between failing sets never re-arm.""" + store = _FailingStore(fail_set=True) + cache = _coordinator(store) + with caplog.at_level(logging.WARNING, logger="mcp.client.caching"): + for _ in range(3): # each cycle: opposite-arm delete succeeds, then the set fails + gen = cache.capture("tools/list", "") + await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use") + assert len(caplog.records) == 1 + store.fail_set = False + gen = cache.capture("tools/list", "") + await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use") # set succeeds, re-arms + store.fail_set = True + gen = cache.capture("tools/list", "") + await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use") + assert len(caplog.records) == 2 + + +# --- Coordinator: generation discipline --- + + +async def test_an_eviction_between_capture_and_write_discards_the_write() -> None: + """Spec-aligned: a fetch in flight when its key is evicted must not write the evicted entry back.""" + store = InMemoryResponseCacheStore() + cache = _coordinator(store) + gen = cache.capture("tools/list", "") + await cache.evict_method("tools/list") + await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use") + assert await store.get(CacheKey("tools/list", "", _private_arm())) is None + assert await store.get(CacheKey("tools/list", "", _public_arm())) is None + + +async def test_recapturing_a_registered_key_returns_its_current_generation() -> None: + store = InMemoryResponseCacheStore() + cache = _coordinator(store) + gen_before = cache.capture("tools/list", "") + await cache.evict_method("tools/list") + gen_after = cache.capture("tools/list", "") + assert gen_after != gen_before + await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen_after, "use") + assert await cache.read("tools/list", "") == _wire_result(ttl_ms=60_000) + + +async def test_the_generation_map_drops_the_oldest_key_at_its_cap() -> None: + """A dropped key's race guard degrades to the accepted co-tenant class - an eviction racing its + in-flight fetch goes undetected (cap is 4096 in production, parametrized small here).""" + store = InMemoryResponseCacheStore() + cache = _coordinator(store, generation_map_cap=2) + gen_a = cache.capture("resources/read", "file:///a") + gen_b = cache.capture("resources/read", "file:///b") + cache.capture("resources/read", "file:///c") # at the cap: drops file:///a + await cache.evict_key("resources/read", "file:///a") # unregistered: no bump + await cache.evict_key("resources/read", "file:///b") # registered: bump + await cache.write("resources/read", "file:///a", _read_result(ttl_ms=60_000), gen_a, "use") + await cache.write("resources/read", "file:///b", _read_result(ttl_ms=60_000), gen_b, "use") + assert await cache.read("resources/read", "file:///a") is not None # degraded guard fails open + assert await cache.read("resources/read", "file:///b") is None # guard held + + +# --- Coordinator: eviction --- + + +async def test_a_refresh_resolving_uncacheable_purges_the_warm_entry() -> None: + """The refetch superseded the warm entry, which must not be served again.""" + store = InMemoryResponseCacheStore() + cache = _coordinator(store) + gen = cache.capture("tools/list", "") + await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use") + assert await cache.read("tools/list", "") is not None + await cache.write("tools/list", "", _wire_result(ttl_ms=0), gen, "refresh") + assert await store.get(CacheKey("tools/list", "", _private_arm())) is None + assert await store.get(CacheKey("tools/list", "", _public_arm())) is None + + +async def test_evict_key_on_an_unregistered_key_still_deletes_both_arms() -> None: + """A persistent store may hold warm entries from a prior process this coordinator never captured.""" + store = InMemoryResponseCacheStore() + await store.set( + CacheKey("resources/read", "file:///warm", _private_arm()), + CacheEntry(value=_read_result(ttl_ms=60_000), scope="private", expires_at=2_000_000.0), + ) + await store.set( + CacheKey("resources/read", "file:///warm", _public_arm()), + CacheEntry(value=_read_result(ttl_ms=60_000), scope="public", expires_at=2_000_000.0), + ) + cache = _coordinator(store) + await cache.evict_key("resources/read", "file:///warm") + assert await store.get(CacheKey("resources/read", "file:///warm", _private_arm())) is None + assert await store.get(CacheKey("resources/read", "file:///warm", _public_arm())) is None + + +@pytest.mark.parametrize( + ("notification", "evicted"), + [ + (ToolListChangedNotification(), {("tools/list", "")}), + (PromptListChangedNotification(), {("prompts/list", "")}), + (ResourceListChangedNotification(), {("resources/list", ""), ("resources/templates/list", "")}), + ( + ResourceUpdatedNotification(params=ResourceUpdatedNotificationParams(uri="file:///a")), + {("resources/read", "file:///a")}, + ), + ( + LoggingMessageNotification(params=LoggingMessageNotificationParams(level="info", data="x")), + set[tuple[str, str]](), + ), + ], + ids=["tools-list-changed", "prompts-list-changed", "resources-list-changed", "resource-updated", "unrelated"], +) +async def test_notifications_evict_exactly_their_mapped_entries( + notification: ServerNotification, evicted: set[tuple[str, str]] +) -> None: + """Spec SHOULD: notifications invalidate - and nothing beyond their mapped entries.""" + store = InMemoryResponseCacheStore() + cache = _coordinator(store) + seeded = [ + ("tools/list", ""), + ("prompts/list", ""), + ("resources/list", ""), + ("resources/templates/list", ""), + ("resources/read", "file:///a"), + ("resources/read", "file:///b"), + ] + for method, params_key in seeded: + # The value's content is irrelevant to eviction; any cacheable model serves. + await store.set( + CacheKey(method, params_key, _private_arm()), + CacheEntry(value=_wire_result(), scope="private", expires_at=2_000_000.0), + ) + await cache.evict_for_notification(notification) + for method, params_key in seeded: + if (method, params_key) in evicted: + assert await cache.read(method, params_key) is None + else: + assert await cache.read(method, params_key) is not None diff --git a/tests/client/test_client.py b/tests/client/test_client.py index a6a9ac6ea8..820478f3ff 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -506,6 +506,100 @@ async def on_list_tools( assert [t.name for t in result.tools] == ["ok", "dropme"] +_RETIRED_TOOL = Tool( + name="retired", + input_schema={"type": "object", "properties": {"region": {"type": "string", "x-mcp-header": "Region"}}}, + output_schema={"type": "object"}, +) +_SURVIVOR_TOOL = Tool(name="survivor", input_schema={"type": "object"}) + + +def _scripted_listing_server(listings: list[ListToolsResult]) -> Server: + """Serves the given listings in order, one per tools/list request.""" + + async def on_list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + return listings.pop(0) + + return Server("test", on_list_tools=on_list_tools) + + +async def test_a_complete_listing_prunes_per_tool_state_for_tools_it_no_longer_contains() -> None: + """SDK-defined: a complete (uncursored, cursorless) listing is the full tool universe, so the + header map and output schema derived from an earlier listing of a now-absent tool are dropped.""" + server = _scripted_listing_server( + [ + ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]), + ListToolsResult(tools=[_SURVIVOR_TOOL]), + ] + ) + + with anyio.fail_after(5): + async with Client(server) as client: + await client.session.list_tools() + assert set(client.session._x_mcp_header_maps) == {"retired", "survivor"} + assert set(client.session._tool_output_schemas) == {"retired", "survivor"} + + await client.session.list_tools() + assert set(client.session._x_mcp_header_maps) == {"survivor"} + assert set(client.session._tool_output_schemas) == {"survivor"} + + +async def test_a_complete_listing_prunes_output_schemas_on_a_legacy_session_too() -> None: + """SDK-defined: the prune is era-independent -- legacy sessions cache output schemas the same + way (their header-map dict just stays empty, since the x-mcp-header filter is 2026-only).""" + server = _scripted_listing_server( + [ + ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]), + ListToolsResult(tools=[_SURVIVOR_TOOL]), + ] + ) + + with anyio.fail_after(5): + async with Client(server, mode="legacy") as client: + await client.session.list_tools() + assert set(client.session._tool_output_schemas) == {"retired", "survivor"} + assert client.session._x_mcp_header_maps == {} + + await client.session.list_tools() + assert set(client.session._tool_output_schemas) == {"survivor"} + + +async def test_a_listing_with_a_next_cursor_prunes_no_per_tool_state() -> None: + """SDK-defined: a first page carrying next_cursor is not the full universe -- state for tools + expected on later pages must survive it.""" + server = _scripted_listing_server( + [ + ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]), + ListToolsResult(tools=[_SURVIVOR_TOOL], next_cursor="2"), + ] + ) + + with anyio.fail_after(5): + async with Client(server) as client: + await client.session.list_tools() + await client.session.list_tools() + assert set(client.session._x_mcp_header_maps) == {"retired", "survivor"} + assert set(client.session._tool_output_schemas) == {"retired", "survivor"} + + +async def test_a_cursor_page_fetch_prunes_no_per_tool_state() -> None: + """SDK-defined: a continuation page is partial even when it ends the pagination (no + next_cursor) -- only an uncursored single-page listing prunes.""" + server = _scripted_listing_server( + [ + ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]), + ListToolsResult(tools=[_SURVIVOR_TOOL]), + ] + ) + + with anyio.fail_after(5): + async with Client(server) as client: + await client.session.list_tools() + await client.session.list_tools(params=types.PaginatedRequestParams(cursor="2")) + assert set(client.session._x_mcp_header_maps) == {"retired", "survivor"} + assert set(client.session._tool_output_schemas) == {"retired", "survivor"} + + 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 diff --git a/tests/client/test_client_caching.py b/tests/client/test_client_caching.py new file mode 100644 index 0000000000..708d83db4a --- /dev/null +++ b/tests/client/test_client_caching.py @@ -0,0 +1,1579 @@ +"""`Client` wiring for the response cache: the `cache=` kwarg, server identity +resolution, the custom-store guard, notification eviction, and the five cacheable +verbs. The coordinator's own behavior is covered in `test_caching.py`.""" + +import hashlib +import json +import time +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import asynccontextmanager +from types import TracebackType +from typing import Any, Literal + +import anyio +import anyio.lowlevel +import httpx +import mcp_types as types +import pytest +from inline_snapshot import snapshot +from mcp_types import ( + INTERNAL_ERROR, + INVALID_PARAMS, + CallToolResult, + DiscoverResult, + ElicitRequest, + ElicitRequestFormParams, + ElicitResult, + Implementation, + InputRequiredResult, + ListPromptsResult, + ListResourcesResult, + ListResourceTemplatesResult, + ListToolsResult, + ReadResourceResult, + ResourceListChangedNotification, + ResourceUpdatedNotification, + ResourceUpdatedNotificationParams, + ServerCapabilities, + ServerNotification, + TextContent, + TextResourceContents, + Tool, + ToolListChangedNotification, +) +from mcp_types.version import LATEST_MODERN_VERSION + +from mcp.client import Client +from mcp.client._transport import TransportStreams +from mcp.client.caching import ( + CacheConfig, + CacheEntry, + CacheKey, + ClientResponseCache, + InMemoryResponseCacheStore, +) +from mcp.client.streamable_http import streamable_http_client +from mcp.server import Server, ServerRequestContext +from mcp.server.caching import CacheHint +from mcp.shared.exceptions import MCPError +from mcp.shared.memory import MessageStream, create_client_server_memory_streams +from mcp.shared.message import SessionMessage +from mcp.shared.session import RequestResponder +from tests.interaction._connect import BASE_URL, mounted_app + +pytestmark = pytest.mark.anyio + +IncomingMessage = RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception + + +def _coordinator(client: Client) -> ClientResponseCache: + cache = client._response_cache + assert cache is not None + return cache + + +def _private_arm(client: Client) -> str: + """The identity arm stamped into store keys; only equality between clients matters here.""" + return _coordinator(client)._arm("private") + + +def _tools_list_key(client: Client) -> CacheKey: + return CacheKey("tools/list", "", _private_arm(client)) + + +class _OpaqueTransport: + """Shape-only `Transport`: identity resolution happens at construction, so tests never enter it.""" + + async def __aenter__(self) -> TransportStreams: + raise NotImplementedError + + async def __aexit__( + self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None + ) -> None: + raise NotImplementedError + + +def _list_changed_server() -> Server[Any]: + """Server whose `touch` tool emits tools/list_changed; connect with `mode="legacy"` + because the modern in-process path drops standalone server notifications.""" + + async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[types.Tool(name="touch", input_schema={"type": "object"})]) + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + assert params.name == "touch" + await ctx.session.send_tool_list_changed() + return CallToolResult(content=[TextContent(text="touched")]) + + return Server("notifier", on_list_tools=list_tools, on_call_tool=call_tool) + + +async def _warm_tools_list_entry(client: Client) -> CacheKey: + """Seed a private-arm tools/list entry directly in the store; payload and expiry are inert to eviction.""" + key = _tools_list_key(client) + await _coordinator(client)._store.set(key, CacheEntry(value="warm", scope="private", expires_at=None)) + return key + + +def test_an_explicit_target_id_overrides_both_url_and_in_process_identity() -> None: + by_target_url = Client("https://example.com/mcp", cache=CacheConfig(target_id="svc")) + by_target_inproc = Client(Server("plain"), cache=CacheConfig(target_id="svc")) + by_url = Client("https://example.com/mcp") + + assert _private_arm(by_target_url) == _private_arm(by_target_inproc) + assert _private_arm(by_target_url) != _private_arm(by_url) + + +def test_userinfo_variants_of_a_server_url_share_one_cache_identity() -> None: + """Stripping userinfo is the single permitted URL rewrite.""" + bare = Client("https://example.com/mcp") + with_password = Client("https://user:secret@example.com/mcp") + with_token = Client("https://token@example.com/mcp") + + assert _private_arm(bare) == _private_arm(with_password) == _private_arm(with_token) + + +@pytest.mark.parametrize( + ("with_userinfo", "bare"), + [ + ("HTTPS://a@X.example/mcp", "HTTPS://X.example/mcp"), + ("https://u@h/p?", "https://h/p?"), + ("https://u@h/p#", "https://h/p#"), + ("https://u\tser:p@h.example/p", "https://h.example/p"), + ("https://u:p@h.example/pa\tth", "https://h.example/pa\tth"), + ], + ids=["scheme-case", "empty-query", "empty-fragment", "tab-in-userinfo", "tab-in-path"], +) +def test_stripping_userinfo_changes_no_other_byte_of_the_url(with_userinfo: str, bare: str) -> None: + """The removed `userinfo@` is the only byte difference: no scheme case-folding, no dropped + empty `?`/`#` delimiters, and control characters - which urlsplit would silently strip, + misaligning any parser-derived slice - stay byte-exact outside the removed span. A + userinfo-free URL passes through untouched, so arm equality proves the stripped form is + byte-identical to the bare URL.""" + assert _private_arm(Client(with_userinfo)) == _private_arm(Client(bare)) + + +def test_a_url_without_an_authority_passes_through_unchanged() -> None: + """No `//` means no authority span, so an `@` elsewhere strips nothing.""" + arm_id = hashlib.sha256(b"mailto:a@b").hexdigest() + assert _private_arm(Client("mailto:a@b")) == json.dumps(["private", None, arm_id, ""]) + + +def test_the_server_url_is_sha256_hashed_before_it_enters_key_material() -> None: + """Pins the docs' secrets-never-in-keys claim: a query-string secret never appears in store keys.""" + client = Client("https://user:pass@example.com/mcp?api_key=SECRET") + + arm_id = hashlib.sha256(b"https://example.com/mcp?api_key=SECRET").hexdigest() + # The era slot is None pre-connect; only the identity hash matters here. + assert _private_arm(client) == json.dumps(["private", None, arm_id, ""]) + + +def test_urls_differing_only_in_query_have_distinct_cache_identities() -> None: + """URL identity is byte-exact outside userinfo; over-normalization would merge tenants.""" + tenant_a = Client("https://example.com/mcp?tenant=a") + tenant_b = Client("https://example.com/mcp?tenant=b") + + assert _private_arm(tenant_a) != _private_arm(tenant_b) + + +def test_two_clients_on_one_in_process_server_get_distinct_cache_identities() -> None: + server = Server("plain") + + assert _private_arm(Client(server)) != _private_arm(Client(server)) + + +def test_a_transport_object_gets_a_per_client_cache_identity() -> None: + transport = _OpaqueTransport() + + assert _private_arm(Client(transport)) != _private_arm(Client(transport)) + + +@pytest.mark.parametrize("make_server", [lambda: Server("plain"), _OpaqueTransport], ids=["in-process", "transport"]) +def test_a_custom_store_without_a_url_or_target_id_is_rejected(make_server: Any) -> None: + with pytest.raises(ValueError) as exc_info: + Client(make_server(), cache=CacheConfig(store=InMemoryResponseCacheStore(), partition="p")) + assert str(exc_info.value) == snapshot( + "a custom cache store requires CacheConfig.target_id when the server is not a URL: in-process servers " + "and Transport instances get a random per-client identity, so their entries in a shared store could " + "never be served to another client" + ) + + +def test_a_custom_store_with_a_url_server_constructs_and_is_used() -> None: + store = InMemoryResponseCacheStore() + client = Client("https://example.com/mcp", cache=CacheConfig(store=store, partition="p")) + + assert _coordinator(client)._store is store + + +def test_a_custom_store_with_an_explicit_target_id_constructs_for_any_server() -> None: + store = InMemoryResponseCacheStore() + client = Client(Server("plain"), cache=CacheConfig(store=store, partition="p", target_id="svc")) + + assert _coordinator(client)._store is store + + +async def test_cache_false_disables_the_cache_and_the_handler_wrap() -> None: + async def handler(message: IncomingMessage) -> None: + raise NotImplementedError + + client = Client(_list_changed_server(), cache=False, message_handler=handler) + assert client._response_cache is None + + async with client: + assert client.session._message_handler is handler + + +def test_the_default_cache_uses_a_per_client_in_memory_store() -> None: + """`cache=None` (the default) is cache-on.""" + server = Server("plain") + first = Client(server) + second = Client(server) + + assert isinstance(_coordinator(first)._store, InMemoryResponseCacheStore) + assert _coordinator(first)._store is not _coordinator(second)._store + + +async def test_the_negotiated_version_supplier_tracks_the_session_lifecycle() -> None: + """The era gate must never read a stale or raising source.""" + client = Client(_list_changed_server()) + supplier = _coordinator(client)._negotiated_version + + assert supplier() is None + async with client: + assert supplier() == client.protocol_version + assert supplier() is None + + +async def test_a_list_changed_notification_evicts_without_a_user_handler() -> None: + """Spec SHOULD (notifications invalidate): the entry is deleted from both arms.""" + + class _EventedStore(InMemoryResponseCacheStore): + """Signals once both arms of an eviction have been deleted.""" + + def __init__(self) -> None: + super().__init__() + self._deletes = 0 + self.both_arms_deleted = anyio.Event() + + async def delete(self, key: CacheKey) -> None: + await super().delete(key) + self._deletes += 1 + if self._deletes == 2: + self.both_arms_deleted.set() + + store = _EventedStore() + client = Client( + _list_changed_server(), mode="legacy", cache=CacheConfig(store=store, partition="p", target_id="svc") + ) + + async with client: + key = await _warm_tools_list_entry(client) + await client.call_tool("touch", {}) + with anyio.fail_after(5): + await store.both_arms_deleted.wait() + assert await store.get(key) is None + + +async def test_a_user_handler_receives_the_notification_the_eviction_consumed() -> None: + """Eviction is a tee, not a filter.""" + received: list[IncomingMessage] = [] + seen = anyio.Event() + + async def collect(message: IncomingMessage) -> None: + received.append(message) + seen.set() + + client = Client(_list_changed_server(), mode="legacy", message_handler=collect) + + async with client: + key = await _warm_tools_list_entry(client) + await client.call_tool("touch", {}) + with anyio.fail_after(5): + await seen.wait() + # The wrap evicts before delegating: delivery implies the entry is gone. + assert await _coordinator(client)._store.get(key) is None + + assert received == snapshot([ToolListChangedNotification()]) + + +async def test_non_notification_items_pass_through_to_the_user_handler_untouched() -> None: + """Transport `Exception` items can't occur in-process, so the installed handler is invoked directly.""" + received: list[IncomingMessage] = [] + + async def collect(message: IncomingMessage) -> None: + received.append(message) + + client = Client(_list_changed_server(), message_handler=collect) + + async with client: + installed = client.session._message_handler + assert installed is not collect # the wrap, not the bare user handler + key = await _warm_tools_list_entry(client) + fault = RuntimeError("stream broke") + await installed(fault) + assert received == [fault] + assert await _coordinator(client)._store.get(key) is not None + + +async def test_a_raising_eviction_does_not_block_notification_delivery(caplog: pytest.LogCaptureFixture) -> None: + class _ExplodingCache(ClientResponseCache): + async def evict_for_notification(self, notification: ServerNotification) -> None: + raise RuntimeError("cache bug") + + received: list[IncomingMessage] = [] + seen = anyio.Event() + + async def collect(message: IncomingMessage) -> None: + received.append(message) + seen.set() + + client = Client(_list_changed_server(), mode="legacy", message_handler=collect) + # The wrap reads `_response_cache` at session build, so the swap must happen pre-enter. + client._response_cache = _ExplodingCache( + store=InMemoryResponseCacheStore(), + partition="", + arm_id="arm", + default_ttl_ms=0, + clock=time.time, + share_public=False, + negotiated_version=lambda: None, + ) + + async with client: + await client.call_tool("touch", {}) + with anyio.fail_after(5): + await seen.wait() + + assert received == snapshot([ToolListChangedNotification()]) + assert "Response cache eviction failed; the notification is still delivered" in [ + record.message for record in caplog.records + ] + + +# --- The cacheable verbs --- + + +class _ManualClock: + """Injected wall clock: tests advance `now` instead of sleeping.""" + + def __init__(self) -> None: + self.now = 1_000_000.0 + + def __call__(self) -> float: + return self.now + + +def _varying_tools_server( + *, ttl_ms: int = 60_000, scope: Literal["public", "private"] = "private" +) -> tuple[Server[Any], list[str | None]]: + """Server whose every tools/list fetch returns a distinct tool name `t`, + so a served entry is distinguishable from a refetch by payload.""" + fetches: list[str | None] = [] + + async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + fetches.append(params.cursor if params is not None else None) + return ListToolsResult(tools=[Tool(name=f"t{len(fetches) - 1}", input_schema={"type": "object"})]) + + server = Server( + "varying", on_list_tools=list_tools, cache_hints={"tools/list": CacheHint(ttl_ms=ttl_ms, scope=scope)} + ) + return server, fetches + + +def _tool_names(result: ListToolsResult) -> list[str]: + return [tool.name for tool in result.tools] + + +async def test_a_second_list_tools_within_the_ttl_is_served_from_the_cache() -> None: + """SEP-2549: a result carrying a `ttlMs` hint is reusable until it expires.""" + server, fetches = _varying_tools_server() + + async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client: + first = await client.list_tools() + second = await client.list_tools() + + assert fetches == [None] + assert second == first + + +async def test_an_expired_entry_is_refetched() -> None: + """Freshness is strict: at exactly `ttlMs` the entry is expired.""" + clock = _ManualClock() + server, fetches = _varying_tools_server(ttl_ms=60_000) + + async with Client(server, cache=CacheConfig(clock=clock)) as client: + assert _tool_names(await client.list_tools()) == ["t0"] + clock.now += 60.0 + assert _tool_names(await client.list_tools()) == ["t1"] + + assert fetches == [None, None] + + +async def test_each_list_verb_caches_independently_under_its_own_method() -> None: + """Cache keys discriminate by method (spec MUST).""" + fetched: list[str] = [] + + async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + fetched.append("tools/list") + return ListToolsResult(tools=[]) + + async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListPromptsResult: + fetched.append("prompts/list") + return ListPromptsResult(prompts=[]) + + async def list_resources( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> ListResourcesResult: + fetched.append("resources/list") + return ListResourcesResult(resources=[]) + + async def list_templates( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> ListResourceTemplatesResult: + fetched.append("resources/templates/list") + return ListResourceTemplatesResult(resource_templates=[]) + + hint = CacheHint(ttl_ms=60_000) + server = Server( + "all-lists", + on_list_tools=list_tools, + on_list_prompts=list_prompts, + on_list_resources=list_resources, + on_list_resource_templates=list_templates, + cache_hints={ + "tools/list": hint, + "prompts/list": hint, + "resources/list": hint, + "resources/templates/list": hint, + }, + ) + + async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client: + await client.list_tools() + await client.list_prompts() + await client.list_resources() + await client.list_resource_templates() + await client.list_tools() + await client.list_prompts() + await client.list_resources() + await client.list_resource_templates() + + assert fetched == ["tools/list", "prompts/list", "resources/list", "resources/templates/list"] + + +async def test_read_resource_caches_per_uri() -> None: + """Cache keys discriminate by result-affecting params (spec MUST).""" + reads: list[str] = [] + + async def read(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult: + reads.append(params.uri) + return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=params.uri)]) + + server = Server("res", on_read_resource=read, cache_hints={"resources/read": CacheHint(ttl_ms=60_000)}) + + async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client: + first_a = await client.read_resource("memo://a") + first_b = await client.read_resource("memo://b") + assert await client.read_resource("memo://a") == first_a + assert await client.read_resource("memo://b") == first_b + + assert reads == ["memo://a", "memo://b"] + + +def _paginated_tools_server() -> tuple[Server[Any], list[str | None]]: + """Cacheable first page; cursor "expired" -> INVALID_PARAMS (the spec's expired-cursor + signal), "fail" -> INTERNAL_ERROR.""" + fetches: list[str | None] = [] + + async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + cursor = params.cursor if params is not None else None + fetches.append(cursor) + if cursor is None: + first_page = Tool(name="first-page", input_schema={"type": "object"}) + return ListToolsResult(tools=[first_page], next_cursor="page-2") + if cursor == "page-2": + return ListToolsResult(tools=[Tool(name="second-page", input_schema={"type": "object"})]) + if cursor == "fail": + raise MCPError(code=INTERNAL_ERROR, message="transient failure") + raise MCPError(code=INVALID_PARAMS, message=f"Unknown cursor: {cursor!r}") + + server = Server("paginated", on_list_tools=list_tools, cache_hints={"tools/list": CacheHint(ttl_ms=60_000)}) + return server, fetches + + +async def test_cursor_continuations_neither_read_nor_write_the_cache() -> None: + """Only cursor-less calls participate in caching (SDK-defined single-page entry).""" + server, fetches = _paginated_tools_server() + + async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client: + assert _tool_names(await client.list_tools()) == ["first-page"] + assert _tool_names(await client.list_tools(cursor="page-2")) == ["second-page"] + assert _tool_names(await client.list_tools()) == ["first-page"] # not overwritten by the continuation + + assert fetches == [None, "page-2"] + + +async def test_an_expired_cursor_rejection_evicts_the_methods_entry() -> None: + """Spec SHOULD: INVALID_PARAMS on a continuation cursor means the listing changed.""" + server, fetches = _paginated_tools_server() + + async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client: + await client.list_tools() + with pytest.raises(MCPError) as exc_info: + await client.list_tools(cursor="expired") + assert exc_info.value.code == INVALID_PARAMS + await client.list_tools() + + assert fetches == [None, "expired", None] + + +async def test_an_expired_cursor_rejection_under_bypass_does_not_evict() -> None: + """Bypass means no cache side-effects at all, eviction included.""" + server, fetches = _paginated_tools_server() + + async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client: + await client.list_tools() + with pytest.raises(MCPError) as exc_info: + await client.list_tools(cursor="expired", cache_mode="bypass") + assert exc_info.value.code == INVALID_PARAMS + await client.list_tools() # still served from the warm entry + + assert fetches == [None, "expired"] + + +async def test_a_non_cursor_error_on_a_continuation_does_not_evict() -> None: + """Only INVALID_PARAMS signals cursor expiry.""" + server, fetches = _paginated_tools_server() + + async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client: + await client.list_tools() + with pytest.raises(MCPError) as exc_info: + await client.list_tools(cursor="fail") + assert exc_info.value.code == INTERNAL_ERROR + await client.list_tools() # still served from the warm entry + + assert fetches == [None, "fail"] + + +async def test_bypass_neither_serves_nor_disturbs_a_warm_entry() -> None: + server, fetches = _varying_tools_server() + + async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client: + assert _tool_names(await client.list_tools()) == ["t0"] + assert _tool_names(await client.list_tools(cache_mode="bypass")) == ["t1"] + assert _tool_names(await client.list_tools()) == ["t0"] # warm entry intact + + assert fetches == [None, None] + + +async def test_refresh_skips_the_read_and_stores_the_refetched_result() -> None: + server, fetches = _varying_tools_server() + + async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client: + assert _tool_names(await client.list_tools()) == ["t0"] + assert _tool_names(await client.list_tools(cache_mode="refresh")) == ["t1"] + assert _tool_names(await client.list_tools()) == ["t1"] + + assert fetches == [None, None] + + +async def test_refresh_storing_a_ttl_zero_result_purges_the_warm_entry() -> None: + """An uncacheable refetch still supersedes the warm entry.""" + fetches: list[str | None] = [] + + async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + fetches.append(params.cursor if params is not None else None) + ttl_ms = 60_000 if len(fetches) == 1 else 0 + tool = Tool(name=f"t{len(fetches) - 1}", input_schema={"type": "object"}) + return ListToolsResult(tools=[tool], ttl_ms=ttl_ms) + + server = Server("flip", on_list_tools=list_tools) + + async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client: + assert _tool_names(await client.list_tools()) == ["t0"] + assert _tool_names(await client.list_tools(cache_mode="refresh")) == ["t1"] + assert _tool_names(await client.list_tools()) == ["t2"] # t0 purged, t1 (ttl 0) never stored + + assert fetches == [None, None, None] + + +async def test_a_list_call_carrying_meta_is_fetched_and_replaces_the_warm_entry() -> None: + """SDK-defined: `meta` (a progress token, tracing fields) expects a wire request, + so under the default "use" the call behaves as a refresh.""" + server, fetches = _varying_tools_server() + + async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client: + assert _tool_names(await client.list_tools()) == ["t0"] + assert _tool_names(await client.list_tools()) == ["t0"] # warm, meta-less: served + assert _tool_names(await client.list_tools(meta={"progress_token": "tok"})) == ["t1"] # meta: fetched + assert _tool_names(await client.list_tools()) == ["t1"] # the fresh result replaced the entry + + assert fetches == [None, None] + + +async def test_a_read_resource_carrying_meta_is_fetched_and_replaces_the_warm_entry() -> None: + reads: list[str] = [] + + async def read(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult: + reads.append(params.uri) + return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=f"v{len(reads)}")], ttl_ms=60_000) + + server = Server("versioned-reads", on_read_resource=read) + + def text(result: ReadResourceResult) -> str: + content = result.contents[0] + assert isinstance(content, TextResourceContents) + return content.text + + async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client: + assert text(await client.read_resource("memo://a")) == "v1" + assert text(await client.read_resource("memo://a")) == "v1" # warm, meta-less: served + assert text(await client.read_resource("memo://a", meta={"progress_token": "tok"})) == "v2" # meta: fetched + assert text(await client.read_resource("memo://a")) == "v2" # the fresh result replaced the entry + + assert reads == ["memo://a", "memo://a"] + + +async def test_cache_mode_is_inert_when_caching_is_disabled() -> None: + server, fetches = _varying_tools_server() + + async with Client(server, cache=False) as client: + await client.list_tools() + await client.list_tools(cache_mode="use") + await client.list_tools(cache_mode="refresh") + + assert fetches == [None, None, None] + + +@pytest.mark.parametrize( + "seed", + [{"request_state": "round-2"}, {"input_responses": {"ask": ElicitResult(action="decline")}}], + ids=["request_state", "input_responses"], +) +async def test_a_seeded_read_resource_skips_the_cache_and_ignores_cache_mode(seed: dict[str, Any]) -> None: + """Spec MUST: results of requests carrying `inputResponses` or `requestState` are never cached.""" + reads = 0 + + async def read(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult: + nonlocal reads + reads += 1 + return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=f"v{reads}")], ttl_ms=60_000) + + server = Server("res", on_read_resource=read) + + def text(result: ReadResourceResult) -> str: + content = result.contents[0] + assert isinstance(content, TextResourceContents) + return content.text + + async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client: + assert text(await client.read_resource("memo://a")) == "v1" + assert text(await client.read_resource("memo://a", **seed)) == "v2" + assert text(await client.read_resource("memo://a", **seed, cache_mode="refresh")) == "v3" + assert text(await client.read_resource("memo://a")) == "v1" # nothing read, written, or purged + + assert reads == 3 + + +async def test_a_terminal_read_reached_through_driver_rounds_is_never_cached() -> None: + """Spec MUST: the driver's retry rounds carry `inputResponses`, so their terminal result is not cached.""" + seeded_rounds: list[bool] = [] + ask = ElicitRequest( + params=ElicitRequestFormParams( + message="What is your name?", + requested_schema={"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}, + ) + ) + + async def read( + ctx: ServerRequestContext, params: types.ReadResourceRequestParams + ) -> ReadResourceResult | InputRequiredResult: + seeded_rounds.append(params.input_responses is not None) + if params.input_responses is not None: + return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text="terminal")], ttl_ms=60_000) + return InputRequiredResult(input_requests={"ask": ask}) + + async def elicitation_callback( + context: Any, params: types.ElicitRequestParams + ) -> types.ElicitResult | types.ErrorData: + return ElicitResult(action="accept", content={"name": "Ada"}) + + server = Server("gated", on_read_resource=read) + + with anyio.fail_after(5): + async with Client( + server, elicitation_callback=elicitation_callback, cache=CacheConfig(clock=_ManualClock()) + ) as client: + first = await client.read_resource("memo://gated") + second = await client.read_resource("memo://gated") + + assert isinstance(first.contents[0], TextResourceContents) and first.contents[0].text == "terminal" + assert second == first + assert seeded_rounds == [False, True, False, True] # two wire rounds per call: never served + + +async def test_a_refresh_that_resolves_to_input_required_purges_the_warm_entry() -> None: + """The refresh cannot store its driven terminal result (the rounds carry + `inputResponses`, a spec MUST), but it still purges the warm entry.""" + reads = 0 + ask = ElicitRequest( + params=ElicitRequestFormParams( + message="What is your name?", + requested_schema={"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}, + ) + ) + + async def read( + ctx: ServerRequestContext, params: types.ReadResourceRequestParams + ) -> ReadResourceResult | InputRequiredResult: + nonlocal reads + reads += 1 + # Starts plain, then flips to requiring input. + if reads > 1 and params.input_responses is None: + return InputRequiredResult(input_requests={"ask": ask}) + return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=f"v{reads}")], ttl_ms=60_000) + + async def elicitation_callback( + context: Any, params: types.ElicitRequestParams + ) -> types.ElicitResult | types.ErrorData: + return ElicitResult(action="accept", content={"name": "Ada"}) + + server = Server("flipping", on_read_resource=read) + + def text(result: ReadResourceResult) -> str: + content = result.contents[0] + assert isinstance(content, TextResourceContents) + return content.text + + with anyio.fail_after(5): + async with Client( + server, elicitation_callback=elicitation_callback, cache=CacheConfig(clock=_ManualClock()) + ) as client: + assert text(await client.read_resource("memo://a")) == "v1" # cached for 60s + assert text(await client.read_resource("memo://a", cache_mode="refresh")) == "v3" + # v1 purged and v3 never stored: the plain read drives fresh rounds. + assert text(await client.read_resource("memo://a")) == "v5" + + assert reads == 5 + + +def _output_schema_server(call_result: CallToolResult) -> tuple[Server[Any], list[str | None]]: + """One tool declaring an output schema; `call_tool` returns the canned `call_result`.""" + fetches: list[str | None] = [] + tool = Tool( + name="run", + input_schema={"type": "object"}, + output_schema={"type": "object", "properties": {"n": {"type": "integer"}}, "required": ["n"]}, + ) + + async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + fetches.append(params.cursor if params is not None else None) + return ListToolsResult(tools=[tool]) + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + assert params.name == "run" + return call_result + + server = Server( + "schemas", + on_list_tools=list_tools, + on_call_tool=call_tool, + cache_hints={"tools/list": CacheHint(ttl_ms=60_000)}, + ) + return server, fetches + + +async def test_a_listing_served_from_a_shared_store_rebuilds_output_schemas() -> None: + """A served listing is absorbed into the session: output validation works without a wire fetch.""" + call_result = CallToolResult(content=[TextContent(text="ok")], structured_content={"n": 1}) + server, fetches = _output_schema_server(call_result) + config = CacheConfig(store=InMemoryResponseCacheStore(), partition="p", target_id="svc", clock=_ManualClock()) + + async with Client(server, cache=config) as warming: + listing = await warming.list_tools() + + async with Client(server, cache=config) as fresh: + assert await fresh.list_tools() == listing # served from the shared store + result = await fresh.call_tool("run", {}) + + assert result.structured_content == {"n": 1} + # A starved schema cache would have re-listed here. + assert fetches == [None] + + +async def test_validation_from_a_served_listing_rejects_missing_structured_content() -> None: + """The schema absorbed from a served listing is enforced, not just present.""" + server, fetches = _output_schema_server(CallToolResult(content=[TextContent(text="ok")])) + config = CacheConfig(store=InMemoryResponseCacheStore(), partition="p", target_id="svc", clock=_ManualClock()) + + async with Client(server, cache=config) as warming: + await warming.list_tools() + + async with Client(server, cache=config) as fresh: + await fresh.list_tools() + with pytest.raises(RuntimeError) as exc_info: + await fresh.call_tool("run", {}) + + assert str(exc_info.value) == snapshot("Tool run has an output schema but did not return structured content") + assert fetches == [None] + + +async def test_a_cache_hit_listing_still_mirrors_x_mcp_headers_on_tools_call() -> None: + """The arg-to-header maps are rebuilt from a served listing. Asserted at the wire + because the client never surfaces outgoing headers.""" + tool = Tool( + name="run", + input_schema={"type": "object", "properties": {"region": {"type": "string", "x-mcp-header": "Region"}}}, + ) + + async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[tool], ttl_ms=60_000) + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + assert params.name == "run" + return CallToolResult(content=[TextContent(text="ok")]) + + server = Server("headers", on_list_tools=list_tools, on_call_tool=call_tool) + + posts: list[httpx.Request] = [] + + async def on_request(request: httpx.Request) -> None: + posts.append(request) + + config = CacheConfig(store=InMemoryResponseCacheStore(), partition="p", target_id="svc") + discover = DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + server_info=Implementation(name="srv", version="0"), + ) + + with anyio.fail_after(5): + async with mounted_app(server, on_request=on_request) as (http, _): + warming = Client( + streamable_http_client(f"{BASE_URL}/mcp", http_client=http), + mode=LATEST_MODERN_VERSION, + prior_discover=discover, + cache=config, + ) + async with warming: + await warming.list_tools() + fresh = Client( + streamable_http_client(f"{BASE_URL}/mcp", http_client=http), + mode=LATEST_MODERN_VERSION, + prior_discover=discover, + cache=config, + ) + async with fresh: + await fresh.list_tools() + await fresh.call_tool("run", {"region": "us-west1"}) + + # One tools/list on the wire: the fresh client served from the store. + assert [json.loads(request.content)["method"] for request in posts] == ["tools/list", "tools/call"] + assert posts[-1].headers["mcp-param-region"] == "us-west1" + + +async def test_a_shared_store_hit_prunes_a_header_map_the_writers_filter_dropped() -> None: + """Cached listings are post-filter: when another client's refresh wrote a listing whose + filter dropped tool `x` (its annotation went invalid), a hit on that entry must prune the + reader's stale arg-to-header map, or it would keep emitting Mcp-Param-* headers for `x`.""" + valid = {"type": "object", "properties": {"region": {"type": "string", "x-mcp-header": "Region"}}} + invalid = {"type": "object", "properties": {"region": {"type": "string", "x-mcp-header": "bad name"}}} + schema = valid + + async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[Tool(name="x", input_schema=schema)]) + + server = Server("filtering", on_list_tools=list_tools, cache_hints={"tools/list": CacheHint(ttl_ms=60_000)}) + config = CacheConfig(store=InMemoryResponseCacheStore(), partition="p", target_id="svc", clock=_ManualClock()) + + with anyio.fail_after(5): + async with Client(server, cache=config) as reader, Client(server, cache=config) as writer: + await reader.list_tools() # fetches while `x` is valid; the reader holds its header map + assert "x" in reader.session._x_mcp_header_maps + + schema = invalid + await writer.list_tools(cache_mode="refresh") # the writer's filter drops `x`; the entry is replaced + + served = await reader.list_tools() # hit on the writer's entry + assert served.tools == [] + assert "x" not in reader.session._x_mcp_header_maps + + +async def test_a_tools_list_changed_notification_makes_the_next_list_refetch() -> None: + """Spec SHOULD: list_changed invalidates the cached listing. Legacy session + + `default_ttl_ms` entry: eviction is era-independent.""" + fetches: list[str | None] = [] + + async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + fetches.append(params.cursor if params is not None else None) + return ListToolsResult(tools=[Tool(name="touch", input_schema={"type": "object"})]) + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + assert params.name == "touch" + await ctx.session.send_tool_list_changed() + return CallToolResult(content=[TextContent(text="ok")]) + + server = Server("notify", on_list_tools=list_tools, on_call_tool=call_tool) + + # The wrap evicts before delegating: delivery implies eviction completed. + delivered = anyio.Event() + + async def on_message(message: IncomingMessage) -> None: + assert isinstance(message, ToolListChangedNotification) # the only message this server emits + delivered.set() + + client = Client(server, mode="legacy", cache=CacheConfig(default_ttl_ms=60_000), message_handler=on_message) + async with client: + await client.list_tools() + await client.list_tools() + assert fetches == [None] # cached via default_ttl_ms + await client.call_tool("touch", {}) + with anyio.fail_after(5): + await delivered.wait() + await client.list_tools() + + assert fetches == [None, None] + + +async def test_a_resource_updated_notification_evicts_that_uris_read_entry() -> None: + """Spec SHOULD: resources/updated invalidates the cached read for its uri, + and the notification's `params.uri` must match the stored key's uri form.""" + uri = "memo://cached" + reads: list[str] = [] + + async def read(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult: + reads.append(params.uri) + return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=f"v{len(reads)}")]) + + async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[Tool(name="poke", input_schema={"type": "object"})]) + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + assert params.name == "poke" + await ctx.session.send_resource_updated(uri) + return CallToolResult(content=[TextContent(text="ok")]) + + server = Server("updates", on_read_resource=read, on_list_tools=list_tools, on_call_tool=call_tool) + + delivered: list[str] = [] + seen = anyio.Event() + + async def on_message(message: IncomingMessage) -> None: + assert isinstance(message, ResourceUpdatedNotification) # the only message this server emits + delivered.append(message.params.uri) + seen.set() + + client = Client(server, mode="legacy", cache=CacheConfig(default_ttl_ms=60_000), message_handler=on_message) + async with client: + await client.read_resource(uri) + await client.read_resource(uri) + assert reads == [uri] # cached via default_ttl_ms + await client.call_tool("poke", {}) + with anyio.fail_after(5): + await seen.wait() + await client.read_resource(uri) + + assert delivered == [uri] # the exact string the entry was stored under + assert reads == [uri, uri] + + +async def test_the_modern_in_process_path_drops_the_eviction_notification() -> None: + """Pins the documented gap: the default in-process path (DirectDispatcher) drops + standalone notifications, so the warm entry survives. If this starts failing the + path gained delivery: flip the `docs/advanced/caching.md` caveat and the legacy-mode tests.""" + fetches: list[str | None] = [] + + async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + fetches.append(params.cursor if params is not None else None) + return ListToolsResult(tools=[Tool(name="touch", input_schema={"type": "object"})]) + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + assert params.name == "touch" + await ctx.session.send_tool_list_changed() + return CallToolResult(content=[TextContent(text="ok")]) + + server = Server( + "notify", + on_list_tools=list_tools, + on_call_tool=call_tool, + cache_hints={"tools/list": CacheHint(ttl_ms=60_000)}, + ) + + async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client: + await client.list_tools() + await client.call_tool("touch", {}) + await client.list_tools() # still served from the warm entry: no eviction arrived + + assert fetches == [None] + + +async def test_a_discover_result_never_enters_the_response_cache() -> None: + """SDK ruling (documented): the cache covers the five verbs only; a persisted + `prior_discover`'s freshness is the user's bookkeeping.""" + server = Server("hinted", cache_hints={"server/discover": CacheHint(ttl_ms=60_000)}) + + async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client: + discover = client.session.discover_result + assert discover is not None + assert discover.ttl_ms == 60_000 # the hint arrived with the probe result... + store = _coordinator(client)._store + assert isinstance(store, InMemoryResponseCacheStore) + assert store._entries == {} # ...and nothing entered the cache + + +# --- The inbound ttlMs clamp (parse seam) --- + + +@pytest.mark.parametrize("wire_ttl", [-5, -5.0]) +async def test_a_negative_inbound_ttl_is_served_as_zero_and_never_cached(wire_ttl: int | float) -> None: + """Spec SHOULD: a negative `ttlMs` is treated as 0, not a wire-validation failure. + Scripted peer: an SDK server enforces `ge=0` and cannot emit one.""" + listings_served = 0 + + async def scripted_server(streams: MessageStream) -> None: + nonlocal listings_served + server_read, server_write = streams + async for message in server_read: + assert isinstance(message, SessionMessage) + frame = message.message + assert isinstance(frame, types.JSONRPCRequest) + if frame.method == "server/discover": + result: dict[str, Any] = { + "supportedVersions": [LATEST_MODERN_VERSION], + "capabilities": {}, + "serverInfo": {"name": "negative-ttl", "version": "0.0.1"}, + "resultType": "complete", + "ttlMs": 0, + } + else: + assert frame.method == "tools/list" + listings_served += 1 + result = {"resultType": "complete", "tools": [], "ttlMs": wire_ttl, "cacheScope": "private"} + await server_write.send(SessionMessage(types.JSONRPCResponse(jsonrpc="2.0", id=frame.id, result=result))) + + @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: + first = await client.list_tools() + second = await client.list_tools() + + assert first.ttl_ms == 0 + assert second.ttl_ms == 0 + assert listings_served == 2 # the clamped-to-zero ttl was never stored + + +@pytest.mark.parametrize("wire_ttl", [-5, -5.0]) +async def test_a_negative_discover_ttl_still_connects_modern_in_auto_mode(wire_ttl: int | float) -> None: + """Regression: pre-clamp, a negative discover `ttlMs` failed validation inside the + mode="auto" probe and silently downgraded to the legacy handshake.""" + 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) + methods_seen.append(frame.method) + # A legacy downgrade would send `initialize`; fail loudly instead. + assert frame.method == "server/discover" + result: dict[str, Any] = { + "supportedVersions": [LATEST_MODERN_VERSION], + "capabilities": {}, + "serverInfo": {"name": "negative-ttl", "version": "0.0.1"}, + "resultType": "complete", + "ttlMs": wire_ttl, + } + await server_write.send(SessionMessage(types.JSONRPCResponse(jsonrpc="2.0", id=frame.id, result=result))) + + @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_MODERN_VERSION + discover = client.session.discover_result + assert discover is not None + assert discover.ttl_ms == 0 + + assert methods_seen == ["server/discover"] + + +# --- Hardening e2e --- + + +def _versioned_read_server(*, ttl_ms: int = 60_000) -> tuple[Server[Any], list[str]]: + """Server whose every read returns a distinct payload `v`, + so a served entry is distinguishable from a refetch.""" + reads: list[str] = [] + + async def read(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult: + reads.append(params.uri) + return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text=f"v{len(reads)}")], ttl_ms=ttl_ms) + + return Server("versioned-reads", on_read_resource=read), reads + + +def _resource_text(result: ReadResourceResult) -> str: + content = result.contents[0] + assert isinstance(content, TextResourceContents) + return content.text + + +async def test_each_notification_evicts_exactly_its_entries_end_to_end() -> None: + """Spec SHOULD (notifications invalidate) plus its negative space: each notification + refetches exactly its own entries, and resources/list_changed also covers templates.""" + uri_x, uri_y = "memo://x", "memo://y" + fetched: list[str] = [] + + async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + fetched.append("tools/list") + return ListToolsResult(tools=[Tool(name="notify", input_schema={"type": "object"})]) + + async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListPromptsResult: + fetched.append("prompts/list") + return ListPromptsResult(prompts=[]) + + async def list_resources( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> ListResourcesResult: + fetched.append("resources/list") + return ListResourcesResult(resources=[]) + + async def list_templates( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> ListResourceTemplatesResult: + fetched.append("resources/templates/list") + return ListResourceTemplatesResult(resource_templates=[]) + + async def read(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult: + fetched.append(f"resources/read {params.uri}") + return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text="body")]) + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + assert params.name == "notify" + kind = (params.arguments or {})["kind"] + if kind == "tools": + await ctx.session.send_tool_list_changed() + elif kind == "resources": + await ctx.session.send_resource_list_changed() + else: + assert kind == "updated-x" + await ctx.session.send_resource_updated(uri_x) + return CallToolResult(content=[TextContent(text="sent")]) + + server = Server( + "notifier", + on_list_tools=list_tools, + on_list_prompts=list_prompts, + on_list_resources=list_resources, + on_list_resource_templates=list_templates, + on_read_resource=read, + on_call_tool=call_tool, + ) + + delivered: list[IncomingMessage] = [] + eviction_done = [anyio.Event() for _ in range(3)] + + async def on_message(message: IncomingMessage) -> None: + # The wrap evicts before delegating: each event implies its eviction completed. + delivered.append(message) + eviction_done[len(delivered) - 1].set() + + client = Client( + server, + mode="legacy", + cache=CacheConfig(default_ttl_ms=60_000, clock=_ManualClock()), + message_handler=on_message, + ) + + async with client: + + async def served_round() -> list[str]: + """Call every cacheable verb once; return the calls that reached the server.""" + before = len(fetched) + await client.list_tools() + await client.list_prompts() + await client.list_resources() + await client.list_resource_templates() + await client.read_resource(uri_x) + await client.read_resource(uri_y) + return fetched[before:] + + assert await served_round() == [ + "tools/list", + "prompts/list", + "resources/list", + "resources/templates/list", + f"resources/read {uri_x}", + f"resources/read {uri_y}", + ] + assert await served_round() == [] # everything primed and served + + await client.call_tool("notify", {"kind": "tools"}) + with anyio.fail_after(5): + await eviction_done[0].wait() + assert await served_round() == ["tools/list"] + + await client.call_tool("notify", {"kind": "resources"}) + with anyio.fail_after(5): + await eviction_done[1].wait() + assert await served_round() == ["resources/list", "resources/templates/list"] + + await client.call_tool("notify", {"kind": "updated-x"}) + with anyio.fail_after(5): + await eviction_done[2].wait() + assert await served_round() == [f"resources/read {uri_x}"] + + assert delivered == [ + ToolListChangedNotification(), + ResourceListChangedNotification(), + ResourceUpdatedNotification(params=ResourceUpdatedNotificationParams(uri=uri_x)), + ] + + +async def test_private_entries_never_cross_partitions_between_clients_sharing_a_store() -> None: + """Spec MUST: "private" never crosses authorization contexts.""" + server, fetches = _varying_tools_server() + store = InMemoryResponseCacheStore() + + def config(partition: str) -> CacheConfig: + return CacheConfig(store=store, partition=partition, target_id="svc", clock=_ManualClock()) + + async with Client(server, cache=config("tenant-a")) as tenant_a: + assert _tool_names(await tenant_a.list_tools()) == ["t0"] + async with Client(server, cache=config("tenant-b")) as tenant_b: + assert _tool_names(await tenant_b.list_tools()) == ["t1"] # fetched, not tenant-a's entry + + assert fetches == [None, None] + + +async def test_a_server_stamped_public_entry_does_not_cross_partitions_by_default() -> None: + """SDK security default (deviates from the ts SDK): the public arm is still keyed by partition.""" + server, fetches = _varying_tools_server(scope="public") + store = InMemoryResponseCacheStore() + + def config(partition: str) -> CacheConfig: + return CacheConfig(store=store, partition=partition, target_id="svc", clock=_ManualClock()) + + async with Client(server, cache=config("tenant-a")) as tenant_a: + assert _tool_names(await tenant_a.list_tools()) == ["t0"] + async with Client(server, cache=config("tenant-a")) as same_partition: + assert _tool_names(await same_partition.list_tools()) == ["t0"] # served from the store + async with Client(server, cache=config("tenant-b")) as tenant_b: + assert _tool_names(await tenant_b.list_tools()) == ["t1"] # fetched + + assert fetches == [None, None] + + +async def test_share_public_serves_a_server_stamped_public_entry_across_partitions() -> None: + """With `share_public=True` the public arm drops the partition.""" + server, fetches = _varying_tools_server(scope="public") + store = InMemoryResponseCacheStore() + + def config(partition: str) -> CacheConfig: + return CacheConfig(store=store, partition=partition, target_id="svc", share_public=True, clock=_ManualClock()) + + async with Client(server, cache=config("tenant-a")) as tenant_a: + assert _tool_names(await tenant_a.list_tools()) == ["t0"] + async with Client(server, cache=config("tenant-b")) as tenant_b: + assert _tool_names(await tenant_b.list_tools()) == ["t0"] # served across partitions + + assert fetches == [None] + + +async def test_same_partition_clients_share_read_entries_through_the_store() -> None: + server, reads = _versioned_read_server() + store = InMemoryResponseCacheStore() + + def config() -> CacheConfig: + return CacheConfig(store=store, partition="p", target_id="svc", clock=_ManualClock()) + + async with Client(server, cache=config()) as first: + first_result = await first.read_resource("memo://a") + async with Client(server, cache=config()) as second: + assert await second.read_resource("memo://a") == first_result + + assert reads == ["memo://a"] + + +async def test_mutating_returned_results_never_corrupts_the_cached_entry() -> None: + """Deep-copy isolation in both directions: write-side (the fetched result) and + serve-side (the served hit).""" + server, fetches = _varying_tools_server() + + async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client: + first = await client.list_tools() + first.tools[0].name = "tampered-after-fetch" + second = await client.list_tools() # cache hit, unaffected by the mutation + assert _tool_names(second) == ["t0"] + second.tools[0].name = "tampered-after-serve" + assert _tool_names(await client.list_tools()) == ["t0"] # still pristine + + assert fetches == [None] + + +async def test_a_cache_hit_still_yields_to_the_event_loop() -> None: + """A hit completes without a wire await, so the verb checkpoints explicitly: a poll + loop over a fresh entry would otherwise starve spawned tasks (eviction dispatch). + Pinned by calling a warm verb inside an already-cancelled scope: only a yield can + observe the cancellation.""" + server, fetches = _varying_tools_server() + + async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client: + assert _tool_names(await client.list_tools()) == ["t0"] # warm the entry + with anyio.CancelScope() as scope: + scope.cancel() + await client.list_tools() # would be a hit; must yield and observe the cancellation + assert scope.cancelled_caught + + assert fetches == [None] # the cancelled call neither fetched nor served + + +async def test_a_legacy_peer_injecting_cache_hints_caches_nothing() -> None: + """Era gate: hint keys a 2025 peer puts on the wire cache nothing. Scripted peer: + an SDK server strips the hint fields when serializing for a 2025 session.""" + listings_served = 0 + + async def scripted_server(streams: MessageStream) -> None: + nonlocal listings_served + server_read, server_write = streams + async for message in server_read: + assert isinstance(message, SessionMessage) + frame = message.message + if isinstance(frame, types.JSONRPCNotification): + assert frame.method == "notifications/initialized" + continue + assert isinstance(frame, types.JSONRPCRequest) + if frame.method == "initialize": + result: dict[str, Any] = { + "protocolVersion": "2025-11-25", + "capabilities": {}, + "serverInfo": {"name": "legacy-injector", "version": "0.0.1"}, + } + else: + assert frame.method == "tools/list" + listings_served += 1 + result = {"tools": [], "ttlMs": 60_000, "cacheScope": "public"} + await server_write.send(SessionMessage(types.JSONRPCResponse(jsonrpc="2.0", id=frame.id, result=result))) + + @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="legacy", cache=CacheConfig(clock=_ManualClock())) as client: + await client.list_tools() + await client.list_tools() + store = _coordinator(client)._store + assert isinstance(store, InMemoryResponseCacheStore) + assert store._entries == {} # neither arm holds an entry + + assert listings_served == 2 + + +class _CancelOnSetStore(InMemoryResponseCacheStore): + """Store whose next `set` awaits a one-shot hook before committing.""" + + def __init__(self) -> None: + super().__init__() + self.before_set: Callable[[], Awaitable[None]] | None = None + + async def set(self, key: CacheKey, entry: CacheEntry) -> None: + if self.before_set is not None: + hook, self.before_set = self.before_set, None + await hook() + await super().set(key, entry) + + +async def test_a_verb_cancelled_mid_write_leaves_no_stale_arm_pair() -> None: + """No-stale-pair invariant: a cancellation between the opposite-arm delete and the + `set` commit leaves at most one entry per key, so the superseded entry cannot be served.""" + fetches: list[str | None] = [] + + async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + fetches.append(params.cursor if params is not None else None) + scope: Literal["public", "private"] = "public" if len(fetches) == 1 else "private" + tool = Tool(name=f"t{len(fetches) - 1}", input_schema={"type": "object"}) + return ListToolsResult(tools=[tool], ttl_ms=60_000, cache_scope=scope) + + server = Server("scope-flip", on_list_tools=list_tools) + store = _CancelOnSetStore() + client = Client(server, cache=CacheConfig(store=store, partition="p", target_id="svc", clock=_ManualClock())) + + async with client: + assert _tool_names(await client.list_tools()) == ["t0"] + assert len(store._entries) == 1 # the public-arm entry + + with anyio.CancelScope() as scope: + + async def cancel_mid_commit() -> None: + scope.cancel() + await anyio.lowlevel.checkpoint() # the cancellation is delivered here, inside `set` + + store.before_set = cancel_mid_commit + await client.list_tools(cache_mode="refresh") + assert scope.cancelled_caught + + # The opposite (public) arm was deleted before the cancelled set could commit. + assert store._entries == {} + assert _tool_names(await client.list_tools()) == ["t2"] # nothing cached: refetched + + assert fetches == [None, None, None] + + +async def test_an_eviction_landing_mid_fetch_discards_that_fetchs_write() -> None: + """Spec-aligned race rule: an eviction landing mid-fetch discards that fetch's write. + The server waits for the client-side eviction before responding, so the interleaving + is deterministic, not scheduler-dependent.""" + fetches: list[str | None] = [] + evicted = anyio.Event() + + async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + fetches.append(params.cursor if params is not None else None) + if len(fetches) == 1: + await ctx.session.send_tool_list_changed() + with anyio.fail_after(5): + await evicted.wait() + return ListToolsResult(tools=[Tool(name=f"t{len(fetches) - 1}", input_schema={"type": "object"})]) + + async def on_message(message: IncomingMessage) -> None: + assert isinstance(message, ToolListChangedNotification) # the only message this server emits + evicted.set() + + server = Server("racer", on_list_tools=list_tools) + client = Client( + server, + mode="legacy", + cache=CacheConfig(default_ttl_ms=60_000, clock=_ManualClock()), + message_handler=on_message, + ) + + async with client: + assert _tool_names(await client.list_tools()) == ["t0"] + # Empty proves the write was skipped, not stored-then-evicted: the eviction + # completed strictly before the response, the write strictly after. + store = _coordinator(client)._store + assert isinstance(store, InMemoryResponseCacheStore) + assert store._entries == {} + assert _tool_names(await client.list_tools()) == ["t1"] # refetched... + assert _tool_names(await client.list_tools()) == ["t1"] # ...and that fetch cached normally + + assert fetches == [None, None] + + +async def test_read_resource_bypass_neither_serves_nor_disturbs_a_warm_entry() -> None: + server, reads = _versioned_read_server() + + async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client: + assert _resource_text(await client.read_resource("memo://a")) == "v1" + assert _resource_text(await client.read_resource("memo://a", cache_mode="bypass")) == "v2" + assert _resource_text(await client.read_resource("memo://a")) == "v1" # warm entry intact + + assert reads == ["memo://a", "memo://a"] + + +async def test_read_resource_refresh_refetches_and_restores() -> None: + server, reads = _versioned_read_server() + + async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client: + assert _resource_text(await client.read_resource("memo://a")) == "v1" + assert _resource_text(await client.read_resource("memo://a", cache_mode="refresh")) == "v2" + assert _resource_text(await client.read_resource("memo://a")) == "v2" # the refreshed value re-stored + + assert reads == ["memo://a", "memo://a"] + + +async def test_a_closed_client_raises_on_every_cacheable_verb_instead_of_serving_the_cache() -> None: + """Cache participation requires a live session.""" + fetched: list[str] = [] + + async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + fetched.append("tools/list") + return ListToolsResult(tools=[]) + + async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListPromptsResult: + fetched.append("prompts/list") + return ListPromptsResult(prompts=[]) + + async def list_resources( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> ListResourcesResult: + fetched.append("resources/list") + return ListResourcesResult(resources=[]) + + async def list_templates( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> ListResourceTemplatesResult: + fetched.append("resources/templates/list") + return ListResourceTemplatesResult(resource_templates=[]) + + async def read(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult: + fetched.append(f"resources/read {params.uri}") + return ReadResourceResult(contents=[TextResourceContents(uri=params.uri, text="body")]) + + hint = CacheHint(ttl_ms=60_000) + server = Server( + "warm", + on_list_tools=list_tools, + on_list_prompts=list_prompts, + on_list_resources=list_resources, + on_list_resource_templates=list_templates, + on_read_resource=read, + cache_hints={ + "tools/list": hint, + "prompts/list": hint, + "resources/list": hint, + "resources/templates/list": hint, + "resources/read": hint, + }, + ) + + client = Client(server, cache=CacheConfig(clock=_ManualClock())) + async with client: + await client.list_tools() + await client.list_prompts() + await client.list_resources() + await client.list_resource_templates() + await client.read_resource("memo://a") + # A repeat round is served entirely from the warm entries. + await client.list_tools() + await client.read_resource("memo://a") + assert len(fetched) == 5 + + with pytest.raises(RuntimeError) as exc_info: + await client.list_tools() + assert str(exc_info.value) == snapshot("Client must be used within an async context manager") + with pytest.raises(RuntimeError): + await client.list_prompts() + with pytest.raises(RuntimeError): + await client.list_resources() + with pytest.raises(RuntimeError): + await client.list_resource_templates() + with pytest.raises(RuntimeError): + await client.read_resource("memo://a") + + assert len(fetched) == 5 # nothing was served from the cache and nothing reached the server diff --git a/tests/client/test_session.py b/tests/client/test_session.py index 83893e36f9..f76991f65d 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -1661,6 +1661,33 @@ async def test_discover_reraises_unsupported_version_with_malformed_error_data() assert [m for m, _ in dispatcher.calls] == ["server/discover"] +# --- inbound ttlMs clamp --- + + +@pytest.mark.anyio +async def test_a_positive_inbound_ttl_reaches_the_result_unchanged() -> None: + listing: dict[str, Any] = {"resultType": "complete", "tools": [], "ttlMs": 60_000, "cacheScope": "private"} + dispatcher = _ScriptedDispatcher(_discover_result_dict(), listing) + with anyio.fail_after(5): + async with ClientSession(dispatcher=dispatcher) as session: + await session.discover() + result = await session.list_tools() + assert result.ttl_ms == 60_000 + + +@pytest.mark.anyio +@pytest.mark.parametrize("wire_ttl", [True, False]) +async def test_a_boolean_inbound_ttl_is_not_clamped_only_coerced_by_validation(wire_ttl: bool) -> None: + """SDK-defined: `bool` is an `int` subclass; the clamp skips it and pydantic's lax mode coerces it instead.""" + listing: dict[str, Any] = {"resultType": "complete", "tools": [], "ttlMs": wire_ttl, "cacheScope": "private"} + dispatcher = _ScriptedDispatcher(_discover_result_dict(), listing) + with anyio.fail_after(5): + async with ClientSession(dispatcher=dispatcher) as session: + await session.discover() + result = await session.list_tools() + assert result.ttl_ms == int(wire_ttl) + + @pytest.mark.anyio async def test_session_call_tool_returns_input_required_result_when_opted_in() -> None: """`ClientSession.call_tool(..., allow_input_required=True)` surfaces the diff --git a/tests/docs_src/test_caching.py b/tests/docs_src/test_caching.py index bc2feb9ac0..58014879c7 100644 --- a/tests/docs_src/test_caching.py +++ b/tests/docs_src/test_caching.py @@ -1,13 +1,19 @@ """`docs/advanced/caching.md`: every claim the page makes, proved against the real SDK.""" +from collections.abc import Mapping from typing import Any, cast +import anyio import pytest from inline_snapshot import snapshot +from mcp_types import INTERNAL_ERROR, ListToolsResult, PaginatedRequestParams, Tool from docs_src.caching import tutorial001, tutorial002, tutorial003 -from mcp import Client -from mcp.server import CacheHint, MCPServer +from mcp import Client, MCPError +from mcp.client import CacheConfig +from mcp.client.caching import InMemoryResponseCacheStore +from mcp.server import CacheHint, MCPServer, Server, ServerRequestContext +from mcp.server.caching import CacheableMethod # See test_index.py for why this is a per-module mark and not a conftest hook. pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] @@ -42,7 +48,7 @@ async def test_a_non_cacheable_method_is_rejected_at_construction() -> None: with pytest.raises(ValueError) as exc: MCPServer("Weather", cache_hints=cast(Any, {"tools/call": CacheHint(ttl_ms=1_000)})) assert str(exc.value) == snapshot( - "cache_hints keys must be cacheable methods (see CacheableMethod); got: tools/call" + "cache_hints keys must be cacheable methods (see CacheableMethod); got: 'tools/call'" ) @@ -55,16 +61,149 @@ async def test_the_handler_value_wins_over_the_map_per_field() -> None: assert tools.cache_scope == "public" -async def test_the_client_program_on_the_page_reads_the_hints(capsys: pytest.CaptureFixture[str]) -> None: - """tutorial003: `main()` is the literal client program on the page - the hints - arrive as parsed fields on the result.""" +async def test_the_client_program_on_the_page_makes_three_fetches_for_four_calls( + capsys: pytest.CaptureFixture[str], +) -> None: + """tutorial003: a cache hit, an expiry, and `cache_mode="refresh"` make four calls cost three fetches.""" await tutorial003.main() - assert capsys.readouterr().out == "1 tools, fresh for 60s, scope=public\n" + assert capsys.readouterr().out == "4 calls, 3 fetches\n" + + +def _counting_tools_server(*, ttl_ms: int | None = 60_000) -> tuple[Server[Any], list[str | None]]: + """Each tools/list fetch returns a distinct tool name, so a cache hit is + payload-distinguishable from a refetch; `ttl_ms=None` sends no hints.""" + fetches: list[str | None] = [] + + async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult: + fetches.append(params.cursor if params is not None else None) + return ListToolsResult(tools=[Tool(name=f"t{len(fetches) - 1}", input_schema={"type": "object"})]) + + hints: Mapping[CacheableMethod, CacheHint] | None = None + if ttl_ms is not None: + hints = {"tools/list": CacheHint(ttl_ms=ttl_ms)} + return Server("counting", on_list_tools=list_tools, cache_hints=hints), fetches + + +async def test_caching_is_on_by_default_the_second_call_makes_no_fetch() -> None: + server, fetches = _counting_tools_server() + async with Client(server) as client: + first = await client.list_tools() + second = await client.list_tools() + assert fetches == [None] + assert second == first + + +async def test_a_hintless_result_is_not_cached_by_default() -> None: + """`default_ttl_ms` defaults to 0, so a hintless server sees its usual call-for-call traffic.""" + server, fetches = _counting_tools_server(ttl_ms=None) + async with Client(server) as client: + await client.list_tools() + await client.list_tools() + assert fetches == [None, None] + + +async def test_cache_false_makes_every_call_a_round_trip() -> None: + server, fetches = _counting_tools_server() + async with Client(server, cache=False) as client: + await client.list_tools() + await client.list_tools() + assert fetches == [None, None] + + +async def test_refresh_refetches_and_replaces_the_cached_entry() -> None: + server, fetches = _counting_tools_server() + async with Client(server) as client: + await client.list_tools() + refreshed = await client.list_tools(cache_mode="refresh") + served = await client.list_tools() + assert fetches == [None, None] + assert [tool.name for tool in refreshed.tools] == ["t1"] + assert served == refreshed + + +async def test_bypass_fetches_without_reading_or_writing_the_cache() -> None: + server, fetches = _counting_tools_server() + async with Client(server) as client: + first = await client.list_tools() + bypassed = await client.list_tools(cache_mode="bypass") + served = await client.list_tools() + assert fetches == [None, None] + assert [tool.name for tool in bypassed.tools] == ["t1"] + assert served == first + + +async def test_an_expired_entry_is_not_revived_when_the_refetch_fails() -> None: + """SDK ruling: no stale-if-error - the refetch failure propagates.""" + now = 1_000_000.0 + fetches: list[None] = [] + + async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult: + fetches.append(None) + if len(fetches) > 1: + raise MCPError(code=INTERNAL_ERROR, message="backend down") + return ListToolsResult(tools=[Tool(name="t0", input_schema={"type": "object"})]) + + server = Server("flaky", on_list_tools=list_tools, cache_hints={"tools/list": CacheHint(ttl_ms=60_000)}) + async with Client(server, cache=CacheConfig(clock=lambda: now)) as client: + await client.list_tools() + now += 60.0 # past the 60s TTL + with pytest.raises(MCPError) as exc: + await client.list_tools() + assert exc.value.code == INTERNAL_ERROR + assert len(fetches) == 2 + + +async def test_two_concurrent_identical_calls_are_two_fetches() -> None: + """SDK ruling: no coalescing. The handler barrier releases only once both + calls are inside it, so the test passes only if the fetches were concurrent.""" + both_fetching = anyio.Event() + fetches: list[None] = [] + + async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult: + fetches.append(None) + if len(fetches) == 2: + both_fetching.set() + with anyio.fail_after(5): + await both_fetching.wait() + return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})]) + + server = Server("concurrent", on_list_tools=list_tools, cache_hints={"tools/list": CacheHint(ttl_ms=60_000)}) + async with Client(server) as client: + async with anyio.create_task_group() as tg: + tg.start_soon(client.list_tools) + tg.start_soon(client.list_tools) + assert len(fetches) == 2 + + +async def test_a_session_tier_call_always_makes_the_round_trip() -> None: + """The cache lives on the `Client` verbs; `client.session` sits below it.""" + server, fetches = _counting_tools_server() + async with Client(server) as client: + await client.list_tools() + await client.session.list_tools() + assert fetches == [None, None] + + +async def test_a_custom_store_requires_a_partition() -> None: + with pytest.raises(ValueError) as exc: + CacheConfig(store=InMemoryResponseCacheStore()) + assert str(exc.value) == snapshot("a custom store requires an explicit partition") + + +async def test_a_custom_store_with_an_in_process_server_requires_target_id() -> None: + server, _ = _counting_tools_server() + with pytest.raises(ValueError) as exc: + Client(server, cache=CacheConfig(store=InMemoryResponseCacheStore(), partition="user-1")) + assert str(exc.value) == snapshot( + "a custom cache store requires CacheConfig.target_id when the server is not a URL: in-process servers " + "and Transport instances get a random per-client identity, so their entries in a shared store could " + "never be served to another client" + ) async def test_the_wire_presence_check_the_page_recommends_works() -> None: """The page's claim: `"ttl_ms" in result.model_fields_set` distinguishes a server that sent the field from one that said nothing (model defaults).""" - async with Client(tutorial003.mcp) as client: + async with Client(tutorial001.mcp) as client: tools = await client.list_tools() assert "ttl_ms" in tools.model_fields_set diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py index a8f1f53c7b..3feed4fed3 100644 --- a/tests/interaction/transports/test_hosting_http_modern.py +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -511,7 +511,8 @@ async def test_modern_client_stops_mirroring_after_a_re_list_drops_the_tool() -> bad_schema = {"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "bad name"}}} valid = Tool(name="run", input_schema=schema) invalid = Tool(name="run", input_schema=bad_schema) - listings = iter([valid, invalid]) + # Three pages: the call after the drop re-lists once because the prune also cleared `run`'s schema entry. + listings = iter([valid, invalid, invalid]) async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: return ListToolsResult(tools=[next(listings)], ttl_ms=0, cache_scope="public") diff --git a/tests/server/test_caching.py b/tests/server/test_caching.py index 46701d6599..abfcfba975 100644 --- a/tests/server/test_caching.py +++ b/tests/server/test_caching.py @@ -1,40 +1,27 @@ """`mcp.server.caching`: `CacheHint` validation, per-field fills, and the `cache_hints` constructor map reaching the wire on both server tiers.""" -from types import UnionType -from typing import Any, cast, get_args +from typing import Any, cast import pytest from inline_snapshot import snapshot from mcp_types import ( - CacheableResult, + InputRequiredResult, ListResourcesResult, ListToolsResult, PaginatedRequestParams, + ReadResourceRequestParams, Resource, Tool, - methods, ) from mcp import Client from mcp.server import CacheHint, MCPServer, Server, ServerRequestContext -from mcp.server.caching import CACHEABLE_METHODS, apply_cache_hint +from mcp.server.caching import apply_cache_hint pytestmark = pytest.mark.anyio -def test_cacheable_methods_match_the_result_models() -> None: - """Spec-mandated set (SEP-2549): `CACHEABLE_METHODS` mirrors exactly the - methods whose monolith result models mix in `CacheableResult` - if the - schema gains or loses a cacheable result, this weld breaks.""" - derived: set[str] = set() - for method, model in methods.MONOLITH_RESULTS.items(): - arms = get_args(model) if isinstance(model, UnionType) else (model,) - if any(isinstance(arm, type) and issubclass(arm, CacheableResult) for arm in arms): - derived.add(method) - assert CACHEABLE_METHODS == derived - - def test_cache_hint_defaults_match_the_conservative_model_defaults() -> None: """SDK-defined: an unconfigured hint fills the same values the result models already default to - immediately stale, not shared - so stamping it is @@ -83,7 +70,7 @@ def test_a_non_cacheable_method_in_cache_hints_is_rejected_at_server_constructio with pytest.raises(ValueError) as exc: Server("srv", cache_hints=cast(Any, {"tools/call": CacheHint()})) assert str(exc.value) == snapshot( - "cache_hints keys must be cacheable methods (see CacheableMethod); got: tools/call" + "cache_hints keys must be cacheable methods (see CacheableMethod); got: 'tools/call'" ) @@ -96,6 +83,72 @@ def test_a_non_cache_hint_value_is_rejected_at_server_construction() -> None: assert str(exc.value) == snapshot("cache_hints['tools/list'] must be a CacheHint, got dict") +def test_a_non_string_cache_hints_key_is_rejected_with_the_unknown_key_error() -> None: + """A non-string key takes the same unknown-key ValueError as a typo, not a TypeError from message formatting.""" + with pytest.raises(ValueError) as exc: + Server("srv", cache_hints=cast(Any, {42: CacheHint()})) + assert str(exc.value) == snapshot("cache_hints keys must be cacheable methods (see CacheableMethod); got: 42") + + +async def test_a_dict_returning_handler_takes_the_configured_hint() -> None: + """The stamp covers raw-dict results too - 2026-07-28 requires both fields on the wire.""" + hint = CacheHint(ttl_ms=60_000, scope="public") + + async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams) -> dict[str, Any]: + return {"tools": [], "resultType": "complete"} + + server = Server("srv", cache_hints={"tools/list": hint}) + server.add_request_handler("tools/list", PaginatedRequestParams, list_tools) + async with Client(server) as client: + result = await client.list_tools() + assert result.ttl_ms == hint.ttl_ms + assert result.cache_scope == hint.scope + + +async def test_a_dict_provided_ttl_wins_and_the_hint_fills_only_the_missing_scope() -> None: + """Dict path mirrors the model path's `model_fields_set` precedence: present wire keys win.""" + + async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams) -> dict[str, Any]: + return {"tools": [], "resultType": "complete", "ttlMs": 25} + + server = Server("srv", cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")}) + server.add_request_handler("tools/list", PaginatedRequestParams, list_tools) + async with Client(server) as client: + result = await client.list_tools() + assert result.ttl_ms == 25 + assert result.cache_scope == "public" + + +async def test_a_dict_returning_handler_leaks_no_hint_fields_to_a_2025_session() -> None: + """The stamp runs version-independently; the 2025 serialize sieve strips the fields.""" + + async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams) -> dict[str, Any]: + return {"tools": []} + + server = Server("srv", cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")}) + server.add_request_handler("tools/list", PaginatedRequestParams, list_tools) + async with Client(server, mode="legacy") as client: + result = await client.list_tools() + assert "ttl_ms" not in result.model_fields_set + assert "cache_scope" not in result.model_fields_set + + +async def test_an_input_required_shaped_dict_is_never_stamped() -> None: + """Spec carve-out: interim `input_required` results carry no cache hints, even on a hinted method.""" + + async def read_resource(ctx: ServerRequestContext[Any], params: ReadResourceRequestParams) -> dict[str, Any]: + return {"resultType": "input_required", "requestState": "s1"} + + server = Server("srv", cache_hints={"resources/read": CacheHint(ttl_ms=60_000, scope="public")}) + server.add_request_handler("resources/read", ReadResourceRequestParams, read_resource) + async with Client(server) as client: + result = await client.session.read_resource("res://x", allow_input_required=True) + assert isinstance(result, InputRequiredResult) + assert result.model_dump(by_alias=True, exclude_none=True) == snapshot( + {"resultType": "input_required", "requestState": "s1"} + ) + + async def test_server_cache_hints_reach_the_wire_for_a_bare_handler_result() -> None: """SDK-defined: a lowlevel handler that never thinks about caching emits the server-wide hint configured at construction.""" diff --git a/tests/types/test_methods.py b/tests/types/test_methods.py index 79ea067c6b..342720c32c 100644 --- a/tests/types/test_methods.py +++ b/tests/types/test_methods.py @@ -548,6 +548,11 @@ def test_built_in_maps_are_immutable(): _assign_item(built_in) +def test_cacheable_methods_mirror_the_cacheable_method_literal(): + """SEP-2549 weld: the hand-written Literal and the set derived from `MONOLITH_RESULTS` must agree.""" + assert methods.CACHEABLE_METHODS == frozenset(get_args(methods.CacheableMethod)) + + def test_minimal_request_bodies_parse_through_every_request_row(): for (method, version), surface_type in methods.CLIENT_REQUESTS.items(): parsed = methods.parse_client_request(method, version, REQUEST_PARAMS_FIXTURES[surface_type]) From 0b200ef03bfcd9c6710d435016b8e651d96dc412 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:07:05 +0100 Subject: [PATCH 032/100] Surface skipped conformance scenarios as baselined known failures (#3030) --- .../expected-failures.2026-07-28.yml | 8 +++- .../actions/conformance/expected-failures.yml | 38 ++++++++++++++++++- .github/workflows/conformance.yml | 34 ++++++++++++----- .../mcp_everything_server/server.py | 20 +++++++++- 4 files changed, 88 insertions(+), 12 deletions(-) diff --git a/.github/actions/conformance/expected-failures.2026-07-28.yml b/.github/actions/conformance/expected-failures.2026-07-28.yml index 504b463856..e61033b394 100644 --- a/.github/actions/conformance/expected-failures.2026-07-28.yml +++ b/.github/actions/conformance/expected-failures.2026-07-28.yml @@ -22,4 +22,10 @@ client: [] -server: [] +server: + # SEP-2575 subscriptions/listen is not implemented yet; see the matching + # entry in expected-failures.yml for the full rationale. + - server-stateless + # SEP-2243 Mcp-Param-* server-side validation is not implemented yet; see + # the matching entry in expected-failures.yml for the full rationale. + - http-custom-header-server-validation diff --git a/.github/actions/conformance/expected-failures.yml b/.github/actions/conformance/expected-failures.yml index 4ad4123d02..efadd7d4d7 100644 --- a/.github/actions/conformance/expected-failures.yml +++ b/.github/actions/conformance/expected-failures.yml @@ -12,4 +12,40 @@ client: [] -server: [] +server: + # SEP-2575 subscriptions/listen is not implemented yet. The everything- + # server's legacy resources/subscribe handlers make it advertise + # `resources.subscribe` in server/discover, and as of conformance #372 a + # server that advertises a subscription capability but answers + # subscriptions/listen with -32601 fails the three listen MUST checks + # ("Not testable") instead of skipping them. Remove this entry when the + # listen runtime lands. NOTE: while listed, this entry also masks new + # failures in the scenario's other 25 (currently passing) checks — the + # baseline is per-scenario, not per-check. + - server-stateless + # SEP-2243 Mcp-Param-* server-side validation is not implemented yet. The + # everything-server's `test_x_mcp_header` tool arms these checks (without an + # x-mcp-header-annotated tool the harness skips all of them silently); the + # accept-path checks pass, the reject-path checks fail until the server + # validates Mcp-Param headers against body params. Read by the draft leg and + # the bare `--suite all` leg; the 2026-07-28 leg carries its own entry. + - http-custom-header-server-validation + # SEP-2663 (io.modelcontextprotocol/tasks): the SDK does not implement the + # tasks extension yet. These extension-tagged scenarios are selected only by + # the bare `--suite all` leg — extension scenarios never match a + # --spec-version filter and the active/draft suites exclude them — so these + # entries are inert for the other legs that read this file. + # + # `tasks-status-notifications` is intentionally NOT listed: the harness + # skips it unconditionally (pending its rewrite against subscriptions/ + # listen), and a baseline entry for a scenario with no failing checks is + # flagged stale. + - tasks-lifecycle + - tasks-capability-negotiation + - tasks-wire-fields + - tasks-request-state-removal + - tasks-mrtr-input + - tasks-request-headers + - tasks-dispatch-and-envelope + - tasks-required-task-error + - tasks-mrtr-composition diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index c73c1e2db7..35f8b6dcc4 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -19,17 +19,17 @@ env: # Bump deliberately and reconcile both # .github/actions/conformance/expected-failures*.yml files in the same change. # - # Temporarily pinned to the pkg.pr.new build of conformance main@b18aa918 - # (the merge of #371, which fixes the http-custom-headers fixture's - # spec-forbidden `number`-typed x-mcp-header annotations) — no published - # release includes it yet. Pinned by commit SHA so the tarball cannot move - # under us; CONFORMANCE_PKG_SHA256 pins the bytes and the fetch-and-verify - # step below downloads, checks the digest, and repoints CONFORMANCE_PKG at the + # Temporarily pinned to the pkg.pr.new build of conformance main@4944b268 + # (0.2.0-alpha.8, which includes #372: fail checks whose prerequisite is + # missing instead of skipping them) — alpha.8 is not published to npm yet. + # Pinned by commit SHA so the tarball cannot move under us; + # CONFORMANCE_PKG_SHA256 pins the bytes and the fetch-and-verify step below + # downloads, checks the digest, and repoints CONFORMANCE_PKG at the # verified local copy. Repin to the next published @modelcontextprotocol/ - # conformance release (>0.2.0-alpha.7) once it ships, then drop + # conformance release (>=0.2.0-alpha.8) once it ships, then drop # CONFORMANCE_PKG_SHA256 and the fetch-and-verify steps. - CONFORMANCE_PKG: "https://pkg.pr.new/@modelcontextprotocol/conformance@b18aa918" - CONFORMANCE_PKG_SHA256: "e9f6bc25085b4692e988cbdbd024a4203d54a52a6aaa065376cf8ecaa09bb680" + CONFORMANCE_PKG: "https://pkg.pr.new/@modelcontextprotocol/conformance@4944b268" + CONFORMANCE_PKG_SHA256: "0f70c035782d319d72ab427653c5275db5c50429d59fae0241a645b33aeda1a7" jobs: server-conformance: @@ -75,6 +75,22 @@ jobs: --suite all --spec-version 2026-07-28 --expected-failures ./.github/actions/conformance/expected-failures.2026-07-28.yml + - name: Run server conformance (all suite, extension scenarios) + # A bare `--suite all` (no --spec-version) selects every scenario + # shipped with the pinned harness — including the extension-tagged + # tasks-* scenarios and pending-listed ones like server-sse-polling, + # which no other leg reaches (extension scenarios never match a + # --spec-version filter, and the pending list keeps them out of the + # active suite). Running the full set keeps unimplemented surfaces + # visible as baselined known failures in expected-failures.yml instead + # of silent exclusions, and stays robust to scenarios moving between + # harness suite lists across pin bumps. `--suite pending` would cover + # the same union slightly faster; the full set is preferred for the + # self-contained run and for parity with typescript-sdk's CI. + run: >- + ./.github/actions/conformance/run-server.sh + --suite all + --expected-failures ./.github/actions/conformance/expected-failures.yml client-conformance: runs-on: ubuntu-latest diff --git a/examples/servers/everything-server/mcp_everything_server/server.py b/examples/servers/everything-server/mcp_everything_server/server.py index e4f5db84f6..8621c877a8 100644 --- a/examples/servers/everything-server/mcp_everything_server/server.py +++ b/examples/servers/everything-server/mcp_everything_server/server.py @@ -11,7 +11,7 @@ import hmac import json import logging -from typing import Any +from typing import Annotated, Any import click from mcp.server import ServerRequestContext @@ -327,6 +327,24 @@ def test_error_handling() -> str: raise RuntimeError("This tool intentionally returns an error for testing") +@mcp.tool() +def test_x_mcp_header( + region: Annotated[ + str, + Field( + description="Mirrored into the Mcp-Param-Region header", + json_schema_extra={"x-mcp-header": "Region"}, + ), + ] = "", +) -> str: + """Tests SEP-2243 Mcp-Param-* server-side validation. + + Arms the http-custom-header-server-validation conformance scenario, which + skips when no tool with an `x-mcp-header` annotation is found. + """ + return f"region={region}" + + @mcp.tool() async def test_missing_capability(ctx: Context) -> str: """Tests that a handler-raised MISSING_REQUIRED_CLIENT_CAPABILITY surfaces as a top-level JSON-RPC error. From 985652491a9c49b4133441941b34b10d94dc2f60 Mon Sep 17 00:00:00 2001 From: Den Delimarsky <53200638+localden@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:47:21 -0700 Subject: [PATCH 033/100] Add Cloudflare Pages docs preview with /preview-docs slash command (#3028) --- .github/workflows/docs-preview-cleanup.yml | 44 ++++ .github/workflows/docs-preview.yml | 231 +++++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 .github/workflows/docs-preview-cleanup.yml create mode 100644 .github/workflows/docs-preview.yml diff --git a/.github/workflows/docs-preview-cleanup.yml b/.github/workflows/docs-preview-cleanup.yml new file mode 100644 index 0000000000..136a50eff8 --- /dev/null +++ b/.github/workflows/docs-preview-cleanup.yml @@ -0,0 +1,44 @@ +name: Docs Preview Cleanup + +# Deletes Cloudflare Pages preview deployments for a PR when it closes. +# Runs as pull_request_target so secrets are available for fork PRs; it never +# checks out PR code, so there is no untrusted-code execution risk. + +on: + pull_request_target: # zizmor: ignore[dangerous-triggers] never checks out PR code + types: [closed] + +permissions: {} + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - name: Delete preview deployments for this PR + env: + CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + CF_PROJECT: ${{ vars.CLOUDFLARE_PAGES_PROJECT }} + BRANCH: pr-${{ github.event.pull_request.number }} + run: | + set -euo pipefail + if [ -z "$CF_API_TOKEN" ] || [ -z "$CF_ACCOUNT_ID" ] || [ -z "$CF_PROJECT" ]; then + echo "Cloudflare credentials/project not configured; skipping cleanup." + exit 0 + fi + base="https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/pages/projects/$CF_PROJECT/deployments" + # Collect matching ids across all pages first, then delete — deleting + # mid-pagination would shift later pages and skip entries. + ids="" + for page in $(seq 1 200); do + resp=$(curl -fsS -H "Authorization: Bearer $CF_API_TOKEN" "$base?env=preview&per_page=25&page=$page") + ids="$ids $(jq -r --arg b "$BRANCH" '.result[]? | select(.deployment_trigger.metadata.branch == $b) | .id' <<<"$resp")" + [ "$(jq '.result | length' <<<"$resp")" -lt 25 ] && break + done + deleted=0 + for id in $ids; do + echo "Deleting deployment $id" + curl -fsS -X DELETE -H "Authorization: Bearer $CF_API_TOKEN" "$base/$id?force=true" > /dev/null + deleted=$((deleted + 1)) + done + echo "Deleted $deleted deployment(s) for $BRANCH." diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml new file mode 100644 index 0000000000..05e8a877f0 --- /dev/null +++ b/.github/workflows/docs-preview.yml @@ -0,0 +1,231 @@ +name: Docs Preview + +# Builds the mkdocs site for a PR and deploys it to Cloudflare Pages. +# +# Security: mkdocs executes Python from the PR (mkdocstrings imports src/mcp, +# `!!python/name:` directives). The build is gated by `authorize` (admin sender +# for auto-preview, admin/maintainer commenter for /preview-docs) and isolated +# from Cloudflare secrets — `build` runs PR code with no secrets and hands the +# static site to `deploy` via an artifact, so PR code never shares a runner +# with the Cloudflare token. +# +# Required configuration: +# - secrets.CLOUDFLARE_API_TOKEN (scope: Account → Cloudflare Pages → Edit) +# - secrets.CLOUDFLARE_ACCOUNT_ID +# - vars.CLOUDFLARE_PAGES_PROJECT (existing Pages project, e.g. mcp-python-sdk-docs) + +on: + pull_request_target: # zizmor: ignore[dangerous-triggers] build is permission-gated and secret-isolated; see header comment + types: [opened, reopened, synchronize] + paths: + - docs/** + - docs_src/** + - mkdocs.yml + - pyproject.toml + issue_comment: + types: [created] + +permissions: {} + +concurrency: + # Workflow-level concurrency is evaluated when the run is queued — before any + # job-level `if:` — so an unrelated PR comment would otherwise cancel an + # in-flight build. Only runs that actually produce a preview share a group; + # everything else falls through to a unique run_id group. + group: >- + docs-preview-pr-${{ + github.event_name == 'pull_request_target' && github.event.pull_request.number + || (github.event.issue.pull_request && startsWith(github.event.comment.body, '/preview-docs') && github.event.issue.number) + || github.run_id + }} + cancel-in-progress: true + +jobs: + authorize: + if: >- + github.event_name == 'pull_request_target' || + (github.event.issue.pull_request && startsWith(github.event.comment.body, '/preview-docs')) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + authorized: ${{ steps.check.outputs.authorized }} + pr_number: ${{ steps.check.outputs.pr_number }} + head_sha: ${{ steps.check.outputs.head_sha }} + slash_attempt: ${{ steps.check.outputs.slash_attempt }} + steps: + - name: Determine authorization + id: check + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + script: | + const { owner, repo } = context.repo; + + async function permissionFor(username) { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username }); + return { level: data.permission, role: data.role_name }; + } + + let authorized = false; + let prNumber = ''; + let headSha = ''; + let slashAttempt = false; + + if (context.eventName === 'pull_request_target') { + // Gate on the *sender* (whoever caused this run — on synchronize that + // is the pusher), not the PR author, so a non-admin pushing to an + // admin-opened branch does not get an automatic build. + const actor = context.payload.sender.login; + prNumber = String(context.payload.pull_request.number); + headSha = context.payload.pull_request.head.sha; + const perm = await permissionFor(actor); + authorized = perm.level === 'admin'; + core.info(`pull_request_target by ${actor} (level=${perm.level}, role=${perm.role}) → authorized=${authorized}`); + } else { + // issue_comment: the job-level `if:` already guarantees this is a PR + // comment starting with /preview-docs. + slashAttempt = true; + const actor = context.payload.comment.user.login; + prNumber = String(context.payload.issue.number); + const perm = await permissionFor(actor); + authorized = perm.level === 'admin' || perm.role === 'maintain'; + if (authorized) { + const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: Number(prNumber) }); + if (pr.state !== 'open') { + authorized = false; + core.info(`PR #${prNumber} is ${pr.state}; refusing to preview.`); + } else { + headSha = pr.head.sha; + } + } + core.info(`/preview-docs by ${actor} (level=${perm.level}, role=${perm.role}) → authorized=${authorized}`); + } + + core.setOutput('authorized', String(authorized)); + core.setOutput('pr_number', prNumber); + core.setOutput('head_sha', headSha); + core.setOutput('slash_attempt', String(slashAttempt)); + + build: + needs: authorize + if: needs.authorize.outputs.authorized == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ needs.authorize.outputs.head_sha }} + persist-credentials: false + + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + # pull_request_target runs share the base-branch Actions cache; saving + # a cache populated while untrusted PR code ran would let it poison + # later trusted workflows. Mirrors publish-pypi.yml. + enable-cache: false + version: 0.9.5 + + - run: uv sync --frozen --group docs + - run: uv run --frozen --no-sync mkdocs build + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: site + path: site/ + retention-days: 1 + + deploy: + needs: [authorize, build] + if: needs.authorize.outputs.authorized == 'true' + runs-on: ubuntu-latest + permissions: {} + outputs: + deployment_url: ${{ steps.wrangler.outputs.deployment-url }} + alias_url: ${{ steps.wrangler.outputs.pages-deployment-alias-url }} + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: site + path: site + + - name: Deploy to Cloudflare Pages + id: wrangler + uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + packageManager: npm + command: >- + pages deploy ./site + --project-name=${{ vars.CLOUDFLARE_PAGES_PROJECT }} + --branch=pr-${{ needs.authorize.outputs.pr_number }} + --commit-hash=${{ needs.authorize.outputs.head_sha }} + --commit-dirty=true + + comment: + needs: [authorize, build, deploy] + if: >- + always() && + needs.deploy.result != 'cancelled' && + (needs.authorize.outputs.authorized == 'true' || needs.authorize.outputs.slash_attempt == 'true') + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Post or update preview comment + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + AUTHORIZED: ${{ needs.authorize.outputs.authorized }} + PR_NUMBER: ${{ needs.authorize.outputs.pr_number }} + HEAD_SHA: ${{ needs.authorize.outputs.head_sha }} + DEPLOY_RESULT: ${{ needs.deploy.result }} + DEPLOYMENT_URL: ${{ needs.deploy.outputs.deployment_url }} + ALIAS_URL: ${{ needs.deploy.outputs.alias_url }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + with: + script: | + const { owner, repo } = context.repo; + const env = process.env; + const issue_number = Number(env.PR_NUMBER); + const marker = ''; + + async function upsert(body) { + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number, per_page: 100 }); + const existing = comments.find(c => c.user?.login === 'github-actions[bot]' && c.body?.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ owner, repo, issue_number, body }); + } + } + + if (env.AUTHORIZED !== 'true') { + await github.rest.issues.createComment({ + owner, repo, issue_number, + body: `@${context.actor} — only repository admins or maintainers can run \`/preview-docs\` (and the PR must be open).`, + }); + return; + } + + if (env.DEPLOY_RESULT !== 'success') { + await upsert( + `${marker}\n### 📚 Documentation preview\n\n` + + `❌ Preview build **failed** for \`${env.HEAD_SHA.slice(0, 7)}\` — [workflow logs](${env.RUN_URL}).` + ); + return; + } + + const previewUrl = env.ALIAS_URL || env.DEPLOYMENT_URL; + const ts = new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC'); + await upsert( + `${marker}\n### 📚 Documentation preview\n\n` + + `| | |\n|---|---|\n` + + `| **Preview** | ${previewUrl} |\n` + + `| **Deployment** | ${env.DEPLOYMENT_URL} |\n` + + `| **Commit** | \`${env.HEAD_SHA.slice(0, 7)}\` |\n` + + `| **Triggered by** | @${context.actor} |\n` + + `| **Updated** | ${ts} |\n` + ); From 7322ca56f4565e4388e9f1cdb0ad6af8d3659730 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:30:32 +0100 Subject: [PATCH 034/100] Require integrity protection for MRTR requestState (#3032) --- docs/advanced/low-level-server.md | 2 +- docs/advanced/multi-round-trip.md | 74 + docs/tutorial/dependencies.md | 3 +- docs_src/mrtr/tutorial005.py | 38 + .../mcp_everything_server/server.py | 42 +- examples/stories/README.md | 2 +- examples/stories/mrtr/README.md | 64 +- examples/stories/mrtr/client.py | 30 +- examples/stories/mrtr/server.py | 6 +- examples/stories/mrtr/server_lowlevel.py | 7 +- examples/stories/refund_desk/README.md | 4 +- examples/stories/refund_desk/server.py | 2 + src/mcp-types/mcp_types/methods.py | 20 +- src/mcp/client/client.py | 4 +- src/mcp/server/auth/middleware/bearer_auth.py | 11 +- src/mcp/server/auth/provider.py | 11 + src/mcp/server/mcpserver/__init__.py | 14 + src/mcp/server/mcpserver/resolve.py | 101 +- src/mcp/server/mcpserver/server.py | 22 + src/mcp/server/request_state.py | 454 ++++++ src/mcp/server/runner.py | 2 +- tests/docs_src/test_mrtr.py | 36 +- .../auth/middleware/test_bearer_auth.py | 27 +- tests/server/auth/test_provider.py | 13 +- tests/server/mcpserver/test_resolve.py | 703 ++++++++- tests/server/mcpserver/test_server.py | 38 +- tests/server/test_request_state.py | 479 ++++++ tests/server/test_request_state_boundary.py | 1297 +++++++++++++++++ tests/types/test_methods.py | 15 + 29 files changed, 3338 insertions(+), 183 deletions(-) create mode 100644 docs_src/mrtr/tutorial005.py create mode 100644 src/mcp/server/request_state.py create mode 100644 tests/server/test_request_state.py create mode 100644 tests/server/test_request_state_boundary.py diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index 12c4532949..6568b76a55 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -181,7 +181,7 @@ The handshake belongs to the runner. `server/discover`, `ping`, and every other Each of these is one idea you now have the vocabulary for; each has its own chapter. -* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](multi-round-trip.md)**. +* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](multi-round-trip.md)**. True to this tier, nothing is installed for you: where `MCPServer` seals `requestState` by default, here the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`: one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` performs (**[Protecting `requestState`](multi-round-trip.md#protecting-requeststate)**). * `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` are the same `(ctx, params) -> result` shape for the other primitives. * `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **[Running your server](../run/index.md)** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story. diff --git a/docs/advanced/multi-round-trip.md b/docs/advanced/multi-round-trip.md index 78e567e9d0..62734b38fc 100644 --- a/docs/advanced/multi-round-trip.md +++ b/docs/advanced/multi-round-trip.md @@ -40,6 +40,7 @@ Everything else in that file (the explicit `input_schema`, the hand-built `CallT ``` * The first round returns the `InputRequiredResult`. On the retry, `ctx.input_responses` holds the answers under the same keys and the function returns its ordinary result — prompt messages here, resource content for a template resource. +* A `request_state` you set is sealed before it crosses the wire and verified on the echo, like everything else on the server; **[Protecting `requestState`](#protecting-requeststate)** below covers what the seal gives you and when you need to configure keys. * An `@mcp.tool()` function can return the result directly the same way, when the dependency form doesn't fit. * Static `@mcp.resource()` functions don't participate: they take no `Context`, so they could never read the retry. Only template resources can ask. * The era rules below apply unchanged: returning an `InputRequiredResult` on a pre-2026 session is the same `-32603` the warning describes. @@ -84,6 +85,78 @@ Drop to the underlying session, where `allow_input_required=True` hands you the * For every entry in `input_requests` you put an `InputResponse` under the **same key** in `input_responses`. `fulfil` is where your UI goes; this one hard-codes the answer. * Same tool name, same `arguments`, every leg. The retry is the original call carried out again, not a new method. +## Protecting `requestState` + +Everything above treats `request_state` as an echo, and on the wire that is all it is. But the client holds it between legs (writing it down across processes is exactly what the previous section blessed), so what comes back is **client-supplied input**: it can be modified, expired, or lifted from a different call entirely. The spec requires servers to integrity-protect this state and reject the round when verification fails, whenever the state can influence authorization, resource access, or business logic. + +`MCPServer` protects it by default. Every server seals outgoing `requestState` and verifies every echo — resolver state and hand-built state alike — under a key generated at process start. You configure nothing, write plaintext, and read plaintext; the wire only ever carries an opaque encrypted token. + +The default key lives and dies with the process, which is the one thing you must know before deploying beyond a single process: + +```python +from mcp.server.mcpserver import MCPServer, RequestStateSecurity + +# Multi-instance or restart-surviving: one or more shared secret keys (>= 32 bytes each). +mcp = MCPServer("fleet", request_state_security=RequestStateSecurity(keys=[key])) +``` + +* **The default (no configuration)** suits a single process: stdio, or exactly one HTTP worker. A retry that lands on a different worker, a different instance behind a load balancer, or the same server after a restart is sealed under a key that process doesn't have — the client gets the frozen rejection below and must start the flow over. +* **`keys=[...]`** is required whenever a retry can reach a **different instance** (multi-worker `uvicorn`, load-balanced HTTP) or must survive restarts: every instance verifies what any sibling minted. Same machinery, your secret instead of a generated one. +* For your own crypto, such as a KMS or an existing token service, pass `RequestStateSecurity(codec=...)` instead of `keys`; **[Bring your own crypto](#bring-your-own-crypto)** below covers the contract. + +### What the seal carries + +Default or configured, `requestState` on the wire is an encrypted, authenticated token. Your code never sees it: handlers and resolvers write plaintext and read plaintext (`ctx.request_state`); the SDK seals on the way out and verifies on the way in. Beyond integrity, each token is bound to: + +* **A time window.** Every round re-seals with a fresh expiry, so `RequestStateSecurity(ttl=...)` (default 600 seconds) bounds per-round think time, not the whole flow. +* **The authenticated principal.** When the request carries an OAuth access token the SDK validated, the state is bound to the token's client, issuer, and subject: state minted for one user fails under another, even when both users share one OAuth client. A verifier that supplies no subject degrades the binding to the client identity alone, which under URL-based client IDs is shared by every user of that client software. When auth is terminated outside the SDK (a fronting proxy), or the transport is unauthenticated, there is no principal to bind and this check is inert, unless `RequestStateSecurity(bind_principal=...)` supplies one from your own identity signal. Whichever components your token verifier supplies, it must supply them consistently: a verifier that includes the subject on some requests and omits it on others changes the principal mid-flow, and in-flight rounds are rejected. +* **The originating request.** The method, the tool or prompt name (or resource URI), and a digest of the arguments. A token replayed against a different tool, different arguments, or a different method fails. +* **The exact question asked.** Every resolver answer is pinned to the rendered question the client was shown, both on the round it first arrives and when a recorded answer is reused later. Redeploy with a reworded message or a changed schema and the server re-asks instead of consuming a stale answer. The same pinning cuts the other way: derive messages from the tool's arguments, not from per-call data. A message built from a timestamp or a live rate renders differently every round, so every recorded answer looks stale and the server re-asks until the client's round limit ends the call. + +All of that is the SDK's job, not yours, and not the codec's if you bring your own. + +### Rotating keys + +`keys[0]` seals new state; every key in the list verifies. Zero-downtime rotation is three phases, each fully rolled out before the next: + +```python +RequestStateSecurity(keys=[OLD, NEW]) # 1: every instance learns to verify NEW; OLD still mints +RequestStateSecurity(keys=[NEW, OLD]) # 2: NEW mints; in-flight OLD state keeps verifying +RequestStateSecurity(keys=[NEW]) # 3: one ttl after phase 2 is fully out, retire OLD +``` + +Never promote the minter first: minting under a key some instance can't yet verify drops in-flight rounds mid-rollout. + +Keys are scoped to one service. The sealed envelope also carries the server's name as an audience claim, so a token minted by a different service that happens to share a secret is rejected anyway. The claim is only as distinctive as the name, so a server given an explicit policy must have a real name or set `RequestStateSecurity(audience=...)` — an unnamed one raises at construction. `audience=` also serves deliberate multi-service topologies where one service must accept state another minted. (The no-configuration default is exempt: its key never leaves the process, so the audience claim has nothing to add.) + +### Bring your own crypto + +`RequestStateSecurity(codec=...)` takes anything with `seal(bytes) -> str` and `unseal(str) -> bytes` that raises `InvalidRequestState` for any token it did not mint. The classic shape is envelope encryption against a KMS, where you unwrap a data key once at startup and keep the per-token crypto local: + +```python title="server.py" hl_lines="12 26-27 34-35 38" +--8<-- "docs_src/mrtr/tutorial005.py" +``` + +TTL, principal binding, and request binding are **not** the codec's job: the SDK stamps them into the payload before `seal` and re-verifies them after `unseal`, for every codec. A codec's only obligations are integrity (tampered means raise) and, ideally, confidentiality. + +### When verification fails + +Every inbound failure, whether tampered, expired, replayed against a different request or principal, or sealed under a key this server doesn't know, gets the same answer: + +```json +{"code": -32602, "message": "Invalid or expired requestState"} +``` + +One frozen message for every cause, so the wire never reveals which check failed; the real reason goes to the server log. Every inbound `requestState` on `tools/call`, `prompts/get`, and `resources/read` is checked, including one arriving for a handler that never mints state. The most common rejection in practice isn't an attacker — it's the default process-local key meeting a retry from before a restart or from another instance; the client restarts the flow, and `keys=[...]` is the fix when that matters. + +### Hand-built state + +A `request_state` you set yourself (returning `InputRequiredResult` from a tool, prompt, or resource-template function) is sealed and verified by the same machinery as resolver state, with zero code changes: write plaintext, read plaintext, and every binding above applies. + +The one thing the SDK cannot pin for you, even when configured, is question identity: it doesn't know which of *your* questions an answer in your state belongs to. If you store answers keyed by question, include your own question identifier in the state and check it on the retry. + +The low-level `Server` is the no-batteries tier: unlike `MCPServer`, nothing is sealed until you append the boundary yourself, and your `request_state` crosses the wire exactly as written until you do. The one-line opt-in is shown in **[The low-level Server](low-level-server.md#the-other-handlers)**. + ## A 2026-07-28 result `InputRequiredResult` only exists at protocol version **2026-07-28**. The in-memory `Client(server)` negotiates it for you; over the wire, `mode="auto"` discovers it. After connecting, `client.protocol_version` tells you what you got. @@ -108,5 +181,6 @@ Drop to the underlying session, where `allow_input_required=True` hands you the * To inspect or persist rounds, use `client.session.call_tool(..., allow_input_required=True)` and own the `while isinstance(result, InputRequiredResult)` loop yourself. * On `@mcp.tool()`, a dependency that asks the user produces this result for you (**[Dependencies](../tutorial/dependencies.md)**); the **low-level** `Server` is the manual form. * Prompts and resources participate too: an `@mcp.prompt()` or template `@mcp.resource()` function returns the `InputRequiredResult` itself and reads `ctx.input_responses` on the retry. +* `requestState` comes back as client-supplied input, so `MCPServer` seals it by default — resolver state and hand-built state alike — under a process-local key; multi-instance deployments pass `RequestStateSecurity(keys=[...])` (or a custom codec) so every instance can verify what a sibling minted. The seal binds every token to a time window, the originating request, and the authenticated principal when the request carries auth the SDK validated or `bind_principal=` supplies your own identity signal (**[Protecting `requestState`](#protecting-requeststate)**). This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **[Deprecated features](deprecated.md)**. diff --git a/docs/tutorial/dependencies.md b/docs/tutorial/dependencies.md index b7b18fe763..8d6d91412d 100644 --- a/docs/tutorial/dependencies.md +++ b/docs/tutorial/dependencies.md @@ -131,7 +131,8 @@ That's the right default for a precondition: no answer, no order. When declining its question, an eliciting resolver must derive its question deterministically from the tool's arguments and earlier answers. A per-call generated value (a `default_factory` id, a timestamp) is re-derived on each round and must not appear in a question the answer is meant - to bind to. + to bind to. A question built from such volatile data makes every recorded answer look stale, + so the server re-asks it on every round until the client's round limit ends the call. ## Recap diff --git a/docs_src/mrtr/tutorial005.py b/docs_src/mrtr/tutorial005.py new file mode 100644 index 0000000000..a8588b250f --- /dev/null +++ b/docs_src/mrtr/tutorial005.py @@ -0,0 +1,38 @@ +import os + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +from mcp.server import MCPServer +from mcp.server.mcpserver import InvalidRequestState, RequestStateSecurity + +PREFIX = "kms1." # format version; fed to GCM as associated data, so it is bound under the tag + + +def unwrap_data_key() -> bytes: + """One KMS call at process start, kms.decrypt(CiphertextBlob=...); every token after that is local crypto.""" + return os.urandom(32) # stand-in for the unwrapped 32-byte data key + + +class EnvelopeCodec: + def __init__(self, data_key: bytes) -> None: + self._aesgcm = AESGCM(data_key) + + def seal(self, payload: bytes) -> str: + nonce = os.urandom(12) + return PREFIX + (nonce + self._aesgcm.encrypt(nonce, payload, PREFIX.encode())).hex() + + def unseal(self, token: str) -> bytes: + if not token.startswith(PREFIX): + raise InvalidRequestState("unknown token format") + body = token[len(PREFIX) :] + try: + raw = bytes.fromhex(body) + if raw.hex() != body: # only the exact string seal() produced verifies + raise ValueError("non-canonical hex") + return self._aesgcm.decrypt(raw[:12], raw[12:], PREFIX.encode()) + except (ValueError, InvalidTag) as exc: + raise InvalidRequestState("token failed verification") from exc + + +mcp = MCPServer("Deployer", request_state_security=RequestStateSecurity(codec=EnvelopeCodec(unwrap_data_key()))) diff --git a/examples/servers/everything-server/mcp_everything_server/server.py b/examples/servers/everything-server/mcp_everything_server/server.py index 8621c877a8..218188f50a 100644 --- a/examples/servers/everything-server/mcp_everything_server/server.py +++ b/examples/servers/everything-server/mcp_everything_server/server.py @@ -6,16 +6,13 @@ import asyncio import base64 -import binascii -import hashlib -import hmac import json import logging from typing import Annotated, Any import click from mcp.server import ServerRequestContext -from mcp.server.mcpserver import Context, MCPServer +from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity from mcp.server.mcpserver.prompts.base import UserMessage from mcp.server.streamable_http import EventCallback, EventMessage, EventStore from mcp.shared.exceptions import MCPError @@ -47,7 +44,7 @@ TextResourceContents, UnsubscribeRequestParams, ) -from mcp_types.jsonrpc import INVALID_PARAMS, MISSING_REQUIRED_CLIENT_CAPABILITY +from mcp_types.jsonrpc import MISSING_REQUIRED_CLIENT_CAPABILITY from pydantic import BaseModel, Field logger = logging.getLogger(__name__) @@ -100,8 +97,12 @@ async def replay_events_after(self, last_event_id: EventId, send_callback: Event # Create event store for SSE resumability (SEP-1699) event_store = InMemoryEventStore() +# Fixed fixture key (RequestStateSecurity requires at least 32 bytes); a real deployment would load a shared secret. +_REQUEST_STATE_KEY = b"everything-server-fixture-request-state-key" + mcp = MCPServer( name="mcp-conformance-test-server", + request_state_security=RequestStateSecurity(keys=[_REQUEST_STATE_KEY]), ) @@ -497,30 +498,12 @@ async def test_input_required_result_multi_round(ctx: Context) -> str | InputReq ) -# Fixed key for the conformance fixture; a real server would derive or rotate this. -_STATE_HMAC_KEY = b"everything-server-fixture-key" - - -def _seal_state(payload: str) -> str: - encoded = base64.urlsafe_b64encode(payload.encode()).decode() - sig = hmac.new(_STATE_HMAC_KEY, encoded.encode(), hashlib.sha256).hexdigest() - return f"{encoded}.{sig}" - - -def _unseal_state(state: str) -> str: - encoded, _, sig = state.partition(".") - expected = hmac.new(_STATE_HMAC_KEY, encoded.encode(), hashlib.sha256).hexdigest() - if not sig or not hmac.compare_digest(sig, expected): - raise MCPError(code=INVALID_PARAMS, message="requestState failed integrity verification") - try: - return base64.urlsafe_b64decode(encoded).decode() - except (binascii.Error, UnicodeDecodeError) as e: - raise MCPError(code=INVALID_PARAMS, message="requestState failed integrity verification") from e - - @mcp.tool() async def test_input_required_result_tampered_state(ctx: Context) -> str | InputRequiredResult: - """Tests that the server rejects a requestState that fails HMAC verification""" + """Tests that the server rejects a tampered requestState echo. + + The handler stays plaintext; tamper rejection happens in the SDK's request-state boundary. + """ if ctx.request_state is None: confirm = ElicitRequest( params=ElicitRequestFormParams( @@ -528,9 +511,8 @@ async def test_input_required_result_tampered_state(ctx: Context) -> str | Input requested_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}, "required": ["ok"]}, ) ) - return InputRequiredResult(input_requests={"confirm": confirm}, request_state=_seal_state("round-1")) - payload = _unseal_state(ctx.request_state) - return f"state-ok: {payload}" + return InputRequiredResult(input_requests={"confirm": confirm}, request_state="round-1") + return f"state-ok: {ctx.request_state}" @mcp.tool() diff --git a/examples/stories/README.md b/examples/stories/README.md index 8c1cceb5b6..79d7143110 100644 --- a/examples/stories/README.md +++ b/examples/stories/README.md @@ -128,7 +128,7 @@ opens with a banner saying what replaces it. | [`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 | -| [`mrtr`](mrtr/) | `InputRequiredResult` round-trip: the `Client` auto-loop and a manual session-level loop | current | +| [`mrtr`](mrtr/) | `InputRequiredResult` round-trip: the `Client` auto-loop, a manual session-level loop, and the default `requestState` sealing (a tampered echo gets one frozen error) | current | | [`legacy_elicitation`](legacy_elicitation/) | server pauses a tool to ask the user (form + url) via a push request | legacy | | [`refund_desk`](refund_desk/) | resolver DI: `Annotated[T, Resolve(fn)]` params filled server-side, hidden from the input schema | current | | [`sampling`](sampling/) | server asks the client's LLM mid-tool (push request) | deprecated | diff --git a/examples/stories/mrtr/README.md b/examples/stories/mrtr/README.md index aaad86ca9d..870db7d298 100644 --- a/examples/stories/mrtr/README.md +++ b/examples/stories/mrtr/README.md @@ -3,15 +3,20 @@ Multi-round tool result: on the 2026-07-28 protocol a tool that needs user input mid-call **returns** `resultType: "input_required"` with embedded `inputRequests` and an opaque `requestState`, instead of pushing a -server→client request. The client fulfils the embedded requests and retries the +server-to-client request. The client fulfils the embedded requests and retries the original `tools/call` carrying `inputResponses` and the echoed `requestState`. The story shows both the `Client` auto-loop (one `await call_tool`, callbacks -fired transparently) and a manual `client.session` loop (the persistable form). +fired transparently) and a manual `client.session` loop (the persistable +form). Because `requestState` round-trips through the client, it also shows +the security surface that protects it: `MCPServer` seals state by default +under a process-local key, handlers keep writing plaintext, and the wire only +ever carries an opaque token. The manual loop tampers with the sealed token to +show what a forged echo gets back. ## Run it ```bash -# HTTP — the client self-hosts the server on a free port, runs, then tears it +# HTTP: the client self-hosts the server on a free port, runs, then tears it # down (the InputRequiredResult round-trip is 2026-era only) uv run python -m stories.mrtr.client --http # same, against the lowlevel-API server variant @@ -20,36 +25,55 @@ uv run python -m stories.mrtr.client --http --server server_lowlevel ## What to look at -- `client.py` `main` — the auto-loop is invisible at the call site: +- `server.py` `build_server`: no security configuration at all. The default + seals under a key generated at process start, which is right for a + single-process server like this one; a fleet (multi-worker or load-balanced) + shares keys with `request_state_security=RequestStateSecurity(keys=[...])` + so any instance can verify state another minted. +- `server.py` `deploy`: handlers stay plaintext. The first round returns + `InputRequiredResult(input_requests={...}, + request_state="awaiting-confirm")` and the retry asserts + `ctx.request_state == "awaiting-confirm"`. The tool never touches the + crypto; the boundary seals on the way out and unseals the echo on the way + back in. +- `client.py` `main`: the auto-loop is invisible at the call site: `Client(target, mode=mode, elicitation_callback=on_elicit)` then `await client.call_tool("deploy", ...)`. The same `on_elicit` callback the legacy push path uses is dispatched for each embedded `inputRequests` entry. -- `client.py` manual block — `client.session.call_tool(..., +- `client.py` manual block: `client.session.call_tool(..., allow_input_required=True)` returns the raw `InputRequiredResult` so - `request_state` can be persisted between rounds; the retry is just another - `tools/call` with `input_responses=` / `request_state=`. -- `server.py` `deploy` — `ctx.input_responses` / `ctx.request_state` read the - retry payload; the first round returns - `InputRequiredResult(input_requests={...}, request_state=...)`, the second - returns the final string. -- `server_lowlevel.py` — same wire contract via `params.input_responses` / - `params.request_state` and a hand-built `InputRequiredResult`. + `request_state` can be persisted between rounds. The wire value is an opaque + sealed token, **not** the string the server code wrote. The client asserts + exactly that, then retries with one character of the token flipped and gets + the single frozen error every verification failure maps to: `-32602`, + `"Invalid or expired requestState"`, `{"reason": "invalid_request_state"}`. + The specific reason (tampered tag, expiry, wrong request, wrong principal) + appears only in the server's log, never on the wire. The untampered token + then completes the round normally. +- `server_lowlevel.py`: the lowlevel tier doesn't seal by default; the same + enforcement is one appended middleware: + `server.middleware.append(RequestStateBoundary(RequestStateSecurity.ephemeral(), + default_audience=server.name))`. ## Caveats - **Loop bound.** The auto-loop gives up after `input_required_max_rounds` (default 10) with `InputRequiredRoundsExceededError`; raise it on the `Client` ctor or drop to the manual loop. -- **`requestState` integrity is the server's job.** The client echoes it - byte-exact and never inspects it; the server MUST treat it as - attacker-controlled. The SDK ships no signing helper yet. +- **The default key dies with the process.** It is generated at startup and + held only in memory, so a server restart (or a retry landing on a different + instance) invalidates in-flight rounds: the client gets the same frozen + rejection and must start the flow over. Use + `RequestStateSecurity(keys=[...])` when state must survive either. ## Spec -[Input required tool results — server features](https://modelcontextprotocol.io/specification/draft/server/tools#input-required-tool-results) +[Input required tool results (server features)](https://modelcontextprotocol.io/specification/draft/server/tools#input-required-tool-results), +[Multi-round-trip requests (security patterns)](https://modelcontextprotocol.io/specification/draft/basic/patterns/mrtr) ## See also -`legacy_elicitation/` and `sampling/` — the handshake-era push equivalents this -mechanism replaces on the 2026 protocol. `refund_desk/` — resolver DI at the -MCPServer tier: the questions a tool can declare instead of pushing by hand. +`legacy_elicitation/` and `sampling/`: the handshake-era push equivalents this +mechanism replaces on the 2026 protocol. `refund_desk/`: resolver DI at the +MCPServer tier: the questions a tool can declare instead of pushing by hand +(its elicited answers ride in the same sealed `requestState`). diff --git a/examples/stories/mrtr/client.py b/examples/stories/mrtr/client.py index 5b686c3c9c..7280fd0aed 100644 --- a/examples/stories/mrtr/client.py +++ b/examples/stories/mrtr/client.py @@ -2,6 +2,7 @@ import mcp_types as types +from mcp import MCPError from mcp.client import Client, ClientRequestContext from stories._harness import Target, run_client @@ -27,14 +28,37 @@ async def main(target: Target, *, mode: str = "auto") -> None: first = await client.session.call_tool("deploy", {"env": "staging"}, allow_input_required=True) assert isinstance(first, types.InputRequiredResult) assert first.input_requests is not None and "confirm" in first.input_requests - assert first.request_state == "awaiting-confirm" - # Decline this time so the path diverges from the auto-loop run above. + # The boundary sealed server.py's plaintext "awaiting-confirm"; the wire token is opaque. + token = first.request_state + assert token is not None and token != "awaiting-confirm", token + responses: types.InputResponses = {"confirm": types.ElicitResult(action="decline")} + + # Tamper demo: flipping any one character fails verification, and every failure + # maps to one frozen wire error; the real reason appears only in the server log. + i = len(token) // 2 + tampered = token[:i] + ("A" if token[i] != "A" else "B") + token[i + 1 :] + try: + await client.session.call_tool( + "deploy", + {"env": "staging"}, + input_responses=responses, + request_state=tampered, + allow_input_required=True, + ) + except MCPError as e: + assert e.code == types.INVALID_PARAMS + assert e.message == "Invalid or expired requestState" + assert e.data == {"reason": "invalid_request_state"} + else: + raise AssertionError("expected MCPError for a tampered requestState") + + # The untampered token still completes the round; decline so this path diverges from the auto run. second = await client.session.call_tool( "deploy", {"env": "staging"}, input_responses=responses, - request_state=first.request_state, + request_state=token, allow_input_required=True, ) assert isinstance(second, types.CallToolResult) diff --git a/examples/stories/mrtr/server.py b/examples/stories/mrtr/server.py index d83c2e9835..8155b90f4d 100644 --- a/examples/stories/mrtr/server.py +++ b/examples/stories/mrtr/server.py @@ -13,19 +13,19 @@ def build_server() -> MCPServer: + # requestState is sealed by default under a process-local key, which suits this + # single-process server; fleets share keys=[...] so any instance can verify. mcp = MCPServer("mrtr-example") @mcp.tool(description="Deploy to an environment, asking the user to confirm first.") async def deploy(env: str, ctx: Context) -> str | InputRequiredResult: responses = ctx.input_responses if responses is None or "confirm" not in responses: - # First round: ask the client to elicit confirmation. request_state is opaque - # to the client; here it carries the step name so the retry can verify the echo. ask = ElicitRequest( params=ElicitRequestFormParams(message=f"Deploy to {env}?", requested_schema=CONFIRM_SCHEMA) ) + # The boundary seals this plaintext request_state on the way out and unseals the echo on retry. return InputRequiredResult(input_requests={"confirm": ask}, request_state="awaiting-confirm") - # Retry round: the client echoed request_state byte-exact and supplied the answer. assert ctx.request_state == "awaiting-confirm", ctx.request_state answer = responses["confirm"] if isinstance(answer, ElicitResult) and answer.action == "accept" and (answer.content or {}).get("confirm"): diff --git a/examples/stories/mrtr/server_lowlevel.py b/examples/stories/mrtr/server_lowlevel.py index 0ed13cea49..6f3f489d8b 100644 --- a/examples/stories/mrtr/server_lowlevel.py +++ b/examples/stories/mrtr/server_lowlevel.py @@ -6,6 +6,7 @@ from mcp.server.context import ServerRequestContext from mcp.server.lowlevel import Server +from mcp.server.request_state import RequestStateBoundary, RequestStateSecurity from stories._hosting import run_server_from_args CONFIRM_SCHEMA: types.ElicitRequestedSchema = { @@ -55,7 +56,11 @@ async def call_tool( return types.CallToolResult(content=[types.TextContent(text=f"deployed to {env}")]) return types.CallToolResult(content=[types.TextContent(text=f"deployment to {env} cancelled")]) - return Server("mrtr-example", on_list_tools=list_tools, on_call_tool=call_tool) + server = Server("mrtr-example", on_list_tools=list_tools, on_call_tool=call_tool) + # Lowlevel opt-in: append the same boundary middleware MCPServer installs by + # default; the server name becomes the token audience. + server.middleware.append(RequestStateBoundary(RequestStateSecurity.ephemeral(), default_audience=server.name)) + return server if __name__ == "__main__": diff --git a/examples/stories/refund_desk/README.md b/examples/stories/refund_desk/README.md index 5b5bb55327..f10363698b 100644 --- a/examples/stories/refund_desk/README.md +++ b/examples/stories/refund_desk/README.md @@ -29,7 +29,9 @@ uv run python -m stories.refund_desk.client --http - `server.py` `refund_order` — the signature is the whole story: `order_id` and `reason` are model-facing; `cents` and `restock` carry `Resolve(...)` markers and never reach the input schema. `client.py` asserts `properties` and - `required` are exactly `{order_id, reason}`. + `required` are exactly `{order_id, reason}`. At 2026 the resolver's elicited + answers ride between rounds inside a `requestState` the SDK seals by default; + see `mrtr/` for the full security walk-through. - `server.py` `refund_scope` — the no-round-trip fast path: a one-line order returns `Scope(full=True)` directly; only a multi-line order returns `Elicit(...)`. The ORD-7001 call completes with zero elicitations. diff --git a/examples/stories/refund_desk/server.py b/examples/stories/refund_desk/server.py index f29a266f0b..a263b93850 100644 --- a/examples/stories/refund_desk/server.py +++ b/examples/stories/refund_desk/server.py @@ -103,6 +103,8 @@ def ask_restock( def build_server() -> MCPServer: + # Elicited answers ride between rounds in a requestState the SDK seals by default; + # see mrtr/ for the full security walk-through. mcp = MCPServer("refund-desk") @mcp.tool(description="Refund an order. The amount comes from the order record, not from the caller.") diff --git a/src/mcp-types/mcp_types/methods.py b/src/mcp-types/mcp_types/methods.py index f49c158d92..37e1145386 100644 --- a/src/mcp-types/mcp_types/methods.py +++ b/src/mcp-types/mcp_types/methods.py @@ -13,7 +13,7 @@ from collections.abc import Mapping from functools import cache from types import MappingProxyType, UnionType -from typing import Any, Final, Literal, TypeVar, get_args +from typing import Any, Final, Literal, TypeGuard, TypeVar, cast, get_args from pydantic import BaseModel, TypeAdapter @@ -28,6 +28,7 @@ "CLIENT_REQUESTS", "CLIENT_RESULTS", "CacheableMethod", + "INPUT_REQUIRED_METHODS", "MONOLITH_NOTIFICATIONS", "MONOLITH_REQUESTS", "MONOLITH_RESULTS", @@ -36,6 +37,7 @@ "SERVER_RESULTS", "SPEC_CLIENT_METHODS", "SPEC_CLIENT_NOTIFICATION_METHODS", + "is_input_required", "parse_client_notification", "parse_client_request", "parse_client_result", @@ -423,6 +425,22 @@ ) """Runtime mirror of `CacheableMethod`, derived from `MONOLITH_RESULTS`.""" +INPUT_REQUIRED_METHODS: Final[frozenset[str]] = frozenset( + method + for method, row in MONOLITH_RESULTS.items() + if any( + issubclass(arm, types.InputRequiredResult) for arm in (get_args(row) if isinstance(row, UnionType) else (row,)) + ) +) +"""Methods whose results may be `InputRequiredResult`, derived from `MONOLITH_RESULTS`.""" + + +def is_input_required(result: object) -> TypeGuard[types.InputRequiredResult | dict[str, Any]]: + """True when `result` is an `input_required` interim result, typed or wire-shaped.""" + if isinstance(result, types.InputRequiredResult): + return True + return isinstance(result, Mapping) and cast("Mapping[str, Any]", result).get("resultType") == "input_required" + # --- Parse functions --- diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index 638ea63a9d..c2db891ca6 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -609,7 +609,9 @@ async def call_tool( callbacks and the call is retried automatically (up to `input_required_max_rounds`). To drive the loop yourself — e.g. to persist `request_state` across process restarts — use - `client.session.call_tool(..., allow_input_required=True)`. + `client.session.call_tool(..., allow_input_required=True)`. Persisted + state is still subject to the server's TTL, request binding, and key + lifetime; a server on the default process-local key rejects it after a restart. Args: name: The name of the tool to call. diff --git a/src/mcp/server/auth/middleware/bearer_auth.py b/src/mcp/server/auth/middleware/bearer_auth.py index ba66e94226..29413abf2b 100644 --- a/src/mcp/server/auth/middleware/bearer_auth.py +++ b/src/mcp/server/auth/middleware/bearer_auth.py @@ -7,7 +7,7 @@ from starlette.requests import HTTPConnection from starlette.types import Receive, Scope, Send -from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.server.auth.provider import AccessToken, TokenVerifier, principal_components class AuthenticatedUser(SimpleUser): @@ -34,13 +34,8 @@ def authorization_context(user: AuthenticatedUser) -> AuthorizationContext: See `examples/servers/simple-auth/mcp_simple_auth/token_verifier.py` for a verifier that populates `subject` and `claims` from an introspection response.""" - token = user.access_token - issuer = (token.claims or {}).get("iss") - return AuthorizationContext( - client_id=token.client_id, - issuer=str(issuer) if issuer is not None else None, - subject=token.subject, - ) + client_id, issuer, subject = principal_components(user.access_token) + return AuthorizationContext(client_id=client_id, issuer=issuer, subject=subject) class BearerAuthBackend(AuthenticationBackend): diff --git a/src/mcp/server/auth/provider.py b/src/mcp/server/auth/provider.py index eeb371f1c2..644868f3e5 100644 --- a/src/mcp/server/auth/provider.py +++ b/src/mcp/server/auth/provider.py @@ -59,6 +59,17 @@ class AccessToken(BaseModel): claims: dict[str, Any] | None = None # additional claims (e.g. `iss`, `act`) +def principal_components(token: AccessToken) -> tuple[str, str | None, str | None]: + """The (client_id, issuer, subject) triple identifying the principal a token represents. + + The single source for "who is this token's principal": session ownership and + request-state binding both build on it. Components the token verifier does + not supply are `None`, so comparisons degrade to the remaining components. + """ + issuer = (token.claims or {}).get("iss") + return token.client_id, str(issuer) if issuer is not None else None, token.subject + + RegistrationErrorCode = Literal[ "invalid_redirect_uri", "invalid_client_metadata", diff --git a/src/mcp/server/mcpserver/__init__.py b/src/mcp/server/mcpserver/__init__.py index 8ee6c4e4e2..0205df1920 100644 --- a/src/mcp/server/mcpserver/__init__.py +++ b/src/mcp/server/mcpserver/__init__.py @@ -3,6 +3,14 @@ from mcp_types import Icon from mcp.server.extension import Extension, MethodBinding, ResourceBinding, ToolBinding +from mcp.server.request_state import ( + AESGCMRequestStateCodec, + InvalidRequestState, + RequestStateBoundary, + RequestStateCodec, + RequestStateSecurity, + authenticated_principal, +) from .context import Context from .resolve import ( @@ -36,4 +44,10 @@ "require_client_extension", "ResourceSecurity", "DEFAULT_RESOURCE_SECURITY", + "RequestStateSecurity", + "RequestStateCodec", + "RequestStateBoundary", + "AESGCMRequestStateCodec", + "InvalidRequestState", + "authenticated_principal", ] diff --git a/src/mcp/server/mcpserver/resolve.py b/src/mcp/server/mcpserver/resolve.py index 9ff8dfeed5..d752afc10c 100644 --- a/src/mcp/server/mcpserver/resolve.py +++ b/src/mcp/server/mcpserver/resolve.py @@ -28,7 +28,11 @@ from __future__ import annotations +import base64 +import hashlib import inspect +import json +import logging import types import typing from collections.abc import Callable, Hashable, Mapping @@ -43,6 +47,7 @@ ElicitRequestFormParams, ElicitResult, FormElicitationCapability, + InputRequest, InputRequests, InputRequiredResult, InputResponses, @@ -61,6 +66,7 @@ ) from mcp.server.mcpserver.context import Context from mcp.server.mcpserver.exceptions import InvalidSignature, ToolError +from mcp.server.request_state import compact_json from mcp.shared._callable_inspection import is_async_callable from mcp.shared.exceptions import MCPError @@ -73,7 +79,9 @@ # `InputRequiredResult` rather than as a standalone server-to-client request. # Pinned (not `LATEST_MODERN_VERSION`, which moves when newer revisions are added). _INPUT_REQUIRED_VERSION = "2026-07-28" -_STATE_VERSION = 1 +_STATE_VERSION = 3 # v3: recorded and pended outcomes pinned to ASCII-canonical question renders + +logger = logging.getLogger(__name__) class Resolve: @@ -369,7 +377,11 @@ def __init__( self.context = context self.input_required = input_required self.answers: InputResponses = context.input_responses or {} if input_required else {} - self.state = _decode_state(context.request_state) if input_required else {} + decoded = _decode_state(context.request_state if input_required else None) + self.state = decoded.outcomes + # Digests of the questions asked last round: an answer is accepted only + # for the exact rendering the client was shown. + self.asked = decoded.asked # In-call dedup keyed by resolver identity (distinguishes two instances of # the same bound method); `persist` holds the wire-shaped record of each # elicited outcome, keyed by its wire key - exactly what the next round's @@ -431,7 +443,8 @@ async def resolve_arguments( injected[name] = outcome if wants_union else _unwrap(outcome, name) if res.pending: - return InputRequiredResult(input_requests=res.pending, request_state=_encode_state(res.persist)) + asked = {key: _request_digest(request) for key, request in res.pending.items()} + return InputRequiredResult(input_requests=res.pending, request_state=_encode_state(res.persist, asked)) return injected @@ -494,19 +507,25 @@ async def _elicit(elicit: Elicit[Any], key: str, res: _Resolution) -> Elicitatio if not res.input_required: return await res.context.elicit(elicit.message, elicit.schema) + request = _elicit_request(elicit) + q = _request_digest(request) + # A recorded outcome from a prior round is consulted only here, after the body # decided to ask, so a `request_state` entry can never stand in for a resolver's - # own computation. Re-validate it against the live `Elicit.schema`. A recorded - # outcome wins over a re-sent answer; an invalid entry self-deletes and falls - # through to the fresh answer (or to re-asking). - outcome = _restore_outcome(res, key, elicit.schema) + # own computation. A recorded outcome wins over a re-sent answer. + outcome = _restore_outcome(res, key, elicit.schema, q) if outcome is not None: return outcome answer = res.answers.get(key) + # An answer counts only for the rendering recorded when it was asked; an answer to + # an unrecorded or differently-worded question re-asks instead of being consumed. + if answer is not None and res.asked.get(key) != q: + logger.info("Discarding the answer for resolver %r: the question changed since it was asked", key) + answer = None if answer is None: _require_form_elicitation(res.context, key) - res.pending[key] = _elicit_request(elicit) + res.pending[key] = request raise _Pending if not isinstance(answer, ElicitResult): raise ToolError(f"Resolver {key!r} received a non-elicitation response") @@ -521,12 +540,12 @@ async def _elicit(elicit: Elicit[Any], key: str, res: _Resolution) -> Elicitatio ) from e # Persist the exact wire content that just passed validation - never the # model - so restoring next round revalidates the same bytes the client sent. - res.persist[key] = _StateEntry(action="accept", data=answer.content) + res.persist[key] = _StateEntry(action="accept", data=answer.content, q=q) return AcceptedElicitation(data=data) if answer.action == "decline": - res.persist[key] = _StateEntry(action="decline") + res.persist[key] = _StateEntry(action="decline", q=q) return DeclinedElicitation() - res.persist[key] = _StateEntry(action="cancel") + res.persist[key] = _StateEntry(action="cancel", q=q) return CancelledElicitation() @@ -595,37 +614,58 @@ class _StateEntry(BaseModel): action: Literal["accept", "decline", "cancel"] data: Any = None + q: str | None = None + """Digest of the exact rendered question this outcome answered.""" + + +def _request_digest(request: InputRequest) -> str: + """Pin an outcome to the exact rendered question the client was shown. + + A redeploy that rewords or reshapes a question re-asks it instead of reusing the recorded answer. + """ + params = request.params + rendered = compact_json(params.model_dump(mode="json", by_alias=True, exclude_none=True) if params else None) + digest = hashlib.sha256(rendered.encode()).digest()[:16] + return base64.urlsafe_b64encode(digest).decode().rstrip("=") class _State(BaseModel): - """The decoded `request_state`: resolver outcomes from earlier rounds.""" + """The decoded `request_state`: resolver progress from earlier rounds.""" v: int outcomes: dict[str, _StateEntry] = {} + asked: dict[str, str] = {} + """Question digest of each elicitation asked last round, keyed by wire key.""" -def _decode_state(request_state: str | None) -> dict[str, _StateEntry]: +def _decode_state(request_state: str | None) -> _State: """Decode the per-call resolution progress from `request_state`. - `request_state` is client-trusted (integrity sealing is a follow-up); validate - it through `_State` and treat anything malformed as "no progress yet". + Parsed with stdlib `json.loads` because `_encode_state` may emit escaped + lone surrogates, which pydantic's JSON parser rejects. The string arrives + boundary-authenticated, so malformed content or a version mismatch is + drift within the operator's own fleet (e.g. a rolling upgrade) and is + treated as "no progress yet". """ + empty = _State(v=_STATE_VERSION) if not request_state: - return {} + return empty try: - state = _State.model_validate_json(request_state) - except ValidationError: - return {} - return state.outcomes if state.v == _STATE_VERSION else {} + state = _State.model_validate(json.loads(request_state)) + except ValueError: + return empty + return state if state.v == _STATE_VERSION else empty -def _encode_state(outcomes: Mapping[str, _StateEntry]) -> str: - """Encode recorded elicitation outcomes (keyed by wire key) for the next round. +def _encode_state(outcomes: Mapping[str, _StateEntry], asked: Mapping[str, str]) -> str: + """Encode recorded outcomes and asked-question digests for the next round. - Entries already hold the client's wire-shaped data exactly as it was sent (and - validated), so encoding is pure wrapping: encode-restore is the identity. + Outcome entries already hold the client's wire-shaped data exactly as it was + sent (and validated), so encoding is pure wrapping: encode-restore is the + identity. """ - return _State(v=_STATE_VERSION, outcomes=dict(outcomes)).model_dump_json() + state = _State(v=_STATE_VERSION, outcomes=dict(outcomes), asked=dict(asked)) + return compact_json(state.model_dump(mode="json")) def _outcome_from_state(entry: _StateEntry, schema: type[BaseModel]) -> ElicitationResult[Any]: @@ -642,12 +682,12 @@ def _outcome_from_state(entry: _StateEntry, schema: type[BaseModel]) -> Elicitat return _accepted(schema.model_validate(entry.data)) -def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel]) -> ElicitationResult[Any] | None: +def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel], q: str) -> ElicitationResult[Any] | None: """Restore `key`'s recorded outcome from a prior round, or `None` when absent. - `request_state` is client-trusted, so an entry whose data fails validation gets - the `_decode_state` treatment - dropped as if no progress was recorded, so the - question is asked again - rather than surfacing a validation error. + An entry pinned to a question digest other than `q`, or whose accepted + data fails validation against the live `schema`, is dropped as if no + progress was recorded, so the question is asked again. Carries the original decoded entry forward unchanged in `res.persist`: if a later resolver is still pending, the next round's `request_state` is built from @@ -657,6 +697,9 @@ def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel]) -> Eli entry = res.state.get(key) if entry is None: return None + if entry.q != q: + del res.state[key] + return None try: outcome = _outcome_from_state(entry, schema) except ValidationError: diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 6764709806..3750429cdc 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -83,6 +83,7 @@ from mcp.server.mcpserver.tools import Tool, ToolManager from mcp.server.mcpserver.utilities.context_injection import find_context_parameter from mcp.server.mcpserver.utilities.logging import configure_logging, get_logger +from mcp.server.request_state import RequestStateBoundary, RequestStateSecurity from mcp.server.sse import SseServerTransport from mcp.server.stdio import stdio_server from mcp.server.streamable_http import EventStore @@ -133,6 +134,15 @@ class Settings(BaseSettings, Generic[LifespanResultT]): auth: AuthSettings | None +_MISSING_AUDIENCE = ( + "request_state_security is configured but this server has no name. Sealed\n" + "requestState carries the server name as an audience claim, so state minted by\n" + "another service that shares the same keys is rejected; unnamed servers would\n" + "all stamp the same placeholder and the check would mean nothing. Name the\n" + 'server (MCPServer("my-service", ...)) or set RequestStateSecurity(audience=...).' +) + + def lifespan_wrapper( app: MCPServer[LifespanResultT], lifespan: Callable[[MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]], @@ -170,6 +180,7 @@ def __init__( lifespan: Callable[[MCPServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT]] | None = None, auth: AuthSettings | None = None, resource_security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY, + request_state_security: RequestStateSecurity | None = None, cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, ): self._resource_security = resource_security @@ -210,6 +221,17 @@ def __init__( # We need to create a Lifespan type that is a generic on the server type, like Starlette does. lifespan=(lifespan_wrapper(self, self.settings.lifespan) if self.settings.lifespan else default_lifespan), # type: ignore ) + # Ordering: inside OpenTelemetry (spans record the sealed wire form), + # outside extension interceptors (extensions see plaintext). + if request_state_security is None: + security = RequestStateSecurity.ephemeral() + else: + # A supplied policy usually means shared keys, where the audience claim is + # what separates services; an unnamed server would stamp the placeholder. + if not name and request_state_security.audience is None: + raise ValueError(_MISSING_AUDIENCE) + security = request_state_security + self._lowlevel_server.middleware.append(RequestStateBoundary(security, default_audience=self.name)) # Validate auth configuration if self.settings.auth is not None: if auth_server_provider and token_verifier: # pragma: no cover diff --git a/src/mcp/server/request_state.py b/src/mcp/server/request_state.py new file mode 100644 index 0000000000..ad1abe8c36 --- /dev/null +++ b/src/mcp/server/request_state.py @@ -0,0 +1,454 @@ +"""Integrity protection for the multi-round-trip `requestState` (MCP 2026-07-28). + +The spec requires servers to treat the client-echoed `requestState` as +attacker-controlled: `RequestStateBoundary` seals every outgoing value and +verifies every inbound echo, so handlers only ever see plaintext they minted. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import logging +import math +import os +import time +from collections.abc import Callable, Mapping, Sequence +from dataclasses import replace +from typing import Any, NoReturn, Protocol, cast + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.hashes import SHA256 +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from mcp_types import INTERNAL_ERROR, INVALID_PARAMS +from mcp_types.methods import INPUT_REQUIRED_METHODS, is_input_required + +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.provider import principal_components +from mcp.server.context import CallNext, HandlerResult, ServerRequestContext +from mcp.shared.exceptions import MCPError + +__all__ = [ + "AESGCMRequestStateCodec", + "InvalidRequestState", + "RequestStateBoundary", + "RequestStateCodec", + "RequestStateSecurity", + "authenticated_principal", +] + +logger = logging.getLogger(__name__) + + +class InvalidRequestState(Exception): + """A sealed `requestState` token failed verification. + + The message is a log-only reason code; the boundary never puts it on the wire. + """ + + +class RequestStateCodec(Protocol): + """Authenticated crypto over the framework's request-state envelope. + + The framework stamps and re-verifies every envelope claim (expiry, request + binding, principal); a codec only provides integrity and, ideally, + confidentiality (a sign-only codec leaves the payload client-readable). + + Requirements: `unseal(seal(payload))` round-trips, and `unseal` raises + `InvalidRequestState` for any token it did not mint unmodified; tokens + never name their algorithm (version with a format prefix bound under the + authentication tag, RFC 8725); comparisons are constant-time. Both methods + are synchronous, so cache key material rather than calling a KMS per token. + """ + + def seal(self, payload: bytes) -> str: + """Return an opaque URL-safe token protecting `payload`.""" + ... + + def unseal(self, token: str) -> bytes: + """Reverse `seal`. + + Raises: + InvalidRequestState: Malformed, unauthentic, or unknown-key token. + """ + ... + + +def authenticated_principal(ctx: ServerRequestContext[Any, Any]) -> str | None: + """Default principal binding: the authenticated (client, issuer, subject) identity. + + Uses the same components session ownership uses, so two users of one OAuth + client are distinct principals whenever the token verifier supplies a + subject, and the binding degrades to the client identity when it does not. + Returns `None` (state not principal-bound) on unauthenticated transports. + """ + token = get_access_token() + if token is None: + return None + return compact_json(principal_components(token)) + + +class RequestStateSecurity: + """Policy for protecting `requestState`: codec, TTL, principal, audience. + + Exactly one of `keys` or `codec`: + + RequestStateSecurity(keys=[secret]) # built-in AES-256-GCM + RequestStateSecurity(codec=MyKmsCodec()) # bring your own crypto + RequestStateSecurity.ephemeral() # process-local key + + `keys` is the rotation ring: `keys[0]` seals, every key unseals. + Zero-downtime rotation, each phase fully rolled out before the next: + `keys=[old, new]`, then `keys=[new, old]`, then `keys=[new]` after one TTL. + + The boundary enforces expiry, request binding, audience, and principal for + every codec, fail-closed in both directions. `audience=None` defers to the + boundary's `default_audience` (`MCPServer` passes its server name). + """ + + codec: RequestStateCodec + ttl: float + bind_principal: Callable[[ServerRequestContext[Any, Any]], str | None] | None + audience: str | None + + def __init__( + self, + *, + keys: Sequence[bytes | bytearray | str] | None = None, + codec: RequestStateCodec | None = None, + ttl: float = 600.0, + bind_principal: Callable[[ServerRequestContext[Any, Any]], str | None] | None = authenticated_principal, + audience: str | None = None, + ) -> None: + if (keys is None) == (codec is None): + raise ValueError("RequestStateSecurity takes exactly one of keys= or codec=") + if not (math.isfinite(ttl) and ttl > 0): + raise ValueError(f"request-state ttl must be a positive finite number, got {ttl!r}") + if keys is not None: + self.codec = AESGCMRequestStateCodec(keys) + else: + assert codec is not None + self.codec = codec + self.ttl = ttl + self.bind_principal = bind_principal + self.audience = audience + + @classmethod + def ephemeral(cls, *, ttl: float = 600.0, audience: str | None = None) -> RequestStateSecurity: + """Protection under a key generated now and held only by this process. + + This is the policy `MCPServer` installs when `request_state_security=` + is omitted; call it yourself on the lowlevel tier or to set `ttl`/ + `audience`. Suits single-process deployments (stdio, one HTTP worker): + state minted before a restart or by another worker is rejected. + Multi-instance deployments must share a key via `keys=[...]`. + """ + return cls(keys=[os.urandom(32)], ttl=ttl, audience=audience) + + +_KDF_INFO = b"mcp/request-state/v1/aes-256-gcm" +_KID_INFO = b"mcp/request-state/v1/kid:" +_TOKEN_PREFIX = "v1." +_KID_LEN = 4 +_NONCE_LEN = 12 + + +def compact_json(value: Any, *, sort_keys: bool = False) -> str: + """Canonical JSON for everything the state path digests or seals. + + ASCII output keeps the encode total: a lone surrogate in client-supplied + text escapes instead of raising. Anything consuming this must parse with + stdlib `json.loads`, which accepts those escapes (pydantic's JSON parser + does not). + """ + return json.dumps(value, sort_keys=sort_keys, separators=(",", ":")) + + +def _b64u(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode().rstrip("=") + + +def _b64u_decode(text: str) -> bytes: + """Strict inverse of `_b64u`: only the canonical unpadded encoding decodes.""" + raw = base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) + if _b64u(raw) != text: + raise ValueError("non-canonical base64url") + return raw + + +def _derive_key(secret: bytes) -> bytes: + """Stretch an operator secret (>= 32 bytes, any format) into the AES-256 key.""" + return HKDF(algorithm=SHA256(), length=32, salt=None, info=_KDF_INFO).derive(secret) + + +class AESGCMRequestStateCodec: + """Built-in codec: AES-256-GCM under key(s) derived with HKDF-SHA256. + + Tokens are encrypted, not merely signed, so clients cannot read the state. + `keys[0]` seals; all keys unseal (rotation, see `RequestStateSecurity`). + Each token carries a 4-byte non-secret key fingerprint for an O(1) ring + lookup, and the "v1." prefix and fingerprint are bound into the GCM + associated data, so a token cannot be replayed into another format version + or ring slot. Key bytes are copied at construction. + """ + + def __init__(self, keys: Sequence[bytes | bytearray | str]) -> None: + for i, key in enumerate(cast("Sequence[object]", keys)): + if not isinstance(key, bytes | bytearray | str): + # Never coerce: bytes(32) would silently build an all-zero key. + raise TypeError( + f"request-state keys must be bytes, bytearray, or str; keys[{i}] is {type(key).__name__}" + ) + material = [k.encode() if isinstance(k, str) else bytes(k) for k in keys] + if not material: + raise ValueError("AESGCMRequestStateCodec requires at least one key") + for i, k in enumerate(material): + if len(k) < 32: + raise ValueError( + f"request-state keys must be at least 32 bytes of secret randomness; " + f"keys[{i}] is {len(k)} bytes. " + 'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"' + ) + self._ring: dict[bytes, AESGCM] = {} + self._mint_kid = b"" + for i, secret in enumerate(material): + key = _derive_key(secret) + kid = hashlib.sha256(_KID_INFO + key).digest()[:_KID_LEN] + if kid in self._ring: + raise ValueError(f"keys[{i}] duplicates an earlier ring key") + self._ring[kid] = AESGCM(key) + if i == 0: + self._mint_kid = kid + + def seal(self, payload: bytes) -> str: + kid = self._mint_kid + nonce = os.urandom(_NONCE_LEN) + sealed = self._ring[kid].encrypt(nonce, payload, _TOKEN_PREFIX.encode() + kid) + return _TOKEN_PREFIX + _b64u(kid + nonce + sealed) + + def unseal(self, token: str) -> bytes: + if not token.startswith(_TOKEN_PREFIX): + raise InvalidRequestState("malformed") + try: + raw = _b64u_decode(token[len(_TOKEN_PREFIX) :]) + except ValueError as exc: + raise InvalidRequestState("malformed") from exc + if len(raw) < _KID_LEN + _NONCE_LEN + 16: + raise InvalidRequestState("malformed") + kid, nonce, sealed = raw[:_KID_LEN], raw[_KID_LEN : _KID_LEN + _NONCE_LEN], raw[_KID_LEN + _NONCE_LEN :] + aead = self._ring.get(kid) + if aead is None: + raise InvalidRequestState("unknown key") + try: + return aead.decrypt(nonce, sealed, _TOKEN_PREFIX.encode() + kid) + except InvalidTag: + raise InvalidRequestState("seal") from None + + +# The multi-round-trip carriers: the only methods whose results may carry `requestState`. +_MRTR_METHODS = INPUT_REQUIRED_METHODS +_ENVELOPE_VERSION = 1 +_FUTURE_SKEW = 60.0 +_PRINCIPAL_LABEL = b"mcp/request-state/principal:" + +_RoundBinding = tuple[str, str, str | None] +"""The (target, args-digest, principal) one round's envelope binds, computed once per round.""" + + +def _reject(method: str, reason: str) -> NoReturn: + """Refuse a round: frozen wire error, real reason to the server log only.""" + logger.warning("requestState rejected on %s: %s", method, reason) + raise MCPError( + code=INVALID_PARAMS, + message="Invalid or expired requestState", + data={"reason": "invalid_request_state"}, + ) + + +def _request_identity(method: str, params: Mapping[str, Any] | None) -> tuple[str, str]: + """Salient (target, args-digest) for the request a token binds to. + + Per-method allowlist, never a denylist: a future wire field cannot silently join the digest. + """ + p: Mapping[str, Any] = params or {} + args: dict[str, Any] = {} + if method == "resources/read": + target = str(p.get("uri", "")) + else: + target, args = str(p.get("name", "")), p.get("arguments") or args + return target, _b64u(hashlib.sha256(compact_json(args, sort_keys=True).encode()).digest()[:16]) + + +def _principal_claim(principal: str) -> str: + salt = os.urandom(8) + tag = hashlib.sha256(_PRINCIPAL_LABEL + salt + _principal_bytes(principal)).digest()[:16] + return _b64u(salt + tag) + + +def _principal_matches(claim: str, principal: str) -> bool: + try: + raw = _b64u_decode(claim) + except ValueError: + return False + # A wrong-length claim never matches: compare_digest handles mismatched sizes. + expected = hashlib.sha256(_PRINCIPAL_LABEL + raw[:8] + _principal_bytes(principal)).digest()[:16] + return hmac.compare_digest(raw[8:], expected) + + +def _principal_bytes(principal: str) -> bytes: + # The digest input is one-way and never decoded, so surrogatepass keeps it total. + return principal.encode("utf-8", "surrogatepass") + + +def _bound_principal( + security: RequestStateSecurity, + ctx: ServerRequestContext[Any, Any], + fail: Callable[[str], NoReturn], +) -> str | None: + """Run `bind_principal` under the deny-on-error discipline, in one place for both directions. + + `fail` converts a failure into the calling direction's wire shape: the + frozen rejection when verifying, the sanitized internal error when sealing. + """ + try: + principal = security.bind_principal(ctx) if security.bind_principal is not None else None + except Exception: # deny-on-error: a raising principal binding must fail closed + logger.exception("bind_principal raised while processing requestState on %s", ctx.method) + fail("principal binding error") + # The declared return type is str | None, but a user callback can ignore it. + if principal is not None and not isinstance(cast("object", principal), str): + fail(f"bind_principal returned {type(principal).__name__}, expected str or None") + return principal + + +class RequestStateBoundary: + """Server middleware sealing/unsealing `requestState` at the wire boundary. + + Acts only on the multi-round-trip carriers (tools/call, prompts/get, + resources/read); every other method passes through untouched. + + Inbound state is verified (codec unseal plus claims check) and replaced + with the plaintext the server minted before any interceptor or handler + runs; failure answers -32602 with the frozen message "Invalid or expired + requestState", the real reason going to the server log only. Outbound, an + `input_required` result carrying `requestState` is sealed in a fresh + claims envelope; handlers and resolvers never call the codec. + + `default_audience` seeds the audience claim when the policy sets none, and + must be stated explicitly: it is the service identity that stops state + minted by another service sharing the same keys. `MCPServer` installs this + middleware with its server name by default (under an ephemeral policy + unless `request_state_security=` supplies one); lowlevel `Server` users + append one to `server.middleware`, passing their server's name (or `None` + to deliberately leave tokens audience-free). + """ + + def __init__(self, security: RequestStateSecurity, *, default_audience: str | None) -> None: + self._security = security + self._audience = security.audience if security.audience is not None else default_audience + + async def __call__(self, ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult: + if ctx.method not in _MRTR_METHODS: + return await call_next(ctx) + binding: _RoundBinding | None = None + if ctx.params is not None and ctx.params.get("requestState") is not None: + # An explicit JSON null counts as absent: stripping the field is already in any client's power. + plaintext, binding = self._unseal(ctx) + ctx = replace(ctx, params={**ctx.params, "requestState": plaintext}) + result = await call_next(ctx) + return self._seal_result(ctx, result, binding) + + def _unseal(self, ctx: ServerRequestContext[Any, Any]) -> tuple[str, _RoundBinding]: + assert ctx.params is not None + wire = ctx.params["requestState"] + if not isinstance(wire, str): + _reject(ctx.method, "non-string requestState") + security = self._security + try: + payload = security.codec.unseal(wire) + except InvalidRequestState as exc: + _reject(ctx.method, str(exc)) + except Exception: # deny-on-error: a buggy custom codec must fail closed + logger.exception("requestState codec raised during unseal on %s", ctx.method) + _reject(ctx.method, "codec error") + try: + claims = json.loads(payload) + version, iat, exp, inner = claims["v"], claims["iat"], claims["exp"], claims["s"] + except (ValueError, KeyError, TypeError): + _reject(ctx.method, "malformed") + if version != _ENVELOPE_VERSION or not isinstance(inner, str): + _reject(ctx.method, "malformed") + now = time.time() + # Accept-conditions are stated positively so a NaN claim fails the comparison and rejects. + if not isinstance(iat, int | float) or not (iat <= now + _FUTURE_SKEW): + _reject(ctx.method, "minted in the future") + if not isinstance(exp, int | float) or not (now < exp): + _reject(ctx.method, "expired") + target, args_digest = _request_identity(ctx.method, ctx.params) + if claims.get("m") != ctx.method or claims.get("t") != target or claims.get("a") != args_digest: + _reject(ctx.method, "request binding") + if claims.get("aud") != self._audience: + _reject(ctx.method, "audience") + + def fail_verify(reason: str) -> NoReturn: + _reject(ctx.method, reason) + + principal = _bound_principal(security, ctx, fail_verify) + claim = claims.get("p") + if (claim is None) != (principal is None): + _reject(ctx.method, "principal drift") + if claim is not None and principal is not None: + if not isinstance(claim, str) or not _principal_matches(claim, principal): + _reject(ctx.method, "principal") + return inner, (target, args_digest, principal) + + def _seal_result( + self, ctx: ServerRequestContext[Any, Any], result: HandlerResult, binding: _RoundBinding | None + ) -> HandlerResult: + # Spec-path results arrive as wire mappings; a short-circuiting middleware may return a model. + if not is_input_required(result): + return result + state = result.get("requestState") if isinstance(result, Mapping) else result.request_state + if state is None: + return result + if isinstance(result, Mapping): + if not isinstance(state, str): + # Only a short-circuiting middleware can put a non-string here; nothing to seal. + return result + return {**result, "requestState": self._seal(ctx, state, binding)} + return result.model_copy(update={"request_state": self._seal(ctx, state, binding)}) + + def _seal(self, ctx: ServerRequestContext[Any, Any], state: str, binding: _RoundBinding | None = None) -> str: + security = self._security + if binding is None: + + def fail_seal(reason: str) -> NoReturn: + logger.error("refusing to seal requestState on %s: %s", ctx.method, reason) + raise MCPError(code=INTERNAL_ERROR, message="Internal error") + + target, args_digest = _request_identity(ctx.method, ctx.params) + binding = (target, args_digest, _bound_principal(security, ctx, fail_seal)) + target, args_digest, principal = binding + now = time.time() + claims: dict[str, Any] = { + "v": _ENVELOPE_VERSION, + "iat": now, + "exp": now + security.ttl, + "m": ctx.method, + "t": target, + "a": args_digest, + "s": state, + } + if self._audience is not None: + claims["aud"] = self._audience + if principal is not None: + claims["p"] = _principal_claim(principal) + payload = compact_json(claims).encode() + try: + return security.codec.seal(payload) + except Exception: # deny-on-error: a raising custom codec must not leak its failure + logger.exception("requestState codec raised during seal on %s", ctx.method) + raise MCPError(code=INTERNAL_ERROR, message="Internal error") from None diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index 6773fd4de8..6aa9cd6d5c 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -204,7 +204,7 @@ async def _inner(ctx: ServerRequestContext[LifespanT, Any]) -> HandlerResult: if (hint := self.server.cache_hints.get(method)) is not None: if isinstance(result, CacheableResult): result = apply_cache_hint(result, hint) - elif isinstance(result, Mapping) and result.get("resultType") != "input_required": + elif isinstance(result, Mapping) and not _methods.is_input_required(result): # Hint keys first so wire keys the handler set win, matching `apply_cache_hint` precedence. result = {"ttlMs": hint.ttl_ms, "cacheScope": hint.scope, **result} # Dump and serialize inside the chain so the OpenTelemetry span (the diff --git a/tests/docs_src/test_mrtr.py b/tests/docs_src/test_mrtr.py index 110bd8f781..cf7842b0af 100644 --- a/tests/docs_src/test_mrtr.py +++ b/tests/docs_src/test_mrtr.py @@ -18,9 +18,10 @@ TextContent, ) -from docs_src.mrtr import tutorial001, tutorial002, tutorial003, tutorial004 +from docs_src.mrtr import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005 from mcp import Client, MCPError from mcp.client import ClientRequestContext +from mcp.server.mcpserver import InvalidRequestState # See test_index.py for why this is a per-module mark and not a conftest hook. pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] @@ -161,3 +162,36 @@ async def test_the_prompt_auto_loop_returns_the_final_messages() -> None: ], ) ) + + +def test_a_custom_codec_round_trips_what_it_sealed() -> None: + """tutorial005: `unseal(seal(payload))` returns the payload; the token itself is opaque hex.""" + codec = tutorial005.EnvelopeCodec(tutorial005.unwrap_data_key()) + token = codec.seal(b"round-1") + assert token.startswith(tutorial005.PREFIX) + assert b"round-1" not in token.encode() + assert codec.unseal(token) == b"round-1" + + +def test_a_custom_codec_raises_invalid_request_state_for_any_bad_token() -> None: + """tutorial005: any token the codec did not mint intact raises `InvalidRequestState`.""" + codec = tutorial005.EnvelopeCodec(tutorial005.unwrap_data_key()) + token = codec.seal(b"round-1") + with pytest.raises(InvalidRequestState): + codec.unseal(token + "00") + with pytest.raises(InvalidRequestState): + codec.unseal("not-a-token") + + +def test_a_custom_codec_rejects_every_alias_of_a_minted_token() -> None: + """tutorial005: only the exact minted string verifies; rewritten spellings of it do not.""" + codec = tutorial005.EnvelopeCodec(tutorial005.unwrap_data_key()) + token = codec.seal(b"round-1") + body = token.removeprefix(tutorial005.PREFIX) + for alias in ( + body, # prefix stripped + tutorial005.PREFIX + body.upper(), # non-canonical hex case + tutorial005.PREFIX + body[:8] + " " + body[8:], # whitespace bytes.fromhex would skip + ): + with pytest.raises(InvalidRequestState): + codec.unseal(alias) diff --git a/tests/server/auth/middleware/test_bearer_auth.py b/tests/server/auth/middleware/test_bearer_auth.py index bd14e294c2..6ab3436771 100644 --- a/tests/server/auth/middleware/test_bearer_auth.py +++ b/tests/server/auth/middleware/test_bearer_auth.py @@ -9,8 +9,18 @@ from starlette.requests import Request from starlette.types import Message, Receive, Scope, Send -from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, BearerAuthBackend, RequireAuthMiddleware -from mcp.server.auth.provider import AccessToken, OAuthAuthorizationServerProvider, ProviderTokenVerifier +from mcp.server.auth.middleware.bearer_auth import ( + AuthenticatedUser, + BearerAuthBackend, + RequireAuthMiddleware, + authorization_context, +) +from mcp.server.auth.provider import ( + AccessToken, + OAuthAuthorizationServerProvider, + ProviderTokenVerifier, + principal_components, +) class MockOAuthProvider: @@ -446,3 +456,16 @@ async def send(message: Message) -> None: # pragma: no cover assert app.scope == scope assert app.receive == receive assert app.send == send + + +def test_authorization_context_is_built_from_principal_components() -> None: + """Session ownership identifies the principal via the shared principal_components triple.""" + token = AccessToken( + token="t", client_id="client-1", scopes=[], subject="alice", claims={"iss": "https://as.example"} + ) + client_id, issuer, subject = principal_components(token) + assert authorization_context(AuthenticatedUser(token)) == { + "client_id": client_id, + "issuer": issuer, + "subject": subject, + } diff --git a/tests/server/auth/test_provider.py b/tests/server/auth/test_provider.py index aaaeb413a4..8c07d02acb 100644 --- a/tests/server/auth/test_provider.py +++ b/tests/server/auth/test_provider.py @@ -1,6 +1,6 @@ """Tests for mcp.server.auth.provider module.""" -from mcp.server.auth.provider import construct_redirect_uri +from mcp.server.auth.provider import AccessToken, construct_redirect_uri, principal_components def test_construct_redirect_uri_no_existing_params(): @@ -77,3 +77,14 @@ def test_construct_redirect_uri_encoded_values(): # urlencode uses + for spaces by default assert "state=test+state+with+spaces" in result + + +def test_principal_components_composes_client_issuer_subject(): + """The triple identifying a token's principal, degrading per missing component.""" + bare = AccessToken(token="t", client_id="client-1", scopes=[]) + assert principal_components(bare) == ("client-1", None, None) + + full = AccessToken( + token="t", client_id="client-1", scopes=[], subject="alice", claims={"iss": "https://as.example"} + ) + assert principal_components(full) == ("client-1", "https://as.example", "alice") diff --git a/tests/server/mcpserver/test_resolve.py b/tests/server/mcpserver/test_resolve.py index 571cefcb6c..c28f12481c 100644 --- a/tests/server/mcpserver/test_resolve.py +++ b/tests/server/mcpserver/test_resolve.py @@ -3,7 +3,7 @@ import json from collections.abc import Callable from datetime import datetime -from typing import Annotated, Any, Literal, TypeVar +from typing import Annotated, Any, Literal, TypeVar, cast import anyio import pytest @@ -23,22 +23,28 @@ from mcp import Client, InputRequiredRoundsExceededError from mcp.client import ClientRequestContext +from mcp.server.context import ServerRequestContext from mcp.server.mcpserver import ( AcceptedElicitation, + AESGCMRequestStateCodec, CancelledElicitation, Context, DeclinedElicitation, Elicit, ElicitationResult, MCPServer, + RequestStateBoundary, + RequestStateSecurity, Resolve, ) from mcp.server.mcpserver.exceptions import InvalidSignature from mcp.server.mcpserver.resolve import ( _check_elicit_return, _decode_state, + _elicit_request, _encode_state, _outcome_from_state, + _request_digest, _resolver_key, _state_key, _StateEntry, @@ -50,6 +56,11 @@ from mcp.shared.exceptions import MCPError +def _question_digest(elicit: Elicit[Any]) -> str: + """The digest `_elicit` pins: the rendered request the client would be shown.""" + return _request_digest(_elicit_request(elicit)) + + class Login(BaseModel): username: str @@ -126,9 +137,49 @@ def _answer_round( return responses +# Fixed key shared with servers under test, so tests can unseal minted wire +# state and seal crafted state the server will accept. +_PIN_KEY = b"0123456789abcdef0123456789abcdef" + + +def _unseal_inner(request_state: str | None) -> str: + """Unseal a wire `request_state` minted under `_PIN_KEY` into the inner plaintext state.""" + assert request_state is not None + claims = json.loads(AESGCMRequestStateCodec([_PIN_KEY]).unseal(request_state)) + inner = claims["s"] + assert isinstance(inner, str) + return inner + + +def _outcomes_on_the_wire(request_state: str | None) -> dict[str, Any]: + """Unseal a wire `request_state` minted under `_PIN_KEY` and return its outcomes.""" + return json.loads(_unseal_inner(request_state))["outcomes"] + + +def _sealed_state(inner: str, *, tool: str, args: dict[str, Any], audience: str) -> str: + """Seal a hand-built inner state exactly as the boundary does for a `tools/call` retry. + + The production `RequestStateBoundary._seal` binds method, tool, arguments, and + audience (the server name), so the test must then call exactly `tool` with + exactly `args` on the MCPServer named `audience`. + """ + ctx = ServerRequestContext( + session=cast("Any", None), + lifespan_context={}, + protocol_version="2026-07-28", + method="tools/call", + params={"name": tool, "arguments": args}, + ) + return RequestStateBoundary(RequestStateSecurity(keys=[_PIN_KEY]), default_audience=audience)._seal(ctx, inner) + + +def _wire_key(fn: Callable[..., Any]) -> str: + return f"{fn.__module__}:{fn.__qualname__}" + + @pytest.mark.anyio async def test_resolver_returns_value_directly_without_eliciting(): - mcp = MCPServer(name="Direct") + mcp = MCPServer(name="Direct", request_state_security=RequestStateSecurity.ephemeral()) async def login(ctx: Context) -> Login | Elicit[Login]: username = (ctx.headers or {}).get("x-github-user") @@ -149,7 +200,7 @@ async def never(context: ClientRequestContext, params: ElicitRequestParams) -> E @pytest.mark.anyio async def test_resolver_elicits_and_injects_unwrapped_model_on_accept(): - mcp = MCPServer(name="Accept") + mcp = MCPServer(name="Accept", request_state_security=RequestStateSecurity.ephemeral()) async def login(ctx: Context) -> Login | Elicit[Login]: return Elicit("GitHub username?", Login) @@ -164,7 +215,7 @@ async def whoami(login: Annotated[Login, Resolve(login)]) -> str: @pytest.mark.anyio async def test_consumer_receives_result_union_and_branches(): - mcp = MCPServer(name="Union") + mcp = MCPServer(name="Union", request_state_security=RequestStateSecurity.ephemeral()) async def login(ctx: Context) -> Login | Elicit[Login]: return Elicit("GitHub username?", Login) @@ -183,7 +234,7 @@ async def whoami(login: Annotated[ElicitationResult[Login], Resolve(login)]) -> @pytest.mark.anyio async def test_decline_reaches_union_consumer_without_aborting(): - mcp = MCPServer(name="UnionDecline") + mcp = MCPServer(name="UnionDecline", request_state_security=RequestStateSecurity.ephemeral()) async def login(ctx: Context) -> Login | Elicit[Login]: return Elicit("GitHub username?", Login) @@ -202,7 +253,7 @@ async def whoami( @pytest.mark.anyio async def test_decline_aborts_when_consumer_wants_unwrapped(): - mcp = MCPServer(name="UnwrappedDecline") + mcp = MCPServer(name="UnwrappedDecline", request_state_security=RequestStateSecurity.ephemeral()) async def login(ctx: Context) -> Login | Elicit[Login]: return Elicit("GitHub username?", Login) @@ -220,7 +271,7 @@ async def whoami(login: Annotated[Login, Resolve(login)]) -> str: @pytest.mark.anyio async def test_nested_resolver_sees_dependency_and_tool_args(): - mcp = MCPServer(name="Nested") + mcp = MCPServer(name="Nested", request_state_security=RequestStateSecurity.ephemeral()) async def login(ctx: Context) -> Login | Elicit[Login]: return Elicit("GitHub username?", Login) @@ -251,7 +302,7 @@ async def callback(context: ClientRequestContext, params: ElicitRequestParams) - @pytest.mark.anyio async def test_resolver_runs_once_for_two_consumers(): - mcp = MCPServer(name="ExactlyOnce") + mcp = MCPServer(name="ExactlyOnce", request_state_security=RequestStateSecurity.ephemeral()) elicit_count = 0 async def login(ctx: Context) -> Login | Elicit[Login]: @@ -281,7 +332,7 @@ async def callback(context: ClientRequestContext, params: ElicitRequestParams) - @pytest.mark.anyio async def test_sync_resolver(): - mcp = MCPServer(name="Sync") + mcp = MCPServer(name="Sync", request_state_security=RequestStateSecurity.ephemeral()) def login(ctx: Context) -> Login: return Login(username="sync-user") @@ -428,7 +479,7 @@ async def tool(login: Annotated[Login, Resolve(BadResolver())]) -> str: @pytest.mark.anyio async def test_by_name_resolver_param_uses_aliased_tool_arg(): - mcp = MCPServer(name="Aliased") + mcp = MCPServer(name="Aliased", request_state_security=RequestStateSecurity.ephemeral()) # `schema` collides with a BaseModel attribute, so func_metadata aliases the field; # the runtime kwarg key is the alias, which is what a by-name resolver must match. @@ -448,7 +499,7 @@ async def never(context: ClientRequestContext, params: ElicitRequestParams) -> E @pytest.mark.anyio async def test_resolver_may_return_non_basemodel_value(): - mcp = MCPServer(name="NonModel") + mcp = MCPServer(name="NonModel", request_state_security=RequestStateSecurity.ephemeral()) async def get_token(ctx: Context) -> str: return "secret-token" @@ -466,7 +517,7 @@ async def never(context: ClientRequestContext, params: ElicitRequestParams) -> E @pytest.mark.anyio async def test_resolver_accepts_optional_context_annotation(): - mcp = MCPServer(name="OptionalContext") + mcp = MCPServer(name="OptionalContext", request_state_security=RequestStateSecurity.ephemeral()) async def whoami(ctx: Context | None) -> str: assert ctx is not None @@ -485,7 +536,7 @@ async def never(context: ClientRequestContext, params: ElicitRequestParams) -> E @pytest.mark.anyio async def test_bound_method_resolver_runs_once_across_references(): - mcp = MCPServer(name="BoundMethod") + mcp = MCPServer(name="BoundMethod", request_state_security=RequestStateSecurity.ephemeral()) calls = 0 class Service: @@ -537,7 +588,7 @@ async def tool(value: Annotated[Login, Resolve(service.a)]) -> str: @pytest.mark.anyio async def test_resolver_and_body_see_the_same_validated_default(): - mcp = MCPServer(name="DefaultFactory") + mcp = MCPServer(name="DefaultFactory", request_state_security=RequestStateSecurity.ephemeral()) counter = {"n": 0} def next_id() -> int: @@ -590,7 +641,7 @@ def fn() -> None: ... # pragma: no cover def _delete_folder_server() -> tuple[MCPServer, dict[str, list[str]]]: """The `delete_folder` example from docs/migration.md, wired to an in-memory fs.""" - mcp = MCPServer(name="files") + mcp = MCPServer(name="files", request_state_security=RequestStateSecurity.ephemeral()) fs: dict[str, list[str]] = {} async def confirm_delete(path: str) -> Confirm | Elicit[Confirm]: @@ -723,7 +774,7 @@ async def never(context: ClientRequestContext, params: ElicitRequestParams) -> E @pytest.mark.anyio async def test_input_required_asks_each_question_once_while_bodies_rerun(): - mcp = MCPServer(name="ExactlyOnceMRTR") + mcp = MCPServer(name="ExactlyOnceMRTR", request_state_security=RequestStateSecurity.ephemeral()) counts = {"login": 0, "confirm": 0} async def login(ctx: Context) -> Login | Elicit[Login]: @@ -768,7 +819,7 @@ async def callback(context: ClientRequestContext, params: ElicitRequestParams) - @pytest.mark.anyio async def test_input_required_batches_independent_elicits_in_one_round(): - mcp = MCPServer(name="BatchedMRTR") + mcp = MCPServer(name="BatchedMRTR", request_state_security=RequestStateSecurity.ephemeral()) async def ask_name(ctx: Context) -> Elicit[Login]: return Elicit("Name?", Login) @@ -812,7 +863,7 @@ def answer(key: str, params: ElicitRequestFormParams) -> ElicitResult: async def test_auto_driver_answers_independent_questions_in_a_single_round(): # The pure `count_round` resolver is never persisted in `request_state`, so it # re-runs on every round: its run count is the number of rounds the call took. - mcp = MCPServer(name="AutoBatch") + mcp = MCPServer(name="AutoBatch", request_state_security=RequestStateSecurity.ephemeral()) rounds = 0 async def count_round(ctx: Context) -> int: @@ -871,7 +922,8 @@ def test_uses_input_required_version_gate(): ], ) def test_decode_state_tolerates_malformed_request_state(request_state: str | None): - assert _decode_state(request_state) == {} + state = _decode_state(request_state) + assert state.outcomes == {} and state.asked == {} def test_state_round_trips_accept_decline_cancel(): @@ -881,8 +933,10 @@ def test_state_round_trips_accept_decline_cancel(): "c": _StateEntry(action="cancel"), "d": _StateEntry(action="accept", data="raw-token"), # non-dict wire value } - decoded = _decode_state(_encode_state(entries)) + state = _decode_state(_encode_state(entries, {"e": "asked-digest"})) + decoded = state.outcomes assert decoded == entries # encode-restore is the identity on the stored entries + assert state.asked == {"e": "asked-digest"} accepted = _outcome_from_state(decoded["a"], Login) assert isinstance(accepted, AcceptedElicitation) and accepted.data == Login(username="octocat") @@ -907,7 +961,7 @@ def test_check_elicit_return_allows_one_arm_and_rejects_two(): @pytest.mark.anyio async def test_non_elicitation_response_raises(): - mcp = MCPServer(name="WrongResponse") + mcp = MCPServer(name="WrongResponse", request_state_security=RequestStateSecurity.ephemeral()) async def ask(ctx: Context) -> Elicit[Login]: return Elicit("Name?", Login) @@ -942,7 +996,7 @@ async def test_direct_call_tool_with_non_eliciting_resolver(): # `MCPServer.call_tool()` called directly builds a Context with no request, so # `ctx.protocol_version` is None. A tool whose resolvers never elicit must still # work there (regression: it used to raise "Context is not available"). - mcp = MCPServer(name="Direct") + mcp = MCPServer(name="Direct", request_state_security=RequestStateSecurity.ephemeral()) async def whoami(ctx: Context) -> Login: return Login(username="direct") @@ -959,7 +1013,7 @@ async def tool(login: Annotated[Login, Resolve(whoami)]) -> str: @pytest.mark.anyio async def test_two_instances_of_one_method_do_not_collide(): - mcp = MCPServer(name="Instances") + mcp = MCPServer(name="Instances", request_state_security=RequestStateSecurity.ephemeral()) class Service: def __init__(self, name: str) -> None: @@ -985,7 +1039,7 @@ async def both( @pytest.mark.anyio async def test_non_serializable_sibling_resolver_does_not_break_rounds(): - mcp = MCPServer(name="NonSerializable") + mcp = MCPServer(name="NonSerializable", request_state_security=RequestStateSecurity.ephemeral()) async def clock(ctx: Context) -> datetime: return datetime(2026, 1, 1) @@ -1013,7 +1067,7 @@ async def callback(context: ClientRequestContext, params: ElicitRequestParams) - async def test_bare_elicit_dependency_restored_as_model(): # A `-> Elicit[Login]` (bare, no union) resolver feeds a dependent resolver. After # the round-trip the dependency must come back as a Login model, not a raw dict. - mcp = MCPServer(name="BareElicitDep") + mcp = MCPServer(name="BareElicitDep", request_state_security=RequestStateSecurity.ephemeral()) async def login(ctx: Context) -> Elicit[Login]: return Elicit("user?", Login) @@ -1045,7 +1099,7 @@ async def callback(context: ClientRequestContext, params: ElicitRequestParams) - async def test_accept_with_no_content_is_an_error_not_a_cancel(mode: Literal["legacy", "auto"]): # Both transports must agree: mode="legacy" elicits synchronously mid-call, # mode="auto" rides the 2026-07-28 input_required loop. - mcp = MCPServer(name="AcceptNoContent") + mcp = MCPServer(name="AcceptNoContent", request_state_security=RequestStateSecurity.ephemeral()) async def ask(ctx: Context) -> Elicit[Login]: return Elicit("user?", Login) @@ -1069,7 +1123,7 @@ async def test_eliciting_tool_without_client_capability_is_a_protocol_error(): # The server must not send an `input_requests` entry the client has not declared # capability for: with no `elicitation` declared (no callback), the call fails as # a -32021 protocol error, not a CallToolResult execution failure. - mcp = MCPServer(name="NoElicitationCapability") + mcp = MCPServer(name="NoElicitationCapability", request_state_security=RequestStateSecurity.ephemeral()) async def ask(ctx: Context) -> Elicit[Login]: return Elicit("user?", Login) @@ -1088,7 +1142,7 @@ async def tool(login: Annotated[Login, Resolve(ask)]) -> str: @pytest.mark.anyio async def test_independent_nested_deps_batch_into_one_round(): - mcp = MCPServer(name="NestedBatch") + mcp = MCPServer(name="NestedBatch", request_state_security=RequestStateSecurity.ephemeral()) async def ask_a(ctx: Context) -> Elicit[Login]: return Elicit("A name?", Login) @@ -1135,7 +1189,7 @@ def answer(key: str, params: ElicitRequestFormParams) -> ElicitResult: async def test_deep_chain_keeps_early_answers_across_rounds(): # A 4-round dependency chain where an early answer (A) must survive in # request_state while later resolvers are asked. It must be asked exactly once. - mcp = MCPServer(name="DeepChain") + mcp = MCPServer(name="DeepChain", request_state_security=RequestStateSecurity.ephemeral()) async def ra(ctx: Context) -> Elicit[Login]: return Elicit("A name?", Login) @@ -1177,7 +1231,7 @@ async def callback(context: ClientRequestContext, params: ElicitRequestParams) - async def test_factory_closures_get_distinct_wire_keys(): # Two resolvers from one factory share module:qualname; they must still get # distinct questions and their own values (regression: they collided on the wire). - mcp = MCPServer(name="FactoryClosures") + mcp = MCPServer(name="FactoryClosures", request_state_security=RequestStateSecurity.ephemeral()) def make(label: str): async def resolver(ctx: Context) -> Elicit[Login]: @@ -1222,7 +1276,7 @@ async def test_eliciting_resolver_without_elicit_arm_restores_a_typed_model(): # round flow, must still come back as a Login model (not a raw dict): restore # validates against the live `Elicit.schema` the body produced, not the lying # annotation, so a dependent resolver/tool can use its attributes. - mcp = MCPServer(name="LyingAnnotation") + mcp = MCPServer(name="LyingAnnotation", request_state_security=RequestStateSecurity.ephemeral()) # Annotated without an `Elicit[T]` return arm; the body asks anyway. async def login(ctx: Context) -> object: @@ -1274,7 +1328,7 @@ async def test_declined_outcome_persists_in_request_state_and_is_not_reasked(): # A decline is recorded in `request_state` just like an accept: RB elicits only # after seeing RA's decline, so RA's outcome must survive into the round that # answers RB without RA being asked again. - mcp = MCPServer(name="DeclinePersists") + mcp = MCPServer(name="DeclinePersists", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) async def ra(ctx: Context) -> Elicit[Login]: return Elicit("user?", Login) @@ -1308,7 +1362,7 @@ async def act( assert second.input_requests is not None (rb_key,) = second.input_requests # only RB's question; RA is not re-asked assert rb_key != ra_key - assert _decode_state(second.request_state)[ra_key].action == "decline" + assert _outcomes_on_the_wire(second.request_state)[ra_key]["action"] == "decline" final = await client.session.call_tool( "act", @@ -1325,9 +1379,8 @@ async def act( @pytest.mark.anyio async def test_unknown_response_keys_and_ghost_state_entries_are_ignored(): # `input_responses` keys the server never asked for and `request_state` outcome - # entries matching no resolver are tolerated (both are client-supplied), and the - # ghost state entry is not echoed into any later round's `request_state`. - mcp = MCPServer(name="GhostKeys") + # entries matching no resolver are tolerated and not echoed into later rounds. + mcp = MCPServer(name="GhostKeys", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) async def ra(ctx: Context) -> Elicit[Login]: return Elicit("user?", Login) @@ -1349,8 +1402,13 @@ async def act( assert first.request_state is not None (ra_key,) = first.input_requests - spliced = json.loads(first.request_state) - spliced["outcomes"]["ghost"] = {"action": "accept", "data": {"username": "spooky"}} + spliced = json.loads(_unseal_inner(first.request_state)) + # A well-formed v2 entry under an unknown key: dropped as unknown, not as malformed. + spliced["outcomes"]["ghost"] = { + "action": "accept", + "data": {"username": "spooky"}, + "q": _question_digest(Elicit("user?", Login)), + } second = await client.session.call_tool( "act", {}, @@ -1358,13 +1416,13 @@ async def act( ra_key: ElicitResult(action="accept", content={"username": "octocat"}), "ghost": ElicitResult(action="accept", content={"username": "spooky"}), }, - request_state=json.dumps(spliced), + request_state=_sealed_state(json.dumps(spliced), tool="act", args={}, audience="GhostKeys"), allow_input_required=True, ) assert isinstance(second, InputRequiredResult) assert second.input_requests is not None (rb_key,) = second.input_requests - outcomes = _decode_state(second.request_state) + outcomes = _outcomes_on_the_wire(second.request_state) assert ra_key in outcomes assert "ghost" not in outcomes # the spliced entry is dropped, not carried onward @@ -1389,10 +1447,8 @@ async def act( ], ) async def test_forged_state_entry_failing_the_schema_is_reasked_not_an_error(forged_data: str | dict[str, bool]): - # `request_state` is client-trusted JSON: an accept entry whose data does not - # validate against the resolver's schema reads as no recorded progress, so the - # question is asked again (not an error) and a proper answer completes the call. - mcp = MCPServer(name="ForgedState") + # Authenticated state is not schema-trusted: a failing accept entry reads as no progress and is re-asked. + mcp = MCPServer(name="ForgedState", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) async def ask(ctx: Context) -> Elicit[Login]: return Elicit("user?", Login) @@ -1408,15 +1464,23 @@ async def whoami(login: Annotated[Login, Resolve(ask)]) -> str: assert first.request_state is not None (key,) = first.input_requests - forged = json.loads(first.request_state) - forged["outcomes"][key] = {"action": "accept", "data": forged_data} + forged = json.loads(_unseal_inner(first.request_state)) + # The digest matches the live question, so the entry stands or falls on schema alone. + forged["outcomes"][key] = { + "action": "accept", + "data": forged_data, + "q": _question_digest(Elicit("user?", Login)), + } second = await client.session.call_tool( - "whoami", {}, request_state=json.dumps(forged), allow_input_required=True + "whoami", + {}, + request_state=_sealed_state(json.dumps(forged), tool="whoami", args={}, audience="ForgedState"), + allow_input_required=True, ) assert isinstance(second, InputRequiredResult) # re-asked, not an error assert second.input_requests is not None assert set(second.input_requests) == {key} - assert _decode_state(second.request_state) == {} # the forged entry is dropped + assert _outcomes_on_the_wire(second.request_state) == {} # the forged entry is dropped final = await client.session.call_tool( "whoami", @@ -1436,7 +1500,7 @@ async def test_schema_mismatched_fresh_answer_fails_the_call_without_pydantic_le # An accepted answer whose content fails the requested schema fails the call # with the framework's own message on both transports; pydantic's error text # (which carries an "errors.pydantic.dev" link) must not leak to the client. - mcp = MCPServer(name="MismatchedAnswer") + mcp = MCPServer(name="MismatchedAnswer", request_state_security=RequestStateSecurity.ephemeral()) async def ask(ctx: Context) -> Elicit[Login]: return Elicit("user?", Login) @@ -1464,7 +1528,7 @@ async def test_auto_driver_gives_up_when_the_chain_outlasts_its_round_budget(): # than the default `input_required_max_rounds`, so `client.call_tool` must raise # rather than loop on. The pure `count_leg` resolver is never persisted, so it # re-runs on every server leg: its final value is the exact number of legs. - mcp = MCPServer(name="TooDeep") + mcp = MCPServer(name="TooDeep", request_state_security=RequestStateSecurity.ephemeral()) legs = 0 async def count_leg(ctx: Context) -> int: @@ -1514,7 +1578,7 @@ async def test_aliased_elicitation_model_round_trips_through_request_state(): # the same validation the answer originally passed - aliases and all. A # re-derived (field-name) shape would fail validation on the round after # next, drop the stored answer, and re-ask the user forever. - mcp = MCPServer(name="AliasState") + mcp = MCPServer(name="AliasState", request_state_security=RequestStateSecurity.ephemeral()) async def who(ctx: Context) -> Elicit[Handle]: return Elicit("handle?", Handle) @@ -1566,7 +1630,7 @@ async def test_divergent_validation_and_serialization_aliases_round_trip(): # the validated model (which serializes under the *serialization* alias) would # produce data the schema's own validation rejects, dropping the stored answer # on the round after next and re-asking the user. - mcp = MCPServer(name="DivergentAliases") + mcp = MCPServer(name="DivergentAliases", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) async def who(ctx: Context) -> Elicit[Account]: return Elicit("account?", Account) @@ -1602,7 +1666,7 @@ async def act( (go_key,) = second.input_requests # only the dependent question; the stored answer holds assert go_key != who_key # The stored entry is the client's wire content, not a re-serialization of it. - assert _decode_state(second.request_state)[who_key].data == {"vUser": "octocat"} + assert _outcomes_on_the_wire(second.request_state)[who_key]["data"] == {"vUser": "octocat"} final = await client.session.call_tool( "act", @@ -1621,7 +1685,7 @@ async def test_state_entry_never_replaces_a_resolver_computed_value(): # `request_state` is client-echoed: an accept entry under a resolver's wire key # must only satisfy a question the resolver is actually asking, never stand in # for the body's own computation on a branch that does not ask. - mcp = MCPServer(name="StateVsBody") + mcp = MCPServer(name="StateVsBody", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) calls = {"decide": 0} async def decide(ctx: Context) -> Restock | Elicit[Restock]: @@ -1632,11 +1696,17 @@ async def decide(ctx: Context) -> Restock | Elicit[Restock]: async def plan_restock(restock: Annotated[Restock, Resolve(decide)]) -> str: return str(restock.needed) - wire_key = f"{decide.__module__}:{decide.__qualname__}" - crafted = json.dumps({"v": 1, "outcomes": {wire_key: {"action": "accept", "data": {"needed": True}}}}) + # A decodable v2 entry; the resolver never asks, so it must go unconsulted, not dropped as malformed. + entry = {"action": "accept", "data": {"needed": True}, "q": _question_digest(Elicit("Restock?", Restock))} + crafted = json.dumps({"v": 3, "outcomes": {_wire_key(decide): entry}}) async with Client(mcp, elicitation_callback=_never) as client: - result = await client.session.call_tool("plan_restock", {}, request_state=crafted, allow_input_required=True) + result = await client.session.call_tool( + "plan_restock", + {}, + request_state=_sealed_state(crafted, tool="plan_restock", args={}, audience="StateVsBody"), + allow_input_required=True, + ) assert isinstance(result, CallToolResult) assert isinstance(result.content[0], TextContent) # The body ran and its computation won; the crafted entry was never consulted. @@ -1648,7 +1718,7 @@ async def plan_restock(restock: Annotated[Restock, Resolve(decide)]) -> str: async def test_state_decline_entry_for_a_pure_resolver_is_ignored(): # A decline/cancel entry can only answer a question; a resolver with no Elicit # arm never asks one, so such an entry cannot suppress its computed value. - mcp = MCPServer(name="PureVsDecline") + mcp = MCPServer(name="PureVsDecline", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) async def lookup(ctx: Context) -> Login: return Login(username="server-side") @@ -1657,11 +1727,17 @@ async def lookup(ctx: Context) -> Login: async def whoami(login: Annotated[Login, Resolve(lookup)]) -> str: return login.username - wire_key = f"{lookup.__module__}:{lookup.__qualname__}" - crafted = json.dumps({"v": 1, "outcomes": {wire_key: {"action": "decline"}}}) + # A decodable v2 entry: `lookup` never asks, so no digest can make the decline apply. + entry = {"action": "decline", "q": _question_digest(Elicit("user?", Login))} + crafted = json.dumps({"v": 3, "outcomes": {_wire_key(lookup): entry}}) async with Client(mcp, elicitation_callback=_never) as client: - result = await client.session.call_tool("whoami", {}, request_state=crafted, allow_input_required=True) + result = await client.session.call_tool( + "whoami", + {}, + request_state=_sealed_state(crafted, tool="whoami", args={}, audience="PureVsDecline"), + allow_input_required=True, + ) assert isinstance(result, CallToolResult) assert not result.is_error assert isinstance(result.content[0], TextContent) @@ -1673,7 +1749,7 @@ async def test_dynamic_schema_resolver_restores_across_rounds(): # `-> Elicit[BaseModel]` is the natural annotation for `create_model(...)` # schemas; the restored answer must validate against the live question's # schema, so the dynamic shape works across a multi-question chain. - mcp = MCPServer(name="DynamicSchema") + mcp = MCPServer(name="DynamicSchema", request_state_security=RequestStateSecurity.ephemeral()) dyn = create_model("Dyn", token=(str, ...)) async def first(ctx: Context) -> Elicit[BaseModel]: @@ -1729,7 +1805,7 @@ def answer(key: str, params: ElicitRequestFormParams) -> ElicitResult: def test_tool_combining_resolvers_with_input_required_return_is_rejected(annotation: Any): # A call has one input_responses/request_state channel: resolver elicitation # and a hand-rolled InputRequiredResult body cannot share it. - mcp = MCPServer(name="ChannelOwnership") + mcp = MCPServer(name="ChannelOwnership", request_state_security=RequestStateSecurity.ephemeral()) async def lookup(ctx: Context) -> Login: return Login(username="x") # pragma: no cover - registration is rejected @@ -1755,7 +1831,7 @@ def test_unevaluable_alias_and_parameterized_generics_declare_no_arm(): # can see and must not break registration (the in-call guard still covers a # body that returns an InputRequiredResult anyway). A parameterized generic # return is never the InputRequiredResult class either. - mcp = MCPServer(name="RegistrationTolerance") + mcp = MCPServer(name="RegistrationTolerance", request_state_security=RequestStateSecurity.ephemeral()) async def lookup(ctx: Context) -> Login: return Login(username="x") # pragma: no cover - only registration is exercised @@ -1778,7 +1854,7 @@ async def test_tool_returning_input_required_dynamically_with_resolvers_is_an_er # The annotated form of this combination is rejected at registration; a body # that returns an InputRequiredResult without declaring it fails loudly at the # same boundary instead of silently fighting the resolvers for the channel. - mcp = MCPServer(name="DynamicChannelClash") + mcp = MCPServer(name="DynamicChannelClash", request_state_security=RequestStateSecurity.ephemeral()) async def lookup(ctx: Context) -> Login: return Login(username="x") @@ -1792,3 +1868,500 @@ async def sneaky(login: Annotated[Login, Resolve(lookup)]): assert result.is_error assert isinstance(result.content[0], TextContent) assert "the multi-round flow is driven either by resolvers or by the tool body" in result.content[0].text + + +def test_question_digest_pins_the_rendered_question(): + # Computed over the rendered wire question: identical Elicits agree, any change diverges. + digest = _question_digest(Elicit("Name?", Login)) + assert digest == _question_digest(Elicit("Name?", Login)) + assert digest != _question_digest(Elicit("Your name, please?", Login)) + assert digest != _question_digest(Elicit("Name?", Confirm)) + # A 16-byte sha256 prefix, base64url without padding. + assert len(digest) == 22 and "=" not in digest + + +def test_state_round_trips_question_digests_at_v3(): + # v2 carries digests for every action and round-trips exactly; v1 (mid rolling deploy) reads as no progress. + entries = { + "a": _StateEntry(action="accept", data={"username": "octocat"}, q="qa"), + "b": _StateEntry(action="decline", q="qb"), + "c": _StateEntry(action="cancel", q="qc"), + } + encoded = _encode_state(entries, {}) + assert json.loads(encoded)["v"] == 3 + assert _decode_state(encoded).outcomes == entries + v1 = json.dumps({"v": 1, "outcomes": {"a": {"action": "decline"}}}) + assert _decode_state(v1).outcomes == {} + + +@pytest.mark.anyio +async def test_restored_answer_with_matching_digest_completes_without_reasking(): + mcp = MCPServer(name="PinHappyPath", request_state_security=RequestStateSecurity.ephemeral()) + + async def who(ctx: Context) -> Elicit[Login]: + return Elicit("Who?", Login) + + async def check(login: Annotated[Login, Resolve(who)]) -> Elicit[Confirm]: + return Elicit(f"Go as {login.username}?", Confirm) + + @mcp.tool() + async def act( + login: Annotated[Login, Resolve(who)], + confirm: Annotated[Confirm, Resolve(check)], + ) -> str: + return f"{login.username}:{confirm.ok}" + + async with Client(mcp, elicitation_callback=_never) as client: + first = await client.session.call_tool("act", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.input_requests is not None + assert set(first.input_requests) == {_wire_key(who)} + + second = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(who): ElicitResult(action="accept", content={"username": "octocat"})}, + request_state=first.request_state, + allow_input_required=True, + ) + assert isinstance(second, InputRequiredResult) + assert second.input_requests is not None + # Only the dependent question; the stored answer holds, "Who?" is not re-asked. + assert set(second.input_requests) == {_wire_key(check)} + + final = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(check): ElicitResult(action="accept", content={"ok": True})}, + request_state=second.request_state, + allow_input_required=True, + ) + assert isinstance(final, CallToolResult) + assert isinstance(final.content[0], TextContent) + assert final.content[0].text == "octocat:True" + + +@pytest.mark.anyio +async def test_restored_entry_is_repersisted_with_its_question_digest_intact(): + # A restored entry must ride into the next round's state digest-intact, or it would be re-asked next round. + mcp = MCPServer(name="RepersistPin", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) + + async def who(ctx: Context) -> Elicit[Login]: + return Elicit("Who?", Login) + + async def check(login: Annotated[Login, Resolve(who)]) -> Elicit[Confirm]: + return Elicit(f"Go as {login.username}?", Confirm) + + async def plan(confirm: Annotated[Confirm, Resolve(check)], ctx: Context) -> Elicit[Restock]: + return Elicit("Restock too?", Restock) + + # The body never runs (a question always pends); a bare `...` costs no coverage. + @mcp.tool() + async def act(restock: Annotated[Restock, Resolve(plan)]) -> str: ... + + async with Client(mcp, elicitation_callback=_never) as client: + first = await client.session.call_tool("act", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + second = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(who): ElicitResult(action="accept", content={"username": "octocat"})}, + request_state=first.request_state, + allow_input_required=True, + ) + assert isinstance(second, InputRequiredResult) + third = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(check): ElicitResult(action="accept", content={"ok": True})}, + request_state=second.request_state, + allow_input_required=True, + ) + assert isinstance(third, InputRequiredResult) + + round_two = _outcomes_on_the_wire(second.request_state) + round_three = _outcomes_on_the_wire(third.request_state) + # Accept entries are pinned to the exact rendered question they answered. + assert round_two[_wire_key(who)]["q"] == _question_digest(Elicit("Who?", Login)) + assert round_three[_wire_key(check)]["q"] == _question_digest(Elicit("Go as octocat?", Confirm)) + assert round_three[_wire_key(who)] == round_two[_wire_key(who)] + + +@pytest.mark.anyio +async def test_decline_and_cancel_entries_carry_the_question_digest(): + mcp = MCPServer(name="PinAllActions", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) + + async def ask_name(ctx: Context) -> Elicit[Login]: + return Elicit("Name?", Login) + + async def ask_confirm(ctx: Context) -> Elicit[Confirm]: + return Elicit("Confirm?", Confirm) + + async def ask_restock(ctx: Context) -> Elicit[Restock]: + return Elicit("Restock?", Restock) + + # The body never runs (a question always pends); a bare `...` costs no coverage. + @mcp.tool() + async def act( + name: Annotated[ElicitationResult[Login], Resolve(ask_name)], + confirm: Annotated[ElicitationResult[Confirm], Resolve(ask_confirm)], + restock: Annotated[Restock, Resolve(ask_restock)], + ) -> str: ... + + async with Client(mcp, elicitation_callback=_never) as client: + first = await client.session.call_tool("act", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + # The third question stays unanswered, so the call pends and outcomes hit the wire. + second = await client.session.call_tool( + "act", + {}, + input_responses={ + _wire_key(ask_name): ElicitResult(action="decline"), + _wire_key(ask_confirm): ElicitResult(action="cancel"), + }, + request_state=first.request_state, + allow_input_required=True, + ) + assert isinstance(second, InputRequiredResult) + + outcomes = _outcomes_on_the_wire(second.request_state) + assert outcomes[_wire_key(ask_name)]["action"] == "decline" + assert outcomes[_wire_key(ask_name)]["q"] == _question_digest(Elicit("Name?", Login)) + assert outcomes[_wire_key(ask_confirm)]["action"] == "cancel" + assert outcomes[_wire_key(ask_confirm)]["q"] == _question_digest(Elicit("Confirm?", Confirm)) + + +@pytest.mark.anyio +async def test_state_entry_without_a_question_digest_is_dropped_and_reasked(): + # An entry with no digest cannot prove its question, so it reads as no progress and is re-asked. + mcp = MCPServer(name="UnpinnedEntry", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) + + async def ask(ctx: Context) -> Elicit[Login]: + return Elicit("user?", Login) + + @mcp.tool() + async def whoami(login: Annotated[Login, Resolve(ask)]) -> str: + return login.username + + async with Client(mcp, elicitation_callback=_never) as client: + first = await client.session.call_tool("whoami", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.input_requests is not None + (key,) = first.input_requests + + # Schema-valid accept data under the live key, but no "q" pin. + entry = {"action": "accept", "data": {"username": "spooky"}} + crafted = json.dumps({"v": 3, "outcomes": {key: entry}}) + second = await client.session.call_tool( + "whoami", + {}, + request_state=_sealed_state(crafted, tool="whoami", args={}, audience="UnpinnedEntry"), + allow_input_required=True, + ) + assert isinstance(second, InputRequiredResult) # re-asked, not honored and not an error + assert second.input_requests is not None + assert set(second.input_requests) == {key} + assert _outcomes_on_the_wire(second.request_state) == {} # the unpinned entry is dropped + + final = await client.session.call_tool( + "whoami", + {}, + input_responses={key: ElicitResult(action="accept", content={"username": "octocat"})}, + request_state=second.request_state, + allow_input_required=True, + ) + assert isinstance(final, CallToolResult) + assert isinstance(final.content[0], TextContent) + assert final.content[0].text == "octocat" + + +@pytest.mark.anyio +async def test_reworded_question_drops_the_stored_answer_and_reasks(): + # An answer holds only while its question is byte-identical: a reword (redeploy) drops it and re-asks. + mcp = MCPServer(name="RewordAccept", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) + wording = {"deploy": "Deploy to prod?"} + + async def ask_deploy(ctx: Context) -> Elicit[Confirm]: + return Elicit(wording["deploy"], Confirm) + + async def ask_name(ctx: Context) -> Elicit[Login]: + return Elicit("Name?", Login) + + @mcp.tool() + async def act( + deploy: Annotated[Confirm, Resolve(ask_deploy)], + name: Annotated[Login, Resolve(ask_name)], + ) -> str: + return f"{deploy.ok}:{name.username}" + + async with Client(mcp, elicitation_callback=_never) as client: + first = await client.session.call_tool("act", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + second = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(ask_deploy): ElicitResult(action="accept", content={"ok": True})}, + request_state=first.request_state, + allow_input_required=True, + ) + assert isinstance(second, InputRequiredResult) + assert _outcomes_on_the_wire(second.request_state)[_wire_key(ask_deploy)]["q"] == _question_digest( + Elicit("Deploy to prod?", Confirm) + ) + + wording["deploy"] = "Deploy to staging?" + + third = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(ask_name): ElicitResult(action="accept", content={"username": "octocat"})}, + request_state=second.request_state, + allow_input_required=True, + ) + # The stale answer is dropped and the reworded question is asked, not an error. + assert isinstance(third, InputRequiredResult) + assert third.input_requests is not None + assert set(third.input_requests) == {_wire_key(ask_deploy)} + question = third.input_requests[_wire_key(ask_deploy)].params + assert isinstance(question, ElicitRequestFormParams) + assert question.message == "Deploy to staging?" + # The sibling answer recorded in the same state survives the drop. + outcomes = _outcomes_on_the_wire(third.request_state) + assert _wire_key(ask_deploy) not in outcomes + assert outcomes[_wire_key(ask_name)]["action"] == "accept" + + final = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(ask_deploy): ElicitResult(action="accept", content={"ok": True})}, + request_state=third.request_state, + allow_input_required=True, + ) + assert isinstance(final, CallToolResult) + assert isinstance(final.content[0], TextContent) + assert final.content[0].text == "True:octocat" + + +@pytest.mark.anyio +async def test_decline_of_a_reworded_question_does_not_suppress_the_new_question(): + # A decline pinned to the old wording must not suppress the reworded question. + mcp = MCPServer(name="RewordDecline", request_state_security=RequestStateSecurity.ephemeral()) + wording = {"q": "Use defaults?"} + + async def ask(ctx: Context) -> Elicit[Confirm]: + return Elicit(wording["q"], Confirm) + + async def ask_name(ctx: Context) -> Elicit[Login]: + return Elicit("Name?", Login) + + @mcp.tool() + async def act( + choice: Annotated[ElicitationResult[Confirm], Resolve(ask)], + name: Annotated[Login, Resolve(ask_name)], + ) -> str: + kind = "accepted" if isinstance(choice, AcceptedElicitation) else "declined" + return f"{kind}:{name.username}" + + async with Client(mcp, elicitation_callback=_never) as client: + first = await client.session.call_tool("act", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + second = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(ask): ElicitResult(action="decline")}, + request_state=first.request_state, + allow_input_required=True, + ) + assert isinstance(second, InputRequiredResult) + + wording["q"] = "Use the new defaults?" + + third = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(ask_name): ElicitResult(action="accept", content={"username": "octocat"})}, + request_state=second.request_state, + allow_input_required=True, + ) + # The stale decline is dropped and the reworded question is asked again. + assert isinstance(third, InputRequiredResult) + assert third.input_requests is not None + assert set(third.input_requests) == {_wire_key(ask)} + question = third.input_requests[_wire_key(ask)].params + assert isinstance(question, ElicitRequestFormParams) + assert question.message == "Use the new defaults?" + + final = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(ask): ElicitResult(action="accept", content={"ok": True})}, + request_state=third.request_state, + allow_input_required=True, + ) + # Accepting the new question proves the old decline did not stick. + assert isinstance(final, CallToolResult) + assert isinstance(final.content[0], TextContent) + assert final.content[0].text == "accepted:octocat" + + +@pytest.mark.anyio +async def test_reworded_question_reasks_even_when_the_answer_first_arrives(): + # The pend round records each question's digest in the state, so an answer that + # first arrives after a reword (redeploy between ask and retry) re-asks instead + # of being consumed as consent to the new wording. + mcp = MCPServer(name="RewordArrival", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) + wording = {"deploy": "Deploy to prod?"} + + async def ask_deploy(ctx: Context) -> Elicit[Confirm]: + return Elicit(wording["deploy"], Confirm) + + @mcp.tool() + async def act(deploy: Annotated[Confirm, Resolve(ask_deploy)]) -> str: + return f"deployed:{deploy.ok}" + + async with Client(mcp, elicitation_callback=_never) as client: + first = await client.session.call_tool("act", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + pended = json.loads(_unseal_inner(first.request_state))["asked"] + assert pended == {_wire_key(ask_deploy): _question_digest(Elicit("Deploy to prod?", Confirm))} + + wording["deploy"] = "Deploy to staging?" + + second = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(ask_deploy): ElicitResult(action="accept", content={"ok": True})}, + request_state=first.request_state, + allow_input_required=True, + ) + # The stale answer to the old wording is not consumed; the reworded question is asked. + assert isinstance(second, InputRequiredResult) + assert second.input_requests is not None + question = second.input_requests[_wire_key(ask_deploy)].params + assert isinstance(question, ElicitRequestFormParams) + assert question.message == "Deploy to staging?" + + final = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(ask_deploy): ElicitResult(action="accept", content={"ok": True})}, + request_state=second.request_state, + allow_input_required=True, + ) + assert isinstance(final, CallToolResult) + assert isinstance(final.content[0], TextContent) + assert final.content[0].text == "deployed:True" + + +@pytest.mark.anyio +async def test_an_answer_without_the_echoed_state_is_reasked_not_consumed(): + # Without the echoed state there is no record of which question the client was + # shown, so an answer arriving stateless re-asks instead of being consumed. + mcp = MCPServer(name="Stateless", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) + + async def ask(ctx: Context) -> Elicit[Confirm]: + return Elicit("Proceed?", Confirm) + + @mcp.tool() + async def act(go: Annotated[Confirm, Resolve(ask)]) -> str: + return f"went:{go.ok}" + + async with Client(mcp, elicitation_callback=_never) as client: + first = await client.session.call_tool("act", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + + second = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(ask): ElicitResult(action="accept", content={"ok": True})}, + allow_input_required=True, + ) + assert isinstance(second, InputRequiredResult) + + final = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(ask): ElicitResult(action="accept", content={"ok": True})}, + request_state=second.request_state, + allow_input_required=True, + ) + assert isinstance(final, CallToolResult) + assert isinstance(final.content[0], TextContent) + assert final.content[0].text == "went:True" + + +@pytest.mark.anyio +async def test_recorded_answer_containing_a_lone_surrogate_survives_to_later_rounds(): + # The state encoder escapes lone surrogates, so the decoder must parse them back: + # a recorded answer with one must restore on the next round, not silently re-ask. + mcp = MCPServer(name="Surrogate", request_state_security=RequestStateSecurity(keys=[_PIN_KEY])) + + async def ask_name(ctx: Context) -> Elicit[Login]: + return Elicit("Name?", Login) + + async def ask_confirm(ctx: Context) -> Elicit[Confirm]: + return Elicit("Confirm?", Confirm) + + @mcp.tool() + async def act( + name: Annotated[Login, Resolve(ask_name)], + go: Annotated[Confirm, Resolve(ask_confirm)], + ) -> str: + return f"{name.username}:{go.ok}" + + async with Client(mcp, elicitation_callback=_never) as client: + first = await client.session.call_tool("act", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + + second = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(ask_name): ElicitResult(action="accept", content={"username": "oc\ud800t"})}, + request_state=first.request_state, + allow_input_required=True, + ) + # The surrogate-bearing answer is recorded; only the unanswered question remains. + assert isinstance(second, InputRequiredResult) + assert second.input_requests is not None + assert set(second.input_requests) == {_wire_key(ask_confirm)} + + final = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(ask_confirm): ElicitResult(action="accept", content={"ok": True})}, + request_state=second.request_state, + allow_input_required=True, + ) + assert isinstance(final, CallToolResult) + assert isinstance(final.content[0], TextContent) + assert final.content[0].text == "oc\ud800t:True" + + +@pytest.mark.anyio +async def test_resolver_elicitation_seals_and_completes_on_a_fully_default_server(): + # The headline default-posture invariant: a resolver tool on a bare MCPServer() - + # no name, no security configuration - mints sealed state and completes the round. + mcp = MCPServer() + + async def ask(ctx: Context) -> Elicit[Confirm]: + return Elicit("Go?", Confirm) + + @mcp.tool() + async def act(go: Annotated[Confirm, Resolve(ask)]) -> str: + return f"went:{go.ok}" + + async with Client(mcp, elicitation_callback=_never) as client: + first = await client.session.call_tool("act", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.request_state is not None + assert first.request_state.startswith("v1.") + final = await client.session.call_tool( + "act", + {}, + input_responses={_wire_key(ask): ElicitResult(action="accept", content={"ok": True})}, + request_state=first.request_state, + allow_input_required=True, + ) + assert isinstance(final, CallToolResult) + assert isinstance(final.content[0], TextContent) + assert final.content[0].text == "went:True" diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index b4a1184580..2ae9d5ff74 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -1866,7 +1866,8 @@ def get_user(user_id: str) -> str: assert exc_info.value.error.data == {"uri": "resource://users/999"} -async def test_tool_returning_input_required_result_reaches_client_unchanged(): +async def test_tool_returning_input_required_result_reaches_client_sealed(): + # Default posture: the wire carries an opaque sealed token, never the handler's plaintext. mcp = MCPServer() @mcp.tool() @@ -1878,7 +1879,7 @@ async def ask(ctx: Context) -> str | InputRequiredResult: result = await client.session.call_tool("ask", allow_input_required=True) assert isinstance(result, InputRequiredResult) - assert result.request_state == "round-1" + _assert_sealed(result.request_state, "round-1") assert result.input_requests is not None assert result.input_requests["roots"].method == "roots/list" @@ -1927,6 +1928,13 @@ async def greet(ctx: Context) -> str | InputRequiredResult: assert block.text == "Hello, Alice! (state=r1)" +def _assert_sealed(state: str | None, plaintext: str) -> None: + """The wire form is an opaque sealed token, never the handler's plaintext.""" + assert state is not None + assert state != plaintext + assert state.startswith("v1.") + + def _ask_who() -> ElicitRequest: return ElicitRequest( params=ElicitRequestFormParams( @@ -1940,9 +1948,9 @@ def _ask_who() -> ElicitRequest: ) -async def test_prompt_returning_input_required_result_reaches_client_unchanged(): - """A prompt function may return an InputRequiredResult and the pipeline passes it - through to the client (spec-mandated: SEP-2322 allows it on prompts/get).""" +async def test_prompt_returning_input_required_result_reaches_client_sealed(): + """A prompt function may return an InputRequiredResult and the pipeline delivers it + to the client with the state sealed (spec-mandated: SEP-2322 allows it on prompts/get).""" mcp = MCPServer() @mcp.prompt() @@ -1954,7 +1962,7 @@ async def briefing(ctx: Context) -> list[UserMessage] | InputRequiredResult: result = await client.session.get_prompt("briefing", allow_input_required=True) assert isinstance(result, InputRequiredResult) - assert result.request_state == "round-1" + _assert_sealed(result.request_state, "round-1") assert result.input_requests is not None assert result.input_requests["who"].method == "elicitation/create" @@ -2023,9 +2031,9 @@ async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: assert exc.value.error.message == "Handler returned an invalid result" -async def test_resource_template_returning_input_required_result_reaches_client_unchanged(): +async def test_resource_template_returning_input_required_result_reaches_client_sealed(): """A resource template function may return an InputRequiredResult and the pipeline - passes it through to the client (spec-mandated: SEP-2322 allows it on resources/read).""" + delivers it with the state sealed (spec-mandated: SEP-2322 allows it on resources/read).""" mcp = MCPServer() @mcp.resource("ask://{topic}") @@ -2037,7 +2045,7 @@ async def ask(topic: str, ctx: Context) -> str | InputRequiredResult: result = await client.session.read_resource("ask://databases", allow_input_required=True) assert isinstance(result, InputRequiredResult) - assert result.request_state == "round-1" + _assert_sealed(result.request_state, "round-1") assert result.input_requests is not None assert result.input_requests["who"].method == "elicitation/create" @@ -2121,22 +2129,26 @@ async def ask(topic: str, ctx: Context) -> str: return f"{topic} content" @mcp.tool() - async def outer(ctx: Context) -> str: + async def outer(ctx: Context) -> str | InputRequiredResult: + if ctx.input_responses is None: + return InputRequiredResult(input_requests={"who": _ask_who()}, request_state="outer-state") contents = list(await ctx.read_resource("ask://databases")) assert isinstance(contents[0].content, str) - return contents[0].content + return f"{contents[0].content} (state={ctx.request_state})" with anyio.fail_after(5): async with Client(mcp, mode="2026-07-28") as client: + r1 = await client.session.call_tool("outer", allow_input_required=True) + assert isinstance(r1, InputRequiredResult) result = await client.session.call_tool( "outer", input_responses={"who": ElicitResult(action="accept", content={"name": "Alice"})}, - request_state="outer-state", + request_state=r1.request_state, ) assert isinstance(result, CallToolResult) block = result.content[0] assert isinstance(block, TextContent) - assert block.text == "databases content" + assert block.text == "databases content (state=outer-state)" assert seen_responses == [None] assert seen_state == [None] diff --git a/tests/server/test_request_state.py b/tests/server/test_request_state.py new file mode 100644 index 0000000000..590c046e94 --- /dev/null +++ b/tests/server/test_request_state.py @@ -0,0 +1,479 @@ +"""Unit tests for `mcp.server.request_state`: codec, security policy, and default principal binding.""" + +import base64 +import string +from collections.abc import Callable +from typing import Any, cast + +import pytest +from inline_snapshot import snapshot + +from mcp.server.auth.middleware.auth_context import auth_context_var +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, authorization_context +from mcp.server.auth.provider import AccessToken, principal_components +from mcp.server.context import ServerRequestContext +from mcp.server.request_state import ( + AESGCMRequestStateCodec, + InvalidRequestState, + RequestStateSecurity, + authenticated_principal, +) + +_TOKEN_PREFIX = "v1." +_KID_LEN = 4 +_NONCE_LEN = 12 +_GCM_TAG_LEN = 16 +_BODY_FLOOR = _KID_LEN + _NONCE_LEN + _GCM_TAG_LEN +_B64URL_ALPHABET = set(string.ascii_letters + string.digits + "-_") + +_KEY_A = b"a" * 32 +_KEY_B = b"b" * 32 +_KEY_OLD = b"o" * 32 +_KEY_NEW = b"n" * 32 + +# Distinctive plaintext: opacity and log-secrecy assertions search for it. +_PAYLOAD = b"sentinel-plaintext-3f9c" +# `InvalidRequestState` messages are short log-only reason codes, never payload. +_REASON_CODE_MAX_LEN = 40 + + +def _b64u_nopad(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode().rstrip("=") + + +def _decode_body(token: str) -> bytes: + body = token.removeprefix(_TOKEN_PREFIX) + return base64.urlsafe_b64decode(body + "=" * (-len(body) % 4)) + + +def _flip_body_byte(token: str, index: int) -> str: + raw = bytearray(_decode_body(token)) + raw[index] ^= 0xFF + return _TOKEN_PREFIX + _b64u_nopad(bytes(raw)) + + +def _flip_prefix_char(token: str) -> str: + return "x" + token[1:] + + +def _flip_kid_byte(token: str) -> str: + return _flip_body_byte(token, 0) + + +def _flip_nonce_byte(token: str) -> str: + return _flip_body_byte(token, _KID_LEN) + + +def _flip_ciphertext_byte(token: str) -> str: + return _flip_body_byte(token, _KID_LEN + _NONCE_LEN) + + +def _flip_tag_byte(token: str) -> str: + return _flip_body_byte(token, -1) + + +def _inject_junk_chars(body: str) -> str: + return body[:10] + "!@\n*" + body[10:] + + +def _append_newline(body: str) -> str: + return body + "\n" + + +def _append_padding(body: str) -> str: + return body + "=" * (-len(body) % 4 or 4) + + +def _bare_context() -> ServerRequestContext[Any, Any]: + return ServerRequestContext( + session=cast("Any", None), + lifespan_context={}, + protocol_version="2026-07-28", + method="tools/call", + ) + + +class _StaticCodec: + """Minimal `RequestStateCodec` stand-in for policy tests; no real crypto.""" + + def seal(self, payload: bytes) -> str: + return payload.hex() + + def unseal(self, token: str) -> bytes: + return bytes.fromhex(token) + + +# -- AESGCMRequestStateCodec -------------------------------------------------- + + +@pytest.mark.parametrize( + "payload", + [ + pytest.param(b"", id="empty"), + pytest.param(b"plain ascii state", id="ascii"), + pytest.param("ünïcødé – 状態".encode(), id="multi-byte-utf8"), + pytest.param(bytes(range(256)), id="raw-binary"), + pytest.param(bytes(range(256)) * 256, id="64KiB"), + ], +) +def test_seal_unseal_round_trips_any_payload(payload: bytes) -> None: + """SDK-defined: the codec is byte-transparent, so any payload survives seal/unseal unchanged.""" + codec = AESGCMRequestStateCodec([_KEY_A]) + assert codec.unseal(codec.seal(payload)) == payload + + +def test_a_sealed_token_is_v1_plus_unpadded_b64url_over_kid_nonce_and_ciphertext() -> None: + """SDK-defined token format: "v1." plus unpadded base64url over kid(4) || nonce(12) || ciphertext+tag.""" + token = AESGCMRequestStateCodec([_KEY_A]).seal(_PAYLOAD) + assert token.startswith(_TOKEN_PREFIX) + body = token.removeprefix(_TOKEN_PREFIX) + assert "=" not in body + assert set(body) <= _B64URL_ALPHABET + assert len(_decode_body(token)) == _KID_LEN + _NONCE_LEN + len(_PAYLOAD) + _GCM_TAG_LEN + + +def test_two_seals_of_the_same_payload_produce_distinct_tokens_that_both_unseal() -> None: + """SDK-defined: every seal draws a fresh nonce, so identical payloads yield distinct tokens that both verify.""" + codec = AESGCMRequestStateCodec([_KEY_A]) + first = codec.seal(_PAYLOAD) + second = codec.seal(_PAYLOAD) + assert first != second + assert codec.unseal(first) == _PAYLOAD + assert codec.unseal(second) == _PAYLOAD + + +@pytest.mark.parametrize( + "corrupt", + [ + pytest.param(_flip_prefix_char, id="prefix-char"), + pytest.param(_flip_kid_byte, id="kid-byte"), + pytest.param(_flip_nonce_byte, id="nonce-byte"), + pytest.param(_flip_ciphertext_byte, id="ciphertext-byte"), + pytest.param(_flip_tag_byte, id="tag-byte"), + ], +) +def test_a_token_corrupted_in_any_region_is_rejected_without_echoing_the_payload( + corrupt: Callable[[str], str], +) -> None: + """Spec-mandated (basic/patterns/mrtr, server requirement 4): any corrupted token region is rejected.""" + codec = AESGCMRequestStateCodec([_KEY_A]) + token = codec.seal(_PAYLOAD) + with pytest.raises(InvalidRequestState) as exc: + codec.unseal(corrupt(token)) + message = str(exc.value) + assert len(message) <= _REASON_CODE_MAX_LEN + assert _PAYLOAD.decode() not in message + + +@pytest.mark.parametrize( + "token", + [ + pytest.param("", id="empty-string"), + pytest.param(_b64u_nopad(b"\x00" * 64), id="missing-prefix"), + pytest.param(_TOKEN_PREFIX + "!!!not-base64!!!", id="garbage-after-prefix"), + pytest.param(_TOKEN_PREFIX + _b64u_nopad(b"\x00" * (_BODY_FLOOR - 1)), id="below-floor"), + ], +) +def test_a_structurally_malformed_token_is_rejected(token: str) -> None: + """Spec-mandated (basic/patterns/mrtr, server requirement 4): tokens this codec never minted fail.""" + with pytest.raises(InvalidRequestState): + AESGCMRequestStateCodec([_KEY_A]).unseal(token) + + +def test_a_token_minted_under_a_key_outside_the_ring_is_rejected_as_unknown_key() -> None: + """Spec-mandated (basic/patterns/mrtr, server requirement 4): a foreign-key token fails as "unknown key".""" + token = AESGCMRequestStateCodec([_KEY_A]).seal(_PAYLOAD) + with pytest.raises(InvalidRequestState) as exc: + AESGCMRequestStateCodec([_KEY_B]).unseal(token) + assert str(exc.value) == "unknown key" + + +@pytest.mark.parametrize( + "ring", + [ + pytest.param([_KEY_OLD, _KEY_NEW], id="rotation-phase-1"), + pytest.param([_KEY_NEW, _KEY_OLD], id="rotation-phase-2"), + ], +) +def test_a_token_minted_under_the_old_key_unseals_under_any_ring_containing_it(ring: list[bytes]) -> None: + """SDK-defined rotation: every ring key verifies, so old-key state survives both rollout phases.""" + token = AESGCMRequestStateCodec([_KEY_OLD]).seal(_PAYLOAD) + assert AESGCMRequestStateCodec(ring).unseal(token) == _PAYLOAD + + +def test_the_first_ring_key_mints_and_later_ring_keys_only_verify() -> None: + """SDK-defined rotation: keys[0] is the minter, so [new, old] state verifies under [new] but not [old].""" + token = AESGCMRequestStateCodec([_KEY_NEW, _KEY_OLD]).seal(_PAYLOAD) + assert AESGCMRequestStateCodec([_KEY_NEW]).unseal(token) == _PAYLOAD + with pytest.raises(InvalidRequestState): + AESGCMRequestStateCodec([_KEY_OLD]).unseal(token) + + +def test_a_token_minted_under_a_retired_key_is_rejected() -> None: + """Spec-mandated (basic/patterns/mrtr, server requirement 4): retired-key state fails verification.""" + token = AESGCMRequestStateCodec([_KEY_OLD]).seal(_PAYLOAD) + with pytest.raises(InvalidRequestState): + AESGCMRequestStateCodec([_KEY_NEW]).unseal(token) + + +def test_an_empty_key_ring_is_rejected_at_construction() -> None: + """SDK-defined: an empty ring is a configuration error caught at construction.""" + with pytest.raises(ValueError) as exc: + AESGCMRequestStateCodec([]) + assert str(exc.value) == snapshot("AESGCMRequestStateCodec requires at least one key") + + +def test_a_key_shorter_than_32_bytes_is_rejected_with_generation_guidance() -> None: + """SDK-defined: keys must carry at least 32 bytes; the error includes generation guidance.""" + with pytest.raises(ValueError) as exc: + AESGCMRequestStateCodec([b"k" * 31]) + assert str(exc.value) == snapshot( + "request-state keys must be at least 32 bytes of secret randomness; keys[0] is 31 bytes. " + 'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"' + ) + + +def test_a_duplicate_key_in_the_ring_is_rejected_at_construction() -> None: + """SDK-defined: duplicate ring keys are a rotation mistake caught at construction.""" + with pytest.raises(ValueError) as exc: + AESGCMRequestStateCodec([_KEY_A, _KEY_A]) + assert str(exc.value) == snapshot("keys[1] duplicates an earlier ring key") + + +def test_a_non_key_typed_ring_entry_is_rejected_naming_its_index_and_type() -> None: + """SDK-defined: a non-key ring entry raises a TypeError naming its index and type, in codec and policy.""" + with pytest.raises(TypeError) as exc: + AESGCMRequestStateCodec([_KEY_A, cast("Any", 32)]) + assert str(exc.value) == snapshot("request-state keys must be bytes, bytearray, or str; keys[1] is int") + with pytest.raises(TypeError) as exc: + RequestStateSecurity(keys=[cast("Any", 32)]) + assert str(exc.value) == snapshot("request-state keys must be bytes, bytearray, or str; keys[0] is int") + + +def test_a_mixed_ring_of_bytes_bytearray_and_str_entries_still_works() -> None: + """SDK-defined: bytes, bytearray, and str keys interoperate in one ring.""" + codec = AESGCMRequestStateCodec([_KEY_A, bytearray(_KEY_B), "c" * 32]) + assert codec.unseal(codec.seal(_PAYLOAD)) == _PAYLOAD + assert codec.unseal(AESGCMRequestStateCodec([bytearray(_KEY_B)]).seal(_PAYLOAD)) == _PAYLOAD + assert codec.unseal(AESGCMRequestStateCodec(["c" * 32]).seal(_PAYLOAD)) == _PAYLOAD + + +def test_a_str_key_is_equivalent_to_its_utf8_bytes_form() -> None: + """SDK-defined: a str key is utf-8 encoded, so it is the same ring key as its bytes spelling.""" + token = AESGCMRequestStateCodec(["k" * 32]).seal(_PAYLOAD) + assert AESGCMRequestStateCodec([b"k" * 32]).unseal(token) == _PAYLOAD + + +def test_bytearray_key_material_is_copied_at_construction() -> None: + """SDK-defined: key bytes are copied at construction; mutating the caller's bytearray later has no effect.""" + material = bytearray(b"m" * 32) + codec = AESGCMRequestStateCodec([cast("Any", material)]) + minted_before_mutation = codec.seal(_PAYLOAD) + material[:] = b"X" * 32 + assert codec.unseal(minted_before_mutation) == _PAYLOAD + assert AESGCMRequestStateCodec([b"m" * 32]).unseal(codec.seal(_PAYLOAD)) == _PAYLOAD + + +def test_the_token_reveals_the_payload_neither_in_its_text_nor_its_decoded_bytes() -> None: + """SDK-defined: the token is encrypted, not merely signed, so the plaintext appears nowhere in it.""" + token = AESGCMRequestStateCodec([_KEY_A]).seal(_PAYLOAD) + assert _PAYLOAD.decode() not in token + assert _b64u_nopad(_PAYLOAD) not in token + assert _PAYLOAD.hex() not in token + assert _PAYLOAD not in _decode_body(token) + + +def test_every_substitution_of_the_final_token_character_is_rejected() -> None: + """Spec-mandated (basic/patterns/mrtr, server requirement 4): canonical decoding + rejects every final-character substitution despite base64 don't-care padding bits.""" + codec = AESGCMRequestStateCodec([_KEY_A]) + body = codec.seal(_PAYLOAD).removeprefix(_TOKEN_PREFIX) + substitutions = [c for c in sorted(_B64URL_ALPHABET) if c != body[-1]] + assert len(substitutions) == 63 + for c in substitutions: + with pytest.raises(InvalidRequestState): + codec.unseal(_TOKEN_PREFIX + body[:-1] + c) + + +@pytest.mark.parametrize( + "mangle", + [ + pytest.param(_inject_junk_chars, id="junk-chars-injected"), + pytest.param(_append_newline, id="newline-appended"), + pytest.param(_append_padding, id="padding-appended"), + ], +) +def test_a_non_canonical_token_body_is_rejected(mangle: Callable[[str], str]) -> None: + """Spec-mandated (basic/patterns/mrtr, server requirement 4): lax-decoder aliases of a token are rejected.""" + codec = AESGCMRequestStateCodec([_KEY_A]) + body = codec.seal(_PAYLOAD).removeprefix(_TOKEN_PREFIX) + with pytest.raises(InvalidRequestState): + codec.unseal(_TOKEN_PREFIX + mangle(body)) + + +def test_a_token_reprefixed_to_a_future_format_version_is_rejected() -> None: + """Spec-mandated (basic/patterns/mrtr, server requirement 4): the prefix is tag-bound; "v2." replay fails.""" + codec = AESGCMRequestStateCodec([_KEY_A]) + token = codec.seal(_PAYLOAD) + with pytest.raises(InvalidRequestState): + codec.unseal("v2." + token.removeprefix(_TOKEN_PREFIX)) + + +def test_a_kid_transplanted_onto_another_tokens_body_is_rejected() -> None: + """Spec-mandated (basic/patterns/mrtr, server requirement 4): the kid is tag-bound; transplanting it fails.""" + raw_a = _decode_body(AESGCMRequestStateCodec([_KEY_A]).seal(_PAYLOAD)) + raw_b = _decode_body(AESGCMRequestStateCodec([_KEY_B]).seal(_PAYLOAD)) + assert raw_a[:_KID_LEN] != raw_b[:_KID_LEN] + transplanted = _TOKEN_PREFIX + _b64u_nopad(raw_a[:_KID_LEN] + raw_b[_KID_LEN:]) + with pytest.raises(InvalidRequestState): + AESGCMRequestStateCodec([_KEY_A, _KEY_B]).unseal(transplanted) + + +# -- RequestStateSecurity ----------------------------------------------------- + + +def test_keys_and_codec_together_are_rejected_at_policy_construction() -> None: + """SDK-defined: keys= and codec= are mutually exclusive.""" + with pytest.raises(ValueError) as exc: + RequestStateSecurity(keys=[_KEY_A], codec=_StaticCodec()) + assert str(exc.value) == snapshot("RequestStateSecurity takes exactly one of keys= or codec=") + + +def test_a_policy_with_neither_keys_nor_codec_is_rejected() -> None: + """SDK-defined: a policy must name its codec; an empty policy is a mistake, not a posture.""" + with pytest.raises(ValueError) as exc: + RequestStateSecurity() + assert str(exc.value) == snapshot("RequestStateSecurity takes exactly one of keys= or codec=") + + +@pytest.mark.parametrize( + "ttl", + [ + pytest.param(0.0, id="zero"), + pytest.param(-600.0, id="negative"), + pytest.param(float("nan"), id="nan"), + pytest.param(float("inf"), id="inf"), + ], +) +def test_a_non_positive_or_non_finite_ttl_is_rejected_at_policy_construction(ttl: float) -> None: + """SDK-defined: zero, negative, NaN, and infinite ttl fail at construction for keys and ephemeral() alike.""" + with pytest.raises(ValueError, match="positive finite"): + RequestStateSecurity(keys=[_KEY_A], ttl=ttl) + with pytest.raises(ValueError, match="positive finite"): + RequestStateSecurity.ephemeral(ttl=ttl) + + +def test_keys_produce_a_working_built_in_codec_on_the_policy() -> None: + """SDK-defined: keys=[...] builds the built-in AES-GCM codec, exposed on .codec.""" + security = RequestStateSecurity(keys=[_KEY_A]) + assert isinstance(security.codec, AESGCMRequestStateCodec) + assert security.codec.unseal(security.codec.seal(_PAYLOAD)) == _PAYLOAD + + +def test_a_custom_codec_is_stored_on_the_policy_as_is() -> None: + """SDK-defined: codec=... stores the caller's object unwrapped.""" + codec = _StaticCodec() + security = RequestStateSecurity(codec=codec) + assert security.codec is codec + assert codec.unseal(codec.seal(_PAYLOAD)) == _PAYLOAD + + +def test_ephemeral_policies_are_protected_and_mutually_unintelligible() -> None: + """SDK-defined: ephemeral() protects under a process-local key, so a sibling instance rejects its tokens.""" + first = RequestStateSecurity.ephemeral() + second = RequestStateSecurity.ephemeral() + token = first.codec.seal(_PAYLOAD) + assert first.codec.unseal(token) == _PAYLOAD + with pytest.raises(InvalidRequestState): + second.codec.unseal(token) + + +def test_the_policy_stores_an_explicit_audience_and_defaults_to_none() -> None: + """SDK-defined: audience is stored as given; None defers to the server tier's `default_audience`.""" + assert RequestStateSecurity(keys=[_KEY_A]).audience is None + assert RequestStateSecurity(keys=[_KEY_A], audience="svc").audience == "svc" + assert RequestStateSecurity.ephemeral(audience="svc").audience == "svc" + + +def test_the_default_principal_binding_is_authenticated_principal() -> None: + """SDK-defined: an unconfigured policy binds state to the authenticated OAuth client by default.""" + assert RequestStateSecurity(keys=[_KEY_A]).bind_principal is authenticated_principal + + +def test_an_explicit_principal_binding_callable_is_stored() -> None: + """SDK-defined: a custom bind_principal callable is stored as given.""" + + def tenant_binding(ctx: ServerRequestContext[Any, Any]) -> str | None: + return "tenant-1" + + security = RequestStateSecurity(keys=[_KEY_A], bind_principal=tenant_binding) + assert security.bind_principal is tenant_binding + assert tenant_binding(_bare_context()) == "tenant-1" + + +# -- authenticated_principal ---------------------------------------------------- + + +def test_authenticated_principal_is_none_without_an_auth_context() -> None: + """SDK-defined: without an auth context the default binding derives no principal.""" + assert authenticated_principal(_bare_context()) is None + + +@pytest.mark.parametrize( + ("token", "expected"), + [ + pytest.param( + AccessToken(token="at-1", client_id="client-123", scopes=[]), + '["client-123",null,null]', + id="client-only", + ), + pytest.param( + AccessToken(token="at-2", client_id="client-123", scopes=[], subject="alice"), + '["client-123",null,"alice"]', + id="with-subject", + ), + pytest.param( + AccessToken( + token="at-3", client_id="client-123", scopes=[], subject="alice", claims={"iss": "https://as.example"} + ), + '["client-123","https://as.example","alice"]', + id="with-issuer-and-subject", + ), + ], +) +def test_authenticated_principal_is_the_tokens_client_issuer_subject_identity( + token: AccessToken, expected: str +) -> None: + """SDK-defined: the default binding composes (client_id, issuer, subject), degrading per component.""" + reset = auth_context_var.set(AuthenticatedUser(token)) + try: + assert authenticated_principal(_bare_context()) == expected + finally: + auth_context_var.reset(reset) + + +def test_authenticated_principal_distinguishes_two_subjects_of_one_client() -> None: + """SDK-defined: two users of the same OAuth client are distinct principals when subjects are supplied.""" + alice = AccessToken(token="at-a", client_id="https://agent.example/client.json", scopes=[], subject="alice") + bob = AccessToken(token="at-b", client_id="https://agent.example/client.json", scopes=[], subject="bob") + principals: list[str | None] = [] + for token in (alice, bob): + reset = auth_context_var.set(AuthenticatedUser(token)) + try: + principals.append(authenticated_principal(_bare_context())) + finally: + auth_context_var.reset(reset) + assert principals[0] != principals[1] + + +def test_authenticated_principal_uses_the_same_components_as_session_ownership() -> None: + """SDK-defined: the binding and authorization_context derive from one principal_components source.""" + token = AccessToken( + token="at-1", client_id="client-123", scopes=[], subject="alice", claims={"iss": "https://as.example"} + ) + assert authorization_context(AuthenticatedUser(token)) == { + "client_id": "client-123", + "issuer": "https://as.example", + "subject": "alice", + } + assert list(principal_components(token)) == ["client-123", "https://as.example", "alice"] diff --git a/tests/server/test_request_state_boundary.py b/tests/server/test_request_state_boundary.py new file mode 100644 index 0000000000..ed4e5b0662 --- /dev/null +++ b/tests/server/test_request_state_boundary.py @@ -0,0 +1,1297 @@ +"""`RequestStateBoundary` end to end: seal outbound, verify and restore inbound, one frozen error on failure.""" + +import json +import logging +from collections.abc import Awaitable, Callable +from typing import Any, cast + +import anyio +import pytest +from mcp_types import ( + INTERNAL_ERROR, + INVALID_PARAMS, + CallToolRequestParams, + CallToolResult, + ElicitRequest, + ElicitRequestFormParams, + ElicitResult, + InputRequiredResult, + ListToolsResult, + PaginatedRequestParams, + ReadResourceResult, + RequestParams, + TextContent, + TextResourceContents, + Tool, +) + +import mcp.server.request_state as request_state_module +from mcp import Client +from mcp.server import MCPServer, Server, ServerRequestContext +from mcp.server.auth.middleware.auth_context import auth_context_var +from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser +from mcp.server.auth.provider import AccessToken +from mcp.server.context import HandlerResult +from mcp.server.mcpserver import Context +from mcp.server.mcpserver.server import _MISSING_AUDIENCE +from mcp.server.request_state import ( + AESGCMRequestStateCodec, + InvalidRequestState, + RequestStateBoundary, + RequestStateSecurity, +) +from mcp.shared.exceptions import MCPError + +from .test_runner import connected_runner + +pytestmark = pytest.mark.anyio + +_KEY = b"0123456789abcdef0123456789abcdef" # 32 bytes +_T0 = 1_782_345_600.0 # frozen mint instant for clock-controlled tests +_TTL = 600.0 + + +def _ask(message: str) -> ElicitRequest: + """A minimal elicitation request for a manual tool's `input_requests`.""" + return ElicitRequest( + params=ElicitRequestFormParams( + message=message, + requested_schema={ + "type": "object", + "properties": {"confirm": {"type": "boolean"}}, + "required": ["confirm"], + }, + ) + ) + + +def _accept() -> ElicitResult: + return ElicitResult(action="accept", content={"confirm": True}) + + +async def _list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult: + """`ClientSession.call_tool` consults tools/list, so lowlevel fixtures must answer it.""" + return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})]) + + +class _PassthroughCodec: + """Cryptography-free codec (the token IS the payload) that puts arbitrary bytes behind a successful unseal.""" + + def seal(self, payload: bytes) -> str: + return payload.decode() + + def unseal(self, token: str) -> bytes: + return token.encode() + + +class _CustomMethodParams(RequestParams): + """Params for a custom (non-carrier) method.""" + + request_state: str | None = None + + +class _Clock: + """Stands in for the `time` module inside `mcp.server.request_state`.""" + + def __init__(self, now: float) -> None: + self.now = now + + def time(self) -> float: + return self.now + + +def _tamper(token: str) -> str: + """Flip one mid-token character; strict canonical decoding rejects any single-character change.""" + i = len(token) // 2 + return token[:i] + ("A" if token[i] != "A" else "B") + token[i + 1 :] + + +def _assert_frozen_rejection(exc: pytest.ExceptionInfo[MCPError]) -> None: + """Assert the single frozen wire shape for every inbound verification failure.""" + assert exc.value.error.code == INVALID_PARAMS + assert exc.value.error.message == "Invalid or expired requestState" + assert exc.value.error.data == {"reason": "invalid_request_state"} + + +def _manual_server( + security: RequestStateSecurity | None, *, state: str = "awaiting-confirm", name: str = "manual" +) -> tuple[MCPServer, list[str | None]]: + """MCPServer with one manual MRTR tool: round 1 asks, the retry records the echoed `ctx.request_state`. + + `security=None` exercises the default posture (process-local ephemeral sealing), not plaintext. + """ + seen: list[str | None] = [] + mcp = MCPServer(name, request_state_security=security) + + @mcp.tool() + async def deploy(env: str, ctx: Context) -> str | InputRequiredResult: + if ctx.input_responses is None: + return InputRequiredResult(input_requests={"confirm": _ask(f"Deploy to {env}?")}, request_state=state) + seen.append(ctx.request_state) + return f"deployed to {env}" + + return mcp, seen + + +async def _first_round(client: Client, name: str, args: dict[str, Any]) -> str: + """Round 1 of the manual loop: call without responses, return the wire token.""" + first = await client.session.call_tool(name, args, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.request_state is not None + return first.request_state + + +async def _retry(client: Client, name: str, args: dict[str, Any], token: str) -> CallToolResult | InputRequiredResult: + """The retry round: echo the wire token with the elicited answer attached.""" + return await client.session.call_tool( + name, args, input_responses={"confirm": _accept()}, request_state=token, allow_input_required=True + ) + + +# -- end-to-end seal/unseal through the public surfaces ------------------------------- + + +async def test_request_state_is_sealed_on_the_wire_and_restored_for_the_handler() -> None: + """Spec-mandated (basic/patterns/mrtr server requirements 4-5): the wire carries an + opaque token, never the handler's plaintext, and a faithful echo restores it.""" + plaintext = "awaiting-confirm:9f2e" + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY]), state=plaintext) + + with anyio.fail_after(5): + async with Client(mcp) as client: + first = await client.session.call_tool("deploy", {"env": "prod"}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.request_state is not None + assert first.request_state != plaintext + assert first.request_state.startswith("v1.") + second = await _retry(client, "deploy", {"env": "prod"}, first.request_state) + + assert isinstance(second, CallToolResult) + assert not second.is_error + assert isinstance(second.content[0], TextContent) + assert second.content[0].text == "deployed to prod" + assert seen == [plaintext] + + +async def test_lowlevel_server_gets_identical_sealing_from_the_one_line_middleware_append() -> None: + """Spec-mandated (basic/patterns/mrtr server requirements 4-5): appending the public + `RequestStateBoundary` to `Server.middleware` gives the lowlevel tier the same sealing.""" + plaintext = "lowlevel-round-1" + seen: list[str | None] = [] + + async def call_tool( + ctx: ServerRequestContext[Any], params: CallToolRequestParams + ) -> CallToolResult | InputRequiredResult: + if params.input_responses is None: + return InputRequiredResult(input_requests={"confirm": _ask("Proceed?")}, request_state=plaintext) + seen.append(params.request_state) + return CallToolResult(content=[TextContent(text="done")]) + + server = Server("srv", on_call_tool=call_tool, on_list_tools=_list_tools) + server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[_KEY]), default_audience=server.name)) + + with anyio.fail_after(5): + async with Client(server) as client: + first = await client.session.call_tool("t", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.request_state is not None + assert first.request_state != plaintext + assert first.request_state.startswith("v1.") + second = await _retry(client, "t", {}, first.request_state) + + assert isinstance(second, CallToolResult) + assert seen == [plaintext] + claims = json.loads(AESGCMRequestStateCodec([_KEY]).unseal(first.request_state)) + assert claims["aud"] == "srv" + + +async def test_a_resource_template_flow_seals_on_resources_read_and_restores_the_plaintext() -> None: + """Spec-mandated (basic/patterns/mrtr server requirements 4-5): resources/read is an + MRTR carrier, so a template's `requestState` crosses sealed and bound to the uri.""" + plaintext = "resource-round-1" + seen: list[str | None] = [] + mcp = MCPServer("templated", request_state_security=RequestStateSecurity(keys=[_KEY])) + + @mcp.resource("deploy://{env}/confirm") + async def confirm(env: str, ctx: Context) -> str | InputRequiredResult: + if ctx.input_responses is None: + return InputRequiredResult(input_requests={"confirm": _ask(f"Read {env}?")}, request_state=plaintext) + seen.append(ctx.request_state) + return f"confirmed {env}" + + with anyio.fail_after(5): + async with Client(mcp) as client: + first = await client.session.read_resource("deploy://prod/confirm", allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.request_state is not None + assert first.request_state != plaintext + assert first.request_state.startswith("v1.") + second = await client.session.read_resource( + "deploy://prod/confirm", + input_responses={"confirm": _accept()}, + request_state=first.request_state, + allow_input_required=True, + ) + + assert isinstance(second, ReadResourceResult) + assert isinstance(second.contents[0], TextResourceContents) + assert second.contents[0].text == "confirmed prod" + claims = json.loads(AESGCMRequestStateCodec([_KEY]).unseal(first.request_state)) + assert (claims["m"], claims["t"], claims["s"]) == ("resources/read", "deploy://prod/confirm", plaintext) + assert seen == [plaintext] + + +# -- verification failures: tamper, expiry, future skew ------------------------------- + + +async def test_tampered_request_state_is_rejected_with_the_frozen_wire_error() -> None: + """Spec-mandated (basic/patterns/mrtr server requirement 5): a modified echo is + rejected with the frozen -32602 shape and the handler never runs.""" + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY])) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, _tamper(token)) + _assert_frozen_rejection(exc) + + assert seen == [] + + +async def test_expired_request_state_is_rejected_and_just_inside_ttl_is_accepted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Spec-mandated (basic/patterns/mrtr server requirements 4-5): one second past `ttl` + is rejected, one second inside completes.""" + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], ttl=_TTL)) + clock = _Clock(_T0) + monkeypatch.setattr(request_state_module, "time", clock) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) # minted at _T0 + clock.now = _T0 + _TTL + 1 + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, token) + clock.now = _T0 + _TTL - 1 + second = await _retry(client, "deploy", {"env": "prod"}, token) + + _assert_frozen_rejection(exc) + assert isinstance(second, CallToolResult) + assert seen == ["awaiting-confirm"] + + +async def test_state_minted_in_the_future_is_rejected_beyond_the_sixty_second_skew( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Spec-mandated (basic/patterns/mrtr server requirements 4-5): a token minted 120 s + ahead of the verifier's clock is rejected, 30 s ahead is inside the skew allowance.""" + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], ttl=_TTL)) + clock = _Clock(_T0) + monkeypatch.setattr(request_state_module, "time", clock) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) # minted at _T0 + clock.now = _T0 - 120 + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, token) + clock.now = _T0 - 30 + second = await _retry(client, "deploy", {"env": "prod"}, token) + + _assert_frozen_rejection(exc) + assert isinstance(second, CallToolResult) + assert seen == ["awaiting-confirm"] + + +# -- request binding ------------------------------------------------------------------- + + +async def test_round_one_state_replayed_on_a_different_tool_is_rejected() -> None: + """Spec-mandated (basic/patterns/mrtr server requirement 4): a token minted for tool + A is rejected when echoed on tool B of the same server.""" + seen: list[str | None] = [] + + def make_tool(state: str) -> Callable[[Context], Awaitable[str | InputRequiredResult]]: + async def tool(ctx: Context) -> str | InputRequiredResult: + if ctx.input_responses is None: + return InputRequiredResult(input_requests={"confirm": _ask(state)}, request_state=state) + seen.append(ctx.request_state) + return "done" + + return tool + + mcp = MCPServer("two-tools", request_state_security=RequestStateSecurity(keys=[_KEY])) + mcp.tool(name="alpha")(make_tool("alpha-state")) + mcp.tool(name="beta")(make_tool("beta-state")) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "alpha", {}) + with pytest.raises(MCPError) as exc: + await _retry(client, "beta", {}, token) + second = await _retry(client, "alpha", {}, token) + + _assert_frozen_rejection(exc) + assert isinstance(second, CallToolResult) + assert seen == ["alpha-state"] + + +async def test_retry_with_different_arguments_is_rejected_and_the_original_arguments_complete() -> None: + """Spec-mandated (basic/patterns/mrtr server requirement 4): the same tool retried + with different arguments is rejected.""" + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY])) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "staging"}, token) + second = await _retry(client, "deploy", {"env": "prod"}, token) + + _assert_frozen_rejection(exc) + assert isinstance(second, CallToolResult) + assert seen == ["awaiting-confirm"] + + +# -- principal binding ----------------------------------------------------------------- + + +async def test_state_minted_with_a_principal_is_rejected_when_the_verifier_derives_none() -> None: + """Spec-mandated (basic/patterns/mrtr server requirement 4): state sealed for a + principal is rejected when the verifying round derives none.""" + principal: list[str | None] = ["alice"] + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], bind_principal=lambda ctx: principal[0])) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + principal[0] = None + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, token) + _assert_frozen_rejection(exc) + + assert seen == [] + + +async def test_state_minted_without_a_principal_is_rejected_when_the_verifier_derives_one() -> None: + """Spec-mandated (basic/patterns/mrtr server requirement 4): unbound state is + rejected once the verifying round derives a principal.""" + principal: list[str | None] = [None] + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], bind_principal=lambda ctx: principal[0])) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + principal[0] = "alice" + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, token) + _assert_frozen_rejection(exc) + + assert seen == [] + + +async def test_state_for_a_different_principal_is_rejected_and_the_same_principal_completes() -> None: + """Spec-mandated (basic/patterns/mrtr server requirement 4): one principal's token is + rejected when echoed by another and accepted when the same principal returns.""" + principal: list[str | None] = ["alice"] + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], bind_principal=lambda ctx: principal[0])) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + principal[0] = "bob" + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, token) + principal[0] = "alice" + second = await _retry(client, "deploy", {"env": "prod"}, token) + + _assert_frozen_rejection(exc) + assert isinstance(second, CallToolResult) + assert seen == ["awaiting-confirm"] + + +async def test_a_principal_binding_that_raises_fails_the_seal_as_an_internal_error( + caplog: pytest.LogCaptureFixture, +) -> None: + """SDK-defined: a raising `bind_principal` fails the seal as a bare internal error, not an unbound mint.""" + + def boom(ctx: ServerRequestContext[Any, Any]) -> str | None: + raise RuntimeError("identity provider down") + + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], bind_principal=boom)) + + with anyio.fail_after(5): + async with Client(mcp) as client: + with pytest.raises(MCPError) as exc: + await client.session.call_tool("deploy", {"env": "prod"}, allow_input_required=True) + assert exc.value.error.code == INTERNAL_ERROR + assert exc.value.error.message == "Internal error" + assert exc.value.error.data is None # the reason never reaches the wire + + assert seen == [] + assert any(r.exc_info is not None and r.exc_info[0] is RuntimeError for r in caplog.records) + + +async def test_a_principal_binding_that_raises_fails_the_unseal_with_the_frozen_rejection( + caplog: pytest.LogCaptureFixture, +) -> None: + """SDK-defined: a `bind_principal` that raises while verifying collapses to the frozen rejection.""" + rounds: list[int] = [] + + def flaky(ctx: ServerRequestContext[Any, Any]) -> str | None: + rounds.append(1) + if len(rounds) == 1: + return "alice" # mint round succeeds + raise RuntimeError("identity provider down") # verify round raises + + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], bind_principal=flaky)) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, token) + _assert_frozen_rejection(exc) + + assert seen == [] + assert any(r.exc_info is not None and r.exc_info[0] is RuntimeError for r in caplog.records) + + +async def test_two_mints_for_the_same_principal_carry_different_salted_principal_claims() -> None: + """SDK-defined: the `p` claim is salted per mint, so two tokens for the same principal are not linkable.""" + mcp, _ = _manual_server(RequestStateSecurity(keys=[_KEY], bind_principal=lambda ctx: "alice")) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token_one = await _first_round(client, "deploy", {"env": "prod"}) + token_two = await _first_round(client, "deploy", {"env": "prod"}) + + codec = AESGCMRequestStateCodec([_KEY]) + claims_one = json.loads(codec.unseal(token_one)) + claims_two = json.loads(codec.unseal(token_two)) + assert "p" in claims_one + assert "p" in claims_two + assert claims_one["p"] != claims_two["p"] + + +# -- audience binding ------------------------------------------------------------------ + + +async def test_two_servers_sharing_a_key_reject_each_others_state_via_the_name_audience() -> None: + """SDK-defined: the server name is the default audience, so servers sharing a key reject each other's state.""" + mcp_billing, seen_billing = _manual_server(RequestStateSecurity(keys=[_KEY]), name="billing") + mcp_payments, seen_payments = _manual_server(RequestStateSecurity(keys=[_KEY]), name="payments") + + with anyio.fail_after(5): + async with Client(mcp_billing) as billing, Client(mcp_payments) as payments: + token = await _first_round(billing, "deploy", {"env": "prod"}) + with pytest.raises(MCPError) as exc: + await _retry(payments, "deploy", {"env": "prod"}, token) + second = await _retry(billing, "deploy", {"env": "prod"}, token) + + _assert_frozen_rejection(exc) + assert isinstance(second, CallToolResult) + assert seen_billing == ["awaiting-confirm"] + assert seen_payments == [] + + +async def test_audience_presence_drift_is_rejected_in_both_directions() -> None: + """SDK-defined: audience presence drift is rejected in both directions; each boundary accepts its own mint.""" + + def make_server(boundary: RequestStateBoundary) -> Server: + async def call_tool( + ctx: ServerRequestContext[Any], params: CallToolRequestParams + ) -> CallToolResult | InputRequiredResult: + if params.input_responses is None: + return InputRequiredResult(input_requests={"confirm": _ask("Go?")}, request_state="round-1") + return CallToolResult(content=[TextContent(text="done")]) + + server = Server("srv", on_call_tool=call_tool, on_list_tools=_list_tools) + server.middleware.append(boundary) + return server + + security = RequestStateSecurity(keys=[_KEY]) + bound = make_server(RequestStateBoundary(security, default_audience="svc")) + unbound = make_server(RequestStateBoundary(security, default_audience=None)) + + with anyio.fail_after(5): + async with Client(bound) as on_bound, Client(unbound) as on_unbound: + bound_token = await _first_round(on_bound, "t", {}) + unbound_token = await _first_round(on_unbound, "t", {}) + with pytest.raises(MCPError) as bound_state_on_unbound: + await _retry(on_unbound, "t", {}, bound_token) + with pytest.raises(MCPError) as unbound_state_on_bound: + await _retry(on_bound, "t", {}, unbound_token) + assert isinstance(await _retry(on_bound, "t", {}, bound_token), CallToolResult) + assert isinstance(await _retry(on_unbound, "t", {}, unbound_token), CallToolResult) + + _assert_frozen_rejection(bound_state_on_unbound) + _assert_frozen_rejection(unbound_state_on_bound) + + +async def test_an_explicit_policy_audience_overrides_the_server_name_default() -> None: + """SDK-defined: `RequestStateSecurity(audience=...)` overrides the server-name default.""" + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], audience="prod-fleet"), name="one-box") + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + second = await _retry(client, "deploy", {"env": "prod"}, token) + + claims = json.loads(AESGCMRequestStateCodec([_KEY]).unseal(token)) + assert claims["aud"] == "prod-fleet" + assert isinstance(second, CallToolResult) + assert seen == ["awaiting-confirm"] + + +# -- claims envelope (white-box through the public codec) ----------------------------- + + +async def test_claims_envelope_carries_the_documented_fields_and_omits_p_when_unbound() -> None: + """SDK-defined: the sealed payload is the documented claims JSON; no `p` claim when the principal is None.""" + plaintext = "step-one" + mcp, _ = _manual_server( + RequestStateSecurity(keys=[_KEY], ttl=_TTL, bind_principal=lambda ctx: None), state=plaintext + ) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + + claims = json.loads(AESGCMRequestStateCodec([_KEY]).unseal(token)) + assert set(claims) == {"v", "iat", "exp", "m", "t", "a", "s", "aud"} + assert claims["v"] == 1 + assert claims["exp"] == claims["iat"] + int(_TTL) + assert claims["m"] == "tools/call" + assert claims["t"] == "deploy" + assert isinstance(claims["a"], str) and claims["a"] + assert claims["aud"] == "manual" # the MCPServer name, the boundary's default audience + assert claims["s"] == plaintext + + +async def test_each_round_is_resealed_with_a_fresh_token_and_a_restamped_iat( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """SDK-defined: every round reseals with a fresh token and `iat`, so `ttl` bounds per-round think time.""" + mcp = MCPServer("wizard-server", request_state_security=RequestStateSecurity(keys=[_KEY], ttl=_TTL)) + + @mcp.tool() + async def wizard(ctx: Context) -> str | InputRequiredResult: + if ctx.input_responses is None: + return InputRequiredResult(input_requests={"first": _ask("First?")}, request_state="step-1") + if ctx.request_state == "step-1": + return InputRequiredResult(input_requests={"second": _ask("Second?")}, request_state="step-2") + return "done" + + clock = _Clock(_T0) + monkeypatch.setattr(request_state_module, "time", clock) + + with anyio.fail_after(5): + async with Client(mcp) as client: + first = await client.session.call_tool("wizard", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.request_state is not None + clock.now = _T0 + 5 + second = await client.session.call_tool( + "wizard", + {}, + input_responses={"first": _accept()}, + request_state=first.request_state, + allow_input_required=True, + ) + assert isinstance(second, InputRequiredResult) + assert second.request_state is not None + third = await client.session.call_tool( + "wizard", + {}, + input_responses={"second": _accept()}, + request_state=second.request_state, + allow_input_required=True, + ) + + assert isinstance(third, CallToolResult) + assert first.request_state != second.request_state + codec = AESGCMRequestStateCodec([_KEY]) + claims_one = json.loads(codec.unseal(first.request_state)) + claims_two = json.loads(codec.unseal(second.request_state)) + assert claims_two["iat"] >= claims_one["iat"] + assert (claims_one["iat"], claims_two["iat"]) == (int(_T0), int(_T0) + 5) + + +# -- the default posture: every MCPServer seals under an ephemeral policy --------------- + + +async def test_an_mcpserver_seals_request_state_by_default() -> None: + """SDK-defined: with no `request_state_security=`, an MCPServer seals under a process-local key.""" + plaintext = "plain-wizard-state" + mcp, seen = _manual_server(None, state=plaintext) + + with anyio.fail_after(5): + async with Client(mcp) as client: + first = await client.session.call_tool("deploy", {"env": "prod"}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.request_state is not None + assert first.request_state != plaintext + assert first.request_state.startswith("v1.") + with pytest.raises(MCPError) as fabricated: + await _retry(client, "deploy", {"env": "prod"}, plaintext) + second = await _retry(client, "deploy", {"env": "prod"}, first.request_state) + + _assert_frozen_rejection(fabricated) + assert isinstance(second, CallToolResult) + assert seen == [plaintext] + + +async def test_the_default_key_is_per_instance_so_servers_never_cross_accept() -> None: + """SDK-defined: each default MCPServer mints its own ephemeral key; another instance rejects its state.""" + one, seen_one = _manual_server(None) + two, seen_two = _manual_server(None) + + with anyio.fail_after(5): + async with Client(one) as on_one, Client(two) as on_two: + token = await _first_round(on_one, "deploy", {"env": "prod"}) + with pytest.raises(MCPError) as exc: + await _retry(on_two, "deploy", {"env": "prod"}, token) + second = await _retry(on_one, "deploy", {"env": "prod"}, token) + + _assert_frozen_rejection(exc) + assert isinstance(second, CallToolResult) + assert seen_one == ["awaiting-confirm"] + assert seen_two == [] + + +async def test_a_boundary_free_lowlevel_server_passes_request_state_through_verbatim() -> None: + """SDK-defined: without a boundary in `Server.middleware`, `requestState` crosses as the handler's plaintext.""" + plaintext = "lowlevel-plain-round-1" + seen: list[str | None] = [] + + async def call_tool( + ctx: ServerRequestContext[Any], params: CallToolRequestParams + ) -> CallToolResult | InputRequiredResult: + if params.input_responses is None: + return InputRequiredResult(input_requests={"confirm": _ask("Proceed?")}, request_state=plaintext) + seen.append(params.request_state) + return CallToolResult(content=[TextContent(text="done")]) + + server = Server("srv", on_call_tool=call_tool, on_list_tools=_list_tools) + + with anyio.fail_after(5): + async with Client(server) as client: + first = await client.session.call_tool("t", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.request_state == plaintext + second = await _retry(client, "t", {}, plaintext) + + assert isinstance(second, CallToolResult) + assert seen == [plaintext] + + +# -- malformed wire input -------------------------------------------------------------- + + +async def test_non_string_inbound_request_state_is_rejected_with_the_frozen_error() -> None: + """Spec-mandated (basic/patterns/mrtr server requirement 5): a non-string + `requestState` fails at the boundary with the frozen shape.""" + calls: list[str] = [] + + async def call_tool(ctx: ServerRequestContext[Any], params: CallToolRequestParams) -> CallToolResult: + calls.append(params.name) + return CallToolResult(content=[TextContent(text="ran")]) + + server = Server("srv", on_call_tool=call_tool) + server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[_KEY]), default_audience=None)) + + async with connected_runner(server) as (client, _): + with pytest.raises(MCPError) as exc: + await client.send_raw_request("tools/call", {"name": "t", "arguments": {}, "requestState": 123}) + assert calls == [] + result = await client.send_raw_request("tools/call", {"name": "t", "arguments": {}}) + + _assert_frozen_rejection(exc) + assert result["content"][0]["text"] == "ran" + assert calls == ["t"] + + +@pytest.mark.parametrize( + "install_boundary", + [ + pytest.param(True, id="boundary-installed"), + pytest.param(False, id="no-boundary"), + ], +) +async def test_an_explicit_null_request_state_is_treated_as_absent(install_boundary: bool) -> None: + """SDK-defined: an explicit `"requestState": null` is the field's absence, so the handler runs and sees None.""" + seen: list[str | None] = [] + + async def call_tool(ctx: ServerRequestContext[Any], params: CallToolRequestParams) -> CallToolResult: + seen.append(params.request_state) + return CallToolResult(content=[TextContent(text="ran")]) + + server = Server("srv", on_call_tool=call_tool) + if install_boundary: + server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[_KEY]), default_audience=None)) + + async with connected_runner(server) as (client, _): + result = await client.send_raw_request("tools/call", {"name": "t", "arguments": {}, "requestState": None}) + + assert result["content"][0]["text"] == "ran" + assert seen == [None] + + +# -- boundary scope: only the three carrier methods are touched ------------------------- + + +async def test_inbound_request_state_on_a_non_carrier_method_passes_through_unverified() -> None: + """SDK-defined: only the MRTR carriers are touched; a custom method's `requestState` arrives as sent.""" + calls: list[str] = [] + + async def custom(ctx: ServerRequestContext[Any], params: _CustomMethodParams) -> dict[str, Any]: + calls.append(params.request_state or "fresh") + return {"resultType": "complete"} + + server = Server("srv", on_list_tools=_list_tools) + server.add_request_handler("example/mrtr", _CustomMethodParams, custom) + server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[_KEY]), default_audience=None)) + + async with connected_runner(server) as (client, _): + ok = await client.send_raw_request("example/mrtr", {"requestState": "CLIENT-SENT-VALUE"}) + fresh = await client.send_raw_request("example/mrtr", {}) + + assert ok == {"resultType": "complete"} + assert fresh == {"resultType": "complete"} + assert calls == ["CLIENT-SENT-VALUE", "fresh"] + + +async def test_outbound_request_state_on_a_non_carrier_method_is_not_sealed() -> None: + """SDK-defined: an input_required result on a custom method keeps its `requestState` unsealed.""" + + async def custom(ctx: ServerRequestContext[Any], params: _CustomMethodParams) -> InputRequiredResult: + return InputRequiredResult(input_requests={"confirm": _ask("?")}, request_state="ext-handler-plaintext") + + server = Server("srv", on_list_tools=_list_tools) + server.add_request_handler("example/mrtr", _CustomMethodParams, custom) + server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[_KEY]), default_audience=None)) + + async with connected_runner(server) as (client, _): + result = await client.send_raw_request("example/mrtr", {}) + + assert result["resultType"] == "input_required" + assert result["requestState"] == "ext-handler-plaintext" + + +async def test_an_off_set_input_required_result_without_state_passes_through_untouched() -> None: + """SDK-defined: an input_required result on a non-carrier method minting no state crosses unmodified.""" + + async def custom(ctx: ServerRequestContext[Any], params: _CustomMethodParams) -> InputRequiredResult: + return InputRequiredResult(input_requests={"confirm": _ask("?")}) + + server = Server("srv", on_list_tools=_list_tools) + server.add_request_handler("example/mrtr", _CustomMethodParams, custom) + server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[_KEY]), default_audience=None)) + + async with connected_runner(server) as (client, _): + result = await client.send_raw_request("example/mrtr", {}) + + assert result["resultType"] == "input_required" + assert "confirm" in result["inputRequests"] + assert "requestState" not in result + + +# -- custom codec: deny on error ------------------------------------------------------- + + +async def test_a_codec_that_raises_unexpectedly_fails_closed_with_the_frozen_error( + caplog: pytest.LogCaptureFixture, +) -> None: + """Spec-mandated (basic/patterns/mrtr server requirement 5): a codec that raises + unexpectedly denies with the frozen rejection.""" + + class ExplodingCodec: + def seal(self, payload: bytes) -> str: + return "opaque-token" + + def unseal(self, token: str) -> bytes: + raise RuntimeError("codec exploded") + + mcp, seen = _manual_server(RequestStateSecurity(codec=ExplodingCodec())) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + assert token == "opaque-token" + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, token) + _assert_frozen_rejection(exc) + + assert seen == [] + assert any(r.exc_info is not None and r.exc_info[0] is RuntimeError for r in caplog.records) + + +async def test_a_codec_reject_reason_reaches_the_log_but_never_the_wire( + caplog: pytest.LogCaptureFixture, +) -> None: + """Spec-mandated (basic/patterns/mrtr server requirement 5): a custom codec's + `InvalidRequestState` reason is logged server-side, never sent on the wire.""" + + class RefusingCodec: + def seal(self, payload: bytes) -> str: + return "opaque-token" + + def unseal(self, token: str) -> bytes: + raise InvalidRequestState("boom") + + mcp, seen = _manual_server(RequestStateSecurity(codec=RefusingCodec())) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, token) + _assert_frozen_rejection(exc) + + assert "boom" in caplog.text + assert seen == [] + + +@pytest.mark.parametrize( + "payload", + [ + pytest.param("not a claims envelope", id="not-json"), + pytest.param(json.dumps({"v": 1, "iat": 1, "exp": 2}), id="json-missing-claims"), + pytest.param(json.dumps({"v": 2, "iat": 1, "exp": 2, "s": "x"}), id="json-wrong-envelope-version"), + pytest.param(json.dumps({"v": 1, "iat": 1, "exp": 2, "s": 7}), id="json-non-string-state"), + ], +) +async def test_codec_authenticated_bytes_that_are_not_a_claims_envelope_are_rejected(payload: str) -> None: + """SDK-defined: codec-authenticated bytes that are not the claims envelope collapse to the frozen rejection.""" + mcp, seen = _manual_server(RequestStateSecurity(codec=_PassthroughCodec(), bind_principal=None)) + + with anyio.fail_after(5): + async with Client(mcp) as client: + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, payload) + _assert_frozen_rejection(exc) + + assert seen == [] + + +async def test_a_forged_principal_claim_that_is_not_base64_is_rejected() -> None: + """SDK-defined: a `p` claim that does not decode as base64 collapses to the frozen rejection.""" + mcp, seen = _manual_server(RequestStateSecurity(codec=_PassthroughCodec(), bind_principal=lambda ctx: "alice")) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + claims = json.loads(token) # passthrough codec: the token IS the envelope JSON + claims["p"] = "A" # a single base64 char can never pad to a valid quantum + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, json.dumps(claims)) + _assert_frozen_rejection(exc) + + assert seen == [] + + +@pytest.mark.parametrize("forged", [pytest.param(7, id="int"), pytest.param({"x": 1}, id="object")]) +async def test_a_non_string_principal_claim_is_rejected_with_the_frozen_error(forged: Any) -> None: + """SDK-defined: a non-string `p` claim inside a validly-sealed envelope collapses to the frozen rejection.""" + mcp, seen = _manual_server(RequestStateSecurity(codec=_PassthroughCodec(), bind_principal=lambda ctx: "alice")) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + claims = json.loads(token) # passthrough codec: the token IS the envelope JSON + claims["p"] = forged + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, json.dumps(claims)) + _assert_frozen_rejection(exc) + + assert seen == [] + + +# -- log secrecy and the cause-invariant wire error ------------------------------------ + + +async def test_the_wire_error_never_varies_by_cause_and_logs_never_leak_secrets( + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Spec-mandated (basic/patterns/mrtr server requirement 5): tampered, expired, and rebound + echoes get identical wire errors, with reasons logged but no secrets in any record.""" + plaintext = "secret-plaintext-state-1f9b" + principal = "principal-alice-7c3d" + mcp, seen = _manual_server( + RequestStateSecurity(keys=[_KEY], ttl=_TTL, bind_principal=lambda ctx: principal), state=plaintext + ) + clock = _Clock(_T0) + monkeypatch.setattr(request_state_module, "time", clock) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + with pytest.raises(MCPError) as tampered: + await _retry(client, "deploy", {"env": "prod"}, _tamper(token)) + clock.now = _T0 + _TTL + 1 + with pytest.raises(MCPError) as expired: + await _retry(client, "deploy", {"env": "prod"}, token) + clock.now = _T0 + with pytest.raises(MCPError) as rebound: + await _retry(client, "deploy", {"env": "staging"}, token) + _assert_frozen_rejection(tampered) + + shapes = [(e.value.error.code, e.value.error.message, e.value.error.data) for e in (tampered, expired, rebound)] + assert shapes[0] == shapes[1] == shapes[2] + assert seen == [] + + reject_logs = [r for r in caplog.records if r.name == "mcp.server.request_state" and r.levelno == logging.WARNING] + assert len(reject_logs) == 3 + for record in caplog.records: + message = record.getMessage() + assert token not in message + assert plaintext not in message + assert principal not in message + + +# -- pass-through inertness ------------------------------------------------------------ + + +async def test_a_complete_result_crosses_the_boundary_untouched() -> None: + """SDK-defined: a complete tools/call wire result passes the boundary as the identical object.""" + boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY], bind_principal=None), default_audience=None) + complete: dict[str, Any] = {"resultType": "complete", "content": []} + + async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: + return complete + + ctx = ServerRequestContext( + session=cast("Any", None), + lifespan_context={}, + protocol_version="2026-07-28", + method="tools/call", + params={"name": "t", "arguments": {}}, + ) + + assert await boundary(ctx, call_next) is complete + + +async def test_input_required_without_request_state_is_untouched() -> None: + """SDK-defined: an `input_required` result that asks without minting state crosses the boundary unmodified.""" + seen: list[str | None] = [] + mcp = MCPServer("stateless-ask", request_state_security=RequestStateSecurity(keys=[_KEY])) + + @mcp.tool() + async def ask(ctx: Context) -> str | InputRequiredResult: + if ctx.input_responses is None: + return InputRequiredResult(input_requests={"confirm": _ask("Sure?")}) + seen.append(ctx.request_state) + return "done" + + with anyio.fail_after(5): + async with Client(mcp) as client: + first = await client.session.call_tool("ask", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.request_state is None + second = await client.session.call_tool( + "ask", {}, input_responses={"confirm": _accept()}, allow_input_required=True + ) + + assert isinstance(second, CallToolResult) + assert seen == [None] + + +async def test_an_input_required_mapping_with_a_non_string_state_is_not_sealed() -> None: + """SDK-defined: a non-string `requestState` in a wire mapping is not this module's mint; it crosses unchanged.""" + boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY], bind_principal=None), default_audience=None) + malformed: dict[str, Any] = {"resultType": "input_required", "inputRequests": {}, "requestState": 7} + + async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: + return malformed + + ctx = ServerRequestContext( + session=cast("Any", None), + lifespan_context={}, + protocol_version="2026-07-28", + method="tools/call", + params={"name": "t", "arguments": {}}, + ) + + assert await boundary(ctx, call_next) is malformed + + +async def test_a_notification_crosses_the_boundary_unharmed() -> None: + """SDK-defined: the boundary is inert for notifications.""" + boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY], bind_principal=None), default_audience=None) + forwarded: list[ServerRequestContext[Any, Any]] = [] + + async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: + forwarded.append(ctx) + return None + + ctx = ServerRequestContext( + session=cast("Any", None), + lifespan_context={}, + protocol_version="2026-07-28", + method="notifications/progress", + params={"progressToken": "p", "progress": 1}, + ) + + assert await boundary(ctx, call_next) is None + assert len(forwarded) == 1 + assert forwarded[0] is ctx + + +async def test_a_non_mrtr_method_with_no_params_is_untouched() -> None: + """SDK-defined: a non-carrier method with absent params passes the boundary inert.""" + boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY], bind_principal=None), default_audience=None) + listing: dict[str, Any] = {"tools": [], "resultType": "complete"} + + async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: + return listing + + ctx = ServerRequestContext( + session=cast("Any", None), + lifespan_context={}, + protocol_version="2026-07-28", + method="tools/list", + params=None, + ) + + assert await boundary(ctx, call_next) is listing + + +# -- direct chain invocation: the model-path seal -------------------------------------- + + +async def test_a_short_circuited_input_required_model_is_sealed_via_the_model_path() -> None: + """SDK-defined: a short-circuited `InputRequiredResult` model is sealed via the model path, on a copy.""" + boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY], bind_principal=None), default_audience=None) + interim = InputRequiredResult(input_requests={"confirm": _ask("Go?")}, request_state="model-plaintext") + + async def call_next(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: + return interim + + ctx = ServerRequestContext( + session=cast("Any", None), + lifespan_context={}, + protocol_version="2026-07-28", + method="tools/call", + params={"name": "shortcut", "arguments": {}}, + ) + + result = await boundary(ctx, call_next) + + assert isinstance(result, InputRequiredResult) + assert result.input_requests == interim.input_requests + assert result.request_state is not None + assert result.request_state != "model-plaintext" + assert result.request_state.startswith("v1.") + claims = json.loads(AESGCMRequestStateCodec([_KEY]).unseal(result.request_state)) + assert (claims["m"], claims["t"], claims["s"]) == ("tools/call", "shortcut", "model-plaintext") + assert interim.request_state == "model-plaintext" + + +# -- user-supplied code on the seal path fails closed ----------------------------------- + + +class _RaisingSealCodec: + """Codec whose seal always fails, standing in for a KMS outage in a custom codec.""" + + def seal(self, payload: bytes) -> str: + raise RuntimeError("kms unreachable at 10.0.0.7: wrapped-key-id-42") + + def unseal(self, token: str) -> bytes: + raise InvalidRequestState("never minted") + + +async def test_a_codec_that_raises_during_seal_yields_a_sanitized_internal_error() -> None: + """SDK-defined: a raising custom codec fails the round with a sanitized internal error, never its own text.""" + mcp, _ = _manual_server(RequestStateSecurity(codec=_RaisingSealCodec())) + + with anyio.fail_after(5): + async with Client(mcp) as client: + with pytest.raises(MCPError) as exc: + await client.session.call_tool("deploy", {"env": "prod"}, allow_input_required=True) + # The unseal direction of the same broken codec still maps to the frozen rejection. + with pytest.raises(MCPError) as inbound: + await _retry(client, "deploy", {"env": "prod"}, "token-this-codec-never-minted") + assert exc.value.error.code == INTERNAL_ERROR + assert exc.value.error.message == "Internal error" + _assert_frozen_rejection(inbound) + _assert_frozen_rejection(inbound) + + +async def test_a_non_string_principal_fails_closed_when_sealing() -> None: + """SDK-defined: a bind_principal returning a non-string denies the round with the sanitized internal error.""" + + def numeric_user_id(ctx: ServerRequestContext[Any, Any]) -> str: + return cast("str", 12345) + + mcp, _ = _manual_server(RequestStateSecurity(keys=[_KEY], bind_principal=numeric_user_id)) + + with anyio.fail_after(5): + async with Client(mcp) as client: + with pytest.raises(MCPError) as exc: + await client.session.call_tool("deploy", {"env": "prod"}, allow_input_required=True) + assert exc.value.error.code == INTERNAL_ERROR + assert exc.value.error.message == "Internal error" + + +async def test_a_non_string_principal_fails_closed_when_verifying() -> None: + """SDK-defined: a non-string principal on the verify side rejects with the frozen error, not a crash.""" + principal: list[Any] = ["alice"] + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], bind_principal=lambda ctx: principal[0])) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + principal[0] = 12345 + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, token) + _assert_frozen_rejection(exc) + assert seen == [] + + +# -- lone surrogates: every encode on the state path is total ---------------------------- + + +async def test_lone_surrogate_arguments_are_digested_not_crashed() -> None: + """SDK-defined: a lone UTF-16 surrogate in an argument string digests like any other value.""" + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY])) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "\ud800-prod"}) + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "\udfff-prod"}, token) + second = await _retry(client, "deploy", {"env": "\ud800-prod"}, token) + + _assert_frozen_rejection(exc) # different args reject as a binding mismatch, not an internal error + assert isinstance(second, CallToolResult) + assert seen == ["awaiting-confirm"] + + +async def test_lone_surrogate_handler_state_seals_and_restores() -> None: + """SDK-defined: handler-minted state containing a lone surrogate round-trips through the seal exactly.""" + plaintext = "awaiting-\ud800-confirm" + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY]), state=plaintext) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + second = await _retry(client, "deploy", {"env": "prod"}, token) + + assert isinstance(second, CallToolResult) + assert seen == [plaintext] + + +async def test_lone_surrogate_principal_binds_and_verifies() -> None: + """SDK-defined: a principal string containing a lone surrogate binds and verifies like any other.""" + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], bind_principal=lambda ctx: "tenant-\ud800")) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + second = await _retry(client, "deploy", {"env": "prod"}, token) + + assert isinstance(second, CallToolResult) + assert seen == ["awaiting-confirm"] + + +# -- fractional clocks: the configured ttl is the effective ttl -------------------------- + + +async def test_a_fractional_mint_instant_keeps_the_full_ttl(monkeypatch: pytest.MonkeyPatch) -> None: + """SDK-defined: a token minted at a fractional instant lives the full configured ttl.""" + mcp, seen = _manual_server(RequestStateSecurity(keys=[_KEY], ttl=0.5)) + clock = _Clock(_T0 + 0.9) + monkeypatch.setattr(request_state_module, "time", clock) + + with anyio.fail_after(5): + async with Client(mcp) as client: + token = await _first_round(client, "deploy", {"env": "prod"}) + clock.now = _T0 + 1.3 # 0.4s after mint, inside the 0.5s ttl + second = await _retry(client, "deploy", {"env": "prod"}, token) + clock.now = _T0 + 2.0 + late = await _first_round(client, "deploy", {"env": "prod"}) + clock.now = _T0 + 2.6 # 0.6s after mint, past the ttl + with pytest.raises(MCPError) as exc: + await _retry(client, "deploy", {"env": "prod"}, late) + _assert_frozen_rejection(exc) + + assert isinstance(second, CallToolResult) + assert seen == ["awaiting-confirm"] + + +async def test_default_principal_distinguishes_two_subjects_of_one_oauth_client() -> None: + """Spec-mandated (basic/patterns/mrtr server requirement 5, cross-user reuse): with the + default binding, state sealed for one user of an OAuth client is rejected for another + user of the same client and restored only for the original subject.""" + boundary = RequestStateBoundary(RequestStateSecurity(keys=[_KEY]), default_audience="svc") + seen: list[str | None] = [] + + async def mint(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: + return InputRequiredResult(input_requests={"confirm": _ask("PIN?")}, request_state="alice-secret") + + async def restore(ctx: ServerRequestContext[Any, Any]) -> HandlerResult: + assert ctx.params is not None + seen.append(ctx.params["requestState"]) + return CallToolResult(content=[TextContent(text="done")]) + + def request(token: str | None = None) -> ServerRequestContext[Any, Any]: + params: dict[str, Any] = {"name": "fetch_pin", "arguments": {}} + if token is not None: + params["requestState"] = token + return ServerRequestContext( + session=cast("Any", None), + lifespan_context={}, + protocol_version="2026-07-28", + method="tools/call", + params=params, + ) + + def as_user(subject: str) -> AuthenticatedUser: + shared_client = "https://agent.example/client.json" + return AuthenticatedUser( + AccessToken(token=f"at-{subject}", client_id=shared_client, scopes=[], subject=subject) + ) + + reset = auth_context_var.set(as_user("alice")) + try: + sealed = await boundary(request(), mint) + finally: + auth_context_var.reset(reset) + assert isinstance(sealed, InputRequiredResult) + assert sealed.request_state is not None + + reset = auth_context_var.set(as_user("bob")) + try: + with pytest.raises(MCPError) as exc: + await boundary(request(sealed.request_state), restore) + finally: + auth_context_var.reset(reset) + _assert_frozen_rejection(exc) + assert seen == [] + + reset = auth_context_var.set(as_user("alice")) + try: + result = await boundary(request(sealed.request_state), restore) + finally: + auth_context_var.reset(reset) + assert isinstance(result, CallToolResult) + assert seen == ["alice-secret"] + + +@pytest.mark.parametrize("name", [None, ""], ids=["unnamed", "empty-string"]) +def test_a_shared_key_policy_without_a_real_name_must_set_an_audience(name: str | None) -> None: + """SDK-defined: explicit keys usually mean a fleet, where the audience claim is what + separates services; without a real name every server would stamp the same placeholder.""" + with pytest.raises(ValueError) as excinfo: + MCPServer(name, request_state_security=RequestStateSecurity(keys=[_KEY])) + assert str(excinfo.value) == _MISSING_AUDIENCE + + # Every neighboring posture constructs: the default needs no name, and a real + # name or an explicit audience satisfies a shared-key policy. + MCPServer(name) + MCPServer(name, request_state_security=RequestStateSecurity(keys=[_KEY], audience="svc")) + MCPServer("named", request_state_security=RequestStateSecurity(keys=[_KEY])) diff --git a/tests/types/test_methods.py b/tests/types/test_methods.py index 342720c32c..126e06c291 100644 --- a/tests/types/test_methods.py +++ b/tests/types/test_methods.py @@ -553,6 +553,21 @@ def test_cacheable_methods_mirror_the_cacheable_method_literal(): assert methods.CACHEABLE_METHODS == frozenset(get_args(methods.CacheableMethod)) +def test_input_required_methods_mirror_the_monolith_input_required_arms(): + """MRTR weld: the spec's three multi-round-trip carriers are the only input_required methods.""" + assert methods.INPUT_REQUIRED_METHODS == frozenset({"prompts/get", "resources/read", "tools/call"}) + + +def test_is_input_required_matches_typed_and_wire_shapes(): + """SDK-defined predicate: True only for the typed model and the tagged wire mapping.""" + assert methods.is_input_required(types.InputRequiredResult(request_state="s")) + assert methods.is_input_required({"resultType": "input_required", "inputRequests": {}}) + assert not methods.is_input_required({"resultType": "complete", "content": []}) + assert not methods.is_input_required({}) + assert not methods.is_input_required(types.CallToolResult(content=[])) + assert not methods.is_input_required(None) + + def test_minimal_request_bodies_parse_through_every_request_row(): for (method, version), surface_type in methods.CLIENT_REQUESTS.items(): parsed = methods.parse_client_request(method, version, REQUEST_PARAMS_FIXTURES[surface_type]) From 4df609119fbec39f422da3dbfcf0a5dc225cc91b Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:31:02 +0100 Subject: [PATCH 035/100] Add a client extension API (#3034) --- docs/advanced/extensions.md | 126 +++- docs/migration.md | 41 +- docs_src/apps/tutorial001.py | 3 +- docs_src/extensions/tutorial004.py | 7 +- docs_src/extensions/tutorial006.py | 70 +++ docs_src/extensions/tutorial007.py | 50 ++ examples/stories/apps/README.md | 2 +- examples/stories/apps/client.py | 6 +- examples/stories/custom_methods/README.md | 14 +- examples/stories/custom_methods/client.py | 10 +- examples/stories/extensions/README.md | 2 +- examples/stories/extensions/client.py | 11 +- src/mcp-types/mcp_types/__init__.py | 2 + src/mcp-types/mcp_types/_types.py | 15 +- src/mcp/client/__init__.py | 14 + src/mcp/client/client.py | 112 +++- src/mcp/client/extension.py | 196 ++++++ src/mcp/client/session.py | 292 ++++++++- src/mcp/server/extension.py | 24 +- src/mcp/shared/extension.py | 28 + tests/client/test_client_extensions.py | 573 ++++++++++++++++++ tests/client/test_extension.py | 379 ++++++++++++ tests/client/test_send_request_mcp_name.py | 251 ++++++++ tests/client/test_session_claims.py | 468 ++++++++++++++ .../test_session_notification_bindings.py | 287 +++++++++ tests/client/test_session_promotions.py | 66 ++ tests/docs_src/test_apps.py | 5 +- tests/docs_src/test_extensions.py | 47 +- tests/interaction/_connect.py | 10 +- tests/interaction/_requirements.py | 76 +++ .../interaction/mcpserver/test_extensions.py | 174 ++++++ .../transports/test_hosting_http_modern.py | 57 +- tests/server/mcpserver/test_extension.py | 54 +- tests/server/test_apps.py | 20 +- tests/server/test_extensions_capability.py | 5 +- tests/shared/test_extension.py | 56 ++ tests/types/test_request_name_param.py | 37 ++ 37 files changed, 3410 insertions(+), 180 deletions(-) create mode 100644 docs_src/extensions/tutorial006.py create mode 100644 docs_src/extensions/tutorial007.py create mode 100644 src/mcp/client/extension.py create mode 100644 src/mcp/shared/extension.py create mode 100644 tests/client/test_client_extensions.py create mode 100644 tests/client/test_extension.py create mode 100644 tests/client/test_send_request_mcp_name.py create mode 100644 tests/client/test_session_claims.py create mode 100644 tests/client/test_session_notification_bindings.py create mode 100644 tests/client/test_session_promotions.py create mode 100644 tests/interaction/mcpserver/test_extensions.py create mode 100644 tests/shared/test_extension.py create mode 100644 tests/types/test_request_name_param.py diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md index 5a6d7d5244..0358ba5a83 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -2,9 +2,10 @@ An **extension** is an opt-in bundle of MCP behaviour behind one identifier. -It can contribute tools, resources, and new request methods, and it can wrap `tools/call`. -The server advertises it under `capabilities.extensions`, the client opts in the same way, -and nothing changes for anyone who didn't ask for it. That is the contract ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)), and +On a server it can contribute tools, resources, and new request methods, and it can wrap +`tools/call`. On a client it can claim extra `tools/call` result shapes and observe vendor +notifications. Each side advertises under its own `capabilities.extensions`, and nothing +changes for anyone who didn't ask for it. That is the contract ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)), and it has one golden rule: **extensions are off by default**. ## Using an extension @@ -79,7 +80,7 @@ And `main()` is the proof, an in-memory client straight against `mcp`: An extension can register **new request methods**: its own verbs, served next to the spec's: -```python title="server.py" hl_lines="15-21 30 39-47" +```python title="server.py" hl_lines="16-22 31 40-48" --8<-- "docs_src/extensions/tutorial004.py" ``` @@ -108,19 +109,19 @@ runtime: The same file's `main()` is the whole client story, both halves of it: -```python title="server.py" hl_lines="53-57" +```python title="server.py" hl_lines="54-58" --8<-- "docs_src/extensions/tutorial004.py" ``` -* `Client(..., extensions={EXTENSION_ID: {}})` declares the extension. That map - becomes `ClientCapabilities.extensions`: on a 2026-07-28 connection it travels in - the per-request `_meta` envelope, so the server sees it on **every** request; on - a legacy connection it rides the `initialize` handshake. Server code doesn't care - which: `require_client_extension(ctx, ...)` and +* `Client(..., extensions=[advertise(EXTENSION_ID)])` declares the extension. The + declarations become `ClientCapabilities.extensions`: on a 2026-07-28 connection + the map travels in the per-request `_meta` envelope, so the server sees it on + **every** request; on a legacy connection it rides the `initialize` handshake. + Server code doesn't care which: `require_client_extension(ctx, ...)` and `ctx.session.check_client_capability(...)` read the right source on both paths. * Vendor methods drop one layer to `client.session.send_request(...)`; `Client` - only grows first-class methods for spec verbs. The `cast` is there because - `send_request` is typed against the spec's closed request union. + only grows first-class methods for spec verbs. `send_request` accepts any + `Request` subclass, so the vendor request passes as-is. ### Intercepting `tools/call` @@ -144,15 +145,104 @@ or veto a tool call: The hook wraps `tools/call` and nothing else. For every-message concerns, use [Middleware](middleware.md). That is what it is for. +## Using a client extension + +A **client extension** is the same contract from the consuming side: a bundle of +client-side behaviour behind one identifier. Pass instances to +`Client(extensions=[...])` and call tools normally: + +```python title="client.py" hl_lines="67-69" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +`call_tool("buy", ...)` returns a plain `CallToolResult`, like every other call. What +the extension changed: the server may now answer `buy` with a `receipt` **result +shape** instead of a final result, and `Receipts` finishes it (here by redeeming the +receipt with a follow-up call) before `call_tool` returns. Nothing about the call +site moves. + +Drop the extension and none of this exists: the server's gate refuses a client +that did not declare it (error -32021), and a claimed shape from a server that +skips the gate fails validation, exactly as the spec requires for an +unrecognized `resultType`. Off by default, on both ends of the wire. + +To advertise an identifier with **no** client-side behaviour (the server gates on +the capability, the client does nothing, as in the search client above), use +`advertise()`: + +```python +from mcp.client import advertise + +client = Client(mcp, extensions=[advertise("com.example/search")]) +``` + +## Writing a client extension + +Subclass `ClientExtension` and override only what you need. Three contribution +kinds, each with a default: `settings()`, `claims()`, and `notifications()`. + +```python title="client.py" hl_lines="18-19 44-45 47-48" +--8<-- "docs_src/extensions/tutorial006.py" +``` + +* The identifier follows the same grammar as the server's, validated when the class + is defined. +* `claims()` returns `ResultClaim`s: a wire tag, the model that parses it, and the + resolver that finishes it. The model must pin the tag with + `result_type: Literal["receipt"]` and must not subclass the verb's core result + types; both are enforced when the claim is constructed. Vendor fields like + `receipt_token` ride the wire as-is: a substituted shape reaches the client + verbatim. +* The resolver receives the parsed model and a `ClaimContext`; `ctx.session` is the + same public handle as `client.session`, so follow-ups are ordinary session calls. + It returns the verb's normal `CallToolResult`. +* `settings()` is the value advertised at `ClientCapabilities.extensions[identifier]`, + read once at `Client` construction. + +`notifications()` declares vendor server notifications to observe: + +```python +def notifications(self) -> Sequence[NotificationBinding[Any]]: + return [NotificationBinding(method="notifications/receipts", params_type=ReceiptEvent, handler=self.on_receipt)] +``` + +The handler receives validated params one at a time, in dispatch order. It observes; it cannot veto +or reply. + +Two quiet rules. Claims are active on 2026-07-28 connections only, and the capability +ad follows them: on a legacy connection the claims dissolve and the identifier drops +out of the ad with them, so the client never advertises an extension whose shapes it +would reject. And when you want the claimed shape yourself instead of the resolver, +call `client.session.call_tool(..., allow_claimed=True)`; without that flag, a +claimed shape reaching a session-tier caller raises `UnexpectedClaimedResult`. + +### Extension verbs + +An extension's own request methods need no client-side registration. A vendor request +type subclasses `mcp_types.Request` and goes through `client.session.send_request`, +as in [Serving your own methods](#serving-your-own-methods). One addition: when a +params key must ride the `Mcp-Name` header (extension specs such as tasks require +this for their verbs), the request type declares `name_param`: + +```python title="client.py" hl_lines="23-26 47-48" +--8<-- "docs_src/extensions/tutorial007.py" +``` + +The session mirrors `params["jobId"]` into `Mcp-Name` on every send path, and a +missing value fails loudly rather than silently omitting a required header. + ## What an extension cannot do -The contribution surface is **closed** on purpose: settings, tools, resources, -methods, one `tools/call` interceptor. An extension cannot: +The contribution surface is **closed** on purpose. On the server: settings, tools, +resources, methods, one `tools/call` interceptor. On the client: settings, result +claims, notification bindings. An extension cannot: -* **Reach into the server.** It declares data; it holds no server reference. -* **Replace core behaviour.** Spec methods are rejected at construction, and - `initialize` is reserved by the runner outright. -* **Register late.** After `MCPServer(...)` returns, the extension set is what it is. +* **Reach into the host.** It declares data; it holds no server or client reference. +* **Replace core behaviour.** Spec methods and core result tags are rejected at + construction (`initialize` is reserved by the runner outright); a notification + binding shadowed by core vocabulary goes quiet with a warning instead. +* **Register late.** After `MCPServer(...)` or `Client(...)` returns, the extension + set is what it is. If you are fighting these walls, you are not writing an extension. You are writing a fork. The walls are the feature: a user reading `extensions=[Apps(), Stamps()]` diff --git a/docs/migration.md b/docs/migration.md index 047626ee21..eb6f68a71c 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -469,11 +469,42 @@ extension handler can call `mcp.server.mcpserver.require_client_extension(ctx, i to reject a request with the `-32021` (missing required client capability) error when the client did not declare the extension. -Clients advertise extension support with the new `Client(extensions=...)` / -`ClientSession(extensions=...)` argument, mirrored into `ClientCapabilities.extensions`. -The extensions capability map is negotiated over `server/discover` (modern path); -a legacy `initialize` handshake does not carry it. Extensions are off by default -and never alter behaviour unless registered. +On the client, `Client(extensions=...)` takes a sequence of +`mcp.client.ClientExtension` instances. A client extension contributes its +capability ad (mirrored into `ClientCapabilities.extensions`), its result +claims (extra `tools/call` result shapes that `Client.call_tool` resolves +transparently through the claim's resolver), and its notification bindings +(handlers for vendor server notifications). The capability map rides +`server/discover` and every modern request's `_meta` envelope; a legacy +`initialize` handshake carries only the claim-less identifiers, since claimed +result shapes cannot be delivered on a legacy wire. Extensions are off by +default and never alter behaviour unless registered. (The low-level +`ClientSession(extensions=...)` keeps the raw identifier-to-settings dict.) + +Changed in the v2 pre-releases: earlier alphas took +`Client(extensions={identifier: settings})`, an advertisement-only dict. +Extensions now contribute behaviour (claims and notification handlers), not +just an ad, so the argument is a sequence of declaration objects. An ad-only +entry becomes an `advertise()` call: + +**Before (v2 alphas):** + +```python +client = Client(server, extensions={"com.example/ui": {"mimeTypes": [...]}}) +``` + +**After:** + +```python +from mcp.client import advertise + +client = Client(server, extensions=[advertise("com.example/ui", {"mimeTypes": [...]})]) +``` + +`advertise()` is only for identifiers with no client-side behaviour. For a +behavioural extension (e.g. tasks, once its extension ships), construct that +extension's object instead; advertising an identifier you do not implement +asserts wire support you don't have. ### `McpError` renamed to `MCPError` diff --git a/docs_src/apps/tutorial001.py b/docs_src/apps/tutorial001.py index 79721c597b..27d9eded83 100644 --- a/docs_src/apps/tutorial001.py +++ b/docs_src/apps/tutorial001.py @@ -1,4 +1,5 @@ from mcp import Client +from mcp.client import advertise from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID, Apps, client_supports_apps from mcp.server.mcpserver import MCPServer from mcp.server.mcpserver.context import Context @@ -32,7 +33,7 @@ def get_time(ctx: Context) -> str: async def main() -> None: - async with Client(mcp, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as client: + async with Client(mcp, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]) as client: result = await client.call_tool("get_time", {}) print(result.content) # [TextContent(text='2026-06-26T12:00:00Z')] diff --git a/docs_src/extensions/tutorial004.py b/docs_src/extensions/tutorial004.py index 4a0a022af3..7ad32052d2 100644 --- a/docs_src/extensions/tutorial004.py +++ b/docs_src/extensions/tutorial004.py @@ -1,10 +1,11 @@ from collections.abc import Sequence -from typing import Any, Literal, cast +from typing import Any, Literal import mcp_types as types from pydantic import Field from mcp import Client +from mcp.client import advertise from mcp.server.context import ServerRequestContext from mcp.server.extension import Extension, MethodBinding from mcp.server.mcpserver import MCPServer, require_client_extension @@ -51,8 +52,8 @@ def methods(self) -> Sequence[MethodBinding]: async def main() -> None: - async with Client(mcp, extensions={EXTENSION_ID: {}}) as client: + async with Client(mcp, extensions=[advertise(EXTENSION_ID)]) as client: request = SearchRequest(params=SearchParams(query="mcp", limit=3)) - result = await client.session.send_request(cast("types.ClientRequest", request), SearchResult) + result = await client.session.send_request(request, SearchResult) print(result.items) # ['mcp-0', 'mcp-1', 'mcp-2'] diff --git a/docs_src/extensions/tutorial006.py b/docs_src/extensions/tutorial006.py new file mode 100644 index 0000000000..05ffbcb9d6 --- /dev/null +++ b/docs_src/extensions/tutorial006.py @@ -0,0 +1,70 @@ +from collections.abc import Sequence +from typing import Any, Literal + +import mcp_types as types + +from mcp import Client +from mcp.client import ClaimContext, ClientExtension, ResultClaim +from mcp.server.context import CallNext, HandlerResult, ServerRequestContext +from mcp.server.extension import Extension +from mcp.server.mcpserver import MCPServer, require_client_extension + +EXTENSION_ID = "com.example/receipts" + + +class ReceiptResult(types.Result): + """The claimed result shape; `result_type` pins the wire tag.""" + + result_type: Literal["receipt"] = "receipt" + receipt_token: str + + +class ReceiptIssuer(Extension): + """Server half: answers `buy` with a receipt instead of a final result.""" + + identifier = EXTENSION_ID + + async def intercept_tool_call( + self, + params: types.CallToolRequestParams, + ctx: ServerRequestContext[Any, Any], + call_next: CallNext, + ) -> HandlerResult: + if params.name != "buy": + return await call_next(ctx) + require_client_extension(ctx, EXTENSION_ID) + return {"resultType": "receipt", "receiptToken": "r-117"} + + +class Receipts(ClientExtension): + """Client half: claims the `receipt` shape and supplies the code that finishes it.""" + + identifier = EXTENSION_ID + + def claims(self) -> Sequence[ResultClaim[Any]]: + return [ResultClaim(result_type="receipt", model=ReceiptResult, resolve=self._redeem)] + + async def _redeem(self, claimed: ReceiptResult, ctx: ClaimContext) -> types.CallToolResult: + return await ctx.session.call_tool("redeem", {"token": claimed.receipt_token}) + + +mcp = MCPServer("shop", extensions=[ReceiptIssuer()]) + + +@mcp.tool() +def buy(item: str) -> types.CallToolResult: + """Buy an item.""" + raise NotImplementedError # ReceiptIssuer answers `buy` before the tool runs + + +@mcp.tool() +def redeem(token: str) -> str: + """Exchange a receipt token for the goods.""" + return f"goods for {token}" + + +async def main() -> None: + async with Client(mcp, extensions=[Receipts()]) as client: + result = await client.call_tool("buy", {"item": "lamp"}) + print(result.content) + # [TextContent(text='goods for r-117')] diff --git a/docs_src/extensions/tutorial007.py b/docs_src/extensions/tutorial007.py new file mode 100644 index 0000000000..37706ca219 --- /dev/null +++ b/docs_src/extensions/tutorial007.py @@ -0,0 +1,50 @@ +from collections.abc import Sequence +from typing import Any, Literal + +import mcp_types as types + +from mcp import Client +from mcp.client import advertise +from mcp.server.context import ServerRequestContext +from mcp.server.extension import Extension, MethodBinding +from mcp.server.mcpserver import MCPServer + +EXTENSION_ID = "com.example/jobs" + + +class JobParams(types.RequestParams): + job_id: str + + +class JobStatus(types.Result): + status: str + + +class JobStatusRequest(types.Request[JobParams, Literal["com.example/jobs.status"]]): + method: Literal["com.example/jobs.status"] = "com.example/jobs.status" + params: JobParams + name_param = "jobId" # params["jobId"] rides the Mcp-Name header + + +async def job_status(ctx: ServerRequestContext[Any, Any], params: JobParams) -> JobStatus: + return JobStatus(status=f"{params.job_id} is running") + + +class Jobs(Extension): + """An extension whose verb names its subject, so the header can route on it.""" + + identifier = EXTENSION_ID + + def methods(self) -> Sequence[MethodBinding]: + return [MethodBinding("com.example/jobs.status", JobParams, job_status)] + + +mcp = MCPServer("worker", extensions=[Jobs()]) + + +async def main() -> None: + async with Client(mcp, extensions=[advertise(EXTENSION_ID)]) as client: + request = JobStatusRequest(params=JobParams(job_id="job-7")) + result = await client.session.send_request(request, JobStatus) + print(result.status) + # job-7 is running diff --git a/examples/stories/apps/README.md b/examples/stories/apps/README.md index dc180a0d3d..40414737e7 100644 --- a/examples/stories/apps/README.md +++ b/examples/stories/apps/README.md @@ -26,7 +26,7 @@ uv run python -m stories.apps.client --http `text/html;profile=mcp-app`. - `server.py` `client_supports_apps(ctx)` — SEP-2133 graceful degradation: a client that did not negotiate Apps gets a text-only result. -- `client.py` `Client(target, extensions={...})` — the client advertises Apps +- `client.py` `Client(target, extensions=[advertise(...)])` — the client advertises Apps support so the server returns the UI-enabled result, then reads the tool's `_meta.ui.resourceUri` and fetches that resource. diff --git a/examples/stories/apps/client.py b/examples/stories/apps/client.py index 8a238f469e..dd79071b1d 100644 --- a/examples/stories/apps/client.py +++ b/examples/stories/apps/client.py @@ -2,7 +2,7 @@ from mcp_types import TextContent, TextResourceContents -from mcp.client import Client +from mcp.client import Client, advertise from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID from stories._harness import Target, run_client @@ -10,7 +10,9 @@ async def main(target: Target, *, mode: str = "auto") -> None: # Advertise MCP Apps support so the server returns the UI-enabled result; a # client that omits this gets the text-only fallback (graceful degradation). - async with Client(target, mode=mode, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as client: + async with Client( + target, mode=mode, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})] + ) as client: # The extensions capability map rides `server/discover` (modern only). On a # legacy connection (today's stdio) it is absent, so assert it only when present. if client.server_capabilities.extensions is not None: diff --git a/examples/stories/custom_methods/README.md b/examples/stories/custom_methods/README.md index 924ea0298d..75f1502028 100644 --- a/examples/stories/custom_methods/README.md +++ b/examples/stories/custom_methods/README.md @@ -28,18 +28,16 @@ uv run python -m stories.custom_methods.client --http 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. + verbs, so vendor methods go through the underlying `ClientSession`. + `send_request` accepts any `types.Request` subclass. ## 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. + **notification** (`acme/searchProgress`). The Python client can observe + vendor notifications via `NotificationBinding` (see + `docs/advanced/extensions.md`). That half is omitted here because the + lowlevel server has no surface for emitting vendor notifications yet. ## Spec diff --git a/examples/stories/custom_methods/client.py b/examples/stories/custom_methods/client.py index 4003885fa4..7bf27dd76c 100644 --- a/examples/stories/custom_methods/client.py +++ b/examples/stories/custom_methods/client.py @@ -1,6 +1,6 @@ """Send a vendor-prefixed request via the `client.session` escape hatch.""" -from typing import Literal, cast +from typing import Literal import mcp_types as types @@ -26,12 +26,10 @@ 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. + # yet, and whether `.session` stays public is undecided. `send_request` + # accepts any `Request` subclass. request = SearchRequest(params=SearchParams(query="mcp", limit=3)) - result = await client.session.send_request(cast("types.ClientRequest", request), SearchResult) + result = await client.session.send_request(request, SearchResult) assert result.items == ["mcp-0", "mcp-1", "mcp-2"], result diff --git a/examples/stories/extensions/README.md b/examples/stories/extensions/README.md index 6d3da72c9f..4668f990d8 100644 --- a/examples/stories/extensions/README.md +++ b/examples/stories/extensions/README.md @@ -24,7 +24,7 @@ uv run python -m stories.extensions.client --http rejects clients that did not declare the extension with `-32021` (missing required client capability) and a machine-readable `requiredCapabilities` payload. -- `client.py` `Client(target, extensions={EXTENSION_ID: {}})` — the client-side +- `client.py` `Client(target, extensions=[advertise(EXTENSION_ID)])` — the client-side half of the negotiation; on 2026-07-28 it travels in the per-request `_meta` envelope. - `client.py` `client.session.send_request(...)` — vendor methods have no diff --git a/examples/stories/extensions/client.py b/examples/stories/extensions/client.py index d3aacc140f..0bb033d7a3 100644 --- a/examples/stories/extensions/client.py +++ b/examples/stories/extensions/client.py @@ -1,11 +1,11 @@ """Discover an extension's capability entry, call its tool, then send its vendor method.""" -from typing import Literal, cast +from typing import Literal import mcp_types as types from mcp_types import TextContent -from mcp.client import Client +from mcp.client import Client, advertise from stories._harness import Target, run_client EXTENSION_ID = "com.example/catalog" @@ -28,7 +28,7 @@ class SearchResult(types.Result): async def main(target: Target, *, mode: str = "auto") -> None: # Declare the extension client-side so the server's `require_client_extension` # gate on `com.example/search` passes. - async with Client(target, mode=mode, extensions={EXTENSION_ID: {}}) as client: + async with Client(target, mode=mode, extensions=[advertise(EXTENSION_ID)]) as client: # The extensions capability map rides `server/discover` (modern only). On a # legacy connection it is absent, so assert it only when present. if client.server_capabilities.extensions is not None: @@ -43,10 +43,9 @@ async def main(target: Target, *, mode: str = "auto") -> None: assert isinstance(result.content[0], TextContent) assert result.content[0].text == "mcp-suggestion", result.content[0].text - # Vendor methods drop one layer to `client.session` (see custom_methods/); - # the cast is needed because `send_request` is typed against the spec union. + # Vendor methods drop one layer to `client.session` (see custom_methods/). request = SearchRequest(params=SearchParams(query="mcp", limit=3)) - found = await client.session.send_request(cast("types.ClientRequest", request), SearchResult) + found = await client.session.send_request(request, SearchResult) assert found.items == ["mcp-0", "mcp-1", "mcp-2"], found diff --git a/src/mcp-types/mcp_types/__init__.py b/src/mcp-types/mcp_types/__init__.py index 2ed97cba33..87c0c5d594 100644 --- a/src/mcp-types/mcp_types/__init__.py +++ b/src/mcp-types/mcp_types/__init__.py @@ -8,6 +8,7 @@ from mcp_types._types import ( CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, + CORE_RESULT_TYPES, DEFAULT_NEGOTIATED_VERSION, LOG_LEVEL_META_KEY, PROTOCOL_VERSION_META_KEY, @@ -231,6 +232,7 @@ "CLIENT_CAPABILITIES_META_KEY", "LOG_LEVEL_META_KEY", # Type aliases and variables + "CORE_RESULT_TYPES", "ContentBlock", "ElicitRequestedSchema", "ElicitRequestParams", diff --git a/src/mcp-types/mcp_types/_types.py b/src/mcp-types/mcp_types/_types.py index 34dc10083b..9c0516836a 100644 --- a/src/mcp-types/mcp_types/_types.py +++ b/src/mcp-types/mcp_types/_types.py @@ -8,7 +8,7 @@ from __future__ import annotations -from typing import Annotated, Any, Final, Generic, Literal, TypeAlias, TypeVar +from typing import Annotated, Any, ClassVar, Final, Generic, Literal, TypeAlias, TypeVar, get_args from pydantic import ( BaseModel, @@ -128,6 +128,12 @@ class Request(MCPModel, Generic[RequestParamsT, MethodT]): method: MethodT params: RequestParamsT + name_param: ClassVar[str | None] = None + """Wire-params key mirrored into the `Mcp-Name` header on sends; SEP-2663 requires it for tasks/*. + + Subclasses override by bare assignment: re-annotating as `ClassVar` trips pyright's invariance check. + """ + class PaginatedRequest(Request[PaginatedRequestParams | None, MethodT], Generic[MethodT]): """Base class for paginated requests, matching the schema's PaginatedRequest interface.""" @@ -144,7 +150,9 @@ class Notification(MCPModel, Generic[NotificationParamsT, MethodT]): params: NotificationParamsT -ResultType = Literal["complete", "input_required"] | str +_CoreResultType = Literal["complete", "input_required"] + +ResultType = _CoreResultType | str """Tags a `Result` so the client knows how to parse it (2026-07-28). "complete" means the result is final; "input_required" means it is an @@ -152,6 +160,9 @@ class Notification(MCPModel, Generic[NotificationParamsT, MethodT]): Absent `resultType` is equivalent to "complete". """ +CORE_RESULT_TYPES: Final[frozenset[str]] = frozenset(get_args(_CoreResultType)) +"""The `resultType` tags owned by the core protocol vocabulary; extension claims may not re-key them.""" + class Result(MCPModel): """Base class for JSON-RPC results. diff --git a/src/mcp/client/__init__.py b/src/mcp/client/__init__.py index b7823f5efe..21581749d0 100644 --- a/src/mcp/client/__init__.py +++ b/src/mcp/client/__init__.py @@ -12,6 +12,14 @@ ) from mcp.client.client import Client from mcp.client.context import ClientRequestContext +from mcp.client.extension import ( + ClaimContext, + ClientExtension, + NotificationBinding, + ResultClaim, + UnexpectedClaimedResult, + advertise, +) from mcp.client.session import ClientSession __all__ = [ @@ -19,11 +27,17 @@ "CacheEntry", "CacheKey", "CacheMode", + "ClaimContext", "Client", + "ClientExtension", "ClientRequestContext", "ClientSession", "InMemoryResponseCacheStore", "InputRequiredRoundsExceededError", + "NotificationBinding", "ResponseCacheStore", + "ResultClaim", "Transport", + "UnexpectedClaimedResult", + "advertise", ] diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index c2db891ca6..c42a29284c 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -5,7 +5,7 @@ import hashlib import logging import uuid -from collections.abc import Awaitable, Callable, Mapping +from collections.abc import Awaitable, Callable, Mapping, Sequence from contextlib import AsyncExitStack from dataclasses import KW_ONLY, dataclass, field from typing import Any, Literal, TypeVar, cast @@ -36,6 +36,7 @@ ReadResourceResult, RequestParamsMeta, ResourceTemplateReference, + Result, ServerCapabilities, ) from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS @@ -46,6 +47,7 @@ from mcp.client._probe import negotiate_auto from mcp.client._transport import Transport from mcp.client.caching import CacheConfig, CacheMode, ClientResponseCache, InMemoryResponseCacheStore +from mcp.client.extension import ClaimContext, ClientExtension, NotificationBinding, ResultClaim from mcp.client.session import ( ClientRequestContext, ClientSession, @@ -62,6 +64,7 @@ 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.extension import validate_extension_identifier from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher from mcp.shared.session import RequestResponder @@ -188,6 +191,72 @@ async def _no_inbound_client_notifications(_dctx: Any, _method: str, _params: Ma """ +@dataclass(frozen=True) +class _FoldedExtensions: + """`Client.extensions` instances folded into the shapes `ClientSession` consumes.""" + + ad: dict[str, dict[str, Any]] | None + claims: dict[str, tuple[ResultClaim[Any], ...]] | None + bindings: tuple[NotificationBinding[Any], ...] | None + by_model: Mapping[type[Result], ResultClaim[Any]] + + +def _fold_extensions(extensions: Sequence[ClientExtension] | None) -> _FoldedExtensions: + """Fold extension contributions at construction, naming both owners on duplicate tags or methods.""" + if isinstance(extensions, Mapping): + raise TypeError( + "extensions= takes a sequence of ClientExtension instances. The mapping form was " + "replaced: use advertise(identifier, settings) for advertise-only entries" + ) + if not extensions: + return _FoldedExtensions(ad=None, claims=None, bindings=None, by_model={}) + ad: dict[str, dict[str, Any]] = {} + claims: dict[str, tuple[ResultClaim[Any], ...]] = {} + bindings: list[NotificationBinding[Any]] = [] + by_model: dict[type[Result], ResultClaim[Any]] = {} + claim_owners: dict[str, str] = {} + binding_owners: dict[str, str] = {} + for extension in extensions: + identifier = getattr(extension, "identifier", None) + if identifier is None: + raise ValueError( + f"{type(extension).__name__} has no `identifier`; a ClientExtension must set the " + "`identifier` class attribute (or assign one in `__init__`) before it can be used" + ) + validate_extension_identifier(identifier, owner=type(extension).__name__) + if identifier in ad: + raise ValueError(f"extension identifier {identifier!r} is passed more than once") + ad[identifier] = extension.settings() + extension_claims = tuple(extension.claims()) + for claim in extension_claims: + tag = claim.result_type + if tag in claim_owners: + owner = claim_owners[tag] + both = ( + f"extension {identifier!r} claims" + if owner == identifier + else (f"extensions {owner!r} and {identifier!r} both claim") + ) + raise ValueError(f"{both} resultType {tag!r}; a wire tag can have only one resolver") + claim_owners[tag] = identifier + # Each model pins its result_type Literal to one tag, so this index cannot collide. + by_model[claim.model] = claim + if extension_claims: + claims[identifier] = extension_claims + for binding in extension.notifications(): + if binding.method in binding_owners: + owner = binding_owners[binding.method] + both = ( + f"extension {identifier!r} binds" + if owner == identifier + else (f"extensions {owner!r} and {identifier!r} both bind") + ) + raise ValueError(f"{both} notification method {binding.method!r}; a method can have only one observer") + binding_owners[binding.method] = identifier + bindings.append(binding) + return _FoldedExtensions(ad=ad, claims=claims or None, bindings=tuple(bindings) or None, by_model=by_model) + + @dataclass class Client: """A high-level MCP client for connecting to MCP servers. @@ -268,9 +337,12 @@ async def main(): `read_resource` give up. Use `client.session.(..., allow_input_required=True)` to drive the loop manually instead.""" - extensions: dict[str, dict[str, Any]] | None = None - """SEP-2133 extension support to advertise under `ClientCapabilities.extensions` - (identifier -> settings), e.g. `{"io.modelcontextprotocol/ui": {"mimeTypes": [...]}}`.""" + extensions: Sequence[ClientExtension] | None = None + """Opt-in client extensions (SEP-2133). + + Each instance contributes its capability ad, its result claims (resolved + transparently by `call_tool`), and its notification bindings. For an + ad-only entry use `mcp.client.advertise(identifier, settings)`.""" cache: CacheConfig | Literal[False] | None = None """Client-side response caching for the SEP-2549 cacheable methods (2026-07-28). @@ -286,6 +358,7 @@ async def main(): _exit_stack: AsyncExitStack | None = field(init=False, default=None) _connect: _Connector = field(init=False, repr=False, compare=False) _response_cache: ClientResponseCache | None = field(init=False, default=None, repr=False, compare=False) + _folded_extensions: _FoldedExtensions = field(init=False, repr=False, compare=False) def __post_init__(self) -> None: if self.mode not in ("legacy", "auto") and self.mode not in MODERN_PROTOCOL_VERSIONS: @@ -298,6 +371,8 @@ def __post_init__(self) -> None: f"mode must be 'legacy', 'auto', or one of {list(MODERN_PROTOCOL_VERSIONS)}; got {self.mode!r}{hint}" ) + self._folded_extensions = _fold_extensions(self.extensions) + srv = self.server if isinstance(srv, MCPServer): srv = srv._lowlevel_server # pyright: ignore[reportPrivateUsage] @@ -348,7 +423,9 @@ async def _build_session(self, exit_stack: AsyncExitStack) -> ClientSession: message_handler=message_handler, client_info=self.client_info, elicitation_callback=self.elicitation_callback, - extensions=self.extensions, + extensions=self._folded_extensions.ad, + result_claims=self._folded_extensions.claims, + notification_bindings=self._folded_extensions.bindings, ) async def __aenter__(self) -> Client: @@ -613,6 +690,11 @@ async def call_tool( state is still subject to the server's TTL, request binding, and key lifetime; a server on the default process-local key rejects it after a restart. + Result shapes claimed by this client's `extensions` are finished by the + owning claim's resolver, whose `CallToolResult` is returned; resolver + exceptions propagate as-is. To receive the claimed shape yourself, use + `client.session.call_tool(..., allow_claimed=True)`. + Args: name: The name of the tool to call. arguments: Arguments to pass to the tool. @@ -631,7 +713,7 @@ async def call_tool( MCPError: A callback returned `ErrorData` for an embedded input request. """ - async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | InputRequiredResult: + async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | InputRequiredResult | Result: return await self.session.call_tool( name, arguments, @@ -641,9 +723,23 @@ async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | Inp request_state=s, meta=meta, allow_input_required=True, + # Input rounds resolve before a claimed result, so a claim may end any round. + allow_claimed=True, ) - return await self._drive_input_required(await retry(input_responses, request_state), retry) + result = await self._drive_input_required(await retry(input_responses, request_state), retry) + if isinstance(result, CallToolResult): + return result + # Only claimed shapes reach this point, so the lookup is total. + claim = self._folded_extensions.by_model[type(result)] + final = await claim.resolve( + result, + ClaimContext(session=self.session, tool_name=name, read_timeout_seconds=read_timeout_seconds), + ) + if not final.is_error: + # Match the direct path: revalidate the output schema, but never for isError results. + await self.session.validate_tool_result(name, final) + return final async def list_prompts( self, @@ -717,7 +813,7 @@ async def _drive_input_required( async def dispatch(key: str, req: InputRequest) -> InputResponse | ErrorData: ctx = ClientRequestContext(session=session, request_id=key, meta=req.params.meta if req.params else None) - return await session._dispatch_input_request(ctx, req) # pyright: ignore[reportPrivateUsage] + return await session.dispatch_input_request(ctx, req) return await run_input_required_driver( first, dispatch=dispatch, retry=retry, max_rounds=self.input_required_max_rounds diff --git a/src/mcp/client/extension.py b/src/mcp/client/extension.py new file mode 100644 index 0000000000..a813475e5e --- /dev/null +++ b/src/mcp/client/extension.py @@ -0,0 +1,196 @@ +"""Opt-in extension interface for MCP clients. + +Subclass `ClientExtension`, set `identifier`, override the hooks you need, and +pass instances to `Client(extensions=[...])`. For an identifier-only +capability ad, use `advertise()`. +""" + +from __future__ import annotations + +from collections.abc import Awaitable, Callable, Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Final, Generic, Literal, TypeVar, get_args + +from mcp_types import CORE_RESULT_TYPES, CallToolResult, InputRequiredResult, Result +from mcp_types.version import MODERN_PROTOCOL_VERSIONS +from pydantic import AliasChoices, AliasPath, BaseModel +from pydantic.fields import FieldInfo + +from mcp.shared.extension import validate_extension_identifier + +if TYPE_CHECKING: + from mcp.client.session import ClientSession + +__all__ = [ + "ClaimContext", + "ClientExtension", + "NotificationBinding", + "ResultClaim", + "UnexpectedClaimedResult", + "advertise", +] + +_CLAIM_METHODS: Final[frozenset[str]] = frozenset({"tools/call"}) +"""The closed set of verbs a claim may attach to; widen together with the `method` Literal.""" + +_RESERVED_WIRE_ALIASES: Final[frozenset[str]] = frozenset({"requestState", "inputRequests"}) +"""Typed optional fields of the core result surface that pre-validates every inbound result.""" + + +def _wire_keys(name: str, field: FieldInfo) -> frozenset[str]: + """Every top-level wire key this field can read from or write to.""" + keys = {field.alias or name} + if field.serialization_alias: + keys.add(field.serialization_alias) + validation_alias = field.validation_alias + choices = validation_alias.choices if isinstance(validation_alias, AliasChoices) else [validation_alias] + for choice in choices: + if isinstance(choice, AliasPath): + choice = choice.path[0] + if isinstance(choice, str): + keys.add(choice) + return frozenset(keys) + + +ClaimedT = TypeVar("ClaimedT", bound=Result) +NotifyParamsT = TypeVar("NotifyParamsT", bound=BaseModel) + + +@dataclass(frozen=True, kw_only=True) +class ClaimContext: + """Host-injected context for one `ResultClaim.resolve` call.""" + + session: ClientSession + tool_name: str + read_timeout_seconds: float | None + + +@dataclass(frozen=True, kw_only=True) +class ResultClaim(Generic[ClaimedT]): + """One extra result shape on one spec verb, keyed by the wire `resultType`. + + Active only while the declaring extension is constructed into the client and + the negotiated protocol version admits it. `resolve` finishes a claimed + result, may send follow-ups through `ctx.session`, and must return the + verb's ordinary result. All field constraints are enforced at construction. + """ + + result_type: str + model: type[ClaimedT] + resolve: Callable[[ClaimedT, ClaimContext], Awaitable[CallToolResult]] + method: Literal["tools/call"] = "tools/call" + protocol_versions: frozenset[str] | None = None + + def __post_init__(self) -> None: + if self.method not in _CLAIM_METHODS: + raise ValueError(f"claims attach to {sorted(_CLAIM_METHODS)} only; got method {self.method!r}") + if self.result_type in CORE_RESULT_TYPES: + raise ValueError(f"resultType {self.result_type!r} is core protocol vocabulary") + if Result not in self.model.__mro__: # runtime guard; the ClaimedT bound only constrains checked callers + raise ValueError(f"{self.model.__name__} must subclass mcp_types.Result") + if issubclass(self.model, CallToolResult | InputRequiredResult): + raise ValueError("claim models must not subclass core result types") + for name, model_field in self.model.model_fields.items(): + for clash in sorted(_wire_keys(name, model_field) & _RESERVED_WIRE_ALIASES): + raise ValueError( + f"{self.model.__name__}.{name} aliases {clash!r}, a typed field of the core " + "result surface; a colliding value would fail core validation before the " + "claim adapter runs" + ) + field = self.model.model_fields.get("result_type") + if field is None or get_args(field.annotation) != (self.result_type,): + raise ValueError(f"{self.model.__name__}.result_type must be Literal[{self.result_type!r}]") + if self.protocol_versions is not None and not self.protocol_versions: + raise ValueError("empty protocol_versions could never activate; use None for all") + if self.protocol_versions is not None and not self.protocol_versions.issubset(MODERN_PROTOCOL_VERSIONS): + unrecognized = sorted(self.protocol_versions.difference(MODERN_PROTOCOL_VERSIONS)) + raise ValueError( + f"protocol_versions {unrecognized} are not modern protocol revisions; claimed shapes " + "cannot be delivered on a legacy wire (None means every modern version)" + ) + + +class UnexpectedClaimedResult(RuntimeError): + """A claimed (extension) result arrived on a `call_tool` that did not opt in. + + The parsed value is carried as `result`; the server may already hold state it + references. Opt in via `Client(extensions=[...])` or `allow_claimed=True`. + """ + + def __init__(self, result: Result) -> None: + super().__init__( + f"Server returned a claimed result ({type(result).__name__}); pass the owning extension to " + "Client(extensions=[...]) for transparent resolution, or call with allow_claimed=True " + "and handle the shape. The carried result may reference server-side state needing cleanup." + ) + self.result = result + + +@dataclass(frozen=True, kw_only=True) +class NotificationBinding(Generic[NotifyParamsT]): + """Deliver server notifications for `method` (the bare wire name) to `handler`. + + Observation-only: validated params arrive one at a time per binding, in + dispatch order, through a bounded queue that drops the oldest with a warning + on overflow. Stream transports dispatch each notification independently, so + near-simultaneous notifications may be dispatched out of wire order. Methods + the negotiated version's core tables handle are never delivered to bindings. + """ + + method: str + params_type: type[NotifyParamsT] + handler: Callable[[NotifyParamsT], Awaitable[None]] + + +class ClientExtension: + """Base class for an opt-in client extension; override only what you need. + + The surface is declarative, fixed at construction, and never receives the client. + """ + + #: Reverse-DNS extension identifier, advertised under `ClientCapabilities.extensions`. + identifier: str + + def __init_subclass__(cls, **kwargs: Any) -> None: + super().__init_subclass__(**kwargs) + # Per-instance identifiers (assigned in __init__) are validated at consumption instead. + if (identifier := cls.__dict__.get("identifier")) is not None: + validate_extension_identifier(identifier, owner=cls.__name__) + + def settings(self) -> dict[str, Any]: + """Per-extension settings advertised at `ClientCapabilities.extensions[identifier]`. + + Read once at `Client` construction. A claim-bearing extension is + advertised only at protocol versions where at least one of its claims + is active. + """ + return {} + + def claims(self) -> Sequence[ResultClaim[Any]]: + """Extra result shapes this extension claims, with their resolvers.""" + return () + + def notifications(self) -> Sequence[NotificationBinding[Any]]: + """Server notifications this extension observes.""" + return () + + +class _AdvertiseOnly(ClientExtension): + """Ad-only extension returned by `advertise()`.""" + + def __init__(self, identifier: str, settings: dict[str, Any]) -> None: + self.identifier = identifier + self._settings = settings + + def settings(self) -> dict[str, Any]: + return self._settings + + +def advertise(identifier: str, settings: dict[str, Any] | None = None) -> ClientExtension: + """Advertise an extension identifier (with optional settings) and nothing else. + + Advertising an extension you do not implement asserts wire support you do + not have; for behavioral extensions construct the real extension instead. + """ + validate_extension_identifier(identifier, owner="advertise") + return _AdvertiseOnly(identifier, {} if settings is None else settings) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 6a2298ad93..e6ae766d99 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -1,15 +1,18 @@ from __future__ import annotations import logging -from collections.abc import Callable, Mapping +from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass +from functools import reduce +from operator import or_ from types import TracebackType -from typing import Any, Literal, Protocol, cast, overload +from typing import Annotated, Any, Final, Literal, Protocol, cast, overload import anyio import anyio.abc import anyio.lowlevel import mcp_types as types +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from mcp_types import ( CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, @@ -27,10 +30,11 @@ LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS, ) -from pydantic import BaseModel, TypeAdapter, ValidationError +from pydantic import BaseModel, Discriminator, Tag, TypeAdapter, ValidationError from typing_extensions import Self, TypeVar, deprecated from mcp.client._transport import ReadStream, WriteStream +from mcp.client.extension import NotificationBinding, ResultClaim, UnexpectedClaimedResult from mcp.shared._compat import resync_tracer from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, ProgressFnT from mcp.shared.exceptions import MCPDeprecationWarning, MCPError @@ -51,6 +55,7 @@ DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0") DISCOVER_TIMEOUT_SECONDS = 10.0 +_NOTIFICATION_QUEUE_SIZE: Final = 256 logger = logging.getLogger("client") @@ -189,7 +194,8 @@ async def _default_logging_callback( ClientResponse: TypeAdapter[types.ClientResult | types.ErrorData] = TypeAdapter(types.ClientResult | types.ErrorData) -_CallToolResultAdapter: TypeAdapter[types.CallToolResult | types.InputRequiredResult] = TypeAdapter( +# Typed against the wide parse union so adopt-built claim adapters share this attribute type. +_CallToolResultAdapter: TypeAdapter[types.CallToolResult | types.InputRequiredResult | types.Result] = TypeAdapter( types.CallToolResult | types.InputRequiredResult ) _GetPromptResultAdapter: TypeAdapter[types.GetPromptResult | types.InputRequiredResult] = TypeAdapter( @@ -200,6 +206,89 @@ async def _default_logging_callback( ) +def _claim_active(claim: ResultClaim[Any], version: str) -> bool: + """A claim is active at modern versions only, narrowed by its optional version subset.""" + return version in MODERN_PROTOCOL_VERSIONS and ( + claim.protocol_versions is None or version in claim.protocol_versions + ) + + +def _active_claims_at( + claims_by_extension: Mapping[str, tuple[ResultClaim[Any], ...]], version: str +) -> dict[str, ResultClaim[Any]]: + """Claims active at `version`, keyed by wire tag; empty at any legacy version.""" + return { + claim.result_type: claim + for claims in claims_by_extension.values() + for claim in claims + if _claim_active(claim, version) + } + + +def _build_call_tool_adapter( + active: Mapping[str, ResultClaim[Any]], +) -> TypeAdapter[types.CallToolResult | types.InputRequiredResult | types.Result]: + """Build a discriminated tools/call adapter: a core arm plus one arm per active claim.""" + if not active: + return _CallToolResultAdapter + tags = frozenset(active) + core_arm = "core" + while core_arm in tags: # the routing sentinel must never collide with a claimed tag + core_arm += "-" + + def _route(value: Any) -> str: + # pydantic hands the discriminator either the raw dict or an already-built model. + # Unknown or non-string tags route to the core arm and fail core validation there. + if isinstance(value, dict): + tag = cast("dict[str, Any]", value).get("resultType") + else: + tag = getattr(value, "result_type", None) + return tag if isinstance(tag, str) and tag in tags else core_arm + + arms: list[Any] = [Annotated[types.CallToolResult | types.InputRequiredResult, Tag(core_arm)]] + arms += [Annotated[claim.model, Tag(tag)] for tag, claim in active.items()] + # reduce(or_) rather than Union star-unpack, which needs py3.11+. + return TypeAdapter(Annotated[reduce(or_, arms), Discriminator(_route)]) + + +def _index_claims( + result_claims: Mapping[str, Sequence[ResultClaim[Any]]] | None, + extensions: dict[str, dict[str, Any]] | None, +) -> dict[str, tuple[ResultClaim[Any], ...]]: + """Validate and copy the claims-by-extension mapping.""" + indexed: dict[str, tuple[ResultClaim[Any], ...]] = {} + seen: set[str] = set() + for identifier, claims in (result_claims or {}).items(): + if extensions is None or identifier not in extensions: + raise ValueError( + f"result_claims key {identifier!r} has no extensions entry; a claim is only " + "advertised through its extension's capability ad" + ) + if not claims: + raise ValueError( + f"result_claims[{identifier!r}] is empty and would drop the extension from " + "the capability ad at every version. Omit the key instead" + ) + for claim in claims: + if claim.result_type in seen: + raise ValueError(f"duplicate result claim for resultType {claim.result_type!r}") + seen.add(claim.result_type) + indexed[identifier] = tuple(claims) + return indexed + + +def _index_bindings( + notification_bindings: Sequence[NotificationBinding[Any]] | None, +) -> dict[str, NotificationBinding[Any]]: + """Index bindings by wire method, rejecting duplicates.""" + indexed: dict[str, NotificationBinding[Any]] = {} + for binding in notification_bindings or (): + if binding.method in indexed: + raise ValueError(f"duplicate notification binding for method {binding.method!r}") + indexed[binding.method] = binding + return indexed + + def _input_required_unexpected(method: str) -> RuntimeError: return RuntimeError( "Server returned InputRequiredResult; pass allow_input_required=True to receive it " @@ -216,6 +305,9 @@ class ClientSession: correlation; this class owns the typed MCP layer and the constructor callbacks. Transport `Exception` items reach `message_handler` only when the session builds its own dispatcher from a stream pair. + + Extension `result_claims` fold into tools/call parsing at `adopt()`; + `notification_bindings` observe vendor notifications via bounded FIFOs. """ def __init__( @@ -232,13 +324,22 @@ def __init__( *, sampling_capabilities: types.SamplingCapability | None = None, extensions: dict[str, dict[str, Any]] | None = None, + result_claims: Mapping[str, Sequence[ResultClaim[Any]]] | None = None, + notification_bindings: Sequence[NotificationBinding[Any]] | 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._sampling_callback = sampling_callback or _default_sampling_callback self._sampling_capabilities = sampling_capabilities - self._extensions = extensions + self._extensions = dict(extensions) if extensions is not None else None + self._result_claims = _index_claims(result_claims, extensions) + self._notification_bindings = _index_bindings(notification_bindings) + self._active_claims: dict[str, ResultClaim[Any]] = {} + self._call_tool_adapter = _CallToolResultAdapter + self._binding_queues: dict[ + str, tuple[MemoryObjectSendStream[BaseModel], MemoryObjectReceiveStream[BaseModel]] + ] = {} self._elicitation_callback = elicitation_callback or _default_elicitation_callback self._list_roots_callback = list_roots_callback or _default_list_roots_callback self._logging_callback = logging_callback or _default_logging_callback @@ -274,7 +375,14 @@ async def __aenter__(self) -> Self: self._task_group = anyio.create_task_group() await self._task_group.__aenter__() try: + # Queues must exist before the dispatcher starts: _on_notify enqueues into this dict. + for binding in self._notification_bindings.values(): + send, receive = anyio.create_memory_object_stream[BaseModel](_NOTIFICATION_QUEUE_SIZE) + self._binding_queues[binding.method] = (send, receive) await self._task_group.start(self._dispatcher.run, self._on_request, self._on_notify) + for binding in self._notification_bindings.values(): + _, receive = self._binding_queues[binding.method] + self._task_group.start_soon(self._deliver_bound_notifications, binding, receive) except BaseException: # Unwind the entered task group before propagating: a cancellation # landing here (e.g. `move_on_after` around connect) would abandon @@ -285,7 +393,10 @@ async def __aenter__(self) -> Self: # Shield the group's own scope (a new one would break LIFO exit) # so a pending outer cancellation cannot re-fire inside __aexit__. task_group.cancel_scope.shield = True - await task_group.__aexit__(None, None, None) + try: + await task_group.__aexit__(None, None, None) + finally: + self._close_binding_queues() raise return self @@ -295,16 +406,38 @@ async def __aexit__( exc_val: BaseException | None, exc_tb: TracebackType | None, ) -> bool | None: - # Exit must not block: cancel the dispatcher and in-flight callbacks. + # Exit must not block: cancel the dispatcher, binding consumers, and in-flight callbacks. assert self._task_group is not None self._task_group.cancel_scope.cancel() - result = await self._task_group.__aexit__(exc_type, exc_val, exc_tb) + try: + result = await self._task_group.__aexit__(exc_type, exc_val, exc_tb) + finally: + self._close_binding_queues() await resync_tracer() return result + def _close_binding_queues(self) -> None: + # Unclosed memory object streams warn at garbage collection; close is idempotent. + for send, receive in self._binding_queues.values(): + send.close() + receive.close() + self._binding_queues.clear() + + async def _deliver_bound_notifications( + self, binding: NotificationBinding[Any], receive: MemoryObjectReceiveStream[BaseModel] + ) -> None: + """Consume one binding's FIFO, decoupled from the dispatcher so handlers can do session I/O.""" + while True: + params = await receive.receive() + try: + await binding.handler(params) + except Exception: + # A raising handler costs only that delivery, as in _on_notify. + logger.exception("notification binding handler for %r raised", binding.method) + async def send_request( self, - request: types.ClientRequest, + request: types.ClientRequest | types.Request[Any, Any], result_type: type[ReceiveResultT] | TypeAdapter[ReceiveResultT], request_read_timeout_seconds: float | None = None, metadata: ClientMessageMetadata | None = None, @@ -318,11 +451,20 @@ async def send_request( Raises: MCPError: Error response, read timeout, or connection closed. RuntimeError: Called before entering the context manager. + ValueError: The request declares `name_param` but its params carry no string name. """ data = request.model_dump(by_alias=True, mode="json", exclude_none=True) method: str = data["method"] opts: CallOptions = {} self._stamp(data, opts) + # The stamp runs first, so its NAME_BEARING_METHODS rows win; a missing name fails loud. + headers = opts.setdefault("headers", {}) + if (key := type(request).name_param) is not None and MCP_NAME_HEADER not in headers: + params_data: dict[str, Any] = data.get("params") or {} + name = params_data.get(key) + if not isinstance(name, str): + raise ValueError(f"{method} requires params[{key!r}] for Mcp-Name") + headers[MCP_NAME_HEADER] = encode_header_value(name) timeout = ( request_read_timeout_seconds if request_read_timeout_seconds is not None @@ -360,7 +502,21 @@ async def send_notification(self, notification: types.ClientNotification) -> Non self._stamp(data, opts) await self._dispatcher.notify(data["method"], data.get("params"), opts) - def _build_capabilities(self) -> types.ClientCapabilities: + def _build_capabilities(self, version: str) -> types.ClientCapabilities: + """Build the capability ad for a wire speaking `version`. + + Claim-bearing identifiers whose claims are all inactive at `version` drop, so + the client never advertises result shapes it would reject; claim-less + identifiers always advertise. + """ + extensions = self._extensions + if extensions is not None and self._result_claims: + extensions = { + identifier: settings + for identifier, settings in extensions.items() + if identifier not in self._result_claims + or any(_claim_active(claim, version) for claim in self._result_claims[identifier]) + } or None sampling = ( (self._sampling_capabilities or types.SamplingCapability()) if self._sampling_callback is not _default_sampling_callback @@ -380,7 +536,7 @@ def _build_capabilities(self) -> types.ClientCapabilities: else None ) return types.ClientCapabilities( - sampling=sampling, elicitation=elicitation, experimental=None, extensions=self._extensions, roots=roots + sampling=sampling, elicitation=elicitation, experimental=None, extensions=extensions, roots=roots ) async def initialize(self) -> types.InitializeResult: @@ -390,7 +546,8 @@ async def initialize(self) -> types.InitializeResult: types.InitializeRequest( params=types.InitializeRequestParams( protocol_version=LATEST_HANDSHAKE_VERSION, - capabilities=self._build_capabilities(), + # The handshake negotiates only legacy versions, where no claim is active. + capabilities=self._build_capabilities(LATEST_HANDSHAKE_VERSION), client_info=self._client_info, ), ), @@ -424,17 +581,30 @@ def adopt(self, result: types.InitializeResult | types.DiscoverResult) -> None: f"No mutually supported modern protocol version " f"(server: {result.supported_versions}, client: {list(MODERN_PROTOCOL_VERSIONS)})" ) + version = mutual[-1] 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._resolve_param_headers) + capabilities = self._build_capabilities(version).model_dump(by_alias=True, mode="json", exclude_none=True) + self._stamp = _make_modern_stamp(version, client_info, capabilities, self._resolve_param_headers) self._discover_result = result self._initialize_result = None - self._negotiated_version = mutual[-1] else: - self._stamp = _make_handshake_stamp(result.protocol_version) + version = result.protocol_version + self._stamp = _make_handshake_stamp(version) self._initialize_result = result self._discover_result = None - self._negotiated_version = result.protocol_version + self._negotiated_version = version + # Both arms reach here, so re-adoption resets cleanly; legacy versions activate no claims. + # Core-vocabulary tags are unconstructible (ResultClaim.__post_init__), so no exclusion needed. + self._active_claims = _active_claims_at(self._result_claims, version) + self._call_tool_adapter = _build_call_tool_adapter(self._active_claims) + for method in self._notification_bindings: + # Bindings are consulted only for methods core does not know, so this one can never fire. + if (method, version) in _methods.SERVER_NOTIFICATIONS: + logger.warning( + "notification binding for %r will never fire at %s: the core protocol defines this method", + method, + version, + ) async def send_discover(self, version: str) -> dict[str, Any]: """Send a single ``server/discover`` at ``version`` and return the raw result dict. @@ -450,7 +620,7 @@ async def send_discover(self, version: str) -> dict[str, Any]: synthesized into a JSON-RPC error by the transport). """ 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) + capabilities = self._build_capabilities(version).model_dump(by_alias=True, mode="json", exclude_none=True) request = types.DiscoverRequest( params=types.RequestParams( _meta={ @@ -704,6 +874,7 @@ async def call_tool( request_state: str | None = None, meta: RequestParamsMeta | None = None, allow_input_required: Literal[False] = False, + allow_claimed: Literal[False] = False, ) -> types.CallToolResult: ... @overload @@ -718,8 +889,39 @@ async def call_tool( request_state: str | None = None, meta: RequestParamsMeta | None = None, allow_input_required: bool, + allow_claimed: Literal[False] = False, ) -> types.CallToolResult | types.InputRequiredResult: ... + @overload + async def call_tool( + self, + name: str, + arguments: dict[str, Any] | None = None, + read_timeout_seconds: float | None = None, + progress_callback: ProgressFnT | None = None, + *, + input_responses: types.InputResponses | None = None, + request_state: str | None = None, + meta: RequestParamsMeta | None = None, + allow_input_required: Literal[False] = False, + allow_claimed: bool, + ) -> types.CallToolResult | types.Result: ... + + @overload + async def call_tool( + self, + name: str, + arguments: dict[str, Any] | None = None, + read_timeout_seconds: float | None = None, + progress_callback: ProgressFnT | None = None, + *, + input_responses: types.InputResponses | None = None, + request_state: str | None = None, + meta: RequestParamsMeta | None = None, + allow_input_required: bool, + allow_claimed: bool, + ) -> types.CallToolResult | types.InputRequiredResult | types.Result: ... + async def call_tool( self, name: str, @@ -731,7 +933,8 @@ async def call_tool( request_state: str | None = None, meta: RequestParamsMeta | None = None, allow_input_required: bool = False, - ) -> types.CallToolResult | types.InputRequiredResult: + allow_claimed: bool = False, + ) -> types.CallToolResult | types.InputRequiredResult | types.Result: """Send a tools/call request with optional progress callback support. On a modern (2026-07-28) connection, arguments annotated with `x-mcp-header` @@ -745,10 +948,13 @@ async def call_tool( allow_input_required: When ``False`` (default), an `InputRequiredResult` from the server raises `RuntimeError`; when ``True``, it is returned so the caller can resolve the requests and retry. + allow_claimed: When `False` (default), a claimed extension result raises + `UnexpectedClaimedResult`; when `True`, the parsed claim model is returned. Raises: RuntimeError: If the server returns an `InputRequiredResult` and ``allow_input_required`` is ``False``. + UnexpectedClaimedResult: Claimed result with `allow_claimed` False; carries the parsed value. """ result = await self.send_request( types.CallToolRequest( @@ -760,16 +966,19 @@ async def call_tool( _meta=meta, ), ), - _CallToolResultAdapter, + self._call_tool_adapter, request_read_timeout_seconds=read_timeout_seconds, progress_callback=progress_callback, ) if isinstance(result, types.CallToolResult) and not result.is_error: - await self._validate_tool_result(name, result) + await self.validate_tool_result(name, result) + # The input_required arm stays first; a claimed shape is terminal for the multi-round-trip driver. if isinstance(result, types.InputRequiredResult) and not allow_input_required: raise _input_required_unexpected("call_tool") + if not isinstance(result, types.CallToolResult | types.InputRequiredResult) and not allow_claimed: + raise UnexpectedClaimedResult(result) return result def _resolve_param_headers(self, name: str, arguments: Mapping[str, Any]) -> dict[str, str]: @@ -779,8 +988,12 @@ def _resolve_param_headers(self, name: str, arguments: Mapping[str, Any]) -> dic return {} return mcp_param_headers(header_map, arguments) - async def _validate_tool_result(self, name: str, result: types.CallToolResult) -> None: - """Validate the structured content of a tool result against its output schema.""" + async def validate_tool_result(self, name: str, result: types.CallToolResult) -> None: + """Revalidate a `CallToolResult` against the tool's declared output schema. + + Raises: + RuntimeError: Structured content is missing or does not conform to the schema. + """ if name not in self._tool_output_schemas: # refresh output schema cache await self.list_tools() @@ -970,7 +1183,7 @@ async def _on_request( ctx = ClientRequestContext( session=self, request_id=dctx.request_id, meta=request.params.meta if request.params else None ) - response = await self._dispatch_input_request(ctx, request) + response = await self.dispatch_input_request(ctx, request) client_response = ClientResponse.validate_python(response) if isinstance(client_response, types.ErrorData): raise MCPError.from_error_data(client_response) @@ -982,16 +1195,18 @@ async def _on_request( raise MCPError(code=INTERNAL_ERROR, message="Client callback returned an invalid result") from None return dumped - async def _dispatch_input_request( - self, ctx: ClientRequestContext, req: types.InputRequest + async def dispatch_input_request( + self, ctx: ClientRequestContext, request: types.InputRequest ) -> types.InputResponse | types.ErrorData: - """Route a server-initiated input request to the matching constructor callback. + """Route an input request through the client's callback table. Shared by the legacy server→client RPC path (`_on_request`) and the 2026-07-28 multi-round-trip driver, which dispatches the embedded `InputRequiredResult.input_requests` through the same callbacks. + + Returns the callback's `InputResponse`, or `ErrorData` when the callback declines. """ - match req: + match request: case types.CreateMessageRequest(params=p): return await self._sampling_callback(ctx, p) case types.ElicitRequest(params=p): @@ -1008,7 +1223,26 @@ async def _on_notify( try: notification = cast(types.ServerNotification, _methods.parse_server_notification(method, version, params)) except KeyError: - logger.debug("dropped %r: not defined at %s", method, version) + # Only methods unknown to the negotiated version's core tables reach the bindings. + binding = self._notification_bindings.get(method) + if binding is None: + logger.debug("dropped %r: not defined at %s", method, version) + return + try: + bound_params = binding.params_type.model_validate(params or {}) + except ValidationError: + logger.warning("Failed to validate notification: %s", method, exc_info=True) + return + send, receive = self._binding_queues[method] + try: + # Must not await: DirectDispatcher calls _on_notify inline; blocking deadlocks in-process servers. + send.send_nowait(bound_params) + except anyio.WouldBlock: + # Evict the oldest event; no checkpoint since the failed send, + # so the buffer is still full and the retry cannot block. + receive.receive_nowait() + logger.warning("notification queue for %r is full; dropped the oldest event", method) + send.send_nowait(bound_params) return except ValidationError: logger.warning("Failed to validate notification: %s", method, exc_info=True) diff --git a/src/mcp/server/extension.py b/src/mcp/server/extension.py index e045e6f29d..e9c62610e9 100644 --- a/src/mcp/server/extension.py +++ b/src/mcp/server/extension.py @@ -19,7 +19,6 @@ from __future__ import annotations -import re from collections.abc import Awaitable, Callable, Sequence from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any @@ -30,31 +29,14 @@ from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext +# Re-exported from `mcp.shared.extension` (shared with the client surface) for existing importers. +from mcp.shared.extension import validate_extension_identifier as validate_extension_identifier + if TYPE_CHECKING: from mcp.server.mcpserver.resources import Resource RequestHandler = Callable[[ServerRequestContext[Any, Any], Any], Awaitable[HandlerResult]] -# Extension identifiers follow the `_meta` key grammar with a mandatory prefix -# (SEP-2133 / basic/index.mdx): dot-separated labels, each starting with a -# letter and ending with a letter or digit (hyphens interior), then `/`, then a -# name that starts and ends alphanumeric (`.`/`_`/`-` interior). -_LABEL = r"[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?" -_NAME = r"[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?" -_IDENTIFIER_RE = re.compile(rf"{_LABEL}(?:\.{_LABEL})*/{_NAME}") - - -def validate_extension_identifier(identifier: Any, *, owner: str) -> None: - """Raise `TypeError` unless `identifier` is a `vendor-prefix/name` string. - - SEP-2133 requires extension identifiers to carry a reverse-DNS prefix. - """ - if not isinstance(identifier, str) or not _IDENTIFIER_RE.fullmatch(identifier): - raise TypeError( - f"{owner}.identifier must be a `vendor-prefix/name` string " - f"(reverse-DNS prefix required), got {identifier!r}" - ) - @dataclass(frozen=True) class ToolBinding: diff --git a/src/mcp/shared/extension.py b/src/mcp/shared/extension.py new file mode 100644 index 0000000000..283e9ba89b --- /dev/null +++ b/src/mcp/shared/extension.py @@ -0,0 +1,28 @@ +"""Extension-identifier grammar shared by the server and client extension surfaces.""" + +from __future__ import annotations + +import re +from typing import Any + +__all__ = ["validate_extension_identifier"] + +# Extension identifiers follow the `_meta` key grammar with a mandatory prefix +# (SEP-2133 / basic/index.mdx): dot-separated labels, each starting with a +# letter and ending with a letter or digit (hyphens interior), then `/`, then a +# name that starts and ends alphanumeric (`.`/`_`/`-` interior). +_LABEL = r"[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?" +_NAME = r"[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?" +_IDENTIFIER_RE = re.compile(rf"{_LABEL}(?:\.{_LABEL})*/{_NAME}") + + +def validate_extension_identifier(identifier: Any, *, owner: str) -> None: + """Raise `TypeError` unless `identifier` is a `vendor-prefix/name` string. + + SEP-2133 requires extension identifiers to carry a reverse-DNS prefix. + """ + if not isinstance(identifier, str) or not _IDENTIFIER_RE.fullmatch(identifier): + raise TypeError( + f"{owner}.identifier must be a `vendor-prefix/name` string " + f"(reverse-DNS prefix required), got {identifier!r}" + ) diff --git a/tests/client/test_client_extensions.py b/tests/client/test_client_extensions.py new file mode 100644 index 0000000000..f80cfe8841 --- /dev/null +++ b/tests/client/test_client_extensions.py @@ -0,0 +1,573 @@ +"""`Client` + `ClientExtension` integration: extension declarations fold into the session at +construction, and `call_tool` drives claim resolvers transparently against real `MCPServer`s. +""" + +import logging +from collections.abc import Awaitable, Callable, Sequence +from typing import Any, Literal, cast + +import anyio +import mcp_types as types +import pytest +from inline_snapshot import snapshot +from mcp_types import CallToolResult, Result, TextContent +from mcp_types.version import LATEST_MODERN_VERSION +from pydantic import BaseModel +from typing_extensions import assert_type + +from mcp.client import ClaimContext, ClientExtension, NotificationBinding, ResultClaim, advertise +from mcp.client.client import Client +from mcp.client.session import ClientRequestContext, _CallToolResultAdapter +from mcp.server import Server, ServerRequestContext +from mcp.server.context import CallNext, HandlerResult +from mcp.server.extension import Extension +from mcp.server.mcpserver import Context, MCPServer + +pytestmark = pytest.mark.anyio + +_VOUCHER_EXT = "com.example/voucher" +_RIVAL_EXT = "com.example/rival" + +_NAME_SCHEMA = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]} + + +def _name_elicitation() -> types.ElicitRequest: + return types.ElicitRequest( + params=types.ElicitRequestFormParams(message="What is your name?", requested_schema=_NAME_SCHEMA) + ) + + +class VoucherResult(Result): + """The claimed `tools/call` shape, tagged `voucher`, carrying a vendor top-level field.""" + + result_type: Literal["voucher"] = "voucher" + voucher_code: str | None = None + + +_Resolver = Callable[[VoucherResult, ClaimContext], Awaitable[CallToolResult]] + + +class _VoucherExtension(ClientExtension): + """Client half: claims the `voucher` tag with the supplied resolver.""" + + identifier = _VOUCHER_EXT + + def __init__(self, resolve: _Resolver) -> None: + self._resolve = resolve + + def claims(self) -> Sequence[ResultClaim[Any]]: + return [ResultClaim(result_type="voucher", model=VoucherResult, resolve=self._resolve)] + + +class _VoucherIssuer(Extension): + """Server half: rewrites every `tools/call` result into the vendor-claimed shape.""" + + identifier = _VOUCHER_EXT + + async def intercept_tool_call( + self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext + ) -> HandlerResult: + return {"resultType": "voucher", "voucherCode": "v-42"} + + +class _TwoRoundVoucherIssuer(Extension): + """Server half: demands input on the first round, then issues the claimed shape.""" + + identifier = _VOUCHER_EXT + + async def intercept_tool_call( + self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext + ) -> HandlerResult: + if params.input_responses is None: + return types.InputRequiredResult(input_requests={"user_name": _name_elicitation()}) + return {"resultType": "voucher", "voucherCode": "after-input"} + + +def _voucher_server(issuer: Extension | None = None) -> MCPServer: + """An `MCPServer` whose `issue` tool the server extension rewrites into the claimed shape.""" + server = MCPServer("vouchers", extensions=[issuer if issuer is not None else _VoucherIssuer()]) + + @server.tool() + def issue() -> CallToolResult: + """Issue a voucher.""" + raise NotImplementedError # the server extension short-circuits before the tool runs + + return server + + +def _structured_voucher_server() -> MCPServer: + """Like `_voucher_server`, but `issue` declares an output schema (`-> str`).""" + server = MCPServer("vouchers", extensions=[_VoucherIssuer()]) + + @server.tool() + def issue() -> str: + """Issue a voucher.""" + raise NotImplementedError # the server extension short-circuits before the tool runs + + return server + + +def _add_server() -> MCPServer: + """A plain claim-less server with one ordinary tool.""" + server = MCPServer("plain") + + @server.tool() + def add(a: int, b: int) -> int: + """Add two integers.""" + return a + b + + return server + + +# Construction-time validation + + +class _CouponResult(Result): + result_type: Literal["coupon"] = "coupon" + + +async def _unreachable_coupon_resolve(claimed: _CouponResult, ctx: ClaimContext) -> CallToolResult: + raise NotImplementedError # the wrong resolver for a voucher; must never run + + +class _CouponExtension(ClientExtension): + identifier = "com.example/coupons" + + def claims(self) -> Sequence[ResultClaim[Any]]: + return [ResultClaim(result_type="coupon", model=_CouponResult, resolve=_unreachable_coupon_resolve)] + + +class _SelfConflictingClaims(ClientExtension): + identifier = "com.example/twice" + + def claims(self) -> Sequence[ResultClaim[Any]]: + return [ + ResultClaim(result_type="twice", model=_TwiceResult, resolve=_unreachable_twice_resolve), + ResultClaim(result_type="twice", model=_TwiceResult, resolve=_unreachable_twice_resolve), + ] + + +class _TwiceResult(Result): + result_type: Literal["twice"] = "twice" + + +async def _unreachable_twice_resolve(claimed: _TwiceResult, ctx: ClaimContext) -> CallToolResult: + raise NotImplementedError + + +def test_mapping_extensions_get_the_migration_error() -> None: + """SDK-defined: the replaced dict form fails with a message naming the new shape.""" + with pytest.raises(TypeError) as exc_info: + Client(_add_server(), extensions=cast("Sequence[ClientExtension]", {"com.example/ui": {}})) + + assert str(exc_info.value) == snapshot( + "extensions= takes a sequence of ClientExtension instances. The mapping form was " + "replaced: use advertise(identifier, settings) for advertise-only entries" + ) + + +def test_one_extension_claiming_a_tag_twice_reads_as_one_owner() -> None: + """SDK-defined: a self-conflict names the one extension once, not as a pair.""" + with pytest.raises(ValueError) as exc_info: + Client(_add_server(), extensions=[_SelfConflictingClaims()]) + + assert str(exc_info.value) == snapshot( + "extension 'com.example/twice' claims resultType 'twice'; a wire tag can have only one resolver" + ) + + +def test_bare_extension_instance_is_rejected_with_the_fix_named() -> None: + """SDK-defined: an instance whose class never set `identifier` fails construction naming the type and the fix.""" + with pytest.raises(ValueError) as exc_info: + Client(_add_server(), extensions=[ClientExtension()]) + + assert str(exc_info.value) == snapshot( + "ClientExtension has no `identifier`; a ClientExtension must set the `identifier` " + "class attribute (or assign one in `__init__`) before it can be used" + ) + + +class _SelfAssignedBadId(ClientExtension): + """Assigns a malformed identifier in `__init__`, invisible at class definition.""" + + def __init__(self) -> None: + self.identifier = "not-prefixed" + + +def test_invalid_per_instance_identifier_raises_the_validators_error() -> None: + """SDK-defined: per-instance identifiers are validated when the Client consumes the extension.""" + with pytest.raises(TypeError) as exc_info: + Client(_add_server(), extensions=[_SelfAssignedBadId()]) + + assert str(exc_info.value) == snapshot( + "_SelfAssignedBadId.identifier must be a `vendor-prefix/name` string " + "(reverse-DNS prefix required), got 'not-prefixed'" + ) + + +def test_duplicate_extension_identifiers_are_rejected_naming_the_identifier() -> None: + """SDK-defined: one identifier cannot appear twice across the extensions sequence.""" + with pytest.raises(ValueError) as exc_info: + Client(_add_server(), extensions=[advertise(_VOUCHER_EXT), advertise(_VOUCHER_EXT, {"a": 1})]) + + assert str(exc_info.value) == snapshot("extension identifier 'com.example/voucher' is passed more than once") + + +async def _unreachable_resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult: + raise NotImplementedError + + +class _RivalVoucherExtension(ClientExtension): + identifier = _RIVAL_EXT + + def claims(self) -> Sequence[ResultClaim[Any]]: + return [ResultClaim(result_type="voucher", model=VoucherResult, resolve=_unreachable_resolve)] + + +def test_conflicting_claims_across_extensions_name_both_owners() -> None: + """SDK-defined: two extensions claiming the same tag fail at construction with both owners named.""" + with pytest.raises(ValueError) as exc_info: + Client(_add_server(), extensions=[_VoucherExtension(_unreachable_resolve), _RivalVoucherExtension()]) + + assert str(exc_info.value) == snapshot( + "extensions 'com.example/voucher' and 'com.example/rival' both claim resultType " + "'voucher'; a wire tag can have only one resolver" + ) + + +class _EventParams(BaseModel): + seq: int + + +async def _unreachable_handler(params: _EventParams) -> None: + raise NotImplementedError + + +class _ObserverA(ClientExtension): + identifier = "com.example/observer-a" + + def notifications(self) -> Sequence[NotificationBinding[Any]]: + return [ + NotificationBinding( + method="notifications/vendor/event", params_type=_EventParams, handler=_unreachable_handler + ) + ] + + +class _ObserverB(ClientExtension): + identifier = "com.example/observer-b" + + def notifications(self) -> Sequence[NotificationBinding[Any]]: + return [ + NotificationBinding( + method="notifications/vendor/event", params_type=_EventParams, handler=_unreachable_handler + ) + ] + + +def test_conflicting_notification_bindings_name_both_owners() -> None: + """SDK-defined: two extensions binding the same notification method fail with both owners named.""" + with pytest.raises(ValueError) as exc_info: + Client(_add_server(), extensions=[_ObserverA(), _ObserverB()]) + + assert str(exc_info.value) == snapshot( + "extensions 'com.example/observer-a' and 'com.example/observer-b' both bind " + "notification method 'notifications/vendor/event'; a method can have only one observer" + ) + + +# settings() consumption + + +class _CountedResult(Result): + result_type: Literal["counted"] = "counted" + + +async def _unreachable_counted_resolve(claimed: _CountedResult, ctx: ClaimContext) -> CallToolResult: + raise NotImplementedError + + +class _CountingSettings(ClientExtension): + identifier = "com.example/counted" + + def __init__(self) -> None: + self.reads = 0 + self.claims_reads = 0 + self.notifications_reads = 0 + + def settings(self) -> dict[str, Any]: + self.reads += 1 + return {"read": self.reads} + + def claims(self) -> Sequence[ResultClaim[Any]]: + self.claims_reads += 1 + return [ResultClaim(result_type="counted", model=_CountedResult, resolve=_unreachable_counted_resolve)] + + def notifications(self) -> Sequence[NotificationBinding[Any]]: + self.notifications_reads += 1 + return [ + NotificationBinding(method="notifications/counted", params_type=_EventParams, handler=_unreachable_handler) + ] + + +async def test_declarations_are_read_exactly_once_at_construction() -> None: + """SDK-defined: each declaration method is read exactly once, at Client construction, never again.""" + extension = _CountingSettings() + client = Client(_add_server(), extensions=[extension]) + assert (extension.reads, extension.claims_reads, extension.notifications_reads) == (1, 1, 1) + + with anyio.fail_after(5): + async with client: + await client.call_tool("add", {"a": 1, "b": 2}) + await client.call_tool("add", {"a": 3, "b": 4}) + + assert (extension.reads, extension.claims_reads, extension.notifications_reads) == (1, 1, 1) + + +async def test_settings_dict_is_held_by_reference_not_copied() -> None: + """SDK-defined: the settings dict is held by reference, so mutating it before connect changes the ad.""" + observed: list[dict[str, dict[str, Any]] | None] = [] + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + assert params.name == "probe" + assert ctx.session.client_params is not None + observed.append(ctx.session.client_params.capabilities.extensions) + return CallToolResult(content=[]) + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})]) + + server = Server("probe", on_call_tool=call_tool, on_list_tools=list_tools) + settings = {"tier": "bronze"} + client = Client(server, extensions=[advertise("com.example/loyalty", settings)]) + settings["tier"] = "gold" + + with anyio.fail_after(5): + async with client: + await client.call_tool("probe", {}) + + assert observed == [{"com.example/loyalty": {"tier": "gold"}}] + + +# extensions=None stays byte-identical + + +@pytest.mark.parametrize("extensions", [None, ()], ids=["none", "empty"]) +async def test_no_extensions_keeps_tools_call_parsing_byte_identical( + extensions: Sequence[ClientExtension] | None, +) -> None: + """SDK-defined: `extensions=None` and an empty sequence leave the session exactly as a claim-less client's.""" + with anyio.fail_after(5): + async with Client(_add_server(), extensions=extensions) as client: + assert client.session._call_tool_adapter is _CallToolResultAdapter + result = await client.call_tool("add", {"a": 1, "b": 2}) + + assert result.structured_content == {"result": 3} + + +# The transparent claim path + + +async def test_claimed_result_resolves_transparently_to_the_resolvers_result() -> None: + """A claimed shape never surfaces: the resolver gets the parsed model and `call_tool` returns its product.""" + received: list[VoucherResult] = [] + produced: list[CallToolResult] = [] + + async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult: + received.append(claimed) + product = CallToolResult(content=[TextContent(text=f"honored {claimed.voucher_code}")]) + produced.append(product) + return product + + with anyio.fail_after(5): + async with Client(_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client: + result = await client.call_tool("issue", {}) + assert_type(result, CallToolResult) + + assert [claimed.voucher_code for claimed in received] == ["v-42"] + assert result is produced[0] + assert result.content == [TextContent(text="honored v-42")] + + +async def test_claimed_shape_routes_to_its_owning_extensions_resolver() -> None: + """With two claim-bearing extensions registered, the parsed shape runs its owner's resolver only.""" + received: list[VoucherResult] = [] + + async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult: + received.append(claimed) + return CallToolResult(content=[TextContent(text="routed")]) + + extensions = [_CouponExtension(), _VoucherExtension(resolve)] + with anyio.fail_after(5): + async with Client(_voucher_server(), extensions=extensions) as client: + result = await client.call_tool("issue", {}) + + assert [claimed.voucher_code for claimed in received] == ["v-42"] + assert result.content == [TextContent(text="routed")] + + +async def test_resolver_product_gets_the_direct_paths_output_schema_revalidation() -> None: + """The resolver's product is revalidated against the tool's output schema exactly like a direct result.""" + + async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult: + return CallToolResult(content=[TextContent(text="unstructured")]) + + async with Client(_structured_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client: + with anyio.fail_after(5), pytest.raises(RuntimeError) as exc_info: + await client.call_tool("issue", {}) + + assert str(exc_info.value) == snapshot("Tool issue has an output schema but did not return structured content") + + +async def test_resolver_error_result_is_returned_not_raised() -> None: + """An `isError` resolver product skips output-schema revalidation and comes back as-is.""" + + async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult: + return CallToolResult(content=[TextContent(text="voucher printer on fire")], is_error=True) + + with anyio.fail_after(5): + async with Client(_structured_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client: + result = await client.call_tool("issue", {}) + + assert result.is_error + assert result.content == [TextContent(text="voucher printer on fire")] + + +async def test_resolver_receives_the_calls_claim_context() -> None: + """`ClaimContext` carries the client's own session object, the tool name, and the per-call read timeout.""" + contexts: list[ClaimContext] = [] + + async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult: + contexts.append(ctx) + return CallToolResult(content=[]) + + with anyio.fail_after(5): + async with Client(_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client: + await client.call_tool("issue", {}, read_timeout_seconds=7.0) + [ctx] = contexts + assert ctx.session is client.session + + assert ctx.tool_name == "issue" + assert ctx.read_timeout_seconds == 7.0 + + +class _VoucherRefused(Exception): + """Extension-owned error vocabulary.""" + + +async def test_resolver_exception_propagates_untouched() -> None: + """A resolver exception reaches the `call_tool` caller as the very object raised, unwrapped.""" + refusal = _VoucherRefused("the voucher is refused") + + async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult: + raise refusal + + async with Client(_voucher_server(), extensions=[_VoucherExtension(resolve)]) as client: + with anyio.fail_after(5), pytest.raises(_VoucherRefused) as exc_info: + await client.call_tool("issue", {}) + + assert exc_info.value is refusal + + +# Unclaimed results with extensions present + + +async def test_unclaimed_result_flows_through_unchanged_with_extensions_present() -> None: + """An ordinary `CallToolResult` is untouched by the claim machinery; the resolver never runs.""" + + async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult: + raise NotImplementedError # this server never produces a claimed shape + + with anyio.fail_after(5): + async with Client(_add_server(), extensions=[_VoucherExtension(resolve)]) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + + assert result.structured_content == {"result": 3} + + +async def test_input_required_then_plain_result_keeps_the_auto_loop_working() -> None: + """With a claim-bearing extension present, the input_required auto loop on an unclaimed tool is unchanged.""" + server = MCPServer("mrtr") + + @server.tool() + async def greet(ctx: Context) -> str | types.InputRequiredResult: + responses = ctx.input_responses + if responses and "user_name" in responses: + answer = responses["user_name"] + assert isinstance(answer, types.ElicitResult) + assert answer.content is not None + return f"Hello, {answer.content['name']}!" + return types.InputRequiredResult(input_requests={"user_name": _name_elicitation()}) + + async def elicitation_callback( + context: ClientRequestContext, params: types.ElicitRequestParams + ) -> types.ElicitResult | types.ErrorData: + return types.ElicitResult(action="accept", content={"name": "Ada"}) + + async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult: + raise NotImplementedError # this server never produces a claimed shape + + with anyio.fail_after(5): + async with Client( + server, elicitation_callback=elicitation_callback, extensions=[_VoucherExtension(resolve)] + ) as client: + result = await client.call_tool("greet") + + assert result.content == [TextContent(text="Hello, Ada!")] + + +# The multi-round-trip + claimed interplay + + +async def test_input_required_then_claimed_result_on_retry_resolves_transparently() -> None: + """A call that demands input first and returns a claimed shape on the retry still resolves transparently.""" + prompted: list[str] = [] + received: list[VoucherResult] = [] + + async def elicitation_callback( + context: ClientRequestContext, params: types.ElicitRequestParams + ) -> types.ElicitResult | types.ErrorData: + assert isinstance(params, types.ElicitRequestFormParams) + prompted.append(params.message) + return types.ElicitResult(action="accept", content={"name": "Ada"}) + + async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult: + received.append(claimed) + return CallToolResult(content=[TextContent(text=f"honored {claimed.voucher_code}")]) + + server = _voucher_server(issuer=_TwoRoundVoucherIssuer()) + with anyio.fail_after(5): + async with Client( + server, elicitation_callback=elicitation_callback, extensions=[_VoucherExtension(resolve)] + ) as client: + result = await client.call_tool("issue", {}) + + assert prompted == ["What is your name?"] + assert [claimed.voucher_code for claimed in received] == ["after-input"] + assert result.content == [TextContent(text="honored after-input")] + + +# Notification bindings fold into the session + + +class _CoreMethodObserver(ClientExtension): + """Binds a method the modern core tables already define.""" + + identifier = "com.example/observer" + + def notifications(self) -> Sequence[NotificationBinding[Any]]: + return [ + NotificationBinding(method="notifications/message", params_type=_EventParams, handler=_unreachable_handler) + ] + + +async def test_notification_bindings_fold_into_the_session(caplog: pytest.LogCaptureFixture) -> None: + """The Client threads extension bindings into its session; a core-known binding draws the one-time warning.""" + with caplog.at_level(logging.WARNING, logger="client"): + async with Client(_add_server(), extensions=[_CoreMethodObserver()]): + pass + + expected = f"notification binding for 'notifications/message' will never fire at {LATEST_MODERN_VERSION}" + assert caplog.text.count(expected) == 1 diff --git a/tests/client/test_extension.py b/tests/client/test_extension.py new file mode 100644 index 0000000000..c30708137e --- /dev/null +++ b/tests/client/test_extension.py @@ -0,0 +1,379 @@ +"""Construction-time tests for `mcp.client.extension`; no session is ever opened.""" + +from dataclasses import FrozenInstanceError +from typing import Any, Literal, cast + +import pytest +from inline_snapshot import snapshot +from mcp_types import CallToolResult, InputRequiredResult, Result +from mcp_types.version import MODERN_PROTOCOL_VERSIONS +from pydantic import AliasChoices, AliasPath, BaseModel, Field +from pydantic.fields import FieldInfo + +from mcp.client.extension import ( + ClaimContext, + ClientExtension, + NotificationBinding, + ResultClaim, + _wire_keys, + advertise, +) + + +class _TaskResult(Result): + result_type: Literal["task"] = "task" + task_id: str = "t-1" + + +class _UntaggedResult(Result): + """No `result_type` field at all.""" + + +class _PlainStringTagResult(Result): + result_type: str = "task" + + +class _OtherTagResult(Result): + result_type: Literal["other"] = "other" + + +class _ClaimedCallToolResult(CallToolResult): + """A core-result subclass; rejected as a claim model regardless of its tag.""" + + +class _ClaimedInputRequiredResult(InputRequiredResult): + """A core-result subclass; rejected as a claim model regardless of its tag.""" + + +async def _resolve(result: Result, ctx: ClaimContext) -> CallToolResult: + raise NotImplementedError + + +def _claim(model: type[Result] = _TaskResult, **kwargs: Any) -> ResultClaim[Result]: + return ResultClaim(result_type="task", model=model, resolve=_resolve, **kwargs) + + +def test_claim_with_literal_discriminated_model_constructs() -> None: + """SDK-defined: a model tagged with the claimed Literal constructs, defaulting to `tools/call` everywhere.""" + claim = ResultClaim(result_type="task", model=_TaskResult, resolve=_resolve) + + assert claim.result_type == "task" + assert claim.model is _TaskResult + assert claim.resolve is _resolve + assert claim.method == "tools/call" + assert claim.protocol_versions is None + + +def test_claim_accepts_modern_protocol_versions() -> None: + """SDK-defined: a non-None `protocol_versions` subset of the modern revisions is accepted.""" + versions = frozenset(MODERN_PROTOCOL_VERSIONS) + + claim = _claim(protocol_versions=versions) + + assert claim.protocol_versions == versions + + +def test_claim_rejects_core_result_type_vocabulary() -> None: + """SDK-defined: a claim cannot re-key the core tags 'complete' and 'input_required'.""" + messages: dict[str, str] = {} + for result_type in ("complete", "input_required"): + with pytest.raises(ValueError) as exc_info: + ResultClaim(result_type=result_type, model=_TaskResult, resolve=_resolve) + messages[result_type] = str(exc_info.value) + + assert messages == snapshot( + { + "complete": "resultType 'complete' is core protocol vocabulary", + "input_required": "resultType 'input_required' is core protocol vocabulary", + } + ) + + +@pytest.mark.parametrize("model", [_ClaimedCallToolResult, _ClaimedInputRequiredResult]) +def test_claim_rejects_model_subclassing_core_result_types(model: type[Result]) -> None: + """SDK-defined: a claim model subclassing a core result type is rejected; it would bypass claim routing.""" + with pytest.raises(ValueError) as exc_info: + _claim(model=model) + + assert str(exc_info.value) == snapshot("claim models must not subclass core result types") + + +def test_claim_rejects_model_without_result_type_field() -> None: + """SDK-defined: the claim model must declare the discriminating `result_type` field.""" + with pytest.raises(ValueError) as exc_info: + _claim(model=_UntaggedResult) + + assert str(exc_info.value) == snapshot("_UntaggedResult.result_type must be Literal['task']") + + +def test_claim_rejects_plain_str_result_type_field() -> None: + """SDK-defined: the model's `result_type` must be a Literal of the claimed tag, not a plain `str`.""" + with pytest.raises(ValueError) as exc_info: + _claim(model=_PlainStringTagResult) + + assert str(exc_info.value) == snapshot("_PlainStringTagResult.result_type must be Literal['task']") + + +def test_claim_rejects_mismatched_result_type_literal() -> None: + """SDK-defined: the model's Literal tag must equal the claim's `result_type`.""" + with pytest.raises(ValueError) as exc_info: + _claim(model=_OtherTagResult) + + assert str(exc_info.value) == snapshot("_OtherTagResult.result_type must be Literal['task']") + + +class _NotAResult(BaseModel): + result_type: Literal["plain"] = "plain" + + +class _ReservedAliasResult(Result): + result_type: Literal["clash"] = "clash" + request_state: dict[str, Any] = {} + + +def test_claim_rejects_model_not_subclassing_result() -> None: + """SDK-defined: a plain BaseModel cannot be a claim model; the session returns `Result` values.""" + with pytest.raises(ValueError) as exc_info: + ResultClaim(result_type="plain", model=cast("type[Result]", _NotAResult), resolve=_resolve) + + assert str(exc_info.value) == snapshot("_NotAResult must subclass mcp_types.Result") + + +def test_claim_rejects_model_aliasing_core_surface_fields() -> None: + """SDK-defined: a field aliasing requestState or inputRequests would fail core pre-validation.""" + with pytest.raises(ValueError) as exc_info: + ResultClaim(result_type="clash", model=_ReservedAliasResult, resolve=_resolve) + + assert str(exc_info.value) == snapshot( + "_ReservedAliasResult.request_state aliases 'requestState', a typed field of the core " + "result surface; a colliding value would fail core validation before the claim adapter runs" + ) + + +class _ValidationAliasResult(Result): + result_type: Literal["va"] = "va" + vendor_state: dict[str, Any] | None = Field(default=None, validation_alias="requestState") + + +class _SerializationAliasResult(Result): + result_type: Literal["sa"] = "sa" + vendor_state: dict[str, Any] | None = Field(default=None, serialization_alias="inputRequests") + + +class _AliasChoicesResult(Result): + result_type: Literal["ac"] = "ac" + vendor_state: dict[str, Any] | None = Field( + default=None, validation_alias=AliasChoices("vendorKey", "requestState") + ) + + +class _AliasPathResult(Result): + result_type: Literal["ap"] = "ap" + vendor_state: dict[str, Any] | None = Field( + default=None, validation_alias=AliasChoices(AliasPath("requestState", "nested")) + ) + + +def test_wire_keys_for_a_bare_field_is_just_its_name() -> None: + """SDK-defined: a field with no aliases reads and writes only its own name.""" + assert _wire_keys("plain", FieldInfo(annotation=str)) == frozenset({"plain"}) + + +def test_claim_rejects_reserved_aliases_in_every_alias_form() -> None: + """SDK-defined: validation_alias, serialization_alias, and AliasChoices routes to a reserved key are all caught.""" + messages: dict[str, str] = {} + for model in (_ValidationAliasResult, _SerializationAliasResult, _AliasChoicesResult, _AliasPathResult): + with pytest.raises(ValueError) as exc_info: + ResultClaim(result_type=model.model_fields["result_type"].default, model=model, resolve=_resolve) + messages[model.__name__] = str(exc_info.value) + + assert messages == snapshot( + { + "_ValidationAliasResult": "_ValidationAliasResult.vendor_state aliases " + "'requestState', a typed field of the core result surface; a colliding value would fail " + "core validation before the claim adapter runs", + "_SerializationAliasResult": "_SerializationAliasResult.vendor_state aliases " + "'inputRequests', a typed field of the core result surface; a colliding value would fail " + "core validation before the claim adapter runs", + "_AliasChoicesResult": "_AliasChoicesResult.vendor_state aliases 'requestState', a typed field of the core " + "result surface; a colliding value would fail core validation before the claim adapter runs", + "_AliasPathResult": "_AliasPathResult.vendor_state aliases " + "'requestState', a typed field of the core result surface; a colliding value would fail " + "core validation before the claim adapter runs", + } + ) + + +def test_claim_rejects_method_outside_the_closed_verb_set() -> None: + """SDK-defined: claims attach to `tools/call` only, even for values that dodge the static Literal gate.""" + with pytest.raises(ValueError) as exc_info: + _claim(method=cast("Literal['tools/call']", "prompts/get")) + + assert str(exc_info.value) == snapshot("claims attach to ['tools/call'] only; got method 'prompts/get'") + + +def test_claim_rejects_empty_protocol_versions() -> None: + """SDK-defined: an empty version set is rejected; `None` is the spelling for every modern version.""" + with pytest.raises(ValueError) as exc_info: + _claim(protocol_versions=frozenset()) + + assert str(exc_info.value) == snapshot("empty protocol_versions could never activate; use None for all") + + +def test_claim_rejects_non_modern_protocol_versions() -> None: + """SDK-defined: a non-None version set must be a subset of the modern protocol revisions.""" + messages: list[str] = [] + for versions in ( + frozenset({"2025-11-25"}), + frozenset({"2026-07-28", "2025-11-25"}), + frozenset({"never-a-version"}), + ): + with pytest.raises(ValueError) as exc_info: + _claim(protocol_versions=versions) + messages.append(str(exc_info.value)) + + assert messages == snapshot( + [ + "protocol_versions ['2025-11-25'] are not modern protocol revisions; claimed shapes " + "cannot be delivered on a legacy wire (None means every modern version)", + "protocol_versions ['2025-11-25'] are not modern protocol revisions; claimed shapes " + "cannot be delivered on a legacy wire (None means every modern version)", + "protocol_versions ['never-a-version'] are not modern protocol revisions; claimed shapes " + "cannot be delivered on a legacy wire (None means every modern version)", + ] + ) + + +def test_result_claim_is_frozen() -> None: + """SDK-defined: claims are immutable; mutating one after construction raises.""" + claim = _claim() + + with pytest.raises(FrozenInstanceError): + setattr(claim, "result_type", "other") # direct assignment is also a type error + + +class _TaskNotificationParams(BaseModel): + task_id: str + + +async def _on_task(params: _TaskNotificationParams) -> None: + raise NotImplementedError + + +def test_notification_binding_constructs() -> None: + """SDK-defined: a binding is a bare declaration with no construction-time validation.""" + binding = NotificationBinding(method="notifications/tasks", params_type=_TaskNotificationParams, handler=_on_task) + + assert binding.method == "notifications/tasks" + assert binding.params_type is _TaskNotificationParams + assert binding.handler is _on_task + + +def test_notification_binding_accepts_core_known_method() -> None: + """SDK-defined: deliberately no spec-table check at construction, so packages survive core adopting a method.""" + binding = NotificationBinding( + method="notifications/progress", params_type=_TaskNotificationParams, handler=_on_task + ) + + assert binding.method == "notifications/progress" + + +def test_notification_binding_is_frozen() -> None: + """SDK-defined: bindings are immutable; mutating one after construction raises.""" + binding = NotificationBinding(method="notifications/tasks", params_type=_TaskNotificationParams, handler=_on_task) + + with pytest.raises(FrozenInstanceError): + setattr(binding, "method", "notifications/other") # direct assignment is also a type error + + +def test_extension_defaults_advertise_nothing() -> None: + """SDK-defined: a minimal subclass advertises empty settings, no claims, and no bindings.""" + + class _MinimalExt(ClientExtension): + identifier = "com.example/minimal" + + ext = _MinimalExt() + + assert ext.settings() == {} + assert ext.claims() == () + assert ext.notifications() == () + + +@pytest.mark.parametrize( + "identifier", + [ + "io.modelcontextprotocol/ui", + "com.example/my_ext", + "com.x-y.z2/n.a-b_c", + "example/x", + "a/b", + "com.example/9start", + ], +) +def test_grammar_conformant_identifiers_accepted_at_class_definition(identifier: str) -> None: + """Spec `_meta` key grammar: conformant `vendor-prefix/name` identifiers are accepted.""" + cls = type("_GoodExt", (ClientExtension,), {"identifier": identifier}) + + assert cls.identifier == identifier + + +@pytest.mark.parametrize( + "identifier", + [ + "noprefix", + "-foo/bar", + ".leading/x", + "a..b/x", + "foo-/x", + "9foo/x", + "foo/-bar", + "foo/bar-", + "foo/", + "/bar", + "foo/ba r", + "io.modelcontextprotocol/ui\n", + "", + 42, + ], +) +def test_malformed_identifier_rejected_at_class_definition(identifier: Any) -> None: + """SDK-defined: the SEP-2133 `vendor-prefix/name` grammar is enforced the moment the subclass is defined.""" + with pytest.raises(TypeError): + type("_BadExt", (ClientExtension,), {"identifier": identifier}) + + +def test_subclass_without_identifier_allowed_at_definition() -> None: + """SDK-defined: a subclass with no class-level `identifier` is allowed; validation waits for consumption.""" + + class _AbstractishExt(ClientExtension): + """Intermediate base; concrete subclasses supply the identifier.""" + + class _ConcreteExt(_AbstractishExt): + identifier = "com.example/concrete" + + assert _ConcreteExt.identifier == "com.example/concrete" + + +def test_advertise_serves_captured_settings() -> None: + """SDK-defined: `advertise()` returns an ad-only extension serving the captured settings.""" + ext = advertise("com.example/flags", {"enabled": True}) + + assert isinstance(ext, ClientExtension) + assert ext.identifier == "com.example/flags" + assert ext.settings() == {"enabled": True} + assert ext.claims() == () + assert ext.notifications() == () + + +def test_advertise_defaults_to_empty_settings() -> None: + """SDK-defined: omitting settings advertises the extension with an empty map.""" + ext = advertise("com.example/flags") + + assert ext.settings() == {} + + +@pytest.mark.parametrize("identifier", ["noprefix", "foo/", ""]) +def test_advertise_validates_identifier_eagerly(identifier: str) -> None: + """SDK-defined: `advertise()` validates the identifier eagerly, at the call site.""" + with pytest.raises(TypeError): + advertise(identifier) diff --git a/tests/client/test_send_request_mcp_name.py b/tests/client/test_send_request_mcp_name.py new file mode 100644 index 0000000000..4088108148 --- /dev/null +++ b/tests/client/test_send_request_mcp_name.py @@ -0,0 +1,251 @@ +"""`ClientSession.send_request` mirrors `Request.name_param` into the `Mcp-Name` +header on send paths the core `NAME_BEARING_METHODS` table does not cover. The +vendor sends also pin the widened `send_request` typing (no cast needed).""" + +from collections.abc import Mapping +from typing import Any, Literal + +import anyio +import anyio.abc +import mcp_types as types +import pytest +from inline_snapshot import snapshot +from mcp_types import ( + CallToolResult, + Implementation, + ListToolsResult, + Request, + ServerCapabilities, + TextContent, + Tool, +) +from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION + +from mcp.client.session import ClientSession +from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest +from mcp.shared.inbound import MCP_NAME_HEADER, MCP_PROTOCOL_VERSION_HEADER, encode_header_value + + +class _RecordingDispatcher: + """Records `send_raw_request` opts and answers with canned per-method results.""" + + def __init__(self) -> None: + self.calls: list[tuple[str, CallOptions]] = [] + + 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, opts or {})) + if method == "tools/call": + return CallToolResult(content=[TextContent(type="text", text="ok")]).model_dump( + by_alias=True, mode="json", exclude_none=True + ) + if method == "tools/list": + return ListToolsResult(tools=[Tool(name="my-tool", input_schema={"type": "object"})]).model_dump( + by_alias=True, mode="json", exclude_none=True + ) + return {} + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: + raise NotImplementedError + + +class _GetWidgetParams(types.RequestParams): + widget_id: str + + +class _GetWidgetRequest(Request[_GetWidgetParams, Literal["vendor/widgets/get"]]): + method: Literal["vendor/widgets/get"] = "vendor/widgets/get" + name_param = "widgetId" + + +class _RawWidgetRequest(Request[dict[str, Any], Literal["vendor/widgets/get"]]): + """Same wire shape with untyped params, so tests can omit or mistype the name value.""" + + method: Literal["vendor/widgets/get"] = "vendor/widgets/get" + name_param = "widgetId" + + +class _ShadowCallToolRequest(Request[dict[str, Any], Literal["tools/call"]]): + """A vendor type declaring `name_param` for a method the core table already covers.""" + + method: Literal["tools/call"] = "tools/call" + name_param = "customKey" + + +class _PlainVendorRequest(Request[dict[str, Any], Literal["vendor/widgets/list"]]): + method: Literal["vendor/widgets/list"] = "vendor/widgets/list" + + +class _OptionalParamsWidgetRequest(Request[dict[str, Any] | None, Literal["vendor/widgets/get"]]): + """Optional params, so a send can carry no params key at all.""" + + method: Literal["vendor/widgets/get"] = "vendor/widgets/get" + params: dict[str, Any] | None = None + name_param = "widgetId" + + +def _adopt_modern(session: ClientSession) -> None: + session.adopt( + types.DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + server_info=Implementation(name="stub", version="0"), + ) + ) + + +def _adopt_handshake(session: ClientSession) -> None: + session.adopt( + types.InitializeResult( + protocol_version=LATEST_HANDSHAKE_VERSION, + capabilities=ServerCapabilities(), + server_info=Implementation(name="stub", version="0"), + ) + ) + + +def _headers(opts: CallOptions) -> dict[str, str]: + return opts.get("headers") or {} + + +@pytest.mark.anyio +async def test_vendor_name_param_emits_mcp_name_on_the_modern_path() -> None: + """A vendor `name_param` emits `Mcp-Name` on a modern wire even outside `NAME_BEARING_METHODS`.""" + dispatcher = _RecordingDispatcher() + with anyio.fail_after(5): + async with ClientSession(dispatcher=dispatcher) as session: + _adopt_modern(session) + await session.send_request(_GetWidgetRequest(params=_GetWidgetParams(widget_id="w-1")), types.EmptyResult) + [(_, opts)] = dispatcher.calls + assert _headers(opts)[MCP_NAME_HEADER] == "w-1" + + +@pytest.mark.anyio +async def test_vendor_name_param_emits_mcp_name_on_the_handshake_path() -> None: + """The handshake stamp sets no `Mcp-Name`, so on a legacy wire the delta is the emitter.""" + dispatcher = _RecordingDispatcher() + with anyio.fail_after(5): + async with ClientSession(dispatcher=dispatcher) as session: + _adopt_handshake(session) + await session.send_request(_GetWidgetRequest(params=_GetWidgetParams(widget_id="w-1")), types.EmptyResult) + [(_, opts)] = dispatcher.calls + assert _headers(opts)[MCP_NAME_HEADER] == "w-1" + # The stamp's own headers survive the delta. + assert _headers(opts)[MCP_PROTOCOL_VERSION_HEADER] == LATEST_HANDSHAKE_VERSION + + +@pytest.mark.anyio +async def test_name_value_passes_through_encode_header_value() -> None: + """A non-ASCII name is base64-sentinel encoded, a spec MUST for `Mcp-Name`.""" + name = "wídget ✨" + dispatcher = _RecordingDispatcher() + with anyio.fail_after(5): + async with ClientSession(dispatcher=dispatcher) as session: + _adopt_handshake(session) + await session.send_request(_GetWidgetRequest(params=_GetWidgetParams(widget_id=name)), types.EmptyResult) + [(_, opts)] = dispatcher.calls + assert _headers(opts)[MCP_NAME_HEADER] == encode_header_value(name) + assert _headers(opts)[MCP_NAME_HEADER].startswith("=?base64?") + + +@pytest.mark.anyio +async def test_core_tools_call_header_comes_from_the_stamp_alone() -> None: + """Core `tools/call` is unchanged: the modern stamp emits the header; legacy stays headerless.""" + dispatcher = _RecordingDispatcher() + with anyio.fail_after(5): + async with ClientSession(dispatcher=dispatcher) as session: + _adopt_modern(session) + await session.call_tool("my-tool", {}) + _adopt_handshake(session) + await session.call_tool("my-tool", {}) + (_, modern_opts), (_, legacy_opts) = (call for call in dispatcher.calls if call[0] == "tools/call") + assert _headers(modern_opts)[MCP_NAME_HEADER] == "my-tool" + assert MCP_NAME_HEADER not in _headers(legacy_opts) + + +@pytest.mark.anyio +async def test_stamp_table_rows_win_over_name_param_by_ordering() -> None: + """A stamp-emitted `Mcp-Name` wins; `name_param` never overwrites an existing header.""" + dispatcher = _RecordingDispatcher() + request = _ShadowCallToolRequest(params={"name": "real-tool", "customKey": "other-value"}) + with anyio.fail_after(5): + async with ClientSession(dispatcher=dispatcher) as session: + _adopt_modern(session) + await session.send_request(request, types.CallToolResult) + [(_, opts)] = dispatcher.calls + assert _headers(opts)[MCP_NAME_HEADER] == "real-tool" + + +@pytest.mark.anyio +async def test_vendor_name_param_emits_mcp_name_on_the_preconnect_path() -> None: + """Emission is era-unconditional: a session that never adopts still emits `Mcp-Name`.""" + dispatcher = _RecordingDispatcher() + with anyio.fail_after(5): + async with ClientSession(dispatcher=dispatcher) as session: + await session.send_request(_GetWidgetRequest(params=_GetWidgetParams(widget_id="w-1")), types.EmptyResult) + [(_, opts)] = dispatcher.calls + assert _headers(opts) == {MCP_NAME_HEADER: "w-1"} # and no era headers: nothing adopted + + +@pytest.mark.anyio +async def test_missing_name_value_fails_loud_naming_method_and_key() -> None: + """A missing name value raises ValueError naming the method and key, before the wire.""" + dispatcher = _RecordingDispatcher() + with anyio.fail_after(5): + async with ClientSession(dispatcher=dispatcher) as session: + _adopt_handshake(session) + with pytest.raises(ValueError) as exc_info: + await session.send_request(_RawWidgetRequest(params={}), types.EmptyResult) + assert dispatcher.calls == [] # raised before reaching the wire + assert str(exc_info.value) == snapshot("vendor/widgets/get requires params['widgetId'] for Mcp-Name") + + +@pytest.mark.anyio +async def test_non_string_name_value_fails_loud() -> None: + """A non-string name value raises the same ValueError as a missing one.""" + dispatcher = _RecordingDispatcher() + with anyio.fail_after(5): + async with ClientSession(dispatcher=dispatcher) as session: + _adopt_handshake(session) + with pytest.raises(ValueError) as exc_info: + await session.send_request(_RawWidgetRequest(params={"widgetId": 7}), types.EmptyResult) + assert dispatcher.calls == [] + assert str(exc_info.value) == snapshot("vendor/widgets/get requires params['widgetId'] for Mcp-Name") + + +@pytest.mark.anyio +async def test_absent_params_fails_loud_not_attribute_error() -> None: + """Absent params still raise the documented ValueError, not an AttributeError.""" + dispatcher = _RecordingDispatcher() + with anyio.fail_after(5): + async with ClientSession(dispatcher=dispatcher) as session: + _adopt_handshake(session) + with pytest.raises(ValueError) as exc_info: + await session.send_request(_OptionalParamsWidgetRequest(), types.EmptyResult) + assert dispatcher.calls == [] + assert str(exc_info.value) == snapshot("vendor/widgets/get requires params['widgetId'] for Mcp-Name") + + +@pytest.mark.anyio +async def test_request_without_name_param_sends_no_mcp_name() -> None: + """No `name_param` and a method outside the core table emits no `Mcp-Name` on either era.""" + dispatcher = _RecordingDispatcher() + with anyio.fail_after(5): + async with ClientSession(dispatcher=dispatcher) as session: + _adopt_modern(session) + await session.send_request(_PlainVendorRequest(params={}), types.EmptyResult) + _adopt_handshake(session) + await session.send_ping() + for _, opts in dispatcher.calls: + assert MCP_NAME_HEADER not in _headers(opts) diff --git a/tests/client/test_session_claims.py b/tests/client/test_session_claims.py new file mode 100644 index 0000000000..21cf2fa691 --- /dev/null +++ b/tests/client/test_session_claims.py @@ -0,0 +1,468 @@ +"""`ClientSession` result claims: construction validation, activation at modern +adopts only, claimed-result routing, the version-aware capability ad, and the +`allow_claimed` escape hatch.""" + +from collections.abc import Mapping +from typing import Any, Literal + +import anyio +import anyio.abc +import mcp_types as types +import pytest +from inline_snapshot import snapshot +from mcp_types import ( + CLIENT_CAPABILITIES_META_KEY, + CallToolResult, + Implementation, + InputRequiredResult, + ListToolsResult, + Result, + ServerCapabilities, + TextContent, + Tool, +) +from mcp_types.methods import validate_server_result +from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION +from pydantic import ValidationError +from typing_extensions import assert_type + +from mcp.client.extension import ClaimContext, ResultClaim, UnexpectedClaimedResult +from mcp.client.session import ClientSession, _CallToolResultAdapter +from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest + +_TASKS_EXT = "com.example/tasks" +_AD_ONLY_EXT = "com.example/flags" + + +class _TaskResult(Result): + """A claimed result shape, tagged `task`.""" + + result_type: Literal["task"] = "task" + task_id: str + + +async def _resolve_task(result: _TaskResult, ctx: ClaimContext) -> CallToolResult: + raise NotImplementedError # session-tier tests never drive a resolver; that is the Client's job + + +def _task_claim(**kwargs: Any) -> ResultClaim[_TaskResult]: + return ResultClaim(result_type="task", model=_TaskResult, resolve=_resolve_task, **kwargs) + + +_COMPLETE_TOOL_RESULT = CallToolResult(content=[TextContent(type="text", text="ok")]).model_dump( + by_alias=True, mode="json", exclude_none=True +) +_CLAIMED_TASK_RESULT = {"resultType": "task", "taskId": "t-1"} +_TOOL_LISTING = ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})]).model_dump( + by_alias=True, mode="json", exclude_none=True +) +_INITIALIZE_RESULT = types.InitializeResult( + protocol_version=LATEST_HANDSHAKE_VERSION, + capabilities=ServerCapabilities(), + server_info=Implementation(name="stub", version="0"), +).model_dump(by_alias=True, mode="json", exclude_none=True) + + +class _RecordingDispatcher: + """Records every send and answers each method with a canned result.""" + + def __init__(self, tool_result: dict[str, Any] | None = None) -> None: + self.calls: list[tuple[str, Mapping[str, Any] | None, CallOptions]] = [] + self.notifications: list[str] = [] + self._tool_result = tool_result if tool_result is not None else _COMPLETE_TOOL_RESULT + + 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, opts or {})) + if method == "tools/call": + return self._tool_result + if method == "tools/list": + return _TOOL_LISTING + if method == "initialize": + return _INITIALIZE_RESULT + return {} + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: + self.notifications.append(method) + + +def _claims_session(dispatcher: _RecordingDispatcher, *claims: ResultClaim[Any]) -> ClientSession: + return ClientSession(dispatcher=dispatcher, extensions={_TASKS_EXT: {}}, result_claims={_TASKS_EXT: list(claims)}) + + +def _adopt_modern(session: ClientSession) -> None: + session.adopt( + types.DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + server_info=Implementation(name="stub", version="0"), + ) + ) + + +def _adopt_handshake(session: ClientSession) -> None: + session.adopt( + types.InitializeResult( + protocol_version=LATEST_HANDSHAKE_VERSION, + capabilities=ServerCapabilities(), + server_info=Implementation(name="stub", version="0"), + ) + ) + + +def test_duplicate_claim_tag_across_extensions_rejected() -> None: + """SDK-defined: two claims on the same resultType cannot be routed apart, so construction fails.""" + with pytest.raises(ValueError) as exc_info: + ClientSession( + dispatcher=_RecordingDispatcher(), + extensions={_TASKS_EXT: {}, _AD_ONLY_EXT: {}}, + result_claims={_TASKS_EXT: [_task_claim()], _AD_ONLY_EXT: [_task_claim()]}, + ) + + assert str(exc_info.value) == snapshot("duplicate result claim for resultType 'task'") + + +def test_claims_keyed_to_unadvertised_extension_rejected() -> None: + """SDK-defined: a `result_claims` key with no `extensions` entry advertises nothing, so construction fails.""" + messages: list[str] = [] + for extensions in (None, {_AD_ONLY_EXT: {"flag": True}}): + with pytest.raises(ValueError) as exc_info: + ClientSession( + dispatcher=_RecordingDispatcher(), + extensions=extensions, + result_claims={_TASKS_EXT: [_task_claim()]}, + ) + messages.append(str(exc_info.value)) + + assert messages == snapshot( + [ + "result_claims key 'com.example/tasks' has no extensions entry; a claim is only " + "advertised through its extension's capability ad", + "result_claims key 'com.example/tasks' has no extensions entry; a claim is only " + "advertised through its extension's capability ad", + ] + ) + + +def test_empty_claim_sequence_rejected() -> None: + """SDK-defined: an empty claim list is rejected at construction; a claim-less extension omits the key.""" + with pytest.raises(ValueError) as exc_info: + ClientSession(dispatcher=_RecordingDispatcher(), extensions={_TASKS_EXT: {}}, result_claims={_TASKS_EXT: []}) + + assert str(exc_info.value) == snapshot( + "result_claims['com.example/tasks'] is empty and would drop the extension from " + "the capability ad at every version. Omit the key instead" + ) + + +def test_empty_settings_count_as_an_advertised_extension() -> None: + """SDK-defined: empty settings ({}) still count as an ad, so claims keyed to the extension construct.""" + session = _claims_session(_RecordingDispatcher(), _task_claim()) + + assert isinstance(session, ClientSession) + + +def test_without_claims_the_call_tool_adapter_is_the_module_constant() -> None: + """SDK-defined: with zero active claims the session holds the module-level adapter by identity.""" + session = ClientSession(dispatcher=_RecordingDispatcher()) + + assert session._call_tool_adapter is _CallToolResultAdapter + _adopt_modern(session) + assert session._call_tool_adapter is _CallToolResultAdapter + _adopt_handshake(session) + assert session._call_tool_adapter is _CallToolResultAdapter + + +@pytest.mark.anyio +@pytest.mark.parametrize("protocol_versions", [None, frozenset({LATEST_MODERN_VERSION})]) +async def test_modern_adopt_activates_claims_and_routes_claimed_results( + protocol_versions: frozenset[str] | None, +) -> None: + """SDK-defined: at a modern adopt, a claim active at the negotiated version routes + the claimed raw to the claim model.""" + dispatcher = _RecordingDispatcher(tool_result=_CLAIMED_TASK_RESULT) + session = _claims_session(dispatcher, _task_claim(protocol_versions=protocol_versions)) + with anyio.fail_after(5): + async with session: + _adopt_modern(session) + result = await session.call_tool("t", {}, allow_claimed=True) + + assert isinstance(result, _TaskResult) + assert result.task_id == "t-1" + + +@pytest.mark.anyio +async def test_legacy_adopt_clears_active_claims() -> None: + """SDK-defined: a legacy adopt clears active claims and restores the module-level adapter.""" + dispatcher = _RecordingDispatcher(tool_result=_CLAIMED_TASK_RESULT) + session = _claims_session(dispatcher, _task_claim()) + with anyio.fail_after(5): + async with session: + _adopt_modern(session) + assert isinstance(await session.call_tool("t", {}, allow_claimed=True), _TaskResult) + + _adopt_handshake(session) + assert session._call_tool_adapter is _CallToolResultAdapter + with pytest.raises(ValidationError): + await session.call_tool("t", {}, allow_claimed=True) + # Rejected at response parsing; the request did reach the wire. + assert dispatcher.calls[-1][0] == "tools/call" + + +@pytest.mark.anyio +async def test_modern_readopt_after_legacy_reactivates_claims() -> None: + """SDK-defined: a modern re-adopt after legacy reactivates the claims.""" + dispatcher = _RecordingDispatcher(tool_result=_CLAIMED_TASK_RESULT) + session = _claims_session(dispatcher, _task_claim()) + with anyio.fail_after(5): + async with session: + _adopt_modern(session) + _adopt_handshake(session) + assert session._call_tool_adapter is _CallToolResultAdapter + + _adopt_modern(session) + result = await session.call_tool("t", {}, allow_claimed=True) + + assert isinstance(result, _TaskResult) + assert session._call_tool_adapter is not _CallToolResultAdapter + + +@pytest.mark.anyio +async def test_legacy_initialize_ad_drops_claim_bearing_identifiers() -> None: + """SDK-defined: the legacy initialize ad drops claim-bearing identifiers; ad-only ones ride along.""" + dispatcher = _RecordingDispatcher() + session = ClientSession( + dispatcher=dispatcher, + extensions={_TASKS_EXT: {}, _AD_ONLY_EXT: {"flag": True}}, + result_claims={_TASKS_EXT: [_task_claim()]}, + ) + with anyio.fail_after(5): + async with session: + await session.initialize() + + [(_, params, _)] = [call for call in dispatcher.calls if call[0] == "initialize"] + assert params is not None + assert params["capabilities"]["extensions"] == {_AD_ONLY_EXT: {"flag": True}} + + +@pytest.mark.anyio +async def test_legacy_ad_omits_extensions_entirely_when_every_identifier_drops() -> None: + """SDK-defined: when every identifier drops, the ad omits the `extensions` key entirely.""" + dispatcher = _RecordingDispatcher() + session = _claims_session(dispatcher, _task_claim()) + with anyio.fail_after(5): + async with session: + await session.initialize() + + [(_, params, _)] = [call for call in dispatcher.calls if call[0] == "initialize"] + assert params is not None + assert "extensions" not in params["capabilities"] + + +@pytest.mark.anyio +async def test_modern_adopt_ad_includes_active_claim_identifiers() -> None: + """SDK-defined: the modern per-request `_meta` ad includes identifiers whose claims are active.""" + dispatcher = _RecordingDispatcher() + session = ClientSession( + dispatcher=dispatcher, + extensions={_TASKS_EXT: {}, _AD_ONLY_EXT: {"flag": True}}, + result_claims={_TASKS_EXT: [_task_claim()]}, + ) + with anyio.fail_after(5): + async with session: + _adopt_modern(session) + await session.send_ping() + + [(_, params, _)] = dispatcher.calls + assert params is not None + capabilities = params["_meta"][CLIENT_CAPABILITIES_META_KEY] + assert capabilities["extensions"] == {_TASKS_EXT: {}, _AD_ONLY_EXT: {"flag": True}} + + +@pytest.mark.anyio +async def test_discover_probe_ad_includes_claim_identifiers_at_the_probe_version() -> None: + """SDK-defined: `send_discover` builds its `_meta` ad at the probe version, where claims are active.""" + dispatcher = _RecordingDispatcher() + session = _claims_session(dispatcher, _task_claim()) + with anyio.fail_after(5): + async with session: + await session.send_discover(LATEST_MODERN_VERSION) + + [(_, params, _)] = dispatcher.calls + assert params is not None + capabilities = params["_meta"][CLIENT_CAPABILITIES_META_KEY] + assert capabilities["extensions"] == {_TASKS_EXT: {}} + + +@pytest.mark.anyio +async def test_discover_probe_ad_drops_claim_identifiers_at_a_legacy_probe_version() -> None: + """SDK-defined: at a legacy probe version no claim can be active, so the identifier drops.""" + dispatcher = _RecordingDispatcher() + session = _claims_session(dispatcher, _task_claim()) + with anyio.fail_after(5): + async with session: + await session.send_discover(LATEST_HANDSHAKE_VERSION) + + [(_, params, _)] = dispatcher.calls + assert params is not None + capabilities = params["_meta"][CLIENT_CAPABILITIES_META_KEY] + assert "extensions" not in capabilities + + +class _CoreTaggedResult(Result): + """A claim whose wire tag collides with the adapter's internal routing sentinel.""" + + result_type: Literal["core"] = "core" + payload: str = "" + + +async def _resolve_core_tagged(result: _CoreTaggedResult, ctx: ClaimContext) -> CallToolResult: + raise NotImplementedError + + +@pytest.mark.anyio +async def test_claim_tagged_core_cannot_hijack_core_parsing() -> None: + """SDK-defined: a claim may use "core" as its wire tag without colliding with core parsing.""" + claim = ResultClaim(result_type="core", model=_CoreTaggedResult, resolve=_resolve_core_tagged) + dispatcher = _RecordingDispatcher(tool_result={"resultType": "core", "payload": "p-1"}) + session = ClientSession(dispatcher=dispatcher, extensions={_TASKS_EXT: {}}, result_claims={_TASKS_EXT: [claim]}) + with anyio.fail_after(5): + async with session: + _adopt_modern(session) + ordinary = session._call_tool_adapter.validate_python(_COMPLETE_TOOL_RESULT) + claimed = await session.call_tool("t", {}, allow_claimed=True) + + assert isinstance(ordinary, CallToolResult) + assert isinstance(claimed, _CoreTaggedResult) + + +@pytest.mark.anyio +@pytest.mark.parametrize("with_claims", [True, False]) +async def test_unknown_result_type_fails_validation_with_and_without_claims(with_claims: bool) -> None: + """SDK-defined: a resultType outside the active claim set fails core validation, claims or not.""" + raw = {"resultType": "weird", "taskId": "t-1"} + dispatcher = _RecordingDispatcher(tool_result=raw) + session = _claims_session(dispatcher, _task_claim()) if with_claims else ClientSession(dispatcher=dispatcher) + with anyio.fail_after(5): + async with session: + _adopt_modern(session) + with pytest.raises(ValidationError): + await session.call_tool("t", {}, allow_claimed=True) + # Rejected at response parsing; the request did reach the wire. + assert dispatcher.calls[-1][0] == "tools/call" + + +@pytest.mark.anyio +async def test_non_string_result_type_fails_core_validation_not_discrimination() -> None: + """SDK-defined: a non-string resultType stays on the core arm and fails as ValidationError, not TypeError.""" + raw: dict[str, Any] = {"resultType": {"nested": True}} + dispatcher = _RecordingDispatcher(tool_result=raw) + session = _claims_session(dispatcher, _task_claim()) + with anyio.fail_after(5): + async with session: + _adopt_modern(session) + with pytest.raises(ValidationError): + await session.call_tool("t", {}, allow_claimed=True) + # Rejected at response parsing; the request did reach the wire. + assert dispatcher.calls[-1][0] == "tools/call" + + +def test_adopt_built_adapter_revalidates_model_instances() -> None: + """SDK-defined: the adopt-built adapter routes already-built model instances as well as raw dicts.""" + session = _claims_session(_RecordingDispatcher(), _task_claim()) + _adopt_modern(session) + adapter = session._call_tool_adapter + + claimed = adapter.validate_python(_TaskResult(task_id="t-2")) + assert isinstance(claimed, _TaskResult) + core = adapter.validate_python(CallToolResult(content=[])) + assert isinstance(core, CallToolResult) + + +@pytest.mark.anyio +async def test_input_required_routes_to_core_arm_with_claims_active() -> None: + """Spec-mandated: `input_required` is core vocabulary; active claims leave that arm untouched.""" + raw = {"resultType": "input_required", "requestState": "s-1"} + session = _claims_session(_RecordingDispatcher(tool_result=raw), _task_claim()) + with anyio.fail_after(5): + async with session: + _adopt_modern(session) + result = await session.call_tool("t", {}, allow_input_required=True, allow_claimed=True) + + assert isinstance(result, InputRequiredResult) + assert result.request_state == "s-1" + + +@pytest.mark.anyio +async def test_claimed_result_raises_unexpected_claimed_result_by_default() -> None: + """SDK-defined: without `allow_claimed` a claimed shape raises, carrying the parsed + result so the caller can clean up any server-side state it references.""" + dispatcher = _RecordingDispatcher(tool_result=_CLAIMED_TASK_RESULT) + session = _claims_session(dispatcher, _task_claim()) + with anyio.fail_after(5): + async with session: + _adopt_modern(session) + with pytest.raises(UnexpectedClaimedResult) as exc_info: + await session.call_tool("t", {}) + # The shape parsed and then raised; the request did reach the wire. + assert dispatcher.calls[-1][0] == "tools/call" + + assert isinstance(exc_info.value.result, _TaskResult) + assert exc_info.value.result.task_id == "t-1" + assert str(exc_info.value) == snapshot( + "Server returned a claimed result (_TaskResult); pass the owning extension to " + "Client(extensions=[...]) for transparent resolution, or call with allow_claimed=True " + "and handle the shape. The carried result may reference server-side state needing cleanup." + ) + + +@pytest.mark.anyio +async def test_call_tool_result_path_identical_under_both_allow_claimed_values() -> None: + """SDK-defined: `allow_claimed` only affects claimed shapes; ordinary results come back identical.""" + dispatcher = _RecordingDispatcher() + session = _claims_session(dispatcher, _task_claim()) + with anyio.fail_after(5): + async with session: + _adopt_modern(session) + r_default = await session.call_tool("t", {}) + r_opted = await session.call_tool("t", {}, allow_claimed=True) + + assert isinstance(r_opted, CallToolResult) + assert r_opted == r_default + + +@pytest.mark.anyio +async def test_call_tool_overload_matrix_narrows_statically() -> None: + """SDK-defined: each flag combination narrows `call_tool` to its documented return union under pyright.""" + dispatcher = _RecordingDispatcher() + session = _claims_session(dispatcher, _task_claim()) + with anyio.fail_after(5): + async with session: + _adopt_modern(session) + r1 = await session.call_tool("t", {}) + assert_type(r1, CallToolResult) + r2 = await session.call_tool("t", {}, allow_input_required=True) + assert_type(r2, CallToolResult | InputRequiredResult) + r3 = await session.call_tool("t", {}, allow_claimed=True) + assert_type(r3, CallToolResult | Result) + r4 = await session.call_tool("t", {}, allow_input_required=True, allow_claimed=True) + assert_type(r4, CallToolResult | InputRequiredResult | Result) + + assert [type(r) for r in (r1, r2, r3, r4)] == [CallToolResult] * 4 + + +def test_claimed_raw_passes_v2026_tools_call_surface_validation() -> None: + """Pins the claim path's dependency: an unknown resultType passes `validate_server_result` + at 2026-07-28; this failing is the signal that mcp-types tightened the surface.""" + validate_server_result("tools/call", LATEST_MODERN_VERSION, {"resultType": "task", "taskId": "t-1"}) diff --git a/tests/client/test_session_notification_bindings.py b/tests/client/test_session_notification_bindings.py new file mode 100644 index 0000000000..2bed2bd64c --- /dev/null +++ b/tests/client/test_session_notification_bindings.py @@ -0,0 +1,287 @@ +"""`ClientSession` notification bindings: serialized per-binding delivery through a +bounded FIFO, consulted only for methods the negotiated version's core tables do +not know.""" + +import logging + +import anyio +import mcp_types as types +import pytest +from mcp_types import EmptyResult, Implementation, ServerCapabilities +from mcp_types.version import LATEST_MODERN_VERSION +from pydantic import BaseModel + +from mcp.client.extension import NotificationBinding +from mcp.client.session import _NOTIFICATION_QUEUE_SIZE, ClientSession +from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair +from mcp.shared.dispatcher import DispatchContext +from mcp.shared.transport_context import TransportContext + +_VENDOR_METHOD = "notifications/vendor/task_done" + + +class _EventParams(BaseModel): + seq: int + + +async def _server_on_request( + ctx: DispatchContext[TransportContext], method: str, params: dict[str, object] | None +) -> dict[str, object]: + assert method == "ping" + return {} + + +async def _server_on_notify( + ctx: DispatchContext[TransportContext], method: str, params: dict[str, object] | None +) -> None: + raise NotImplementedError + + +def _adopt_modern(session: ClientSession) -> None: + session.adopt( + types.DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + server_info=Implementation(name="stub", version="0"), + ) + ) + + +async def _noop_handler(params: _EventParams) -> None: + raise NotImplementedError # construction-only tests never deliver + + +def test_duplicate_binding_method_rejected() -> None: + """SDK-defined: two bindings on one wire method cannot be routed apart, so construction fails.""" + client_side, _ = create_direct_dispatcher_pair() + binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=_noop_handler) + + with pytest.raises(ValueError) as exc_info: + ClientSession(dispatcher=client_side, notification_bindings=[binding, binding]) + + assert str(exc_info.value) == "duplicate notification binding for method 'notifications/vendor/task_done'" + + +@pytest.mark.anyio +async def test_bound_vendor_notifications_are_delivered_in_order() -> None: + """SDK-defined: one consumer per binding delivers events in the order the server sent them.""" + delivered: list[int] = [] + done = anyio.Event() + + async def on_event(params: _EventParams) -> None: + delivered.append(params.seq) + if params.seq == 3: + done.set() + + client_side, server_side = create_direct_dispatcher_pair() + binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event) + session = ClientSession(dispatcher=client_side, notification_bindings=[binding]) + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: + await tg.start(server_side.run, _server_on_request, _server_on_notify) + async with session: + _adopt_modern(session) + for seq in (1, 2, 3): + await server_side.notify(_VENDOR_METHOD, {"seq": seq}) + await done.wait() + server_side.close() + + assert delivered == [1, 2, 3] + + +@pytest.mark.anyio +async def test_binding_handler_may_do_session_io_without_deadlock() -> None: + """SDK-defined: delivery is spawn-decoupled, so a handler may await session I/O without deadlock.""" + pongs: list[EmptyResult] = [] + done = anyio.Event() + + client_side, server_side = create_direct_dispatcher_pair() + + async def on_event(params: _EventParams) -> None: + pongs.append(await session.send_ping()) + done.set() + + binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event) + session = ClientSession(dispatcher=client_side, notification_bindings=[binding]) + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: + await tg.start(server_side.run, _server_on_request, _server_on_notify) + async with session: + _adopt_modern(session) + await server_side.notify(_VENDOR_METHOD, {"seq": 1}) + await done.wait() + server_side.close() + + assert pongs == [EmptyResult()] + + +@pytest.mark.anyio +async def test_overflow_drops_oldest_event_with_a_warning(caplog: pytest.LogCaptureFixture) -> None: + """SDK-defined: on overflow the bounded FIFO drops the oldest queued event with a + warning; everything still queued delivers in order.""" + delivered: list[int] = [] + consumer_blocked = anyio.Event() + gate = anyio.Event() + done = anyio.Event() + last_seq = _NOTIFICATION_QUEUE_SIZE + 1 + + async def on_event(params: _EventParams) -> None: + delivered.append(params.seq) + if params.seq == 0: + consumer_blocked.set() + await gate.wait() + if params.seq == last_seq: + done.set() + + client_side, server_side = create_direct_dispatcher_pair() + binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event) + session = ClientSession(dispatcher=client_side, notification_bindings=[binding]) + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: + await tg.start(server_side.run, _server_on_request, _server_on_notify) + async with session: + _adopt_modern(session) + await server_side.notify(_VENDOR_METHOD, {"seq": 0}) + await consumer_blocked.wait() + for seq in range(1, last_seq + 1): + await server_side.notify(_VENDOR_METHOD, {"seq": seq}) + gate.set() + await done.wait() + server_side.close() + + assert delivered == [0, *range(2, last_seq + 1)] + assert caplog.text.count(f"notification queue for {_VENDOR_METHOD!r} is full") == 1 + + +@pytest.mark.anyio +async def test_invalid_params_are_warned_and_dropped_without_reaching_handler( + caplog: pytest.LogCaptureFixture, +) -> None: + """SDK-defined: params failing the binding's model are warned and dropped; later valid events deliver.""" + delivered: list[int] = [] + done = anyio.Event() + + async def on_event(params: _EventParams) -> None: + delivered.append(params.seq) + done.set() + + client_side, server_side = create_direct_dispatcher_pair() + binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event) + session = ClientSession(dispatcher=client_side, notification_bindings=[binding]) + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: + await tg.start(server_side.run, _server_on_request, _server_on_notify) + async with session: + _adopt_modern(session) + await server_side.notify(_VENDOR_METHOD, {"bogus": "no seq"}) + await server_side.notify(_VENDOR_METHOD, {"seq": 1}) + await done.wait() + server_side.close() + + assert delivered == [1] + assert f"Failed to validate notification: {_VENDOR_METHOD}" in caplog.text + + +@pytest.mark.anyio +async def test_unbound_vendor_notification_keeps_the_debug_drop(caplog: pytest.LogCaptureFixture) -> None: + """SDK-defined: a vendor method with no binding keeps the debug-log-and-drop behaviour.""" + caplog.set_level(logging.DEBUG, logger="client") + + client_side, server_side = create_direct_dispatcher_pair() + binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=_noop_handler) + session = ClientSession(dispatcher=client_side, notification_bindings=[binding]) + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: + await tg.start(server_side.run, _server_on_request, _server_on_notify) + async with session: + _adopt_modern(session) + await server_side.notify("notifications/vendor/unbound", {"seq": 1}) + server_side.close() + + assert f"dropped 'notifications/vendor/unbound': not defined at {LATEST_MODERN_VERSION}" in caplog.text + + +@pytest.mark.anyio +async def test_core_known_method_never_reaches_binding_and_warns_once_at_adopt( + caplog: pytest.LogCaptureFixture, +) -> None: + """SDK-defined: a binding for a core-known method never fires and warns once at + adopt(); the typed callback still runs.""" + logged: list[types.LoggingMessageNotificationParams] = [] + + async def logging_callback(params: types.LoggingMessageNotificationParams) -> None: + logged.append(params) + + async def on_message(params: BaseModel) -> None: + raise NotImplementedError # structurally unreachable: core parses the method first + + client_side, server_side = create_direct_dispatcher_pair() + binding = NotificationBinding(method="notifications/message", params_type=BaseModel, handler=on_message) + session = ClientSession(dispatcher=client_side, logging_callback=logging_callback, notification_bindings=[binding]) + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: + await tg.start(server_side.run, _server_on_request, _server_on_notify) + async with session: + _adopt_modern(session) + # In-process notify() awaits _on_notify inline, so the typed callback has already run. + await server_side.notify("notifications/message", {"level": "info", "data": "hello"}) + server_side.close() + + assert [params.data for params in logged] == ["hello"] + # The bound handler never ran; a delivery would have logged its NotImplementedError. + assert "notification binding handler" not in caplog.text + expected = f"notification binding for 'notifications/message' will never fire at {LATEST_MODERN_VERSION}" + assert caplog.text.count(expected) == 1 + + +@pytest.mark.anyio +async def test_handler_exception_is_contained_and_later_events_deliver(caplog: pytest.LogCaptureFixture) -> None: + """SDK-defined: a raising handler costs only that delivery; later events still deliver.""" + delivered: list[int] = [] + done = anyio.Event() + + async def on_event(params: _EventParams) -> None: + if params.seq == 1: + raise ValueError("handler boom") + delivered.append(params.seq) + done.set() + + client_side, server_side = create_direct_dispatcher_pair() + binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event) + session = ClientSession(dispatcher=client_side, notification_bindings=[binding]) + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: + await tg.start(server_side.run, _server_on_request, _server_on_notify) + async with session: + _adopt_modern(session) + await server_side.notify(_VENDOR_METHOD, {"seq": 1}) + await server_side.notify(_VENDOR_METHOD, {"seq": 2}) + await done.wait() + server_side.close() + + assert delivered == [2] + assert f"notification binding handler for {_VENDOR_METHOD!r} raised" in caplog.text + + +@pytest.mark.anyio +async def test_binding_delivery_works_without_adopt() -> None: + """SDK-defined: bindings deliver pre-handshake, under the default version tables.""" + delivered: list[int] = [] + done = anyio.Event() + + async def on_event(params: _EventParams) -> None: + delivered.append(params.seq) + done.set() + + client_side, server_side = create_direct_dispatcher_pair() + binding = NotificationBinding(method=_VENDOR_METHOD, params_type=_EventParams, handler=on_event) + session = ClientSession(dispatcher=client_side, notification_bindings=[binding]) + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: + await tg.start(server_side.run, _server_on_request, _server_on_notify) + async with session: + await server_side.notify(_VENDOR_METHOD, {"seq": 7}) + await done.wait() + server_side.close() + + assert delivered == [7] diff --git a/tests/client/test_session_promotions.py b/tests/client/test_session_promotions.py new file mode 100644 index 0000000000..e4a62732b9 --- /dev/null +++ b/tests/client/test_session_promotions.py @@ -0,0 +1,66 @@ +"""`dispatch_input_request` and `validate_tool_result` are public `ClientSession` API.""" + +import mcp_types as types +import pytest +from mcp_types import ( + CallToolResult, + ErrorData, + ListRootsResult, + ListToolsResult, + PaginatedRequestParams, + Tool, +) + +from mcp.client.client import Client +from mcp.client.session import ClientRequestContext, ClientSession +from mcp.server import Server, ServerRequestContext +from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair + + +@pytest.mark.anyio +async def test_dispatch_input_request_routes_through_the_callback_table() -> None: + expected = ListRootsResult(roots=[]) + + async def list_roots(context: ClientRequestContext) -> ListRootsResult: + return expected + + client_side, _server_side = create_direct_dispatcher_pair() + session = ClientSession(dispatcher=client_side, list_roots_callback=list_roots) + ctx = ClientRequestContext(session=session, request_id="r-1") + response = await session.dispatch_input_request(ctx, types.ListRootsRequest()) + assert response is expected + + +@pytest.mark.anyio +async def test_dispatch_input_request_returns_error_data_on_refusal() -> None: + """With no callback registered, refusal comes back as `ErrorData`, not a raise.""" + client_side, _server_side = create_direct_dispatcher_pair() + session = ClientSession(dispatcher=client_side) + ctx = ClientRequestContext(session=session, request_id="r-1") + response = await session.dispatch_input_request(ctx, types.ListRootsRequest()) + assert isinstance(response, ErrorData) + assert response.code == types.INVALID_REQUEST + + +def _make_server(output_schema: dict[str, object]) -> Server: + async def on_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"}, output_schema=output_schema)]) + + return Server("test-server", on_list_tools=on_list_tools) + + +@pytest.mark.anyio +async def test_validate_tool_result_passes_a_conforming_result() -> None: + server = _make_server({"type": "object", "properties": {"x": {"type": "integer"}}, "required": ["x"]}) + async with Client(server) as client: + # The session fetches the listing itself when the tool isn't cached yet. + await client.session.validate_tool_result("t", CallToolResult(content=[], structured_content={"x": 1})) + + +@pytest.mark.anyio +async def test_validate_tool_result_raises_on_schema_mismatch() -> None: + server = _make_server({"type": "object", "properties": {"x": {"type": "integer"}}, "required": ["x"]}) + async with Client(server) as client: + # Stable SDK prefix only: the message tail is jsonschema text that shifts with the dependency. + with pytest.raises(RuntimeError, match="Invalid structured content returned by tool t"): + await client.session.validate_tool_result("t", CallToolResult(content=[], structured_content={"x": "no"})) diff --git a/tests/docs_src/test_apps.py b/tests/docs_src/test_apps.py index 02375f97a3..8b692fcc95 100644 --- a/tests/docs_src/test_apps.py +++ b/tests/docs_src/test_apps.py @@ -7,6 +7,7 @@ from docs_src.apps import tutorial001, tutorial002, tutorial003 from mcp import Client +from mcp.client import advertise from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID # See test_index.py for why this is a per-module mark and not a conftest hook. @@ -34,7 +35,9 @@ async def test_the_ui_resource_is_served_as_the_app_mime_type() -> None: async def test_one_tool_two_answers() -> None: """tutorial001: the canonical degradation pattern: raw data for a client that negotiated Apps, a human sentence for one that did not.""" - async with Client(tutorial001.mcp, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as ui_client: + async with Client( + tutorial001.mcp, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})] + ) as ui_client: rich = await ui_client.call_tool("get_time", {}) async with Client(tutorial001.mcp) as text_client: plain = await text_client.call_tool("get_time", {}) diff --git a/tests/docs_src/test_extensions.py b/tests/docs_src/test_extensions.py index ebe00e5a88..2a141337b1 100644 --- a/tests/docs_src/test_extensions.py +++ b/tests/docs_src/test_extensions.py @@ -1,15 +1,22 @@ """`docs/advanced/extensions.md`: every claim the page makes, proved against the real SDK.""" import logging -from typing import cast -import mcp_types as types import pytest from inline_snapshot import snapshot from mcp_types import METHOD_NOT_FOUND, MISSING_REQUIRED_CLIENT_CAPABILITY, TextContent -from docs_src.extensions import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005 +from docs_src.extensions import ( + tutorial001, + tutorial002, + tutorial003, + tutorial004, + tutorial005, + tutorial006, + tutorial007, +) from mcp import Client, MCPError +from mcp.client import advertise from mcp.server.extension import Extension # See test_index.py for why this is a per-module mark and not a conftest hook. @@ -70,7 +77,7 @@ async def test_vendor_method_rejects_a_non_declaring_client_with_32021() -> None async with Client(tutorial004.mcp) as client: request = tutorial004.SearchRequest(params=tutorial004.SearchParams(query="mcp")) with pytest.raises(MCPError) as exc_info: - await client.session.send_request(cast("types.ClientRequest", request), tutorial004.SearchResult) + await client.session.send_request(request, tutorial004.SearchResult) assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY assert exc_info.value.error.data == {"requiredCapabilities": {"extensions": {"com.example/search": {}}}} @@ -78,10 +85,10 @@ async def test_vendor_method_rejects_a_non_declaring_client_with_32021() -> None async def test_version_pinned_method_is_not_found_on_a_legacy_connection() -> None: """tutorial004: `protocol_versions={"2026-07-28"}` makes the method METHOD_NOT_FOUND at any other wire version; for a legacy client it doesn't exist.""" - async with Client(tutorial004.mcp, mode="legacy", extensions={tutorial004.EXTENSION_ID: {}}) as client: + async with Client(tutorial004.mcp, mode="legacy", extensions=[advertise(tutorial004.EXTENSION_ID)]) as client: request = tutorial004.SearchRequest(params=tutorial004.SearchParams(query="mcp")) with pytest.raises(MCPError) as exc_info: - await client.session.send_request(cast("types.ClientRequest", request), tutorial004.SearchResult) + await client.session.send_request(request, tutorial004.SearchResult) assert exc_info.value.code == METHOD_NOT_FOUND @@ -95,3 +102,31 @@ async def test_interceptor_observes_the_call_and_passes_the_result_through( assert result.structured_content == {"result": 5} messages = [record.getMessage() for record in caplog.records if record.name == tutorial005.logger.name] assert messages == ["tool 'add' called"] + + +async def test_the_receipts_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial006: `main()` runs as printed and the output is the redeemed result, never the claimed shape.""" + await tutorial006.main() + assert "goods for r-117" in capsys.readouterr().out + + +async def test_a_client_without_the_extension_is_refused_by_the_gate() -> None: + """The page's off-by-default claim: the server's capability gate refuses a non-declaring client.""" + async with Client(tutorial006.mcp) as client: + with pytest.raises(MCPError) as exc_info: + await client.call_tool("buy", {"item": "lamp"}) + assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY + + +async def test_session_tier_allow_claimed_returns_the_raw_shape() -> None: + """The page's escape hatch: `allow_claimed=True` returns the parsed claim model, not the resolved result.""" + async with Client(tutorial006.mcp, extensions=[tutorial006.Receipts()]) as client: + result = await client.session.call_tool("buy", {"item": "lamp"}, allow_claimed=True) + assert isinstance(result, tutorial006.ReceiptResult) + assert result.receipt_token == "r-117" + + +async def test_the_jobs_client_program_runs_as_shown(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial007: a vendor request with `name_param` round-trips `send_request` with no registration.""" + await tutorial007.main() + assert "job-7 is running" in capsys.readouterr().out diff --git a/tests/interaction/_connect.py b/tests/interaction/_connect.py index 05b2d2277b..5da269ba45 100644 --- a/tests/interaction/_connect.py +++ b/tests/interaction/_connect.py @@ -7,7 +7,7 @@ (session ids, SSE encoding, session management) runs with no sockets, threads, or subprocesses. """ -from collections.abc import AsyncIterator, Awaitable, Callable, Iterable +from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence from contextlib import AbstractAsyncContextManager, asynccontextmanager from functools import partial from typing import Any, Protocol @@ -30,6 +30,7 @@ from starlette.routing import Mount, Route from mcp.client.client import Client +from mcp.client.extension import ClientExtension from mcp.client.session import ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT from mcp.client.sse import sse_client from mcp.client.streamable_http import streamable_http_client @@ -70,6 +71,7 @@ def __call__( message_handler: MessageHandlerFnT | None = None, client_info: Implementation | None = None, elicitation_callback: ElicitationFnT | None = None, + extensions: Sequence[ClientExtension] | None = None, spec_version: str = LATEST_HANDSHAKE_VERSION, ) -> AbstractAsyncContextManager[Client]: ... @@ -85,6 +87,7 @@ async def connect_in_memory( message_handler: MessageHandlerFnT | None = None, client_info: Implementation | None = None, elicitation_callback: ElicitationFnT | None = None, + extensions: Sequence[ClientExtension] | None = None, spec_version: str = LATEST_HANDSHAKE_VERSION, ) -> AsyncIterator[Client]: """Yield a Client connected to the server over the in-memory transport. @@ -103,6 +106,7 @@ async def connect_in_memory( message_handler=message_handler, client_info=client_info, elicitation_callback=elicitation_callback, + extensions=extensions, ) as client: yield client @@ -122,6 +126,7 @@ async def connect_over_streamable_http( message_handler: MessageHandlerFnT | None = None, client_info: Implementation | None = None, elicitation_callback: ElicitationFnT | None = None, + extensions: Sequence[ClientExtension] | None = None, spec_version: str = LATEST_HANDSHAKE_VERSION, ) -> AsyncIterator[Client]: """Yield a Client connected to the server's streamable HTTP app, entirely in process. @@ -156,6 +161,7 @@ async def connect_over_streamable_http( message_handler=message_handler, client_info=client_info, elicitation_callback=elicitation_callback, + extensions=extensions, ) as client, ): yield client @@ -357,6 +363,7 @@ async def connect_over_sse( message_handler: MessageHandlerFnT | None = None, client_info: Implementation | None = None, elicitation_callback: ElicitationFnT | None = None, + extensions: Sequence[ClientExtension] | None = None, spec_version: str = LATEST_HANDSHAKE_VERSION, ) -> AsyncIterator[Client]: """Yield a Client connected to the server's legacy SSE transport, entirely in process.""" @@ -390,5 +397,6 @@ def httpx_client_factory( message_handler=message_handler, client_info=client_info, elicitation_callback=elicitation_callback, + extensions=extensions, ) as client: yield client diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index d376f0b9f0..ada4b7fa05 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -2384,6 +2384,69 @@ def __post_init__(self) -> None: ), ), # ═══════════════════════════════════════════════════════════════════════════ + # Extensions (SEP-2133): client-side result claims and the capability ad + # ═══════════════════════════════════════════════════════════════════════════ + "extensions:client:claimed-result-resolved": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic#resulttype", + behavior=( + "A tools/call answered with an extension-claimed resultType is finished by the owning " + "ClientExtension's claim resolver, and Client.call_tool returns the resolver's ordinary " + "CallToolResult. The resolver may send follow-up requests through the session it is handed." + ), + added_in="2026-07-28", + ), + "extensions:client:claimed-result-undeclared-invalid": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic#resulttype", + behavior=( + "A resultType unrecognized by the client is invalid: a claimed shape delivered to a client that " + "did not construct the owning extension fails result validation (the supported set is core plus " + "declared claims, never more)." + ), + added_in="2026-07-28", + note=( + "Known leniency: the monolith result surface still accepts an unknown tag when the payload " + "also parses as a complete core result (open result_type, extras ignored). Rejecting tags " + "outside core plus active claims is a tracked follow-up ruling." + ), + ), + "extensions:client:capability-ad:gates-server-behaviour": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic#resulttype", + behavior=( + "The per-request _meta capability ad carries each declared extension's identifier and settings, " + "and is what entitles the server to substitute that extension's claimed shapes: a server " + "extension gating on the ad sees the declared settings, and refuses a non-declaring client with " + "-32021 (missing required client capability)." + ), + added_in="2026-07-28", + ), + "extensions:client:capability-ad:legacy-omits-claimed": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic#resulttype", + behavior=( + "On a legacy connection no claim can activate, and the initialize capability ad omits " + "claim-bearing identifiers in the same breath (claim-less identifiers still advertise), so the " + "client never advertises an extension whose claimed shapes it would reject." + ), + removed_in="2026-07-28", + note=( + "The legacy-era half of the ad/claims coupling: only a handshake connection can exhibit it, so " + "the version window ends where the modern era begins." + ), + arm_exclusions=(ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),), + ), + "extensions:client:notification-binding-delivery": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic#resulttype", + behavior=( + "A vendor server notification bound by a ClientExtension's NotificationBinding is validated " + "against the binding's params type and delivered to its handler serially, in dispatch order." + ), + added_in="2026-07-28", + deferred=( + "Covered at session tier by tests/client/test_session_notification_bindings.py: no public " + "server-side surface emits vendor-method notifications (ServerNotification is a closed union), " + "and HTTP-modern arrival additionally needs the subscriptions/listen client runtime." + ), + ), + # ═══════════════════════════════════════════════════════════════════════════ # Transports (in-suite coverage) # ═══════════════════════════════════════════════════════════════════════════ "transport:streamable-http:stateful": Requirement( @@ -3341,6 +3404,19 @@ def __post_init__(self) -> None: transports=("streamable-http",), note="Only observable over streamable HTTP: headers are derived from the cached tool schema at the seam.", ), + "client-transport:http:vendor-name-param-header": Requirement( + source="sdk", + behavior=( + "A vendor request type declaring name_param mirrors that wire-params key into the Mcp-Name " + "header of its outgoing HTTP request, with no client-side registration of the method." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "SDK mechanism honouring the per-extension Mcp-Name requirements (e.g. SEP-2663 mandates the " + "header for tasks/*); only observable over streamable HTTP, where headers exist." + ), + ), "client-transport:http:stateless-ignores-session-id": Requirement( source=f"{SPEC_2026_BASE_URL}/basic/transports#stateless-request-headers", behavior=( diff --git a/tests/interaction/mcpserver/test_extensions.py b/tests/interaction/mcpserver/test_extensions.py new file mode 100644 index 0000000000..205a7fd6ea --- /dev/null +++ b/tests/interaction/mcpserver/test_extensions.py @@ -0,0 +1,174 @@ +"""Client extensions (SEP-2133) over the full client-server loop: a server extension +substitutes a claimed `tools/call` shape and the declaring client's `ClientExtension` resolves it.""" + +from collections.abc import Awaitable, Callable, Sequence +from typing import Any, Literal + +import mcp_types as types +import pytest +from inline_snapshot import snapshot +from mcp_types import MISSING_REQUIRED_CLIENT_CAPABILITY, CallToolResult, Result, TextContent +from pydantic import ValidationError + +from mcp import MCPError +from mcp.client import ClaimContext, ClientExtension, ResultClaim, advertise +from mcp.server.context import CallNext, HandlerResult, ServerRequestContext +from mcp.server.extension import Extension +from mcp.server.mcpserver import Context, MCPServer, require_client_extension +from tests.interaction._connect import Connect +from tests.interaction._requirements import requirement + +pytestmark = pytest.mark.anyio + +_RECEIPTS = "com.example/receipts" +_FLAGS = "com.example/flags" + + +class ReceiptResult(Result): + result_type: Literal["receipt"] = "receipt" + receipt_token: str + settings_echo: dict[str, Any] | None = None + + +_Resolver = Callable[[ReceiptResult, ClaimContext], Awaitable[CallToolResult]] + + +class Receipts(ClientExtension): + """Client half: claims the `receipt` shape with the test's resolver and settings.""" + + identifier = _RECEIPTS + + def __init__(self, resolve: _Resolver, settings: dict[str, Any] | None = None) -> None: + self._resolve = resolve + self._settings = {} if settings is None else settings + + def settings(self) -> dict[str, Any]: + return self._settings + + def claims(self) -> Sequence[ResultClaim[Any]]: + return [ResultClaim(result_type="receipt", model=ReceiptResult, resolve=self._resolve)] + + +class _ReceiptIssuer(Extension): + """Server half: answers `buy` with the claimed shape; every other tool passes through.""" + + identifier = _RECEIPTS + + async def intercept_tool_call( + self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext + ) -> HandlerResult: + if params.name != "buy": + return await call_next(ctx) + return {"resultType": "receipt", "receiptToken": "r-117"} + + +def _receipt_shop(issuer: Extension) -> MCPServer: + server = MCPServer("shop", extensions=[issuer]) + + @server.tool() + def buy(item: str) -> CallToolResult: + """Buy an item.""" + raise NotImplementedError # the server extension answers `buy` before the tool runs + + @server.tool() + def redeem(token: str) -> str: + """Exchange a receipt token for the goods.""" + return f"goods for {token}" + + return server + + +@requirement("extensions:client:claimed-result-resolved") +async def test_claimed_result_is_finished_by_the_owning_extensions_resolver(connect: Connect) -> None: + """The owning extension's claim resolver redeems the substituted `receipt` through + `ctx.session`, and `call_tool` returns the resolver's plain `CallToolResult`.""" + received: list[ReceiptResult] = [] + + async def redeem_receipt(claimed: ReceiptResult, ctx: ClaimContext) -> CallToolResult: + received.append(claimed) + return await ctx.session.call_tool("redeem", {"token": claimed.receipt_token}) + + async with connect(_receipt_shop(_ReceiptIssuer()), extensions=[Receipts(redeem_receipt)]) as client: + result = await client.call_tool("buy", {"item": "lamp"}) + + assert [claimed.receipt_token for claimed in received] == ["r-117"] + assert result == snapshot( + CallToolResult(content=[TextContent(text="goods for r-117")], structured_content={"result": "goods for r-117"}) + ) + + +@requirement("extensions:client:claimed-result-undeclared-invalid") +async def test_claimed_shape_fails_validation_for_a_client_without_the_extension(connect: Connect) -> None: + """Spec-mandated: an unrecognized `resultType` is invalid, so a client without the + owning extension fails to parse the claimed shape.""" + async with connect(_receipt_shop(_ReceiptIssuer())) as client: + with pytest.raises(ValidationError): + await client.call_tool("buy", {"item": "lamp"}) + + +class _SettingsEchoIssuer(Extension): + """Server half: requires the declaring client, then echoes its declared settings.""" + + identifier = _RECEIPTS + + async def intercept_tool_call( + self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext + ) -> HandlerResult: + require_client_extension(ctx, _RECEIPTS) + client_params = ctx.session.client_params + assert client_params is not None + extensions = client_params.capabilities.extensions + assert extensions is not None + return {"resultType": "receipt", "receiptToken": "echo", "settingsEcho": extensions[_RECEIPTS]} + + +@requirement("extensions:client:capability-ad:gates-server-behaviour") +async def test_per_request_ad_carries_settings_and_gates_the_claimed_substitution(connect: Connect) -> None: + """The per-request `_meta` capability ad gates the claimed substitution: declared + settings reach the resolver and a non-declaring client is refused with -32021.""" + server = MCPServer("shop", extensions=[_SettingsEchoIssuer()]) + + @server.tool() + def buy(item: str) -> CallToolResult: + """Buy an item.""" + raise NotImplementedError # the server extension answers `buy` before the tool runs + + received: list[ReceiptResult] = [] + + async def keep(claimed: ReceiptResult, ctx: ClaimContext) -> CallToolResult: + received.append(claimed) + return CallToolResult(content=[TextContent(text="done")]) + + async with connect(server, extensions=[Receipts(keep, settings={"tier": "gold"})]) as client: + result = await client.call_tool("buy", {"item": "lamp"}) + assert result.content == [TextContent(text="done")] + assert [claimed.settings_echo for claimed in received] == [{"tier": "gold"}] + + async with connect(server) as client: + with pytest.raises(MCPError) as exc_info: + await client.call_tool("buy", {"item": "lamp"}) + assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY + + +async def _unreachable_resolve(claimed: ReceiptResult, ctx: ClaimContext) -> CallToolResult: + raise NotImplementedError # no claimed shape can be delivered on a legacy wire + + +@requirement("extensions:client:capability-ad:legacy-omits-claimed") +async def test_legacy_ad_omits_claim_bearing_identifiers_but_keeps_claim_less_ones(connect: Connect) -> None: + """On a legacy connection the claim-bearing identifier drops out of the initialize + capability ad while an ad-only identifier still advertises.""" + server = MCPServer("introspector") + + @server.tool() + def declared(ctx: Context) -> list[str]: + """Report the extension identifiers the client advertised.""" + capabilities = ctx.client_capabilities + assert capabilities is not None + return sorted(capabilities.extensions or {}) + + client_extensions = [Receipts(_unreachable_resolve), advertise(_FLAGS)] + async with connect(server, extensions=client_extensions) as client: + result = await client.call_tool("declared", {}) + + assert result.structured_content == {"result": [_FLAGS]} diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py index 3feed4fed3..f01502bcd5 100644 --- a/tests/interaction/transports/test_hosting_http_modern.py +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -9,7 +9,7 @@ import json from collections.abc import Callable -from typing import Any +from typing import Any, Literal import anyio import httpx @@ -30,7 +30,9 @@ JSONRPCResponse, ListToolsResult, PaginatedRequestParams, + Request, RequestParams, + Result, ServerCapabilities, TextContent, Tool, @@ -551,3 +553,56 @@ async def on_request(request: httpx.Request) -> None: before, after = tool_calls assert before.headers.get("mcp-param-region") == "x" assert not any(k.startswith("mcp-param-") for k in after.headers) + + +class _JobParams(RequestParams): + job_id: str + + +class _JobStatusRequest(Request[_JobParams, Literal["com.example/jobs.status"]]): + method: Literal["com.example/jobs.status"] = "com.example/jobs.status" + name_param = "jobId" + + +class _JobStatusResult(Result): + status: str + + +@requirement("client-transport:http:vendor-name-param-header") +async def test_vendor_request_with_name_param_carries_mcp_name_on_the_wire() -> None: + """`send_request` mirrors an unregistered vendor request's `name_param` value into the + `Mcp-Name` header while the body keeps the params key unchanged.""" + + async def job_status(ctx: ServerRequestContext, params: _JobParams) -> _JobStatusResult: + assert params.job_id == "job-7" + return _JobStatusResult(status="running") + + server = _server() + server.add_request_handler("com.example/jobs.status", _JobParams, job_status) + + requests: list[httpx.Request] = [] + + async def on_request(request: httpx.Request) -> None: + requests.append(request) + + discover = DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + server_info=Implementation(name="srv", version="0"), + ) + 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=LATEST_MODERN_VERSION, + prior_discover=discover, + ) as client, + ): + request = _JobStatusRequest(params=_JobParams(job_id="job-7")) + result = await client.session.send_request(request, _JobStatusResult) + + assert result.status == "running" + [wire_request] = requests + assert wire_request.headers["mcp-name"] == "job-7" + assert json.loads(wire_request.content)["params"]["jobId"] == "job-7" diff --git a/tests/server/mcpserver/test_extension.py b/tests/server/mcpserver/test_extension.py index e2ec366b29..b6ff0283d6 100644 --- a/tests/server/mcpserver/test_extension.py +++ b/tests/server/mcpserver/test_extension.py @@ -18,6 +18,7 @@ TextContent, ) +from mcp.client import advertise from mcp.client.client import Client from mcp.server.context import CallNext, HandlerResult, ServerRequestContext from mcp.server.extension import ( @@ -26,7 +27,6 @@ ResourceBinding, ToolBinding, compose_tool_call_interceptor, - validate_extension_identifier, ) from mcp.server.mcpserver import Context, MCPServer, require_client_extension from mcp.server.mcpserver.resources import TextResource @@ -193,7 +193,7 @@ async def test_extension_method_reachable_via_session_send_request() -> None: async with Client(server) as client: request = _PingRequest(params=_PingParams()) - result = await client.session.send_request(cast("types.ClientRequest", request), _PingResult) + result = await client.session.send_request(request, _PingResult) assert result == snapshot(_PingResult(pong=True)) @@ -343,7 +343,7 @@ async def test_version_pinned_method_is_served_at_an_allowed_version() -> None: async with Client(server, mode="2026-07-28") as client: request = _VersionPinnedRequest(params=_VersionPinnedParams()) - result = await client.session.send_request(cast("types.ClientRequest", request), _VersionPinnedResult) + result = await client.session.send_request(request, _VersionPinnedResult) assert result == snapshot(_VersionPinnedResult(ok=True)) @@ -356,56 +356,12 @@ async def test_version_pinned_method_is_method_not_found_at_a_disallowed_version async with Client(server, mode="legacy") as client: request = _VersionPinnedRequest(params=_VersionPinnedParams()) with pytest.raises(MCPError) as exc_info: - await client.session.send_request(cast("types.ClientRequest", request), _VersionPinnedResult) + await client.session.send_request(request, _VersionPinnedResult) assert exc_info.value.code == METHOD_NOT_FOUND assert exc_info.value.error.data == "com.example/pinned" -@pytest.mark.parametrize( - "identifier", - [ - "io.modelcontextprotocol/ui", - "com.example/my_ext", - "com.x-y.z2/n.a-b_c", - "example/x", - "a/b", - "com.example/9start", - ], -) -def test_grammar_conformant_extension_identifiers_are_accepted(identifier: str) -> None: - """Spec `_meta` key grammar: dot-separated labels (letter start, letter/digit end, - hyphens interior), a slash, then a name that starts and ends alphanumeric.""" - validate_extension_identifier(identifier, owner="T") - - -@pytest.mark.parametrize( - "identifier", - [ - "noprefix", - "-foo/bar", - ".leading/x", - "a..b/x", - "foo-/x", - "9foo/x", - "foo/-bar", - "foo/bar-", - "foo/", - "/bar", - "foo/ba r", - "io.modelcontextprotocol/ui\n", - "", - None, - 42, - ], -) -def test_malformed_extension_identifiers_are_rejected(identifier: Any) -> None: - """Spec `_meta` key grammar: malformed prefixes (bad label start/end, empty labels) - and malformed names are rejected, as are non-strings.""" - with pytest.raises(TypeError): - validate_extension_identifier(identifier, owner="T") - - @pytest.mark.parametrize("method", ["tools/list", "completion/complete"]) def test_method_binding_rejects_spec_methods(method: str) -> None: """SDK-defined: extension methods are additive — binding a spec-defined request method @@ -466,7 +422,7 @@ async def test_require_client_extension_passes_when_client_declared_it() -> None """SDK-defined: `require_client_extension` is a no-op when the client advertised the id.""" server = MCPServer("test", extensions=[_RequiresExt()]) - async with Client(server, extensions={_NEEDS_EXT: {}}) as client: + async with Client(server, extensions=[advertise(_NEEDS_EXT)]) as client: result = await client.call_tool("guarded", {}) assert result == snapshot(CallToolResult(content=[TextContent(text="ok")], structured_content={"result": "ok"})) diff --git a/tests/server/test_apps.py b/tests/server/test_apps.py index 65908309ad..262bdfe7a1 100644 --- a/tests/server/test_apps.py +++ b/tests/server/test_apps.py @@ -14,6 +14,7 @@ from inline_snapshot import snapshot from mcp_types import CallToolResult, ReadResourceResult, TextContent, TextResourceContents +from mcp.client import advertise from mcp.client.client import Client from mcp.server import Server, ServerRequestContext from mcp.server.apps import ( @@ -95,7 +96,7 @@ async def test_apps_tool_returns_rich_output_when_client_negotiated_apps() -> No branching on `client_supports_apps(ctx)`, drives both halves.""" server = _clock_server() - async with Client(server, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as supports: + async with Client(server, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]) as supports: rich = await supports.call_tool("get_time", {}) async with Client(server) as plain: fallback = await plain.call_tool("get_time", {}) @@ -104,7 +105,7 @@ async def test_apps_tool_returns_rich_output_when_client_negotiated_apps() -> No assert fallback.content == snapshot([TextContent(text="The time is 2026-06-26T00:00:00Z.")]) -async def _observed_client_supports_apps(extensions: dict[str, dict[str, Any]] | None) -> bool: +async def _observed_client_supports_apps(ui_settings: dict[str, Any] | None) -> bool: """Run one probe `tools/call` and report what `client_supports_apps` saw server-side. Exercises the lowlevel `ServerRequestContext` form, which reads the client's @@ -123,29 +124,30 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara return CallToolResult(content=[TextContent(text="ok")]) server = Server("probe", on_list_tools=list_tools, on_call_tool=call_tool) + extensions = None if ui_settings is None else [advertise(EXTENSION_ID, ui_settings)] async with Client(server, extensions=extensions) as client: await client.call_tool("probe", {}) return observed[0] @pytest.mark.parametrize( - ("extensions", "expected"), + ("ui_settings", "expected"), [ - pytest.param({EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}, True, id="html-mime-listed"), - pytest.param({EXTENSION_ID: {"mimeTypes": (APP_MIME_TYPE,)}}, True, id="in-process-tuple-mime-types"), + pytest.param({"mimeTypes": [APP_MIME_TYPE]}, True, id="html-mime-listed"), + pytest.param({"mimeTypes": (APP_MIME_TYPE,)}, True, id="in-process-tuple-mime-types"), pytest.param(None, False, id="extension-not-declared"), - pytest.param({EXTENSION_ID: {"mimeTypes": ["application/x-other"]}}, False, id="html-mime-not-offered"), - pytest.param({EXTENSION_ID: {}}, False, id="mime-types-key-missing"), + pytest.param({"mimeTypes": ["application/x-other"]}, False, id="html-mime-not-offered"), + pytest.param({}, False, id="mime-types-key-missing"), ], ) async def test_client_supports_apps_from_lowlevel_request_context( - extensions: dict[str, dict[str, Any]] | None, expected: bool + ui_settings: dict[str, Any] | None, expected: bool ) -> None: """ext-apps: `client_supports_apps` is `True` only when the client declared the ui extension AND listed `text/html;profile=mcp-app` in its `mimeTypes` settings — a required field, so its absence means unsupported (the reference SDK's check is `uiCap?.mimeTypes?.includes(...)`).""" - assert await _observed_client_supports_apps(extensions) is expected + assert await _observed_client_supports_apps(ui_settings) is expected def test_apps_tool_rejects_non_ui_resource_uri() -> None: diff --git a/tests/server/test_extensions_capability.py b/tests/server/test_extensions_capability.py index 90f24be2bc..49c81f58f4 100644 --- a/tests/server/test_extensions_capability.py +++ b/tests/server/test_extensions_capability.py @@ -13,6 +13,7 @@ import pytest from inline_snapshot import snapshot +from mcp.client import advertise from mcp.client.client import Client from mcp.server import Server, ServerRequestContext from mcp.server.extension import Extension @@ -82,7 +83,7 @@ async def list_tools( return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})]) server = Server("checker", on_call_tool=call_tool, on_list_tools=list_tools) - async with Client(server, extensions={_EXTENSION_ID: {"mimeTypes": ["text/html"]}}) as client: + async with Client(server, extensions=[advertise(_EXTENSION_ID, {"mimeTypes": ["text/html"]})]) as client: await client.call_tool("probe", {}) assert supported == [True] @@ -105,7 +106,7 @@ async def list_tools( return types.ListToolsResult(tools=[types.Tool(name="probe", input_schema={"type": "object"})]) server = Server("checker", on_call_tool=call_tool, on_list_tools=list_tools) - async with Client(server, extensions={_EXTENSION_ID: {"mimeTypes": ["text/html"]}}) as client: + async with Client(server, extensions=[advertise(_EXTENSION_ID, {"mimeTypes": ["text/html"]})]) as client: await client.call_tool("probe", {}) assert supported == [False] diff --git a/tests/shared/test_extension.py b/tests/shared/test_extension.py new file mode 100644 index 0000000000..fd9192554e --- /dev/null +++ b/tests/shared/test_extension.py @@ -0,0 +1,56 @@ +"""The extension-identifier grammar in `mcp.shared.extension`, shared by server and client.""" + +from typing import Any + +import pytest + +import mcp.server.extension +import mcp.shared.extension +from mcp.shared.extension import validate_extension_identifier + + +def test_server_extension_module_reexports_shared_validator() -> None: + """SDK-defined: `mcp.server.extension` re-exports the shared validator as the same function object.""" + assert mcp.server.extension.validate_extension_identifier is mcp.shared.extension.validate_extension_identifier + + +@pytest.mark.parametrize( + "identifier", + [ + "io.modelcontextprotocol/ui", + "com.example/my_ext", + "com.x-y.z2/n.a-b_c", + "example/x", + "a/b", + "com.example/9start", + ], +) +def test_grammar_conformant_extension_identifiers_are_accepted(identifier: str) -> None: + """Spec `_meta` key grammar: conformant `vendor-prefix/name` identifiers are accepted.""" + validate_extension_identifier(identifier, owner="T") + + +@pytest.mark.parametrize( + "identifier", + [ + "noprefix", + "-foo/bar", + ".leading/x", + "a..b/x", + "foo-/x", + "9foo/x", + "foo/-bar", + "foo/bar-", + "foo/", + "/bar", + "foo/ba r", + "io.modelcontextprotocol/ui\n", + "", + None, + 42, + ], +) +def test_malformed_extension_identifiers_are_rejected(identifier: Any) -> None: + """Spec `_meta` key grammar: malformed prefixes, malformed names, and non-strings are rejected.""" + with pytest.raises(TypeError): + validate_extension_identifier(identifier, owner="T") diff --git a/tests/types/test_request_name_param.py b/tests/types/test_request_name_param.py new file mode 100644 index 0000000000..c8efe4fbba --- /dev/null +++ b/tests/types/test_request_name_param.py @@ -0,0 +1,37 @@ +"""`Request.name_param`: the wire-params key a request type declares for `Mcp-Name` emission.""" + +from typing import Literal + +import mcp_types as types +from mcp_types import CallToolRequest, PingRequest, Request + + +class _VendorParams(types.RequestParams): + task_id: str + + +class _VendorRequest(Request[_VendorParams, Literal["vendor/tasks/get"]]): + method: Literal["vendor/tasks/get"] = "vendor/tasks/get" + name_param = "taskId" + + +def test_request_base_declares_no_name_param() -> None: + assert Request.name_param is None + + +def test_core_request_types_inherit_none() -> None: + assert CallToolRequest.name_param is None + assert PingRequest.name_param is None + + +def test_subclass_overrides_by_bare_assignment() -> None: + """Subclasses set `name_param` by bare assignment; the override is class-local.""" + assert _VendorRequest.name_param == "taskId" + assert Request.name_param is None + + +def test_name_param_is_not_a_pydantic_field() -> None: + request = _VendorRequest(params=_VendorParams(task_id="t-1")) + assert "name_param" not in _VendorRequest.model_fields + dumped = request.model_dump(by_alias=True, mode="json", exclude_none=True) + assert dumped == {"method": "vendor/tasks/get", "params": {"taskId": "t-1"}} From 48ef569f7e2178eebb8db1088263df733ff84663 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:39:32 +0100 Subject: [PATCH 036/100] Validate Mcp-Param-* headers server-side on the 2026-07-28 HTTP path (SEP-2243) (#3033) --- .../expected-failures.2026-07-28.yml | 3 - .../actions/conformance/expected-failures.yml | 7 - docs/migration.md | 10 +- src/mcp/server/_streamable_http_modern.py | 152 +++++++- src/mcp/shared/inbound.py | 180 ++++++++- .../transports/test_hosting_http_modern.py | 20 +- tests/server/test_streamable_http_modern.py | 363 +++++++++++++++++- tests/shared/test_inbound.py | 273 +++++++++++++ 8 files changed, 971 insertions(+), 37 deletions(-) diff --git a/.github/actions/conformance/expected-failures.2026-07-28.yml b/.github/actions/conformance/expected-failures.2026-07-28.yml index e61033b394..5b19d6d2d2 100644 --- a/.github/actions/conformance/expected-failures.2026-07-28.yml +++ b/.github/actions/conformance/expected-failures.2026-07-28.yml @@ -26,6 +26,3 @@ server: # SEP-2575 subscriptions/listen is not implemented yet; see the matching # entry in expected-failures.yml for the full rationale. - server-stateless - # SEP-2243 Mcp-Param-* server-side validation is not implemented yet; see - # the matching entry in expected-failures.yml for the full rationale. - - http-custom-header-server-validation diff --git a/.github/actions/conformance/expected-failures.yml b/.github/actions/conformance/expected-failures.yml index efadd7d4d7..6d99fba750 100644 --- a/.github/actions/conformance/expected-failures.yml +++ b/.github/actions/conformance/expected-failures.yml @@ -23,13 +23,6 @@ server: # failures in the scenario's other 25 (currently passing) checks — the # baseline is per-scenario, not per-check. - server-stateless - # SEP-2243 Mcp-Param-* server-side validation is not implemented yet. The - # everything-server's `test_x_mcp_header` tool arms these checks (without an - # x-mcp-header-annotated tool the harness skips all of them silently); the - # accept-path checks pass, the reject-path checks fail until the server - # validates Mcp-Param headers against body params. Read by the draft leg and - # the bare `--suite all` leg; the 2026-07-28 leg carries its own entry. - - http-custom-header-server-validation # SEP-2663 (io.modelcontextprotocol/tasks): the SDK does not implement the # tasks extension yet. These extension-tagged scenarios are selected only by # the bare `--suite all` leg — extension scenarios never match a diff --git a/docs/migration.md b/docs/migration.md index eb6f68a71c..a671ea4932 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -425,7 +425,15 @@ On `ClientSession`, `call_tool` / `get_prompt` / `read_resource` still return th ### `call_tool` mirrors `x-mcp-header` arguments into `Mcp-Param-*` headers ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)) -For protocol 2026-07-28 over Streamable HTTP, a tool's input-schema property may carry an `x-mcp-header` annotation. When a tool the client has listed is called, each annotated argument is mirrored into an `Mcp-Param-` request header (string verbatim, integer as decimal, boolean as `true`/`false`, base64-sentinel-wrapped when not header-safe; `null`/absent arguments are omitted). The argument is also left in the request body. `list_tools` caches a tool's annotations, so list a tool before calling it to enable mirroring; a tool the client never listed emits no `Mcp-Param-*` headers. Other transports ignore the annotation. +For protocol 2026-07-28 over Streamable HTTP, a tool's input-schema property may carry an `x-mcp-header` annotation. When a tool the client has listed is called, each annotated argument is mirrored into an `Mcp-Param-` request header (string verbatim, integer as decimal, boolean as `true`/`false`, base64-sentinel-wrapped when not header-safe; `null`/absent arguments — and values with no scalar rendering, such as objects or arrays — are omitted). The argument is also left in the request body. `list_tools` caches a tool's annotations, so list a tool before calling it to enable mirroring; a tool the client never listed emits no `Mcp-Param-*` headers. Other transports ignore the annotation. + +### Servers validate `Mcp-Param-*` headers against the request body ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)) + +The server half of the same contract: on the 2026-07-28 Streamable HTTP path, a `tools/call` whose tool declares `x-mcp-header` annotations is validated before dispatch — each annotated argument and its mirroring `Mcp-Param-*` header must be present together and agree (after base64-sentinel decoding; integers compare numerically), or absent together. A violation is rejected with HTTP 400 and JSON-RPC error `-32020` (`HeaderMismatch`), as the spec requires. A client that sends an annotated argument *without* its header — for example one that never listed the tool — is therefore rejected instead of silently served; the spec's recovery is to re-list and retry. + +There is nothing to configure. The server resolves the called tool's schema through its own registered `tools/list` handler (for `MCPServer`, the built-in one), so the validated catalog is exactly what that caller would be shown. Two consequences worth knowing: the listing runs internally on validated calls, so middleware and an expensive or paginated `tools/list` handler see extra invocations; and validation is skipped — never failing the call — when no `tools/list` handler is registered, the tool isn't in the listing, the handler raises (logged as an error), or the call has no arguments and no `Mcp-Param-*` headers. Headers with no matching annotation are ignored; a recognized header supplied more than once is rejected, as is a duplicated `MCP-Protocol-Version`, `Mcp-Method`, or `Mcp-Name` line. The codec and validator are public in `mcp.shared.inbound` (`decode_header_value`, `validate_mcp_param_headers`) for low-level servers hosting their own HTTP entry. + +Base64-sentinel decoding is strict everywhere it applies, including the `Mcp-Name` header: a `=?base64?...?=` value whose payload is not canonical base64 (wrong padding, stray characters, non-zero trailing bits) or not valid UTF-8 is rejected as malformed rather than leniently decoded. ### `Client` verbs may serve cached responses ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)) diff --git a/src/mcp/server/_streamable_http_modern.py b/src/mcp/server/_streamable_http_modern.py index e36ac7dd4e..a047892e6f 100644 --- a/src/mcp/server/_streamable_http_modern.py +++ b/src/mcp/server/_streamable_http_modern.py @@ -22,14 +22,18 @@ import logging from collections.abc import Awaitable, Mapping from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Final, TypeVar +from typing import TYPE_CHECKING, Any, Final, TypeVar, cast import anyio from anyio.streams.memory import MemoryObjectSendStream from mcp_types import ( + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + HEADER_MISMATCH, INTERNAL_ERROR, INVALID_REQUEST, PARSE_ERROR, + PROTOCOL_VERSION_META_KEY, ClientCapabilities, ErrorData, Implementation, @@ -40,6 +44,7 @@ ProgressToken, RequestId, ) +from mcp_types import methods as _methods from pydantic import BaseModel, ValidationError from starlette.requests import Request from starlette.responses import Response @@ -53,8 +58,12 @@ from mcp.shared.exceptions import NoBackChannelError from mcp.shared.inbound import ( ERROR_CODE_HTTP_STATUS, + MCP_PARAM_HEADER_PREFIX, InboundLadderRejection, + InboundModernRoute, classify_inbound_request, + find_duplicated_routing_header, + validate_mcp_param_headers, ) from mcp.shared.jsonrpc_dispatcher import handler_exception_to_error_data, progress_token_from_params from mcp.shared.message import MessageMetadata, ServerMessageMetadata @@ -172,6 +181,22 @@ def _sse_event(msg: JSONRPCResponse | JSONRPCError | JSONRPCNotification) -> byt return f"event: message\r\ndata: {data}\r\n\r\n".encode() +async def _write_rejection( + rejection: InboundLadderRejection, + request_id: RequestId, + scope: Scope, + receive: Receive, + send: Send, +) -> None: + """Send a ladder rejection as its JSON-RPC error with the table-mapped HTTP status.""" + rej = JSONRPCError( + jsonrpc="2.0", + id=request_id, + error=ErrorData(code=rejection.code, message=rejection.message, data=rejection.data), + ) + await _write(rej, scope, receive, send) + + async def _write( msg: JSONRPCResponse | JSONRPCError, scope: Scope, @@ -192,6 +217,111 @@ async def _write( )(scope, receive, send) +_MCP_PARAM_PREFIX_LOWER: Final = MCP_PARAM_HEADER_PREFIX.lower() + +_MCP_PARAM_LIST_PAGE_CAP: Final = 100 +"""Page cap for the schema-resolving tools/list walk: a buggy paginator degrades to a logged skip, not a hang.""" + + +async def _tool_input_schema( + app: Server[Any], + request: Request, + request_id: RequestId, + verdict: InboundModernRoute, + lifespan_state: Any, + name: str, +) -> Any | None: + """Resolve `name`'s inputSchema from the server's own registered `tools/list` handler. + + The listing runs through the normal `serve_one` path, so a visibility-scoped + catalog yields exactly what *this* caller was advertised. Returns None + (caller skips validation) when the listing fails or never advertises the tool. + """ + meta = { + PROTOCOL_VERSION_META_KEY: verdict.protocol_version, + CLIENT_INFO_META_KEY: verdict.client_info, + CLIENT_CAPABILITIES_META_KEY: verdict.client_capabilities, + } + list_params: dict[str, Any] = {"_meta": meta} + try: + _methods.validate_client_request("tools/list", verdict.protocol_version, list_params) + except ValidationError: + # Client-fault envelope: the real dispatch produces the INVALID_PARAMS + # reply, and anything above a debug line would let clients flood the log. + logger.debug("Mcp-Param header validation skipped: the request envelope fails tools/list validation") + return None + seen_cursors: set[str] = set() + client_info = _typed(Implementation, verdict.client_info) + client_capabilities = _typed(ClientCapabilities, verdict.client_capabilities) + dctx = _SingleExchangeDispatchContext( + transport=TransportContext(kind="streamable-http", can_send_request=False, headers=request.headers), + request_id=request_id, + message_metadata=ServerMessageMetadata(request_context=request), + ) + for _ in range(_MCP_PARAM_LIST_PAGE_CAP): + # Fresh Connection per page: serve_one tears down the connection's exit stack on the way out. + connection = Connection.from_envelope(verdict.protocol_version, client_info, client_capabilities) + try: + result = await serve_one( + app, dctx, "tools/list", list_params, connection=connection, lifespan_state=lifespan_state + ) + for tool in result.get("tools", []): + if tool.get("name") == name: + return tool.get("inputSchema") + cursor = result.get("nextCursor") + except Exception: + # Fail-open boundary by design: header validation must never break a + # working call path. Loud, precisely because the skip is fail-open. + logger.exception("Mcp-Param header validation skipped: the tools/list listing failed") + return None + if not isinstance(cursor, str): + # Listing exhausted without advertising `name`; dispatch owns rejecting an unknown tool. + return None + if cursor in seen_cursors: + logger.warning("Mcp-Param header validation skipped: the tools/list handler returned a cursor cycle") + return None + seen_cursors.add(cursor) + list_params = {"_meta": meta, "cursor": cursor} + logger.warning( + "Mcp-Param header validation skipped: tools/list pagination did not terminate within %d pages", + _MCP_PARAM_LIST_PAGE_CAP, + ) + return None + + +async def _mcp_param_rejection( + app: Server[Any], + request: Request, + req: JSONRPCRequest, + verdict: InboundModernRoute, + lifespan_state: Any, +) -> InboundLadderRejection | None: + """Validate a `tools/call` request's `Mcp-Param-*` headers against the called tool's schema. + + Runs pre-dispatch, before any SSE machinery, so a rejection is always a + plain `application/json` 400 (the spec's MUST). With no `tools/list` handler + the catalog is undiscoverable and there is no recognized header to validate. + """ + if req.method != "tools/call" or app.get_request_handler("tools/list") is None: + return None + params = req.params or {} + name = params.get("name") + if not isinstance(name, str): + return None + raw_arguments = params.get("arguments") + if raw_arguments is not None and not isinstance(raw_arguments, Mapping): + return None + arguments: Mapping[str, Any] = cast("Mapping[str, Any]", raw_arguments) if raw_arguments is not None else {} + # ASGI guarantees lowercase header names, so no case-folding here. + if not arguments and not any(header.startswith(_MCP_PARAM_PREFIX_LOWER) for header in request.headers): + # No argument values and no `Mcp-Param-*` headers: no declaration can be violated either way. + return None + input_schema = await _tool_input_schema(app, request, req.id, verdict, lifespan_state, name) + if input_schema is None: + return None + return validate_mcp_param_headers(input_schema, arguments, request.headers) + + async def handle_modern_request( app: Server[Any], security_settings: TransportSecuritySettings | None, @@ -230,7 +360,8 @@ async def handle_modern_request( body = await request.body() try: decoded = json.loads(body) - except json.JSONDecodeError: + except (ValueError, RecursionError): + # Not just JSONDecodeError: oversized integer literals raise bare ValueError, deep nesting RecursionError. rej = JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error")) await _write(rej, scope, receive, send) return @@ -252,12 +383,21 @@ async def handle_modern_request( await _write(rej, scope, receive, send) return + duplicated = find_duplicated_routing_header(request.headers.items()) + if duplicated is not None: + # The raw carrier is the only place duplicates are visible; the classifier sees a folded mapping. + rejection = InboundLadderRejection(code=HEADER_MISMATCH, message=f"{duplicated} header appears more than once") + await _write_rejection(rejection, req.id, scope, receive, send) + return + verdict = classify_inbound_request(decoded, headers=dict(request.headers)) if isinstance(verdict, InboundLadderRejection): - rej = JSONRPCError( - jsonrpc="2.0", id=req.id, error=ErrorData(code=verdict.code, message=verdict.message, data=verdict.data) - ) - await _write(rej, scope, receive, send) + await _write_rejection(verdict, req.id, scope, receive, send) + return + + mcp_param_rejection = await _mcp_param_rejection(app, request, req, verdict, lifespan_state) + if mcp_param_rejection is not None: + await _write_rejection(mcp_param_rejection, req.id, scope, receive, send) return connection = Connection.from_envelope( diff --git a/src/mcp/shared/inbound.py b/src/mcp/shared/inbound.py index 3eb16495ee..a1baf0f6e6 100644 --- a/src/mcp/shared/inbound.py +++ b/src/mcp/shared/inbound.py @@ -14,7 +14,7 @@ import base64 import binascii import re -from collections.abc import Iterator, Mapping, Sequence +from collections.abc import Iterable, Iterator, Mapping, Sequence from dataclasses import dataclass from types import MappingProxyType from typing import Any, Final, cast @@ -49,8 +49,10 @@ "classify_inbound_request", "decode_header_value", "encode_header_value", + "find_duplicated_routing_header", "find_invalid_x_mcp_header", "mcp_param_headers", + "validate_mcp_param_headers", "x_mcp_header_map", ] @@ -161,18 +163,27 @@ def decode_header_value(value: str | None) -> str | None: Returns the value verbatim unless it carries the `=?base64?...?=` sentinel, in which case the payload is decoded as UTF-8. A malformed sentinel (bad - base64 or bad UTF-8) yields `None` so a corrupt header never matches a body - value by accident. `None` in → `None` out so callers can pass - `headers.get(...)` directly. + base64, non-canonical base64, or bad UTF-8) yields `None` so a corrupt + header never matches a body value by accident. `None` in → `None` out so + callers can pass `headers.get(...)` directly. """ if value is None: return None m = _B64_SENTINEL.fullmatch(value) if m is None: return value + payload = m.group("payload") try: - return base64.b64decode(m.group("payload"), validate=True).decode("utf-8") - except (binascii.Error, UnicodeDecodeError): + decoded = base64.b64decode(payload, validate=True) + except binascii.Error: + return None + # Reject non-canonical base64 (e.g. non-zero trailing bits), which + # `validate=True` tolerates; the encoder only ever emits canonical form. + if base64.b64encode(decoded).decode("ascii") != payload: + return None + try: + return decoded.decode("utf-8") + except UnicodeDecodeError: return None @@ -232,11 +243,33 @@ def x_mcp_header_map(input_schema: Any) -> dict[tuple[str, ...], str]: :func:`find_invalid_x_mcp_header` accepts; an invalid schema yields an undefined subset. """ - mapping: dict[tuple[str, ...], str] = {} + return {path: token for path, token, _ in _annotated_positions(input_schema)} + + +def _annotated_positions(input_schema: Any) -> Iterator[tuple[tuple[str, ...], str, dict[str, Any]]]: + """Yield `(path, token, schema)` for every statically-reachable `x-mcp-header` annotation. + + Shared by client emit and server validate so both ends agree on what counts as a declared header. + """ for path, schema in _walk_schema_positions(input_schema): - if path and isinstance(header := schema.get(X_MCP_HEADER_KEY), str): - mapping[path] = header - return mapping + if path and isinstance(token := schema.get(X_MCP_HEADER_KEY), str): + yield path, token, schema + + +def _render_header_scalar(value: Any) -> str | None: + """Render `value` the way the client mirrors it into a header, or `None` when no rendering exists. + + Shared by emit and validate so both sides agree on what is mirrorable: + non-primitives and ints beyond CPython's int-to-str digit limit are not. + """ + if isinstance(value, bool): + return "true" if value else "false" + if not isinstance(value, str | int | float): + return None + try: + return str(value) + except ValueError: + return None def mcp_param_headers(header_map: Mapping[tuple[str, ...], str], arguments: Mapping[str, Any]) -> dict[str, str]: @@ -247,14 +280,14 @@ def mcp_param_headers(header_map: Mapping[tuple[str, ...], str], arguments: Mapp `Mcp-Param-` carrying it: `bool` as `true`/`false`, other scalars via `str`, each passed through :func:`encode_header_value` so a non-token value is base64-wrapped. A path that hits a missing key or a non-mapping node is - skipped, matching the spec's "omit the header when no value is present". + skipped, matching the spec's "omit the header when no value is present", + as is a value with no header rendering. """ headers: dict[str, str] = {} for path, token in header_map.items(): value = _value_at_path(arguments, path) - if value is None: + if value is None or (rendered := _render_header_scalar(value)) is None: continue - rendered = ("true" if value else "false") if isinstance(value, bool) else str(value) headers[f"{MCP_PARAM_HEADER_PREFIX}{token}"] = encode_header_value(rendered) return headers @@ -313,6 +346,26 @@ class InboundLadderRejection: data: Any = None +_ROUTING_HEADER_NAMES: Final = frozenset({MCP_PROTOCOL_VERSION_HEADER, MCP_METHOD_HEADER, MCP_NAME_HEADER}) + + +def find_duplicated_routing_header(headers: Iterable[tuple[str, str]]) -> str | None: + """Name of a routing header supplied more than once in raw header lines, or `None`. + + Takes raw `(name, value)` pairs — a folded mapping hides duplicates. A + duplicate is rejected because first-copy and last-copy readers would + disagree. `Mcp-Param-*` duplicates are :func:`validate_mcp_param_headers`'s job. + """ + seen: set[str] = set() + for name, _ in headers: + key = name.lower() + if key in _ROUTING_HEADER_NAMES: + if key in seen: + return key + seen.add(key) + return None + + def classify_inbound_request( body: Mapping[str, Any], *, @@ -395,3 +448,104 @@ def classify_inbound_request( client_info=client_info, client_capabilities=client_capabilities, ) + + +# Header values eligible for the spec's numeric-comparison SHOULD; scientific +# notation never compares numerically (matching the typescript-sdk's gate). +_CANONICAL_DECIMAL = re.compile(r"^-?[0-9]+(\.[0-9]+)?$") + + +def _mcp_param_value_matches(prop_type: Any, value: Any, rendered: str, decoded: str) -> bool: + """True when a decoded `Mcp-Param-*` header value agrees with the body argument. + + Integer-typed declarations with an integral body value compare numerically + (`42` matches `42.0`, the spec's SHOULD) for canonical-decimal headers — + exact, no float round-trip, so values beyond the IEEE754 safe range still + compare. Anything else compares against `rendered`, the emit-side rendering. + """ + if ( + prop_type == "integer" + and not isinstance(value, bool) + and (isinstance(value, int) or (isinstance(value, float) and value.is_integer())) + and _CANONICAL_DECIMAL.fullmatch(decoded) is not None + ): + whole, _, fraction = decoded.partition(".") + if fraction and set(fraction) != {"0"}: + return False + try: + return int(whole) == int(value) + except ValueError: + return False + return decoded == rendered + + +def validate_mcp_param_headers( + input_schema: Any, + arguments: Mapping[str, Any], + headers: Mapping[str, str], +) -> InboundLadderRejection | None: + """Compare a `tools/call` request's `Mcp-Param-*` headers against its body arguments. + + Each annotated property's header and argument must agree: present together + and equal after sentinel decoding, or absent together (`null` counts as + absent). Returns the first failure as a `HEADER_MISMATCH` rejection, else `None`. + + A header whose argument is absent or unrenderable is deliberately rejected: + the spec's purpose clause is exactly an intermediary routing on a value the + body never carried. A duplicated recognized header is rejected — first-copy + and last-copy readers would disagree. A schema :func:`find_invalid_x_mcp_header` + rejects validates nothing: conforming clients drop the tool and emit no headers. + """ + if find_invalid_x_mcp_header(input_schema) is not None: + return None + folded: dict[str, str] = {} + duplicated: set[str] = set() + for name, value in headers.items(): + key = name.lower() + if key in folded: + duplicated.add(key) + folded[key] = value + for path, token, schema in _annotated_positions(input_schema): + header_name = f"{MCP_PARAM_HEADER_PREFIX}{token}" + key = header_name.lower() + raw = folded.get(key) + value = _value_at_path(arguments, path) + argument = ".".join(path) + if raw is not None and key in duplicated: + return InboundLadderRejection( + code=HEADER_MISMATCH, + message=f"{header_name} header appears more than once", + ) + if value is None: + if raw is not None: + return InboundLadderRejection( + code=HEADER_MISMATCH, + message=f"{header_name} header is present but the request body's {argument!r} argument is absent", + ) + continue + rendered = _render_header_scalar(value) + if rendered is None: + # Unrenderable value: a conforming client omitted the header, so one claiming it can never match. + if raw is not None: + return InboundLadderRejection( + code=HEADER_MISMATCH, + message=f"{header_name} header does not match the request body's {argument!r} argument", + ) + continue + if raw is None: + return InboundLadderRejection( + code=HEADER_MISMATCH, + message=f"{header_name} header is missing but the request body's {argument!r} argument is present", + ) + decoded = decode_header_value(raw) + if decoded is None: + return InboundLadderRejection( + code=HEADER_MISMATCH, + message=f"{header_name} header carries a malformed base64 sentinel value", + ) + if not _mcp_param_value_matches(schema.get("type"), value, rendered, decoded): + return InboundLadderRejection( + code=HEADER_MISMATCH, + message=f"{header_name} header does not match the request body's {argument!r} argument", + ) + return None diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py index f01502bcd5..7ba905306e 100644 --- a/tests/interaction/transports/test_hosting_http_modern.py +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -17,6 +17,7 @@ from inline_snapshot import snapshot from mcp_types import ( CLIENT_CAPABILITIES_META_KEY, + HEADER_MISMATCH, INTERNAL_ERROR, INVALID_PARAMS, METHOD_NOT_FOUND, @@ -475,12 +476,13 @@ async def test_modern_client_emits_no_param_headers_for_an_unlisted_tool() -> No The spec lets a client that lacks the tool's `inputSchema` send the request without custom headers. The call is made with no prior `list_tools`, so the first `tools/call` POST -- captured before the implicit output-schema `list_tools` runs -- has no cached annotations and emits no `Mcp-Param-*` header. + The server validates `Mcp-Param-*` against its own catalog and rejects as the spec's scenario table + requires for an omitted header (the relist-and-retry recovery is a SHOULD the client does not implement yet). """ requests: list[httpx.Request] = [] async def on_request(request: httpx.Request) -> None: - if json.loads(request.content)["method"] == "tools/call": - requests.append(request) + requests.append(request) discover = DiscoverResult( supported_versions=[LATEST_MODERN_VERSION], @@ -496,8 +498,12 @@ async def on_request(request: httpx.Request) -> None: prior_discover=discover, ) as client, ): - await client.call_tool("run", {"region": "us-west1"}) + with pytest.raises(MCPError) as excinfo: # pragma: no branch + await client.call_tool("run", {"region": "us-west1"}) + assert excinfo.value.error.code == HEADER_MISMATCH + assert len(requests) == 1 + assert json.loads(requests[0].content)["method"] == "tools/call" assert not any(k.startswith("mcp-param-") for k in requests[0].headers) @@ -513,11 +519,13 @@ async def test_modern_client_stops_mirroring_after_a_re_list_drops_the_tool() -> bad_schema = {"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "bad name"}}} valid = Tool(name="run", input_schema=schema) invalid = Tool(name="run", input_schema=bad_schema) - # Three pages: the call after the drop re-lists once because the prune also cleared `run`'s schema entry. - listings = iter([valid, invalid, invalid]) + # First listing valid, every later one invalid; the count is not pinned because the server also + # reads its own catalog on each tools/call. + listings: list[None] = [] async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: - return ListToolsResult(tools=[next(listings)], ttl_ms=0, cache_scope="public") + listings.append(None) + return ListToolsResult(tools=[valid if len(listings) == 1 else invalid], ttl_ms=0, cache_scope="public") async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: return CallToolResult(content=[TextContent(text="ok")]) diff --git a/tests/server/test_streamable_http_modern.py b/tests/server/test_streamable_http_modern.py index 6e8df458d1..17ea9eb435 100644 --- a/tests/server/test_streamable_http_modern.py +++ b/tests/server/test_streamable_http_modern.py @@ -23,6 +23,8 @@ METHOD_NOT_FOUND, PARSE_ERROR, PROTOCOL_VERSION_META_KEY, + CallToolRequestParams, + CallToolResult, ErrorData, JSONRPCError, JSONRPCResponse, @@ -36,7 +38,7 @@ from starlette.types import Message, Receive, Scope, Send from trio.testing import MockClock -from mcp.server import Server, ServerRequestContext, runner +from mcp.server import Server, ServerRequestContext, _streamable_http_modern, runner from mcp.server._streamable_http_modern import ( _SingleExchangeDispatchContext, _to_jsonrpc_response, @@ -647,3 +649,362 @@ async def send(message: Message) -> None: # pragma: no cover await cleanup_ran.wait() assert handler_started.is_set() + + +# --- Mcp-Param-* validation (SEP-2243 server half) ------------------------------- + + +_REGION_TOOL = Tool( + name="search", + input_schema={ + "type": "object", + "properties": {"region": {"type": "string", "x-mcp-header": "Region"}}, + }, +) + + +def _tool_call_body(arguments: dict[str, Any] | None = None, *, name: str | None = "search") -> dict[str, Any]: + """A valid 2026-07-28 `tools/call` body; `name=None` omits the name entirely.""" + body = _list_tools_body() + body["method"] = "tools/call" + if name is not None: + body["params"]["name"] = name + if arguments is not None: + body["params"]["arguments"] = arguments + return body + + +_TOOL_CALL_HEADERS = {MCP_METHOD_HEADER: "tools/call", MCP_NAME_HEADER: "search"} + + +async def _ok_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + return CallToolResult(content=[]) + + +def _x_mcp_server(tools: list[Tool] | None = None) -> Server[Any]: + """A lowlevel server whose `tools/list` handler advertises an `x-mcp-header` tool.""" + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=tools if tools is not None else [_REGION_TOOL], ttl_ms=0, cache_scope="public") + + return Server("test", on_list_tools=list_tools, on_call_tool=_ok_call_tool) + + +async def test_modern_tools_call_accepts_matching_mcp_param_header() -> None: + """A `Mcp-Param-*` header that agrees with the body argument after sentinel decoding dispatches normally.""" + async with _asgi_client(_x_mcp_server()) as http: + response = await http.post( + "/mcp", + json=_tool_call_body({"region": "Tōkyō"}), + headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "=?base64?VMWNa3nFjQ==?="}, + ) + assert response.status_code == 200 + assert response.json()["result"]["content"] == [] + + +@pytest.mark.parametrize("json_response", [True, False]) +async def test_modern_tools_call_rejects_mcp_param_mismatch_with_400_and_header_mismatch( + json_response: bool, +) -> None: + """Spec MUST: a header/body mismatch is HTTP 400 + `HEADER_MISMATCH`, plain JSON even in SSE mode.""" + async with _asgi_client(_x_mcp_server(), json_response=json_response) as http: + response = await http.post( + "/mcp", + json=_tool_call_body({"region": "us"}), + headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"}, + ) + assert response.status_code == 400 + assert response.headers["content-type"].split(";", 1)[0] == "application/json" + error = response.json()["error"] + assert error["code"] == HEADER_MISMATCH + assert "Mcp-Param-Region" in error["message"] + + +async def test_modern_tools_call_rejects_missing_mcp_param_header_for_present_argument() -> None: + """Spec table: a missing header for a present annotated argument MUST be rejected.""" + async with _asgi_client(_x_mcp_server()) as http: + response = await http.post("/mcp", json=_tool_call_body({"region": "test-value"}), headers=_TOOL_CALL_HEADERS) + assert response.status_code == 400 + assert response.json()["error"]["code"] == HEADER_MISMATCH + + +async def test_modern_tools_call_rejects_orphan_mcp_param_header() -> None: + """SDK posture: a header for an absent argument is the routing-spoof case the gate exists to stop.""" + async with _asgi_client(_x_mcp_server()) as http: + response = await http.post( + "/mcp", json=_tool_call_body({}), headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"} + ) + assert response.status_code == 400 + assert response.json()["error"]["code"] == HEADER_MISMATCH + + +async def test_modern_tools_call_skips_validation_without_a_tools_list_handler() -> None: + """Without a `tools/list` handler no annotations were ever advertised, so `Mcp-Param-*` headers are ignored.""" + server: Server[Any] = Server("test") + server.add_request_handler("tools/call", CallToolRequestParams, _ok_call_tool) + async with _asgi_client(server) as http: + response = await http.post( + "/mcp", + json=_tool_call_body({"region": "us"}), + headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"}, + ) + assert response.status_code == 200 + assert response.json()["result"]["content"] == [] + + +async def test_modern_tools_call_skips_validation_when_tool_not_listed_to_this_caller() -> None: + """A tool absent from this caller's listing was never advertised to it, so its headers go unvalidated.""" + other = Tool(name="other", input_schema={"type": "object"}) + async with _asgi_client(_x_mcp_server(tools=[other])) as http: + response = await http.post( + "/mcp", + json=_tool_call_body({"region": "us"}), + headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"}, + ) + assert response.status_code == 200 + + +async def test_modern_tools_call_skips_validation_when_list_handler_raises( + caplog: pytest.LogCaptureFixture, +) -> None: + """A raising `tools/list` handler fails open — validation is skipped and the skip logged at error level.""" + + async def broken_list(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + raise RuntimeError("catalog backend down") + + server: Server[Any] = Server("test", on_list_tools=broken_list, on_call_tool=_ok_call_tool) + with caplog.at_level(logging.ERROR, logger=_streamable_http_modern.__name__): + async with _asgi_client(server) as http: + response = await http.post( + "/mcp", + json=_tool_call_body({"region": "us"}), + headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"}, + ) + assert response.status_code == 200 + assert "Mcp-Param header validation skipped: the tools/list listing failed" in caplog.text + + +async def test_modern_tools_call_walks_pagination_to_find_the_tool() -> None: + """The schema lookup follows `nextCursor` pages; a tool on a later page is still validated.""" + cursors_seen: list[str | None] = [] + + async def paged_list(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + cursor = params.cursor if params is not None else None + cursors_seen.append(cursor) + if cursor is None: + return ListToolsResult(tools=[], next_cursor="page-2", ttl_ms=0, cache_scope="public") + return ListToolsResult(tools=[_REGION_TOOL], ttl_ms=0, cache_scope="public") + + server: Server[Any] = Server("test", on_list_tools=paged_list, on_call_tool=_ok_call_tool) + async with _asgi_client(server) as http: + response = await http.post( + "/mcp", + json=_tool_call_body({"region": "us"}), + headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"}, + ) + assert response.status_code == 400 + assert response.json()["error"]["code"] == HEADER_MISMATCH + assert cursors_seen == [None, "page-2"] + + +async def test_modern_tools_call_skips_validation_on_a_cursor_cycle( + caplog: pytest.LogCaptureFixture, +) -> None: + """A repeated cursor stops the walk with a logged skip instead of hanging the request.""" + + async def cycling_list(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[], next_cursor="loop", ttl_ms=0, cache_scope="public") + + server: Server[Any] = Server("test", on_list_tools=cycling_list, on_call_tool=_ok_call_tool) + with caplog.at_level(logging.WARNING, logger=_streamable_http_modern.__name__): + async with _asgi_client(server) as http: + response = await http.post( + "/mcp", + json=_tool_call_body({"region": "us"}), + headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"}, + ) + assert response.status_code == 200 + assert "cursor cycle" in caplog.text + + +async def test_modern_tools_call_skips_validation_at_the_pagination_cap( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """A non-terminating cursor chain stops at the page cap: logged skip, never a hang.""" + monkeypatch.setattr(_streamable_http_modern, "_MCP_PARAM_LIST_PAGE_CAP", 3) + pages = iter(range(1_000_000)) + + async def endless_list(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[], next_cursor=f"page-{next(pages)}", ttl_ms=0, cache_scope="public") + + server: Server[Any] = Server("test", on_list_tools=endless_list, on_call_tool=_ok_call_tool) + with caplog.at_level(logging.WARNING, logger=_streamable_http_modern.__name__): + async with _asgi_client(server) as http: + response = await http.post( + "/mcp", + json=_tool_call_body({"region": "us"}), + headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"}, + ) + assert response.status_code == 200 + assert "did not terminate within 3 pages" in caplog.text + + +async def test_modern_tools_call_threads_the_callers_envelope_into_the_synthetic_listing() -> None: + """The synthetic `tools/list` runs as this caller, so a visibility-scoped handler produces its view.""" + seen: list[Any] = [] + + async def recording_list(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + seen.append(ctx.session.client_params) + return ListToolsResult(tools=[_REGION_TOOL], ttl_ms=0, cache_scope="public") + + server: Server[Any] = Server("test", on_list_tools=recording_list, on_call_tool=_ok_call_tool) + async with _asgi_client(server) as http: + response = await http.post( + "/mcp", + json=_tool_call_body({"region": "eu"}), + headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"}, + ) + assert response.status_code == 200 + assert len(seen) == 1 + assert seen[0] is not None + assert seen[0].client_info.name == "raw" + + +async def test_modern_tools_call_leaves_mis_shaped_name_and_arguments_to_dispatch() -> None: + """A missing `name` or non-mapping `arguments` is dispatch's INVALID_PARAMS, never a header mismatch.""" + async with _asgi_client(_x_mcp_server()) as http: + nameless = await http.post("/mcp", json=_tool_call_body(name=None), headers={MCP_METHOD_HEADER: "tools/call"}) + bad_arguments_body = _tool_call_body() + bad_arguments_body["params"]["arguments"] = ["not", "a", "mapping"] + bad_arguments = await http.post( + "/mcp", + json=bad_arguments_body, + headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"}, + ) + assert nameless.json()["error"]["code"] == INVALID_PARAMS + assert bad_arguments.json()["error"]["code"] == INVALID_PARAMS + + +async def test_modern_tools_call_rejects_a_duplicated_mcp_param_header() -> None: + """A duplicated recognized header is rejected even if one copy matches: readers may disagree on which wins.""" + # An httpx header list with a repeated name reaches the ASGI scope as two raw header lines. + duplicated = httpx.Headers( + [*_TOOL_CALL_HEADERS.items(), ("mcp-param-region", "spoofed"), ("mcp-param-region", "eu")] + ) + async with _asgi_client(_x_mcp_server()) as http: + response = await http.post("/mcp", json=_tool_call_body({"region": "eu"}), headers=duplicated) + assert response.status_code == 400 + error = response.json()["error"] + assert error["code"] == HEADER_MISMATCH + assert "more than once" in error["message"] + + +async def test_modern_synthetic_listing_does_not_replay_caller_meta_extras() -> None: + """The rebuilt synthetic-listing envelope does not replay caller `_meta` extras like a progress token.""" + seen_metas: list[Any] = [] + + async def recording_list(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + assert params is not None + seen_metas.append(params.meta) + return ListToolsResult(tools=[_REGION_TOOL], ttl_ms=0, cache_scope="public") + + server: Server[Any] = Server("test", on_list_tools=recording_list, on_call_tool=_ok_call_tool) + body = _tool_call_body({"region": "eu"}) + body["params"]["_meta"]["progressToken"] = "tok-1" + async with _asgi_client(server) as http: + response = await http.post("/mcp", json=body, headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"}) + assert response.status_code == 200 + assert len(seen_metas) == 1 + assert seen_metas[0] is not None + assert "progressToken" not in seen_metas[0] + + +async def test_modern_post_rejects_a_duplicated_routing_header() -> None: + """A duplicated routing header (`Mcp-Name`) is unverifiable and rejected before the validation ladder runs.""" + duplicated = httpx.Headers( + [(MCP_METHOD_HEADER, "tools/call"), (MCP_NAME_HEADER, "search"), (MCP_NAME_HEADER, "admin-tool")] + ) + async with _asgi_client(_x_mcp_server()) as http: + response = await http.post("/mcp", json=_tool_call_body({"region": "eu"}), headers=duplicated) + assert response.status_code == 400 + error = response.json()["error"] + assert error["code"] == HEADER_MISMATCH + assert "more than once" in error["message"] + + +async def test_modern_tools_call_mis_shaped_envelope_skips_validation_without_an_error_log( + caplog: pytest.LogCaptureFixture, +) -> None: + """A mis-shaped envelope value is a debug-logged client fault; the wire reply is dispatch's INVALID_PARAMS.""" + body = _tool_call_body({"region": "eu"}) + body["params"]["_meta"][CLIENT_INFO_META_KEY] = "not-an-object" + with caplog.at_level(logging.DEBUG, logger=_streamable_http_modern.__name__): + async with _asgi_client(_x_mcp_server()) as http: + response = await http.post("/mcp", json=body, headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"}) + assert response.status_code == 400 + assert response.json()["error"]["code"] == INVALID_PARAMS + module_records = [r for r in caplog.records if r.name == _streamable_http_modern.__name__] + assert all(r.levelno < logging.ERROR for r in module_records) + assert any("fails tools/list validation" in r.message for r in module_records) + + +async def test_modern_tools_call_survives_a_middleware_short_circuit_with_a_mis_shaped_listing( + caplog: pytest.LogCaptureFixture, +) -> None: + """A mis-shaped `tools/list` result from a middleware short-circuit fails open instead of crashing to a 500.""" + + async def short_circuit(ctx: Any, call_next: Any) -> Any: + if ctx.method == "tools/list": + return {"tools": None} + return await call_next(ctx) + + server = _x_mcp_server() + server.middleware = [short_circuit] + with caplog.at_level(logging.ERROR, logger=_streamable_http_modern.__name__): + async with _asgi_client(server) as http: + response = await http.post( + "/mcp", + json=_tool_call_body({"region": "us"}), + headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"}, + ) + assert response.status_code == 200 + assert "Mcp-Param header validation skipped: the tools/list listing failed" in caplog.text + + +async def test_modern_post_with_an_oversized_integer_literal_is_parse_error_not_a_crash() -> None: + """An integer past CPython's digit limit makes json.loads raise a bare ValueError — still 400 + PARSE_ERROR.""" + body = b'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"a":' + b"1" * 5000 + b"}}" + async with _asgi_client(_x_mcp_server()) as http: + response = await http.post("/mcp", content=body, headers={"content-type": "application/json"}) + assert response.status_code == 400 + assert response.json()["error"]["code"] == PARSE_ERROR + + +async def test_modern_tools_call_logs_a_handler_raised_validation_error_loudly( + caplog: pytest.LogCaptureFixture, +) -> None: + """A ValidationError from inside the tools/list handler is a server fault: the skip is + logged at error level, not mistaken for a client-fault envelope.""" + + async def broken_list(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[Tool.model_validate({"bogus": 1})], ttl_ms=0, cache_scope="public") + + server: Server[Any] = Server("test", on_list_tools=broken_list, on_call_tool=_ok_call_tool) + with caplog.at_level(logging.ERROR, logger=_streamable_http_modern.__name__): + async with _asgi_client(server) as http: + response = await http.post( + "/mcp", + json=_tool_call_body({"region": "us"}), + headers=_TOOL_CALL_HEADERS | {"mcp-param-region": "eu"}, + ) + assert response.status_code == 200 + assert "Mcp-Param header validation skipped: the tools/list listing failed" in caplog.text + + +async def test_modern_post_with_deeply_nested_body_is_parse_error_not_a_crash() -> None: + """Deep nesting makes json.loads raise RecursionError; still an unparseable body: 400 + PARSE_ERROR.""" + body = b"[" * 100_000 + b"]" * 100_000 + async with _asgi_client(_x_mcp_server()) as http: + response = await http.post("/mcp", content=body, headers={"content-type": "application/json"}) + assert response.status_code == 400 + assert response.json()["error"]["code"] == PARSE_ERROR diff --git a/tests/shared/test_inbound.py b/tests/shared/test_inbound.py index 11e20d632c..8ba9cb9359 100644 --- a/tests/shared/test_inbound.py +++ b/tests/shared/test_inbound.py @@ -6,6 +6,7 @@ """ import dataclasses +from collections.abc import Iterator, Mapping from typing import Any import pytest @@ -39,8 +40,10 @@ classify_inbound_request, decode_header_value, encode_header_value, + find_duplicated_routing_header, find_invalid_x_mcp_header, mcp_param_headers, + validate_mcp_param_headers, x_mcp_header_map, ) @@ -574,3 +577,273 @@ def test_mcp_param_headers_omits_when_nested_path_is_broken() -> None: header_map = {("outer", "inner"): "Inner"} assert mcp_param_headers(header_map, {"outer": "not-a-mapping"}) == {} assert mcp_param_headers(header_map, {}) == {} + + +# --- validate_mcp_param_headers -------------------------------------------- + +REGION_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"region": {"type": "string", "x-mcp-header": "Region"}}, +} + + +@pytest.mark.parametrize( + ("argument", "header"), + [ + pytest.param("Hello", "Hello", id="plain-literal"), + pytest.param("Hello", "=?base64?SGVsbG8=?=", id="valid-sentinel"), + pytest.param("", "=?base64??=", id="empty-sentinel"), + pytest.param("SGVsbG8=", "SGVsbG8=", id="missing-prefix-is-literal"), + pytest.param("=?base64?SGVsbG8=", "=?base64?SGVsbG8=", id="missing-suffix-is-literal"), + ], +) +def test_validate_mcp_param_headers_accepts_agreeing_header_and_argument(argument: str, header: str) -> None: + """Spec Value Encoding: a fully-wrapped sentinel decodes before comparison; anything else is a literal.""" + assert validate_mcp_param_headers(REGION_SCHEMA, {"region": argument}, {"Mcp-Param-Region": header}) is None + + +@pytest.mark.parametrize( + "header", + [ + pytest.param("=?base64?SGVsbG8?=", id="missing-padding"), + pytest.param("=?base64?SGVs!!!bG8=?=", id="non-alphabet-chars"), + pytest.param("=?base64?SGVsbG9=?=", id="non-canonical-trailing-bits"), + pytest.param("=?base64?gA==?=", id="invalid-utf8"), + ], +) +def test_validate_mcp_param_headers_rejects_malformed_sentinel(header: str) -> None: + """Spec: servers MUST reject a recognized header whose sentinel cannot be strictly decoded — not a literal.""" + rejection = assert_rejected( + validate_mcp_param_headers(REGION_SCHEMA, {"region": "Hello"}, {"Mcp-Param-Region": header}), + HEADER_MISMATCH, + ) + assert "malformed base64" in rejection.message + + +def test_validate_mcp_param_headers_rejects_missing_header_for_present_argument() -> None: + """Spec table: client omits the header but the value is in the body → server MUST reject.""" + rejection = assert_rejected( + validate_mcp_param_headers(REGION_SCHEMA, {"region": "test-value"}, {}), HEADER_MISMATCH + ) + assert "missing" in rejection.message + + +@pytest.mark.parametrize( + "arguments", + [pytest.param({}, id="absent"), pytest.param({"region": None}, id="null")], +) +def test_validate_mcp_param_headers_rejects_orphan_header_for_absent_or_null_argument( + arguments: dict[str, Any], +) -> None: + """SDK-defined posture on a spec gap: an orphan header is the routing-spoof case; go rejects too, ts skips.""" + rejection = assert_rejected( + validate_mcp_param_headers(REGION_SCHEMA, arguments, {"Mcp-Param-Region": "eu"}), HEADER_MISMATCH + ) + assert "absent" in rejection.message + + +def test_validate_mcp_param_headers_rejects_value_mismatch() -> None: + rejection = assert_rejected( + validate_mcp_param_headers(REGION_SCHEMA, {"region": "us"}, {"Mcp-Param-Region": "eu"}), HEADER_MISMATCH + ) + assert "does not match" in rejection.message + + +def test_validate_mcp_param_headers_accepts_absent_argument_with_no_header() -> None: + """Spec table: parameter not in arguments / null → client MUST omit, server MUST NOT expect.""" + assert validate_mcp_param_headers(REGION_SCHEMA, {}, {}) is None + assert validate_mcp_param_headers(REGION_SCHEMA, {"region": None}, {}) is None + + +def test_validate_mcp_param_headers_matches_header_names_case_insensitively() -> None: + """Spec Case Sensitivity: header-name comparison MUST be case-insensitive.""" + assert validate_mcp_param_headers(REGION_SCHEMA, {"region": "eu"}, {"MCP-PARAM-REGION": "eu"}) is None + rejection = validate_mcp_param_headers(REGION_SCHEMA, {"region": "us"}, {"MCP-PARAM-REGION": "eu"}) + assert_rejected(rejection, HEADER_MISMATCH) + + +def test_validate_mcp_param_headers_ignores_undeclared_mcp_param_headers() -> None: + """Spec: an undeclared `Mcp-Param-*` header is unrecognized — forwarded and ignored, never a failure.""" + headers = {"Mcp-Param-Region": "eu", "Mcp-Param-Undeclared": "=?base64?not even base64?="} + assert validate_mcp_param_headers(REGION_SCHEMA, {"region": "eu"}, headers) is None + + +def test_validate_mcp_param_headers_validates_nothing_for_an_invalid_annotation_schema() -> None: + """Spec gives definition rejection to clients (drop the tool), so an invalid schema recognizes no headers.""" + invalid = { + "type": "object", + "properties": { + "region": {"type": "string", "x-mcp-header": "Region"}, + "dupe": {"type": "string", "x-mcp-header": "region"}, + }, + } + assert find_invalid_x_mcp_header(invalid) is not None + assert validate_mcp_param_headers(invalid, {"region": "us"}, {"Mcp-Param-Region": "eu"}) is None + + +def test_validate_mcp_param_headers_reads_nested_argument_paths() -> None: + """A nested annotated property compares against the matching nested `arguments` path; a broken path is absent.""" + schema = { + "type": "object", + "properties": { + "outer": {"type": "object", "properties": {"inner": {"type": "string", "x-mcp-header": "Inner"}}} + }, + } + assert validate_mcp_param_headers(schema, {"outer": {"inner": "deep"}}, {"Mcp-Param-Inner": "deep"}) is None + rejection = validate_mcp_param_headers(schema, {"outer": {"inner": "deep"}}, {"Mcp-Param-Inner": "other"}) + assert_rejected(rejection, HEADER_MISMATCH) + assert validate_mcp_param_headers(schema, {"outer": "not-a-mapping"}, {}) is None + + +def test_validate_mcp_param_headers_compares_booleans_against_true_false_rendering() -> None: + """Booleans compare against the lowercase `true`/`false` rendering the client emits.""" + schema = {"type": "object", "properties": {"flag": {"type": "boolean", "x-mcp-header": "Flag"}}} + assert validate_mcp_param_headers(schema, {"flag": True}, {"Mcp-Param-Flag": "true"}) is None + assert validate_mcp_param_headers(schema, {"flag": False}, {"Mcp-Param-Flag": "false"}) is None + rejection = validate_mcp_param_headers(schema, {"flag": True}, {"Mcp-Param-Flag": "True"}) + assert_rejected(rejection, HEADER_MISMATCH) + + +INTEGER_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"n": {"type": "integer", "x-mcp-header": "N"}}, +} + + +@pytest.mark.parametrize( + ("body_value", "header", "matches"), + [ + pytest.param(42, "42", True, id="exact"), + pytest.param(42, "42.0", True, id="trailing-zero-fraction"), + pytest.param(42, "42.000", True, id="long-zero-fraction"), + pytest.param(42, "42.5", False, id="real-fraction"), + pytest.param(42, "1e2", False, id="scientific-notation-never-numeric"), + pytest.param(42, "43", False, id="different-value"), + pytest.param(-7, "-7.0", True, id="negative"), + pytest.param(9007199254740993, "9007199254740993", True, id="beyond-ieee754-safe-range-exact"), + pytest.param(9007199254740993, "9007199254740992", False, id="beyond-ieee754-safe-range-off-by-one"), + ], +) +def test_validate_mcp_param_headers_compares_integers_numerically(body_value: int, header: str, matches: bool) -> None: + """Spec SHOULD: integers compare numerically (`42` == `42.0`) — gated to canonical decimals, compared exactly.""" + result = validate_mcp_param_headers(INTEGER_SCHEMA, {"n": body_value}, {"Mcp-Param-N": header}) + if matches: + assert result is None + else: + assert_rejected(result, HEADER_MISMATCH) + + +def test_validate_mcp_param_headers_non_primitive_body_value_rejects_only_when_a_header_claims_it() -> None: + """A header claiming a non-primitive argument is a mismatch; without one, rejection is params validation's job.""" + rejection = assert_rejected( + validate_mcp_param_headers(REGION_SCHEMA, {"region": {"k": "v"}}, {"Mcp-Param-Region": "x"}), + HEADER_MISMATCH, + ) + assert "does not match" in rejection.message + assert validate_mcp_param_headers(REGION_SCHEMA, {"region": {"k": "v"}}, {}) is None + assert validate_mcp_param_headers(REGION_SCHEMA, {"region": [1, 2]}, {}) is None + + +class _RepeatedHeaders(Mapping[str, str]): + """A header carrier whose `items()` yields duplicate names, like a raw HTTP header list.""" + + def __init__(self, pairs: list[tuple[str, str]]) -> None: + self._pairs = pairs + + def __getitem__(self, key: str) -> str: + return next(value for name, value in self._pairs if name == key) + + def __iter__(self) -> Iterator[str]: + return (name for name, _ in self._pairs) + + def __len__(self) -> int: + return len(self._pairs) + + def items(self) -> Any: + return list(self._pairs) + + +def test_validate_mcp_param_headers_rejects_a_recognized_header_supplied_more_than_once() -> None: + """Duplicate recognized headers reject even when one matches: first-copy readers and last-wins checks diverge.""" + headers = _RepeatedHeaders([("Mcp-Param-Region", "spoofed"), ("mcp-param-region", "eu")]) + # The carrier behaves like a raw header list: first-wins lookup, every line iterated. + assert headers["Mcp-Param-Region"] == "spoofed" + assert len(headers) == len(list(headers)) == 2 + rejection = assert_rejected(validate_mcp_param_headers(REGION_SCHEMA, {"region": "eu"}, headers), HEADER_MISMATCH) + assert "more than once" in rejection.message + noisy = _RepeatedHeaders([("Mcp-Param-Region", "eu"), ("Mcp-Param-Other", "a"), ("mcp-param-other", "b")]) + assert validate_mcp_param_headers(REGION_SCHEMA, {"region": "eu"}, noisy) is None + + +def test_validate_mcp_param_headers_rejects_a_header_exceeding_the_int_conversion_limit() -> None: + """A canonical-decimal header beyond CPython's int-conversion digit limit is a clean mismatch, never an error.""" + rejection = validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 1}, {"Mcp-Param-N": "1" * 5000}) + assert_rejected(rejection, HEADER_MISMATCH) + + +def test_validate_mcp_param_headers_compares_integral_float_bodies_numerically() -> None: + """JSON Schema admits `42.0` as an integer; the numeric SHOULD applies in both directions.""" + assert validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 42.0}, {"Mcp-Param-N": "42"}) is None + assert validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 42.0}, {"Mcp-Param-N": "42.0"}) is None + assert_rejected(validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 42.0}, {"Mcp-Param-N": "43"}), HEADER_MISMATCH) + # A genuinely fractional body value falls back to the exact string rendering. + assert validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 42.5}, {"Mcp-Param-N": "42.5"}) is None + + +@pytest.mark.parametrize( + "value", + [ + pytest.param("=?base64?SGVsbG9=?=", id="non-canonical-trailing-bits"), + pytest.param("=?base64?SGVsbG8?=", id="missing-padding"), + ], +) +def test_decode_header_value_returns_none_for_non_canonical_base64(value: str) -> None: + """Canonical base64 only: a payload that decodes but does not re-encode byte-identically is malformed.""" + assert decode_header_value(value) is None + + +def test_validate_mcp_param_headers_union_typed_annotation_invalidates_the_whole_tool() -> None: + """A union-typed annotation fails the integer/string/boolean-only rule, so the whole schema validates nothing.""" + union_schema = { + "type": "object", + "properties": {"n": {"type": ["integer", "null"], "x-mcp-header": "N"}}, + } + assert find_invalid_x_mcp_header(union_schema) is not None + assert validate_mcp_param_headers(union_schema, {"n": 42}, {"Mcp-Param-N": "999"}) is None + assert validate_mcp_param_headers(union_schema, {"n": 42}, {}) is None + + +def test_validate_mcp_param_headers_accepts_the_clients_own_rendering_of_large_integral_floats() -> None: + """A non-canonical-decimal header falls back to rendered comparison, so the client's own mirroring round-trips.""" + emitted = mcp_param_headers(x_mcp_header_map(INTEGER_SCHEMA), {"n": 1e16}) + assert emitted == {"Mcp-Param-N": "1e+16"} + assert validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 1e16}, emitted) is None + assert_rejected(validate_mcp_param_headers(INTEGER_SCHEMA, {"n": 42}, {"Mcp-Param-N": "1e2"}), HEADER_MISMATCH) + + +def test_validate_mcp_param_headers_handles_unrenderable_huge_integer_bodies_without_raising() -> None: + """An integer beyond CPython's int-to-str digit limit has no rendering: claimed → mismatch, unclaimed → fine.""" + huge = 10**5000 + rejection = validate_mcp_param_headers(REGION_SCHEMA, {"region": huge}, {"Mcp-Param-Region": "x"}) + assert_rejected(rejection, HEADER_MISMATCH) + assert validate_mcp_param_headers(REGION_SCHEMA, {"region": huge}, {}) is None + assert validate_mcp_param_headers(INTEGER_SCHEMA, {"n": huge}, {}) is None + + +def test_mcp_param_headers_omits_values_with_no_scalar_rendering() -> None: + """Objects, arrays, and over-limit integers have no scalar rendering, so the client omits the header.""" + header_map = {("v",): "Val"} + assert mcp_param_headers(header_map, {"v": {"k": 1}}) == {} + assert mcp_param_headers(header_map, {"v": [1, 2]}) == {} + assert mcp_param_headers(header_map, {"v": 10**5000}) == {} + + +def test_find_duplicated_routing_header_detects_repeats_of_routing_headers_only() -> None: + """Repeated routing headers report case-insensitively; `Mcp-Param-*` or unrelated repeats are ignored.""" + assert find_duplicated_routing_header([("Mcp-Name", "a"), ("mcp-name", "b")]) == MCP_NAME_HEADER + assert find_duplicated_routing_header([("MCP-Protocol-Version", "x"), ("mcp-protocol-version", "x")]) == ( + MCP_PROTOCOL_VERSION_HEADER + ) + assert find_duplicated_routing_header([("Mcp-Method", "a"), ("Mcp-Method", "a")]) == MCP_METHOD_HEADER + assert find_duplicated_routing_header([("Mcp-Name", "a"), ("Mcp-Param-X", "1"), ("Mcp-Param-X", "2")]) is None + assert find_duplicated_routing_header([("accept", "a"), ("accept", "b")]) is None From ca10dade2c68ec8946bd181d0969ad1719f9353b Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:01:04 +0100 Subject: [PATCH 037/100] Serve subscriptions/listen with a pluggable event bus (SEP-2575) (#3035) --- .../expected-failures.2026-07-28.yml | 5 +- .../actions/conformance/expected-failures.yml | 10 - docs/advanced/low-level-server.md | 1 + docs/advanced/subscriptions.md | 88 ++++ docs/client/index.md | 2 +- docs/tutorial/context.md | 2 + docs/tutorial/first-steps.md | 2 +- docs_src/subscriptions/__init__.py | 0 docs_src/subscriptions/tutorial001.py | 28 ++ docs_src/subscriptions/tutorial002.py | 40 ++ .../mcp_everything_server/server.py | 30 +- examples/stories/README.md | 2 +- examples/stories/manifest.toml | 7 +- examples/stories/subscriptions/README.md | 69 ++- examples/stories/subscriptions/__init__.py | 0 examples/stories/subscriptions/client.py | 97 ++++ examples/stories/subscriptions/server.py | 41 ++ .../stories/subscriptions/server_lowlevel.py | 72 +++ mkdocs.yml | 1 + src/mcp/server/_streamable_http_modern.py | 12 +- src/mcp/server/lowlevel/server.py | 29 +- src/mcp/server/mcpserver/context.py | 46 +- src/mcp/server/mcpserver/prompts/manager.py | 6 + src/mcp/server/mcpserver/server.py | 30 +- src/mcp/server/subscriptions.py | 296 +++++++++++ tests/docs_src/test_client.py | 7 +- tests/docs_src/test_first_steps.py | 6 +- tests/docs_src/test_subscriptions.py | 138 +++++ tests/server/lowlevel/test_server_discover.py | 85 +++- tests/server/mcpserver/test_server.py | 89 ++++ tests/server/test_streamable_http_modern.py | 74 +++ tests/server/test_subscriptions.py | 473 ++++++++++++++++++ 32 files changed, 1722 insertions(+), 66 deletions(-) create mode 100644 docs/advanced/subscriptions.md create mode 100644 docs_src/subscriptions/__init__.py create mode 100644 docs_src/subscriptions/tutorial001.py create mode 100644 docs_src/subscriptions/tutorial002.py create mode 100644 examples/stories/subscriptions/__init__.py create mode 100644 examples/stories/subscriptions/client.py create mode 100644 examples/stories/subscriptions/server.py create mode 100644 examples/stories/subscriptions/server_lowlevel.py create mode 100644 src/mcp/server/subscriptions.py create mode 100644 tests/docs_src/test_subscriptions.py create mode 100644 tests/server/test_subscriptions.py diff --git a/.github/actions/conformance/expected-failures.2026-07-28.yml b/.github/actions/conformance/expected-failures.2026-07-28.yml index 5b19d6d2d2..504b463856 100644 --- a/.github/actions/conformance/expected-failures.2026-07-28.yml +++ b/.github/actions/conformance/expected-failures.2026-07-28.yml @@ -22,7 +22,4 @@ client: [] -server: - # SEP-2575 subscriptions/listen is not implemented yet; see the matching - # entry in expected-failures.yml for the full rationale. - - server-stateless +server: [] diff --git a/.github/actions/conformance/expected-failures.yml b/.github/actions/conformance/expected-failures.yml index 6d99fba750..aa31cb757b 100644 --- a/.github/actions/conformance/expected-failures.yml +++ b/.github/actions/conformance/expected-failures.yml @@ -13,16 +13,6 @@ client: [] server: - # SEP-2575 subscriptions/listen is not implemented yet. The everything- - # server's legacy resources/subscribe handlers make it advertise - # `resources.subscribe` in server/discover, and as of conformance #372 a - # server that advertises a subscription capability but answers - # subscriptions/listen with -32601 fails the three listen MUST checks - # ("Not testable") instead of skipping them. Remove this entry when the - # listen runtime lands. NOTE: while listed, this entry also masks new - # failures in the scenario's other 25 (currently passing) checks — the - # baseline is per-scenario, not per-check. - - server-stateless # SEP-2663 (io.modelcontextprotocol/tasks): the SDK does not implement the # tasks extension yet. These extension-tagged scenarios are selected only by # the bare `--suite all` leg — extension scenarios never match a diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index 6568b76a55..123c85dd7d 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -183,6 +183,7 @@ Each of these is one idea you now have the vocabulary for; each has its own chap * `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](multi-round-trip.md)**. True to this tier, nothing is installed for you: where `MCPServer` seals `requestState` by default, here the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`: one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` performs (**[Protecting `requestState`](multi-round-trip.md#protecting-requeststate)**). * `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` are the same `(ctx, params) -> result` shape for the other primitives. +* `on_subscriptions_listen` serves the 2026-07-28 `subscriptions/listen` stream. Pass a `ListenHandler` built over a `SubscriptionBus` and publish events to the bus from your other handlers; see **[Subscriptions](subscriptions.md)** for the full composition. * `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **[Running your server](../run/index.md)** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story. ## Recap diff --git a/docs/advanced/subscriptions.md b/docs/advanced/subscriptions.md new file mode 100644 index 0000000000..46014ef772 --- /dev/null +++ b/docs/advanced/subscriptions.md @@ -0,0 +1,88 @@ +# Subscriptions + +A server's catalog is not fixed. Tools get registered at runtime, resources change behind their URIs. The client side of that story is a subscription: on the 2026-07-28 protocol, a client that wants to hear about changes sends one `subscriptions/listen` request, and the response to that request *is* the stream — it stays open, carrying exactly the notification kinds the client asked for. + +Your side of it is one line: publish the change. + +```python title="server.py" hl_lines="16 27" +--8<-- "docs_src/subscriptions/tutorial001.py" +``` + +* `await ctx.notify_resource_updated("note://todo")` delivers `notifications/resources/updated` to every open listen stream that subscribed to that URI. Not to anyone else. +* `await ctx.notify_tools_changed()` delivers `notifications/tools/list_changed` to every stream that asked for tool-list changes. A client that receives it calls `tools/list` again — and now sees `search`. +* The siblings are `notify_prompts_changed()` and `notify_resources_changed()`, for the other two list-changed kinds. +* No subscribers, no work: publishing to an idle server is a no-op. You don't check whether anyone is listening; you state what changed. + +The SDK serves `subscriptions/listen` for you — `MCPServer` registers the handler at construction, and the wire obligations (the acknowledgment as the first frame, the per-stream filtering, the subscription id tagged onto every frame) are its job, not yours. + +!!! check + On the wire, a stream whose filter named `note://todo` looks like this after `edit_note` runs: + + ```json + {"method": "notifications/subscriptions/acknowledged", + "params": {"notifications": {"resourceSubscriptions": ["note://todo"]}, "_meta": {"io.modelcontextprotocol/subscriptionId": 7}}} + + {"method": "notifications/resources/updated", + "params": {"uri": "note://todo", "_meta": {"io.modelcontextprotocol/subscriptionId": 7}}} + ``` + + The acknowledgment echoes the filter the server agreed to honor, and every frame carries the + listen request's JSON-RPC id under `_meta` — that id *is* the subscription id. + +## Only what was asked for + +The filter is a contract. A stream that requested tool-list changes and one resource URI receives those two kinds and nothing else — publish a prompt change and that stream stays silent. Resource URIs are matched as exact strings: `note://todo` does not cover `note://todo/draft`. + +!!! warning + Filters are honored without per-client authorization: any client may name any URI — + including one it cannot read — and will receive update notifications for it (resource + existence and change timing, never content). On a multi-tenant server, don't publish + sensitive per-user URIs through `notify_resource_updated`, or serve the method with + your own handler on the low-level `Server` and narrow the filter there before acking — + the honored subset exists in the protocol precisely so servers can do this. + +Two more things the stream is *not*: + +* **It is not a replay log.** A dropped stream is gone; events published while nobody was connected are not queued. The client's contract is to re-listen and re-fetch what it cares about. +* **It is not the 2025 path.** Clients on earlier protocol versions that called `resources/subscribe` are served by `ctx.session.send_resource_updated(uri)` — the `notify_*` methods reach `subscriptions/listen` streams only. + +## One process is the default. More takes a bus + +Publishes travel from your handler to the open streams over a `SubscriptionBus`. The default is in-memory: one process, every stream in it. That is the right answer until you run replicas behind a load balancer — then a client's stream is pinned to one replica, and a publish on another replica has to reach it. + +That seam is yours to implement: two methods over your pub/sub backend. + +```python +class RedisSubscriptionBus: + async def publish(self, event: ServerEvent) -> None: + await self.redis.publish("mcp-events", encode(event)) # to every replica + + def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]: + ... # register the local listener; a reader task calls it for arriving events +``` + +```python +mcp = MCPServer("Notebook", subscriptions=RedisSubscriptionBus(...)) +``` + +The bus carries typed `ServerEvent` values — four small dataclasses — never JSON-RPC. Stamping, filtering, and stream lifecycles stay in the SDK, so a bus implementation cannot break the protocol; it can only move events between processes. To publish from outside a request, keep a reference to the bus you constructed and `await bus.publish(ToolsListChanged())` — the server holds the same instance. + +## The low-level composition + +Down on the low-level `Server` there is no pre-wired anything — and the same parts assemble in three lines: + +```python title="server.py" hl_lines="9 31 39" +--8<-- "docs_src/subscriptions/tutorial002.py" +``` + +* You own the bus, so you publish to it directly: `await bus.publish(ResourceUpdated(uri=...))`. Put it wherever your handlers can reach it — module scope here, the lifespan in a bigger app. +* `ListenHandler(bus)` is the same handler `MCPServer` registers; `on_subscriptions_listen=` is an ordinary handler slot. Don't want the SDK's semantics? Write your own handler for the slot — the spec obligations come with it. +* `ListenHandler.close()` gracefully ends every open stream: each one receives the listen request's result as its final frame, the spec's signal that the server ended the subscription deliberately — a clean end, as opposed to the abrupt drop a client may treat as a cue to reconnect. Without it, streams end when the client disconnects. + +## Recap + +* A client opts in with one `subscriptions/listen` request; the response is the stream. There is nothing to configure server-side — serving it is built in. +* You publish: `await ctx.notify_resource_updated(uri)`, `notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`. Idle servers make these free. +* Streams receive only what their filter requested; URIs match exactly; nothing is replayed. +* Scaling out means implementing `SubscriptionBus` — two methods — over your own pub/sub, and passing it as `MCPServer(subscriptions=...)`. +* The low-level spelling is the same machinery held in your hands: a bus, `ListenHandler(bus)`, one constructor argument. diff --git a/docs/client/index.md b/docs/client/index.md index a8026b5b90..7712e0620f 100644 --- a/docs/client/index.md +++ b/docs/client/index.md @@ -145,7 +145,7 @@ The resource verbs come in pairs: two ways to list, one way to read. `read_resource` returns `contents`, a list of `TextResourceContents` or `BlobResourceContents`. Same idea as tool content: narrow with `isinstance`, then read `.text` (or `.blob`). -A client can also **subscribe** to a resource and be told when it changes: `subscribe_resource(uri)` and `unsubscribe_resource(uri)`, same shape as everything else here. `MCPServer` doesn't implement that half. It says so up front (`server_capabilities.resources.subscribe` is `False`) and answers the request with an `MCPError`: `-32601`, *Method not found*. A server that does support subscriptions is built on the low-level `Server` (**[The low-level Server](../advanced/low-level-server.md)**). +A client can also be told when a resource changes. On 2025-era connections that is `subscribe_resource(uri)` / `unsubscribe_resource(uri)` - a method pair `MCPServer` doesn't implement, so on the 2026-07-28 wire (where those verbs no longer exist) the request answers `-32601`, *Method not found*. The 2026 replacement is a `subscriptions/listen` stream, which `MCPServer` *does* serve - `server_capabilities.resources.subscribe` is `True` there, and the server side of the story is **[Subscriptions](../advanced/subscriptions.md)**. ## Prompts diff --git a/docs/tutorial/context.md b/docs/tutorial/context.md index c2d43c4726..96bdd0776e 100644 --- a/docs/tutorial/context.md +++ b/docs/tutorial/context.md @@ -104,6 +104,8 @@ What a server offers is not fixed at import time. Register a tool at runtime, th The siblings are `send_resource_list_changed()`, `send_prompt_list_changed()`, and `send_resource_updated(uri)` for a change to one specific resource. +On a 2026-07-28 connection, clients receive change notifications only on a `subscriptions/listen` stream they opened — the `send_*` methods above do not reach those streams. The `Context` publish methods — `await ctx.notify_tools_changed()`, `await ctx.notify_prompts_changed()`, `await ctx.notify_resources_changed()`, and `await ctx.notify_resource_updated(uri)` — deliver to every subscribed stream at once. The whole story, including scaling out across replicas, is in **[Subscriptions](../advanced/subscriptions.md)**. + !!! check Before anyone runs `enable_recommendations`, the tool you are promising does not exist. Call it anyway and the result is an error the model can read: diff --git a/docs/tutorial/first-steps.md b/docs/tutorial/first-steps.md index ba59c64870..5328d12be4 100644 --- a/docs/tutorial/first-steps.md +++ b/docs/tutorial/first-steps.md @@ -97,7 +97,7 @@ asyncio.run(main()) ``` ```text -{'prompts': {'list_changed': False}, 'resources': {'subscribe': False, 'list_changed': False}, 'tools': {'list_changed': False}} +{'prompts': {'list_changed': True}, 'resources': {'subscribe': True, 'list_changed': True}, 'tools': {'list_changed': True}} ``` That dictionary is the server's half of the handshake: diff --git a/docs_src/subscriptions/__init__.py b/docs_src/subscriptions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/subscriptions/tutorial001.py b/docs_src/subscriptions/tutorial001.py new file mode 100644 index 0000000000..5063fceed4 --- /dev/null +++ b/docs_src/subscriptions/tutorial001.py @@ -0,0 +1,28 @@ +from mcp.server.mcpserver import Context, MCPServer + +mcp = MCPServer("Notebook") + +NOTES = {"todo": "buy milk", "journal": "day one"} + + +@mcp.resource("note://{name}") +def note(name: str) -> str: + return NOTES[name] + + +@mcp.tool() +async def edit_note(name: str, text: str, ctx: Context) -> str: + NOTES[name] = text + await ctx.notify_resource_updated(f"note://{name}") + return "saved" + + +def search(query: str) -> list[str]: + return [name for name, text in NOTES.items() if query in text] + + +@mcp.tool() +async def enable_search(ctx: Context) -> str: + mcp.add_tool(search) + await ctx.notify_tools_changed() + return "search is live" diff --git a/docs_src/subscriptions/tutorial002.py b/docs_src/subscriptions/tutorial002.py new file mode 100644 index 0000000000..c0b04f64db --- /dev/null +++ b/docs_src/subscriptions/tutorial002.py @@ -0,0 +1,40 @@ +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, ResourceUpdated + +bus = InMemorySubscriptionBus() + +NOTES = {"todo": "buy milk"} + +EDIT_NOTE_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"name": {"type": "string"}, "text": {"type": "string"}}, + "required": ["name", "text"], +} + + +async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[types.Tool(name="edit_note", description="Replace a note's text.", input_schema=EDIT_NOTE_SCHEMA)] + ) + + +async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + args = params.arguments or {} + NOTES[args["name"]] = args["text"] + await bus.publish(ResourceUpdated(uri=f"note://{args['name']}")) + return types.CallToolResult(content=[types.TextContent(type="text", text="saved")]) + + +server = Server( + "notebook", + on_list_tools=list_tools, + on_call_tool=call_tool, + on_subscriptions_listen=ListenHandler(bus), +) diff --git a/examples/servers/everything-server/mcp_everything_server/server.py b/examples/servers/everything-server/mcp_everything_server/server.py index 218188f50a..90dc1f64f0 100644 --- a/examples/servers/everything-server/mcp_everything_server/server.py +++ b/examples/servers/everything-server/mcp_everything_server/server.py @@ -13,7 +13,7 @@ import click from mcp.server import ServerRequestContext from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity -from mcp.server.mcpserver.prompts.base import UserMessage +from mcp.server.mcpserver.prompts.base import Prompt, UserMessage from mcp.server.streamable_http import EventCallback, EventMessage, EventStore from mcp.shared.exceptions import MCPError from mcp_types import ( @@ -585,6 +585,34 @@ async def test_reconnection(ctx: Context) -> str: return "Reconnection test completed" +def _dynamic_tool() -> str: + """A tool registered and removed by test_trigger_tool_change.""" + return "dynamic" + + +def _dynamic_prompt() -> str: + """A prompt registered and removed by test_trigger_prompt_change.""" + return "dynamic" + + +@mcp.tool() +async def test_trigger_tool_change(ctx: Context) -> str: + """Mutates the tool list and announces it to subscriptions/listen streams (SEP-2575)""" + mcp.add_tool(_dynamic_tool, name="test_dynamic_tool") + mcp.remove_tool("test_dynamic_tool") + await ctx.notify_tools_changed() + return "tool list changed" + + +@mcp.tool() +async def test_trigger_prompt_change(ctx: Context) -> str: + """Mutates the prompt list and announces it to subscriptions/listen streams (SEP-2575)""" + mcp.add_prompt(Prompt.from_function(_dynamic_prompt, name="test_dynamic_prompt", description="dynamic")) + mcp.remove_prompt("test_dynamic_prompt") + await ctx.notify_prompts_changed() + return "prompt list changed" + + # Resources @mcp.resource("test://static-text") def static_text_resource() -> str: diff --git a/examples/stories/README.md b/examples/stories/README.md index 79d7143110..ed8f1dd9b8 100644 --- a/examples/stories/README.md +++ b/examples/stories/README.md @@ -148,6 +148,7 @@ opens with a banner saying what replaces it. | [`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 | +| [`subscriptions`](subscriptions/) | `subscriptions/listen` streams: `ctx.notify_*`, `SubscriptionBus`, `ListenHandler` | current | | [`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 | @@ -155,7 +156,6 @@ opens with a banner saying what replaces it. | [`identity_assertion`](identity_assertion/) | SEP-990 enterprise IdP flow: present an ID-JAG under the `jwt-bearer` grant | current | | **— deferred (README only) —** | | | | [`caching`](caching/) | `CacheableResult` ttl/scope hints; client honouring | not yet implemented | -| [`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) | diff --git a/examples/stories/manifest.toml b/examples/stories/manifest.toml index 1ba2fe862a..965e04aa4f 100644 --- a/examples/stories/manifest.toml +++ b/examples/stories/manifest.toml @@ -68,6 +68,12 @@ lowlevel = false transports = ["in-memory", "http-asgi"] era = "dual-in-body" +[story.subscriptions] +# subscriptions/listen exists only on the 2026 wire, so there is no legacy leg. +# The listen request parks for the stream's lifetime; the client ends it by +# cancelling the awaiting scope (the spec's client-side close). +era = "modern" + [story.schema_validators] [story.middleware] @@ -166,7 +172,6 @@ fixed_port = 8000 # issuer/PRM metadata bake in :8 [deferred] caching = "client honouring + per-result override unlanded" -subscriptions = "#2901 — Client.listen / ServerEventBus" tasks = "SEP-2663 — tasks extension runtime (server-decided augmentation, CreateTaskResult)" skills = "#2896 — SEP-2640" events = "#2901 + #2896" diff --git a/examples/stories/subscriptions/README.md b/examples/stories/subscriptions/README.md index d41d0f82ba..22b947ba3e 100644 --- a/examples/stories/subscriptions/README.md +++ b/examples/stories/subscriptions/README.md @@ -1,27 +1,60 @@ # 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 is in this base — -[#2967](https://github.com/modelcontextprotocol/python-sdk/pull/2967) -(`ae13ede`) added the lowlevel `on_subscriptions_listen` handler slot — but -there is no `Client.listen()` or `ServerEventBus` yet. The runnable story is -deliberately a follow-up PR to keep this one reviewable. +Server-originated change notifications on the 2026-07-28 protocol. A client +opens one `subscriptions/listen` request whose response **is** the stream; the +server publishes with `ctx.notify_resource_updated(uri)` / +`ctx.notify_tools_changed()` and the SDK does the wire work (ack-first, +per-stream filtering, subscription-id tagging). Replaces the handshake-era +`resources/subscribe` + standalone-GET notification path. + +The client edits a note it did not subscribe to (silence), edits the one it +did (a tagged `notifications/resources/updated`), registers a tool at runtime +(`notifications/tools/list_changed`, then re-lists and calls it), and finally +stops listening - cancelling the parked request releases the local task, and +closing the connection ends the stream server-side. + +## Run it + +```bash +# HTTP — the client self-hosts the server on a free port, runs, then tears it +# down (subscriptions/listen is 2026-era only) +uv run python -m stories.subscriptions.client --http +# same, against the lowlevel-API server variant +uv run python -m stories.subscriptions.client --http --server server_lowlevel +``` + +## What to look at + +- `client.py` — stream frames arrive as ordinary server notifications via the + constructor-only `message_handler=`. There is no client-side listen API yet, + so opening the stream drops to the `client.session` escape hatch; the request + parks for the stream's lifetime. Cancelling it releases the local task; over + HTTP the server-side stream ends when the connection closes. Every frame's + `_meta["io.modelcontextprotocol/subscriptionId"]` is the listen request's + JSON-RPC id. +- `server.py` — publishing is one `await ctx.notify_*()` line per change; the + filter, the tagging, and the ack ordering are the SDK's job. Publishing with + no subscribers is a no-op. +- `server_lowlevel.py` — the same machinery held by hand: an + `InMemorySubscriptionBus`, handlers that `await bus.publish(...)`, and + `ListenHandler(bus)` passed as `on_subscriptions_listen=`. A multi-replica + deployment swaps the bus for one backed by its own pub/sub + (`MCPServer(subscriptions=...)` on the high-level server). + +## Caveats + +- 2026-era only: on a 2025 connection the method does not exist (clients there + use `resources/subscribe` and unsolicited notifications instead), so the + story pins the modern era and has no legacy leg. +- No replay: events published while no stream is open are not queued. The + contract after a dropped stream is re-listen and re-fetch. ## 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). +`streaming/` (request-scoped notifications), `events/` (the events extension +on top of this channel, deferred), and `docs/advanced/subscriptions.md` (the +narrative version). diff --git a/examples/stories/subscriptions/__init__.py b/examples/stories/subscriptions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/stories/subscriptions/client.py b/examples/stories/subscriptions/client.py new file mode 100644 index 0000000000..379d69bc65 --- /dev/null +++ b/examples/stories/subscriptions/client.py @@ -0,0 +1,97 @@ +"""Open a `subscriptions/listen` stream, watch one URI and the tool list, then close it.""" + +import anyio +import mcp_types as types + +from mcp.client import Client +from stories._harness import Target, run_client + +SUBSCRIPTION_ID = "io.modelcontextprotocol/subscriptionId" + + +async def main(target: Target, *, mode: str = "auto") -> None: + # Stream frames arrive as ordinary server notifications; `message_handler` + # is constructor-only on `Client`, so the list it fills exists first. + received: list[types.ServerNotification] = [] + arrival = anyio.Event() + + async def on_message(message: object) -> None: + nonlocal arrival + if isinstance( + message, + types.SubscriptionsAcknowledgedNotification + | types.ResourceUpdatedNotification + | types.ToolListChangedNotification, + ): + received.append(message) + arrival.set() + arrival = anyio.Event() + + async def wait_for(count: int) -> None: + with anyio.fail_after(10): + while len(received) < count: + await arrival.wait() + + async with Client(target, mode=mode, message_handler=on_message) as client: + before = await client.list_tools() + assert "search" not in {tool.name for tool in before.tools} + + async with anyio.create_task_group() as tg: + # There is no client-side listen API yet, so the story drops to the + # `client.session` escape hatch. The request parks for the stream's + # lifetime, so it runs as a task; cancelling it releases the local + # awaiting scope. In-memory that also ends the server's stream; over + # HTTP today nothing aborts the POST, so the server-side stream ends + # when the connection closes (the `Client` exit right below). + async def listen() -> None: + request = types.SubscriptionsListenRequest( + params=types.SubscriptionsListenRequestParams( + notifications=types.SubscriptionFilter( + tools_list_changed=True, resource_subscriptions=["note://todo"] + ) + ) + ) + await client.session.send_request(request, types.SubscriptionsListenResult) + + tg.start_soon(listen) + + # ── the ack is the first frame: it echoes the honored filter, tagged ── + await wait_for(1) + ack = received[0] + assert isinstance(ack, types.SubscriptionsAcknowledgedNotification), ack + assert ack.params.notifications.tools_list_changed is True + assert ack.params.notifications.resource_subscriptions == ["note://todo"] + assert ack.params.meta is not None and SUBSCRIPTION_ID in ack.params.meta + + # ── exact-URI filtering: an unsubscribed note edit stays silent ── + await client.call_tool("edit_note", {"name": "journal", "text": "day two"}) + # ── the subscribed URI delivers, carrying the same subscription id ── + await client.call_tool("edit_note", {"name": "todo", "text": "water plants"}) + await wait_for(2) + updated = received[1] + assert isinstance(updated, types.ResourceUpdatedNotification), updated + assert updated.params.uri == "note://todo" + assert updated.params.meta is not None + assert updated.params.meta[SUBSCRIPTION_ID] == ack.params.meta[SUBSCRIPTION_ID] + assert len(received) == 2, "the journal edit must not have been delivered" + + # ── a runtime tool registration announces itself ── + await client.call_tool("enable_search", {}) + await wait_for(3) + assert isinstance(received[2], types.ToolListChangedNotification), received[2] + + # The client is done listening: cancel the parked request and let + # the connection teardown below end the stream server-side. + tg.cancel_scope.cancel() + + # list_changed told us to re-fetch - the new tool is callable, and the + # session outlives the closed stream. + tools = await client.list_tools() + assert "search" in {tool.name for tool in tools.tools} + result = await client.call_tool("search", {"query": "water"}) + content = result.content[0] + assert isinstance(content, types.TextContent) and content.text == "todo", result + + +if __name__ == "__main__": + run_client(main) diff --git a/examples/stories/subscriptions/server.py b/examples/stories/subscriptions/server.py new file mode 100644 index 0000000000..a248bf0cac --- /dev/null +++ b/examples/stories/subscriptions/server.py @@ -0,0 +1,41 @@ +"""A notebook whose edits and tool changes reach `subscriptions/listen` streams.""" + +from mcp.server.mcpserver import Context, MCPServer +from stories._hosting import run_server_from_args + + +def build_server() -> MCPServer: + mcp = MCPServer("subscriptions-example") + notes = {"todo": "buy milk", "journal": "day one"} + + @mcp.resource("note://{name}") + def note(name: str) -> str: + return notes[name] + + @mcp.tool() + async def edit_note(name: str, text: str, ctx: Context) -> str: + """Replace a note's text and tell subscribers that URI changed.""" + notes[name] = text + await ctx.notify_resource_updated(f"note://{name}") + return "saved" + + def search(query: str) -> list[str]: + return [name for name, text in notes.items() if query in text] + + enabled = False + + @mcp.tool() + async def enable_search(ctx: Context) -> str: + """Register the `search` tool at runtime and tell subscribers the list changed.""" + nonlocal enabled + if not enabled: + enabled = True + mcp.add_tool(search) + await ctx.notify_tools_changed() + return "search is live" + + return mcp + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/examples/stories/subscriptions/server_lowlevel.py b/examples/stories/subscriptions/server_lowlevel.py new file mode 100644 index 0000000000..6d9da182d5 --- /dev/null +++ b/examples/stories/subscriptions/server_lowlevel.py @@ -0,0 +1,72 @@ +"""The same notebook against the low-level Server: an explicit bus + ListenHandler.""" + +from typing import Any + +import mcp_types as types + +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from mcp.server.subscriptions import ( + InMemorySubscriptionBus, + ListenHandler, + ResourceUpdated, + ToolsListChanged, +) +from stories._hosting import run_server_from_args + +EDIT_NOTE_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"name": {"type": "string"}, "text": {"type": "string"}}, + "required": ["name", "text"], +} +EMPTY_SCHEMA: dict[str, Any] = {"type": "object", "properties": {}} +SEARCH_SCHEMA: dict[str, Any] = { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], +} + + +def build_server() -> Server[Any]: + # The bus lives wherever your handlers can reach it; the lifespan is the + # natural home in a bigger app. The closure is enough here. + bus = InMemorySubscriptionBus() + notes = {"todo": "buy milk", "journal": "day one"} + search_enabled = False + + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + tools = [ + types.Tool(name="edit_note", description="Replace a note's text.", input_schema=EDIT_NOTE_SCHEMA), + types.Tool(name="enable_search", description="Register the search tool.", input_schema=EMPTY_SCHEMA), + ] + if search_enabled: + tools.append(types.Tool(name="search", description="Find notes.", input_schema=SEARCH_SCHEMA)) + return types.ListToolsResult(tools=tools) + + async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: + nonlocal search_enabled + args = params.arguments or {} + if params.name == "edit_note": + notes[args["name"]] = args["text"] + await bus.publish(ResourceUpdated(uri=f"note://{args['name']}")) + return types.CallToolResult(content=[types.TextContent(text="saved")]) + if params.name == "enable_search": + search_enabled = True + await bus.publish(ToolsListChanged()) + return types.CallToolResult(content=[types.TextContent(text="search is live")]) + assert params.name == "search" and search_enabled + matches = [name for name, text in notes.items() if args["query"] in text] + return types.CallToolResult(content=[types.TextContent(text=", ".join(matches))]) + + return Server( + "subscriptions-example", + on_list_tools=list_tools, + on_call_tool=call_tool, + on_subscriptions_listen=ListenHandler(bus), + ) + + +if __name__ == "__main__": + run_server_from_args(build_server) diff --git a/mkdocs.yml b/mkdocs.yml index a00a982be2..fda7647141 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -44,6 +44,7 @@ nav: - URI templates: advanced/uri-templates.md - Pagination: advanced/pagination.md - Caching hints: advanced/caching.md + - Subscriptions: advanced/subscriptions.md - Middleware: advanced/middleware.md - Extensions: advanced/extensions.md - MCP Apps: advanced/apps.md diff --git a/src/mcp/server/_streamable_http_modern.py b/src/mcp/server/_streamable_http_modern.py index a047892e6f..52a9a70175 100644 --- a/src/mcp/server/_streamable_http_modern.py +++ b/src/mcp/server/_streamable_http_modern.py @@ -383,6 +383,13 @@ async def handle_modern_request( await _write(rej, scope, receive, send) return + if req.method == "subscriptions/listen" and not has_sse: + # A listen response IS a notification stream, never JSON (the + # json_response carve-out below), so this one method requires the + # SSE accept even in JSON-response mode; SSE mode gated it above. + await Response(status_code=406)(scope, receive, send) + return + duplicated = find_duplicated_routing_header(request.headers.items()) if duplicated is not None: # The raw carrier is the only place duplicates are visible; the classifier sees a folded mapping. @@ -412,7 +419,10 @@ async def handle_modern_request( progress_token=progress_token_from_params(req.params), ) - if json_response: + if json_response and req.method != "subscriptions/listen": + # A listen response IS a notification stream, so it always takes the + # SSE path below regardless of the JSON-response preference (the + # TypeScript and Go SDKs route it the same way). msg = await _to_jsonrpc_response( req.id, serve_one(app, dctx, req.method, req.params, connection=connection, lifespan_state=lifespan_state) ) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 97b5557e20..66c497199a 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -561,12 +561,22 @@ def get_capabilities( notification_options: NotificationOptions | None = None, experimental_capabilities: dict[str, dict[str, Any]] | None = None, extensions: dict[str, dict[str, Any]] | None = None, + *, + protocol_version: str | None = None, ) -> types.ServerCapabilities: """Convert existing handlers to a ServerCapabilities object. `extensions` is the SEP-2133 extension map (identifier -> settings) advertised under `ServerCapabilities.extensions`; it defaults to `self.extensions`. + + `protocol_version` makes the subscription-delivered bits era-honest: + at 2026-07-28+ versions, change notifications are delivered only on + `subscriptions/listen` streams, so the `listChanged` flags and + `resources.subscribe` derive from whether that method is served - + `notification_options` and the legacy `resources/subscribe` handler + (which the modern wire cannot dispatch) are ignored. When omitted, the + handshake-era derivation applies unchanged. """ notification_options = notification_options or NotificationOptions() prompts_capability = None @@ -575,20 +585,29 @@ def get_capabilities( logging_capability = None completions_capability = None + if protocol_version in MODERN_PROTOCOL_VERSIONS: + listen_served = "subscriptions/listen" in self._request_handlers + prompts_changed = tools_changed = resources_changed = subscribe = listen_served + else: + prompts_changed = notification_options.prompts_changed + tools_changed = notification_options.tools_changed + resources_changed = notification_options.resources_changed + subscribe = "resources/subscribe" in self._request_handlers + # Set prompt capabilities if handler exists if "prompts/list" in self._request_handlers: - prompts_capability = types.PromptsCapability(list_changed=notification_options.prompts_changed) + prompts_capability = types.PromptsCapability(list_changed=prompts_changed) # Set resource capabilities if handler exists if "resources/list" in self._request_handlers: resources_capability = types.ResourcesCapability( - subscribe="resources/subscribe" in self._request_handlers, - list_changed=notification_options.resources_changed, + subscribe=subscribe, + list_changed=resources_changed, ) # Set tool capabilities if handler exists if "tools/list" in self._request_handlers: - tools_capability = types.ToolsCapability(list_changed=notification_options.tools_changed) + tools_capability = types.ToolsCapability(list_changed=tools_changed) # Set logging capabilities if handler exists if "logging/setLevel" in self._request_handlers: @@ -638,7 +657,7 @@ async def _handle_discover( """ return types.DiscoverResult( supported_versions=list(MODERN_PROTOCOL_VERSIONS), - capabilities=self.get_capabilities(), + capabilities=self.get_capabilities(protocol_version=ctx.protocol_version), server_info=self.server_info, instructions=self.instructions, ) diff --git a/src/mcp/server/mcpserver/context.py b/src/mcp/server/mcpserver/context.py index 6640467411..28d06761d3 100644 --- a/src/mcp/server/mcpserver/context.py +++ b/src/mcp/server/mcpserver/context.py @@ -16,6 +16,13 @@ elicit_with_validation, ) from mcp.server.lowlevel.helper_types import ReadResourceContents +from mcp.server.subscriptions import ( + PromptsListChanged, + ResourcesListChanged, + ResourceUpdated, + SubscriptionBus, + ToolsListChanged, +) from mcp.shared.exceptions import MCPDeprecationWarning if TYPE_CHECKING: @@ -59,6 +66,7 @@ async def my_tool(x: int, ctx: Context) -> str: _request_context: ServerRequestContext[LifespanContextT, RequestT] | None _mcp_server: MCPServer | None _input_params: InputResponseRequestParams | None + _subscriptions: SubscriptionBus | None # TODO(maxisbey): Consider making request_context/mcp_server required, or refactor Context entirely. def __init__( @@ -67,6 +75,7 @@ def __init__( request_context: ServerRequestContext[LifespanContextT, RequestT] | None = None, mcp_server: MCPServer | None = None, input_params: InputResponseRequestParams | None = None, + subscriptions: SubscriptionBus | None = None, # TODO(Marcelo): We should drop this kwargs parameter. **kwargs: Any, ): @@ -74,13 +83,14 @@ def __init__( self._request_context = request_context self._mcp_server = mcp_server self._input_params = input_params + self._subscriptions = subscriptions @property def mcp_server(self) -> MCPServer: """Access to the MCPServer instance.""" - if self._mcp_server is None: # pragma: no cover + if self._mcp_server is None: raise ValueError("Context is not available outside of a request") - return self._mcp_server # pragma: no cover + return self._mcp_server @property def request_context(self) -> ServerRequestContext[LifespanContextT, RequestT]: @@ -97,7 +107,9 @@ def _nested_invocation(self) -> Context[LifespanContextT, RequestT]: request's own target — their keys are ones that handler minted — so a nested invocation always starts on round one. """ - return Context(request_context=self._request_context, mcp_server=self._mcp_server) + return Context( + request_context=self._request_context, mcp_server=self._mcp_server, subscriptions=self._subscriptions + ) async def report_progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: """Report progress for the current operation. @@ -109,6 +121,34 @@ async def report_progress(self, progress: float, total: float | None = None, mes """ await self.request_context.session.report_progress(progress, total, message) + @property + def _bus(self) -> SubscriptionBus: + if self._subscriptions is None: + raise ValueError("Context is not available outside of a request") + return self._subscriptions + + async def notify_tools_changed(self) -> None: + """Publish a tools list-changed event to `subscriptions/listen` subscribers.""" + await self._bus.publish(ToolsListChanged()) + + async def notify_prompts_changed(self) -> None: + """Publish a prompts list-changed event to `subscriptions/listen` subscribers.""" + await self._bus.publish(PromptsListChanged()) + + async def notify_resources_changed(self) -> None: + """Publish a resources list-changed event to `subscriptions/listen` subscribers.""" + await self._bus.publish(ResourcesListChanged()) + + async def notify_resource_updated(self, uri: str | AnyUrl) -> None: + """Publish a resource-updated event for `uri` to `subscriptions/listen` subscribers. + + The URI is matched as an exact string against each stream's filter. + Reaches `subscriptions/listen` streams only; clients on earlier + protocol versions that used `resources/subscribe` are notified via + `ctx.session.send_resource_updated(uri)` instead. + """ + await self._bus.publish(ResourceUpdated(uri=str(uri))) + async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContents]: """Read a resource by URI. diff --git a/src/mcp/server/mcpserver/prompts/manager.py b/src/mcp/server/mcpserver/prompts/manager.py index 7e7f350787..01a0823bf7 100644 --- a/src/mcp/server/mcpserver/prompts/manager.py +++ b/src/mcp/server/mcpserver/prompts/manager.py @@ -47,6 +47,12 @@ def add_prompt( self._prompts[prompt.name] = prompt return prompt + def remove_prompt(self, name: str) -> None: + """Remove a prompt by name.""" + if name not in self._prompts: + raise ValueError(f"Unknown prompt: {name}") + del self._prompts[name] + async def render_prompt( self, name: str, diff --git a/src/mcp/server/mcpserver/server.py b/src/mcp/server/mcpserver/server.py index 3750429cdc..d933e82d55 100644 --- a/src/mcp/server/mcpserver/server.py +++ b/src/mcp/server/mcpserver/server.py @@ -88,6 +88,7 @@ from mcp.server.stdio import stdio_server from mcp.server.streamable_http import EventStore from mcp.server.streamable_http_manager import StreamableHTTPSessionManager +from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, SubscriptionBus from mcp.server.transport_security import TransportSecuritySettings from mcp.shared.exceptions import MCPError from mcp.shared.uri_template import UriTemplate @@ -182,6 +183,7 @@ def __init__( resource_security: ResourceSecurity = DEFAULT_RESOURCE_SECURITY, request_state_security: RequestStateSecurity | None = None, cache_hints: Mapping[CacheableMethod, CacheHint] | None = None, + subscriptions: SubscriptionBus | None = None, ): self._resource_security = resource_security self.settings = Settings( @@ -201,6 +203,10 @@ def __init__( resources=resources, warn_on_duplicate_resources=self.settings.warn_on_duplicate_resources ) self._prompt_manager = PromptManager(warn_on_duplicate_prompts=self.settings.warn_on_duplicate_prompts) + # The subscriptions/listen fan-out seam (2026-07-28). The default bus is + # in-process; pass an `SubscriptionBus` implementation over an external pub/sub + # backend to fan events out across replicas. + self._subscriptions: SubscriptionBus = subscriptions if subscriptions is not None else InMemorySubscriptionBus() self._lowlevel_server = Server( name=name or "mcp-server", title=title, @@ -217,6 +223,7 @@ def __init__( on_list_resource_templates=self._handle_list_resource_templates, on_list_prompts=self._handle_list_prompts, on_get_prompt=self._handle_get_prompt, + on_subscriptions_listen=ListenHandler(self._subscriptions), # TODO(Marcelo): It seems there's a type mismatch between the lifespan type from an MCPServer and Server. # We need to create a Lifespan type that is a generic on the server type, like Starlette does. lifespan=(lifespan_wrapper(self, self.settings.lifespan) if self.settings.lifespan else default_lifespan), # type: ignore @@ -396,7 +403,7 @@ async def _handle_list_tools( async def _handle_call_tool( self, ctx: ServerRequestContext[LifespanResultT], params: CallToolRequestParams ) -> CallToolResult | InputRequiredResult: - context = Context(request_context=ctx, mcp_server=self, input_params=params) + context = Context(request_context=ctx, mcp_server=self, input_params=params, subscriptions=self._subscriptions) try: return await self.call_tool(params.name, params.arguments or {}, context) except MCPError: @@ -412,7 +419,7 @@ async def _handle_list_resources( async def _handle_read_resource( self, ctx: ServerRequestContext[LifespanResultT], params: ReadResourceRequestParams ) -> ReadResourceResult | InputRequiredResult: - context = Context(request_context=ctx, mcp_server=self, input_params=params) + context = Context(request_context=ctx, mcp_server=self, input_params=params, subscriptions=self._subscriptions) try: results = await self.read_resource(params.uri, context) except ResourceNotFoundError as err: @@ -456,7 +463,7 @@ async def _handle_list_prompts( async def _handle_get_prompt( self, ctx: ServerRequestContext[LifespanResultT], params: GetPromptRequestParams ) -> GetPromptResult | InputRequiredResult: - context = Context(request_context=ctx, mcp_server=self, input_params=params) + context = Context(request_context=ctx, mcp_server=self, input_params=params, subscriptions=self._subscriptions) return await self.get_prompt(params.name, params.arguments, context) async def list_tools(self) -> list[MCPTool]: @@ -481,7 +488,7 @@ async def call_tool( ) -> CallToolResult | InputRequiredResult: """Call a tool by name with arguments.""" if context is None: - context = Context(mcp_server=self) + context = Context(mcp_server=self, subscriptions=self._subscriptions) return await self._tool_manager.call_tool(name, arguments, context, convert_result=True) async def list_resources(self) -> list[MCPResource]: @@ -533,7 +540,7 @@ async def read_resource( ResourceError: If template creation or resource reading fails. """ if context is None: - context = Context(mcp_server=self) + context = Context(mcp_server=self, subscriptions=self._subscriptions) resource = await self._resource_manager.get_resource(uri, context) if isinstance(resource, InputRequiredResult): return resource @@ -879,6 +886,17 @@ def add_prompt(self, prompt: Prompt) -> None: """ self._prompt_manager.add_prompt(prompt) + def remove_prompt(self, name: str) -> None: + """Remove a prompt from the server by name. + + Args: + name: The name of the prompt to remove + + Raises: + ValueError: If the prompt does not exist + """ + self._prompt_manager.remove_prompt(name) + def prompt( self, name: str | None = None, @@ -1242,7 +1260,7 @@ async def get_prompt( carrying the echoed opaque state. """ if context is None: - context = Context(mcp_server=self) + context = Context(mcp_server=self, subscriptions=self._subscriptions) try: prompt = self._prompt_manager.get_prompt(name) if not prompt: diff --git a/src/mcp/server/subscriptions.py b/src/mcp/server/subscriptions.py new file mode 100644 index 0000000000..d071cfdbf4 --- /dev/null +++ b/src/mcp/server/subscriptions.py @@ -0,0 +1,296 @@ +"""Server-side `subscriptions/listen` support (2026-07-28, SEP-2575). + +On the 2026-07-28 wire there is no standing GET stream: a client opts in to +server events by sending a `subscriptions/listen` request whose response IS +the stream. This module provides the two pieces a server needs: + +- `SubscriptionBus`: the pluggable fan-out seam. The bus carries typed `ServerEvent` + values, not wire notifications - the listen handler owns subscription-id + stamping and per-stream filtering, so a custom bus (e.g. backed by Redis + pub/sub for multi-replica deployments) never sees JSON-RPC. The in-process + default is `InMemorySubscriptionBus`. +- `ListenHandler`: the request handler that serves `subscriptions/listen`. + `MCPServer` registers one automatically; lowlevel `Server` users pass an + instance as `on_subscriptions_listen=`. + +Per the spec, the handler acknowledges first (the ack is the first frame on +the stream), tags every frame with the listen request's JSON-RPC id under +`_meta["io.modelcontextprotocol/subscriptionId"]`, and never delivers an +event kind the client did not request. Delivery is fire-and-forget with no +replay: a dropped stream is not resumable - clients re-listen and refetch. +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Protocol + +import anyio +import anyio.lowlevel +import anyio.streams.memory +from mcp_types import ( + INTERNAL_ERROR, + INVALID_REQUEST, + NotificationParams, + PromptListChangedNotification, + ResourceListChangedNotification, + ResourceUpdatedNotification, + ResourceUpdatedNotificationParams, + ServerNotification, + SubscriptionFilter, + SubscriptionsAcknowledgedNotification, + SubscriptionsAcknowledgedNotificationParams, + SubscriptionsListenRequestParams, + SubscriptionsListenResult, + ToolListChangedNotification, +) + +from mcp.server.context import ServerRequestContext +from mcp.shared.exceptions import MCPError + +logger = logging.getLogger(__name__) + +SUBSCRIPTION_ID_META_KEY = "io.modelcontextprotocol/subscriptionId" +"""The `_meta` key carrying the subscription id on every listen-stream frame. + +The value is the `subscriptions/listen` request's JSON-RPC id, verbatim. +""" + + +@dataclass(frozen=True) +class ToolsListChanged: + """The server's tool list changed.""" + + +@dataclass(frozen=True) +class PromptsListChanged: + """The server's prompt list changed.""" + + +@dataclass(frozen=True) +class ResourcesListChanged: + """The server's resource list changed.""" + + +@dataclass(frozen=True) +class ResourceUpdated: + """The resource at `uri` changed and may need to be read again.""" + + uri: str + + +ServerEvent = ToolsListChanged | PromptsListChanged | ResourcesListChanged | ResourceUpdated +"""An event a server publishes for delivery to listen subscribers.""" + + +class SubscriptionBus(Protocol): + """Fan-out seam between event publishers and open listen streams. + + Implement this over an external pub/sub backend (Redis, NATS, ...) to fan + events out across replicas: `publish` forwards the event to the backend, + and each replica's bus invokes its local listeners for events arriving + from the backend. The same instance can be shared across servers. + + `publish` is async so backend implementations can do network I/O. + `subscribe` is synchronous local registration. Listeners are synchronous, + must not raise, and are invoked on the server's event loop. + """ + + async def publish(self, event: ServerEvent) -> None: + """Deliver `event` to every subscribed listener.""" + ... + + def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]: + """Register `listener` and return an idempotent unsubscribe callable.""" + ... + + +class InMemorySubscriptionBus: + """In-process `SubscriptionBus`: synchronous fan-out to listeners in subscription order.""" + + def __init__(self) -> None: + # Keyed by a per-subscription token so the same callable can be + # registered more than once (bound methods compare equal). + self._listeners: dict[object, Callable[[ServerEvent], None]] = {} + + async def publish(self, event: ServerEvent) -> None: + """Deliver `event` to every subscribed listener. + + A raising listener is logged and skipped: one bad listener must not + starve the others or fail the publishing handler. Ends with a + checkpoint so a burst of publishes from one task lets listen streams + drain between events instead of overflowing their buffers unread. + """ + for listener in list(self._listeners.values()): + try: + listener(event) + except Exception: # fan-out boundary: isolate listeners from each other + logger.exception("subscription listener raised; continuing") + await anyio.lowlevel.checkpoint() + + def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]: + """Register `listener` and return an idempotent unsubscribe callable.""" + token = object() + self._listeners[token] = listener + + def unsubscribe() -> None: + self._listeners.pop(token, None) + + return unsubscribe + + +def _safe_unsubscribe(unsubscribe: Callable[[], None]) -> None: + """Run a bus's unsubscribe callable, isolating the stream from it raising. + + The callable comes from a custom `SubscriptionBus`; a raising one is + logged and skipped so it cannot stop the stream's own cleanup from + releasing its subscription slot. + """ + try: + unsubscribe() + except Exception: # fan-out boundary: a raising bus must not skip stream cleanup + logger.exception("bus unsubscribe raised; continuing stream cleanup") + + +def _honored_subset(requested: SubscriptionFilter) -> SubscriptionFilter: + """The subset of `requested` the server will deliver, for the ack. + + Every requested kind is honored - whether an event kind ever fires + depends on what the server publishes, exactly as a subscription to a + nonexistent resource URI is honored and never fires. Non-true flags and + an empty URI list are dropped rather than echoed as falsy values. + """ + return SubscriptionFilter( + tools_list_changed=True if requested.tools_list_changed else None, + prompts_list_changed=True if requested.prompts_list_changed else None, + resources_list_changed=True if requested.resources_list_changed else None, + resource_subscriptions=list(requested.resource_subscriptions) if requested.resource_subscriptions else None, + ) + + +def _event_matches(honored: SubscriptionFilter, uris: frozenset[str], event: ServerEvent) -> bool: + """Whether `event` is within the stream's honored filter. + + `uris` is the honored `resource_subscriptions` as a set: matching runs on + every publish, and the wire filter may name many URIs. + """ + if isinstance(event, ToolsListChanged): + return honored.tools_list_changed is True + if isinstance(event, PromptsListChanged): + return honored.prompts_list_changed is True + if isinstance(event, ResourcesListChanged): + return honored.resources_list_changed is True + return event.uri in uris + + +def _event_to_notification(event: ServerEvent, meta: dict[str, Any]) -> ServerNotification: + """Build the stamped wire notification for `event`.""" + if isinstance(event, ToolsListChanged): + return ToolListChangedNotification(params=NotificationParams(_meta=meta)) + if isinstance(event, PromptsListChanged): + return PromptListChangedNotification(params=NotificationParams(_meta=meta)) + if isinstance(event, ResourcesListChanged): + return ResourceListChangedNotification(params=NotificationParams(_meta=meta)) + return ResourceUpdatedNotification(params=ResourceUpdatedNotificationParams(uri=event.uri, _meta=meta)) + + +class ListenHandler: + """Serves `subscriptions/listen`: one call is one subscription stream. + + Register on a lowlevel `Server` via `on_subscriptions_listen=` (or + `add_request_handler`); `MCPServer` does so automatically. Each call + acknowledges the honored filter first, then forwards matching bus events + onto the request's response stream until the client disconnects (which + cancels the handler; the stream just ends, per the spec's abrupt-close + contract) or `close` ends all streams gracefully. + + Requires a transport that can stream a request's response (streamable + HTTP's SSE mode). + + `max_subscriptions` bounds concurrent streams (further listen requests are + rejected with `INTERNAL_ERROR`, before the ack). `max_buffered_events` + bounds each stream's event backlog: a stream whose client has stopped + reading is ended at the cap (the client re-listens and refetches - there + is no replay, so ending the stream loses nothing the backlog wasn't + already losing). + """ + + def __init__(self, bus: SubscriptionBus, *, max_subscriptions: int = 1024, max_buffered_events: int = 1024) -> None: + self._bus = bus + self._max_subscriptions = max_subscriptions + self._max_buffered_events = max_buffered_events + self._streams: set[anyio.streams.memory.MemoryObjectSendStream[ServerEvent]] = set() + + async def __call__( + self, + ctx: ServerRequestContext[Any, Any], + params: SubscriptionsListenRequestParams, + ) -> SubscriptionsListenResult: + """Serve one listen stream.""" + subscription_id = ctx.request_id + if subscription_id is None: + raise MCPError(INVALID_REQUEST, "subscriptions/listen requires a request id") + if len(self._streams) >= self._max_subscriptions: + raise MCPError(INTERNAL_ERROR, "Subscription limit reached") + honored = _honored_subset(params.notifications) + honored_uris = frozenset(honored.resource_subscriptions or ()) + meta: dict[str, Any] = {SUBSCRIPTION_ID_META_KEY: subscription_id} + + # Buffered so publishers don't block on a slow consumer (the transport + # write happens in this handler task, not the publisher's). A stream + # whose backlog hits the cap is ended - see the class docstring. + send, recv = anyio.create_memory_object_stream[ServerEvent](self._max_buffered_events) + + def deliver(event: ServerEvent) -> None: + if _event_matches(honored, honored_uris, event): + try: + send.send_nowait(event) + except anyio.ClosedResourceError: + # `close` closed this stream; the loop below is unwinding. + pass + except anyio.WouldBlock: + logger.warning("listen stream %r backlog full; ending the stream", subscription_id) + # Release the subscription slot now: the handler's own + # cleanup can be wedged in a transport write that closing + # this buffer cannot wake (a client that stopped reading). + self._streams.discard(send) + send.close() + + # Subscribe before sending the ack so an event published while the + # ack write is suspended is buffered rather than lost. The ack is + # still the first frame: this task alone writes the stream, and it + # only starts draining the buffer after the ack send returns. + unsubscribe = self._bus.subscribe(deliver) + self._streams.add(send) + try: + await ctx.session.send_notification( + SubscriptionsAcknowledgedNotification( + params=SubscriptionsAcknowledgedNotificationParams(notifications=honored, _meta=meta) + ), + related_request_id=subscription_id, + ) + async for event in recv: + await ctx.session.send_notification( + _event_to_notification(event, meta), related_request_id=subscription_id + ) + finally: + _safe_unsubscribe(unsubscribe) + self._streams.discard(send) + send.close() + recv.close() + return SubscriptionsListenResult(_meta=meta) + + def close(self) -> None: + """Initiate graceful closure of every open listen stream. + + Each stream then drains its buffered events and sends its + `SubscriptionsListenResult` (stamped with the subscription id) as the + final frame from its own handler task - the spec's graceful closure + flow, telling clients the stream ended deliberately rather than + dropping. This method only initiates that; it does not wait for the + streams to finish flushing. + """ + for stream in list(self._streams): + stream.close() diff --git a/tests/docs_src/test_client.py b/tests/docs_src/test_client.py index 97cc327dcb..af5e692491 100644 --- a/tests/docs_src/test_client.py +++ b/tests/docs_src/test_client.py @@ -121,11 +121,12 @@ async def test_read_resource_fills_in_a_template() -> None: assert contents.text == "3 books filed under poetry." -async def test_mcpserver_does_not_implement_resource_subscriptions() -> None: - """The Resources section: MCPServer advertises subscribe=False and rejects subscribe_resource with -32601.""" +async def test_resource_subscriptions_are_listen_based_on_the_modern_wire() -> None: + """The Resources section: at 2026-07-28 `resources.subscribe` is True (served via + subscriptions/listen) while the legacy subscribe_resource verb answers -32601.""" async with Client(tutorial004.mcp) as client: assert client.server_capabilities.resources is not None - assert client.server_capabilities.resources.subscribe is False + assert client.server_capabilities.resources.subscribe is True with pytest.raises(MCPError) as exc_info: await client.subscribe_resource("catalog://genres") assert exc_info.value.error.code == -32601 diff --git a/tests/docs_src/test_first_steps.py b/tests/docs_src/test_first_steps.py index 15d6708ee2..2b1674a471 100644 --- a/tests/docs_src/test_first_steps.py +++ b/tests/docs_src/test_first_steps.py @@ -89,9 +89,9 @@ async def test_the_three_primitive_capabilities_are_always_declared() -> None: # The exact dictionary the page prints from `model_dump(exclude_none=True)`. assert declared.model_dump(exclude_none=True) == snapshot( { - "prompts": {"list_changed": False}, - "resources": {"subscribe": False, "list_changed": False}, - "tools": {"list_changed": False}, + "prompts": {"list_changed": True}, + "resources": {"subscribe": True, "list_changed": True}, + "tools": {"list_changed": True}, } ) async with Client(MCPServer("Empty")) as client: diff --git a/tests/docs_src/test_subscriptions.py b/tests/docs_src/test_subscriptions.py new file mode 100644 index 0000000000..cdfe1d9354 --- /dev/null +++ b/tests/docs_src/test_subscriptions.py @@ -0,0 +1,138 @@ +"""`docs/advanced/subscriptions.md`: every claim the page makes, proved against the real SDK.""" + +from typing import Any + +import anyio +import mcp_types as types +import pytest + +from docs_src.subscriptions import tutorial001, tutorial002 +from mcp import Client +from mcp.server.subscriptions import SUBSCRIPTION_ID_META_KEY, ToolsListChanged + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +class _Stream: + """Collects listen-stream notifications and lets tests await arrival counts.""" + + def __init__(self) -> None: + self.received: list[types.ServerNotification] = [] + self._arrival = anyio.Event() + + async def handler( + self, + message: object, + ) -> None: + # The only messages these connections produce are the stream's frames. + assert isinstance( + message, + types.SubscriptionsAcknowledgedNotification + | types.ResourceUpdatedNotification + | types.ToolListChangedNotification, + ), message + self.received.append(message) + self._arrival.set() + self._arrival = anyio.Event() + + async def wait_for(self, count: int) -> None: + with anyio.fail_after(5): + while len(self.received) < count: + await self._arrival.wait() + + +def _listen_request(**fields: Any) -> types.SubscriptionsListenRequest: + return types.SubscriptionsListenRequest( + params=types.SubscriptionsListenRequestParams(notifications=types.SubscriptionFilter(**fields)) + ) + + +async def test_publishes_reach_the_stream_filtered_and_tagged() -> None: + """tutorial001: the full arc - ack first, exact-URI filtering, list_changed + leading to a refreshed tool list, and client-side close.""" + stream = _Stream() + async with Client(tutorial001.mcp, mode="2026-07-28", message_handler=stream.handler) as client: + async with anyio.create_task_group() as tg: + + async def listen() -> None: + await client.session.send_request( + _listen_request(tools_list_changed=True, resource_subscriptions=["note://todo"]), + types.SubscriptionsListenResult, + ) + + tg.start_soon(listen) + await stream.wait_for(1) + + ack = stream.received[0] + assert isinstance(ack, types.SubscriptionsAcknowledgedNotification) + assert ack.params.notifications == types.SubscriptionFilter( + tools_list_changed=True, resource_subscriptions=["note://todo"] + ) + assert ack.params.meta is not None and SUBSCRIPTION_ID_META_KEY in ack.params.meta + + # An edit to a URI the stream did not subscribe to stays silent... + await client.call_tool("edit_note", {"name": "journal", "text": "day two"}) + # ...and the subscribed URI delivers, tagged with the same subscription id. + await client.call_tool("edit_note", {"name": "todo", "text": "water plants"}) + await stream.wait_for(2) + updated = stream.received[1] + assert isinstance(updated, types.ResourceUpdatedNotification) + assert updated.params.uri == "note://todo" + assert updated.params.meta == ack.params.meta + + await client.call_tool("enable_search", {}) + await stream.wait_for(3) + assert isinstance(stream.received[2], types.ToolListChangedNotification) + + # The client ends the stream by closing it - cancel the parked request. + tg.cancel_scope.cancel() + + # The list_changed told us to re-fetch: the new tool is there, and the + # session outlives the closed stream. + tools = await client.list_tools() + assert "search" in {tool.name for tool in tools.tools} + contents = (await client.read_resource("note://todo")).contents[0] + assert isinstance(contents, types.TextResourceContents) + assert contents.text == "water plants" + + +async def test_publish_with_no_subscribers_is_a_no_op() -> None: + """tutorial001: publishing to an idle server does nothing and breaks nothing.""" + async with Client(tutorial001.mcp, mode="2026-07-28") as client: + result = await client.call_tool("edit_note", {"name": "todo", "text": "buy milk"}) + assert result.is_error is not True + + +async def test_lowlevel_composition_serves_the_same_stream() -> None: + """tutorial002: bus + ListenHandler on the lowlevel Server is the same machinery.""" + stream = _Stream() + async with Client(tutorial002.server, mode="2026-07-28", message_handler=stream.handler) as client: + tools = await client.list_tools() + assert [tool.name for tool in tools.tools] == ["edit_note"] + + async with anyio.create_task_group() as tg: + + async def listen() -> None: + await client.session.send_request( + _listen_request(resource_subscriptions=["note://todo"]), + types.SubscriptionsListenResult, + ) + + tg.start_soon(listen) + await stream.wait_for(1) + + await client.call_tool("edit_note", {"name": "todo", "text": "water plants"}) + await stream.wait_for(2) + updated = stream.received[1] + assert isinstance(updated, types.ResourceUpdatedNotification) + assert updated.params.uri == "note://todo" + + # The bus you constructed is also the publish surface outside a + # request; an unrequested kind never reaches this stream. + await tutorial002.bus.publish(ToolsListChanged()) + await client.call_tool("edit_note", {"name": "todo", "text": "done"}) + await stream.wait_for(3) + assert isinstance(stream.received[2], types.ResourceUpdatedNotification) + + tg.cancel_scope.cancel() diff --git a/tests/server/lowlevel/test_server_discover.py b/tests/server/lowlevel/test_server_discover.py index 1d036c2e7b..05d57d846a 100644 --- a/tests/server/lowlevel/test_server_discover.py +++ b/tests/server/lowlevel/test_server_discover.py @@ -12,18 +12,26 @@ import pytest from mcp_types.version import MODERN_PROTOCOL_VERSIONS -from mcp.server import Server, ServerRequestContext - -# `Server._handle_discover` ignores its `ctx` argument entirely (it derives the -# result from server state), so a sentinel keeps the call site type-correct -# without dragging session machinery into a unit test. -_UNUSED_CTX = cast("ServerRequestContext[Any]", None) +from mcp.server import NotificationOptions, Server, ServerRequestContext + + +# `Server._handle_discover` reads only `ctx.protocol_version` (capabilities are +# era-dependent), so a minimal context keeps the call site honest without +# dragging session machinery into a unit test. +def _ctx(protocol_version: str) -> ServerRequestContext[Any]: + return ServerRequestContext( + session=cast("Any", None), + lifespan_context={}, + protocol_version=protocol_version, + method="server/discover", + request_id=1, + ) -async def _discover(server: Server[Any]) -> types.DiscoverResult: +async def _discover(server: Server[Any], protocol_version: str = MODERN_PROTOCOL_VERSIONS[0]) -> types.DiscoverResult: entry = server.get_request_handler("server/discover") assert entry is not None - result = await entry.handler(_UNUSED_CTX, types.RequestParams()) + result = await entry.handler(_ctx(protocol_version), types.RequestParams()) assert isinstance(result, types.DiscoverResult) return result @@ -149,3 +157,64 @@ async def custom_discover( server.add_request_handler("server/discover", types.RequestParams, custom_discover) result = await _discover(server) assert result is custom + + +async def _listen_stub( + ctx: ServerRequestContext[Any], params: types.SubscriptionsListenRequestParams +) -> types.SubscriptionsListenResult: + raise NotImplementedError + + +@pytest.mark.anyio +async def test_modern_subscription_bits_derive_from_listen_serving() -> None: + """Spec-driven (SEP-2575): at 2026-07-28, change notifications exist only on + `subscriptions/listen` streams, so the `listChanged`/`subscribe` bits mean + "this server serves listen" - they flip together with the handler.""" + + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + raise NotImplementedError + + async def list_resources( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListResourcesResult: + raise NotImplementedError + + server = Server("caps", on_list_tools=list_tools, on_list_resources=list_resources) + + before = await _discover(server) + assert before.capabilities.tools is not None and before.capabilities.tools.list_changed is False + assert before.capabilities.resources is not None + assert before.capabilities.resources.subscribe is False + assert before.capabilities.resources.list_changed is False + + server.add_request_handler("subscriptions/listen", types.SubscriptionsListenRequestParams, _listen_stub) + + after = await _discover(server) + assert after.capabilities.tools is not None and after.capabilities.tools.list_changed is True + assert after.capabilities.resources is not None + assert after.capabilities.resources.subscribe is True + assert after.capabilities.resources.list_changed is True + + +@pytest.mark.anyio +async def test_legacy_capability_derivation_ignores_listen() -> None: + """SDK-defined: without `protocol_version`, `get_capabilities` keeps the + handshake-era derivation - `NotificationOptions` drives `listChanged` and the + `resources/subscribe` handler drives `subscribe`; a registered listen handler + changes nothing on that path.""" + + async def list_tools( + ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + raise NotImplementedError + + server = Server("caps", on_list_tools=list_tools) + server.add_request_handler("subscriptions/listen", types.SubscriptionsListenRequestParams, _listen_stub) + + legacy = server.get_capabilities() + assert legacy.tools is not None and legacy.tools.list_changed is False + + opted_in = server.get_capabilities(NotificationOptions(tools_changed=True)) + assert opted_in.tools is not None and opted_in.tools.list_changed is True diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 2ae9d5ff74..3103a50f38 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -51,6 +51,14 @@ from mcp.server.mcpserver.prompts.base import Message, UserMessage from mcp.server.mcpserver.resources import FileResource, FunctionResource from mcp.server.mcpserver.utilities.types import Audio, Image +from mcp.server.subscriptions import ( + InMemorySubscriptionBus, + PromptsListChanged, + ResourcesListChanged, + ResourceUpdated, + ServerEvent, + ToolsListChanged, +) from mcp.server.transport_security import TransportSecuritySettings from mcp.shared.exceptions import MCPError from mcp.shared.uri_template import InvalidUriTemplate @@ -2248,3 +2256,84 @@ async def probe(ctx: Context) -> str: await client.call_tool("probe") assert captured == {"responses": None, "state": None} + + +async def test_context_notify_methods_publish_to_the_configured_bus() -> None: + bus = InMemorySubscriptionBus() + mcp = MCPServer(subscriptions=bus) + seen: list[ServerEvent] = [] + bus.subscribe(seen.append) + + @mcp.tool() + async def touch(ctx: Context) -> str: + await ctx.notify_tools_changed() + await ctx.notify_prompts_changed() + await ctx.notify_resources_changed() + await ctx.notify_resource_updated("r://x") + return "ok" + + with anyio.fail_after(5): + async with Client(mcp) as client: + await client.call_tool("touch") + + assert seen == [ToolsListChanged(), PromptsListChanged(), ResourcesListChanged(), ResourceUpdated(uri="r://x")] + + +async def test_programmatic_entry_points_carry_the_subscription_bus() -> None: + """`ctx.notify_*` works when tools, resources, and prompts are invoked + programmatically (no wire request): the server-scoped bus rides along in + the fallback Context.""" + bus = InMemorySubscriptionBus() + mcp = MCPServer(subscriptions=bus) + seen: list[ServerEvent] = [] + bus.subscribe(seen.append) + + @mcp.tool() + async def touch_tools(ctx: Context) -> str: + await ctx.notify_tools_changed() + return "ok" + + @mcp.resource("res://{name}") + async def thing(name: str, ctx: Context) -> str: + await ctx.notify_resources_changed() + return "data" + + @mcp.prompt() + async def ask(ctx: Context) -> str: + await ctx.notify_prompts_changed() + return "question" + + await mcp.call_tool("touch_tools", {}) + await mcp.read_resource("res://thing") + await mcp.get_prompt("ask") + + assert seen == [ToolsListChanged(), ResourcesListChanged(), PromptsListChanged()] + + +def test_context_mcp_server_outside_request_raises() -> None: + with pytest.raises(ValueError, match="outside of a request"): + _ = Context().mcp_server + + +async def test_context_notify_outside_a_request_raises() -> None: + with pytest.raises(ValueError, match="outside of a request"): + await Context().notify_tools_changed() + + +def test_context_exposes_its_mcp_server() -> None: + mcp = MCPServer() + assert Context(mcp_server=mcp).mcp_server is mcp + + +def test_remove_prompt_removes_and_unknown_name_raises() -> None: + mcp = MCPServer() + + @mcp.prompt() + def greeting() -> str: # pragma: no cover + return "hello" + + assert len(mcp._prompt_manager.list_prompts()) == 1 + mcp.remove_prompt("greeting") + assert mcp._prompt_manager.list_prompts() == [] + with pytest.raises(ValueError, match="Unknown prompt: greeting"): + mcp.remove_prompt("greeting") diff --git a/tests/server/test_streamable_http_modern.py b/tests/server/test_streamable_http_modern.py index 17ea9eb435..19ad33f194 100644 --- a/tests/server/test_streamable_http_modern.py +++ b/tests/server/test_streamable_http_modern.py @@ -8,6 +8,7 @@ import json import logging +from collections.abc import Callable from typing import Any import anyio @@ -44,6 +45,7 @@ _to_jsonrpc_response, handle_modern_request, ) +from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, ServerEvent from mcp.server.transport_security import TransportSecuritySettings from mcp.shared.exceptions import MCPError, NoBackChannelError from mcp.shared.inbound import MCP_METHOD_HEADER, MCP_NAME_HEADER, MCP_PROTOCOL_VERSION_HEADER @@ -1008,3 +1010,75 @@ async def test_modern_post_with_deeply_nested_body_is_parse_error_not_a_crash() response = await http.post("/mcp", content=body, headers={"content-type": "application/json"}) assert response.status_code == 400 assert response.json()["error"]["code"] == PARSE_ERROR + + +class _OpenSignalBus(InMemorySubscriptionBus): + """Sets an event when a listen stream subscribes, so tests can sequence close().""" + + def __init__(self) -> None: + super().__init__() + self.opened = anyio.Event() + + def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]: + unsubscribe = super().subscribe(listener) + self.opened.set() + return unsubscribe + + +def _listen_body() -> dict[str, Any]: + """A minimal valid 2026-07-28 `subscriptions/listen` request body.""" + return { + "jsonrpc": "2.0", + "id": 9, + "method": "subscriptions/listen", + "params": { + "notifications": {"toolsListChanged": True}, + "_meta": { + PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION, + CLIENT_INFO_META_KEY: {"name": "raw", "version": "0.0.0"}, + CLIENT_CAPABILITIES_META_KEY: {}, + }, + }, + } + + +async def test_subscriptions_listen_requires_the_sse_accept_even_in_json_mode() -> None: + """SDK-defined: a listen response is always SSE, so a request whose Accept + lacks `text/event-stream` is rejected with 406 rather than served a content + type it never accepted - JSON-response mode included.""" + server = Server("test", on_subscriptions_listen=ListenHandler(InMemorySubscriptionBus())) + async with _asgi_client(server, json_response=True, accept="application/json") as http: + response = await http.post("/mcp", json=_listen_body(), headers={MCP_METHOD_HEADER: "subscriptions/listen"}) + assert response.status_code == 406 + + +async def test_json_response_mode_still_streams_subscriptions_listen() -> None: + """SDK-defined (TypeScript/Go parity): a listen response IS a notification + stream, so `json_response=True` does not apply to it - the request takes + the SSE path, acks first, and ends with the stamped result on close().""" + bus = _OpenSignalBus() + handler = ListenHandler(bus) + server = Server("test", on_subscriptions_listen=handler) + body = _listen_body() + + responses: list[httpx.Response] = [] + async with _asgi_client(server, json_response=True) as http: + async with anyio.create_task_group() as tg: + + async def post() -> None: + responses.append( + await http.post("/mcp", json=body, headers={MCP_METHOD_HEADER: "subscriptions/listen"}) + ) + + tg.start_soon(post) + with anyio.fail_after(5): + await bus.opened.wait() + handler.close() + + response = responses[0] + assert response.status_code == 200 + assert response.headers["content-type"].split(";", 1)[0] == "text/event-stream" + events = _sse_payloads(response.text) + assert events[0]["method"] == "notifications/subscriptions/acknowledged" + assert events[1]["id"] == 9 + assert events[1]["result"]["_meta"] == {"io.modelcontextprotocol/subscriptionId": 9} diff --git a/tests/server/test_subscriptions.py b/tests/server/test_subscriptions.py new file mode 100644 index 0000000000..579e3522fe --- /dev/null +++ b/tests/server/test_subscriptions.py @@ -0,0 +1,473 @@ +"""Tests for `subscriptions/listen` serving (mcp.server.subscriptions).""" + +from collections.abc import Callable +from typing import Any, cast + +import anyio +import pytest +from mcp_types import ( + INVALID_REQUEST, + PromptListChangedNotification, + RequestId, + ResourceListChangedNotification, + ResourceUpdatedNotification, + ServerNotification, + SubscriptionFilter, + SubscriptionsAcknowledgedNotification, + SubscriptionsListenRequestParams, + SubscriptionsListenResult, + ToolListChangedNotification, +) + +from mcp.server.context import ServerRequestContext +from mcp.server.session import ServerSession +from mcp.server.subscriptions import ( + SUBSCRIPTION_ID_META_KEY, + InMemorySubscriptionBus, + ListenHandler, + PromptsListChanged, + ResourcesListChanged, + ResourceUpdated, + ServerEvent, + ToolsListChanged, +) +from mcp.shared.exceptions import MCPError + + +class _RecordingSession: + """Stands in for `ServerSession`: records sent notifications and wakes waiters.""" + + def __init__(self) -> None: + self.sent: list[tuple[ServerNotification, RequestId | None]] = [] + self._arrival = anyio.Event() + + async def send_notification( + self, notification: ServerNotification, related_request_id: RequestId | None = None + ) -> None: + self.sent.append((notification, related_request_id)) + self._arrival.set() + self._arrival = anyio.Event() + + async def wait_for(self, count: int) -> None: + with anyio.fail_after(5): + while len(self.sent) < count: + await self._arrival.wait() + + +def _ctx(session: _RecordingSession, request_id: RequestId | None = 7) -> ServerRequestContext[Any, Any]: + return ServerRequestContext( + session=cast(ServerSession, session), + lifespan_context={}, + protocol_version="2026-07-28", + method="subscriptions/listen", + request_id=request_id, + ) + + +def _params(**fields: Any) -> SubscriptionsListenRequestParams: + return SubscriptionsListenRequestParams(notifications=SubscriptionFilter(**fields)) + + +class _SpyBus(InMemorySubscriptionBus): + """Counts unsubscribe calls so tests can assert stream cleanup.""" + + def __init__(self) -> None: + super().__init__() + self.unsubscribed = 0 + + def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]: + unsubscribe = super().subscribe(listener) + + def counting_unsubscribe() -> None: + self.unsubscribed += 1 + unsubscribe() + + return counting_unsubscribe + + +@pytest.mark.anyio +async def test_in_memory_bus_fans_out_until_unsubscribed() -> None: + """SDK-defined bus contract: fan-out to all listeners; unsubscribe is idempotent.""" + bus = InMemorySubscriptionBus() + seen_a: list[ServerEvent] = [] + seen_b: list[ServerEvent] = [] + unsubscribe_a = bus.subscribe(seen_a.append) + bus.subscribe(seen_b.append) + + await bus.publish(ToolsListChanged()) + assert seen_a == [ToolsListChanged()] + assert seen_b == [ToolsListChanged()] + + unsubscribe_a() + unsubscribe_a() # idempotent + await bus.publish(PromptsListChanged()) + assert seen_a == [ToolsListChanged()] + assert seen_b == [ToolsListChanged(), PromptsListChanged()] + + +@pytest.mark.anyio +async def test_in_memory_bus_keeps_equal_callables_distinct() -> None: + """SDK-defined: registering the same callable twice yields two registrations, + and each unsubscribe detaches exactly one (bound methods compare equal).""" + bus = InMemorySubscriptionBus() + seen: list[ServerEvent] = [] + first = bus.subscribe(seen.append) + bus.subscribe(seen.append) + + await bus.publish(ToolsListChanged()) + assert len(seen) == 2 + + first() + await bus.publish(ToolsListChanged()) + assert len(seen) == 3 + + +@pytest.mark.anyio +async def test_ack_first_honored_subset_and_stamped_graceful_result() -> None: + """Spec-mandated: the ack is the first frame, echoes the honored subset, and + every frame (graceful result included) carries the subscription-id tag.""" + bus = _SpyBus() + handler = ListenHandler(bus) + session = _RecordingSession() + results: list[SubscriptionsListenResult] = [] + + async with anyio.create_task_group() as tg: + + async def run() -> None: + results.append( + await handler( + _ctx(session), + _params(tools_list_changed=True, prompts_list_changed=False, resource_subscriptions=["r://a"]), + ) + ) + + tg.start_soon(run) + await session.wait_for(1) + + ack, related = session.sent[0] + assert isinstance(ack, SubscriptionsAcknowledgedNotification) + assert related == 7 + # Honored subset: requested-false and absent kinds are omitted, not echoed. + assert ack.params.notifications == SubscriptionFilter(tools_list_changed=True, resource_subscriptions=["r://a"]) + assert ack.params.meta == {SUBSCRIPTION_ID_META_KEY: 7} + + await bus.publish(ToolsListChanged()) + await session.wait_for(2) + event, related = session.sent[1] + assert isinstance(event, ToolListChangedNotification) + assert related == 7 + assert event.params is not None and event.params.meta == {SUBSCRIPTION_ID_META_KEY: 7} + + handler.close() + + assert results[0].meta == {SUBSCRIPTION_ID_META_KEY: 7} + assert bus.unsubscribed == 1 # the stream unsubscribed on the way out + + +@pytest.mark.anyio +async def test_only_requested_event_kinds_are_delivered() -> None: + """Spec-mandated: the server never sends a notification type (or resource URI) + the client did not request on this stream.""" + bus = InMemorySubscriptionBus() + handler = ListenHandler(bus) + session = _RecordingSession() + + async with anyio.create_task_group() as tg: + + async def run() -> None: + await handler( + _ctx(session), + _params(prompts_list_changed=True, resources_list_changed=True, resource_subscriptions=["r://a"]), + ) + + tg.start_soon(run) + await session.wait_for(1) + + await bus.publish(ToolsListChanged()) # not requested + await bus.publish(ResourceUpdated(uri="r://other")) # URI not subscribed + await bus.publish(PromptsListChanged()) + await bus.publish(ResourcesListChanged()) + await bus.publish(ResourceUpdated(uri="r://a")) + await session.wait_for(4) + handler.close() + + delivered = [notification for notification, _ in session.sent[1:]] + assert isinstance(delivered[0], PromptListChangedNotification) + assert isinstance(delivered[1], ResourceListChangedNotification) + assert isinstance(delivered[2], ResourceUpdatedNotification) + assert delivered[2].params.uri == "r://a" + assert delivered[2].params.meta == {SUBSCRIPTION_ID_META_KEY: 7} + assert len(delivered) == 3 + + +@pytest.mark.anyio +async def test_empty_filter_honors_nothing_and_delivers_nothing() -> None: + """SDK-defined: falsy flags and an empty URI list are dropped from the ack + rather than echoed, and such a stream delivers nothing.""" + bus = InMemorySubscriptionBus() + handler = ListenHandler(bus) + session = _RecordingSession() + + async with anyio.create_task_group() as tg: + + async def run() -> None: + await handler(_ctx(session), _params(tools_list_changed=False, resource_subscriptions=[])) + + tg.start_soon(run) + await session.wait_for(1) + + ack, _ = session.sent[0] + assert isinstance(ack, SubscriptionsAcknowledgedNotification) + assert ack.params.notifications == SubscriptionFilter() + + for event in (ToolsListChanged(), PromptsListChanged(), ResourcesListChanged(), ResourceUpdated(uri="r://a")): + await bus.publish(event) + handler.close() + + assert len(session.sent) == 1 # the ack only + + +@pytest.mark.anyio +async def test_publish_after_close_is_dropped() -> None: + """SDK-defined: an event racing `close()` while the stream unwinds is dropped.""" + bus = InMemorySubscriptionBus() + handler = ListenHandler(bus) + session = _RecordingSession() + + async with anyio.create_task_group() as tg: + + async def run() -> None: + await handler(_ctx(session), _params(tools_list_changed=True)) + + tg.start_soon(run) + await session.wait_for(1) + + handler.close() + # The handler task has not resumed yet, so the listener is still + # subscribed but its stream is closed: the event is dropped. + await bus.publish(ToolsListChanged()) + + assert len(session.sent) == 1 + + +@pytest.mark.anyio +async def test_event_published_during_ack_send_is_delivered_after_the_ack() -> None: + """SDK-defined: the stream subscribes before sending the ack, so an event + published while the ack write is suspended is buffered and delivered after + it - never lost, and never ahead of the ack frame.""" + bus = InMemorySubscriptionBus() + handler = ListenHandler(bus) + + class _PublishDuringAck(_RecordingSession): + async def send_notification( + self, notification: ServerNotification, related_request_id: RequestId | None = None + ) -> None: + if not self.sent: + # Publish while the handler is still inside the ack send. + await bus.publish(ToolsListChanged()) + await super().send_notification(notification, related_request_id) + + session = _PublishDuringAck() + + async with anyio.create_task_group() as tg: + + async def run() -> None: + await handler(_ctx(session), _params(tools_list_changed=True)) + + tg.start_soon(run) + await session.wait_for(2) + handler.close() + + assert isinstance(session.sent[0][0], SubscriptionsAcknowledgedNotification) + assert isinstance(session.sent[1][0], ToolListChangedNotification) + + +@pytest.mark.anyio +async def test_listen_requires_a_request_id() -> None: + """SDK-defined: a context without a request id cannot open a stream.""" + handler = ListenHandler(InMemorySubscriptionBus()) + + with pytest.raises(MCPError) as exc_info: + await handler(_ctx(_RecordingSession(), request_id=None), _params()) + assert exc_info.value.error.code == INVALID_REQUEST + + +def test_close_without_open_streams_is_a_no_op() -> None: + """SDK-defined: `close()` with nothing open does nothing.""" + ListenHandler(InMemorySubscriptionBus()).close() + + +@pytest.mark.anyio +async def test_raising_listener_is_isolated_from_others() -> None: + """SDK-defined: one raising listener is logged and skipped; later listeners + and the publishing handler are unaffected.""" + bus = InMemorySubscriptionBus() + + def bad(event: ServerEvent) -> None: + raise RuntimeError("boom") + + seen: list[ServerEvent] = [] + bus.subscribe(bad) + bus.subscribe(seen.append) + + await bus.publish(ToolsListChanged()) + assert seen == [ToolsListChanged()] + + +@pytest.mark.anyio +async def test_raising_unsubscribe_does_not_skip_stream_cleanup() -> None: + """SDK-defined: a custom bus whose unsubscribe callable raises is logged + and isolated - the stream still releases its subscription slot, closes its + buffers, and returns the graceful result.""" + + class _RaisingUnsubscribeBus(InMemorySubscriptionBus): + def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]: + super().subscribe(listener) + + def unsubscribe() -> None: + raise RuntimeError("boom") + + return unsubscribe + + handler = ListenHandler(_RaisingUnsubscribeBus(), max_subscriptions=1) + session = _RecordingSession() + results: list[SubscriptionsListenResult] = [] + + async with anyio.create_task_group() as tg: + + async def run() -> None: + results.append(await handler(_ctx(session), _params(tools_list_changed=True))) + + tg.start_soon(run) + await session.wait_for(1) + handler.close() + + assert results[0].meta == {SUBSCRIPTION_ID_META_KEY: 7} # the graceful result still returned + + # The slot was released despite the raising unsubscribe: a second listen + # is accepted at the cap of one. + late_session = _RecordingSession() + late_results: list[SubscriptionsListenResult] = [] + + async with anyio.create_task_group() as tg: + + async def run_late() -> None: + late_results.append(await handler(_ctx(late_session, request_id=8), _params(tools_list_changed=True))) + + tg.start_soon(run_late) + await late_session.wait_for(1) + handler.close() + + assert late_results[0].meta == {SUBSCRIPTION_ID_META_KEY: 8} + + +@pytest.mark.anyio +async def test_subscription_limit_rejects_further_streams_pre_ack() -> None: + """SDK-defined cap (mirrors the TypeScript SDK): past `max_subscriptions`, + a listen request is rejected before any ack frame.""" + handler = ListenHandler(InMemorySubscriptionBus(), max_subscriptions=1) + session = _RecordingSession() + + async with anyio.create_task_group() as tg: + + async def run() -> None: + await handler(_ctx(session), _params(tools_list_changed=True)) + + tg.start_soon(run) + await session.wait_for(1) + + rejected_session = _RecordingSession() + with pytest.raises(MCPError) as exc_info: + await handler(_ctx(rejected_session, request_id=8), _params()) + assert exc_info.value.error.message == "Subscription limit reached" + assert rejected_session.sent == [] + + handler.close() + + +class _GatedSession(_RecordingSession): + """Lets the ack through, then wedges event sends until released - a client + that stopped reading the transport.""" + + def __init__(self) -> None: + super().__init__() + self.wedged = anyio.Event() + self.release = anyio.Event() + + async def send_notification( + self, notification: ServerNotification, related_request_id: RequestId | None = None + ) -> None: + if self.sent: # the ack is the first frame; only event sends wedge + self.wedged.set() + await self.release.wait() + await super().send_notification(notification, related_request_id) + + +@pytest.mark.anyio +async def test_backlog_overflow_ends_the_stream_and_frees_its_slot() -> None: + """SDK-defined: a stream whose client stopped reading is ended at + `max_buffered_events` rather than buffering forever. The subscription slot + frees at overflow time - the stream's own cleanup may be wedged in a + transport write nothing can wake - and the backlog still drains into the + stamped graceful result once that write completes.""" + bus = InMemorySubscriptionBus() + handler = ListenHandler(bus, max_subscriptions=1, max_buffered_events=1) + session = _GatedSession() + results: list[SubscriptionsListenResult] = [] + late_session = _RecordingSession() + late_results: list[SubscriptionsListenResult] = [] + + async with anyio.create_task_group() as tg: + + async def run() -> None: + results.append(await handler(_ctx(session), _params(tools_list_changed=True))) + + tg.start_soon(run) + await session.wait_for(1) + + await bus.publish(ToolsListChanged()) # consumed, then wedged mid-send + with anyio.fail_after(5): + await session.wedged.wait() + await bus.publish(ToolsListChanged()) # fills the one-slot buffer + await bus.publish(ToolsListChanged()) # overflows: the stream is ended + + async def run_late() -> None: + late_results.append(await handler(_ctx(late_session, request_id=8), _params(tools_list_changed=True))) + + # The ended stream's slot is free immediately - a new listen does not + # wait for the wedged write to die with its connection. + tg.start_soon(run_late) + await late_session.wait_for(1) + + session.release.set() + handler.close() + + delivered = [notification for notification, _ in session.sent[1:]] + assert len(delivered) == 2 # the wedged event and the buffered one still drained + assert results[0].meta == {SUBSCRIPTION_ID_META_KEY: 7} + assert late_results[0].meta == {SUBSCRIPTION_ID_META_KEY: 8} + + +@pytest.mark.anyio +async def test_same_task_publish_burst_does_not_overflow_a_healthy_stream() -> None: + """SDK-defined: `publish` ends with a checkpoint, so a burst of events from + one task (no yields of its own) lets a reading stream drain between + publishes instead of deterministically overflowing the buffer.""" + bus = InMemorySubscriptionBus() + handler = ListenHandler(bus, max_buffered_events=99) + session = _RecordingSession() + + async with anyio.create_task_group() as tg: + + async def run() -> None: + await handler(_ctx(session), _params(tools_list_changed=True)) + + tg.start_soon(run) + await session.wait_for(1) + + for _ in range(100): + await bus.publish(ToolsListChanged()) + await session.wait_for(101) + handler.close() + + assert len(session.sent) == 101 # the ack plus every event in the burst From 410cc0db31650d0ec5f2b7b9c3b36304750f883e Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:03:11 +0100 Subject: [PATCH 038/100] Add v2 feedback issue template (#3037) --- .github/ISSUE_TEMPLATE/v2-feedback.yaml | 59 +++++++++++++++++++++++++ README.md | 2 +- docs/index.md | 1 + 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 .github/ISSUE_TEMPLATE/v2-feedback.yaml diff --git a/.github/ISSUE_TEMPLATE/v2-feedback.yaml b/.github/ISSUE_TEMPLATE/v2-feedback.yaml new file mode 100644 index 0000000000..35ed633d5d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/v2-feedback.yaml @@ -0,0 +1,59 @@ +name: v2 feedback +description: Bugs, API friction, or docs gaps in v2 of the SDK +title: "[v2] " +labels: ["v2-alpha"] + +body: + - type: markdown + attributes: + value: | + Thanks for trying v2. Anything that broke, surprised you, or slowed you down is useful — API feedback is explicitly welcome while v2 is in pre-release. + + Docs: https://py.sdk.modelcontextprotocol.io/v2/ · Migration from v1: https://py.sdk.modelcontextprotocol.io/v2/migration/ + + - type: textarea + id: what + attributes: + label: What happened? + description: What did you do, and what went wrong (or felt wrong)? Paste error output verbatim if there is any. + validations: + required: true + + - type: textarea + id: expected + attributes: + label: What did you expect? + validations: + required: false + + - type: textarea + id: repro + attributes: + label: Code to reproduce + description: The smallest snippet or repository that shows it. For docs feedback, link the page instead. + render: Python + validations: + required: false + + - type: input + id: version + attributes: + label: SDK version + description: The published version (`pip show mcp`) or commit. + validations: + required: false + + - type: dropdown + id: area + attributes: + label: Area + options: + - Server + - Client + - Transports + - Auth + - Documentation + - Migration + - Other + validations: + required: false diff --git a/README.md b/README.md index 88b74ad97c..8074351fd2 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ > > **v1.x is the only stable release line and remains recommended for production.** It lives on the [`v1.x` branch](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x) and continues to receive critical bug fixes and security patches; see [the v1.x README](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/README.md) for its documentation. `pip` and `uv` don't select a pre-release unless you explicitly request one, so existing installs are unaffected. **If your package depends on `mcp`, add a `<2` upper bound to your version constraint (for example `mcp>=1.27,<2`) before the stable release lands.** > -> v2 is a major rework of the SDK, both to support the [2026-07-28 MCP specification release](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) and to fix long-standing architectural issues. See the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/) for what's changed. Stable v2 is targeted for 2026-07-27, alongside the spec release. Try the pre-releases and tell us what breaks: [#python-sdk-dev on the MCP Contributors Discord](https://discord.gg/6CSzBmMkjX). +> v2 is a major rework of the SDK, both to support the [2026-07-28 MCP specification release](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) and to fix long-standing architectural issues. See the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/) for what's changed. Stable v2 is targeted for 2026-07-27, alongside the spec release. Try the pre-releases and [tell us what breaks](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml) — or discuss in [#python-sdk-dev on the MCP Contributors Discord](https://discord.gg/6CSzBmMkjX). ## Documentation diff --git a/docs/index.md b/docs/index.md index e0b82f8b08..3a82b5d505 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,6 +2,7 @@ !!! info "You are viewing the in-development v2 documentation" For the current stable release, see the [v1.x documentation](https://py.sdk.modelcontextprotocol.io/). + Trying v2? [Tell us what you find](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml) — it is the most useful thing you can do for the SDK right now. The **Model Context Protocol (MCP)** lets applications provide context to LLMs in a standardized way, separating the concern of *providing* context from the LLM interaction itself. From dcf8a6a0b5c67550aab973e81d9661d9dcfe2932 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:03:17 +0100 Subject: [PATCH 039/100] Document pydantic.ValidationError in client Raises sections (#3036) --- src/mcp/client/client.py | 6 ++++++ src/mcp/client/session.py | 2 ++ 2 files changed, 8 insertions(+) diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index c42a29284c..d581fe6a5e 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -629,6 +629,8 @@ async def read_resource( Raises: InputRequiredRoundsExceededError: `input_required_max_rounds` exhausted. MCPError: A callback returned `ErrorData` for an embedded input request. + pydantic.ValidationError: The server returned a result that does not + conform to the negotiated protocol version. """ async def retry(r: InputResponses | None, s: str | None) -> ReadResourceResult | InputRequiredResult: @@ -711,6 +713,8 @@ async def call_tool( Raises: InputRequiredRoundsExceededError: `input_required_max_rounds` exhausted. MCPError: A callback returned `ErrorData` for an embedded input request. + pydantic.ValidationError: The server returned a result that does not + conform to the negotiated protocol version. """ async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | InputRequiredResult | Result: @@ -787,6 +791,8 @@ async def get_prompt( Raises: InputRequiredRoundsExceededError: `input_required_max_rounds` exhausted. MCPError: A callback returned `ErrorData` for an embedded input request. + pydantic.ValidationError: The server returned a result that does not + conform to the negotiated protocol version. """ async def retry(r: InputResponses | None, s: str | None) -> GetPromptResult | InputRequiredResult: diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index e6ae766d99..804180e05e 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -452,6 +452,8 @@ async def send_request( MCPError: Error response, read timeout, or connection closed. RuntimeError: Called before entering the context manager. ValueError: The request declares `name_param` but its params carry no string name. + pydantic.ValidationError: The server returned a result that does not + conform to the negotiated protocol version. """ data = request.model_dump(by_alias=True, mode="json", exclude_none=True) method: str = data["method"] From e50fb5be195b59748dd2aa82bf42f4e5c91bce46 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:11:56 +0100 Subject: [PATCH 040/100] Serve the 2026-07-28 era over stdio and other stream-pair transports (#3038) --- src/mcp/server/connection.py | 27 ++- src/mcp/server/lowlevel/server.py | 12 +- src/mcp/server/runner.py | 227 +++++++++++++++++++- tests/client/test_client.py | 43 +++- tests/server/test_runner.py | 341 +++++++++++++++++++++++++++++- tests/server/test_stdio.py | 151 ++++++++++--- 6 files changed, 757 insertions(+), 44 deletions(-) diff --git a/src/mcp/server/connection.py b/src/mcp/server/connection.py index 4d9496fef1..73e775a914 100644 --- a/src/mcp/server/connection.py +++ b/src/mcp/server/connection.py @@ -100,6 +100,30 @@ async def notify(self, method: str, params: Mapping[str, Any] | None, opts: Call _NO_CHANNEL = _NoChannelOutbound() +class NotifyOnlyOutbound: + """Connection-scoped `Outbound` that forwards notifications and refuses requests. + + Installed by `serve_dual_era_loop` for modern (2026-07-28+) connections + over duplex stream transports: the pipe is real, so server notifications + ride it, but the modern protocol forbids server-initiated JSON-RPC + requests, so `send_raw_request` refuses by construction. + """ + + def __init__(self, outbound: Outbound) -> None: + self._outbound = outbound + + async def send_raw_request( + self, + method: str, + params: Mapping[str, Any] | None, + opts: CallOptions | None = None, + ) -> dict[str, Any]: + raise NoBackChannelError(method) + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: + await self._outbound.notify(method, params, opts) + + class Connection: """Per-client connection state and standalone-stream `Outbound`. @@ -167,7 +191,8 @@ def from_envelope( both supplied) are recorded as `client_params` so capability checks work. `outbound` defaults to the no-channel sentinel for the single-exchange HTTP path; duplex modern transports (e.g. stdio) pass - the dispatcher so server-initiated messages have a back-channel. + a notify-only wrapper around the dispatcher so server notifications + ride the pipe while server-initiated requests stay refused. """ client_params = None if client_info is not None and client_capabilities is not None: diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 66c497199a..81eaa2b86a 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -62,7 +62,7 @@ async def main(): from mcp.server.caching import CacheableMethod, CacheHint, validate_cache_hints from mcp.server.context import HandlerResult, ServerMiddleware, ServerRequestContext from mcp.server.models import InitializationOptions -from mcp.server.runner import serve_loop +from mcp.server.runner import serve_dual_era_loop from mcp.server.streamable_http import EventStore from mcp.server.streamable_http_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager from mcp.server.transport_security import TransportSecuritySettings @@ -689,12 +689,14 @@ async def run( ) -> None: """Serve a single connection over the given streams until the read side closes. - Thin wrapper over `serve_loop`: enters the server lifespan, - then drives the loop. Transports with their own lifespan owner - (the streamable-HTTP manager) call `serve_loop` directly instead. + Thin wrapper over `serve_dual_era_loop`: enters the server lifespan, + then drives the loop, serving the legacy handshake era and the modern + per-request-envelope era (the first era-distinctive message locks the + connection). Transports with their own lifespan owner (the + streamable-HTTP manager) call `serve_loop` directly instead. """ async with self.lifespan(self) as lifespan_context: - await serve_loop( + await serve_dual_era_loop( self, read_stream, write_stream, diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index 6aa9cd6d5c..d5783a5981 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -5,8 +5,8 @@ pure kernel: it holds a pre-populated `Connection` and reads `connection.protocol_version` / `connection.outbound` as facts. Driving a dispatcher loop and tearing down the connection live in the free-function -drivers (`serve_connection`, `serve_loop`, `serve_one`); the entry constructs -the `Connection`, the driver tears it down. +drivers (`serve_connection`, `serve_loop`, `serve_dual_era_loop`, `serve_one`); +the entry constructs the `Connection`, the driver tears it down. `ServerRunner` holds a `Server` directly - `Server` is the registry. """ @@ -17,7 +17,7 @@ from collections.abc import Awaitable, Mapping from dataclasses import KW_ONLY, dataclass from functools import cached_property, partial -from typing import TYPE_CHECKING, Any, Generic, cast +from typing import TYPE_CHECKING, Any, Generic, Literal, cast import anyio import anyio.abc @@ -26,31 +26,41 @@ CLIENT_INFO_META_KEY, INTERNAL_ERROR, INVALID_PARAMS, + INVALID_REQUEST, METHOD_NOT_FOUND, PROTOCOL_VERSION_META_KEY, + UNSUPPORTED_PROTOCOL_VERSION, CacheableResult, ErrorData, Implementation, InitializeRequestParams, InitializeResult, + RequestId, RequestParams, RequestParamsMeta, + UnsupportedProtocolVersionErrorData, ) from mcp_types import methods as _methods -from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS, LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION +from mcp_types.version import ( + HANDSHAKE_PROTOCOL_VERSIONS, + LATEST_HANDSHAKE_VERSION, + LATEST_MODERN_VERSION, + MODERN_PROTOCOL_VERSIONS, +) from pydantic import BaseModel, ValidationError from typing_extensions import TypeVar from mcp.server.caching import apply_cache_hint -from mcp.server.connection import Connection +from mcp.server.connection import Connection, NotifyOnlyOutbound from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext from mcp.server.models import InitializationOptions from mcp.server.session import ServerSession from mcp.shared._stream_protocols import ReadStream, WriteStream -from mcp.shared.dispatcher import DispatchContext, Dispatcher, OnNotify, OnRequest -from mcp.shared.exceptions import MCPError +from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, OnNotify, OnRequest +from mcp.shared.exceptions import MCPError, NoBackChannelError +from mcp.shared.inbound import InboundLadderRejection, classify_inbound_request from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher -from mcp.shared.message import ServerMessageMetadata, SessionMessage +from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMessage from mcp.shared.transport_context import TransportContext if TYPE_CHECKING: @@ -63,6 +73,7 @@ "aclose_shielded", "modern_on_request", "serve_connection", + "serve_dual_era_loop", "serve_loop", "serve_one", ] @@ -427,6 +438,206 @@ async def serve_loop( ) +_MODERN_ENVELOPE_KEYS = (PROTOCOL_VERSION_META_KEY, CLIENT_INFO_META_KEY, CLIENT_CAPABILITIES_META_KEY) + + +def _has_modern_envelope(params: Mapping[str, Any] | None) -> bool: + """Whether `params._meta` carries every reserved modern-envelope key. + + Era evidence is the FULL key triple - bare `_meta` is not (legacy traffic + carries `progressToken` there). + """ + if not params: + return False + meta = params.get("_meta") + return isinstance(meta, Mapping) and all(key in meta for key in _MODERN_ENVELOPE_KEYS) + + +def _initialize_after_modern_data(params: Mapping[str, Any] | None) -> dict[str, Any]: + """Error data for an `initialize` arriving on a modern-locked connection. + + The typed -32022 payload when the client's proposed version is parseable; + otherwise just the supported list (the point is naming what we serve). + """ + requested = (params or {}).get("protocolVersion") + if isinstance(requested, str): + return UnsupportedProtocolVersionErrorData( + supported=list(MODERN_PROTOCOL_VERSIONS), requested=requested + ).model_dump(mode="json") + return {"supported": list(MODERN_PROTOCOL_VERSIONS)} + + +@dataclass +class _NoServerRequestsDispatchContext: + """Delegating `DispatchContext` that refuses server-initiated requests. + + Wraps the loop dispatcher's per-message context for modern-era dispatch: + the modern protocol forbids server-initiated JSON-RPC requests, so + `send_raw_request` refuses while notifications and progress still ride + the duplex pipe. + """ + + _inner: DispatchContext[TransportContext] + + @property + def transport(self) -> TransportContext: + return self._inner.transport + + @property + def can_send_request(self) -> bool: + return False + + @property + def request_id(self) -> RequestId | None: + return self._inner.request_id + + @property + def message_metadata(self) -> MessageMetadata: + return self._inner.message_metadata + + @property + def cancel_requested(self) -> anyio.Event: + return self._inner.cancel_requested + + async def send_raw_request( + self, + method: str, + params: Mapping[str, Any] | None, + opts: CallOptions | None = None, + ) -> dict[str, Any]: + raise NoBackChannelError(method) + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: + await self._inner.notify(method, params, opts) + + async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: + await self._inner.progress(progress, total, message) + + +async def serve_dual_era_loop( + server: Server[LifespanT], + read_stream: ReadStream[SessionMessage | Exception], + write_stream: WriteStream[SessionMessage], + *, + lifespan_state: LifespanT, + session_id: str | None = None, + init_options: InitializationOptions | None = None, + raise_exceptions: bool = False, +) -> None: + """Drive `server` over a duplex stream pair, serving both protocol eras. + + The stream-pair counterpart of the modern HTTP entry's era router. Era is + a property of the connection, decided by how the client opens it, and + mid-stream switching is undefined - so the first era-distinctive message + locks the connection (matching the typescript-sdk): + + - `initialize` locks legacy: the connection behaves exactly like + `serve_loop` for its lifetime, and modern envelope traffic is rejected + with INVALID_REQUEST. + - A request carrying the modern `_meta` envelope triple - or + `server/discover`, a modern-only method - locks modern: every request is + classified (`classify_inbound_request`) and served single-exchange via + `serve_one` with a born-ready per-request `Connection`, the same + dispatch model as the modern HTTP entry. A later `initialize` is + rejected with UNSUPPORTED_PROTOCOL_VERSION naming the modern versions. + + Modern connections push notifications over the duplex pipe but refuse + server-initiated requests on both channels (the modern protocol forbids + them). A rejected classification (malformed envelope, unsupported version) + never locks the era, so a failed probe leaves the legacy handshake + available - released auto-negotiating clients fall back on any error code + except -32022. + """ + dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher( + read_stream, + write_stream, + raise_handler_exceptions=raise_exceptions, + # `initialize` inline for the same pipelining reason as `serve_loop`; + # `server/discover` inline so the modern era lock commits before the + # next pipelined message is read. + inline_methods=frozenset({"initialize", "server/discover"}), + ) + loop_connection = Connection.for_loop(dispatcher, session_id=session_id) + loop_runner = ServerRunner(server, loop_connection, lifespan_state, init_options=init_options) + standalone_outbound = NotifyOnlyOutbound(dispatcher) + era: Literal["unlocked", "legacy", "modern"] = "unlocked" + modern_version = LATEST_MODERN_VERSION + + async def serve_modern( + dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None + ) -> dict[str, Any]: + nonlocal era, modern_version + route = classify_inbound_request({"method": method, "params": params}) + if isinstance(route, InboundLadderRejection): + raise MCPError(code=route.code, message=route.message, data=route.data) + if era != "modern": + era, modern_version = "modern", route.protocol_version + if method == "subscriptions/listen": + # The registered listen handler assumes the HTTP entry's stream + # semantics; served over a stream pair it would wedge. Reject until + # this transport grows its own listen design. + raise MCPError( + code=METHOD_NOT_FOUND, message="subscriptions/listen is not served over this transport", data=method + ) + connection = Connection.from_envelope( + route.protocol_version, + route.client_info, + route.client_capabilities, + outbound=standalone_outbound, + ) + return await serve_one( + server, + _NoServerRequestsDispatchContext(dctx), + method, + params, + connection=connection, + lifespan_state=lifespan_state, + ) + + async def on_request( + dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None + ) -> dict[str, Any]: + nonlocal era + if era == "legacy": + if method == "server/discover" or _has_modern_envelope(params): + raise MCPError( + code=INVALID_REQUEST, + message="connection is locked to the legacy handshake era; " + "modern envelope requests are not accepted", + ) + return await loop_runner.on_request(dctx, method, params) + if era == "modern" and method == "initialize": + raise MCPError( + code=UNSUPPORTED_PROTOCOL_VERSION, + message="connection already negotiated a modern protocol version", + data=_initialize_after_modern_data(params), + ) + if era == "modern" or method == "server/discover" or _has_modern_envelope(params): + return await serve_modern(dctx, method, params) + result = await loop_runner.on_request(dctx, method, params) + if method == "initialize": + # Lock only on success: a failed handshake leaves both eras open. + era = "legacy" + return result + + async def on_notify(dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None) -> None: + if era != "modern": + return await loop_runner.on_notify(dctx, method, params) + # The envelope is request-only, so notifications inherit the + # connection's locked version. + connection = Connection.from_envelope(modern_version, None, None, outbound=standalone_outbound) + notify_runner = ServerRunner(server, connection, lifespan_state) + try: + await notify_runner.on_notify(_NoServerRequestsDispatchContext(dctx), method, params) + finally: + await aclose_shielded(connection) + + try: + await dispatcher.run(on_request, on_notify) + finally: + await aclose_shielded(loop_connection) + + async def serve_one( server: Server[LifespanT], dctx: DispatchContext[TransportContext], diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 820478f3ff..5b4cc54786 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -408,8 +408,7 @@ async def check_context() -> str: 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.""" + an `initialize` handshake.""" with anyio.fail_after(5): async with ( mounted_app(simple_server) as (http, _), @@ -419,6 +418,46 @@ async def test_client_auto_mode_probes_discover_then_adopts(simple_server: Serve assert (await client.list_resources()).resources[0].name == "Test Resource" +@asynccontextmanager +async def _stream_loop_transport(server: Server) -> AsyncIterator[TransportStreams]: + """A Transport whose far end is `Server.run` over crossed memory streams - the stdio shape, in process.""" + async with ( + create_client_server_memory_streams() as ((client_read, client_write), (server_read, server_write)), + anyio.create_task_group() as tg, + ): + tg.start_soon(server.run, server_read, server_write, server.create_initialization_options()) + yield client_read, client_write + tg.cancel_scope.cancel() + + +async def test_client_auto_mode_negotiates_modern_over_a_stream_loop(simple_server: Server) -> None: + """`mode='auto'` against a real `Server.run` stream loop: the probe reaches the + dual-era driver, the connection locks modern, and feature requests are served + at 2026-07-28 with no `initialize` handshake.""" + with anyio.fail_after(5): + async with Client(_stream_loop_transport(simple_server), mode="auto") as client: + assert client.protocol_version == "2026-07-28" + assert (await client.list_resources()).resources[0].name == "Test Resource" + + +async def test_client_pinned_modern_mode_works_over_a_stream_loop(simple_server: Server) -> None: + """A pinned-modern client sends no probe: its first envelope-bearing request + locks the stream-loop connection modern and is served.""" + with anyio.fail_after(5): + async with Client(_stream_loop_transport(simple_server), mode="2026-07-28") as client: + assert client.protocol_version == "2026-07-28" + assert (await client.list_resources()).resources[0].name == "Test Resource" + + +async def test_client_legacy_mode_still_handshakes_over_a_stream_loop(simple_server: Server) -> None: + """`mode='legacy'` against the dual-era stream loop is byte-identical legacy: + the handshake runs and the session lands at a handshake-era version.""" + with anyio.fail_after(5): + async with Client(_stream_loop_transport(simple_server), mode="legacy") as client: + assert client.protocol_version == LATEST_HANDSHAKE_VERSION + assert (await client.list_resources()).resources[0].name == "Test Resource" + + @pytest.mark.parametrize("code", [types.METHOD_NOT_FOUND, types.REQUEST_TIMEOUT, types.INTERNAL_ERROR]) async def test_client_auto_mode_falls_back_to_initialize_on_legacy_signal(code: int) -> None: """`mode='auto'`: any JSON-RPC error from `server/discover` makes diff --git a/tests/server/test_runner.py b/tests/server/test_runner.py index 9200158459..8281e8897a 100644 --- a/tests/server/test_runner.py +++ b/tests/server/test_runner.py @@ -17,10 +17,15 @@ import anyio.abc import pytest from mcp_types import ( + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, INTERNAL_ERROR, INVALID_PARAMS, + INVALID_REQUEST, LATEST_PROTOCOL_VERSION, METHOD_NOT_FOUND, + PROTOCOL_VERSION_META_KEY, + UNSUPPORTED_PROTOCOL_VERSION, ClientCapabilities, ErrorData, Implementation, @@ -33,26 +38,35 @@ SetLevelRequestParams, Tool, ) -from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION, OLDEST_SUPPORTED_VERSION +from mcp_types.version import ( + LATEST_HANDSHAKE_VERSION, + LATEST_MODERN_VERSION, + MODERN_PROTOCOL_VERSIONS, + OLDEST_SUPPORTED_VERSION, +) import mcp.server.runner from mcp.server.caching import CacheHint -from mcp.server.connection import Connection +from mcp.server.connection import Connection, NotifyOnlyOutbound from mcp.server.context import ServerRequestContext from mcp.server.lowlevel.server import NotificationOptions, Server from mcp.server.models import InitializationOptions from mcp.server.runner import ( ServerRunner, _extract_meta, + _has_modern_envelope, + _initialize_after_modern_data, + _NoServerRequestsDispatchContext, aclose_shielded, serve_connection, + serve_dual_era_loop, serve_one, ) from mcp.server.session import ServerSession from mcp.shared.dispatcher import CallOptions -from mcp.shared.exceptions import MCPError +from mcp.shared.exceptions import MCPError, NoBackChannelError from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher -from mcp.shared.message import MessageMetadata +from mcp.shared.message import MessageMetadata, SessionMessage from mcp.shared.peer import dump_params from mcp.shared.transport_context import TransportContext @@ -1221,3 +1235,322 @@ async def test_serve_connection_drives_dispatcher_loop_and_tears_down(server: Sr assert cleaned == [1] assert conn.protocol_version == LATEST_HANDSHAKE_VERSION assert conn.client_params is not None + + +# --- serve_dual_era_loop ---------------------------------------------------- + + +def _modern_envelope(version: str = LATEST_MODERN_VERSION) -> dict[str, Any]: + return { + PROTOCOL_VERSION_META_KEY: version, + CLIENT_INFO_META_KEY: {"name": "test-client", "version": "1.0"}, + CLIENT_CAPABILITIES_META_KEY: {}, + } + + +def _modern_params(version: str = LATEST_MODERN_VERSION, **params: Any) -> dict[str, Any]: + return {**params, "_meta": _modern_envelope(version)} + + +@asynccontextmanager +async def dual_era_client(server: SrvT) -> AsyncIterator[tuple[JSONRPCDispatcher[TransportContext], Recorder]]: + """Yield `(client, recorder)` speaking raw frames to a `serve_dual_era_loop` server. + + The driver owns its dispatcher and connection, so unlike `connected_runner` + the harness hands it bare streams and performs no handshake: each test + drives the era lock itself. + """ + c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) + s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) + + def builder(_meta: object) -> TransportContext: + return TransportContext(kind="jsonrpc", can_send_request=True) + + client: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(s2c_recv, c2s_send, transport_builder=builder) + recorder = Recorder() + c_req, c_notify = echo_handlers(recorder) + body_exc: BaseException | None = None + async with anyio.create_task_group() as tg: + await tg.start(client.run, c_req, c_notify) + tg.start_soon(partial(serve_dual_era_loop, server, c2s_recv, s2c_send, lifespan_state=_LIFESPAN)) + try: + with anyio.fail_after(5): + yield client, recorder + except BaseException as e: + body_exc = e + tg.cancel_scope.cancel() + if body_exc is not None: + raise body_exc + + +@pytest.mark.anyio +async def test_dual_era_loop_discover_locks_modern_and_serves_envelope_requests(server: SrvT): + """`server/discover` over the loop returns a DiscoverResult and locks the + connection modern: envelope-bearing feature requests are then served + single-exchange, and a later legacy `initialize` is rejected with -32022 + naming the modern versions.""" + async with dual_era_client(server) as (client, _): + discover = await client.send_raw_request("server/discover", _modern_params()) + assert LATEST_MODERN_VERSION in discover["supportedVersions"] + assert "tools" in discover["capabilities"] + result = await client.send_raw_request("tools/list", _modern_params()) + assert result["tools"][0]["name"] == "t" + with pytest.raises(MCPError) as exc_info: + await client.send_raw_request("initialize", _initialize_params()) + assert exc_info.value.error.code == UNSUPPORTED_PROTOCOL_VERSION + assert exc_info.value.error.data == { + "supported": list(MODERN_PROTOCOL_VERSIONS), + "requested": LATEST_HANDSHAKE_VERSION, + } + + +@pytest.mark.anyio +async def test_dual_era_loop_pinned_modern_request_locks_without_a_probe(server: SrvT): + """A pinned-modern client sends no probe: its first envelope-bearing + feature request locks the connection modern directly.""" + async with dual_era_client(server) as (client, _): + result = await client.send_raw_request("tools/list", _modern_params()) + assert result["tools"][0]["name"] == "t" + with pytest.raises(MCPError) as exc_info: + await client.send_raw_request("initialize", _initialize_params()) + assert exc_info.value.error.code == UNSUPPORTED_PROTOCOL_VERSION + + +@pytest.mark.anyio +async def test_dual_era_loop_initialize_after_modern_lock_without_a_parseable_version(server: SrvT): + """An `initialize` with no string protocolVersion still gets the supported + list in the -32022 data (the typed payload needs a `requested` string).""" + async with dual_era_client(server) as (client, _): + await client.send_raw_request("server/discover", _modern_params()) + with pytest.raises(MCPError) as exc_info: + await client.send_raw_request("initialize", {"capabilities": {}}) + assert exc_info.value.error.code == UNSUPPORTED_PROTOCOL_VERSION + assert exc_info.value.error.data == {"supported": list(MODERN_PROTOCOL_VERSIONS)} + + +@pytest.mark.anyio +async def test_dual_era_loop_initialize_locks_legacy_and_rejects_modern_traffic(server: SrvT): + """After a successful handshake the connection is legacy for its lifetime: + `server/discover` and envelope-bearing requests are rejected with + INVALID_REQUEST while plain legacy requests keep working.""" + async with dual_era_client(server) as (client, _): + init = await client.send_raw_request("initialize", _initialize_params()) + assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION + with pytest.raises(MCPError) as discover_exc: + await client.send_raw_request("server/discover", _modern_params()) + with pytest.raises(MCPError) as envelope_exc: + await client.send_raw_request("tools/list", _modern_params()) + result = await client.send_raw_request("tools/list", None) + assert result["tools"][0]["name"] == "t" + assert discover_exc.value.error.code == INVALID_REQUEST + assert envelope_exc.value.error.code == INVALID_REQUEST + assert "locked to the legacy handshake era" in discover_exc.value.error.message + + +@pytest.mark.anyio +async def test_dual_era_loop_unsupported_modern_version_rejects_without_locking(server: SrvT): + """A probe at an unknown modern version gets -32022 with the supported + list, and the rejection does not lock the era: the legacy handshake still + succeeds afterwards (the released auto clients' retry/fallback contract).""" + async with dual_era_client(server) as (client, _): + with pytest.raises(MCPError) as exc_info: + await client.send_raw_request("server/discover", _modern_params(version="2099-01-01")) + init = await client.send_raw_request("initialize", _initialize_params()) + assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION + assert exc_info.value.error.code == UNSUPPORTED_PROTOCOL_VERSION + assert exc_info.value.error.data == { + "supported": list(MODERN_PROTOCOL_VERSIONS), + "requested": "2099-01-01", + } + + +@pytest.mark.anyio +async def test_dual_era_loop_bare_discover_rejects_without_locking(server: SrvT): + """A `server/discover` with no envelope triple is INVALID_PARAMS - never + -32022, so a released auto client's code-keyed fallback predicate takes the + legacy branch - and the connection can still complete the handshake.""" + async with dual_era_client(server) as (client, _): + with pytest.raises(MCPError) as exc_info: + await client.send_raw_request("server/discover", None) + init = await client.send_raw_request("initialize", _initialize_params()) + assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION + assert exc_info.value.error.code == INVALID_PARAMS + assert exc_info.value.error.code != UNSUPPORTED_PROTOCOL_VERSION + + +@pytest.mark.anyio +async def test_dual_era_loop_ping_before_any_lock_stays_exempt_and_neutral(server: SrvT): + """A pre-handshake `ping` is answered (the init-gate exemption) and does + not lock an era: the connection can still go modern.""" + async with dual_era_client(server) as (client, _): + assert await client.send_raw_request("ping", None) == {} + result = await client.send_raw_request("tools/list", _modern_params()) + assert result["tools"][0]["name"] == "t" + + +@pytest.mark.anyio +async def test_dual_era_loop_modern_request_without_envelope_rejects(server: SrvT): + """On a modern-locked connection every request is classified: one without + the envelope triple is INVALID_PARAMS.""" + async with dual_era_client(server) as (client, _): + await client.send_raw_request("server/discover", _modern_params()) + with pytest.raises(MCPError) as exc_info: + await client.send_raw_request("tools/list", None) + assert exc_info.value.error.code == INVALID_PARAMS + + +@pytest.mark.anyio +async def test_dual_era_loop_rejects_subscriptions_listen_on_modern(server: SrvT): + """`subscriptions/listen` is rejected before dispatch on the stream-pair + modern path: the registered handler assumes the HTTP entry's stream + semantics.""" + async with dual_era_client(server) as (client, _): + with pytest.raises(MCPError) as exc_info: + await client.send_raw_request("subscriptions/listen", _modern_params()) + assert exc_info.value.error.code == METHOD_NOT_FOUND + assert "not served over this transport" in exc_info.value.error.message + + +@pytest.mark.anyio +async def test_dual_era_loop_modern_notification_dispatches_at_locked_version(server: SrvT): + """Notifications carry no envelope, so on a modern-locked connection they + dispatch with the locked protocol version.""" + seen_versions: list[str] = [] + handled = anyio.Event() + + async def on_custom(ctx: Ctx, params: NotificationParams | None) -> None: + seen_versions.append(ctx.protocol_version) + handled.set() + + server.add_notification_handler("notifications/custom", NotificationParams, on_custom) + async with dual_era_client(server) as (client, _): + await client.send_raw_request("server/discover", _modern_params()) + await client.notify("notifications/custom", None) + await handled.wait() + assert seen_versions == [LATEST_MODERN_VERSION] + + +@pytest.mark.anyio +async def test_dual_era_loop_legacy_notifications_reach_the_loop_runner(server: SrvT): + """Before/after a legacy lock, notifications flow through the loop runner + exactly as under `serve_loop`.""" + handled = anyio.Event() + + async def on_custom(ctx: Ctx, params: NotificationParams | None) -> None: + handled.set() + + server.add_notification_handler("notifications/custom", NotificationParams, on_custom) + async with dual_era_client(server) as (client, _): + await client.send_raw_request("initialize", _initialize_params()) + await client.notify("notifications/custom", None) + await handled.wait() + + +@pytest.mark.anyio +async def test_dual_era_loop_modern_server_notifications_ride_the_pipe(server: SrvT): + """A modern handler's standalone notification reaches the client over the + duplex stream - the notify-only outbound forwards it.""" + + async def emit(ctx: Ctx, params: RequestParams | None) -> dict[str, Any]: + await ctx.session.send_tool_list_changed() + return {} + + server.add_request_handler("x/emit", RequestParams, emit) + async with dual_era_client(server) as (client, recorder): + await client.send_raw_request("x/emit", _modern_params()) + await recorder.notified.wait() + assert recorder.notifications[0][0] == "notifications/tools/list_changed" + + +@pytest.mark.anyio +async def test_dual_era_loop_modern_refuses_server_initiated_requests(server: SrvT): + """A modern handler attempting a server-initiated request gets + `NoBackChannelError` from the standalone channel: the modern protocol + forbids the frame, duplex pipe or not.""" + + async def wants_roots(ctx: Ctx, params: RequestParams | None) -> dict[str, Any]: + await ctx.session.list_roots() # pyright: ignore[reportDeprecated] + return {} # pragma: no cover - list_roots raises + + server.add_request_handler("x/roots", RequestParams, wants_roots) + async with dual_era_client(server) as (client, _): + with pytest.raises(MCPError) as exc_info: + await client.send_raw_request("x/roots", _modern_params()) + assert exc_info.value.error.code == INVALID_REQUEST + assert "no back-channel" in exc_info.value.error.message + + +def test_has_modern_envelope_requires_the_full_key_triple(): + assert not _has_modern_envelope(None) + assert not _has_modern_envelope({}) + assert not _has_modern_envelope({"_meta": None}) + assert not _has_modern_envelope({"_meta": {"progressToken": 1}}) + partial_meta = {k: v for k, v in _modern_envelope().items() if k != CLIENT_CAPABILITIES_META_KEY} + assert not _has_modern_envelope({"_meta": partial_meta}) + assert _has_modern_envelope(_modern_params()) + + +def test_initialize_after_modern_data_arms(): + typed = _initialize_after_modern_data({"protocolVersion": LATEST_HANDSHAKE_VERSION}) + assert typed == {"supported": list(MODERN_PROTOCOL_VERSIONS), "requested": LATEST_HANDSHAKE_VERSION} + assert _initialize_after_modern_data(None) == {"supported": list(MODERN_PROTOCOL_VERSIONS)} + assert _initialize_after_modern_data({"protocolVersion": 7}) == {"supported": list(MODERN_PROTOCOL_VERSIONS)} + + +class _RecordingInnerDctx: + """Minimal `DispatchContext` double recording delegated calls.""" + + def __init__(self) -> None: + self.transport = TransportContext(kind="jsonrpc", can_send_request=True) + self.can_send_request = True + self.request_id = 7 + self.message_metadata = None + self.cancel_requested = anyio.Event() + self.notifies: list[str] = [] + self.progresses: list[float] = [] + + async def send_raw_request( + self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None + ) -> dict[str, Any]: + raise AssertionError("must never be reached through the denying wrapper") # pragma: no cover + + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: + self.notifies.append(method) + + async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: + self.progresses.append(progress) + + +@pytest.mark.anyio +async def test_no_server_requests_dispatch_context_denies_requests_and_delegates_the_rest(): + inner = _RecordingInnerDctx() + wrapper = _NoServerRequestsDispatchContext(inner) + assert wrapper.can_send_request is False + assert wrapper.transport is inner.transport + assert wrapper.request_id == 7 + assert wrapper.message_metadata is None + assert wrapper.cancel_requested is inner.cancel_requested + with pytest.raises(NoBackChannelError): + await wrapper.send_raw_request("roots/list", None) + await wrapper.notify("notifications/progress", None) + await wrapper.progress(0.5) + assert inner.notifies == ["notifications/progress"] + assert inner.progresses == [0.5] + + +@pytest.mark.anyio +async def test_notify_only_outbound_forwards_notifications_and_refuses_requests(): + inner = _RecordingInnerDctx() + outbound = NotifyOnlyOutbound(inner) + await outbound.notify("notifications/tools/list_changed", None) + assert inner.notifies == ["notifications/tools/list_changed"] + with pytest.raises(NoBackChannelError): + await outbound.send_raw_request("ping", None) + + +@pytest.mark.anyio +async def test_dual_era_client_propagates_body_exception_unwrapped(server: SrvT): + """The harness re-raises body exceptions as-is, not as `ExceptionGroup`.""" + with pytest.raises(RuntimeError, match="boom"): + async with dual_era_client(server): + raise RuntimeError("boom") diff --git a/tests/server/test_stdio.py b/tests/server/test_stdio.py index 886bc51b54..f0c8b1c29b 100644 --- a/tests/server/test_stdio.py +++ b/tests/server/test_stdio.py @@ -7,7 +7,15 @@ import anyio import pytest -from mcp_types import JSONRPCMessage, JSONRPCRequest, JSONRPCResponse, jsonrpc_message_adapter +from mcp_types import ( + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + PROTOCOL_VERSION_META_KEY, + JSONRPCMessage, + JSONRPCRequest, + JSONRPCResponse, + jsonrpc_message_adapter, +) from mcp.server.mcpserver import MCPServer from mcp.server.stdio import stdio_server @@ -96,32 +104,108 @@ async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch) -> Non assert second.message == valid -class _KeepOpenBytesIO(io.BytesIO): - """A BytesIO that survives its TextIOWrapper being closed. +class _GatedStdin(io.RawIOBase): + """Raw stdin double: serves its frames, then blocks until released before EOF. - Lets the test read what was written after `run()` has torn the wrapper down. + A real stdio client keeps stdin open until it has read the responses it is + awaiting; an immediate EOF after the last frame races the dispatcher's + EOF-time cancellation of in-flight handlers (only inline-handled methods + would deterministically answer first). The blocked read sits in + `stdio_server`'s reader worker thread and unblocks on `release()`. """ + name = "" + + def __init__(self, payload: bytes) -> None: + self._pending = payload + self._released = threading.Event() + + def readable(self) -> bool: + return True + + def readinto(self, b: bytearray | memoryview) -> int: # pyright: ignore[reportIncompatibleMethodOverride] + if self._pending: + n = min(len(b), len(self._pending)) + b[:n] = self._pending[:n] + self._pending = self._pending[n:] + return n + # A missed release falls through to EOF after the bound; the caller's + # own response assertions then report what actually arrived. + self._released.wait(5) + return 0 + + def release(self) -> None: + self._released.set() + + +class _NotifyingStdout(io.RawIOBase): + """Raw stdout double that counts newline-terminated lines and can be awaited on. + + Survives wrapper close (`close()` is a no-op) so the test can read what was + written after `run()` has torn its TextIOWrapper down. + """ + + name = "" + + def __init__(self) -> None: + self._chunks: list[bytes] = [] + self._lines = 0 + self._cond = threading.Condition() + + def writable(self) -> bool: + return True + + def write(self, b: bytes | bytearray | memoryview) -> int: # pyright: ignore[reportIncompatibleMethodOverride] + data = bytes(b) + with self._cond: + self._chunks.append(data) + self._lines += data.count(b"\n") + self._cond.notify_all() + return len(data) + + def wait_for_lines(self, n: int, timeout: float = 5) -> bool: + with self._cond: + return self._cond.wait_for(lambda: self._lines >= n, timeout) + + def getvalue(self) -> bytes: + with self._cond: + return b"".join(self._chunks) + def close(self) -> None: pass -def _run_stdio_bounded(server: MCPServer) -> None: - """Run the blocking `server.run("stdio")` in a daemon thread joined with a 5s bound. +def _serve_stdio_and_collect( + monkeypatch: pytest.MonkeyPatch, server: MCPServer, frames: list[JSONRPCRequest], responses: int +) -> list[JSONRPCMessage]: + """Serve `frames` over process stdio and return the parsed response lines. - `run()` creates its own event loop, so a sync test cannot arm `anyio.fail_after`; - the join timeout turns a run loop that never returns on stdin EOF into a red test - instead of a silent CI hang. An exception escaping `run()` still fails the test: - pytest's unhandled-thread warning is escalated by `filterwarnings = ["error"]`. + Runs the blocking `server.run("stdio")` in a daemon thread (it creates its + own event loop, so a sync test cannot arm `anyio.fail_after`) and signals + stdin EOF only after `responses` lines arrive on stdout - the way a real + client closes the pipe - so spawned in-flight handlers never race the + dispatcher's EOF cancellation. The join bound turns a run loop that never + returns on stdin EOF into a red test instead of a silent CI hang; an + exception escaping `run()` still fails the test via pytest's + unhandled-thread warning, escalated by `filterwarnings = ["error"]`. """ + payload = "".join(f.model_dump_json(by_alias=True, exclude_none=True) + "\n" for f in frames).encode() + stdin = _GatedStdin(payload) + stdout = _NotifyingStdout() + monkeypatch.setattr(sys, "stdin", TextIOWrapper(stdin, encoding="utf-8")) + monkeypatch.setattr(sys, "stdout", TextIOWrapper(stdout, encoding="utf-8")) def target() -> None: server.run("stdio") thread = threading.Thread(target=target, daemon=True) thread.start() + arrived = stdout.wait_for_lines(responses) + stdin.release() thread.join(5) assert not thread.is_alive(), 'run("stdio") did not return after stdin EOF' + assert arrived, f"expected {responses} response line(s); stdout carried: {stdout.getvalue()!r}" + return [jsonrpc_message_adapter.validate_json(line) for line in stdout.getvalue().decode().splitlines()] def test_mcpserver_run_stdio_serves_until_stdin_closes(monkeypatch: pytest.MonkeyPatch) -> None: @@ -131,15 +215,10 @@ def test_mcpserver_run_stdio_serves_until_stdin_closes(monkeypatch: pytest.Monke rather than serving forever. """ ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping") - stdin_bytes = io.BytesIO(ping.model_dump_json(by_alias=True, exclude_none=True).encode() + b"\n") - captured = _KeepOpenBytesIO() - monkeypatch.setattr(sys, "stdin", TextIOWrapper(stdin_bytes, encoding="utf-8")) - monkeypatch.setattr(sys, "stdout", TextIOWrapper(captured, encoding="utf-8")) - _run_stdio_bounded(MCPServer(name="RunStdioServer")) + responses = _serve_stdio_and_collect(monkeypatch, MCPServer(name="RunStdioServer"), [ping], 1) - response = jsonrpc_message_adapter.validate_json(captured.getvalue().decode().strip()) - assert response == JSONRPCResponse(jsonrpc="2.0", id=1, result={}) + assert responses == [JSONRPCResponse(jsonrpc="2.0", id=1, result={})] def test_mcpserver_run_stdio_runs_lifespan_cleanup_after_stdin_closes(monkeypatch: pytest.MonkeyPatch) -> None: @@ -159,13 +238,37 @@ async def lifespan(server: MCPServer) -> AsyncIterator[None]: events.append("cleanup") ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping") - stdin_bytes = io.BytesIO(ping.model_dump_json(by_alias=True, exclude_none=True).encode() + b"\n") - captured = _KeepOpenBytesIO() - monkeypatch.setattr(sys, "stdin", TextIOWrapper(stdin_bytes, encoding="utf-8")) - monkeypatch.setattr(sys, "stdout", TextIOWrapper(captured, encoding="utf-8")) - _run_stdio_bounded(MCPServer(name="LifespanStdioServer", lifespan=lifespan)) + server = MCPServer(name="LifespanStdioServer", lifespan=lifespan) + responses = _serve_stdio_and_collect(monkeypatch, server, [ping], 1) assert events == ["setup", "cleanup"] - response = jsonrpc_message_adapter.validate_json(captured.getvalue().decode().strip()) - assert response == JSONRPCResponse(jsonrpc="2.0", id=1, result={}) + assert responses == [JSONRPCResponse(jsonrpc="2.0", id=1, result={})] + + +def test_mcpserver_run_stdio_serves_a_modern_connection(monkeypatch: pytest.MonkeyPatch) -> None: + """`MCPServer.run("stdio")` serves the modern era over process stdio. + + A `server/discover` probe gets a DiscoverResult (no initialize handshake) + and a subsequent envelope-bearing request is served at the discovered + version - the wire exchange `Client(mode='auto')` drives against a stdio + server. + """ + envelope = { + PROTOCOL_VERSION_META_KEY: "2026-07-28", + CLIENT_INFO_META_KEY: {"name": "probe", "version": "1.0"}, + CLIENT_CAPABILITIES_META_KEY: {}, + } + discover = JSONRPCRequest(jsonrpc="2.0", id=1, method="server/discover", params={"_meta": envelope}) + tools = JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list", params={"_meta": envelope}) + + responses = _serve_stdio_and_collect(monkeypatch, MCPServer(name="ModernStdioServer"), [discover, tools], 2) + + assert isinstance(responses[0], JSONRPCResponse) and responses[0].id == 1 + assert "2026-07-28" in responses[0].result["supportedVersions"] + assert responses[0].result["serverInfo"]["name"] == "ModernStdioServer" + assert isinstance(responses[1], JSONRPCResponse) and responses[1].id == 2 + # `resultType` is the modern-only wire field: its presence proves the + # request was served at the discovered version, not the handshake era. + assert responses[1].result["tools"] == [] + assert responses[1].result["resultType"] == "complete" From 0da90920376949705e834580c05a32784f626cfa Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:14:52 +0100 Subject: [PATCH 041/100] Point pre-release install pins at 2.0.0b1 (#3039) --- README.md | 4 ++-- docs/index.md | 8 ++++---- docs/installation.md | 10 +++++----- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 8074351fd2..0c0876bb66 100644 --- a/README.md +++ b/README.md @@ -41,10 +41,10 @@ Python 3.10+. ## Installation ```bash -uv add "mcp[cli]==2.0.0a3" # or: pip install "mcp[cli]==2.0.0a3" +uv add "mcp[cli]==2.0.0b1" # or: pip install "mcp[cli]==2.0.0b1" ``` -The pin matters while v2 is in pre-release: an unpinned install resolves to the latest stable v1.x, which this README does not describe. Check [PyPI](https://pypi.org/project/mcp/#history) for the newest pre-release, and use `uv run --with "mcp==2.0.0a3"` for one-off commands. +The pin matters while v2 is in pre-release: an unpinned install resolves to the latest stable v1.x, which this README does not describe. Check [PyPI](https://pypi.org/project/mcp/#history) for the newest pre-release, and use `uv run --with "mcp==2.0.0b1"` for one-off commands. ## A server in 15 lines diff --git a/docs/index.md b/docs/index.md index 3a82b5d505..fe700a0af9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -21,21 +21,21 @@ Python 3.10+. === "uv" ```bash - uv add "mcp[cli]==2.0.0a3" + uv add "mcp[cli]==2.0.0b1" ``` === "pip" ```bash - pip install "mcp[cli]==2.0.0a3" + pip install "mcp[cli]==2.0.0b1" ``` The `[cli]` extra gives you the `mcp` command; you'll want it for development. -!!! warning "Pin the version while v2 is in alpha" +!!! warning "Pin the version while v2 is in beta" Installers never select a pre-release unless you name one, so an unpinned `uv add "mcp[cli]"` gives you the latest **v1.x** release, which this documentation does not describe. Check - [PyPI](https://pypi.org/project/mcp/#history) for the newest alpha before you copy the line + [PyPI](https://pypi.org/project/mcp/#history) for the newest beta before you copy the line above. See [Installation](installation.md) for the details. ## Example diff --git a/docs/installation.md b/docs/installation.md index bc2a8281cf..4c26912517 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -2,27 +2,27 @@ The Python SDK is on PyPI as [`mcp`](https://pypi.org/project/mcp/). It requires **Python 3.10+**. -These docs describe **v2**, which is in alpha, so the version pin is not optional yet: +These docs describe **v2**, which is in beta, so the version pin is not optional yet: === "uv" ```bash - uv add "mcp[cli]==2.0.0a3" + uv add "mcp[cli]==2.0.0b1" ``` === "pip" ```bash - pip install "mcp[cli]==2.0.0a3" + pip install "mcp[cli]==2.0.0b1" ``` !!! warning "Why the pin" Installers never select a pre-release unless you name one, so an unpinned `uv add "mcp[cli]"` gives you the latest **v1.x** release, which these docs do not describe. Check the - [release history](https://pypi.org/project/mcp/#history) for the newest alpha before you copy + [release history](https://pypi.org/project/mcp/#history) for the newest beta before you copy the line above. - The same applies to one-off commands: `uv run --with "mcp==2.0.0a3" ...`, not `uv run --with mcp ...`. + The same applies to one-off commands: `uv run --with "mcp==2.0.0b1" ...`, not `uv run --with mcp ...`. If your *package* depends on `mcp`, add a `<2` upper bound (for example `mcp>=1.27,<2`) before the stable v2 lands so the major version bump doesn't surprise you. From d39c68df238222495bcd025bfe09d98fcffce16b Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:59:06 +0100 Subject: [PATCH 042/100] De-flake conformance CI: solo re-verification, spawn-storm reduction, result artifacts (#3043) --- .github/actions/conformance/run-client.sh | 104 ++++++++++++++++++ .github/workflows/conformance.yml | 39 ++++++- .gitignore | 3 + .../mcp_everything_server/server.py | 4 +- 4 files changed, 144 insertions(+), 6 deletions(-) create mode 100755 .github/actions/conformance/run-client.sh diff --git a/.github/actions/conformance/run-client.sh b/.github/actions/conformance/run-client.sh new file mode 100755 index 0000000000..3c96788772 --- /dev/null +++ b/.github/actions/conformance/run-client.sh @@ -0,0 +1,104 @@ +#!/bin/bash +# Run a client conformance suite, re-verifying unexpected failures solo. +# Concurrent suite runs on a 2-vCPU runner can push scenarios with real-time +# waits past tolerance; solo, a real failure fails again while a contention +# artifact passes. Failures that only reproduce under concurrency are excused. +set -uo pipefail + +: "${CONFORMANCE_PKG:?set CONFORMANCE_PKG (pinned in .github/workflows/conformance.yml)}" +# One attempt: a solo failure on the quiet runner disproves the contention +# hypothesis; a second try would be the blind retry this script avoids. +SOLO_ATTEMPTS="${CONFORMANCE_SOLO_ATTEMPTS:-1}" + +# Relative args resolve from the repo root; same contract as run-server.sh. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/../../.." || exit 1 + +log="$(mktemp)" +trap 'rm -f "$log"' EXIT + +npx --yes "$CONFORMANCE_PKG" client "$@" 2>&1 | tee "$log" +rc=${PIPESTATUS[0]} +if [ "$rc" -eq 0 ]; then + exit 0 +fi + +plain="$(sed 's/\x1b\[[0-9;]*m//g' "$log")" + +# If the harness's summary wording changes, the list comes up empty and the +# original exit code passes through - never a false green. +mapfile -t scenarios < <( + printf '%s\n' "$plain" | + sed -n '/^Unexpected failures (not in baseline):$/,/^$/p' | + sed -n 's/^ ✗ //p' +) +if [ "${#scenarios[@]}" -eq 0 ]; then + exit "$rc" +fi +for scenario in "${scenarios[@]}"; do + if ! [[ "$scenario" =~ ^[A-Za-z0-9/_-]+$ ]]; then + echo "Extracted unexpected-failure name '${scenario}' does not look like a scenario name; passing the suite failure through." >&2 + exit "$rc" + fi +done + +# A stale baseline entry is a configuration error a solo rerun cannot excuse. +# Here-string, not a pipe: grep -q quitting early would SIGPIPE printf and, +# under pipefail, skip this guard exactly when the pattern is present. +if grep -q '^Stale baseline entries' <<<"$plain"; then + echo "Suite also reported stale baseline entries; not retrying." >&2 + exit "$rc" +fi + +# Drop the suite-only flags: --scenario replaces --suite, and solo runs are +# judged directly rather than against the baseline. +rerun_args=() +output_dir="" +skip_next=0 +expect_output_dir=0 +for arg in "$@"; do + if [ "$skip_next" -eq 1 ]; then + if [ "$expect_output_dir" -eq 1 ]; then + output_dir="$arg" + fi + skip_next=0 + expect_output_dir=0 + continue + fi + case "$arg" in + --output-dir) + skip_next=1 + expect_output_dir=1 + ;; + --suite | --expected-failures) skip_next=1 ;; + --output-dir=*) output_dir="${arg#--output-dir=}" ;; + --suite=* | --expected-failures=*) ;; + *) rerun_args+=("$arg") ;; + esac +done +if [ -n "$output_dir" ]; then + rerun_args+=(--output-dir "${output_dir}-solo") +fi + +for scenario in "${scenarios[@]}"; do + passed=0 + for attempt in $(seq 1 "$SOLO_ATTEMPTS"); do + echo "" + echo "Re-running '${scenario}' solo (attempt ${attempt}/${SOLO_ATTEMPTS})..." + if npx --yes "$CONFORMANCE_PKG" client --scenario "$scenario" "${rerun_args[@]}"; then + passed=1 + break + fi + done + if [ "$passed" -ne 1 ]; then + echo "'${scenario}' still fails when run alone: real failure, not suite contention." >&2 + exit 1 + fi +done + +if [ -n "$output_dir" ]; then + mkdir -p "$output_dir" + printf '%s\n' "${scenarios[@]}" > "$output_dir/FLAKE_RESCUED" +fi +echo "All ${#scenarios[@]} unexpected failure(s) passed when re-run solo; the suite failures were parallel-run contention." +exit 0 diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 35f8b6dcc4..dd132698dd 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -64,17 +64,20 @@ jobs: ./.github/actions/conformance/run-server.sh --suite active --expected-failures ./.github/actions/conformance/expected-failures.yml + --output-dir conformance-results/server-active - name: Run server conformance (draft suite) run: >- ./.github/actions/conformance/run-server.sh --suite draft --expected-failures ./.github/actions/conformance/expected-failures.yml + --output-dir conformance-results/server-draft - name: Run server conformance (2026-07-28 wire, all suite) run: >- ./.github/actions/conformance/run-server.sh --suite all --spec-version 2026-07-28 --expected-failures ./.github/actions/conformance/expected-failures.2026-07-28.yml + --output-dir conformance-results/server-2026-07-28 - name: Run server conformance (all suite, extension scenarios) # A bare `--suite all` (no --spec-version) selects every scenario # shipped with the pinned harness — including the extension-tagged @@ -91,6 +94,15 @@ jobs: ./.github/actions/conformance/run-server.sh --suite all --expected-failures ./.github/actions/conformance/expected-failures.yml + --output-dir conformance-results/server-all + - name: Upload conformance results + # The log has only summary counts; per-check data is in checks.json. + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: server-conformance-results + path: conformance-results/ + if-no-files-found: ignore client-conformance: runs-on: ubuntu-latest @@ -118,22 +130,39 @@ jobs: echo "CONFORMANCE_PKG=file:/tmp/conformance.tgz" >> "$GITHUB_ENV" ;; esac - - run: uv sync --frozen --all-extras --package mcp + # --compile-bytecode: without it, ~40 concurrently spawned interpreters + # race to byte-compile site-packages during the timing-sensitive window. + - run: uv sync --frozen --all-extras --package mcp --compile-bytecode + - name: Pre-compile bytecode (editable sources) + run: uv run --frozen python -m compileall -q src .github/actions/conformance - name: Run client conformance (all suite) # The harness runs all scenarios via unbounded Promise.all; with 40 # scenarios on a 2-core runner the slowest one (sse-retry, which has a # real-time SSE reconnect wait) needs more than the 30s default budget. + # `.venv/bin/python` (not `uv run`) avoids lockfile re-checks in ~40 + # concurrent spawns; run-client.sh re-runs unexpected failures solo. run: >- - npx --yes "$CONFORMANCE_PKG" client - --command 'uv run --frozen python .github/actions/conformance/client.py' + ./.github/actions/conformance/run-client.sh + --command '.venv/bin/python .github/actions/conformance/client.py' --suite all --timeout 60000 --expected-failures ./.github/actions/conformance/expected-failures.yml + --output-dir conformance-results/client-all - name: Run client conformance (2026-07-28 wire, all suite) run: >- - npx --yes "$CONFORMANCE_PKG" client - --command 'uv run --frozen python .github/actions/conformance/client.py' + ./.github/actions/conformance/run-client.sh + --command '.venv/bin/python .github/actions/conformance/client.py' --suite all --timeout 60000 --spec-version 2026-07-28 --expected-failures ./.github/actions/conformance/expected-failures.2026-07-28.yml + --output-dir conformance-results/client-2026-07-28 + - name: Upload conformance results + # The log has only summary counts; per-check data is in checks.json. + # Also on FLAKE_RESCUED: rescued-flake evidence is otherwise discarded. + if: failure() || hashFiles('conformance-results/**/FLAKE_RESCUED') != '' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: client-conformance-results + path: conformance-results/ + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index 3443adf7c8..684f8d7b01 100644 --- a/.gitignore +++ b/.gitignore @@ -173,3 +173,6 @@ cython_debug/ # claude code results/ + +# conformance CI local runs +conformance-results/ diff --git a/examples/servers/everything-server/mcp_everything_server/server.py b/examples/servers/everything-server/mcp_everything_server/server.py index 90dc1f64f0..4b56a671c7 100644 --- a/examples/servers/everything-server/mcp_everything_server/server.py +++ b/examples/servers/everything-server/mcp_everything_server/server.py @@ -192,10 +192,12 @@ async def test_tool_with_progress(ctx: Context) -> str: async def test_sampling(prompt: str, ctx: Context) -> str: """Tests server-initiated sampling (LLM completion request)""" try: - # Request sampling from client + # Request sampling from client. Without related_request_id the request goes + # to the standalone GET stream and is silently dropped if it is not open yet. result = await ctx.session.create_message( # pyright: ignore[reportDeprecated] messages=[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))], max_tokens=100, + related_request_id=ctx.request_id, ) # Since we're not passing tools param, result.content is single content From 080f2a869d08f7030fc2055ade5e33745bc3b1aa Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:07:12 +0100 Subject: [PATCH 043/100] Harden the dual-era stream loop's era-lock and rejection semantics (#3040) --- docs/advanced/subscriptions.md | 6 + docs/client/protocol-versions.md | 6 +- docs/migration.md | 2 + src/mcp/client/_probe.py | 26 ++- src/mcp/server/_streamable_http_modern.py | 47 ++--- src/mcp/server/connection.py | 76 ++++--- src/mcp/server/lowlevel/server.py | 4 +- src/mcp/server/runner.py | 161 +++++++++++---- src/mcp/shared/inbound.py | 27 ++- tests/client/test_client.py | 62 ++++++ tests/client/test_probe.py | 83 +++++++- tests/docs_src/test_client_callbacks.py | 2 +- tests/server/test_runner.py | 229 +++++++++++++++++++++- tests/server/test_stdio.py | 10 +- tests/shared/test_inbound.py | 36 ++++ 15 files changed, 652 insertions(+), 125 deletions(-) diff --git a/docs/advanced/subscriptions.md b/docs/advanced/subscriptions.md index 46014ef772..6ff85dd86a 100644 --- a/docs/advanced/subscriptions.md +++ b/docs/advanced/subscriptions.md @@ -46,6 +46,12 @@ Two more things the stream is *not*: * **It is not a replay log.** A dropped stream is gone; events published while nobody was connected are not queued. The client's contract is to re-listen and re-fetch what it cares about. * **It is not the 2025 path.** Clients on earlier protocol versions that called `resources/subscribe` are served by `ctx.session.send_resource_updated(uri)` — the `notify_*` methods reach `subscriptions/listen` streams only. +!!! warning "Streamable HTTP only, for now" + `subscriptions/listen` is served on the streamable-HTTP transport. Over stdio (and other + stream-pair transports) a 2026-07-28 connection rejects it with METHOD_NOT_FOUND — the + open-stream semantics haven't been built for that transport yet, even though + `server/discover` still advertises the subscription capabilities there. + ## One process is the default. More takes a bus Publishes travel from your handler to the open streams over a `SubscriptionBus`. The default is in-memory: one process, every stream in it. That is the right answer until you run replicas behind a load balancer — then a client's stream is pinned to one replica, and a publish on another replica has to reach it. diff --git a/docs/client/protocol-versions.md b/docs/client/protocol-versions.md index 0d4b9ab974..43624549ce 100644 --- a/docs/client/protocol-versions.md +++ b/docs/client/protocol-versions.md @@ -26,9 +26,9 @@ Either way you come out connected, and `client.protocol_version` tells you which That is the whole feature. One `Client`, any era of server, no branching in your code. !!! info - `MCPServer` answers `server/discover`, so against your own in-memory server `auto` always lands - on `2026-07-28`. The fallback only ever fires against a real pre-2026 server, which is exactly - when you want it to. + `MCPServer` answers `server/discover` on every transport — in-memory, stdio, streamable + HTTP — so against your own server `auto` always lands on `2026-07-28`. The fallback only + ever fires against a real pre-2026 server, which is exactly when you want it to. ## `mode="legacy"` diff --git a/docs/migration.md b/docs/migration.md index a671ea4932..6cf4913f24 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -411,6 +411,8 @@ On the high-level `Client`, `client.server_capabilities`, `client.server_info`, In v1, connecting to a server always performed the `initialize` handshake. In v2, `Client` defaults to `mode='auto'`: on enter it probes `server/discover` and, if the server doesn't support it, falls back to the `initialize` handshake. Pass `mode='legacy'` to force the initialize handshake and reproduce v1's byte-identical pre-2026 behavior, or pass a modern protocol-version string (e.g. `mode='2026-07-28'`) to pin a version without probing. +The probe is transport-independent: v2 servers answer it over stdio (and any other stream-pair transport) as well as streamable HTTP, so `mode='auto'` lands on `2026-07-28` against a v2 server on every transport. If your stdio workflow relies on server-initiated requests (sampling, push elicitation), pass `mode='legacy'` — a 2026-07-28 connection refuses them on every transport. + For an in-process `Client(server)` (where `server` is a `Server` or `MCPServer` instance), `mode='auto'` dispatches calls directly through `DirectDispatcher` with no JSON-RPC framing. Pass `mode='legacy'` if you need the in-memory JSON-RPC transport that v1 used. `Client.send_ping()` is deprecated (ping is removed in 2026-07-28); pin `mode='legacy'` if you need it. diff --git a/src/mcp/client/_probe.py b/src/mcp/client/_probe.py index 39a5c52964..7e0754119b 100644 --- a/src/mcp/client/_probe.py +++ b/src/mcp/client/_probe.py @@ -11,6 +11,12 @@ the same path. Any non-``MCPError`` exception (network/connection errors, anyio cancellation, the ``RuntimeError`` from ``adopt()`` on no-mutual) propagates to the caller; an outage or in-process bug is never an era verdict. + +The fallback handshake itself can be answered with ``-32022`` — e.g. a probe +that timed out client-side but succeeded on a slow-starting server locked the +connection modern before the pipelined ``initialize`` arrived. That code is +itself positive modern evidence (it names the server's versions), so it +triggers one re-probe at a mutual version instead of failing the connect. """ from __future__ import annotations @@ -49,7 +55,8 @@ async def negotiate_auto(session: ClientSession) -> None: Raises: MCPError: The server is modern-only and shares no version with this - client (-32022 with a disjoint ``supported`` list). + client (-32022 with a disjoint ``supported`` list), or the + fallback handshake failed and one corrective re-probe did too. Exception: Any transport/network error from the probe propagates as-is. """ version = LATEST_MODERN_VERSION @@ -65,7 +72,22 @@ async def negotiate_auto(session: ClientSession) -> None: continue if supported is not None and not any(v in HANDSHAKE_PROTOCOL_VERSIONS for v in supported): raise # server is modern-only and disjoint — real incompatibility - await session.initialize() # every other rpc-error → legacy (the denylist) + try: + await session.initialize() # every other rpc-error → legacy (the denylist) + except MCPError as handshake_exc: + if handshake_exc.code != UNSUPPORTED_PROTOCOL_VERSION or attempt != 0: + raise + # -32022 from the handshake is itself modern evidence: a probe + # that timed out client-side but succeeded on the server locked + # the connection modern before this initialize arrived. Re-probe + # once at a version the server names; the era is already + # settled, so the second probe answers without the slow start. + supported = _parse_supported(handshake_exc.error.data) + mutual = [v for v in MODERN_PROTOCOL_VERSIONS if v in (supported or ())] + if not mutual: + raise + version = mutual[-1] + continue return # any other exception (httpx.TransportError, ConnectionError, anyio errors, # RuntimeError from adopt) → propagate diff --git a/src/mcp/server/_streamable_http_modern.py b/src/mcp/server/_streamable_http_modern.py index 52a9a70175..f612511568 100644 --- a/src/mcp/server/_streamable_http_modern.py +++ b/src/mcp/server/_streamable_http_modern.py @@ -22,7 +22,7 @@ import logging from collections.abc import Awaitable, Mapping from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Final, TypeVar, cast +from typing import TYPE_CHECKING, Any, Final, cast import anyio from anyio.streams.memory import MemoryObjectSendStream @@ -30,13 +30,10 @@ CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, HEADER_MISMATCH, - INTERNAL_ERROR, INVALID_REQUEST, PARSE_ERROR, PROTOCOL_VERSION_META_KEY, - ClientCapabilities, ErrorData, - Implementation, JSONRPCError, JSONRPCNotification, JSONRPCRequest, @@ -45,13 +42,13 @@ RequestId, ) from mcp_types import methods as _methods -from pydantic import BaseModel, ValidationError +from pydantic import ValidationError from starlette.requests import Request from starlette.responses import Response from starlette.types import Receive, Scope, Send from mcp.server.connection import Connection -from mcp.server.runner import serve_one +from mcp.server.runner import modern_error_data, serve_one from mcp.server.streamable_http import check_accept_headers from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings from mcp.shared.dispatcher import CallOptions @@ -65,7 +62,7 @@ find_duplicated_routing_header, validate_mcp_param_headers, ) -from mcp.shared.jsonrpc_dispatcher import handler_exception_to_error_data, progress_token_from_params +from mcp.shared.jsonrpc_dispatcher import progress_token_from_params from mcp.shared.message import MessageMetadata, ServerMessageMetadata from mcp.shared.transport_context import TransportContext @@ -74,7 +71,6 @@ logger = logging.getLogger(__name__) -_ModelT = TypeVar("_ModelT", bound=BaseModel) _OK_STATUS = 200 @@ -125,37 +121,20 @@ async def progress(self, progress: float, total: float | None = None, message: s await self.notify("notifications/progress", params) -def _typed(model: type[_ModelT], raw: Any) -> _ModelT | None: - """Validate the classifier's raw envelope value into a typed model. - - Rung 1 guarantees the envelope key was present; a ``null`` or mis-shaped - value falls through to ``ValidationError`` and is treated as not supplied - so the request still routes. - """ - try: - return model.model_validate(raw, by_name=False) - except ValidationError: - 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. + `serve_one`: `modern_error_data` maps the shared ladder and surfaces + anything else as a generic `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 JSONRPCError(jsonrpc="2.0", id=request_id, error=modern_error_data(exc)) return JSONRPCResponse(jsonrpc="2.0", id=request_id, result=result) @@ -251,8 +230,6 @@ async def _tool_input_schema( logger.debug("Mcp-Param header validation skipped: the request envelope fails tools/list validation") return None seen_cursors: set[str] = set() - client_info = _typed(Implementation, verdict.client_info) - client_capabilities = _typed(ClientCapabilities, verdict.client_capabilities) dctx = _SingleExchangeDispatchContext( transport=TransportContext(kind="streamable-http", can_send_request=False, headers=request.headers), request_id=request_id, @@ -260,7 +237,9 @@ async def _tool_input_schema( ) for _ in range(_MCP_PARAM_LIST_PAGE_CAP): # Fresh Connection per page: serve_one tears down the connection's exit stack on the way out. - connection = Connection.from_envelope(verdict.protocol_version, client_info, client_capabilities) + connection = Connection.from_envelope( + verdict.protocol_version, verdict.client_info, verdict.client_capabilities + ) try: result = await serve_one( app, dctx, "tools/list", list_params, connection=connection, lifespan_state=lifespan_state @@ -409,8 +388,8 @@ async def handle_modern_request( connection = Connection.from_envelope( verdict.protocol_version, - _typed(Implementation, verdict.client_info), - _typed(ClientCapabilities, verdict.client_capabilities), + verdict.client_info, + verdict.client_capabilities, ) dctx = _SingleExchangeDispatchContext( transport=TransportContext(kind="streamable-http", can_send_request=False, headers=request.headers), diff --git a/src/mcp/server/connection.py b/src/mcp/server/connection.py index 73e775a914..8cb7dc4213 100644 --- a/src/mcp/server/connection.py +++ b/src/mcp/server/connection.py @@ -42,7 +42,7 @@ ) from mcp_types import methods as _methods from mcp_types.version import LATEST_HANDSHAKE_VERSION -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError from typing_extensions import deprecated from mcp.shared.dispatcher import CallOptions, Outbound @@ -68,6 +68,23 @@ } +_ModelT = TypeVar("_ModelT", bound=BaseModel) + + +def _typed(model: type[_ModelT], raw: Any) -> _ModelT | None: + """Validate a raw envelope value into a typed model. + + A missing, null or mis-shaped value falls through to `ValidationError` + and is treated as not supplied so the request still routes. Spec methods + are separately re-validated by the kernel's per-version params surface, + which types the reserved `_meta` keys strictly. + """ + try: + return model.model_validate(raw, by_name=False) + except ValidationError: + return None + + def _notification_params(payload: dict[str, Any] | None, meta: Meta | None) -> dict[str, Any] | None: if not meta: return payload @@ -100,26 +117,18 @@ async def notify(self, method: str, params: Mapping[str, Any] | None, opts: Call _NO_CHANNEL = _NoChannelOutbound() -class NotifyOnlyOutbound: +class NotifyOnlyOutbound(_NoChannelOutbound): """Connection-scoped `Outbound` that forwards notifications and refuses requests. Installed by `serve_dual_era_loop` for modern (2026-07-28+) connections over duplex stream transports: the pipe is real, so server notifications ride it, but the modern protocol forbids server-initiated JSON-RPC - requests, so `send_raw_request` refuses by construction. + requests, so `send_raw_request` (inherited) refuses by construction. """ def __init__(self, outbound: Outbound) -> None: self._outbound = outbound - async def send_raw_request( - self, - method: str, - params: Mapping[str, Any] | None, - opts: CallOptions | None = None, - ) -> dict[str, Any]: - raise NoBackChannelError(method) - async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: await self._outbound.notify(method, params, opts) @@ -180,26 +189,34 @@ def __init__( def from_envelope( cls, protocol_version: str, - client_info: Implementation | None, - client_capabilities: ClientCapabilities | None, + client_info: Any, + client_capabilities: Any, *, outbound: Outbound = _NO_CHANNEL, ) -> Connection: """A born-ready connection populated from a request's `_meta` envelope. - `initialized` is set and the envelope's client info/capabilities (when - both supplied) are recorded as `client_params` so capability checks - work. `outbound` defaults to the no-channel sentinel for the - single-exchange HTTP path; duplex modern transports (e.g. stdio) pass - a notify-only wrapper around the dispatcher so server notifications - ride the pipe while server-initiated requests stay refused. + `protocol_version` must be an already-validated version string - the + inbound classification ladder owns rejecting non-string or unsupported + values. `client_info` and `client_capabilities` are the raw envelope + values: this constructor owns turning them into connection identity, + identically on every modern entry, so a mis-shaped value degrades to + not-supplied rather than failing the request. `initialized` + is set and the info/capabilities (when both supplied and well-formed) + are recorded as `client_params` so capability checks work. `outbound` + defaults to the no-channel sentinel for the single-exchange HTTP path; + duplex modern transports (e.g. stdio) pass a notify-only wrapper + around the dispatcher so server notifications ride the pipe while + server-initiated requests stay refused. """ + info = _typed(Implementation, client_info) + capabilities = _typed(ClientCapabilities, client_capabilities) client_params = None - if client_info is not None and client_capabilities is not None: + if info is not None and capabilities is not None: client_params = InitializeRequestParams( protocol_version=protocol_version, - capabilities=client_capabilities, - client_info=client_info, + capabilities=capabilities, + client_info=info, ) connection = cls(outbound, protocol_version=protocol_version, client_params=client_params) connection.initialized.set() @@ -230,7 +247,12 @@ def for_loop( def has_standalone_channel(self) -> bool: """Whether this connection has a real back-channel for server-initiated messages. Derived from `outbound` - the no-channel sentinel is the only - case that doesn't.""" + case that doesn't. + + Channel presence, not request permission: a modern (2026-07-28+) + duplex connection has a channel that carries notifications while + `send_raw_request` still refuses, because the protocol forbids + server-initiated requests.""" return self.outbound is not _NO_CHANNEL @property @@ -255,7 +277,9 @@ async def send_raw_request( Raises: MCPError: The peer responded with an error. - NoBackChannelError: `has_standalone_channel` is `False`. + NoBackChannelError: no back-channel for server-initiated requests - + `has_standalone_channel` is `False`, or a modern (2026-07-28+) + connection, where the protocol forbids them. """ return await self.outbound.send_raw_request(method, params, opts) @@ -316,7 +340,9 @@ async def ping(self, *, meta: Meta | None = None, opts: CallOptions | None = Non Raises: MCPError: The peer responded with an error. - NoBackChannelError: `has_standalone_channel` is `False`. + NoBackChannelError: no back-channel for server-initiated requests - + `has_standalone_channel` is `False`, or a modern (2026-07-28+) + connection, where the protocol forbids them. """ await self.send_raw_request("ping", dump_params(None, meta), opts) diff --git a/src/mcp/server/lowlevel/server.py b/src/mcp/server/lowlevel/server.py index 81eaa2b86a..dc83563967 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -691,8 +691,8 @@ async def run( Thin wrapper over `serve_dual_era_loop`: enters the server lifespan, then drives the loop, serving the legacy handshake era and the modern - per-request-envelope era (the first era-distinctive message locks the - connection). Transports with their own lifespan owner (the + per-request-envelope era (the first era-distinctive message to succeed + locks the connection). Transports with their own lifespan owner (the streamable-HTTP manager) call `serve_loop` directly instead. """ async with self.lifespan(self) as lifespan_context: diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index d5783a5981..3b53335ae4 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -15,7 +15,7 @@ import logging from collections.abc import Awaitable, Mapping -from dataclasses import KW_ONLY, dataclass +from dataclasses import KW_ONLY, dataclass, replace from functools import cached_property, partial from typing import TYPE_CHECKING, Any, Generic, Literal, cast @@ -59,7 +59,7 @@ from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, OnNotify, OnRequest from mcp.shared.exceptions import MCPError, NoBackChannelError from mcp.shared.inbound import InboundLadderRejection, classify_inbound_request -from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher +from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher, handler_exception_to_error_data from mcp.shared.message import MessageMetadata, ServerMessageMetadata, SessionMessage from mcp.shared.transport_context import TransportContext @@ -415,13 +415,14 @@ async def serve_loop( init_options: InitializationOptions | None = None, raise_exceptions: bool = False, ) -> None: - """Drive ``server`` in loop mode over a stream pair until the channel closes. + """Drive ``server`` in handshake-only loop mode over a stream pair until the channel closes. Builds the loop-mode `JSONRPCDispatcher` + `Connection` and hands them to - `serve_connection`, so loop-mode callers share one dispatcher-construction - recipe (notably the `inline_methods={"initialize"}` rule). Callers that own - a lifespan (the streamable-HTTP manager) pass it in; callers that don't - (`Server.run` for stdio/memory) enter the lifespan and then call this. + `serve_connection`. The streamable-HTTP manager (which owns its lifespan + and serves the modern era on the single-exchange entry instead) calls + this; `Server.run` drives `serve_dual_era_loop`, which extends the same + dispatcher recipe (notably the `inline_methods={"initialize"}` rule) with + era routing. """ dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher( read_stream, @@ -467,6 +468,23 @@ def _initialize_after_modern_data(params: Mapping[str, Any] | None) -> dict[str, return {"supported": list(MODERN_PROTOCOL_VERSIONS)} +def modern_error_data(exc: Exception) -> ErrorData: + """Map a modern request's handler exception to its wire `ErrorData`. + + The exception-to-wire fact shared by the modern entries (the + single-exchange HTTP path and the dual-era stream loop), so an identical + modern request fails identically on every transport: `MCPError` and + `ValidationError` map via the shared `handler_exception_to_error_data` + ladder; anything else is logged server-side and surfaced as a generic + INTERNAL_ERROR so handler internals never reach the wire. + """ + error = handler_exception_to_error_data(exc) + if error is not None: + return error + logger.exception("modern request handler raised") + return ErrorData(code=INTERNAL_ERROR, message="Internal server error") + + @dataclass class _NoServerRequestsDispatchContext: """Delegating `DispatchContext` that refuses server-initiated requests. @@ -481,7 +499,11 @@ class _NoServerRequestsDispatchContext: @property def transport(self) -> TransportContext: - return self._inner.transport + # Mask the per-message flag so the transport metadata agrees with this + # wrapper's denial: the modern HTTP entry builds its context with + # can_send_request=False, while the loop's default builder says True. + transport = self._inner.transport + return replace(transport, can_send_request=False) if transport.can_send_request else transport @property def can_send_request(self) -> bool: @@ -529,24 +551,39 @@ async def serve_dual_era_loop( The stream-pair counterpart of the modern HTTP entry's era router. Era is a property of the connection, decided by how the client opens it, and mid-stream switching is undefined - so the first era-distinctive message - locks the connection (matching the typescript-sdk): + to SUCCEED locks the connection (matching the typescript-sdk): - - `initialize` locks legacy: the connection behaves exactly like - `serve_loop` for its lifetime, and modern envelope traffic is rejected - with INVALID_REQUEST. + - A successful `initialize` locks legacy: the connection behaves exactly + like `serve_loop` for its lifetime, and modern envelope traffic is then + rejected with INVALID_REQUEST. `initialize` never routes modern - the + method is legacy-distinctive by definition - even when a confused + client stamps the envelope triple on it. - A request carrying the modern `_meta` envelope triple - or - `server/discover`, a modern-only method - locks modern: every request is - classified (`classify_inbound_request`) and served single-exchange via - `serve_one` with a born-ready per-request `Connection`, the same - dispatch model as the modern HTTP entry. A later `initialize` is - rejected with UNSUPPORTED_PROTOCOL_VERSION naming the modern versions. + `server/discover`, a modern-only method - is classified + (`classify_inbound_request`) and served single-exchange via `serve_one` + with a born-ready per-request `Connection`, the same dispatch model as + the modern HTTP entry. The first such request to succeed locks the + connection modern; a later `initialize` is then rejected with + UNSUPPORTED_PROTOCOL_VERSION naming the modern versions. Modern connections push notifications over the duplex pipe but refuse server-initiated requests on both channels (the modern protocol forbids - them). A rejected classification (malformed envelope, unsupported version) - never locks the era, so a failed probe leaves the legacy handshake - available - released auto-negotiating clients fall back on any error code - except -32022. + them). A request that fails - rejected classification, malformed envelope + content, unknown method - never locks either era, so a failed probe + leaves the legacy handshake available: released auto-negotiating clients + fall back on any error code except -32022, and that code is only emitted + for genuine version negotiation or for `initialize` on an + already-modern connection. + + The era lock rides the request's own dispatch. For the inline methods + (`initialize`, `server/discover`) that completes before the next frame is + read, so the canonical probe-then-go flow is race-free; a pinned-modern + client that pipelines frames ahead of its first response should expect + envelope-less notifications sent in that window to be dropped. The lock + settles exactly once: a request from the other era that was already in + flight when the lock committed may still complete and its response + stands, but the era does not move; and a success the peer cancelled away + (it sees "Request cancelled", not the result) does not lock either. """ dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher( read_stream, @@ -563,6 +600,15 @@ async def serve_dual_era_loop( era: Literal["unlocked", "legacy", "modern"] = "unlocked" modern_version = LATEST_MODERN_VERSION + def era_settles(dctx: DispatchContext[TransportContext]) -> bool: + # The one definition of "this request may lock the era": it settled as + # a client-visible success on a still-unlocked connection. The lock is + # monotone - the first success wins, so a straggling request from the + # other era can never overwrite a committed lock. A pending peer + # cancel means the dispatcher is about to replace this response with + # "Request cancelled": the client never sees the success, no lock. + return era == "unlocked" and not dctx.cancel_requested.is_set() + async def serve_modern( dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None ) -> dict[str, Any]: @@ -570,8 +616,6 @@ async def serve_modern( route = classify_inbound_request({"method": method, "params": params}) if isinstance(route, InboundLadderRejection): raise MCPError(code=route.code, message=route.message, data=route.data) - if era != "modern": - era, modern_version = "modern", route.protocol_version if method == "subscriptions/listen": # The registered listen handler assumes the HTTP entry's stream # semantics; served over a stream pair it would wedge. Reject until @@ -585,37 +629,58 @@ async def serve_modern( route.client_capabilities, outbound=standalone_outbound, ) - return await serve_one( - server, - _NoServerRequestsDispatchContext(dctx), - method, - params, - connection=connection, - lifespan_state=lifespan_state, - ) + try: + result = await serve_one( + server, + _NoServerRequestsDispatchContext(dctx), + method, + params, + connection=connection, + lifespan_state=lifespan_state, + ) + except (MCPError, ValidationError): + # The dispatcher's shared ladder maps these to the same wire error + # the modern HTTP entry produces. + raise + except Exception as exc: + if raise_exceptions: + raise + error = modern_error_data(exc) + raise MCPError(code=error.code, message=error.message, data=error.data) from exc + if era_settles(dctx): + era, modern_version = "modern", route.protocol_version + return result async def on_request( dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None ) -> dict[str, Any]: nonlocal era if era == "legacy": - if method == "server/discover" or _has_modern_envelope(params): + if _has_modern_envelope(params): raise MCPError( code=INVALID_REQUEST, message="connection is locked to the legacy handshake era; " "modern envelope requests are not accepted", ) + # Bare modern-only methods (e.g. `server/discover`) fall through to + # the loop runner's per-version surface validation - the same + # METHOD_NOT_FOUND a handshake-only server produced, byte for byte. return await loop_runner.on_request(dctx, method, params) - if era == "modern" and method == "initialize": - raise MCPError( - code=UNSUPPORTED_PROTOCOL_VERSION, - message="connection already negotiated a modern protocol version", - data=_initialize_after_modern_data(params), - ) - if era == "modern" or method == "server/discover" or _has_modern_envelope(params): + if era == "modern": + if method == "initialize": + raise MCPError( + code=UNSUPPORTED_PROTOCOL_VERSION, + message="connection already negotiated a modern protocol version", + data=_initialize_after_modern_data(params), + ) + return await serve_modern(dctx, method, params) + # Unlocked. `initialize` is legacy-distinctive by definition (the + # method does not exist at modern versions), so it takes the handshake + # path even when the envelope triple is stamped on it. + if method != "initialize" and (method == "server/discover" or _has_modern_envelope(params)): return await serve_modern(dctx, method, params) result = await loop_runner.on_request(dctx, method, params) - if method == "initialize": + if method == "initialize" and era_settles(dctx): # Lock only on success: a failed handshake leaves both eras open. era = "legacy" return result @@ -670,8 +735,11 @@ def modern_on_request(server: Server[LifespanT], lifespan_state: LifespanT) -> O 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). Like `serve_one`, this raises whatever the - handler chain raises - the dispatcher owns the exception-to-error mapping. + no `initialize` handshake). The dispatch context is wrapped in the + server-requests denial, so the modern prohibition on server-initiated + JSON-RPC requests holds on this entry like on the others. Like `serve_one`, + this raises whatever the handler chain raises - the dispatcher owns the + exception-to-error mapping. """ async def handle( @@ -683,6 +751,13 @@ async def handle( meta.get(CLIENT_INFO_META_KEY), meta.get(CLIENT_CAPABILITIES_META_KEY), ) - return await serve_one(server, dctx, method, params, connection=connection, lifespan_state=lifespan_state) + return await serve_one( + server, + _NoServerRequestsDispatchContext(dctx), + method, + params, + connection=connection, + lifespan_state=lifespan_state, + ) return handle diff --git a/src/mcp/shared/inbound.py b/src/mcp/shared/inbound.py index a1baf0f6e6..c3e0ea338f 100644 --- a/src/mcp/shared/inbound.py +++ b/src/mcp/shared/inbound.py @@ -385,8 +385,11 @@ def classify_inbound_request( body param → else :data:`~mcp_types.jsonrpc.HEADER_MISMATCH`. Runs before the supported-version rung so a client that disagrees with itself is told so, rather than told the body's version is unsupported. - 3. The envelope's protocol version is in `supported_modern_versions` → - else :data:`~mcp_types.jsonrpc.UNSUPPORTED_PROTOCOL_VERSION` with + 3. The envelope's protocol version is a string in + `supported_modern_versions` → non-string values are + :data:`~mcp_types.jsonrpc.INVALID_PARAMS` (a shape defect, not a + negotiation outcome), else + :data:`~mcp_types.jsonrpc.UNSUPPORTED_PROTOCOL_VERSION` with `data = {"supported": [...], "requested": }`. Method existence is *not* a rung: kernel dispatch owns that decision so @@ -411,9 +414,11 @@ def classify_inbound_request( message="params._meta must carry the reserved protocol-version, client-info and " "client-capabilities envelope keys", ) - if headers is not None: - if headers.get(MCP_PROTOCOL_VERSION_HEADER) != protocol_version: + version_header = headers.get(MCP_PROTOCOL_VERSION_HEADER) + # Presence is checked explicitly: a null body version would otherwise + # slip the equality check (None == None) and mask the absent header. + if version_header is None or version_header != protocol_version: return InboundLadderRejection( code=HEADER_MISMATCH, message=f"{MCP_PROTOCOL_VERSION_HEADER} header does not match the request envelope's protocol version", @@ -434,6 +439,20 @@ def classify_inbound_request( message=f"{MCP_NAME_HEADER} header does not match the request body's {name_key!r} parameter", ) + if not isinstance(protocol_version, str): + # Rung 3's precondition: a shape defect, not a version-negotiation + # outcome - -32022 is the one code auto-negotiating clients do NOT + # fall back from, and the typed rung-3 payload itself requires a + # string `requested`. Sits after the header rung, which fires first + # for every header-bearing entry (an absent version header is a + # mismatch, and a present one is a string that can never equal a + # non-string body value) - so this rejection is reachable only on + # header-less transports. + return InboundLadderRejection( + code=INVALID_PARAMS, + message="the protocol-version envelope value must be a string", + ) + if protocol_version not in supported_modern_versions: return InboundLadderRejection( code=UNSUPPORTED_PROTOCOL_VERSION, diff --git a/tests/client/test_client.py b/tests/client/test_client.py index 5b4cc54786..f8c02c9734 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -243,6 +243,26 @@ async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequ assert exc_info.value.__cause__ is None +async def test_modern_inproc_path_refuses_server_initiated_requests(): + """The in-process modern entry enforces the same prohibition as the other + modern entries: a handler's request-scoped server-initiated request is + refused server-side with the no-back-channel contract, instead of the + protocol-forbidden frame being delivered to the client.""" + + async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + schema = types.ElicitRequestedSchema(type="object", properties={"x": {"type": "string"}}) + await ctx.session.elicit_form("question", schema, related_request_id=ctx.request_id) + raise AssertionError("unreachable: elicit_form must refuse") # pragma: no cover + + server = Server("test", on_call_tool=handle_call_tool) + async with Client(server, mode="2026-07-28") as client: + with pytest.raises(MCPError) as exc_info: + await client.call_tool("asker", {}) + assert exc_info.value.error.code == types.INVALID_REQUEST + assert "no back-channel" in exc_info.value.error.message + assert "elicitation/create" in exc_info.value.error.message + + async def test_get_prompt(app: MCPServer): """Test getting a prompt.""" async with Client(app) as client: @@ -458,6 +478,48 @@ async def test_client_legacy_mode_still_handshakes_over_a_stream_loop(simple_ser assert (await client.list_resources()).resources[0].name == "Test Resource" +async def test_client_auto_mode_recovers_from_a_timed_out_probe_over_a_stream_loop( + simple_server: Server, monkeypatch: pytest.MonkeyPatch +) -> None: + """A probe that outlives the client's discover timeout still succeeds on the + (slow-starting) server and locks the connection modern; the fallback + handshake's -32022 is modern evidence, so one corrective re-probe completes + the connect instead of stranding `mode='auto'`.""" + monkeypatch.setattr("mcp.client.session.DISCOVER_TIMEOUT_SECONDS", 0.05) + c2relay_send, c2relay_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) + relay2s_send, relay2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) + s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) + + async def relay() -> None: + # Hold the client's first frame (the probe) until its second frame (the + # post-timeout initialize) arrives - the deterministic stand-in for a + # server too slow to answer before the client's discover timeout. + held: SessionMessage | Exception | None = None + first = True + async for item in c2relay_recv: + if first: + held, first = item, False + continue + if held is not None: + await relay2s_send.send(held) + held = None + await relay2s_send.send(item) + + @asynccontextmanager + async def transport() -> AsyncIterator[TransportStreams]: + async with c2relay_send, c2relay_recv, relay2s_send, relay2s_recv, s2c_send, s2c_recv: + async with anyio.create_task_group() as tg: + tg.start_soon(simple_server.run, relay2s_recv, s2c_send, simple_server.create_initialization_options()) + tg.start_soon(relay) + yield s2c_recv, c2relay_send + tg.cancel_scope.cancel() + + with anyio.fail_after(10): + async with Client(transport(), 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, types.INTERNAL_ERROR]) async def test_client_auto_mode_falls_back_to_initialize_on_legacy_signal(code: int) -> None: """`mode='auto'`: any JSON-RPC error from `server/discover` makes diff --git a/tests/client/test_probe.py b/tests/client/test_probe.py index 34a347fa7d..3b5977bfb7 100644 --- a/tests/client/test_probe.py +++ b/tests/client/test_probe.py @@ -24,6 +24,7 @@ INVALID_REQUEST, METHOD_NOT_FOUND, PARSE_ERROR, + REQUEST_TIMEOUT, UNSUPPORTED_PROTOCOL_VERSION, Implementation, ServerCapabilities, @@ -45,12 +46,16 @@ class _StubSession: """Minimal stand-in for `ClientSession` exposing only what `negotiate_auto` touches. `send_discover` plays back a script (raise an exception, or return a dict); - `initialize` and `adopt` just record that they were called. + `initialize` raises the next entry of an optional `handshake` exception + script (succeeding once it is exhausted) and records its calls; `adopt` + just records. """ - def __init__(self, *script: dict[str, Any] | Exception) -> None: + def __init__(self, *script: dict[str, Any] | Exception, handshake: list[Exception] | None = None) -> None: self._script: list[dict[str, Any] | Exception] = list(script) + self._handshake: list[Exception] = list(handshake or []) self.probed_at: list[str] = [] + self.initialize_calls: int = 0 self.initialized: bool = False self.adopted: types.DiscoverResult | None = None @@ -62,6 +67,9 @@ async def send_discover(self, version: str) -> dict[str, Any]: return step async def initialize(self) -> None: + self.initialize_calls += 1 + if self._handshake: + raise self._handshake.pop(0) self.initialized = True def adopt(self, result: types.DiscoverResult) -> None: @@ -201,6 +209,77 @@ async def test_a_second_unsupported_version_after_the_corrective_retry_does_not_ assert session.adopted is None +# --- -32022 from the fallback handshake: modern evidence, one re-probe --- + + +async def test_handshake_unsupported_after_a_timed_out_probe_reprobes_and_adopts() -> None: + """A probe that times out client-side but succeeds on a slow-starting + server locks the connection modern, so the fallback handshake answers + -32022. That code is itself modern evidence: re-probe once at a version + the server names and adopt - the connect must not fail.""" + session = _StubSession( + MCPError(code=REQUEST_TIMEOUT, message="Request 'server/discover' timed out"), + _discover_dict(), + handshake=[_err_32022(list(MODERN_PROTOCOL_VERSIONS))], + ) + await _negotiate(session) + assert session.probed_at == [LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS[-1]] + assert session.adopted is not None + assert session.initialize_calls == 1 + assert not session.initialized + + +@pytest.mark.parametrize( + "data", + [ + pytest.param({"supported": ["2099-01-01"], "requested": LATEST_MODERN_VERSION}, id="disjoint"), + pytest.param(None, id="no-data"), + ], +) +async def test_handshake_unsupported_without_a_mutual_version_reraises(data: Any) -> None: + """-32022 from the handshake naming no version we speak (or nothing + parseable) leaves nothing to retry with - the error propagates.""" + session = _StubSession( + MCPError(code=METHOD_NOT_FOUND, message="nope"), + handshake=[MCPError(code=UNSUPPORTED_PROTOCOL_VERSION, message="already modern", data=data)], + ) + with pytest.raises(MCPError) as exc_info: + await _negotiate(session) + assert exc_info.value.code == UNSUPPORTED_PROTOCOL_VERSION + assert session.adopted is None + assert not session.initialized + + +async def test_handshake_unsupported_reprobes_at_most_once() -> None: + """The handshake-driven re-probe is bounded: if the second attempt also + ends in a timed-out probe and a -32022 handshake, the -32022 propagates + instead of looping.""" + timeout = MCPError(code=REQUEST_TIMEOUT, message="Request 'server/discover' timed out") + session = _StubSession( + timeout, + timeout, + handshake=[_err_32022(list(MODERN_PROTOCOL_VERSIONS)), _err_32022(list(MODERN_PROTOCOL_VERSIONS))], + ) + with pytest.raises(MCPError) as exc_info: + await _negotiate(session) + assert exc_info.value.code == UNSUPPORTED_PROTOCOL_VERSION + assert session.probed_at == [LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS[-1]] + assert session.initialize_calls == 2 + + +async def test_any_other_handshake_error_propagates_unchanged() -> None: + """A non--32022 error from the fallback handshake is a real handshake + failure, not era evidence - it propagates without a re-probe.""" + session = _StubSession( + MCPError(code=METHOD_NOT_FOUND, message="nope"), + handshake=[MCPError(code=INTERNAL_ERROR, message="handshake broke")], + ) + with pytest.raises(MCPError) as exc_info: + await _negotiate(session) + assert exc_info.value.code == INTERNAL_ERROR + assert session.probed_at == [LATEST_MODERN_VERSION] + + # --- non-MCP errors propagate --- diff --git a/tests/docs_src/test_client_callbacks.py b/tests/docs_src/test_client_callbacks.py index b615c4700f..420bfe8d77 100644 --- a/tests/docs_src/test_client_callbacks.py +++ b/tests/docs_src/test_client_callbacks.py @@ -107,7 +107,7 @@ async def test_each_callback_declares_its_own_capability() -> None: async def test_the_modern_in_memory_path_has_no_back_channel() -> None: """The `!!! info`: under the default mode the negotiated path has no back-channel for `elicitation/create`.""" async with Client(tutorial001.mcp, elicitation_callback=tutorial002.handle_elicitation) as client: - with pytest.raises(MCPError, match="Method not found"): + with pytest.raises(MCPError, match="no back-channel"): await client.call_tool("issue_card") diff --git a/tests/server/test_runner.py b/tests/server/test_runner.py index 8281e8897a..29d3f07fa6 100644 --- a/tests/server/test_runner.py +++ b/tests/server/test_runner.py @@ -30,6 +30,7 @@ ErrorData, Implementation, InitializeRequestParams, + JSONRPCRequest, ListToolsResult, NotificationParams, PaginatedRequestParams, @@ -1331,8 +1332,9 @@ async def test_dual_era_loop_initialize_after_modern_lock_without_a_parseable_ve @pytest.mark.anyio async def test_dual_era_loop_initialize_locks_legacy_and_rejects_modern_traffic(server: SrvT): """After a successful handshake the connection is legacy for its lifetime: - `server/discover` and envelope-bearing requests are rejected with - INVALID_REQUEST while plain legacy requests keep working.""" + envelope-bearing requests (including a triple-stamped `server/discover`) + are rejected with INVALID_REQUEST while plain legacy requests keep + working.""" async with dual_era_client(server) as (client, _): init = await client.send_raw_request("initialize", _initialize_params()) assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION @@ -1347,6 +1349,21 @@ async def test_dual_era_loop_initialize_locks_legacy_and_rejects_modern_traffic( assert "locked to the legacy handshake era" in discover_exc.value.error.message +@pytest.mark.anyio +async def test_dual_era_loop_bare_discover_after_legacy_lock_is_byte_identical(server: SrvT): + """A bare `server/discover` on a legacy-locked connection falls through to + the loop runner's per-version surface validation - the same + METHOD_NOT_FOUND shape a handshake-only server produced, byte for byte + (released probing clients key on it).""" + async with dual_era_client(server) as (client, _): + await client.send_raw_request("initialize", _initialize_params()) + with pytest.raises(MCPError) as exc_info: + await client.send_raw_request("server/discover", None) + assert exc_info.value.error.code == METHOD_NOT_FOUND + assert exc_info.value.error.message == "Method not found" + assert exc_info.value.error.data == "server/discover" + + @pytest.mark.anyio async def test_dual_era_loop_unsupported_modern_version_rejects_without_locking(server: SrvT): """A probe at an unknown modern version gets -32022 with the supported @@ -1402,15 +1419,60 @@ async def test_dual_era_loop_modern_request_without_envelope_rejects(server: Srv @pytest.mark.anyio async def test_dual_era_loop_rejects_subscriptions_listen_on_modern(server: SrvT): """`subscriptions/listen` is rejected before dispatch on the stream-pair - modern path: the registered handler assumes the HTTP entry's stream - semantics.""" + modern path (the registered handler assumes the HTTP entry's stream + semantics) - and like every failed request it does not lock the era, so + the legacy handshake stays available.""" async with dual_era_client(server) as (client, _): with pytest.raises(MCPError) as exc_info: await client.send_raw_request("subscriptions/listen", _modern_params()) + init = await client.send_raw_request("initialize", _initialize_params()) + assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION assert exc_info.value.error.code == METHOD_NOT_FOUND assert "not served over this transport" in exc_info.value.error.message +@pytest.mark.anyio +async def test_dual_era_loop_malformed_envelope_content_never_locks(server: SrvT): + """The envelope triple with mis-shaped values fails the request but never + locks the era: the lock commits only when a modern request SUCCEEDS, so a + buggy client's initialize fallback still works (it must never see -32022 + for a request that failed).""" + params: dict[str, Any] = {"_meta": {**_modern_envelope(), CLIENT_INFO_META_KEY: 42}} + async with dual_era_client(server) as (client, _): + with pytest.raises(MCPError) as exc_info: + await client.send_raw_request("tools/list", params) + init = await client.send_raw_request("initialize", _initialize_params()) + assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION + assert exc_info.value.error.code == INVALID_PARAMS + assert exc_info.value.error.code != UNSUPPORTED_PROTOCOL_VERSION + + +@pytest.mark.anyio +async def test_dual_era_loop_failed_modern_request_never_locks(server: SrvT): + """A well-formed modern request for an unknown method fails without + locking; the next modern request locks on its own success.""" + async with dual_era_client(server) as (client, _): + with pytest.raises(MCPError) as exc_info: + await client.send_raw_request("nope/missing", _modern_params()) + init = await client.send_raw_request("initialize", _initialize_params()) + assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION + assert exc_info.value.error.code == METHOD_NOT_FOUND + + +@pytest.mark.anyio +async def test_dual_era_loop_initialize_with_envelope_takes_the_handshake_path(server: SrvT): + """`initialize` is legacy-distinctive by definition - it does not exist at + modern versions - so stamping the envelope triple on it still runs the + handshake and locks legacy.""" + init_params = {**_initialize_params(), **_modern_params()} + async with dual_era_client(server) as (client, _): + init = await client.send_raw_request("initialize", init_params) + assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION + with pytest.raises(MCPError) as exc_info: + await client.send_raw_request("tools/list", _modern_params()) + assert exc_info.value.error.code == INVALID_REQUEST + + @pytest.mark.anyio async def test_dual_era_loop_modern_notification_dispatches_at_locked_version(server: SrvT): """Notifications carry no envelope, so on a modern-locked connection they @@ -1480,6 +1542,156 @@ async def wants_roots(ctx: Ctx, params: RequestParams | None) -> dict[str, Any]: assert "no back-channel" in exc_info.value.error.message +@pytest.mark.anyio +async def test_dual_era_loop_late_modern_success_does_not_overwrite_a_committed_legacy_lock(): + """The era settles exactly once, on the FIRST client-visible success: a + modern request that was already in flight when a legacy handshake + committed may still complete - its response stands - but the connection + stays legacy, so the handshaked client is never stranded.""" + entered = anyio.Event() + release = anyio.Event() + + async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult: + entered.set() + await release.wait() + return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})]) + + parked = Server(name="parked-server", version="0.0.1", on_list_tools=list_tools) + async with dual_era_client(parked) as (client, _): + modern_result: dict[str, Any] = {} + + async def modern_call() -> None: + modern_result.update(await client.send_raw_request("tools/list", _modern_params())) + + async with anyio.create_task_group() as tg: + tg.start_soon(modern_call) + # The modern dispatch is parked in its handler before the + # handshake frame is even written, so the initialize commits first. + await entered.wait() + init = await client.send_raw_request("initialize", _initialize_params()) + assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION + release.set() + assert modern_result["tools"][0]["name"] == "t" + # The straggler's success did not move the era: plain legacy requests + # still serve (a modern overwrite would demand the envelope triple). + result = await client.send_raw_request("tools/list", None) + assert result["tools"][0]["name"] == "t" + + +@pytest.mark.anyio +async def test_dual_era_loop_modern_success_cancelled_away_at_the_response_write_never_locks(): + """A peer cancel that lands while the handler is finishing means the + dispatcher replaces the computed result with "Request cancelled" - the + client never sees the success, so the era must not lock and the legacy + handshake must stay available.""" + entered = anyio.Event() + release = anyio.Event() + + async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult: + entered.set() + # Survive the interrupt-mode scope cancel so the handler completes + # with the cancel pending - the cancellation is then delivered at the + # dispatcher's response-write checkpoint, after the era commit ran. + with anyio.CancelScope(shield=True): + await release.wait() + return ListToolsResult(tools=[]) + + parked = Server(name="parked-server", version="0.0.1", on_list_tools=list_tools) + async with dual_era_client(parked) as (client, _): + failures: list[MCPError] = [] + + async def modern_call() -> None: + with pytest.raises(MCPError) as exc_info: + await client.send_raw_request("tools/list", _modern_params()) + failures.append(exc_info.value) + + async with anyio.create_task_group() as tg: + tg.start_soon(modern_call) + await entered.wait() + # First request on a fresh dispatcher pair, so its id is 1. + await client.notify("notifications/cancelled", {"requestId": 1}) + # The read loop handles frames in order: this marker's response + # proves the cancel was processed before the handler resumes. + with pytest.raises(MCPError): + await client.send_raw_request("probe/marker", None) + release.set() + assert failures[0].error.message == "Request cancelled" + # The cancelled-away success never locked the era. + init = await client.send_raw_request("initialize", _initialize_params()) + assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION + + +@pytest.mark.anyio +async def test_dual_era_loop_maps_unmapped_handler_exceptions_like_the_modern_http_entry(): + """An unmapped handler exception on a modern request surfaces as the + generic INTERNAL_ERROR - the same boundary as the modern HTTP entry - so + handler internals never reach the wire. (The dispatcher's code-0 + catch-all is a handshake-era compat pin and stays legacy-only.) The + failed request never locks, so the handshake stays available.""" + + async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult: + raise RuntimeError("handler internals") + + exploding = Server(name="exploding-server", version="0.0.1", on_list_tools=list_tools) + async with dual_era_client(exploding) as (client, _): + with pytest.raises(MCPError) as exc_info: + await client.send_raw_request("tools/list", _modern_params()) + init = await client.send_raw_request("initialize", _initialize_params()) + assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION + assert exc_info.value.error.code == INTERNAL_ERROR + assert exc_info.value.error.message == "Internal server error" + assert "handler internals" not in str(exc_info.value.error) + + +@pytest.mark.anyio +async def test_dual_era_loop_raise_exceptions_reraises_unmapped_modern_handler_exceptions(): + """Debug mode keeps its contract on the modern path: an unmapped handler + exception still propagates out of the loop instead of being swallowed + into the generic INTERNAL_ERROR mapping.""" + + async def list_tools(ctx: Ctx, params: PaginatedRequestParams | None) -> ListToolsResult: + raise RuntimeError("boom") + + exploding = Server(name="exploding-server", version="0.0.1", on_list_tools=list_tools) + c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](8) + s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](8) + frame = JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/list", params=_modern_params()) + with pytest.RaisesGroup(RuntimeError, flatten_subgroups=True, allow_unwrapped=True): + async with anyio.create_task_group() as tg, c2s_send, c2s_recv, s2c_send, s2c_recv: + tg.start_soon( + partial( + serve_dual_era_loop, exploding, c2s_recv, s2c_send, lifespan_state=_LIFESPAN, raise_exceptions=True + ) + ) + with anyio.fail_after(5): # pragma: no branch - group exit misreports the with arcs + await c2s_send.send(SessionMessage(message=frame)) + # The dispatcher answers on the wire before re-raising; waiting + # for the answer keeps the streams open until the handler ran. + await s2c_recv.receive() + + +@pytest.mark.anyio +async def test_dual_era_loop_custom_method_with_mis_shaped_envelope_values_still_routes(): + """A mis-shaped clientInfo envelope value degrades to not-supplied - the + `Connection.from_envelope` coercion the modern HTTP entry uses - so a + custom method (no kernel params surface to re-reject it) serves + identically on both transports, with `client_params is None`.""" + seen: list[object] = [] + + async def greet(ctx: Ctx, params: RequestParams | None) -> dict[str, Any]: + seen.append(ctx.session.client_params) + return {"ok": True} + + greeter = Server(name="greeter-server", version="0.0.1") + greeter.add_request_handler("custom/greet", RequestParams, greet) + params = _modern_params() + params["_meta"][CLIENT_INFO_META_KEY] = "not-an-object" + async with dual_era_client(greeter) as (client, _): + result = await client.send_raw_request("custom/greet", params) + assert result == {"ok": True} + assert seen == [None] + + def test_has_modern_envelope_requires_the_full_key_triple(): assert not _has_modern_envelope(None) assert not _has_modern_envelope({}) @@ -1526,7 +1738,8 @@ async def test_no_server_requests_dispatch_context_denies_requests_and_delegates inner = _RecordingInnerDctx() wrapper = _NoServerRequestsDispatchContext(inner) assert wrapper.can_send_request is False - assert wrapper.transport is inner.transport + # The transport metadata is masked to agree with the wrapper's denial. + assert wrapper.transport == TransportContext(kind="jsonrpc", can_send_request=False) assert wrapper.request_id == 7 assert wrapper.message_metadata is None assert wrapper.cancel_requested is inner.cancel_requested @@ -1538,6 +1751,12 @@ async def test_no_server_requests_dispatch_context_denies_requests_and_delegates assert inner.progresses == [0.5] +def test_no_server_requests_dispatch_context_passes_an_already_denying_transport_through(): + inner = _RecordingInnerDctx() + inner.transport = TransportContext(kind="jsonrpc", can_send_request=False) + assert _NoServerRequestsDispatchContext(inner).transport is inner.transport + + @pytest.mark.anyio async def test_notify_only_outbound_forwards_notifications_and_refuses_requests(): inner = _RecordingInnerDctx() diff --git a/tests/server/test_stdio.py b/tests/server/test_stdio.py index f0c8b1c29b..218e34d5ac 100644 --- a/tests/server/test_stdio.py +++ b/tests/server/test_stdio.py @@ -16,6 +16,7 @@ JSONRPCResponse, jsonrpc_message_adapter, ) +from typing_extensions import Buffer from mcp.server.mcpserver import MCPServer from mcp.server.stdio import stdio_server @@ -123,10 +124,11 @@ def __init__(self, payload: bytes) -> None: def readable(self) -> bool: return True - def readinto(self, b: bytearray | memoryview) -> int: # pyright: ignore[reportIncompatibleMethodOverride] + def readinto(self, b: Buffer) -> int: + view = memoryview(b) if self._pending: - n = min(len(b), len(self._pending)) - b[:n] = self._pending[:n] + n = min(len(view), len(self._pending)) + view[:n] = self._pending[:n] self._pending = self._pending[n:] return n # A missed release falls through to EOF after the bound; the caller's @@ -155,7 +157,7 @@ def __init__(self) -> None: def writable(self) -> bool: return True - def write(self, b: bytes | bytearray | memoryview) -> int: # pyright: ignore[reportIncompatibleMethodOverride] + def write(self, b: Buffer) -> int: data = bytes(b) with self._cond: self._chunks.append(data) diff --git a/tests/shared/test_inbound.py b/tests/shared/test_inbound.py index 8ba9cb9359..2bf9c36411 100644 --- a/tests/shared/test_inbound.py +++ b/tests/shared/test_inbound.py @@ -128,6 +128,42 @@ def test_envelope_rung_rejects_non_mapping_shapes(body: dict[str, Any]) -> None: assert_rejected(classify_inbound_request(body), INVALID_PARAMS) +@pytest.mark.parametrize("version", [7, None, ["2026-07-28"]], ids=["int", "null", "list"]) +def test_envelope_rung_rejects_non_string_protocol_version(version: Any) -> None: + """A present-but-non-string protocol version is a shape defect, rejected + INVALID_PARAMS: it must never become -32022 (the one code auto-negotiating + clients do not fall back from), and must not escape as a ValidationError + from the version rung's own typed payload (`requested` is a `str` field).""" + body = envelope() + body["params"]["_meta"][PROTOCOL_VERSION_META_KEY] = version + rejection = assert_rejected(classify_inbound_request(body), INVALID_PARAMS) + assert "string" in rejection.message + + +def test_non_string_protocol_version_over_http_still_rejects_at_the_header_rung() -> None: + """SDK-defined: the non-string guard sits after the header rung, so over + HTTP a present version header (a string, which can never equal a + non-string body value) keeps producing HEADER_MISMATCH - the guard's wire + delta is confined to header-less transports.""" + body = envelope() + headers = matching_headers(body) + body["params"]["_meta"][PROTOCOL_VERSION_META_KEY] = 7 + assert_rejected(classify_inbound_request(body, headers=headers), HEADER_MISMATCH) + + +@pytest.mark.parametrize("version", [7, None], ids=["int", "null"]) +def test_absent_version_header_rejects_before_the_string_guard(version: Any) -> None: + """SDK-defined: the version header must be PRESENT, not merely equal - a + null body version would otherwise slip the equality check (None == None) + - so an absent header is HEADER_MISMATCH for every body value and the + string guard stays reachable only on header-less transports.""" + body = envelope() + headers = matching_headers(body) + del headers[MCP_PROTOCOL_VERSION_HEADER] + body["params"]["_meta"][PROTOCOL_VERSION_META_KEY] = version + assert_rejected(classify_inbound_request(body, headers=headers), HEADER_MISMATCH) + + # --- rung 2: protocol-version-supported ---------------------------------------- From 220d36211200d7fc5298173567d6273909a613e0 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:06:04 +0100 Subject: [PATCH 044/100] docs: restructure into topical sections and add the four most-asked-for pages (#3044) --- AGENTS.md | 7 +- README.md | 4 +- RELEASE.md | 2 +- docs/advanced/index.md | 29 ++ docs/advanced/low-level-server.md | 20 +- docs/advanced/middleware.md | 6 +- docs/advanced/pagination.md | 2 +- docs/{advanced => client}/caching.md | 4 +- docs/client/callbacks.md | 16 +- .../identity-assertion.md | 12 +- docs/client/index.md | 18 +- docs/{advanced => client}/oauth-clients.md | 24 +- docs/{advanced => client}/session-groups.md | 4 +- docs/client/transports.md | 8 +- docs/{advanced => }/deprecated.md | 12 +- docs/{tutorial => get-started}/first-steps.md | 20 +- docs/get-started/index.md | 52 +++ docs/{ => get-started}/installation.md | 0 docs/get-started/real-host.md | 182 ++++++++ docs/{tutorial => get-started}/testing.md | 15 +- docs/{tutorial => handlers}/context.md | 8 +- docs/{tutorial => handlers}/dependencies.md | 6 +- docs/{tutorial => handlers}/elicitation.md | 71 +-- docs/handlers/index.md | 28 ++ docs/{tutorial => handlers}/lifespan.md | 2 +- docs/{tutorial => handlers}/logging.md | 6 +- .../multi-round-trip.md | 12 +- docs/{tutorial => handlers}/progress.md | 8 +- docs/{advanced => handlers}/subscriptions.md | 0 docs/hooks/llms_txt.py | 2 +- docs/index.md | 7 +- docs/migration.md | 12 +- docs/{client => }/protocol-versions.md | 8 +- docs/run/asgi.md | 57 +-- docs/{advanced => run}/authorization.md | 8 +- docs/run/deploy.md | 174 ++++++++ docs/run/index.md | 10 +- docs/run/legacy-clients.md | 120 +++++ docs/{advanced => run}/opentelemetry.md | 4 +- docs/{tutorial => servers}/completions.md | 6 +- docs/{tutorial => servers}/handling-errors.md | 10 +- docs/servers/index.md | 30 ++ docs/{tutorial => servers}/media.md | 6 +- docs/{tutorial => servers}/prompts.md | 6 +- docs/{tutorial => servers}/resources.md | 6 +- .../structured-output.md | 6 +- docs/{tutorial => servers}/tools.md | 4 +- docs/{advanced => servers}/uri-templates.md | 12 +- docs/troubleshooting.md | 412 ++++++++++++++++++ docs/tutorial/index.md | 51 --- docs_src/deploy/__init__.py | 0 docs_src/deploy/tutorial001.py | 17 + docs_src/deploy/tutorial002.py | 27 ++ docs_src/deploy/tutorial003.py | 27 ++ docs_src/deploy/tutorial004.py | 23 + docs_src/legacy_clients/__init__.py | 0 docs_src/legacy_clients/tutorial001.py | 42 ++ docs_src/legacy_clients/tutorial002.py | 28 ++ docs_src/legacy_clients/tutorial003.py | 21 + docs_src/real_host/__init__.py | 0 docs_src/real_host/tutorial001.py | 34 ++ docs_src/troubleshooting/__init__.py | 0 docs_src/troubleshooting/tutorial001.py | 22 + docs_src/troubleshooting/tutorial002.py | 15 + docs_src/troubleshooting/tutorial003.py | 12 + docs_src/troubleshooting/tutorial004.py | 18 + docs_src/troubleshooting/tutorial005.py | 16 + docs_src/troubleshooting/tutorial006.py | 19 + docs_src/troubleshooting/tutorial007.py | 25 ++ docs_src/troubleshooting/tutorial008.py | 23 + examples/README.md | 2 +- examples/stories/subscriptions/README.md | 2 +- mkdocs.yml | 75 ++-- pyproject.toml | 2 +- tests/client/test_client_caching.py | 2 +- tests/docs_src/test_authorization.py | 2 +- tests/docs_src/test_caching.py | 2 +- tests/docs_src/test_completions.py | 2 +- tests/docs_src/test_context.py | 2 +- tests/docs_src/test_dependencies.py | 2 +- tests/docs_src/test_deploy.py | 230 ++++++++++ tests/docs_src/test_deprecated.py | 2 +- tests/docs_src/test_elicitation.py | 2 +- tests/docs_src/test_first_steps.py | 2 +- tests/docs_src/test_handling_errors.py | 2 +- tests/docs_src/test_identity_assertion.py | 2 +- tests/docs_src/test_legacy_clients.py | 136 ++++++ tests/docs_src/test_lifespan.py | 2 +- tests/docs_src/test_logging.py | 2 +- tests/docs_src/test_media.py | 2 +- tests/docs_src/test_mrtr.py | 2 +- tests/docs_src/test_oauth_clients.py | 2 +- tests/docs_src/test_opentelemetry.py | 2 +- tests/docs_src/test_progress.py | 2 +- tests/docs_src/test_prompts.py | 2 +- tests/docs_src/test_protocol_versions.py | 2 +- tests/docs_src/test_real_host.py | 54 +++ tests/docs_src/test_resources.py | 2 +- tests/docs_src/test_session_groups.py | 2 +- tests/docs_src/test_structured_output.py | 2 +- tests/docs_src/test_subscriptions.py | 2 +- tests/docs_src/test_testing.py | 2 +- tests/docs_src/test_tools.py | 2 +- tests/docs_src/test_troubleshooting.py | 281 ++++++++++++ tests/docs_src/test_uri_templates.py | 2 +- tests/server/test_caching.py | 2 +- tests/test_examples.py | 8 +- uv.lock | 2 +- 108 files changed, 2403 insertions(+), 343 deletions(-) create mode 100644 docs/advanced/index.md rename docs/{advanced => client}/caching.md (97%) rename docs/{advanced => client}/identity-assertion.md (93%) rename docs/{advanced => client}/oauth-clients.md (78%) rename docs/{advanced => client}/session-groups.md (93%) rename docs/{advanced => }/deprecated.md (87%) rename docs/{tutorial => get-started}/first-steps.md (85%) create mode 100644 docs/get-started/index.md rename docs/{ => get-started}/installation.md (100%) create mode 100644 docs/get-started/real-host.md rename docs/{tutorial => get-started}/testing.md (86%) rename docs/{tutorial => handlers}/context.md (91%) rename docs/{tutorial => handlers}/dependencies.md (97%) rename docs/{tutorial => handlers}/elicitation.md (73%) create mode 100644 docs/handlers/index.md rename docs/{tutorial => handlers}/lifespan.md (97%) rename docs/{tutorial => handlers}/logging.md (91%) rename docs/{advanced => handlers}/multi-round-trip.md (94%) rename docs/{tutorial => handlers}/progress.md (93%) rename docs/{advanced => handlers}/subscriptions.md (100%) rename docs/{client => }/protocol-versions.md (94%) rename docs/{advanced => run}/authorization.md (92%) create mode 100644 docs/run/deploy.md create mode 100644 docs/run/legacy-clients.md rename docs/{advanced => run}/opentelemetry.md (95%) rename docs/{tutorial => servers}/completions.md (89%) rename docs/{tutorial => servers}/handling-errors.md (91%) create mode 100644 docs/servers/index.md rename docs/{tutorial => servers}/media.md (92%) rename docs/{tutorial => servers}/prompts.md (93%) rename docs/{tutorial => servers}/resources.md (96%) rename docs/{tutorial => servers}/structured-output.md (95%) rename docs/{tutorial => servers}/tools.md (97%) rename docs/{advanced => servers}/uri-templates.md (95%) create mode 100644 docs/troubleshooting.md delete mode 100644 docs/tutorial/index.md create mode 100644 docs_src/deploy/__init__.py create mode 100644 docs_src/deploy/tutorial001.py create mode 100644 docs_src/deploy/tutorial002.py create mode 100644 docs_src/deploy/tutorial003.py create mode 100644 docs_src/deploy/tutorial004.py create mode 100644 docs_src/legacy_clients/__init__.py create mode 100644 docs_src/legacy_clients/tutorial001.py create mode 100644 docs_src/legacy_clients/tutorial002.py create mode 100644 docs_src/legacy_clients/tutorial003.py create mode 100644 docs_src/real_host/__init__.py create mode 100644 docs_src/real_host/tutorial001.py create mode 100644 docs_src/troubleshooting/__init__.py create mode 100644 docs_src/troubleshooting/tutorial001.py create mode 100644 docs_src/troubleshooting/tutorial002.py create mode 100644 docs_src/troubleshooting/tutorial003.py create mode 100644 docs_src/troubleshooting/tutorial004.py create mode 100644 docs_src/troubleshooting/tutorial005.py create mode 100644 docs_src/troubleshooting/tutorial006.py create mode 100644 docs_src/troubleshooting/tutorial007.py create mode 100644 docs_src/troubleshooting/tutorial008.py create mode 100644 tests/docs_src/test_deploy.py create mode 100644 tests/docs_src/test_legacy_clients.py create mode 100644 tests/docs_src/test_real_host.py create mode 100644 tests/docs_src/test_troubleshooting.py diff --git a/AGENTS.md b/AGENTS.md index 6c51e89819..43fbb887d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,9 +138,10 @@ rather than adding new standalone sections. ## Documentation When a change affects public API or user-visible behaviour, update the relevant -page(s) under `docs/` in the same PR. Docs are organised by topic -(`tutorial/`, `client/`, `run/`, `advanced/`) — find the page covering the -feature you touched rather than adding a new one. +page(s) under `docs/` in the same PR. Docs are organised by the `nav:` sections +in `mkdocs.yml` (Get started, Servers, Inside your handler, Running your server, +Clients, Advanced), not by the on-disk directory names. Find the page covering +the feature you touched in `mkdocs.yml` rather than adding a new one. ## Formatting & Type Checking diff --git a/README.md b/README.md index 0c0876bb66..1324ac57ef 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ **The documentation lives at .** -It has the full [tutorial](https://py.sdk.modelcontextprotocol.io/v2/tutorial/), the [API reference](https://py.sdk.modelcontextprotocol.io/v2/api/mcp/), and the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/). +It has a [Get started guide](https://py.sdk.modelcontextprotocol.io/v2/get-started/), the [API reference](https://py.sdk.modelcontextprotocol.io/v2/api/mcp/), and the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/). ## What is MCP? @@ -82,7 +82,7 @@ Call `add` with `a=1`, `b=2` and you get `3` back. Notice what you did **not** write: no JSON Schema (`a: int, b: int` _is_ the schema), no request parsing, no validation code, no protocol handling. Two type-hinted Python functions and a docstring. -[The tutorial](https://py.sdk.modelcontextprotocol.io/v2/tutorial/) takes it from here. +[Get started](https://py.sdk.modelcontextprotocol.io/v2/get-started/) takes it from here. ## A client in 10 lines diff --git a/RELEASE.md b/RELEASE.md index cfd4d927cb..70eef5d692 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -35,7 +35,7 @@ the publish job — `skip-existing` makes it skip whatever already landed. The 1. Update the pre-release version examples in `README.md` and the docs (grep the outgoing version — the pins live in the README Installation - section, `docs/index.md`, and `docs/installation.md`) so the tagged + section, `docs/index.md`, `docs/get-started/installation.md`, and `docs/get-started/real-host.md`) so the tagged commit — and therefore the README PyPI publishes — names the version being released. When entering a new phase (alpha → beta → rc), update the banner wording too. diff --git a/docs/advanced/index.md b/docs/advanced/index.md new file mode 100644 index 0000000000..92af6d1782 --- /dev/null +++ b/docs/advanced/index.md @@ -0,0 +1,29 @@ +# Advanced + +Everything an ordinary server or client needs has a topical home in the sections above. +This section is the escape hatches you reach for when `MCPServer`'s convenience +layer is in the way: + +* **[The low-level Server](low-level-server.md)**: the class `MCPServer` is built on. + Hand-written schemas, `on_*` handlers, nothing checked for you, and custom JSON-RPC + methods of your own. +* **[Pagination](pagination.md)** and **[Middleware](middleware.md)**: two things you + can *only* do on the low-level `Server`. +* **[Extensions](extensions.md)** and **[MCP Apps](apps.md)**: the protocol's + extension surface. Compose extension packages into a server, or write your own. + +A few things you might reasonably look for here live where you'd actually use them +instead: + +* **Authorization** is under **[Running your server](../run/index.md)** because you + protect a server where you deploy it. +* **OAuth**, **identity assertion**, connecting to **multiple servers**, and the + response **cache** are all under **[Clients](../client/index.md)**. +* **Multi-round-trip requests** and **Subscriptions** are under + **[Inside your handler](../handlers/index.md)** because both are things a + handler *does*. +* **URI templates** is under **[Servers](../servers/index.md)**, next to Resources. +* **[Protocol versions](../protocol-versions.md)** and + **[Deprecated features](../deprecated.md)** each have their own top-level page. + +If you're not sure whether you need this section, you don't. diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index 123c85dd7d..26df8f6123 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -12,7 +12,7 @@ For everything else, stay on `MCPServer`. ## The same tool, by hand -This is `search_books` from **[Tools](../tutorial/tools.md)** (the nine-line `@mcp.tool()` file) with the sugar removed: +This is the `search_books` tool that **[Tools](../servers/tools.md)** writes in nine lines of `@mcp.tool()`, with the sugar removed: ```python title="server.py" hl_lines="23 27 33" --8<-- "docs_src/lowlevel/tutorial001.py" @@ -56,12 +56,12 @@ asyncio.run(main()) The same text the `@mcp.tool()` version produced. Two honest differences: -* `result.structured_content` is `None`. The high-level server wrapped your `-> str` into `{"result": ...}`; here nobody builds what you didn't build. +* `result.structured_content` is `None`. The high-level server wraps a `-> str` into `{"result": ...}` for you; here nobody builds what you didn't build. * `list_tools` returns the schema **you** typed, character for character. The high-level version had `"title": "Query"` on every property and a `"title": "search_booksArguments"` at the root: Pydantic artifacts. Down here, if it's on the wire, you put it there. ## Nothing is checked for you -In **[Tools](../tutorial/tools.md)** you saw a bad argument get rejected before your function ran. That was `MCPServer` validating the call against the schema it generated. +`MCPServer` rejects a bad argument before your function ever runs, validating the call against the schema it generated (**[Tools](../servers/tools.md)**). `Server` does not do that. Your `input_schema` is *advertised* to the client; it is never *applied* to `params.arguments`. @@ -72,9 +72,9 @@ In **[Tools](../tutorial/tools.md)** you saw a bad argument get rejected before MCPError: Internal server error ``` - A JSON-RPC error, code `-32603`, with a deliberately generic message: the SDK won't leak your traceback to a remote caller. The model never finds out what it did wrong, so it can't retry. (In a test, `raise_exceptions=True` surfaces the real exception instead; see **[Testing](../tutorial/testing.md)**.) + A JSON-RPC error, code `-32603`, with a deliberately generic message: the SDK won't leak your traceback to a remote caller. The model never finds out what it did wrong, so it can't retry. (In a test, `raise_exceptions=True` surfaces the real exception instead; see **[Testing](../get-started/testing.md)**.) -That generalises. An exception raised from a low-level handler is **always** a protocol error, never an `is_error=True` tool result. If you want the model to read the failure and recover, validate `params.arguments` yourself and return `CallToolResult(content=[TextContent(...)], is_error=True)`. The two kinds of failure are the subject of **[Handling errors](../tutorial/handling-errors.md)**. +That generalises. An exception raised from a low-level handler is **always** a protocol error, never an `is_error=True` tool result. If you want the model to read the failure and recover, validate `params.arguments` yourself and return `CallToolResult(content=[TextContent(...)], is_error=True)`. The two kinds of failure are the subject of **[Handling errors](../servers/handling-errors.md)**. ## Two tools, one handler @@ -106,7 +106,7 @@ Call it and the result carries both representations: } ``` -The server never compares the two fields. This SDK's `Client` does: return `structured_content` that doesn't satisfy the `output_schema` you declared and `call_tool` raises a `RuntimeError` that starts with `Invalid structured content returned by tool search_books` and goes on to quote the `jsonschema` failure. Promising a schema is cheap; keeping it is on you. The whole ladder of return types and schemas is in **[Structured Output](../tutorial/structured-output.md)**. +The server never compares the two fields. This SDK's `Client` does: return `structured_content` that doesn't satisfy the `output_schema` you declared and `call_tool` raises a `RuntimeError` that starts with `Invalid structured content returned by tool search_books` and goes on to quote the `jsonschema` failure. Promising a schema is cheap; keeping it is on you. The whole ladder of return types and schemas is in **[Structured Output](../servers/structured-output.md)**. ## `_meta`: for the application, not the model @@ -147,7 +147,7 @@ No `resources`, no `prompts`: there is nothing to back them. Pass `on_list_promp * The lifespan is a `Callable[[Server[Catalog]], AbstractAsyncContextManager[Catalog]]`; `@asynccontextmanager` on an `async` generator gives you exactly that. * Whatever it `yield`s becomes `ctx.lifespan_context`, and because the handlers are annotated `ServerRequestContext[Catalog]`, `.search(...)` autocompletes and type-checks. -* It is entered once when the server starts and exited once when it stops. Startup, teardown, and `MCPServer`'s version of the same idea are in **[Lifespan](../tutorial/lifespan.md)**. +* It is entered once when the server starts and exited once when it stops. Startup, teardown, and `MCPServer`'s version of the same idea are in **[Lifespan](../handlers/lifespan.md)**. Without a `lifespan=`, `ctx.lifespan_context` is an empty `dict`. @@ -179,11 +179,11 @@ The handshake belongs to the runner. `server/discover`, `ping`, and every other ## The other handlers -Each of these is one idea you now have the vocabulary for; each has its own chapter. +Each of these is one idea you now have the vocabulary for; each has its own page. -* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](multi-round-trip.md)**. True to this tier, nothing is installed for you: where `MCPServer` seals `requestState` by default, here the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`: one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` performs (**[Protecting `requestState`](multi-round-trip.md#protecting-requeststate)**). +* `on_call_tool`, `on_get_prompt`, and `on_read_resource` may return an `InputRequiredResult` instead of their normal result to pause the call and ask the client for input; see **[Multi-round-trip requests](../handlers/multi-round-trip.md)**. True to this tier, nothing is installed for you: where `MCPServer` seals `requestState` by default, here the `request_state` you set crosses the wire exactly as written until you opt in with `server.middleware.append(RequestStateBoundary(RequestStateSecurity(keys=[...]), default_audience=server.name))`: one line (both names import from `mcp.server.request_state`) for the identical sealing and verification `MCPServer` performs (**[Protecting `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**). * `on_list_resources`, `on_read_resource`, `on_list_prompts`, `on_get_prompt`, `on_completion` are the same `(ctx, params) -> result` shape for the other primitives. -* `on_subscriptions_listen` serves the 2026-07-28 `subscriptions/listen` stream. Pass a `ListenHandler` built over a `SubscriptionBus` and publish events to the bus from your other handlers; see **[Subscriptions](subscriptions.md)** for the full composition. +* `on_subscriptions_listen` serves the 2026-07-28 `subscriptions/listen` stream. Pass a `ListenHandler` built over a `SubscriptionBus` and publish events to the bus from your other handlers; see **[Subscriptions](../handlers/subscriptions.md)** for the full composition. * `server.streamable_http_app()` returns the same Starlette app `MCPServer`'s does; deploy it the way **[Running your server](../run/index.md)** deploys any other ASGI app. There is no `server.run(transport=...)` down here: `server.run(read_stream, write_stream, server.create_initialization_options())` drives one connection over a pair of streams, and that one line is the whole story. ## Recap diff --git a/docs/advanced/middleware.md b/docs/advanced/middleware.md index 7cc15ce3c6..4e57bae82d 100644 --- a/docs/advanced/middleware.md +++ b/docs/advanced/middleware.md @@ -84,7 +84,7 @@ In increasing order of how much you should hesitate: The SDK ships exactly one middleware, and it is already on your server's list: the one that emits an OpenTelemetry span for every message. You don't append it, and most of the time you don't think about it. It is a no-op until you install an exporter, and it has its own page: -**[OpenTelemetry](opentelemetry.md)**. +**[OpenTelemetry](../run/opentelemetry.md)**. !!! info If you have written ASGI middleware, you already know this shape. Starlette's @@ -101,8 +101,8 @@ don't think about it. It is a no-op until you install an exporter, and it has it * `ctx.request_id is None` is how you tell a notification from a request. * Raise instead of calling `call_next` to refuse one message; the connection survives. * The SDK's own OpenTelemetry tracing is a middleware too, already on the list. See - **[OpenTelemetry](opentelemetry.md)**. + **[OpenTelemetry](../run/opentelemetry.md)**. * The whole surface is provisional. Observe with it; don't build on it. -That is everything that wraps a request. **[Authorization](authorization.md)** is what decides whether the request +That is everything that wraps a request. **[Authorization](../run/authorization.md)** is what decides whether the request gets to run at all. diff --git a/docs/advanced/pagination.md b/docs/advanced/pagination.md index aac63f4c71..381f7fae04 100644 --- a/docs/advanced/pagination.md +++ b/docs/advanced/pagination.md @@ -48,7 +48,7 @@ Every `list_*` method on `Client` (`list_tools`, `list_resources`, `list_resourc Run its `main()` and it prints `100 resources`: ten pages of ten, stitched together by a loop that never knew there were ten pages. -This is the same loop **[The Client](../client/index.md)** chapter showed you, and it costs nothing against a server that doesn't page: `next_cursor` is `None` on the first response and the loop runs once. +This is the same loop **[The Client](../client/index.md)** shows for every `list_*` verb, and it costs nothing against a server that doesn't page: `next_cursor` is `None` on the first response and the loop runs once. ## The three rules diff --git a/docs/advanced/caching.md b/docs/client/caching.md similarity index 97% rename from docs/advanced/caching.md rename to docs/client/caching.md index ba979ccc13..5e0976fb5f 100644 --- a/docs/advanced/caching.md +++ b/docs/client/caching.md @@ -13,7 +13,7 @@ Out of the box every result says `ttlMs: 0, cacheScope: "private"`: immediately * The map is keyed by **method name**, and the six cacheable methods are the only legal keys. The parameter is typed `Mapping[CacheableMethod, CacheHint]`, so your editor autocompletes the keys and flags a typo before you run; anything that slips past the type checker raises at construction. * A method you don't mention keeps the defaults. The map is a set of overrides, not a manifest. * `CacheHint(ttl_ms=5_000)` left `scope` unset, so it stays `"private"`: five seconds of freshness, per caller. Scope and TTL are independent decisions. -* `"server/discover"` is a legal key too, since the handshake result is cacheable like any list. +* `"server/discover"` is a legal key too, since the discovery result is cacheable like any list. !!! warning `cacheScope: "public"` means *anyone* may be served your cached response. A shared @@ -85,7 +85,7 @@ Cache keys also carry the **server's identity**: the URL string you dialed, with ### What the cache never does * **Session-tier calls bypass it.** `client.session.list_tools()` and friends always make the round trip; the cache lives on the `Client` verbs. -* **`server/discover` stays out of it.** The discover result is delivered once, at connect, and never enters the response cache, even when it carries a `ttlMs`. If you persist one yourself to skip the reconnect probe ([`prior_discover`](../client/protocol-versions.md#reconnecting-with-prior_discover)), its freshness is your bookkeeping: `DiscoverResult` carries `ttl_ms` and `cache_scope`, already parsed, for exactly that purpose. +* **`server/discover` stays out of it.** The discover result is delivered once, at connect, and never enters the response cache, even when it carries a `ttlMs`. If you persist one yourself to skip the reconnect probe ([`prior_discover`](../protocol-versions.md#reconnecting-with-prior_discover)), its freshness is your bookkeeping: `DiscoverResult` carries `ttl_ms` and `cache_scope`, already parsed, for exactly that purpose. * **Continuation pages are never cached.** Only cursor-less calls participate. A continuation page rejected for an expired cursor does *evict* the cached listing, because the listing changed under it. * **Multi-round-trip reads are never cached.** A `read_resource` seeded with `input_responses`/`request_state`, or one that resolves through input rounds, never enters the cache (a spec MUST). * **Notification eviction needs notifications.** Eviction is only as good as the transport's delivery, and the modern in-process path (`Client(server)` with the default `mode="auto"`) does not deliver standalone notifications today. diff --git a/docs/client/callbacks.md b/docs/client/callbacks.md index 31a4d635ba..e9787da8da 100644 --- a/docs/client/callbacks.md +++ b/docs/client/callbacks.md @@ -1,6 +1,6 @@ # Client callbacks -So far every request has gone one way: client to server. +Nearly every request in MCP goes one way: client to server. A server can also ask the **client** for things: to put a question to the user, to sample the user's model, to list the user's workspace folders. You answer those requests by passing **callbacks** to `Client(...)`. @@ -15,7 +15,7 @@ Here is a server whose tool can't finish on its own: * `ctx.elicit(...)` sends an `elicitation/create` request **to the client** and waits. * The tool doesn't return until somebody (a person in a form, or your code) supplies a `name`. -That is the server half, and the **[Elicitation](../tutorial/elicitation.md)** chapter owns it. This chapter is the other end of the wire. +That is the server half, and the **[Elicitation](../handlers/elicitation.md)** page owns it. This page is the other end of the wire. ## The elicitation callback @@ -31,7 +31,7 @@ That is the server half, and the **[Elicitation](../tutorial/elicitation.md)** c !!! tip `params` is a union of the two elicitation modes. Here `params.mode` is `"form"`; a `"url"` request carries `params.url` instead of a schema. One callback handles both; branch on `params.mode`. - **[Elicitation](../tutorial/elicitation.md)** shows the full pattern. + **[Elicitation](../handlers/elicitation.md)** shows the full pattern. ### Try it @@ -59,11 +59,11 @@ One `tools/call` from you, one `elicitation/create` back from the server, answer protocol path, and that path has no back-channel for server-to-client requests: `ctx.elicit` fails before your callback ever runs. The transport doesn't decide that; the negotiated protocol does, in-memory and over a URL alike. Pin `mode="legacy"` whenever your client has - to answer one; every test behind this page does. **[Protocol versions](protocol-versions.md)** has the whole story. + to answer one; every test behind this page does. **[Protocol versions](../protocol-versions.md)** has the whole story. On a 2026-07-28 session the callback isn't dead, it's fed differently: when a tool returns an `InputRequiredResult` carrying an `ElicitRequest`, `Client` dispatches that entry to the same - `elicitation_callback` and retries the call for you. That flow is **[Multi-round-trip requests](../advanced/multi-round-trip.md)**. + `elicitation_callback` and retries the call for you. That flow is **[Multi-round-trip requests](../handlers/multi-round-trip.md)**. ## A callback is a capability @@ -113,7 +113,7 @@ Pass all three callbacks and you get `['elicitation', 'sampling', 'roots']`. Pas `sampling_callback` answers `sampling/createMessage`: the server asking *your* model to complete something. `list_roots_callback` answers `roots/list`: the server asking which directories it may work in. -Both work. Both follow the rule above. And both serve RPCs the **2026-07-28 spec removes**: a modern server doesn't call back into your client mid-request, it hands the request back to you as part of the tool result (**[Multi-round-trip requests](../advanced/multi-round-trip.md)**). The callbacks themselves are not dead. When an `InputRequiredResult` carries a `CreateMessageRequest` or a `ListRootsRequest`, `Client`'s auto-loop dispatches it to the same `sampling_callback` or `list_roots_callback` you registered here. The whole list is in **[Deprecated features](../advanced/deprecated.md)**. +Both work. Both follow the rule above. And both serve RPCs the **2026-07-28 spec removes**: a modern server doesn't call back into your client mid-request, it hands the request back to you as part of the tool result (**[Multi-round-trip requests](../handlers/multi-round-trip.md)**). The callbacks themselves are not dead. When an `InputRequiredResult` carries a `CreateMessageRequest` or a `ListRootsRequest`, `Client`'s auto-loop dispatches it to the same `sampling_callback` or `list_roots_callback` you registered here. The whole list is in **[Deprecated features](../deprecated.md)**. You still need the callbacks to talk to servers that haven't moved. The signatures: @@ -131,7 +131,7 @@ Pass them to `Client(...)` exactly like `elicitation_callback`. Two more. Neither declares anything. -`logging_callback` receives every `notifications/message` a server sends, as `LoggingMessageNotificationParams` (`level`, `logger`, `data`). Protocol logging is itself deprecated by the 2026-07-28 spec (**[Logging](../tutorial/logging.md)** has what to do instead), so this callback exists for the servers that still emit it. +`logging_callback` receives every `notifications/message` a server sends, as `LoggingMessageNotificationParams` (`level`, `logger`, `data`). Protocol logging is itself deprecated by the 2026-07-28 spec (**[Logging](../handlers/logging.md)** has what to do instead), so this callback exists for the servers that still emit it. `message_handler` is the catch-all: every server notification reaches it (as well as its specific callback), and on a stream-backed transport so does every transport-level `Exception`. The one pattern worth knowing is `if isinstance(message, Exception): raise message`, so a broken connection fails loudly instead of vanishing. @@ -144,4 +144,4 @@ Two more. Neither declares anything. * `sampling_callback` and `list_roots_callback` work the same way but serve deprecated features; modern servers use multi-round-trip requests instead. * `logging_callback` and `message_handler` receive notifications. They declare nothing. -Next: the first argument you've been passing to `Client(...)` all along, **[Client transports](transports.md)**. +The first argument to `Client(...)` is a transport object. **[Client transports](transports.md)** covers every kind. diff --git a/docs/advanced/identity-assertion.md b/docs/client/identity-assertion.md similarity index 93% rename from docs/advanced/identity-assertion.md rename to docs/client/identity-assertion.md index 7e73183616..908f08a142 100644 --- a/docs/advanced/identity-assertion.md +++ b/docs/client/identity-assertion.md @@ -1,14 +1,14 @@ # Identity assertion -Every provider in **[OAuth clients](oauth-clients.md)** starts by asking the MCP server a question: *which authorization server do you trust?* It follows the answer wherever it points, and then either a person signs in or a pre-shared secret stands in for one. +An ordinary OAuth provider (**[OAuth clients](oauth-clients.md)**) starts by asking the MCP server a question: *which authorization server do you trust?* It follows the answer wherever it points, and then either a person signs in or a pre-shared secret stands in for one. An enterprise wants neither decided per server. It already runs an identity provider (Okta, Microsoft Entra ID, your own); the user already signed in to it this morning; and it is the one place the security team wants to decide who may reach what. [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990), the **Enterprise-Managed Authorization** extension, moves the decision there. The IdP signs a short-lived JWT, an **Identity Assertion JWT Authorization Grant**, the **ID-JAG**: a statement that *this user*, through *this client*, may reach *this MCP server*. The client trades it for an ordinary access token. No browser, no consent screen, no dynamic registration. -This chapter is both ends of that trade. The MCP server itself never changes: it is still the resource server from **[Authorization](authorization.md)**, checking whatever token shows up. +This page is both ends of that trade. The MCP server itself never changes: it is still the resource server from **[Authorization](../run/authorization.md)**, checking whatever token shows up. ## Two token requests -Two different authorities are in play, and naming them apart is most of understanding this page. The **enterprise IdP** is your organization's identity provider: it knows who the employee is, it is where policy lives, and it issues the ID-JAG. The SDK never talks to it. The **MCP authorization server** is the same party it was in **[Authorization](authorization.md)**: the issuer named in the MCP server's metadata, the thing that mints the tokens that MCP server accepts. In the flows you already know, those two roles are usually one box. Here they are two, and the whole grant is the second agreeing to trust the first. +Two different authorities are in play, and naming them apart is most of understanding this page. The **enterprise IdP** is your organization's identity provider: it knows who the employee is, it is where policy lives, and it issues the ID-JAG. The SDK never talks to it. The **MCP authorization server** is the same party it was in **[Authorization](../run/authorization.md)**: the issuer named in the MCP server's metadata, the thing that mints the tokens that MCP server accepts. In an ordinary OAuth flow, those two roles are usually one box. Here they are two, and the whole grant is the second agreeing to trust the first. The client makes one token request to each. @@ -27,7 +27,7 @@ Everything below is the second request: the client that sends it and the authori Read it from the bottom. -* `main()` is the `main()` from **[OAuth clients](oauth-clients.md)**, line for line. That is the point: once the provider exists, nothing downstream knows which grant produced the token. +* `main()` is the standard OAuth-client `main()` (**[OAuth clients](oauth-clients.md)**), unchanged line for line. That is the point: once the provider exists, nothing downstream knows which grant produced the token. * The provider takes what the other providers cannot discover: a `client_id` and `client_secret` somebody **pre-registered** with the authorization server, that authorization server's `issuer`, and `assertion_provider`, an async callback that returns a fresh ID-JAG on demand. * `storage` is the same `TokenStorage` protocol. Only the two token methods are ever called; there is no dynamic registration here, so there is no `client_info` to remember. @@ -108,7 +108,7 @@ And notice what the returned `OAuthToken` does not carry: a refresh token. The I !!! info A server that still embeds its authorization server with `auth_server_provider=` reaches the same - code through `AuthSettings(identity_assertion_enabled=True)`. **[Authorization](authorization.md)** explains why new + code through `AuthSettings(identity_assertion_enabled=True)`. **[Authorization](../run/authorization.md)** explains why new servers should not start there. !!! check @@ -143,4 +143,4 @@ And notice what the returned `OAuthToken` does not carry: a refresh token. The I * The authorization server is never discovered from the resource server. Configure `issuer` to exactly the string its metadata document serves; the comparison is character for character. * Server side, `identity_assertion_enabled=True` plus `exchange_identity_assertion`. The SDK authenticates the client and gates the grant; validating the ID-JAG is entirely yours, and the issued token is bound to the ID-JAG's `resource`, not the request's. -The one party this page never touched is the MCP server. What it does with the token you just minted, it was already doing in **[Authorization](authorization.md)**. +The one party this page never touched is the MCP server. What it does with the token you just minted, it was already doing in **[Authorization](../run/authorization.md)**. diff --git a/docs/client/index.md b/docs/client/index.md index 7712e0620f..01287da054 100644 --- a/docs/client/index.md +++ b/docs/client/index.md @@ -12,7 +12,7 @@ It is one object with one lifecycle: construct it, enter `async with`, call meth The server at the top is only there so you have something to connect to. The client is the five highlighted lines. -* `Client(mcp)` is given the **server object itself**. That is the in-memory transport: no subprocess, no port, no HTTP. It is how every example in this chapter, and every test you write, connects. +* `Client(mcp)` is given the **server object itself**. That is the in-memory transport: no subprocess, no port, no HTTP. It is how every example on this page, and every test you write, connects. * `async with` is the **lifecycle**. Entering it connects and negotiates; leaving it disconnects. There is no `connect()` / `close()` pair, and a `Client` cannot be reused after the block ends. * Inside the block the connection facts are already there as plain properties. @@ -24,7 +24,7 @@ The server at the top is only there so you have something to connect to. The cli * A URL string (`Client("http://localhost:8000/mcp")`): Streamable HTTP, the production path. * A **transport**: anything you can `async with ... as (read, write)`, such as `stdio_client(...)` wrapping a subprocess. -Everything else on this page is identical across all three. Headers, subprocesses, timeouts, and the `Transport` protocol get their own chapter: **[Client transports](transports.md)**. +Everything else on this page is identical across all three. Headers, subprocesses, timeouts, and the `Transport` protocol get their own page: **[Client transports](transports.md)**. ### What's on a connected client @@ -35,7 +35,7 @@ Four read-only properties, populated the moment you enter the block: * `client.protocol_version`: the protocol version the two sides agreed on. Here it is `"2026-07-28"`. * `client.instructions`: the server's `instructions=` string, or `None` if it didn't set one. -You never picked a protocol version. By default the `Client` probes the server and falls back to the classic handshake on older ones, so one client works against any era of server. When you need to control that, **[Protocol versions](protocol-versions.md)** has the whole story. +You never picked a protocol version. By default the `Client` probes the server and falls back to the classic handshake on older ones, so one client works against any era of server. When you need to control that, **[Protocol versions](../protocol-versions.md)** has the whole story. !!! tip `client.session` is the underlying `ClientSession`, the low-level escape hatch. @@ -104,7 +104,7 @@ That is why `main` narrows with `isinstance(block, TextContent)` before touching `structured_content` is the tool's return value as JSON, matching the tool's declared `output_schema`. No string parsing, no guessing. -When both are present they say the same thing twice on purpose: `content` is for a model, `structured_content` is for code. Where the structured half comes from, and how to control it, is the **[Structured Output](../tutorial/structured-output.md)** chapter. +When both are present they say the same thing twice on purpose: `content` is for a model, `structured_content` is for code. Where the structured half comes from, and how to control it, is the **[Structured Output](../servers/structured-output.md)** page. ### `is_error`: whether the tool failed @@ -129,7 +129,7 @@ A tool that raises does **not** raise in your client. It comes back as an ordina (`call_tool("does_not_exist", {})`) and nothing raises. You get the same shape back, `is_error=True` with `Unknown tool: does_not_exist` in `content`. A `Client` method raises `MCPError` only when the server answers with a JSON-RPC **error** instead of a result, and - **[Handling errors](../tutorial/handling-errors.md)** covers when a server produces which. + **[Handling errors](../servers/handling-errors.md)** covers when a server produces which. ## Resources @@ -145,7 +145,7 @@ The resource verbs come in pairs: two ways to list, one way to read. `read_resource` returns `contents`, a list of `TextResourceContents` or `BlobResourceContents`. Same idea as tool content: narrow with `isinstance`, then read `.text` (or `.blob`). -A client can also be told when a resource changes. On 2025-era connections that is `subscribe_resource(uri)` / `unsubscribe_resource(uri)` - a method pair `MCPServer` doesn't implement, so on the 2026-07-28 wire (where those verbs no longer exist) the request answers `-32601`, *Method not found*. The 2026 replacement is a `subscriptions/listen` stream, which `MCPServer` *does* serve - `server_capabilities.resources.subscribe` is `True` there, and the server side of the story is **[Subscriptions](../advanced/subscriptions.md)**. +A client can also be told when a resource changes. On 2025-era connections that is `subscribe_resource(uri)` / `unsubscribe_resource(uri)` - a method pair `MCPServer` doesn't implement, so on the 2026-07-28 wire (where those verbs no longer exist) the request answers `-32601`, *Method not found*. The 2026 replacement is a `subscriptions/listen` stream, which `MCPServer` *does* serve - `server_capabilities.resources.subscribe` is `True` there, and the server side of the story is **[Subscriptions](../handlers/subscriptions.md)**. ## Prompts @@ -181,7 +181,7 @@ A server with a completion handler can autocomplete prompt and resource-template * `ref` says *which* prompt or template you're filling in: a `PromptReference` or a `ResourceTemplateReference`. * `argument` is `{"name": ..., "value": ...}`: the argument and what the user has typed so far. -The answer is in `result.completion.values`. Type `"p"` and the server comes back with `['poetry']`. The server side, and how a handler uses the *other* already-filled arguments to narrow its suggestions, is the **[Completions](../tutorial/completions.md)** chapter. +The answer is in `result.completion.values`. Type `"p"` and the server comes back with `['poetry']`. The server side, and how a handler uses the *other* already-filled arguments to narrow its suggestions, is the **[Completions](../servers/completions.md)** page. ## Pagination @@ -197,7 +197,7 @@ This loop is correct against every server. `MCPServer` returns everything in one `Client(mcp)` with no process and no port is already a test harness for your server. -There is one constructor flag built for that: `Client(mcp, raise_exceptions=True)`. It only has an effect on in-memory connections, and **[Testing](../tutorial/testing.md)** is the chapter that explains it and builds the whole pattern around it. +There is one constructor flag built for that: `Client(mcp, raise_exceptions=True)`. It only has an effect on in-memory connections, and **[Testing](../get-started/testing.md)** is the page that explains it and builds the whole pattern around it. ## Recap @@ -209,4 +209,4 @@ There is one constructor flag built for that: `Client(mcp, raise_exceptions=True * `list_resources` / `list_resource_templates` / `read_resource`, `list_prompts` / `get_prompt`, and `complete` round out the verbs. * Every `list_*` takes `cursor=`; loop until `next_cursor` is `None`. -Next: the things a server can ask the *client* for, and how you answer, in **[Client callbacks](callbacks.md)**. +The things a server can ask the *client* for, and how you answer them, are **[Client callbacks](callbacks.md)**. diff --git a/docs/advanced/oauth-clients.md b/docs/client/oauth-clients.md similarity index 78% rename from docs/advanced/oauth-clients.md rename to docs/client/oauth-clients.md index 698a08f4f1..bde925d4d6 100644 --- a/docs/advanced/oauth-clients.md +++ b/docs/client/oauth-clients.md @@ -4,7 +4,7 @@ Some MCP servers are protected. Send them a request without a token and they ans **`OAuthClientProvider`** is how you get the token. It is not an MCP object at all. It is an `httpx.Auth`, the standard httpx hook for "do something to every request". You attach it to an `httpx.AsyncClient`, hand that client to the Streamable HTTP transport, and stop thinking about it. -This chapter is the client side. Making your own server demand a token is **[Authorization](authorization.md)**. +This page is the client side. Making your own server demand a token is **[Authorization](../run/authorization.md)**. ## The provider @@ -70,7 +70,7 @@ A real client runs a small local HTTP server on the redirect URI instead of call Look at `main()`. The provider goes on the **httpx client**, the httpx client goes into `streamable_http_client(url, http_client=...)`, and that transport goes into `Client`. -`streamable_http_client` has no `auth=` keyword. Anything HTTP-level (auth, headers, timeouts, proxies) belongs on the `httpx.AsyncClient` you bring. That layering is **[Client transports](../client/transports.md)**. +`streamable_http_client` has no `auth=` keyword. Anything HTTP-level (auth, headers, timeouts, proxies) belongs on the `httpx.AsyncClient` you bring. That layering is **[Client transports](transports.md)**. ## What the provider does for you @@ -83,13 +83,21 @@ The first time `Client` sends a request, the server answers `401`. The provider After that it is quiet. Tokens come out of storage, an expired access token is refreshed with the refresh token, and only when none of that works does it run the flow again. -You wrote none of it. Three keyword arguments remain (`timeout`, `client_metadata_url` and `validate_resource_url`), and this file needs none of them. +You wrote none of it. Three keyword arguments remain (`timeout`, `client_metadata_url` and `validate_resource_url`), and this file needs none of them. `client_metadata_url` is the one worth knowing about; it gets its own section below. ### Try it -Everything else in these docs you have checked with an in-memory `Client(server)`. Not this: the whole point of the flow is an HTTP `401`, and there is no HTTP between an in-memory client and its server. +Most examples in these docs you can check with an in-memory `Client(server)`. Not this: the whole point of the flow is an HTTP `401`, and there is no HTTP between an in-memory client and its server. -The repository ships the live version. `examples/servers/simple-auth/` runs a standalone authorization server and a protected MCP server; `examples/clients/simple-auth-client/` is this chapter's client grown into a small CLI. Its README has the two commands: start the servers, run the client against them, and you watch the four steps go by. +The repository ships the live version. `examples/servers/simple-auth/` runs a standalone authorization server and a protected MCP server; `examples/clients/simple-auth-client/` is this page's client grown into a small CLI. Its README has the two commands: start the servers, run the client against them, and you watch the four steps go by. + +## Client ID Metadata Documents + +The 2026-07-28 revision of the spec deprecates dynamic client registration in favor of **Client ID Metadata Documents** (CIMD). Instead of POSTing a fresh registration to every authorization server it meets, your client publishes one JSON document about itself at a stable HTTPS URL, and that URL *is* its `client_id`. The authorization server fetches the document; the provider never touches it. + +The SDK already speaks it: pass the URL as `client_metadata_url=` when you construct the provider. When the authorization server's metadata advertises `client_id_metadata_document_supported: true`, the provider skips the `/register` request entirely: the URL goes into the flow as the `client_id`, and there is no `client_secret`. When the server doesn't advertise it (most don't yet), or you never pass a URL, the provider falls back to dynamic registration **silently**, and everything above works exactly as described. Stored `client_info` still wins over both. + +The URL must be HTTPS with a non-root path; anything else is a `ValueError` at construction, before any network happens. The shipped `examples/clients/simple-auth-client/` takes it as the `MCP_CLIENT_METADATA_URL` environment variable. ## Machine to machine @@ -119,7 +127,7 @@ By default the secret travels as HTTP Basic auth on the token request (`client_s the same pattern: construct one, put it on `auth=`. The same module ships `SignedJWTParameters` and `static_assertion_provider`, two helpers that build its assertion. -There is one more no-human situation: the client belongs to an enterprise whose identity provider, not the user, decides which MCP servers it may reach. That is a different grant with its own trust model and its own chapter, **[Identity assertion](identity-assertion.md)**. +There is one more no-human situation: the client belongs to an enterprise whose identity provider, not the user, decides which MCP servers it may reach. That is a different grant with its own trust model and its own page, **[Identity assertion](identity-assertion.md)**. ## When it fails @@ -132,8 +140,8 @@ Not everything is a flow error. The network can still fail; those are ordinary ` * `OAuthClientProvider` is an `httpx.Auth`. Put it on an `httpx.AsyncClient`, pass that to `streamable_http_client(url, http_client=...)`, and `Client` never knows OAuth happened. * You supply four things: the server URL, an `OAuthClientMetadata`, a `TokenStorage`, and the redirect/callback handler pair. * `TokenStorage` is a `Protocol`: four async methods, no base class. Persist `client_info` as well as the tokens. -* Discovery, dynamic registration, PKCE, the `state` and `iss` checks, and token refresh are the provider's job, not yours. +* Discovery, registration (dynamic, or via a **Client ID Metadata Document**), PKCE, the `state` and `iss` checks, and token refresh are the provider's job, not yours. * `ClientCredentialsOAuthProvider` is the no-human version: `client_id` + `client_secret`, no handlers, no browser. * Every OAuth failure is an `OAuthFlowError`; `OAuthRegistrationError` and `OAuthTokenError` are its subclasses. -The other half of this handshake, making your *server* demand the token, is **[Authorization](authorization.md)**. +The other half of this handshake, making your *server* demand the token, is **[Authorization](../run/authorization.md)**. diff --git a/docs/advanced/session-groups.md b/docs/client/session-groups.md similarity index 93% rename from docs/advanced/session-groups.md rename to docs/client/session-groups.md index 952231b842..c7a1434fb1 100644 --- a/docs/advanced/session-groups.md +++ b/docs/client/session-groups.md @@ -68,7 +68,7 @@ If you already hold a connected `ClientSession` (`Client.session` is one), hand ## The classic handshake -`ClientSessionGroup` is built on `ClientSession`, not on `Client`. Each `connect_to_server` runs the classic `initialize` handshake. It never sends the `server/discover` probe described in **[Protocol versions](../client/protocol-versions.md)**. Every MCP server understands that handshake, so this costs you compatibility with nothing; it only means a group takes the older, slower path to a server that could do better. +`ClientSessionGroup` is built on `ClientSession`, not on `Client`. Each `connect_to_server` runs the classic `initialize` handshake. It never sends the `server/discover` probe described in **[Protocol versions](../protocol-versions.md)**. Every MCP server understands that handshake, so this costs you compatibility with nothing; it only means a group takes the older, slower path to a server that could do better. ## Recap @@ -79,4 +79,4 @@ If you already hold a connected `ClientSession` (`Client.session` is one), hand * `component_name_hook=` rewrites every registered name. The dict key changes, the wire name does not. * `connect_with_session` adds a session you already hold; `disconnect_from_server` removes one. -The handshake a group speaks (and the faster one a `Client` prefers) is the subject of **[Protocol versions](../client/protocol-versions.md)**. +The handshake a group speaks (and the faster one a `Client` prefers) is the subject of **[Protocol versions](../protocol-versions.md)**. diff --git a/docs/client/transports.md b/docs/client/transports.md index 1503979a3a..1587a1a7d7 100644 --- a/docs/client/transports.md +++ b/docs/client/transports.md @@ -18,7 +18,7 @@ No subprocess, no port, no bytes on a wire. The client and the server are two ob That makes it two things at once: -* **A test harness.** Every example in this documentation is exercised this way, and the **[Testing](../tutorial/testing.md)** chapter builds the whole pattern around it. +* **A test harness.** Every example in this documentation is exercised this way, and the **[Testing](../get-started/testing.md)** page builds the whole pattern around it. * **An embedding API.** An application that constructs the server doesn't need a network hop to call its tools. ## Streamable HTTP @@ -68,11 +68,11 @@ Two things to notice: !!! info If you know `httpx`, you already know how to do auth, proxies, event hooks, retries and connection limits here. The SDK adds nothing on top and takes nothing away. It is also where OAuth plugs in: - `httpx.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **[OAuth clients](../advanced/oauth-clients.md)**. + `httpx.AsyncClient(auth=OAuthClientProvider(...))`. That whole flow is **[OAuth clients](oauth-clients.md)**. ## stdio -A **stdio** server is a subprocess. The client launches it, writes JSON-RPC to its stdin and reads JSON-RPC from its stdout. It is how a desktop host runs a server on your machine. +A **stdio** server is a subprocess. The client launches it, writes JSON-RPC to its stdin and reads JSON-RPC from its stdout. It is how a desktop host runs a server on your machine: a host *is* this code plus a UI, and **[Connect to a real host](../get-started/real-host.md)** is the same relationship seen from the host's side, as a config file. Describe the process with `StdioServerParameters`, turn it into a transport with `stdio_client`, and hand *that* to `Client`: @@ -112,4 +112,4 @@ A **transport** is any async context manager that yields a `(read, write)` pair * A transport is anything you can `async with x as (read, write)`. `Client` hands anything that isn't a server object or a URL straight to that protocol. * Constructing a `Client` picks the transport. `async with` opens it. -Once the transport is open the two sides have to agree on a protocol version. You normally never think about it; when you do, **[Protocol versions](protocol-versions.md)** is the page. +Once the transport is open the two sides have to agree on a protocol version. You normally never think about it; when you do, **[Protocol versions](../protocol-versions.md)** is the page. diff --git a/docs/advanced/deprecated.md b/docs/deprecated.md similarity index 87% rename from docs/advanced/deprecated.md rename to docs/deprecated.md index 18bcc79463..9b879f1f6f 100644 --- a/docs/advanced/deprecated.md +++ b/docs/deprecated.md @@ -8,16 +8,16 @@ The table below names each deprecated feature, why it is going away, and the rep | Deprecated | Why | What you do instead | |---|---|---| -| **Roots**: `ctx.session.list_roots()`, `client.send_roots_list_changed()`, the `list_roots_callback=` you pass to `Client(...)` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) retires the capability. | Take the paths as ordinary tool arguments or resource URIs, or embed a `ListRootsRequest` in an `InputRequiredResult` (see **[Multi-round-trip requests](multi-round-trip.md)**). | -| **Server-initiated sampling**: `ctx.session.create_message()`, the `sampling_callback=` you pass to `Client(...)` | SEP-2577 retires the capability. | Return `InputRequiredResult` and let the client retry the call (see **[Multi-round-trip requests](multi-round-trip.md)**). | -| **Protocol logging**: `ctx.log()`, `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`, `ctx.session.send_log_message()`, `client.set_logging_level()` | SEP-2577 retires the capability. Nothing in-protocol replaces it. | Ordinary `import logging` to stderr (see **[Logging](../tutorial/logging.md)**). | +| **Roots**: `ctx.session.list_roots()`, `client.send_roots_list_changed()`, the `list_roots_callback=` you pass to `Client(...)` | [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) retires the capability. | Take the paths as ordinary tool arguments or resource URIs, or embed a `ListRootsRequest` in an `InputRequiredResult` (see **[Multi-round-trip requests](handlers/multi-round-trip.md)**). | +| **Server-initiated sampling**: `ctx.session.create_message()`, the `sampling_callback=` you pass to `Client(...)` | SEP-2577 retires the capability. | Return `InputRequiredResult` and let the client retry the call (see **[Multi-round-trip requests](handlers/multi-round-trip.md)**). | +| **Protocol logging**: `ctx.log()`, `ctx.debug()`, `ctx.info()`, `ctx.warning()`, `ctx.error()`, `ctx.session.send_log_message()`, `client.set_logging_level()` | SEP-2577 retires the capability. Nothing in-protocol replaces it. | Ordinary `import logging` to stderr (see **[Logging](handlers/logging.md)**). | | **`ping`**: `client.send_ping()` | **Removed** from the protocol, not merely deprecated. There is no `ping` method in 2026-07-28. | Nothing. It only works against a `mode="legacy"` connection. | -| **Client->server progress**: `client.send_progress_notification()` | 2026-07-28 makes progress server->client only. | Nothing to send. Your *server* reports progress with `ctx.report_progress()` (see **[Progress](../tutorial/progress.md)**). | +| **Client->server progress**: `client.send_progress_notification()` | 2026-07-28 makes progress server->client only. | Nothing to send. Your *server* reports progress with `ctx.report_progress()` (see **[Progress](handlers/progress.md)**). | Three things fall out of that table: * Roots, sampling, and logging go together. One proposal, **SEP-2577**, deprecates all three capabilities at once. -* Sampling and roots share a deeper problem: they are places a **server** sends a **request** to the **client**. That whole direction is what 2026-07-28 replaces with **[Multi-round-trip requests](multi-round-trip.md)**. It is the standalone RPC methods (`sampling/createMessage`, `roots/list`, and push-style `elicitation/create`) that are gone; the `CreateMessageRequest` / `ListRootsRequest` / `ElicitRequest` payload types survive, embedded in `InputRequiredResult.input_requests`, and on the client they hit the same callbacks. +* Sampling and roots share a deeper problem: they are places a **server** sends a **request** to the **client**. That whole direction is what 2026-07-28 replaces with **[Multi-round-trip requests](handlers/multi-round-trip.md)**. It is the standalone RPC methods (`sampling/createMessage`, `roots/list`, and push-style `elicitation/create`) that are gone; the `CreateMessageRequest` / `ListRootsRequest` / `ElicitRequest` payload types survive, embedded in `InputRequiredResult.input_requests`, and on the client they hit the same callbacks. * `ping` is the odd one out. The protocol does not deprecate it, it removes it. The SDK method still warns (its message says *removed*, not *deprecated*) and calling it on a modern connection answers with *"Method not found"*. ## Deprecated is advisory @@ -82,7 +82,7 @@ That is the whole API. There is no per-method switch, and you don't want one: th ## Recap * The 2026-07-28 spec deprecates **roots**, server-initiated **sampling**, and protocol **logging** (all [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), restricts **progress** to server-to-client, and removes **`ping`**. -* The replacement column points you onward: **[Multi-round-trip requests](multi-round-trip.md)** for sampling and roots, **[Logging](../tutorial/logging.md)** for logging, **[Progress](../tutorial/progress.md)** for progress. `ping` needs nothing at all. +* The replacement column points you onward: **[Multi-round-trip requests](handlers/multi-round-trip.md)** for sampling and roots, **[Logging](handlers/logging.md)** for logging, **[Progress](handlers/progress.md)** for progress. `ping` needs nothing at all. * Deprecated is advisory: no wire changes, everything keeps working against pre-2026 sessions, and you get a visible `MCPDeprecationWarning` (a `UserWarning`, so it is on by default). * Sampling and roots additionally need a back-channel that a 2026-07-28 session does not have. On a modern connection they warn and then they raise. * `warnings.filterwarnings("ignore", category=MCPDeprecationWarning)` silences the whole category; `"error::mcp.MCPDeprecationWarning"` in pytest turns it into a test failure. diff --git a/docs/tutorial/first-steps.md b/docs/get-started/first-steps.md similarity index 85% rename from docs/tutorial/first-steps.md rename to docs/get-started/first-steps.md index 5328d12be4..ad2f4c63fd 100644 --- a/docs/tutorial/first-steps.md +++ b/docs/get-started/first-steps.md @@ -1,8 +1,8 @@ # First steps -On the landing page you wrote a server, ran it, and called a tool. +The **[landing page](../index.md)** moves fast: write a server, run it, call a tool. -Now do it again, slowly, with all three things a server can expose, and the names for everything you just saw. +This page takes it slowly, with all three things a server can expose, and a name for everything along the way. ## Host, client, and server @@ -12,7 +12,7 @@ Three words you'll see on every page from here on: * A **client** lives inside the host and speaks MCP. The host runs one client per server it's connected to. * A **server** is what you build with this SDK. It exposes things to clients. It never talks to the model directly. -You write the server. Hosts are someone else's product. The SDK also gives you a `Client`. You'll use it to test your servers, and it shows up later in this chapter. +You write the server. Hosts are someone else's product. The SDK also gives you a `Client`. You'll use it to test your servers, and it shows up later on this page. ## The three primitives @@ -70,7 +70,7 @@ Hello, World! **Prompts.** One entry: `summarize`, with a single required `text` argument. Get it with some text and you receive one message with `role: user` and your rendered string as the content. That's all a prompt is: a function that builds messages. -The Inspector ran your server over **stdio**, one of the transports an MCP server can speak. You don't pick one yet; **[Running your server](../run/index.md)** is the chapter for that. +The Inspector ran your server over **stdio**, one of the transports an MCP server can speak. You don't pick one yet; **[Running your server](../run/index.md)** is the page for that. ## Capabilities @@ -100,7 +100,7 @@ asyncio.run(main()) {'prompts': {'list_changed': True}, 'resources': {'subscribe': True, 'list_changed': True}, 'tools': {'list_changed': True}} ``` -That dictionary is the server's half of the handshake: +That dictionary is your server's declared **capabilities**. It's the first thing every connecting client learns: | Capability | The client may now call | |-------------|------------------------------------------------------------| @@ -110,11 +110,11 @@ That dictionary is the server's half of the handshake: `MCPServer` serves all three primitives, so all three are always declared. -Notice what isn't there. `completions` (argument autocomplete for resource templates and prompts) needs a handler you write, this server doesn't have one, so the capability is absent and a well-behaved client won't ask. That's the rule for everything optional: register the thing and the capability appears; **[Completions](completions.md)** proves it. +Notice what isn't there. `completions` (argument autocomplete for resource templates and prompts) needs a handler you write, this server doesn't have one, so the capability is absent and a well-behaved client won't ask. That's the rule for everything optional: register the thing and the capability appears; **[Completions](../servers/completions.md)** proves it. !!! info - `Client(mcp)` is the same in-memory client every example in this tutorial is tested with, and - it's how you'll test yours. It gets a whole chapter: **[Testing](testing.md)**. + `Client(mcp)` is the same in-memory client every example in these docs is tested with, and + it's how you'll test yours. It gets a whole page: **[Testing](testing.md)**. ## What you did not write @@ -123,7 +123,7 @@ Look back over this page. You wrote three small Python functions. You did **not* * A JSON Schema. `a: int, b: int` *is* the schema for `add`. * A request handler. `tools/list`, `resources/read`, `prompts/get`: all served for you. * A capability declaration. `MCPServer` made it for you. -* A line of protocol. The handshake, the version negotiation, the JSON-RPC framing: all of it happened inside `mcp dev` and `Client(mcp)`, and you never saw it. +* A line of protocol. The version negotiation, the JSON-RPC framing, the capability exchange: all of it happened inside `mcp dev` and `Client(mcp)`, and you never saw it. That ratio is the whole point of the SDK. @@ -136,4 +136,4 @@ That ratio is the whole point of the SDK. * The server's **capabilities** are declared for you, and a client only asks for what a server declares. * `Client(mcp)` connects to the server object in memory: your test harness from day one. -Each primitive now gets its own chapter, starting with the one the model drives: **[Tools](tools.md)**. +Next up is **[Connect to a real host](real-host.md)**: this server inside Claude Desktop or an IDE, for real. Then **[Testing](testing.md)**: one page, one in-memory client, and you're never guessing whether it works. After that, each primitive gets its own page, starting with the one the model drives: **[Tools](../servers/tools.md)**. diff --git a/docs/get-started/index.md b/docs/get-started/index.md new file mode 100644 index 0000000000..6a317692bf --- /dev/null +++ b/docs/get-started/index.md @@ -0,0 +1,52 @@ +# Get started + +New to MCP, or new to this SDK? Start here. These pages take you from nothing to a +working, tested server: [install the SDK](installation.md), build your +[first server](first-steps.md), [connect it to a real host](real-host.md), and +[test it](testing.md) with an in-memory client. + +## Run the code + +All the code blocks can be copied and used directly: they are complete, working files. + +To follow along, paste a block into a `server.py` and open it in the MCP Inspector: + +```console +uv run mcp dev server.py +``` + +It is **HIGHLY encouraged** that you write (or copy) the code, edit it, and run it locally. Using it in your own editor is what really shows you the point: how little you write, the autocompletion, the type checks catching mistakes before you run anything. + +## You will not be guessing + +Every example in these docs is a complete file under [`docs_src/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/docs_src) in the SDK's own repository, and every one of them is exercised by the SDK's test suite through an **in-memory client**: + +```python +import pytest +from mcp import Client + +from server import mcp + + +@pytest.mark.anyio +async def test_add() -> None: + async with Client(mcp) as client: + result = await client.call_tool("add", {"a": 1, "b": 2}) + assert result.structured_content == {"result": 3} +``` + +No subprocess, no port, no transport. `Client(mcp)` connects to the server object directly. + +If a change to the SDK breaks an example on one of these pages, CI goes red before the page does. The code you read here is the code that runs. + +You'll use this yourself in [Testing](testing.md); it's how you test your own servers, too. + +## Where to go next + +Once you have a server running, the rest of these docs are a reference, not a course. +Every page stands on its own, so jump straight to what you need: + +* What a server exposes (tools, resources, prompts) is **[Servers](../servers/index.md)**. +* What's available inside the functions you register is **[Inside your handler](../handlers/index.md)**. +* Getting it in front of clients (stdio, HTTP, your existing FastAPI app) is **[Running your server](../run/index.md)**. +* Building the other side, an application that *uses* MCP servers, is **[Clients](../client/index.md)**. diff --git a/docs/installation.md b/docs/get-started/installation.md similarity index 100% rename from docs/installation.md rename to docs/get-started/installation.md diff --git a/docs/get-started/real-host.md b/docs/get-started/real-host.md new file mode 100644 index 0000000000..159c1f56f7 --- /dev/null +++ b/docs/get-started/real-host.md @@ -0,0 +1,182 @@ +# Connect to a real host + +A **host** is the application your server ends up inside: Claude Desktop, Claude Code, an IDE. The host is what the user talks to. Inside it, an MCP **client** launches your server as a child process and speaks to it over that process's stdin and stdout. + +Which means connecting to a host is one act: you tell it **the command that starts your server**. Everything on this page (two CLI commands, three JSON files) is a different place to put that same command. + +## One server, every host + +```python title="server.py" hl_lines="3 33-34" +--8<-- "docs_src/real_host/tutorial001.py" +``` + +Two tools and a resource, one file. Three things about that file matter to every host below: + +* `mcp.run()` with no arguments starts a **stdio** server: it blocks, reads protocol messages on stdin, and writes them on stdout. That is the transport every host on this page speaks. The host starts your file as a child process and owns those two pipes, which is why connecting is only ever "here is the command". You never pick a port, and nothing listens on one. +* `run()` is under `if __name__ == "__main__":`. Everything below **imports** this file rather than executing it, so an unguarded `run()` would start a server the moment anything loaded the module. +* The server object is a module-level global named `mcp`. That's the name `mcp run` looks for (`server` and `app` also work). Call it something else and you name it explicitly: `mcp run server.py:bookshop`. + +That is the last line of Python on this page. From here down it is all host configuration. + +## The launch command + +Every host below gets the same command: + +```bash +uv run --with "mcp[cli]==2.0.0b1" mcp run /absolute/path/to/server.py +``` + +One command for all of them because `uv run --with` resolves the pinned SDK into a fresh environment on the spot: it works from any directory, needs no project and no virtual environment to activate, and always gets the exact `mcp` version these docs describe. That matters here more than anywhere else, because a host launches your server from *its* working directory with a near-empty environment, not from your shell. + +It is also the command `mcp install` writes into Claude Desktop's config for you (below), so what you type by hand and what the tool generates agree. + +!!! warning "The version pin is not optional" + v2 of this SDK is in beta, and installers never select a pre-release unless you name one. An + unpinned `--with "mcp[cli]"` gives you the latest **v1.x**, which these docs do not describe. + Use the exact pin from **[Installation](installation.md)**. + +!!! tip "If a host can't find `uv`" + A host spawns your server with a minimal `PATH`, and `uv` may not be on it. Replace the bare + `uv` with the absolute path from `which uv` (macOS/Linux) or `where uv` (Windows). That is + exactly what `mcp install` writes. + +!!! note "This page is the local story" + Everything here runs your server on the machine the host is on: the host launches your + file, over stdio. That is exactly right for a personal or single-machine tool. To give a + server to people who do *not* have your file, you hand out a **URL**, not a command: the + same `mcp` object served over Streamable HTTP. **[Running your server](../run/index.md)** + is that decision in one table, and **[Deploy & scale](../run/deploy.md)** is the road from + there to a real hostname. + + And a host is nothing more than an application with an MCP client inside it, so your own + Python can play the host's part: **[Client transports](../client/transports.md)** launches + this same file as a subprocess with `stdio_client(...)`, and **[Testing](testing.md)** + connects to it in memory with no process at all. + +## Claude Desktop + +The one host the SDK can configure for you: + +```bash +uv run mcp install server.py +``` + +That's it. `mcp install` imports the file to read the server's name, finds Claude Desktop's config file, and writes the launch command into it. Along the way it converts your path to an absolute one, so you don't have to. + +There is nothing to be mystified by. This is the entry it writes: + +```json +{ + "mcpServers": { + "Bookshop": { + "command": "/absolute/path/to/uv", + "args": [ + "run", + "--frozen", + "--with", + "mcp[cli]==2.0.0b1", + "mcp", + "run", + "/absolute/path/to/server.py" + ] + } + } +} +``` + +That's the launch command from the section above with two additions: the absolute path to `uv`, and `--frozen` so `uv` never rewrites a lockfile it happens to be near. It lands in `claude_desktop_config.json`, which lives at: + +* **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +* **Windows**: `%APPDATA%\Claude\claude_desktop_config.json` + +You can write that file by hand. `mcp install` exists so you don't make the two classic mistakes (a relative path, a missing version pin) while doing it. + +Fully quit Claude Desktop (not just its window) and reopen it. + +!!! warning + `mcp install` fails with `Claude app not found` if Claude Desktop's config *directory* doesn't + exist yet. Install Claude Desktop and run it once: that's what creates the directory. + +!!! tip + Claude Desktop starts your server in its own process, so your shell's environment variables are + not there. `uv run mcp install server.py -v API_KEY=abc123` (or `-f .env`) records them in the + entry's `env` field. `--name` overrides the entry name; it defaults to the server's `name`. + +## Claude Code + +There is no file to edit. Register the server with the `claude` CLI; everything after `--` is the launch command. + +```bash +claude mcp add bookshop -- uv run --with "mcp[cli]==2.0.0b1" mcp run /absolute/path/to/server.py +``` + +Run `/mcp` inside a Claude Code session to confirm `bookshop` is connected and its tools are listed. + +## Cursor + +Create `.cursor/mcp.json` in your project root. + +```json +{ + "mcpServers": { + "bookshop": { + "command": "uv", + "args": ["run", "--with", "mcp[cli]==2.0.0b1", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +The same `command` plus `args`, under the same `mcpServers` key Claude Desktop uses. The server appears in Cursor's MCP settings with both tools listed. + +## VS Code + +Create `.vscode/mcp.json` in your project root. + +```json +{ + "servers": { + "bookshop": { + "type": "stdio", + "command": "uv", + "args": ["run", "--with", "mcp[cli]==2.0.0b1", "mcp", "run", "/absolute/path/to/server.py"] + } + } +} +``` + +Two differences from Cursor's file, and they are the only two: the wrapper key is `servers`, not `mcpServers`, and each entry declares its `type`. Confirm the trust prompt, then **MCP: List Servers** in the Command Palette shows `bookshop` running. + +!!! note + You need VS Code 1.99 or later with the **GitHub Copilot** extension signed in (Copilot Free is + enough), and Copilot Chat must be in **Agent** mode, because no other mode calls tools. + +## It doesn't show up + +Before you touch any host config, run the launch command yourself: + +```bash +uv run --with "mcp[cli]==2.0.0b1" mcp run /absolute/path/to/server.py +``` + +Nothing prints, and it doesn't return. That silence is correct: a stdio server is waiting for a host to speak first on stdin (`Ctrl-C` to stop it). A traceback or an immediate exit is the real bug, and now you can read it instead of guessing at it through a host. + +Once that command sits and waits, what's left is almost always one of three things: + +* **A relative path.** The host launches your server from *its* working directory, not the one you registered from. `server.py` where `/absolute/path/to/server.py` is needed is the single most common failure. If the host can't find `uv` either, that path has to be absolute too. +* **The host is still running its old config.** Hosts read their config at launch. Claude Desktop in particular has to be *fully quit* (not just its window closed) and reopened before an edit to `claude_desktop_config.json` takes effect. +* **Something reached stdout.** On stdio, stdout *is* the protocol. One stray `print()` and the host reads a corrupt message and drops the connection. Log with the `logging` module, which writes to stderr. **[Logging](../handlers/logging.md)** has the whole story. + +Claude Desktop keeps a log per server: `mcp-server-.log` is your server's stderr, next to `mcp.log` for connections, under `~/Library/Logs/Claude` on macOS and `%APPDATA%\Claude\logs` on Windows. + +For anything past those three, **[Troubleshooting](../troubleshooting.md)** is the page. + +## Recap + +* A **host** (Claude Desktop, an IDE) runs an MCP client that launches your server as a child process over stdio. Connecting means giving it one launch command. +* That command is `uv run --with "mcp[cli]==2.0.0b1" mcp run /absolute/path/to/server.py`: version-pinned, no venv to activate, works from any directory. The pin is mandatory while v2 is in beta. +* **Claude Desktop** is the one host `mcp install` configures for you. It writes that same command (plus the absolute path to `uv`) into `claude_desktop_config.json`, so you never have to. +* **Claude Code** is `claude mcp add bookshop -- `. **Cursor** is `.cursor/mcp.json` under `mcpServers`. **VS Code** is `.vscode/mcp.json` under `servers`, each entry with a `type`. +* Absolute paths everywhere, restart the host after editing its config, and never let anything but the SDK write to stdout. + +Every host on this page connected to the same file, with the same command. What that file can *expose* is the rest of these docs: **[Tools](../servers/tools.md)**, **[Resources](../servers/resources.md)**, and every transport besides stdio in **[Running your server](../run/index.md)**. diff --git a/docs/tutorial/testing.md b/docs/get-started/testing.md similarity index 86% rename from docs/tutorial/testing.md rename to docs/get-started/testing.md index f5fe16765c..82a113cfb1 100644 --- a/docs/tutorial/testing.md +++ b/docs/get-started/testing.md @@ -78,8 +78,8 @@ Two different things can go wrong, and this flag only touches one of them. An exception inside one of **your tools** is not a protocol failure. It becomes a normal result with `is_error=True`, and the model reads the message. `raise_exceptions` doesn't change that: with or -without it, `call_tool` returns the same `is_error=True` result. There's a whole chapter on it: -**[Handling errors](handling-errors.md)**. +without it, `call_tool` returns the same `is_error=True` result. There's a whole page on it: +**[Handling errors](../servers/handling-errors.md)**. A failure **outside** a tool body is different. On the connection `Client(mcp)` gives you, the server sanitises it into a generic `"Internal server error"` before the client sees it. You should @@ -98,9 +98,10 @@ Leave it on in tests. It has no meaning in production code. there: a legacy connection never sanitises in the first place, and the flag re-raises the failure inside the server task instead of in your test. -That one line is also why the rest of this tutorial can promise you that its examples work: every -example file is exercised by the SDK's own test suite through exactly this client. You're using the -same tool the SDK uses on itself. +That one line is also why these docs can promise you that their examples work: every +example file is exercised by the SDK's own test suite, almost all of them through exactly this +client. You're using the same tool the SDK uses on itself. -The tutorial ends here. Putting your tested server in front of a real client, over a real -transport, is **[Running your server](../run/index.md)**. +You have a working, tested server. Putting it inside a real application (Claude Desktop, an +IDE) is **[Connect to a real host](real-host.md)**; every other way to serve it is +**[Running your server](../run/index.md)**. diff --git a/docs/tutorial/context.md b/docs/handlers/context.md similarity index 91% rename from docs/tutorial/context.md rename to docs/handlers/context.md index 96bdd0776e..f43521aa05 100644 --- a/docs/tutorial/context.md +++ b/docs/handlers/context.md @@ -66,7 +66,7 @@ The injected object is small. Besides `request_id`: * `ctx.headers`: the request headers the transport carried, or `None` on stdio. Read a custom header with `(ctx.headers or {}).get("x-...")`. Headers are client-supplied input - fine for a locale or a feature flag, never an identity. * `ctx.request_context`: the raw per-request record. The field you'll reach for is `lifespan_context`, the object your startup code yielded (see **[Lifespan](lifespan.md)**). -Logging is deliberately not on that list. A server logs with Python's `logging` module, like any other Python program. **[Logging](logging.md)** is the short chapter on why. +Logging is deliberately not on that list. A server logs with Python's `logging` module, like any other Python program. **[Logging](logging.md)** is the short page on why. !!! tip Injection only happens for the function you registered. A helper that your tool calls doesn't get @@ -104,7 +104,7 @@ What a server offers is not fixed at import time. Register a tool at runtime, th The siblings are `send_resource_list_changed()`, `send_prompt_list_changed()`, and `send_resource_updated(uri)` for a change to one specific resource. -On a 2026-07-28 connection, clients receive change notifications only on a `subscriptions/listen` stream they opened — the `send_*` methods above do not reach those streams. The `Context` publish methods — `await ctx.notify_tools_changed()`, `await ctx.notify_prompts_changed()`, `await ctx.notify_resources_changed()`, and `await ctx.notify_resource_updated(uri)` — deliver to every subscribed stream at once. The whole story, including scaling out across replicas, is in **[Subscriptions](../advanced/subscriptions.md)**. +On a 2026-07-28 connection, clients receive change notifications only on a `subscriptions/listen` stream they opened, so the `send_*` methods above do not reach those streams. The `Context` publish methods deliver to every subscribed stream at once: `await ctx.notify_tools_changed()`, `await ctx.notify_prompts_changed()`, `await ctx.notify_resources_changed()`, and `await ctx.notify_resource_updated(uri)`. The whole story, including scaling out across replicas, is in **[Subscriptions](subscriptions.md)**. !!! check Before anyone runs `enable_recommendations`, the tool you are promising does not exist. Call it @@ -124,6 +124,6 @@ On a 2026-07-28 connection, clients receive change notifications only on a `subs * `ctx.request_id` identifies the request; `ctx.request_context.lifespan_context` is what your startup yielded. * `await ctx.read_resource(uri)` lets a tool read the server's own resources. * `ctx.session` is the channel back to the client: `send_tool_list_changed()` and its siblings tell it to re-fetch a list you changed. -* Progress reporting and elicitation also start at `Context`; each has its own chapter. +* Progress reporting and elicitation also start at `Context`; each has its own page. -Next: parameters the model never sees, filled by your own functions, in **[Dependencies](dependencies.md)**. +Parameters the model never sees, filled by your own functions, are **[Dependencies](dependencies.md)**. diff --git a/docs/tutorial/dependencies.md b/docs/handlers/dependencies.md similarity index 97% rename from docs/tutorial/dependencies.md rename to docs/handlers/dependencies.md index 8d6d91412d..6260b72f5a 100644 --- a/docs/tutorial/dependencies.md +++ b/docs/handlers/dependencies.md @@ -84,7 +84,7 @@ A resolver's parameters resolve exactly like a tool's: another `Resolve(...)`, t !!! warning On HTTP transports the `Context` includes `ctx.headers`. Headers are **client-supplied input**, like any tool argument: fine for a locale or a feature flag, never an identity. Who the caller - is comes from your authorization layer (**[Authorization](../advanced/authorization.md)**), not from a header anyone can set. + is comes from your authorization layer (**[Authorization](../run/authorization.md)**), not from a header anyone can set. !!! tip *Once per call* means exactly that: the next `tools/call` runs `check_stock` again. A resource @@ -120,7 +120,7 @@ That's the right default for a precondition: no answer, no order. When declining The framework picks the question's transport from the negotiated protocol version; the code above is identical on both. On **2026-07-28** and later the question rides inside a multi-round-trip `tools/call` - the server returns it, the client's `elicitation_callback` - answers it, and the `Client` retries the call for you (**[Multi-round-trip requests](../advanced/multi-round-trip.md)**). On + answers it, and the `Client` retries the call for you (**[Multi-round-trip requests](multi-round-trip.md)**). On **2025-11-25** and earlier it is a synchronous elicitation request mid-call. Each question is asked exactly once per call - a guarantee about the question, not the resolver. In the multi-round-trip form any resolver may run again whenever the call resumes after a question, @@ -142,4 +142,4 @@ That's the right default for a precondition: no answer, no order. When declining * Bad graphs fail at registration with `InvalidSignature`, not mid-call. * Return `Elicit(message, Model)` to ask the user, only when you have to. Unwrapped annotations abort on decline; `ElicitationResult[T]` lets the tool branch. -Next: what happens when your tool fails, and how to choose who finds out, in **[Handling errors](handling-errors.md)**. +The state your server builds once at startup, and how a handler reaches it, is the **[Lifespan](lifespan.md)** page. diff --git a/docs/tutorial/elicitation.md b/docs/handlers/elicitation.md similarity index 73% rename from docs/tutorial/elicitation.md rename to docs/handlers/elicitation.md index 7bd27a78a0..3f3f5a6c07 100644 --- a/docs/tutorial/elicitation.md +++ b/docs/handlers/elicitation.md @@ -2,22 +2,53 @@ A tool that is halfway through its job and missing one answer doesn't have to fail. -**Elicitation** lets it ask. In the middle of a tool call the server sends the client a question, the client puts it to the user, and the answer comes back into the same function call. +**Elicitation** lets it ask. In the middle of a tool call the user gets a question, and their answer comes back into the same function call. There are two modes: * **Form mode**: you need a value (a confirmation, a date, a quantity). You describe the fields, the client renders the form. * **URL mode**: you need the user to go somewhere else (an OAuth consent screen, a payment page). Nothing they do there passes through the protocol. -## Ask with a form +And there are two ways to ask. The one to reach for is a **resolver**: you hang the question on a parameter, and the SDK asks - on any connection, whatever protocol era the client speaks. The direct way, `await ctx.elicit(...)`, is a request from the *server* to the *client*, a channel that only exists for a client on a legacy connection (spec version 2025-11-25 or earlier). Both are on this page; start with the resolver. -`ctx.elicit()` takes a message and a Pydantic model: +## Ask with a resolver + +A question that gates the whole tool - *are you sure? which of the three matching accounts?* - can be lifted out of the tool body into a **resolver**, and the framework asks it for you. + +A parameter annotated `Annotated[T, Resolve(fn)]` is filled by running `fn` before the tool body. The resolver returns the value directly when it already knows it, or returns `Elicit(...)` to have the framework ask: + +```python title="server.py" hl_lines="24-30 35-36" +--8<-- "docs_src/elicitation/tutorial004.py" +``` + +* `confirm_delete` reads the tool's own `path` argument by name, lists the folder, and **only elicits when it must** - an empty folder resolves to `Confirm(ok=True)` with no round-trip to the client. +* `delete_folder` annotates `ElicitationResult[Confirm]`, so the framework injects the whole outcome and the tool `match`es every case: accept-and-confirm, accept-but-keep (`ok=False`), decline, cancel. +* The `confirm` parameter never appears in the tool's input schema - the client supplies `path`, the resolver supplies `confirm`. + +Annotate the unwrapped model (`Annotated[Confirm, Resolve(confirm_delete)]`) instead when the tool doesn't need to branch: it receives the model on accept and the call aborts with an error on decline or cancel. + +A resolver works on **every** connection. For a client on a legacy connection the SDK sends it the question directly; on a **2026-07-28** connection the SDK *returns* the question from the call, and the client's next attempt carries the answer. Your resolver never knows the difference; what happens underneath is **[Multi-round-trip requests](multi-round-trip.md)**. + +Asking is only one thing a resolver can do. The general mechanism - dependencies that compute without asking, dependencies of dependencies, what the model can and cannot supply - is the **[Dependencies](dependencies.md)** page. + +## Ask from inside the tool + +A tool can also stop in the middle of its own body and ask. + +!!! warning + `ctx.elicit()` and `ctx.elicit_url()` are requests from the *server* to the *client* - a + channel that only exists for a client on a legacy connection (spec version **2025-11-25** + or earlier). On a **2026-07-28** connection there are no server-initiated requests, so + these calls fail. A resolver works on both. **[Protocol versions](../protocol-versions.md)** + has the whole story. + +`await ctx.elicit()` takes a message and a Pydantic model: ```python title="server.py" hl_lines="9-11 20-23 25" --8<-- "docs_src/elicitation/tutorial001.py" ``` -* The **`Context`** parameter is what gives you `ctx.elicit`; any tool can take one. That object has its own chapter: **[The Context](context.md)**. +* The **`Context`** parameter is what gives you `ctx.elicit`; any tool can take one. That object has its own page: **[The Context](context.md)**. * `AlternativeDate` is the **schema** of the answer you want. * The tool is `async def`. It has to be: it stops in the middle and waits for a person. * On any other date the tool returns straight away. It only asks when it has to. @@ -48,7 +79,7 @@ The client gets your message and, next to it, a JSON Schema generated from the m } ``` -That schema is the form. `Field(description=...)` is the label; a default pre-fills the input and makes the field optional. It's the same Pydantic-to-JSON-Schema machinery you already used for a tool's arguments in **[Tools](tools.md)**. +That schema is the form. `Field(description=...)` is the label; a default pre-fills the input and makes the field optional. It's the same Pydantic-to-JSON-Schema machinery **[Tools](../servers/tools.md)** describes for a tool's arguments. !!! warning An elicitation schema is not as expressive as a tool's input schema. Flat, primitive fields @@ -79,24 +110,6 @@ A refusal is not an error. The tool decides what declining means (here, no booki `"maybe"` for a `bool` doesn't corrupt your booking: the call fails with a schema-mismatch error, your `if` never runs. -## Ask before the tool runs - -The booking tool above weaves the question into its own body. When the question is really a *precondition* - confirm before deleting, authenticate before acting - you can lift it out of the tool into a **resolver** and let the framework ask for you. - -A parameter annotated `Annotated[T, Resolve(fn)]` is filled by running `fn` before the tool body. The resolver returns the value directly when it already knows it, or returns `Elicit(...)` to have the framework ask: - -```python title="server.py" hl_lines="24-30 35-36" ---8<-- "docs_src/elicitation/tutorial004.py" -``` - -* `confirm_delete` reads the tool's own `path` argument by name, lists the folder, and **only elicits when it must** - an empty folder resolves to `Confirm(ok=True)` with no round-trip to the client. -* `delete_folder` annotates `ElicitationResult[Confirm]`, so the framework injects the whole outcome and the tool `match`es every case: accept-and-confirm, accept-but-keep (`ok=False`), decline, cancel. -* The `confirm` parameter never appears in the tool's input schema - the client supplies `path`, the resolver supplies `confirm`. - -Annotate the unwrapped model (`Annotated[Confirm, Resolve(confirm_delete)]`) instead when the tool doesn't need to branch: it receives the model on accept and the call aborts with an error on decline or cancel. - -Asking is only one thing a resolver can do. The general mechanism - dependencies that compute without asking, dependencies of dependencies, what the model can and cannot supply - is the **[Dependencies](dependencies.md)** chapter. - ## Send the user to a URL Some things must not go through the model or the client: credentials, card numbers, OAuth consent. For those you don't ask for data; you ask the user to go somewhere: @@ -128,11 +141,11 @@ Servers ask. Clients answer by passing an **`elicitation_callback`** to `Client( Elicitation is a request from the *server* to the *client*, and those only exist on a classic-handshake session, which is why this client passes `mode="legacy"`. On a **2026-07-28** connection a tool asks by *returning* the question from the call - instead; that flow is **[Multi-round-trip requests](../advanced/multi-round-trip.md)**. + instead; that flow is **[Multi-round-trip requests](multi-round-trip.md)**. ### Try it -Start the form-mode `server.py` (the first one on this page) on Streamable HTTP (**[Running your server](../run/index.md)** has the one-liner), then run the client's `main()` and ask `book_table` for Christmas day. +Start the `ctx.elicit` form-mode `server.py` (the `book_table` one) on Streamable HTTP (**[Running your server](../run/index.md)** has the one-liner), then run the client's `main()` and ask `book_table` for Christmas day. The callback prints the question it was sent: @@ -162,11 +175,11 @@ Now swap in the URL-mode `server.py` and point the same `main()` at `pay_deposit ## Recap -* `await ctx.elicit(message, schema=Model)` asks mid-call; your tool resumes with the answer. +* A parameter annotated `Annotated[T, Resolve(fn)]` is filled by a resolver, which returns `Elicit(...)` when it has to ask. It works on every connection. * The schema is a flat Pydantic model: primitive fields only, validated on the way back. * `result.action` is `"accept"`, `"decline"` or `"cancel"`; `result.data` exists only on accept. -* `await ctx.elicit_url(message, url, elicitation_id)` is for everything that must not pass through the model; `ctx.session.send_elicit_complete(elicitation_id)` says the out-of-band part is done. +* `await ctx.elicit(message, schema=Model)` asks from inside the tool body, and `await ctx.elicit_url(message, url, elicitation_id)` is for everything that must not pass through the model (`ctx.session.send_elicit_complete(elicitation_id)` says the out-of-band part is done). Both are server-to-client requests: they need the client on a legacy connection. * The client answers with one `elicitation_callback`, branching on the params type; registering it is what declares the capability. -* On a 2026-07-28 connection the server returns the question instead of pushing it; the same callback is fed by **[Multi-round-trip requests](../advanced/multi-round-trip.md)**. +* On a 2026-07-28 connection the server returns the question instead of pushing it; the same callback is fed by **[Multi-round-trip requests](multi-round-trip.md)**. -A tool that can ask is good. A tool that says how far along it is (**[Progress](progress.md)**) is next. +Everything underneath that return (the retry loop, protecting `requestState`, driving it yourself) is **[Multi-round-trip requests](multi-round-trip.md)**. diff --git a/docs/handlers/index.md b/docs/handlers/index.md new file mode 100644 index 0000000000..eb2b5be414 --- /dev/null +++ b/docs/handlers/index.md @@ -0,0 +1,28 @@ +# Inside your handler + +A handler's arguments come from the client. Everything *else* it can read, and +everything it can do while it runs, is here. + +What it can read: + +* **[The Context](context.md)** is the one extra parameter any handler can + ask for: the live request, its headers, its session, and the progress and + change-notification verbs. +* **[Dependencies](dependencies.md)** are parameters the model never sees, + filled in by your own functions with `Resolve`. +* **[Lifespan](lifespan.md)** covers state your server builds once at + startup, and how a handler reaches it through the `Context`. + +What it can do while it runs: + +* Ask the user for more input with **[Elicitation](elicitation.md)**, and + **[Multi-round-trip requests](multi-round-trip.md)**, the 2026-07-28 + pattern that carries it. +* Report **[Progress](progress.md)** on something slow. +* Write logs (to standard error, for whoever operates the server) with + **[Logging](logging.md)**. +* Tell subscribed clients that something changed with + **[Subscriptions](subscriptions.md)**. + +If you haven't registered a handler yet, start with +**[Tools](../servers/tools.md)**. Every page here assumes you have one. diff --git a/docs/tutorial/lifespan.md b/docs/handlers/lifespan.md similarity index 97% rename from docs/tutorial/lifespan.md rename to docs/handlers/lifespan.md index 796462f935..35b9bd0803 100644 --- a/docs/tutorial/lifespan.md +++ b/docs/handlers/lifespan.md @@ -99,4 +99,4 @@ Strip the server down to the lifecycle: give `Database` a `connected` flag, flip * `ctx: Context[AppContext]` makes that access fully typed in tools. Resources and prompts take the bare `Context`. * No `lifespan=` means an empty `dict`, never `None`. -Next: tools that return more than text, **[Media](media.md)**. +A handler that stops mid-call to ask the user for something only they know is **[Elicitation](elicitation.md)**. diff --git a/docs/tutorial/logging.md b/docs/handlers/logging.md similarity index 91% rename from docs/tutorial/logging.md rename to docs/handlers/logging.md index bea34f1c9d..945aa60d5e 100644 --- a/docs/tutorial/logging.md +++ b/docs/handlers/logging.md @@ -2,7 +2,7 @@ Log from a tool the way you log from any other Python function: with the standard library. -MCP has a protocol-level **logging capability**: a server could push its log messages to the client as notifications, through methods on the `Context` object. The 2026-07-28 revision of the spec **deprecates that capability and does not replace it**, so this tutorial doesn't teach it. The full list of what's deprecated and what to do instead is in **[Deprecated features](../advanced/deprecated.md)**. +MCP has a protocol-level **logging capability**: a server could push its log messages to the client as notifications, through methods on the `Context` object. The 2026-07-28 revision of the spec **deprecates that capability and does not replace it**, so these docs don't teach it. The full list of what's deprecated and what to do instead is in **[Deprecated features](../deprecated.md)**. What you do instead is what you do in every other Python program: the standard library. @@ -65,7 +65,7 @@ went to standard error: the terminal, not the wire. !!! info If what you actually want is *tracing* (every request, how long it took, whether it failed), you don't want log lines, you want spans. Your server already emits them: the SDK traces every - message with OpenTelemetry out of the box. See **[OpenTelemetry](../advanced/opentelemetry.md)**. + message with OpenTelemetry out of the box. See **[OpenTelemetry](../run/opentelemetry.md)**. ## Recap @@ -75,4 +75,4 @@ went to standard error: the terminal, not the wire. * Standard error is yours; stdout belongs to the protocol. Never `print()` in a stdio server. * `MCPServer(..., log_level="DEBUG")` sets the level, and a logging configuration you made first is left alone. -Next: the in-memory client that has been running every example on these pages, and how to point it at your own server, in **[Testing](testing.md)**. +Telling connected clients that something on your server changed (the tool list, a resource) is **[Subscriptions](subscriptions.md)**. diff --git a/docs/advanced/multi-round-trip.md b/docs/handlers/multi-round-trip.md similarity index 94% rename from docs/advanced/multi-round-trip.md rename to docs/handlers/multi-round-trip.md index 62734b38fc..d5451e2311 100644 --- a/docs/advanced/multi-round-trip.md +++ b/docs/handlers/multi-round-trip.md @@ -19,7 +19,7 @@ That's the whole protocol. Every leg is an ordinary request from the client to t ## The server side -On `@mcp.tool()` you rarely build this by hand: declare a dependency that asks the user and the SDK returns the `InputRequiredResult` for you - that form is the **[Dependencies](../tutorial/dependencies.md)** tutorial. The two forms don't mix: a call has one `input_responses`/`request_state` channel, so a tool that uses `Resolve(...)` parameters cannot also return `InputRequiredResult` from its body. A declared `InputRequiredResult` return is rejected at registration (`InvalidSignature`), and an undeclared one fails the call at runtime. The manual form is the **low-level** `Server`, whose `on_call_tool` handler is allowed to return either result type: +On `@mcp.tool()` you rarely build this by hand: declare a dependency that asks the user and the SDK returns the `InputRequiredResult` for you - that form is the **[Dependencies](dependencies.md)** page. The two forms don't mix: a call has one `input_responses`/`request_state` channel, so a tool that uses `Resolve(...)` parameters cannot also return `InputRequiredResult` from its body. A declared `InputRequiredResult` return is rejected at registration (`InvalidSignature`), and an undeclared one fails the call at runtime. The manual form is the **low-level** `Server`, whose `on_call_tool` handler is allowed to return either result type: ```python title="server.py" hl_lines="44-47" --8<-- "docs_src/mrtr/tutorial001.py" @@ -29,7 +29,7 @@ On `@mcp.tool()` you rarely build this by hand: declare a dependency that asks t * On the first call `params.input_responses` is `None`, so the guard fires and the handler asks instead of answering. * On the retry, the `ElicitResult` the client sent is sitting under the **same key** (`"region"`) that the server used in `input_requests`. -Everything else in that file (the explicit `input_schema`, the hand-built `CallToolResult`) is the ordinary low-level `Server`, covered in **[The low-level Server](low-level-server.md)**. This page only adds the second return type. +Everything else in that file (the explicit `input_schema`, the hand-built `CallToolResult`) is the ordinary low-level `Server`, covered in **[The low-level Server](../advanced/low-level-server.md)**. This page only adds the second return type. ## Beyond tools @@ -155,7 +155,7 @@ A `request_state` you set yourself (returning `InputRequiredResult` from a tool, The one thing the SDK cannot pin for you, even when configured, is question identity: it doesn't know which of *your* questions an answer in your state belongs to. If you store answers keyed by question, include your own question identifier in the state and check it on the retry. -The low-level `Server` is the no-batteries tier: unlike `MCPServer`, nothing is sealed until you append the boundary yourself, and your `request_state` crosses the wire exactly as written until you do. The one-line opt-in is shown in **[The low-level Server](low-level-server.md#the-other-handlers)**. +The low-level `Server` is the no-batteries tier: unlike `MCPServer`, nothing is sealed until you append the boundary yourself, and your `request_state` crosses the wire exactly as written until you do. The one-line opt-in is shown in **[The low-level Server](../advanced/low-level-server.md#the-other-handlers)**. ## A 2026-07-28 result @@ -171,7 +171,7 @@ The low-level `Server` is the no-batteries tier: unlike `MCPServer`, nothing is **URL-mode elicitation** rides this exact mechanism on a 2026 connection. The entry in `input_requests` is an `ElicitRequest` whose params are `ElicitRequestURLParams`; the user finishes the out-of-band flow and your client retries the call. Same loop, no new API. The - high-level server half is in **[Elicitation](../tutorial/elicitation.md)**. + high-level server half is in **[Elicitation](elicitation.md)**. ## Recap @@ -179,8 +179,8 @@ The low-level `Server` is the no-batteries tier: unlike `MCPServer`, nothing is * `input_requests` is what it needs. `request_state` is an opaque resume token only the server reads. * `Client` runs the retry loop for you: register `elicitation_callback` / `sampling_callback` / `list_roots_callback` and `call_tool` returns a plain `CallToolResult`. `input_required_max_rounds` (default 10) bounds it. * To inspect or persist rounds, use `client.session.call_tool(..., allow_input_required=True)` and own the `while isinstance(result, InputRequiredResult)` loop yourself. -* On `@mcp.tool()`, a dependency that asks the user produces this result for you (**[Dependencies](../tutorial/dependencies.md)**); the **low-level** `Server` is the manual form. +* On `@mcp.tool()`, a dependency that asks the user produces this result for you (**[Dependencies](dependencies.md)**); the **low-level** `Server` is the manual form. * Prompts and resources participate too: an `@mcp.prompt()` or template `@mcp.resource()` function returns the `InputRequiredResult` itself and reads `ctx.input_responses` on the retry. * `requestState` comes back as client-supplied input, so `MCPServer` seals it by default — resolver state and hand-built state alike — under a process-local key; multi-instance deployments pass `RequestStateSecurity(keys=[...])` (or a custom codec) so every instance can verify what a sibling minted. The seal binds every token to a time window, the originating request, and the authenticated principal when the request carries auth the SDK validated or `bind_principal=` supplies your own identity signal (**[Protecting `requestState`](#protecting-requeststate)**). -This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **[Deprecated features](deprecated.md)**. +This is the mechanism that replaces server-initiated sampling and the rest of the push-style back-channel; see **[Deprecated features](../deprecated.md)**. diff --git a/docs/tutorial/progress.md b/docs/handlers/progress.md similarity index 93% rename from docs/tutorial/progress.md rename to docs/handlers/progress.md index d553de4735..57bbb59e03 100644 --- a/docs/tutorial/progress.md +++ b/docs/handlers/progress.md @@ -18,7 +18,7 @@ Three arguments, and you decide what they mean: * `total`: how much there is in total, if you know. Optional. * `message`: one human-readable line about *this* step. Optional. -`ctx` is injected because of its type hint and the model never sees it: `import_catalog`'s input schema has a single property, `urls`. **[The Context](context.md)** chapter is all about that object; progress is one of the things it gives you. +`ctx` is injected because of its type hint and the model never sees it: `import_catalog`'s input schema has a single property, `urls`. **[The Context](context.md)** page is all about that object; progress is one of the things it gives you. ## Listen for it from the client @@ -51,8 +51,8 @@ anyio.run(main) The callback is an `async` function taking exactly what the server reported: `progress`, `total`, `message`. !!! info - `Client(mcp)` connects straight to the server object, in memory, the same client the **[Testing](testing.md)** - chapter is built on. `progress_callback` is the same parameter whatever transport the `Client` + `Client(mcp)` connects straight to the server object, in memory, the same client the **[Testing](../get-started/testing.md)** + page is built on. `progress_callback` is the same parameter whatever transport the `Client` uses; the *timing* you are about to see is the in-memory connection's. It runs your callback inline, so every report lands before `call_tool` returns. Over a real transport the notifications race the result, and a slow callback can still be running after `call_tool` has @@ -114,4 +114,4 @@ The callback receives `total=None`. A client can still show *activity* ("3 impor * No callback on the call means `report_progress` does nothing. Report unconditionally. * Omit `total` when you don't know it; the callback gets `None`. -Progress is what a running tool shows the *user*. The lines it logs for *you*, the person operating the server, are a different channel: **[Logging](logging.md)** is next. +Progress is what a running tool shows the *user*. The lines it logs for *you*, the person operating the server, are a different channel: **[Logging](logging.md)**. diff --git a/docs/advanced/subscriptions.md b/docs/handlers/subscriptions.md similarity index 100% rename from docs/advanced/subscriptions.md rename to docs/handlers/subscriptions.md diff --git a/docs/hooks/llms_txt.py b/docs/hooks/llms_txt.py index d8ac13eb20..c6dea3196a 100644 --- a/docs/hooks/llms_txt.py +++ b/docs/hooks/llms_txt.py @@ -5,7 +5,7 @@ - `llms.txt`: a markdown index of the documentation, one link per page, grouped by nav section. - a `.md` rendition of every prose page next to its HTML (e.g. - `tutorial/tools/index.md`), which is what the llms.txt links point at. + `servers/tools/index.md`), which is what the llms.txt links point at. - `llms-full.txt`: every prose page concatenated for single-fetch consumption. Page markdown is the source markdown with `--8<--` snippet includes resolved diff --git a/docs/index.md b/docs/index.md index fe700a0af9..a729cfba23 100644 --- a/docs/index.md +++ b/docs/index.md @@ -36,7 +36,7 @@ The `[cli]` extra gives you the `mcp` command; you'll want it for development. Installers never select a pre-release unless you name one, so an unpinned `uv add "mcp[cli]"` gives you the latest **v1.x** release, which this documentation does not describe. Check [PyPI](https://pypi.org/project/mcp/#history) for the newest beta before you copy the line - above. See [Installation](installation.md) for the details. + above. See [Installation](get-started/installation.md) for the details. ## Example @@ -89,7 +89,10 @@ You wrote two Python functions with type hints and a docstring. The SDK does the ## Where to go next -* The **[Tutorial](tutorial/index.md)** walks through everything a server can do, one small step at a time. +* **[Get started](get-started/index.md)** takes you from install to a working, tested server. +* Building an application that *uses* MCP servers? Start with **[Clients](client/index.md)**. +* Already have a FastAPI or Starlette app? **[Add to an existing app](run/asgi.md)** mounts an MCP server inside it. +* Hunting an exact error message? **[Troubleshooting](troubleshooting.md)** is keyed by the verbatim text. * Migrating from v1? Start with the **[Migration Guide](migration.md)**. * Hunting for an exact signature? The **[API Reference](api/mcp/index.md)** is generated from the source. * Reading with an LLM? This documentation is also published in the [llms.txt](https://llmstxt.org/) format: diff --git a/docs/migration.md b/docs/migration.md index 6cf4913f24..186f3d40e2 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -27,7 +27,7 @@ Like `call_tool()` above, `MCPServer.get_prompt()` now returns `Iterable[ReadResourceContents] | InputRequiredResult`: at 2026-07-28 an `@mcp.prompt()` function or an `@mcp.resource()` template function may answer with an `InputRequiredResult` to request client input first (see -[Multi-round-trip requests](advanced/multi-round-trip.md)). If you call these +[Multi-round-trip requests](handlers/multi-round-trip.md)). If you call these methods directly, narrow with `isinstance` (or `assert not isinstance(result, InputRequiredResult)` when your prompt and resource functions never return one). `Prompt.render()` and @@ -439,7 +439,7 @@ Base64-sentinel decoding is strict everywhere it applies, including the `Mcp-Nam ### `Client` verbs may serve cached responses ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)) -On protocol 2026-07-28, servers attach caching hints (`ttlMs`, `cacheScope`) to the cacheable results, and `Client` now honors them: `list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, and `read_resource` may serve a cached response instead of making a round trip, for as long as the server's `ttlMs` says the result is fresh. With the default configuration, servers that send no hints, including every pre-2026 server, see identical call-for-call behavior, because hint-less results are not cached (a `CacheConfig.default_ttl_ms` above zero caches them too). Pass `Client(..., cache=False)` to disable the cache and restore v1 behavior exactly; per-call control (`cache_mode`) and configuration (`CacheConfig`) are described in [Caching hints](advanced/caching.md). +On protocol 2026-07-28, servers attach caching hints (`ttlMs`, `cacheScope`) to the cacheable results, and `Client` now honors them: `list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, and `read_resource` may serve a cached response instead of making a round trip, for as long as the server's `ttlMs` says the result is fresh. With the default configuration, servers that send no hints, including every pre-2026 server, see identical call-for-call behavior, because hint-less results are not cached (a `CacheConfig.default_ttl_ms` above zero caches them too). Pass `Client(..., cache=False)` to disable the cache and restore v1 behavior exactly; per-call control (`cache_mode`) and configuration (`CacheConfig`) are described in [Caching hints](client/caching.md). ### Server extensions API ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)) @@ -785,7 +785,7 @@ Context injection for static resources is not supported — use a template with at least one variable or access context through other means. -See [URI templates](advanced/uri-templates.md) for the full template syntax, +See [URI templates](servers/uri-templates.md) for the full template syntax, security configuration, and filesystem safety utilities. ### Registering lowlevel handlers from `MCPServer` @@ -1512,7 +1512,7 @@ Behavior changes: Tasks ([SEP-1686](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1686)) have been removed from the MCP specification and are no longer part of this SDK. The `mcp.client.experimental`, `mcp.server.experimental`, `mcp.shared.experimental`, and `mcp.server.lowlevel.experimental` modules have been removed, along with the `experimental` properties on `ClientSession`, `ServerSession`, `Server`, and `ServerRequestContext`. The corresponding `Task*` types remain in `mcp_types` as types-only definitions. -Tasks are expected to return as a separate MCP extension in a future release. +The 2026-07-28 revision reintroduces Tasks as an official extension: [SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663), `io.modelcontextprotocol/tasks`, redesigned around polling (`tasks/get`) instead of a blocking `tasks/result`. This SDK does not implement the extension yet. ## Deprecations @@ -1665,7 +1665,7 @@ The implementation is responsible for validating the assertion per RFC 7523 §3 ### 2025-11-25 and 2026-07-28 protocol fields modeled -`mcp_types` models the 2025-11-25 and 2026-07-28 protocol fields (e.g. `resultType`, `ttlMs`/`cacheScope` on cacheable results, `inputResponses`/`requestState` on retried requests), so inbound payloads carrying these keys parse into typed fields and round-trip. `ttlMs`/`cacheScope` default to `0`/`"private"` (immediately stale, not shared-cacheable); `resultType` defaults to `"complete"` on concrete results (`None` on `EmptyResult`); the server strips all of them from the wire at pre-2026 versions. Servers set per-method values with `cache_hints={method: CacheHint(...)}` on the `Server`/`MCPServer` constructor — see [Caching hints](advanced/caching.md). +`mcp_types` models the 2025-11-25 and 2026-07-28 protocol fields (e.g. `resultType`, `ttlMs`/`cacheScope` on cacheable results, `inputResponses`/`requestState` on retried requests), so inbound payloads carrying these keys parse into typed fields and round-trip. `ttlMs`/`cacheScope` default to `0`/`"private"` (immediately stale, not shared-cacheable); `resultType` defaults to `"complete"` on concrete results (`None` on `EmptyResult`); the server strips all of them from the wire at pre-2026 versions. Servers set per-method values with `cache_hints={method: CacheHint(...)}` on the `Server`/`MCPServer` constructor. See [Caching hints](client/caching.md) for details. ### `streamable_http_app()` available on lowlevel Server @@ -1693,7 +1693,7 @@ The lowlevel `Server` also now exposes a `session_manager` property to access th ### `ElicitationResult` is now a subscriptable generic alias -`ElicitationResult` is now a `TypeAliasType` instead of a plain union, so `ElicitationResult[Confirm]` works as an annotation (resolver dependency injection consumes it that way - see [Dependencies](tutorial/dependencies.md)). The members are unchanged: `AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation`. +`ElicitationResult` is now a `TypeAliasType` instead of a plain union, so `ElicitationResult[Confirm]` works as an annotation (resolver dependency injection consumes it that way - see [Dependencies](handlers/dependencies.md)). The members are unchanged: `AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation`. The one behavioral change: a runtime `isinstance(result, ElicitationResult)` now raises `TypeError`. Check against the member classes directly instead: diff --git a/docs/client/protocol-versions.md b/docs/protocol-versions.md similarity index 94% rename from docs/client/protocol-versions.md rename to docs/protocol-versions.md index 43624549ce..221a87dc41 100644 --- a/docs/client/protocol-versions.md +++ b/docs/protocol-versions.md @@ -4,7 +4,7 @@ MCP has two eras. Servers released before 2026-07-28 open every connection with the **`initialize` handshake**: the client proposes a version, the server counters, the client acknowledges, all before the first useful request. Servers at **2026-07-28** drop the handshake. The client sends one **`server/discover`** probe and the server answers it with everything in a single result. -You haven't had to care, because `Client` negotiates for you. This chapter is about the one constructor argument that controls it, `mode=`, and the three times you change it. +You almost never have to care, because `Client` negotiates for you. This page is about the one constructor argument that controls it, `mode=`, and the three times you change it. ## `mode="auto"` @@ -48,9 +48,9 @@ You want this for the **push-style** features. A server-initiated request is the server calling *you*: `ctx.elicit(...)` putting a form in front of your user, sampling asking your model for a completion mid-tool-call. That channel only exists on a handshake-era session. -At 2026-07-28 it is gone. The server *returns* its questions and you retry the call with the answers (**[Multi-round-trip requests](../advanced/multi-round-trip.md)**). +At 2026-07-28 it is gone. The server *returns* its questions and you retry the call with the answers (**[Multi-round-trip requests](handlers/multi-round-trip.md)**). -`mode="auto"` only gives you a handshake when the server is too old for anything else. `mode="legacy"` guarantees one. Reach for it whenever you hand `Client(...)` a `sampling_callback`, an `elicitation_callback` you want driven as a request, or a `message_handler`. **[Client callbacks](callbacks.md)** goes through each. +`mode="auto"` only gives you a handshake when the server is too old for anything else. `mode="legacy"` guarantees one. Reach for it whenever you hand `Client(...)` a `sampling_callback`, an `elicitation_callback` you want driven as a request, or a `message_handler`. **[Client callbacks](client/callbacks.md)** goes through each. ## Pinning a version @@ -124,4 +124,4 @@ The second connection made **zero** negotiation round trips and still knows exac * A version pin (`mode="2026-07-28"`) sends no negotiation traffic at all, at the cost of a blank `server_info`. * `prior_discover=` pays that cost back: save `client.session.discover_result`, reconnect with it, get both. -A modern connection has no push channel, so how does a 2026 server ask you a question mid-call? It returns it: **[Multi-round-trip requests](../advanced/multi-round-trip.md)**. +A modern connection has no push channel, so how does a 2026 server ask you a question mid-call? It returns it: **[Multi-round-trip requests](handlers/multi-round-trip.md)**. diff --git a/docs/run/asgi.md b/docs/run/asgi.md index c72becb888..2eca9273cd 100644 --- a/docs/run/asgi.md +++ b/docs/run/asgi.md @@ -1,4 +1,4 @@ -# ASGI +# Add to an existing app `mcp.run("streamable-http")` starts a web server for you. Sometimes you don't want that: your MCP server is one piece of a larger web application, or you already have an ASGI deployment. @@ -30,51 +30,20 @@ Run the app on its own (`uvicorn server:app`) and you never think about either. !!! tip `streamable_http_app()` takes the same keyword arguments as `mcp.run("streamable-http", ...)`, minus `port`: the port belongs to whatever serves the app. `host` is still accepted but binds - nothing here; the next section is what it actually controls. **[Running your server](index.md)** covers the - options themselves. + nothing here; **[Deploy & scale](deploy.md)** explains what it actually controls. + **[Running your server](index.md)** covers the options themselves. `mcp.sse_app()` does the same for the superseded SSE transport. ## Localhost only, until you say otherwise -`streamable_http_app()` cannot know which hostname it will be served behind, so it assumes the -safest answer: localhost. With no `transport_security=`, the app switches on **DNS-rebinding -protection** and accepts a request only if its `Host` header is `127.0.0.1:`, -`localhost:`, or `[::1]:`, and only if its `Origin` header, when there is one, is the -`http://` form of the same. For `uvicorn server:app` on your machine that is exactly what you want: -it stops a malicious web page from driving your local server through a DNS name it rebound to -`127.0.0.1`. - -It also means that **deployed behind a real hostname, the app rejects every request until you -configure it**. The check runs before MCP does, the client sees only a generic transport error, and -the reason is a single warning in the *server's* log: - -```text -421 Misdirected Request Invalid Host header the Host is not in the allowlist -403 Forbidden Invalid Origin header the Origin is not in the allowlist -``` - -`transport_security=` is how you configure it. Allowlist what you actually serve: - -```python -from mcp.server.transport_security import TransportSecuritySettings - -security = TransportSecuritySettings( - allowed_hosts=["mcp.example.com", "mcp.example.com:*"], - allowed_origins=["https://app.example.com"], -) -app = mcp.streamable_http_app(transport_security=security) -``` - -* `allowed_hosts` entries are exact strings: `"mcp.example.com"` matches a bare `Host` header and - `"mcp.example.com:*"` matches any port. List both. -* `allowed_origins` only matters for browsers (nothing else sends `Origin`). It is the server-side - twin of the CORS configuration below. -* Behind a reverse proxy that already controls the `Host` header, switching the check off is the - honest configuration: `TransportSecuritySettings(enable_dns_rebinding_protection=False)`. -* Passing a non-localhost `host=` (for example `host="mcp.example.com"`) does **not** allowlist that - hostname. It only stops the localhost default from arming the protection, which leaves every Host - and Origin accepted. Say what you mean with `transport_security=` instead. +Out of the box the app answers **only** requests addressed to localhost. `streamable_http_app()` +cannot know which hostname it will be served behind, so it arms DNS-rebinding protection with the +safest possible allowlist; on your machine that is exactly right. Deployed behind a real hostname, +it means **every request is rejected with `421 Misdirected Request`** until you pass +`transport_security=` an allowlist of what you actually serve. Nothing you built is even +consulted first. That allowlist, and everything else between a working app and a real hostname, +is **[Deploy & scale](deploy.md)**. ## Mounting it @@ -88,7 +57,7 @@ The moment the MCP server is *part* of a bigger application, you put the app ins * The `lifespan` function enters `mcp.session_manager.run()` for the lifetime of the **host** app. This is the line everyone forgets. * `mcp.session_manager` only exists *after* `streamable_http_app()` has been called. That is why the routes are built at module level and the manager is only touched inside the lifespan. -Starlette's `Host` route works the same way: swap `Mount("/", ...)` for `Host("mcp.example.com", ...)` to route by hostname instead of by path. The lifespan rule does not change, and neither does the transport-security one. A `Host("mcp.example.com", ...)` route only ever receives requests addressed to that hostname, so without `allowed_hosts=["mcp.example.com", "mcp.example.com:*"]` it answers every one of them with a `421`. +Starlette's `Host` route works the same way: swap `Mount("/", ...)` for `Host("mcp.example.com", ...)` to route by hostname instead of by path. The lifespan rule does not change, and neither does the transport-security one. A `Host("mcp.example.com", ...)` route only ever receives requests addressed to that hostname, but the transport's own Host allowlist (**[Deploy & scale](deploy.md)**) still runs first. Without `"mcp.example.com"` in it, that route answers every one of them with a `421`. !!! warning "The host app owns the lifespan" `streamable_http_app()` wires `session_manager.run()` into the lifespan of the Starlette it @@ -150,7 +119,7 @@ A browser-based client needs two permissions from you: to **send** its MCP reque * The handler is plain Starlette: an `async` function from `Request` to `Response`. * `streamable_http_app()` picks up every custom route. `app.routes` is now `/mcp` and `/health`. -* `GET /health` answers `{"status": "ok"}` with no MCP in sight: no session, no handshake. +* `GET /health` answers `{"status": "ok"}` with no MCP in sight. !!! warning Custom routes are **never authenticated**, even when the rest of the server is. That is @@ -160,7 +129,7 @@ A browser-based client needs two permissions from you: to **send** its MCP reque ## Recap * `mcp.streamable_http_app()` returns a Starlette app with one route, `/mcp`. Any ASGI server can run it. -* Out of the box the app answers only requests addressed to localhost. Deploying behind a real hostname means passing `transport_security=TransportSecuritySettings(...)`. +* Out of the box the app answers only requests addressed to localhost, and behind a real hostname it rejects everything with a `421` until you pass `transport_security=` an allowlist. **[Deploy & scale](deploy.md)** owns that, and the rest of the road to production. * `Mount` (or `Host`) puts it inside a bigger Starlette or FastAPI app. * **Mounting disables the built-in lifespan.** The host app's lifespan must enter `mcp.session_manager.run()`, or the first request fails. * Several servers in one app means several mounts and one lifespan that enters every session manager. diff --git a/docs/advanced/authorization.md b/docs/run/authorization.md similarity index 92% rename from docs/advanced/authorization.md rename to docs/run/authorization.md index 9b5a32a4ec..b7d731b1e2 100644 --- a/docs/advanced/authorization.md +++ b/docs/run/authorization.md @@ -4,6 +4,8 @@ Over Streamable HTTP your MCP server is an ordinary web service, and you protect In OAuth terms, your server is a **resource server**. It never signs anyone in and it never issues a token. It does one thing: look at the `Authorization` header on each request and decide whether the token in it is good. +This page is the server side. A client that discovers your authorization server and fetches the token is **[OAuth clients](../client/oauth-clients.md)**. + ## The three parties * The **authorization server** signs people in and issues access tokens. You don't write this. It's your identity provider (Auth0, Keycloak, Entra, your own). @@ -36,7 +38,7 @@ The SDK has no opinion about what a valid token looks like. You tell it, by impl ## What you get over HTTP -Authorization lives in HTTP headers, so it exists only on the HTTP transports. Run it on the one you deploy: `mcp.run(transport="streamable-http")` puts it on `http://127.0.0.1:8000/mcp`, and **[Running your server](../run/index.md)** has the rest. The app now has two routes: +Authorization lives in HTTP headers, so it exists only on the HTTP transports. Run it on the one you deploy: `mcp.run(transport="streamable-http")` puts it on `http://127.0.0.1:8000/mcp`, and **[Running your server](index.md)** has the rest. The app now has two routes: ```text /mcp @@ -109,7 +111,7 @@ To watch all three parties move, run `examples/servers/simple-auth/` from the SD server inside your MCP server. It predates the AS/RS separation that the MCP authorization spec is built around. New servers should not reach for it. -An authorization server can also accept an enterprise identity provider's signed assertion in place of a user clicking through a consent screen, and the SDK supports both sides of that exchange. The grant, and the client that presents it, is **[Identity assertion](identity-assertion.md)**. +An authorization server can also accept an enterprise identity provider's signed assertion in place of a user clicking through a consent screen, and the SDK supports both sides of that exchange. The grant, and the client that presents it, is **[Identity assertion](../client/identity-assertion.md)**. ## Recap @@ -120,4 +122,4 @@ An authorization server can also accept an enterprise identity provider's signed * `get_access_token()` in any handler is who's calling. * Authorization is an HTTP concern. `stdio` and the in-memory client never see it. -The other side of the handshake, a client that discovers your authorization server and fetches the token for you, is **[OAuth clients](oauth-clients.md)**. +The client half (discovering your authorization server and fetching the token for you) is **[OAuth clients](../client/oauth-clients.md)**. And a client that *asserts* an identity instead of asking a user for one is **[Identity assertion](../client/identity-assertion.md)**. diff --git a/docs/run/deploy.md b/docs/run/deploy.md new file mode 100644 index 0000000000..cad564c421 --- /dev/null +++ b/docs/run/deploy.md @@ -0,0 +1,174 @@ +# Deploy & scale + +Your server works. Now it needs a real hostname, and more than one worker behind it. + +Almost none of that is MCP's business. You bring the ASGI server, the process manager, the load balancer. What this page has is the short list of things that *are* MCP's business: one setting that gates every deployment, and the two places where "more than one worker" changes what the SDK does. + +## Before anything else: the Host allowlist + +`streamable_http_app()` cannot know which hostname it will be served behind, so it assumes the safest answer: localhost. With no `transport_security=`, the app switches on **DNS-rebinding protection** and accepts a request only if its `Host` header is `127.0.0.1:`, `localhost:`, or `[::1]:`. The `Origin` header, when there is one, has to be the `http://` form of the same. On your machine that is exactly right: it stops a malicious web page from driving your local server through a DNS name it rebound to `127.0.0.1`. + +Deployed behind a real hostname, that same default rejects **every request** until you say otherwise. The check runs before anything MCP-shaped does, so nothing you built is even consulted: + +```text +421 Misdirected Request Invalid Host header the Host is not in the allowlist +403 Forbidden Invalid Origin header the Origin is not in the allowlist +``` + +`transport_security=` is the fix. Allowlist what you actually serve: + +```python title="server.py" hl_lines="2 13-17" +--8<-- "docs_src/deploy/tutorial001.py" +``` + +* `allowed_hosts` entries are exact strings: `"mcp.example.com"` matches a bare `Host` header and `"mcp.example.com:*"` matches any port. List both. +* `allowed_origins` only matters for browsers, because nothing else sends `Origin`. It is the server-side twin of the CORS configuration in **[Add to an existing app](asgi.md)**. +* Behind a reverse proxy that already controls the `Host` header, switching the check off is the honest configuration: `TransportSecuritySettings(enable_dns_rebinding_protection=False)`. +* Passing a non-localhost `host=` (for example `host="mcp.example.com"`) does **not** allowlist that hostname. It only stops the localhost default from arming the protection, which leaves every Host and Origin accepted. Say what you mean with `transport_security=` instead. + +!!! check + Delete the `transport_security=security` argument and deploy the app anyway. It starts, `/mcp` + routes, and every request (including from a plain `curl`) comes back: + + ```text + HTTP/1.1 421 Misdirected Request + + Invalid Host header + ``` + + You will not find those words on the client side. A `421` is a plain-text HTTP response, not a + JSON-RPC error, so the MCP client raises a generic transport error; the hostname it + didn't like appears only in the **server's** log, as a single warning. A freshly + deployed server that refuses every connection is a Host allowlist until proven otherwise. + **[Troubleshooting](../troubleshooting.md)** starts here too. + +## Workers, and who has to be sticky + +Once the hostname answers, put more than one worker behind it. There is no SDK knob for that; you scale a Starlette app the way you scale any ASGI app, by handing the object to something that knows how to fork: + +```console +uvicorn server:app --workers 4 +``` + +Four processes, one socket. And now the question every deployment has to answer: **does a request have to reach the worker that saw the last one?** + +For a client speaking the **2026-07-28** protocol, no. A modern request is one self-contained POST: no `initialize` handshake before it, no `Mcp-Session-Id` on the response, nothing for a second request to come back *to*. Route it to any worker. + +That is not a mode you switch on. `stateless_http=True` looks like it should be, but the transport routes on the `MCP-Protocol-Version` request header, hands a modern request to the modern handler, and **returns**. The line that reads `stateless_http` comes *after* that return. It isn't that the flag is ignored on the 2026-07-28 path; it is never reached. `stateless_http` is a knob for the **legacy** leg only, and the modern path is sessionless by construction. + +For a legacy client on spec version 2025-11-25 or earlier, the answer depends on that flag: + +| Client's protocol version | Session | What the load balancer must do | +| --- | --- | --- | +| **2026-07-28** | None. `Mcp-Session-Id` is never set. | Nothing. Any worker serves any request. | +| **2025-11-25 and earlier** (the default) | `Mcp-Session-Id`, held in one worker's memory. | **Sticky sessions.** A follow-up that reaches a different worker gets a `404` *"Session not found"*. | +| **2025-11-25 and earlier**, with `stateless_http=True` | None. | Nothing. The cost is the server-to-client back-channel (sampling, push elicitation, `roots/list`) and resumability. | + +Sticky sessions and what the legacy leg costs are their own page, **[Serving legacy clients](legacy-clients.md)**; the two eras themselves are **[Protocol versions](../protocol-versions.md)**. What matters here is the shape of the answer: *on 2026-07-28 you are already stateless, with nothing to configure.* + +The rest of this page is the two things that being stateless does **not** buy you. + +## `requestState` across workers + +A **[multi-round-trip](../handlers/multi-round-trip.md)** tool needs something the client has to go get (a confirmation, a choice, a credential), so it returns a question instead of an answer and finishes on the retry. Between the two rounds the client holds an opaque `request_state` token the server minted. On the retry the server has to open that token again. + +*Sealed under what key?* By default, one the server generated with `os.urandom(32)` at construction time. Under `--workers 4` that is four constructions, in four processes: four different keys, never written anywhere, never shared, gone on restart. + +Here is a tool that asks before it acts, on a server that configures nothing: + +```python title="server.py" hl_lines="15 21" +--8<-- "docs_src/deploy/tutorial002.py" +``` + +The first round reaches worker A. Worker A seals `refund:120` under **its** key and returns the token. The client puts the question in front of a person, gets a yes, and retries. The retry is a brand-new HTTP request. + +!!! check + Let that retry reach worker B. B tries to unseal a token it did not mint, cannot, and refuses the + whole round. `refund` is never called; the client gets a JSON-RPC error: + + ```json + { + "code": -32602, + "message": "Invalid or expired requestState", + "data": {"reason": "invalid_request_state"} + } + ``` + + That message is **frozen**. Expired, tampered with, replayed against different arguments, or (by + far the most common cause in a real deployment) sealed by a sibling worker: the client is told + the same thing every time, so the wire never reveals which check failed. The real reason is one + `WARNING` in the server's log: + + ```text + requestState rejected on tools/call: unknown key + ``` + + A multi-round-trip tool that worked with one worker and started failing *some of the time* at + two is this. Both rounds still have to reach the same process, so it fails exactly as often as + your load balancer separates them. + +The two rounds are two independent HTTP requests, and several ordinary things separate them: a proxy that balances per request, a connection that dropped in between, a deploy or a restart, a client that persisted `request_state` and is resuming from a different process entirely (**[Driving the loop yourself](../handlers/multi-round-trip.md#driving-the-loop-yourself)**). Any of them is "a different worker". + +The fix is one argument. It has **two** halves. + +```python title="server.py" hl_lines="3 13 15" +--8<-- "docs_src/deploy/tutorial003.py" +``` + +* **`keys=[...]`** is the half everyone finds. Give every instance the same secret (at least 32 bytes of it), and every instance can unseal what any sibling minted. `keys[0]` seals and every key in the list unseals, which is the rotation ring; **[Rotating keys](../handlers/multi-round-trip.md#rotating-keys)** is how you turn it without downtime. +* **The server's name** is the half almost nobody finds, and the reason cross-instance retries still fail after you share the key. Every sealed token carries the server's `name` as an **audience claim**, checked strictly on the way back in. Two instances built from the same code have the same name and never notice it. Name them apart (`MCPServer(f"billing-{POD}")` reads like good observability hygiene), and every cross-instance retry is refused exactly as above, shared key or not. The log says `audience` instead of `unknown key`; the client cannot tell the difference. + +Mint the secret once and hand the same value to every instance. This is the command the SDK's own error message tells you to run if you pass it fewer than 32 bytes: + +```console +python -c "import secrets; print(secrets.token_hex(32))" +``` + +!!! warning "Same keys, *and* the same name" + A multi-instance deployment must share both. If per-instance names are load-bearing for you, + give the fleet one explicit audience instead: `RequestStateSecurity(keys=[...], audience="billing")`. + Every instance then mints and accepts under `"billing"` no matter what it is called. + +Everything else about the seal is **[Protecting `requestState`](../handlers/multi-round-trip.md#protecting-requeststate)**: what it binds, the per-round `ttl` (600 seconds by default), bringing your own codec, why the unconfigured default is exactly right on `stdio`. This page's whole contribution is a two-item checklist: *same keys, same name.* + +!!! info + You are on this path even if you have never typed `InputRequiredResult`. A tool whose parameters + use `Resolve(...)` (**[Dependencies](../handlers/dependencies.md)**) is a multi-round-trip tool, + and the SDK mints and seals its `request_state` for it. Same default key, same failure across + workers, same fix. + +## Change notifications across replicas + +A client's `subscriptions/listen` stream is one long-lived response, so it is pinned to one replica for its whole life. A `ctx.notify_resource_updated(...)` published on a **different** replica has to reach it. + +The seam between the two is the `SubscriptionBus`. Whatever bus you give a server is the one every publish goes into and every open stream listens on, so hand the same bus to every replica: + +```python title="server.py" hl_lines="2 7 9" +--8<-- "docs_src/deploy/tutorial004.py" +``` + +Nothing about the fan-out cares which server object a stream is attached to. Two servers holding one `InMemorySubscriptionBus` already behave this way: open a listen stream on one, `edit_note` on the other, and the stream hears about it. That in-memory bus only spans server objects inside one process, which makes it the model, not the deployment: + +* Across real processes, **the SDK ships no bus that can help you.** `SubscriptionBus` is a two-method `Protocol` (`publish` and `subscribe`) that you implement over your own pub/sub backend (Redis, NATS, whatever you already run) and pass as `MCPServer(subscriptions=...)`. **[Subscriptions](../handlers/subscriptions.md#one-process-is-the-default-more-takes-a-bus)** has the sketch and the contract. +* The bus carries four small typed events, never JSON-RPC. Acknowledgment, filtering, and stream lifecycle stay in the SDK, so your bus cannot break the protocol; it can only move events between processes. +* Streams are **not** resumable and events are **not** replayed. Losing a replica drops its streams; the clients re-listen and re-fetch. There is no event store to share and nothing else to configure. This is the one place where scaling out is genuinely just more of the same. + +## What the SDK does not give you + +An `MCPServer` is a protocol implementation, not an application server. The deployment knobs you go looking for next are missing on purpose: + +* **No `workers=`.** `mcp.run("streamable-http")` starts exactly one uvicorn process, and that is all it will ever start. Multi-process is `streamable_http_app()` handed to whatever you already deploy ASGI with: `uvicorn --workers`, gunicorn, your platform's process manager. This page is deliberately not a tutorial for any of them; their documentation is better than a copy of it here would be. +* **No health-check route.** `@mcp.custom_route("/health", methods=["GET"])` is the whole answer, and it is never authenticated even when the rest of the server is. That is right for a liveness probe, wrong for anything private. **[Add to an existing app](asgi.md#custom-routes)** shows one. +* **No production settings object.** There is nowhere on `MCPServer` to write down timeouts, TLS, graceful shutdown, or connection limits, because none of those are its job. They belong to your ASGI server, and you configure them there. **[Running your server](index.md)** covers the handful of settings the constructor *does* take. +* **No shipped `EventStore`, and on 2026-07-28 no use for one.** Resumability is a feature of the legacy stateful leg; a modern exchange is one POST, one response, and nothing to resume. + +## Recap + +* Out of the box the app answers only requests addressed to localhost. `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` is the go-live gate: until you pass it, every request behind a real hostname is a `421` and the reason is only in the server's log. +* On 2026-07-28 there is no session and nothing for a load balancer to be sticky on. `stateless_http=True` is a legacy-only knob because a modern request is routed and answered before that flag is ever read. +* The default `requestState` key is `os.urandom(32)`, minted per process. A multi-round-trip retry that reaches a different worker fails with `-32602` *"Invalid or expired requestState"*. +* The fix is `RequestStateSecurity(keys=[...])` **and** the same server name on every instance. The name is the token's default audience claim. Same keys, same name. +* Change notifications cross replicas through one shared `SubscriptionBus`. The SDK's only implementation is in-process; the two-method `Protocol` over your own pub/sub is yours to write. +* There is no `workers=`, no health route, no production settings object. Bring your own ASGI server. + +The other thing a real hostname needs in front of it is a token: **[Authorization](authorization.md)**. diff --git a/docs/run/index.md b/docs/run/index.md index aafb1f3330..b3cea554cc 100644 --- a/docs/run/index.md +++ b/docs/run/index.md @@ -39,7 +39,7 @@ python server.py Nothing prints, and it doesn't return. It is waiting on stdin for a host to speak first. -That also means stdout **is the wire**. A stray `print()` corrupts the stream; the `logging` module writes to stderr and is the right tool. That story is in **[Logging](../tutorial/logging.md)**. +That also means stdout **is the wire**. A stray `print()` corrupts the stream; the `logging` module writes to stderr and is the right tool. That story is in **[Logging](../handlers/logging.md)**. ### Try it @@ -67,7 +67,7 @@ Each transport has its own keyword arguments, all on `run()`: * `streamable_http_path`: where the MCP endpoint lives. Default `/mcp`. * `json_response=True`: answer with plain JSON instead of an SSE stream. * `stateless_http=True`: a fresh transport per request, no session tracking. -* `event_store`, `retry_interval`, `transport_security`: resumability and DNS-rebinding protection. They can wait, until you deploy somewhere other than localhost; **[ASGI](asgi.md)** covers `transport_security`. +* `event_store`, `retry_interval`, `transport_security`: resumability and DNS-rebinding protection. They can wait, until you deploy somewhere other than localhost; **[Deploy & scale](deploy.md)** covers `transport_security`. !!! warning Transport options go to `run()`, **not** to `MCPServer(...)`. The constructor describes what @@ -78,7 +78,7 @@ Each transport has its own keyword arguments, all on `run()`: TypeError: MCPServer.__init__() got an unexpected keyword argument 'port' ``` -`run()` is the short road. The moment you need more (your server mounted inside an existing app, two servers in one process, CORS for browser clients), you build the ASGI app yourself and hand it to any ASGI host. That is **[ASGI](asgi.md)**. +`run()` is the short road. The moment you need more (your server mounted inside an existing app, two servers in one process, CORS for browser clients), you build the ASGI app yourself and hand it to any ASGI host. That is **[Add to an existing app](asgi.md)**. ## Server settings @@ -127,6 +127,8 @@ uv run mcp install server.py -v API_KEY=abc123 -f .env `-v KEY=VALUE` and `-f .env` record environment variables in that entry. Claude Desktop starts your server in its own process. Your shell's environment is not there. +Claude Desktop is the only host `mcp install` knows. Every other host (Claude Code, Cursor, VS Code) takes the same launch command in its own config file, and **[Connect to a real host](../get-started/real-host.md)** has each one. + `mcp version` prints the installed SDK version. !!! tip @@ -143,4 +145,4 @@ uv run mcp install server.py -v API_KEY=abc123 -f .env * `mcp dev` for the Inspector, `mcp run` to execute a file, `mcp install` for Claude Desktop, `mcp version` for the version. * The transport never changes what your server *is*: all three files on this page expose the identical tool. -When `run()` itself is the limit (your server inside an app that already exists), the next step is **[ASGI](asgi.md)**. +When `run()` itself is the limit (your server inside an app that already exists), it is **[Add to an existing app](asgi.md)**. A real hostname and more than one worker is **[Deploy & scale](deploy.md)**. And if some of your clients are still on spec version 2025-11-25 or earlier, **[Serving legacy clients](legacy-clients.md)** is the good news. diff --git a/docs/run/legacy-clients.md b/docs/run/legacy-clients.md new file mode 100644 index 0000000000..c7a1096db6 --- /dev/null +++ b/docs/run/legacy-clients.md @@ -0,0 +1,120 @@ +# Serving legacy clients + +MCP has two protocol eras: the `initialize`-handshake era, up to spec version `2025-11-25`, and the modern era, `2026-07-28`. **[Protocol versions](../protocol-versions.md)** is the page on the split itself. + +This page is about the server side of that split, and the answer fits in one sentence: **the `streamable_http_app()` you already deploy serves both.** + +The SDK routes every request by its `MCP-Protocol-Version` header. A request naming `2026-07-28` goes to the modern handler. A request naming a handshake-era version, or carrying no header at all (which is how a pre-2026 client's `initialize` arrives), goes to the transport those clients expect: `initialize` handshake, sessions and all. It happens per request, before your code, on the one app. + +So a legacy client is not something you build *for*. It is something that connects *to* the server you already wrote. You configure nothing. + +!!! note + Nothing, literally. There is no `legacy=` option, no version allowlist, no way to reject or + disable an era: not on `streamable_http_app()`, not on `run()`, not on the session manager. + Both eras are always on. The nearest thing to a per-era switch in that signature is + `stateless_http`, and it is most of this page. + +## One handler, both eras + +Here is a tool that has to ask the user something, and both eras of client calling it: + +```python title="server.py" hl_lines="24 37-38" +--8<-- "docs_src/legacy_clients/tutorial001.py" +``` + +`reserve` needs one thing the model didn't supply: how many copies. `Annotated[..., Resolve(ask_quantity)]` is how a tool declares that (**[Dependencies](../handlers/dependencies.md)** is that whole story). Nothing in `reserve` names a version, checks a capability, or branches. + +The two clients are open **at the same time**, on the same `mcp` object. `mode="legacy"` runs the `initialize` handshake: the exact connection a pre-2026 client opens. The other one takes the default and lands on `2026-07-28`. + +```text +2025-11-25 {'result': "Reserved 2 of 'Dune'."} +2026-07-28 {'result': "Reserved 2 of 'Dune'."} +``` + +Same server, same handler, same answer. That is the whole feature. + +It is worth pausing on *how*, because the two clients were asked the same question over two completely different wires. The `2026-07-28` connection has no channel for the server to send a request on, so `Resolve` returned the question inside the tool result and the client retried the call with the answer (**[Multi-round-trip requests](../handlers/multi-round-trip.md)**). The `2025-11-25` connection has no such thing; there, `Resolve` sent a live `elicitation/create` request mid-call and waited. You wrote neither. `Resolve` reads the connection's negotiated version and picks; your tool body sees an `AcceptedElicitation` either way. + +!!! tip + That era-portability is *why* `Resolve` is the API to build on. Its older sibling `ctx.elicit()` + (**[Elicitation](../handlers/elicitation.md)**) only ever sends `elicitation/create`, so it only + ever works on a legacy connection. On a `2026-07-28` one the call fails. If a tool still uses + it, the fix is the one you see above, not a version check. + +## What a legacy session costs you + +The routing is free. The session is not. + +A `2026-07-28` connection is **sessionless**: every request stands alone, and the modern handler never issues an `Mcp-Session-Id`. A legacy connection is the opposite. The moment a pre-2026 client sends `initialize`, the SDK mints an `Mcp-Session-Id`, returns it in a response header, and keeps a live record behind it for the client's later requests to find: the negotiated version, the open streams, a background task driving the session. + +That record is a **plain in-process `dict`**. There is no distributed session store and no way to plug one in. + +On one worker that is invisible. On two, it is the whole problem: a request that carries an `Mcp-Session-Id` and lands on a worker that didn't mint it finds nothing in that dict, and the answer is a `404` (`Session not found`), not the tool result. So the moment you run more than one worker, **legacy clients need sticky routing**: every request in a session has to reach the process that started it. Modern clients never do; they have no session to be sticky to. **[Deploy & scale](deploy.md)** covers stickiness and everything else about running more than one of these. + +!!! warning + `event_store=` looks like the fix and is not. It is **resumability** (replaying missed SSE + events to a client reconnecting to the *same* session), not a session store. It never makes a + session reachable from another process. + +## The one knob: `stateless_http` + +If stickiness is a cost you refuse to pay, there is exactly one thing you can change. + +```python title="server.py" hl_lines="28" +--8<-- "docs_src/legacy_clients/tutorial002.py" +``` + +That is the server from the top of the page plus one keyword. `stateless_http=True` makes the legacy leg build a throwaway, per-request session instead: no `Mcp-Session-Id` issued, nothing remembered between requests, so any worker can serve any request and the load balancer can do whatever it likes. + +Two things about it matter more than what it does. + +**It only touches the legacy leg.** Requests are routed on the version header *before* `stateless_http` is read, so the modern path never sees it. A `2026-07-28` connection is already sessionless and is exactly the same under either value. + +**It costs both server-to-client channels on that leg.** A session that lives for one `POST` has no stream for the server to push a request down and no standalone stream for it to push notifications down. Every server-initiated request raises `NoBackChannelError`: `ctx.elicit()`, the retired sampling and roots calls (**[Deprecated features](../deprecated.md)**), and, yes, `Resolve` asking a *legacy* client its question. Notifications don't even get an error; they are silently dropped. + +!!! check + Do the wrong thing. `reserve` is the exact tool that just served both clients. Deploy it with + `stateless_http=True`, connect the same two clients over HTTP, and call it from each. + + The modern client still gets `Reserved 2 of 'Dune'.` The modern leg didn't change. + + The legacy client's call does not come back as an `is_error` result the model could read. + The whole request fails, as a top-level protocol error: + + ```text + mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests. + ``` + + `Resolve` did not save you. On a `2025-11-25` connection it *has* to send `elicitation/create`, + and the channel it needs is exactly the thing `stateless_http=True` gave away. Era-portable + code is not back-channel-free code. + +So it is a real trade, and it only exists on the legacy leg: **sessionful and sticky, or stateless and one-directional.** If your tools never call back into the client, `stateless_http=True` is free and you should take it. If they do, keep the sessions and keep the routing sticky. + +## Where your code actually forks + +Almost nowhere. + +Tools, resources, prompts, structured output, progress, errors: none of them care which era called. The `initialize` handshake, the `Mcp-Session-Id`, the standalone stream, the `DELETE` that ends a session: the SDK owns all of it, and a handler never sees any of it. Interactive input is *the* place the eras genuinely differ on the wire, and `Resolve` exists so that it is not your problem: you just watched one tool serve both. + +There is exactly one thing left, and it is **change notifications**, because the two eras listen on different pipes: + +* A `2026-07-28` client opens a `subscriptions/listen` stream and reads the subscriptions bus. `ctx.notify_resource_updated()` (and `notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`) publish there, and *only* there. **[Subscriptions](../handlers/subscriptions.md)** is that page. +* A legacy client reads the standalone stream its session keeps open. `ctx.session.send_resource_updated()` (and `send_tool_list_changed()` and friends) write to the *connection* that carried the request: for a legacy session, that is its standalone stream. For a modern HTTP request there is no such channel, and the notification is quietly dropped. + +Over HTTP, neither call reaches the other era's clients. To tell everyone, call both: + +```python title="server.py" hl_lines="19-20" +--8<-- "docs_src/legacy_clients/tutorial003.py" +``` + +Two lines, no `if`, no version check, and you are done. That is the entire list of things a handler does differently because a legacy client exists. + +## Recap + +* One `streamable_http_app()` serves both protocol eras. The SDK routes each request by its `MCP-Protocol-Version` header; there is nothing to configure and no era knob to look for. +* A legacy client costs you a session: an in-process `Mcp-Session-Id` record with no distributed store behind it. More than one worker means **sticky routing**, or the wrong worker answers `404 Session not found`. **[Deploy & scale](deploy.md)** has the multi-worker story. +* `stateless_http=True` is the one knob, and it is **legacy-leg-only**. It buys free load balancing for legacy clients at the price of both server-to-client channels on that leg: server-initiated requests raise `NoBackChannelError` (a top-level error at the client, not an `is_error` result), and notifications are dropped. +* A `2026-07-28` connection is sessionless either way. `stateless_http` never touches it. +* Your handler code forks on era in exactly one place: change notifications. `ctx.notify_*` reaches `subscriptions/listen` clients; `ctx.session.send_*` reaches legacy sessions. Call both. +* Everything else (including asking the user for input, via `Resolve`) is era-portable by construction. Write the modern thing once. diff --git a/docs/advanced/opentelemetry.md b/docs/run/opentelemetry.md similarity index 95% rename from docs/advanced/opentelemetry.md rename to docs/run/opentelemetry.md index 80fb83f04d..c36f5ceaa4 100644 --- a/docs/advanced/opentelemetry.md +++ b/docs/run/opentelemetry.md @@ -90,7 +90,7 @@ mcp._lowlevel_server.middleware[:] = [ !!! warning That import has a leading underscore, and that is on purpose. The class is provisional, the - same way [`Server.middleware`](middleware.md) is provisional, so the import path is something + same way [`Server.middleware`](../advanced/middleware.md) is provisional, so the import path is something you should expect to change. You almost never need this: with no exporter installed the spans are free, so the usual answer is to leave them on and not install an exporter. @@ -104,4 +104,4 @@ mcp._lowlevel_server.middleware[:] = [ with no change to your server. * Client-to-server trace context propagates automatically when both sides run the SDK. -Next, the thing that decides whether a request runs at all: **[Authorization](authorization.md)**. +The thing that decides whether a request runs at all is **[Authorization](authorization.md)**. diff --git a/docs/tutorial/completions.md b/docs/servers/completions.md similarity index 89% rename from docs/tutorial/completions.md rename to docs/servers/completions.md index 31cc8f0820..b7b8750fcd 100644 --- a/docs/tutorial/completions.md +++ b/docs/servers/completions.md @@ -39,7 +39,7 @@ Add **one** function decorated with `@mcp.completion()`: ### Try it -Drive it with the in-memory `Client`, the same one you use in **[Testing](testing.md)**. Call +Drive it with the in-memory `Client` from **[Testing](../get-started/testing.md)**. Call `client.complete()` with `ref=PromptReference(name="review_code")` and `argument={"name": "language", "value": "py"}`: @@ -72,7 +72,7 @@ Registering the handler is the declaration. Connect a client and look: client.server_capabilities.completions # CompletionsCapability() ``` -You didn't list `completions` anywhere. The SDK saw the handler and advertised it during the handshake. Every *optional* capability works this way: the handler is the declaration. (The three primitives are not optional: `MCPServer` always declares those, handlers or not.) +You didn't list `completions` anywhere. The SDK saw the handler and declared the capability for you. Every *optional* capability works this way: the handler is the declaration. (The three primitives are not optional: `MCPServer` always declares those, handlers or not.) !!! check Go back to the first `server.py` (the one with no handler) and ask it anyway. The call fails @@ -122,4 +122,4 @@ Drop `context_arguments=` and the same call returns `[]`. The handler can't know * `context.arguments` holds the already-resolved values; the client supplies them as `context_arguments=`. * The `completions` capability appears the moment you register the handler. Without it, the request is `Method not found`. -Suggestions help *before* a tool runs. To ask the user a question in the *middle* of one, you want **[Elicitation](elicitation.md)**. +Suggestions help while the user is still *filling in* a prompt or template; to ask them a question in the *middle* of a tool call, you want **[Elicitation](../handlers/elicitation.md)**. Everything a tool can return besides text is **[Images, audio & icons](media.md)**. diff --git a/docs/tutorial/handling-errors.md b/docs/servers/handling-errors.md similarity index 91% rename from docs/tutorial/handling-errors.md rename to docs/servers/handling-errors.md index 90efddc243..0cb0a7df32 100644 --- a/docs/tutorial/handling-errors.md +++ b/docs/servers/handling-errors.md @@ -4,7 +4,7 @@ A tool can fail in two ways, and the SDK treats them very differently. Raise an ordinary exception and the **model** sees it. Raise `MCPError` and the **protocol** sees it. -This chapter is about choosing. +This page is about choosing. ## An error the model can fix @@ -110,7 +110,7 @@ Notice there is no `is_error=True` half-result here. A resource read either retu A bad argument never reaches your function. -Send `get_author` a `title` that isn't a string and the SDK rejects it against the input schema **before** calling you, as the same kind of `is_error=True` tool error the model can read and correct. You saw this in **[Tools](tools.md)** with `Field(le=50)`. +Send `get_author` a `title` that isn't a string and the SDK rejects it against the input schema **before** calling you, as the same kind of `is_error=True` tool error the model can read and correct. **[Tools](tools.md)** shows the same rejection with a `Field(le=50)` constraint. It means a whole class of `raise` statements you don't write: don't re-validate your own type hints. @@ -118,7 +118,7 @@ It means a whole class of `raise` statements you don't write: don't re-validate Everything on this page is what a **client** sees, and the in-memory `Client` you'll write tests with sees exactly the same thing. Even `raise_exceptions=True` doesn't turn a tool error back into a traceback: by the time that flag could act, your exception is already the - `is_error=True` result. Assert on the result. **[Testing](testing.md)** covers the pattern. + `is_error=True` result. Assert on the result. **[Testing](../get-started/testing.md)** covers the pattern. ## Recap @@ -129,4 +129,6 @@ It means a whole class of `raise` statements you don't write: don't re-validate * Bad arguments are rejected against the schema before your function runs; you don't `raise` for those. * `from mcp import MCPError`; the error-code constants come from `mcp_types`. -Errors handled. Next: the things your server sets up once, before the first call ever arrives, the **[Lifespan](lifespan.md)**. +Errors handled. That is everything a server *exposes*. What every handler can read, and do back to the client while it runs, is the next section: **[Inside your handler](../handlers/index.md)**. + +The exact text of the SDK errors you are most likely to meet, what each means, and the one-move fix for each is **[Troubleshooting](../troubleshooting.md)**. diff --git a/docs/servers/index.md b/docs/servers/index.md new file mode 100644 index 0000000000..72eda00a4f --- /dev/null +++ b/docs/servers/index.md @@ -0,0 +1,30 @@ +# Servers + +An `MCPServer` exposes three primitives to a connected client. They differ by who +decides to use them: + +* A **[tool](tools.md)** is an action the *model* picks and calls. This is + the page most people want first, and + **[Structured Output](structured-output.md)** is its reference companion: + everything about the shape of what a tool returns. +* A **[resource](resources.md)** is read-only data the *application* + chooses to read. **[URI templates](uri-templates.md)** is its reference + companion: the full addressing syntax and the path-safety rules. +* A **[prompt](prompts.md)** is a message template a *person* invokes by + name, from a menu or a slash command. + +Around the three primitives, the rest of what a server declares: + +* **[Completions](completions.md)** is server-side autocomplete for prompt + and resource-template arguments. +* **[Images, audio & icons](media.md)** covers everything a tool can + return besides text, and the icons a client shows next to your server. +* **[Handling errors](handling-errors.md)** explains the difference between an + error the model can recover from and one it must never see. + +Every page here stands on its own; jump straight to the one you need. If you haven't +built a server yet, start with **[First steps](../get-started/first-steps.md)** instead. + +What happens *inside* the functions you register (the `Context`, dependency injection, +asking the user for more input mid-call) is the next section, +**[Inside your handler](../handlers/index.md)**. diff --git a/docs/tutorial/media.md b/docs/servers/media.md similarity index 92% rename from docs/tutorial/media.md rename to docs/servers/media.md index 06fde16082..e5e8a76565 100644 --- a/docs/tutorial/media.md +++ b/docs/servers/media.md @@ -30,7 +30,7 @@ Two things to notice: !!! info `ImageContent` and `AudioContent` live in `mcp_types`, right next to the `TextContent` - you met in **[Tools](tools.md)**. A tool result is a list of content blocks; `Image` and `Audio` are + that a plain `str` result becomes (**[Tools](tools.md)**). A tool result is a list of content blocks; `Image` and `Audio` are the shortest way to produce the two binary kinds. ### Try it @@ -89,7 +89,7 @@ The same `icons=[...]` keyword is accepted by `MCPServer(...)`, `@mcp.tool()`, ` ### Where a client sees them -Icons travel with whatever they decorate. The server's arrive during the handshake, on `client.server_info`: +Icons travel with whatever they decorate. The server's arrive when the client connects, on `client.server_info`: ```python client.server_info.icons # [Icon(src="https://example.com/brand-kit.png", mime_type="image/png", sizes=["48x48"])] @@ -105,4 +105,4 @@ A tool's icons are on the `Tool` object from `tools/list`, a resource's on the ` * An `Icon` is a pointer: a `src` URI plus optional `mime_type`, `sizes`, and `theme`. * `icons=[...]` works on the server, on tools, on resources, and on prompts, and clients find them on the matching objects. -That is everything a tool can put *into* a result. Helping the user fill in a prompt's or a resource template's arguments *before* anything runs is **[Completions](completions.md)**. +That is everything a tool can put *into* a result. What happens when a tool *fails* (and who should find out) is **[Handling errors](handling-errors.md)**. diff --git a/docs/tutorial/prompts.md b/docs/servers/prompts.md similarity index 93% rename from docs/tutorial/prompts.md rename to docs/servers/prompts.md index c512e96ff9..c49860dfd6 100644 --- a/docs/tutorial/prompts.md +++ b/docs/servers/prompts.md @@ -12,7 +12,7 @@ You declare one by putting `@mcp.prompt()` on a function that returns the text. --8<-- "docs_src/prompts/tutorial001.py" ``` -The SDK reads the same three things it read from your tools: +The SDK reads the same three things it reads from a tool: * The **name** is the function name: `review_code`. * The **description** the client shows is the docstring: `Review a piece of code.` @@ -116,7 +116,7 @@ Notice the last one. Pre-filling an `assistant` turn is how you steer the model' ``` * `title="Code review"` is the human-readable name, exactly like a tool's `title`. -* `Annotated[str, Field(description=...)]` is the same pattern you used in **[Tools](tools.md)**. Here the description lands on the argument instead of in a schema. +* `Annotated[str, Field(description=...)]` is the same pattern **[Tools](tools.md)** uses to describe a tool's parameters. Here the description lands on the argument instead of in a schema. * `language` has a default, so it stops being required. The `prompts/list` entry now carries everything a client needs to draw a good form: @@ -147,4 +147,4 @@ The `prompts/list` entry now carries everything a client needs to draw a good fo * `title=` and `Field(description=...)` are what a client puts in its UI. * A missing required argument fails the whole request. There is no per-prompt error result. -Next up: the one extra parameter a tool, resource or prompt can ask the SDK for, **[The Context](context.md)**. +Server-side autocomplete for a prompt's (or a resource template's) arguments is **[Completions](completions.md)**. diff --git a/docs/tutorial/resources.md b/docs/servers/resources.md similarity index 96% rename from docs/tutorial/resources.md rename to docs/servers/resources.md index 8c63053a13..407f1d9ae1 100644 --- a/docs/tutorial/resources.md +++ b/docs/servers/resources.md @@ -92,9 +92,9 @@ Notice the `uri` in the result. It is the **concrete** URI the client asked for, A mismatch can only ever be a bug, so the SDK makes it impossible to start the server with one. -The placeholder syntax is [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570): `{+path}` for multi-segment values, `{?q,lang}` for optional query parameters, and more. The SDK also applies path-safety checks to extracted values by default. See **[URI templates and path safety](../advanced/uri-templates.md)** for the full reference. +The placeholder syntax is [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570): `{+path}` for multi-segment values, `{?q,lang}` for optional query parameters, and more. The SDK also applies path-safety checks to extracted values by default. See **[URI templates and path safety](uri-templates.md)** for the full reference. -`get_user_profile` can also take a parameter annotated `Context`. The SDK injects it without ever treating it as a URI parameter, and **[The Context](context.md)** chapter covers what it gives you. +`get_user_profile` can also take a parameter annotated `Context`. The SDK injects it without ever treating it as a URI parameter, and **[The Context](../handlers/context.md)** page covers what it gives you. ## What you return @@ -138,4 +138,4 @@ A client can also **subscribe** to a resource and be notified when it changes; t * `str` becomes text, `bytes` becomes a base64 blob, anything else becomes JSON text. `mime_type=` is how you label it. * Tools are for the model to act. Resources are for the application to read. -Next: the third primitive, the one a person picks from a menu, **[Prompts](prompts.md)**. +The third primitive, the one a person picks from a menu, is **[Prompts](prompts.md)**. diff --git a/docs/tutorial/structured-output.md b/docs/servers/structured-output.md similarity index 95% rename from docs/tutorial/structured-output.md rename to docs/servers/structured-output.md index 65ab1794e4..a146e01442 100644 --- a/docs/tutorial/structured-output.md +++ b/docs/servers/structured-output.md @@ -1,8 +1,8 @@ # Structured Output -In **[Tools](tools.md)** you returned a `str` and the result came back twice: as text in `content`, and as `{"result": "..."}` in `structured_content`. +A tool that returns a plain `str` produces the result twice: as text in `content`, and as `{"result": "..."}` in `structured_content`. -This chapter is about that second channel: where it comes from, every shape it can take, and how the SDK keeps it honest. +This page is about that second channel: where it comes from, every shape it can take, and how the SDK keeps it honest. The short version: **the return type annotation is the output schema**. You already wrote it. @@ -14,7 +14,7 @@ The short version: **the return type annotation is the output schema**. You alre The line that matters is the signature: `-> int`. -Because of it, the tool the SDK sends during `tools/list` carries an `output_schema` next to the input schema you met in **[Tools](tools.md)**: +Because of it, the tool the SDK sends during `tools/list` carries an `output_schema` next to the input schema it builds from your parameters (**[Tools](tools.md)** covers that one): ```json { diff --git a/docs/tutorial/tools.md b/docs/servers/tools.md similarity index 97% rename from docs/tutorial/tools.md rename to docs/servers/tools.md index 120b96e005..8b7ee05721 100644 --- a/docs/tutorial/tools.md +++ b/docs/servers/tools.md @@ -49,7 +49,7 @@ result.structured_content # {'result': "Found 3 books matching 'dune' (showing `content` is the text the **model** reads. `structured_content` is typed data for the **client application**. It's there because you declared the return type as `-> str`. -Don't worry about `structured_content` yet. Return real Python objects from your tools and the right thing happens; the **[Structured Output](structured-output.md)** chapter is all about it. +Don't worry about `structured_content` yet. Return real Python objects from your tools and the right thing happens; the **[Structured Output](structured-output.md)** page is all about it. ### Try it @@ -169,4 +169,4 @@ A well-behaved client uses them to decide things like *"do I need to ask the use * Bad arguments are rejected for you, with an error the model can read and recover from. * `async def` for I/O, plain `def` for everything else. -Next up, **[Structured Output](structured-output.md)**: what happens to the value you `return`. +**[Structured Output](structured-output.md)** is what happens to the value you `return`. diff --git a/docs/advanced/uri-templates.md b/docs/servers/uri-templates.md similarity index 95% rename from docs/advanced/uri-templates.md rename to docs/servers/uri-templates.md index 51208d7250..6cda30eb30 100644 --- a/docs/advanced/uri-templates.md +++ b/docs/servers/uri-templates.md @@ -1,10 +1,10 @@ # URI templates and path safety This is the reference for the URI-template syntax that -[`@mcp.resource`](../tutorial/resources.md) accepts, and for the +[`@mcp.resource`](resources.md) accepts, and for the path-safety policy the SDK applies to extracted values. For an introduction to what resources are and when to use them, start with -**[Resources](../tutorial/resources.md)**; this page assumes you're already comfortable declaring a +**[Resources](resources.md)**; this page assumes you're already comfortable declaring a resource and want the full operator set, the security knobs, or the low-level wiring. @@ -17,7 +17,7 @@ details (message formats, lifecycle, pagination) see the ## The full operator set -**[Resources](../tutorial/resources.md)** showed one placeholder, `{user_id}`. There are four more +The plain placeholder, `{user_id}`, is the one **[Resources](resources.md)** introduces. There are four more operator forms; here they are on one server so you can see them next to each other: @@ -30,7 +30,7 @@ The sections below walk them top to bottom. ### Simple expansion: `{name}` -`books://{isbn}` is the form you already know. The placeholder maps to +`books://{isbn}` is the plain, everyday form. The placeholder maps to the `isbn` parameter, so a client reading `books://978-0441172719` calls `get_book("978-0441172719")`. @@ -201,13 +201,13 @@ These checks are a heuristic pre-filter; for filesystem access, !!! tip If your handler can't fulfil the request (the file doesn't exist, the id is unknown), raise an exception. The SDK turns it into an - error response. See **[Handling errors](../tutorial/handling-errors.md)** for the difference between a + error response. See **[Handling errors](handling-errors.md)** for the difference between a protocol error and a tool error. ## Resources on the low-level Server If you're building on the low-level `Server` (see **[The low-level -Server](low-level-server.md)**), you register handlers for the `resources/list` and +Server](../advanced/low-level-server.md)**), you register handlers for the `resources/list` and `resources/read` protocol methods directly. There's no decorator; you return the protocol types yourself. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000000..621b32c6cd --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,412 @@ +# Troubleshooting + +Every heading on this page is the exact text of an error the SDK produces, followed by what it means and the one-move fix. Find the last line of your traceback (or your server log) here with your browser's find-in-page, and read only that entry. + +Several entries run against this one server. One tool and one templated resource, each raising for a city it doesn't know: + +```python title="server.py" +--8<-- "docs_src/troubleshooting/tutorial001.py" +``` + +The errors this page quotes are real: the SDK's own test suite reproduces every one of them. + +## `ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)` + +This is not an MCP error. It is anyio noise, and your real error is the **last line** of the paste. + +`Client.__aenter__` starts a task group. anyio wraps anything that leaves a task group in an `ExceptionGroup`, so *every* exception that escapes an `async with Client(...)` block, whatever it is, arrives inside one: + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.read_resource("weather://Atlantis") +``` + +```text + + Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Exception Group Traceback (most recent call last): + | ... + | ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception) + +-+---------------- 1 ---------------- + | Traceback (most recent call last): + | ... + | mcp.shared.exceptions.MCPError: No forecast for 'Atlantis'. + +------------------------------------ +``` + +Two things to do with that: + +1. **Read the bottom.** `MCPError: No forecast for 'Atlantis'.` is the failure; find *its* text on this page. +2. **Catch inside the block.** The `ExceptionGroup` only appears when the exception *leaves* the `async with`. Caught inside it, the same failure is the plain `MCPError`, no group anywhere: + +```python +async def main() -> None: + async with Client(mcp) as client: + try: + await client.read_resource("weather://Atlantis") + except MCPError as e: + print(e) # No forecast for 'Atlantis'. +``` + +!!! tip + A failure during *connection* (a wrong URL, a server that isn't running, the `421` further + down this page) escapes from `async with` itself, so there is no "inside" to catch it in. + For those, read the bottom of the group. + +## `RuntimeError: Client must be used within an async context manager` + +`Client(...)` only builds the object. Nothing connects until `async with`, so every method refuses: + +```python +async def main() -> None: + client = Client(mcp) + tools = await client.list_tools() # RuntimeError +``` + +Enter it. `__aenter__` is the connection: + +```python +async def main() -> None: + async with Client(mcp) as client: + tools = await client.list_tools() +``` + +`__aexit__` is the disconnection, which is why there is no `client.close()` to forget. **[Testing](get-started/testing.md)** is built on exactly this pattern. + +## `Error executing tool : ` and `Unknown tool: ` + +You are reading a **result**, not an exception. `call_tool` did not raise, and it never will for a failing tool. + +Call `forecast` for a city the server doesn't know, and the exception it raises comes back with the request marked as *succeeded*: + +```python +result.is_error # True +result.content # [TextContent(text="Error executing tool forecast: No forecast for 'Atlantis'.")] +result.structured_content # None +``` + +`Unknown tool: get_forecast` is the same shape for a name the server never registered, and a bad argument is rejected the same way, against the tool's input schema, before your function ever runs. + +The fix is in your client: **check `result.is_error`**. A `try/except` around `call_tool` catches none of these, because there is nothing to catch. This is deliberate, and it is the single most useful thing on this page to internalise: the *model* chose the call, so the model gets the message and a chance to try again. **[Handling errors](servers/handling-errors.md)** is the whole story, including the `MCPError` path that *does* raise. + +## `TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool` + +You wrote `@mcp.tool` instead of `@mcp.tool()`. `tool()` is a decorator *factory*: without the parentheses, Python hands your function to its `name=` parameter. + +```python +@mcp.tool # <- missing () +def forecast(city: str) -> str: + """Today's forecast for one city.""" + return f"{city}: Rain." +``` + +```text +TypeError: The @tool decorator was used incorrectly. Did you forget to call it? Use @tool() instead of @tool +``` + +Add the parentheses. `@mcp.resource(...)` and `@mcp.prompt()` say the same thing for the same slip. + +!!! note + This raises when the module is **imported**, before any client connects. So a host that shows + your server as *failed to start* (or *disconnected*), rather than as connected with zero + tools, has this shape: run `python server.py` yourself and read the traceback. A type checker + also catches it: a function is not a valid `name=`. + +## `Tool already exists: ` + +Two registrations used the same tool name. The **first** one wins, the second is silently dropped, and this warning in the *server log* is the only signal: + +```python title="server.py" hl_lines="6 12" +--8<-- "docs_src/troubleshooting/tutorial002.py" +``` + +```text +WARNING mcp.server.mcpserver.tools.tool_manager: Tool already exists: forecast +``` + +`tools/list` reports one `forecast`, and it is `forecast_today`. Rename one of them. `MCPServer(..., warn_on_duplicate_tools=False)` silences the warning without changing the outcome, so leave it on. Resources and prompts have the same rule and the same log line (`Resource already exists:`, `Prompt already exists:`). + +## My host lists zero tools + +There is no error string for this, which is exactly why it is hard to search. The SDK never drops a registered tool from `tools/list`, so work outward: + +* **Did the server start at all?** `@mcp.tool` without parentheses raises at import time, and a crashed server looks a lot like an empty one in some hosts. Run `python server.py` yourself. +* **Is the tool on the `mcp` the host is running?** A second `MCPServer(...)` in another module is a different, empty server. Check which object the host's command actually imports. +* **Did two tools share a name?** Then one of them is gone. Look for `Tool already exists:` in the server log. +* **Is the host's list stale?** Adding a tool after startup only reaches clients that handle `notifications/tools/list_changed`. Restarting the host is the blunt fix. +* **Did something write to `stdout`?** On a stdio transport, stdout *is* the protocol: one stray `print()` and the host drops the connection, which some hosts render as a server with nothing in it. Log with the `logging` module instead. The rest of the host-side checklist is on **[Connect to a real host](get-started/real-host.md)**. + +An "invalid" tool name is *not* on that list: a non-conforming name logs a warning but the tool is registered and listed anyway. + +## `MCPError: Server returned an error response` + +The server refused the HTTP request outright, with a body that is not JSON-RPC, so the python `Client` has nothing better to show you than this stand-in. + +By far the most common cause is a freshly deployed Streamable HTTP server. `streamable_http_app()` (and `mcp.run("streamable-http")`) with no `transport_security=` defaults to **DNS-rebinding protection**: it accepts only requests whose `Host` header is localhost. That is the right default on your laptop and the wrong one behind a real hostname: + +```python title="server.py" hl_lines="12" +--8<-- "docs_src/troubleshooting/tutorial003.py" +``` + +Deploy that, point a client at it, and the connection fails on the handshake: + +```python +async with Client("https://mcp.example.com/mcp") as client: + ... +``` + +```text +mcp.shared.exceptions.MCPError: Server returned an error response +``` + +The words the server actually sent, `421` and `Invalid Host header`, never reach you: the 421 body has no `Content-Type: application/json`, so the client cannot parse it. They are in the **server's log**, which is where to look next: + +```text +WARNING mcp.server.transport_security: Invalid Host header: mcp.example.com +``` + +The fix is `transport_security=`. Allowlist the hostname you actually serve: + +```python title="server.py" hl_lines="14-17" +--8<-- "docs_src/troubleshooting/tutorial004.py" +``` + +!!! check + That is the whole change. The identical client now connects, negotiates `2026-07-28`, and + calls `forecast`. + +**[Deploy & scale](run/deploy.md)** covers what each field means, the reverse-proxy case, and everything else that changes at deploy time. And `421 Misdirected Request` / `Invalid Host header`, right below, is the same failure seen from the other side. + +## `421 Misdirected Request` / `Invalid Host header` + +This is `Server returned an error response`, seen from anything that is *not* the python `Client`: curl, a browser's network tab, a reverse proxy's access log, or another SDK. + +```bash +curl -i https://mcp.example.com/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' +``` + +```text +HTTP/1.1 421 Misdirected Request + +Invalid Host header +``` + +`421 Misdirected Request` is HTTP's own reason phrase for the status; `Invalid Host header` is the SDK's response body; and the python `Client` renders the same event as `Server returned an error response`. All three are one refusal. The check runs against the **`Host` header the request carries**, not the address the server bound, so a reverse proxy that forwards the public hostname trips it exactly as a direct client does. + +The fix is the same `transport_security=TransportSecuritySettings(allowed_hosts=[...], allowed_origins=[...])` shown under `Server returned an error response`. Two of its edges are worth naming: + +* An `allowed_hosts` entry is an exact string. `"mcp.example.com"` matches a bare `Host` header and `"mcp.example.com:*"` matches any explicit port. List both. +* A `403` with the body `Invalid Origin header` is the sibling check on the `Origin` header. It only fires for browsers (nothing else sends `Origin`), and `allowed_origins=` is its allowlist. + +**[Deploy & scale](run/deploy.md)** has the full treatment, including when switching the check off is the honest configuration. + +## `RuntimeError: Task group is not initialized. Make sure to use run().` + +Your MCP app is mounted inside another ASGI app, and nothing started its **session manager**. + +`mcp.streamable_http_app()` returns a Starlette app whose own lifespan starts the manager, and `uvicorn server:app` runs that lifespan for you. But Starlette **never runs a mounted sub-application's lifespan**, so the moment the app goes inside a `Mount`, the manager never starts and the first request explodes: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial005.py" +``` + +The server starts. The route resolves. Then `uvicorn` prints this for every request: + +```text +ERROR: Exception in ASGI application +Traceback (most recent call last): + ... +RuntimeError: Task group is not initialized. Make sure to use run(). +``` + +The client sees a 500. The fix is a lifespan on the **host** app that enters `mcp.session_manager.run()`: + +```python +@asynccontextmanager +async def lifespan(app: Starlette) -> AsyncIterator[None]: + async with mcp.session_manager.run(): + yield + + +app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())], lifespan=lifespan) +``` + +**[Add to an existing app](run/asgi.md)** is the page for this, including several servers in one app and FastAPI. Two neighbouring strings from the same class: + +* `StreamableHTTPSessionManager .run() can only be called once per instance. Create a new instance if you need to run again.` The manager is single-use; entering the same app's lifespan twice hits it. +* `mcp.session_manager` only exists **after** `streamable_http_app()` has been called, so build the routes first and touch the manager only inside the lifespan. + +## `MCPError: Session not found` + +The server does not recognise the `Mcp-Session-Id` your client sent, almost always because the server **restarted** (or you were routed to a different instance). Sessions live in that one process's memory. + +There is no server bug to find. The HTTP response is a `404` whose body *is* JSON-RPC, so, unlike the `421` above, the python `Client` shows you this one verbatim: + +```json +{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Session not found"}} +``` + +The fix is to reconnect: leave the `async with Client(...)` block and enter a new one, which negotiates a fresh session. For a long-lived client, that means catching `MCPError` around your calls and reconnecting on this message rather than retrying inside a dead session. + +If it happens *without* a restart, you are running more than one worker without sticky sessions: each worker holds its own session table, so a request routed to the wrong one lands here. **[Deploy & scale](run/deploy.md)** and **[Serving legacy clients](run/legacy-clients.md)** own that story and its two fixes (sticky routing, or `stateless_http=True`). + +For the server operator, the matching log line is `Rejected request with unknown or expired session ID: `. It is logged at `INFO`, so it is invisible at the usual `WARNING` threshold. Seeing it in bursts right after a deploy is normal; every connected client is reconnecting. + +## `MCPError: Method not found` + +One side sent a JSON-RPC request the other has no handler for, and `e.error.data` names the method. The usual cause is an **era mismatch**: a method that exists in one protocol revision and not in the other, sent to a peer on the wrong one, such as a `2025`-era `resources/subscribe` arriving at a `2026-07-28` connection, or a `2026`-only `subscriptions/listen` sent by a client pinned to `mode="legacy"`. **[Protocol versions](protocol-versions.md)** is the map of which side speaks what, and the other honest cause (an optional capability you never registered a handler for) is on **[Completions](servers/completions.md)**. + +One thing does **not** produce this error, despite being a request the modern protocol removed: a tool calling `ctx.elicit()` on a `2026-07-28` connection. The server refuses to *send* that request at all, so what you get instead is `Cannot send 'elicitation/create': ...`, further down this page. + +## `MCPError: Client did not declare the form elicitation capability required by resolver ''` + +Your server wants to ask the user something, and this client never said it can be asked. + +An elicitation resolver refuses up front when the connected client did not declare form elicitation, and `e.error.data` names exactly what is missing: + +```json +{ + "code": -32021, + "message": "Client did not declare the form elicitation capability required by resolver 'server:ask_to_confirm'", + "data": {"requiredCapabilities": {"elicitation": {"form": {}}}} +} +``` + +Pass `elicitation_callback=` to `Client(...)`. Registering the callback *is* the capability declaration; there is no second switch: + +```python +async def main() -> None: + async with Client(mcp, elicitation_callback=handle_elicitation) as client: + result = await client.call_tool("book_table", {"date": "Friday"}) +``` + +**[Client callbacks](client/callbacks.md)** lists the others (`sampling_callback`, `list_roots_callback`), each of which is a declaration in the same way. + +!!! info + `-32021` is `MISSING_REQUIRED_CLIENT_CAPABILITY`, one of three error codes the 2026-07-28 + spec adds. None of them is an exception class: they all arrive as `MCPError`, and + `e.error.code` is where to look. `mcp_types` exports the constants. The other two are + `-32020` `HEADER_MISMATCH` (an HTTP header disagrees with the request body it accompanies) + and `-32022` `UNSUPPORTED_PROTOCOL_VERSION` (the request named a version this server does not + speak). A conforming SDK client cannot produce either, so if you see one, look at whatever is + rewriting requests between your client and your server. + +## `MCPError: Elicitation not supported` + +The same gap as `Client did not declare the form elicitation capability ...`, spelled by the paths that don't check up front: the server needed an elicitation answered, and the connected client registered no `elicitation_callback`. + +You see this one from `ctx.elicit()` on a legacy connection, and on any connection at all from a returned multi-round-trip question (**[Multi-round-trip requests](handlers/multi-round-trip.md)**) that reaches a client with no callback to answer it. The fix is identical: pass `elicitation_callback=` to `Client(...)`. There is no version of "the user wasn't asked" that your tool receives as a `decline`; a client that cannot be asked is a failed call, so design your tools for it. + +## `MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests.` + +Your handler tried to reach the client mid-request, on a connection where nothing can carry a request from the server. There are exactly two ways to be on one. + +**A `2026-07-28` connection: any transport, always.** The modern protocol has no server-initiated requests at all, so the server refuses before anything is sent. `ctx.elicit()` inside a tool is the classic way to meet this (on the very first in-memory test, since `Client(server)` negotiates `2026-07-28` without being asked), and passing `elicitation_callback=` changes nothing, because no request ever reaches the client for it to answer: + +```python title="server.py" hl_lines="16" +--8<-- "docs_src/troubleshooting/tutorial006.py" +``` + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("book_table", {"date": "Friday"}) +``` + +```text +mcp.shared.exceptions.MCPError: Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests. +``` + +**A legacy connection on a `stateless_http=True` server.** Statelessness means every request is its own world: no session, no server-to-client stream, and so nowhere to send an `elicitation/create` (or `sampling/createMessage`, or `roots/list`) even for the era that has them: + +```python title="server.py" hl_lines="16 23" +--8<-- "docs_src/troubleshooting/tutorial008.py" +``` + +The message names the method it could not send. `NoBackChannelError` is the class the server raises, but the wire carries only the base `MCPError`, so the sentence above is your traceback's last line, not the class name. + +The fix is the same for both: don't reach back mid-call. Move the question into a **resolver** (or return an `InputRequiredResult` yourself) and it becomes part of the *response*, which every connection can carry: + +```python title="server.py" hl_lines="15-17 21" +--8<-- "docs_src/troubleshooting/tutorial007.py" +``` + +Same question, same `elicitation_callback` on the client. The difference is under the hood: a resolver lets the server *return* the question from the call instead of pushing it, so nothing ever flows server-to-client. **[Elicitation](handlers/elicitation.md)** covers resolvers; **[Multi-round-trip requests](handlers/multi-round-trip.md)** covers what happens on the wire. + +!!! check + The tool with `ctx.elicit()` is not wrong, it is *pre-2026*. Connect with `mode="legacy"` + (the classic `initialize` handshake, spec `2025-11-25` and earlier) to a server that is not + `stateless_http=True`, and it works, because the server-to-client channel exists there. + **[Protocol versions](protocol-versions.md)** is the page on what each version has. + +## `MCPError: Invalid or expired requestState` + +The server could not verify the `requestState` token your client echoed back, so it refused the round. + +`requestState` is the opaque resume token a **[multi-round-trip](handlers/multi-round-trip.md)** call carries between legs. `MCPServer` seals it on the way out and verifies every echo, and it verifies *every* inbound `request_state` on `tools/call`, `prompts/get`, and `resources/read`, even for a handler that never mints one. So a token this process didn't seal is refused wherever it lands: + +```python +async def main() -> None: + async with Client(mcp) as client: + await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") +``` + +```text +mcp.shared.exceptions.MCPError: Invalid or expired requestState +``` + +The message is deliberately frozen: the wire never reveals which check failed. The reason goes to the **server log**, and reading it is the whole diagnosis: + +```text +WARNING mcp.server.request_state: requestState rejected on tools/call: malformed +``` + +The reasons you will actually see: + +* **`unknown key`** is the one that matters. The default sealing key is generated at process start, so a retry that lands on a **different worker**, a different instance behind a load balancer, or the same server **after a restart** was sealed under a key this process never had. That is not an attacker; it is the default meeting more than one process. +* **`audience`**: the token was sealed by an instance with a *different server name*. The name is the seal's default audience claim, so a fleet must share the name (or set an explicit `RequestStateSecurity(audience=...)`) as well as the keys. +* **`expired`**: the round took longer than the seal's `ttl`, which is 600 seconds and per round, not per call. +* **`malformed`** / **`codec error`**: the token was altered in transit, or was never a sealed token at all. +* **`request binding`**: the token came back with a different tool, different arguments, or a different method. + +The multi-process fix is one argument (the *same* `keys` on every instance) plus one thing that is not an argument at all: the same server *name* (or an explicit shared `audience=`). + +```python +mcp = MCPServer("Weather", request_state_security=RequestStateSecurity(keys=[key])) +``` + +`keys[0]` seals; every key in the list verifies, which is what makes zero-downtime rotation possible. **[Multi-round-trip requests](handlers/multi-round-trip.md#protecting-requeststate)** explains what the seal protects and the rotation sequence, and **[Deploy & scale](run/deploy.md)** walks the whole two-worker failure and its two-part fix. + +!!! tip + `keys=[...]` refuses a weak key immediately, with an unusually helpful message: + + ```text + ValueError: request-state keys must be at least 32 bytes of secret randomness; keys[0] is 7 bytes. Generate one with: python -c "import secrets; print(secrets.token_hex(32))" + ``` + + Do what it says. + +## Still stuck? + +* If a message the SDK produced is not on this page, that is a documentation bug worth reporting on its own. +* Search the [issue tracker](https://github.com/modelcontextprotocol/python-sdk/issues); most error strings appearing there are already someone's write-up. +* Found nothing? [Open an issue](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml) with the full traceback, or ask in [#python-sdk-dev on the MCP Contributors Discord](https://discord.gg/6CSzBmMkjX). + +## Recap + +* `ExceptionGroup: unhandled errors in a TaskGroup` is never the error. Read the **last line**; catching `MCPError` *inside* the `async with Client(...)` block skips the wrapping entirely. +* `call_tool` does not raise for a failing tool. `Error executing tool ...` and `Unknown tool: ...` are results: check `result.is_error`. +* `Client must be used within an async context manager` -> use `async with`. `Use @tool() instead of @tool` -> add the parentheses. +* `Tool already exists:` in the server log is the only sign that two same-named tools collapsed into one. +* One 421, three spellings: `Server returned an error response` (the python `Client`), `421 Misdirected Request` / `Invalid Host header` (everything else), `Invalid Host header: ` (the server log). Fix: `transport_security=TransportSecuritySettings(allowed_hosts=[...])`. +* `Task group is not initialized` -> a mounted app whose host lifespan never entered `mcp.session_manager.run()`. +* `Session not found` -> the server restarted; reconnect. +* `Cannot send 'elicitation/create': ... no back-channel ...` -> `ctx.elicit()` needs a server-to-client channel: a `2026-07-28` connection never has one, and `stateless_http=True` takes away the legacy one. Use a resolver. Its neighbour `Method not found` is a request for a method the other side's protocol revision doesn't have. +* `Client did not declare the form elicitation capability ...` and `Elicitation not supported` -> the client is missing `elicitation_callback=`. +* `Invalid or expired requestState` never says why on the wire. The server log does; `unknown key` means share `RequestStateSecurity(keys=[...])` across workers. diff --git a/docs/tutorial/index.md b/docs/tutorial/index.md deleted file mode 100644 index e7c7ba799e..0000000000 --- a/docs/tutorial/index.md +++ /dev/null @@ -1,51 +0,0 @@ -# Tutorial - User Guide - -This tutorial shows you how to use the MCP Python SDK, step by step. - -Each section gradually builds on the previous ones, but it's written so you can go straight to any specific section to solve a specific problem. It also works as a future reference: you can come back to exactly the part you need. - -## Run the code - -All the code blocks can be copied and used directly: they are complete, working files. - -To follow along, paste a block into a `server.py` and open it in the MCP Inspector: - -```console -uv run mcp dev server.py -``` - -It is **HIGHLY encouraged** that you write (or copy) the code, edit it, and run it locally. Using it in your own editor is what really shows you the point: how little you write, the autocompletion, the type checks catching mistakes before you run anything. - -## You will not be guessing - -Every example in this tutorial is a complete file under [`docs_src/`](https://github.com/modelcontextprotocol/python-sdk/tree/main/docs_src) in the SDK's own repository, and every one of them is exercised by the SDK's test suite through an **in-memory client**: - -```python -import pytest -from mcp import Client - -from server import mcp - - -@pytest.mark.anyio -async def test_add() -> None: - async with Client(mcp) as client: - result = await client.call_tool("add", {"a": 1, "b": 2}) - assert result.structured_content == {"result": 3} -``` - -No subprocess, no port, no transport. `Client(mcp)` connects to the server object directly. - -If a change to the SDK breaks an example on one of these pages, CI goes red before the page does. The code you read here is the code that runs. - -You'll use this yourself in the [Testing](testing.md) chapter; it's how you test your own servers, too. - -## Install the SDK - -If you haven't yet, [install the SDK](../installation.md) first. - -## Advanced User Guide - -There is also an **Advanced User Guide** you can read after this one. - -It builds on this tutorial, uses the same concepts, and teaches you the extra things: the low-level `Server`, middleware, authorization, the 2026-07-28 protocol negotiation. But you should read this first: everything in the Advanced guide assumes you know the basics. diff --git a/docs_src/deploy/__init__.py b/docs_src/deploy/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/deploy/tutorial001.py b/docs_src/deploy/tutorial001.py new file mode 100644 index 0000000000..7b00259365 --- /dev/null +++ b/docs_src/deploy/tutorial001.py @@ -0,0 +1,17 @@ +from mcp.server import MCPServer +from mcp.server.transport_security import TransportSecuritySettings + +mcp = MCPServer("Notes") + + +@mcp.tool() +def add_note(text: str) -> str: + """Save a note.""" + return f"Saved: {text}" + + +security = TransportSecuritySettings( + allowed_hosts=["mcp.example.com", "mcp.example.com:*"], + allowed_origins=["https://app.example.com"], +) +app = mcp.streamable_http_app(transport_security=security) diff --git a/docs_src/deploy/tutorial002.py b/docs_src/deploy/tutorial002.py new file mode 100644 index 0000000000..8b61aacac1 --- /dev/null +++ b/docs_src/deploy/tutorial002.py @@ -0,0 +1,27 @@ +from mcp_types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult + +from mcp.server.mcpserver import Context, MCPServer + +CONFIRM = ElicitRequest( + params=ElicitRequestFormParams( + message="Issue this refund?", + requested_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}, "required": ["ok"]}, + ) +) + + +def make_server() -> MCPServer: + """Every worker process builds one of these, once, at import.""" + mcp = MCPServer("billing") + + @mcp.tool() + async def refund(amount: int, ctx: Context) -> str | InputRequiredResult: + """Refund an amount, once a human has confirmed it.""" + if ctx.input_responses is None: + return InputRequiredResult(input_requests={"ok": CONFIRM}, request_state=f"refund:{amount}") + answer = (ctx.input_responses or {}).get("ok") + if not isinstance(answer, ElicitResult) or answer.action != "accept" or not (answer.content or {}).get("ok"): + return "refund cancelled" + return f"refunded ${amount}" + + return mcp diff --git a/docs_src/deploy/tutorial003.py b/docs_src/deploy/tutorial003.py new file mode 100644 index 0000000000..8d9d126c0c --- /dev/null +++ b/docs_src/deploy/tutorial003.py @@ -0,0 +1,27 @@ +from mcp_types import ElicitRequest, ElicitRequestFormParams, ElicitResult, InputRequiredResult + +from mcp.server.mcpserver import Context, MCPServer, RequestStateSecurity + +CONFIRM = ElicitRequest( + params=ElicitRequestFormParams( + message="Issue this refund?", + requested_schema={"type": "object", "properties": {"ok": {"type": "boolean"}}, "required": ["ok"]}, + ) +) + + +def make_server(key: str) -> MCPServer: + """Every worker process: the same key, and the same name.""" + mcp = MCPServer("billing", request_state_security=RequestStateSecurity(keys=[key])) + + @mcp.tool() + async def refund(amount: int, ctx: Context) -> str | InputRequiredResult: + """Refund an amount, once a human has confirmed it.""" + if ctx.input_responses is None: + return InputRequiredResult(input_requests={"ok": CONFIRM}, request_state=f"refund:{amount}") + answer = (ctx.input_responses or {}).get("ok") + if not isinstance(answer, ElicitResult) or answer.action != "accept" or not (answer.content or {}).get("ok"): + return "refund cancelled" + return f"refunded ${amount}" + + return mcp diff --git a/docs_src/deploy/tutorial004.py b/docs_src/deploy/tutorial004.py new file mode 100644 index 0000000000..5f32c65bb9 --- /dev/null +++ b/docs_src/deploy/tutorial004.py @@ -0,0 +1,23 @@ +from mcp.server.mcpserver import Context, MCPServer +from mcp.server.subscriptions import SubscriptionBus + +NOTES = {"todo": "buy milk"} + + +def make_server(bus: SubscriptionBus) -> MCPServer: + """Every replica gets its own server object; all of them hold the same bus.""" + mcp = MCPServer("Notebook", subscriptions=bus) + + @mcp.resource("note://{name}") + def note(name: str) -> str: + """One note, by name.""" + return NOTES[name] + + @mcp.tool() + async def edit_note(name: str, text: str, ctx: Context) -> str: + """Replace a note's text.""" + NOTES[name] = text + await ctx.notify_resource_updated(f"note://{name}") + return "saved" + + return mcp diff --git a/docs_src/legacy_clients/__init__.py b/docs_src/legacy_clients/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/legacy_clients/tutorial001.py b/docs_src/legacy_clients/tutorial001.py new file mode 100644 index 0000000000..2f8b1191e4 --- /dev/null +++ b/docs_src/legacy_clients/tutorial001.py @@ -0,0 +1,42 @@ +from typing import Annotated + +from mcp_types import ElicitRequestParams, ElicitResult +from pydantic import BaseModel + +from mcp import Client +from mcp.client import ClientRequestContext +from mcp.server import MCPServer +from mcp.server.mcpserver import AcceptedElicitation, Elicit, ElicitationResult, Resolve + +mcp = MCPServer("Bookshop") + + +class Quantity(BaseModel): + copies: int + + +async def ask_quantity() -> Elicit[Quantity]: + """Resolver: ask the user how many copies to put aside.""" + return Elicit("How many copies?", Quantity) + + +@mcp.tool() +async def reserve(title: str, quantity: Annotated[ElicitationResult[Quantity], Resolve(ask_quantity)]) -> str: + """Reserve copies of a book, asking the user how many.""" + if isinstance(quantity, AcceptedElicitation): + return f"Reserved {quantity.data.copies} of {title!r}." + return "Nothing reserved." + + +async def answer(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + return ElicitResult(action="accept", content={"copies": 2}) + + +async def main() -> None: + async with ( + Client(mcp, mode="legacy", elicitation_callback=answer) as legacy, + Client(mcp, elicitation_callback=answer) as modern, + ): + for client in (legacy, modern): + result = await client.call_tool("reserve", {"title": "Dune"}) + print(client.protocol_version, result.structured_content) diff --git a/docs_src/legacy_clients/tutorial002.py b/docs_src/legacy_clients/tutorial002.py new file mode 100644 index 0000000000..53c6475a46 --- /dev/null +++ b/docs_src/legacy_clients/tutorial002.py @@ -0,0 +1,28 @@ +from typing import Annotated + +from pydantic import BaseModel + +from mcp.server import MCPServer +from mcp.server.mcpserver import AcceptedElicitation, Elicit, ElicitationResult, Resolve + +mcp = MCPServer("Bookshop") + + +class Quantity(BaseModel): + copies: int + + +async def ask_quantity() -> Elicit[Quantity]: + """Resolver: ask the user how many copies to put aside.""" + return Elicit("How many copies?", Quantity) + + +@mcp.tool() +async def reserve(title: str, quantity: Annotated[ElicitationResult[Quantity], Resolve(ask_quantity)]) -> str: + """Reserve copies of a book, asking the user how many.""" + if isinstance(quantity, AcceptedElicitation): + return f"Reserved {quantity.data.copies} of {title!r}." + return "Nothing reserved." + + +app = mcp.streamable_http_app(stateless_http=True) diff --git a/docs_src/legacy_clients/tutorial003.py b/docs_src/legacy_clients/tutorial003.py new file mode 100644 index 0000000000..52f8f1ea66 --- /dev/null +++ b/docs_src/legacy_clients/tutorial003.py @@ -0,0 +1,21 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Bookshop") + +STOCK = {"Dune": 3} + + +@mcp.resource("stock://{title}") +def stock(title: str) -> str: + """How many copies of one book are on the shelf.""" + return f"{STOCK[title]} in stock" + + +@mcp.tool() +async def restock(title: str, copies: int, ctx: Context) -> str: + """Put copies of a book back on the shelf.""" + STOCK[title] = STOCK.get(title, 0) + copies + await ctx.notify_resource_updated(f"stock://{title}") + await ctx.session.send_resource_updated(f"stock://{title}") + return f"{STOCK[title]} in stock" diff --git a/docs_src/real_host/__init__.py b/docs_src/real_host/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/real_host/tutorial001.py b/docs_src/real_host/tutorial001.py new file mode 100644 index 0000000000..1cd39c8c58 --- /dev/null +++ b/docs_src/real_host/tutorial001.py @@ -0,0 +1,34 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Bookshop") + +CATALOG = { + "Dune": "Frank Herbert", + "Neuromancer": "William Gibson", + "The Left Hand of Darkness": "Ursula K. Le Guin", +} + + +@mcp.tool() +def search_books(query: str) -> list[str]: + """Search the catalog by title or author.""" + needle = query.lower() + return [title for title, author in CATALOG.items() if needle in title.lower() or needle in author.lower()] + + +@mcp.tool() +def get_author(title: str) -> str: + """Look up the author of a book in the catalog.""" + if title not in CATALOG: + raise ValueError(f"No book titled {title!r} in the catalog.") + return CATALOG[title] + + +@mcp.resource("catalog://titles") +def titles() -> str: + """Every title in the catalog, one per line.""" + return "\n".join(sorted(CATALOG)) + + +if __name__ == "__main__": + mcp.run() diff --git a/docs_src/troubleshooting/__init__.py b/docs_src/troubleshooting/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/troubleshooting/tutorial001.py b/docs_src/troubleshooting/tutorial001.py new file mode 100644 index 0000000000..e83a552df0 --- /dev/null +++ b/docs_src/troubleshooting/tutorial001.py @@ -0,0 +1,22 @@ +from mcp.server import MCPServer +from mcp.server.mcpserver.exceptions import ResourceNotFoundError + +mcp = MCPServer("Weather") + +FORECASTS = {"London": "Rain.", "Cairo": "Sun."} + + +@mcp.tool() +def forecast(city: str) -> str: + """Today's forecast for one city.""" + if city not in FORECASTS: + raise ValueError(f"No forecast for {city!r}.") + return FORECASTS[city] + + +@mcp.resource("weather://{city}") +def report(city: str) -> str: + """The full report for one city.""" + if city not in FORECASTS: + raise ResourceNotFoundError(f"No forecast for {city!r}.") + return f"{city}: {FORECASTS[city]}" diff --git a/docs_src/troubleshooting/tutorial002.py b/docs_src/troubleshooting/tutorial002.py new file mode 100644 index 0000000000..ec68725679 --- /dev/null +++ b/docs_src/troubleshooting/tutorial002.py @@ -0,0 +1,15 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + + +@mcp.tool(name="forecast") +def forecast_today(city: str) -> str: + """Today's forecast for one city.""" + return f"{city}: Rain." + + +@mcp.tool(name="forecast") # Same name. This registration is dropped. +def forecast_hourly(city: str, hours: int) -> str: + """The next few hours for one city.""" + return f"{city}: Rain for {hours}h." diff --git a/docs_src/troubleshooting/tutorial003.py b/docs_src/troubleshooting/tutorial003.py new file mode 100644 index 0000000000..e2e07f688f --- /dev/null +++ b/docs_src/troubleshooting/tutorial003.py @@ -0,0 +1,12 @@ +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + + +@mcp.tool() +def forecast(city: str) -> str: + """Today's forecast for one city.""" + return f"{city}: Rain." + + +app = mcp.streamable_http_app() diff --git a/docs_src/troubleshooting/tutorial004.py b/docs_src/troubleshooting/tutorial004.py new file mode 100644 index 0000000000..b78fa1d94e --- /dev/null +++ b/docs_src/troubleshooting/tutorial004.py @@ -0,0 +1,18 @@ +from mcp.server import MCPServer +from mcp.server.transport_security import TransportSecuritySettings + +mcp = MCPServer("Weather") + + +@mcp.tool() +def forecast(city: str) -> str: + """Today's forecast for one city.""" + return f"{city}: Rain." + + +app = mcp.streamable_http_app( + transport_security=TransportSecuritySettings( + allowed_hosts=["mcp.example.com", "mcp.example.com:*"], + allowed_origins=["https://app.example.com"], + ) +) diff --git a/docs_src/troubleshooting/tutorial005.py b/docs_src/troubleshooting/tutorial005.py new file mode 100644 index 0000000000..ca990da7db --- /dev/null +++ b/docs_src/troubleshooting/tutorial005.py @@ -0,0 +1,16 @@ +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server import MCPServer + +mcp = MCPServer("Weather") + + +@mcp.tool() +def forecast(city: str) -> str: + """Today's forecast for one city.""" + return f"{city}: Rain." + + +# The mount works. The MCP app's own lifespan never runs. +app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())]) diff --git a/docs_src/troubleshooting/tutorial006.py b/docs_src/troubleshooting/tutorial006.py new file mode 100644 index 0000000000..2c995f56a5 --- /dev/null +++ b/docs_src/troubleshooting/tutorial006.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel + +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Bistro") + + +class Confirmation(BaseModel): + confirm: bool + + +@mcp.tool() +async def book_table(date: str, ctx: Context) -> str: + """Book a table at the bistro.""" + result = await ctx.elicit(f"Book a table for {date}?", schema=Confirmation) + if result.action == "accept" and result.data.confirm: + return f"Booked for {date}." + return "No booking made." diff --git a/docs_src/troubleshooting/tutorial007.py b/docs_src/troubleshooting/tutorial007.py new file mode 100644 index 0000000000..051c29c051 --- /dev/null +++ b/docs_src/troubleshooting/tutorial007.py @@ -0,0 +1,25 @@ +from typing import Annotated + +from pydantic import BaseModel + +from mcp.server import MCPServer +from mcp.server.mcpserver import Elicit, Resolve + +mcp = MCPServer("Bistro") + + +class Confirmation(BaseModel): + confirm: bool + + +async def ask_to_confirm(date: str) -> Elicit[Confirmation]: + """Resolver: ask the user to confirm the booking.""" + return Elicit(f"Book a table for {date}?", Confirmation) + + +@mcp.tool() +async def book_table(date: str, answer: Annotated[Confirmation, Resolve(ask_to_confirm)]) -> str: + """Book a table at the bistro.""" + if answer.confirm: + return f"Booked for {date}." + return "No booking made." diff --git a/docs_src/troubleshooting/tutorial008.py b/docs_src/troubleshooting/tutorial008.py new file mode 100644 index 0000000000..1779cffd38 --- /dev/null +++ b/docs_src/troubleshooting/tutorial008.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel + +from mcp.server import MCPServer +from mcp.server.mcpserver import Context + +mcp = MCPServer("Bistro") + + +class Confirmation(BaseModel): + confirm: bool + + +@mcp.tool() +async def book_table(date: str, ctx: Context) -> str: + """Book a table at the bistro.""" + result = await ctx.elicit(f"Book a table for {date}?", schema=Confirmation) + if result.action == "accept" and result.data.confirm: + return f"Booked for {date}." + return "No booking made." + + +# Stateless HTTP: every request is its own world. No channel back to the client. +app = mcp.streamable_http_app(stateless_http=True) diff --git a/examples/README.md b/examples/README.md index 4bfa140bdf..68c8ac9088 100644 --- a/examples/README.md +++ b/examples/README.md @@ -16,7 +16,7 @@ - [`clients/`](clients/) and the remaining [`servers/`](servers/) directories (`simple-*`, `sse-polling-demo`, `structured-output-lowlevel`) — standalone v1-era projects retained pending consolidation into `stories/` (the - `simple-auth` pair is still linked from `docs/advanced/`). + `simple-auth` pair is still linked from `docs/run/authorization.md` and `docs/client/oauth-clients.md`). For real-world servers see the [servers repository](https://github.com/modelcontextprotocol/servers). diff --git a/examples/stories/subscriptions/README.md b/examples/stories/subscriptions/README.md index 22b947ba3e..c7f1a44369 100644 --- a/examples/stories/subscriptions/README.md +++ b/examples/stories/subscriptions/README.md @@ -56,5 +56,5 @@ uv run python -m stories.subscriptions.client --http --server server_lowlevel ## See also `streaming/` (request-scoped notifications), `events/` (the events extension -on top of this channel, deferred), and `docs/advanced/subscriptions.md` (the +on top of this channel, deferred), and `docs/handlers/subscriptions.md` (the narrative version). diff --git a/mkdocs.yml b/mkdocs.yml index fda7647141..5da05cc42a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,48 +12,57 @@ site_url: https://py.sdk.modelcontextprotocol.io/v2/ nav: - MCP Python SDK: index.md - - Installation: installation.md - - Tutorial - User Guide: - - tutorial/index.md - - First steps: tutorial/first-steps.md - - Tools: tutorial/tools.md - - Structured Output: tutorial/structured-output.md - - Resources: tutorial/resources.md - - Prompts: tutorial/prompts.md - - The Context: tutorial/context.md - - Dependencies: tutorial/dependencies.md - - Handling errors: tutorial/handling-errors.md - - Lifespan: tutorial/lifespan.md - - Media: tutorial/media.md - - Completions: tutorial/completions.md - - Elicitation: tutorial/elicitation.md - - Progress: tutorial/progress.md - - Logging: tutorial/logging.md - - Testing: tutorial/testing.md + - Get started: + - get-started/index.md + - Installation: get-started/installation.md + - First steps: get-started/first-steps.md + - Connect to a real host: get-started/real-host.md + - Testing: get-started/testing.md + - Servers: + - servers/index.md + - Tools: servers/tools.md + - Structured Output: servers/structured-output.md + - Resources: servers/resources.md + - URI templates: servers/uri-templates.md + - Prompts: servers/prompts.md + - Completions: servers/completions.md + - "Images, audio & icons": servers/media.md + - Handling errors: servers/handling-errors.md + - Inside your handler: + - handlers/index.md + - The Context: handlers/context.md + - Dependencies: handlers/dependencies.md + - Lifespan: handlers/lifespan.md + - Elicitation: handlers/elicitation.md + - Multi-round-trip requests: handlers/multi-round-trip.md + - Progress: handlers/progress.md + - Logging: handlers/logging.md + - Subscriptions: handlers/subscriptions.md - Running your server: - run/index.md - - ASGI: run/asgi.md - - The Client: + - Add to an existing app: run/asgi.md + - Deploy & scale: run/deploy.md + - Authorization: run/authorization.md + - OpenTelemetry: run/opentelemetry.md + - Serving legacy clients: run/legacy-clients.md + - Clients: - client/index.md - - Client callbacks: client/callbacks.md - - Client transports: client/transports.md - - Protocol versions: client/protocol-versions.md + - Callbacks: client/callbacks.md + - Transports: client/transports.md + - OAuth: client/oauth-clients.md + - Identity assertion: client/identity-assertion.md + - Multiple servers: client/session-groups.md + - Caching: client/caching.md + - Protocol versions: protocol-versions.md + - Deprecated features: deprecated.md - Advanced: - - Multi-round-trip requests: advanced/multi-round-trip.md + - advanced/index.md - The low-level Server: advanced/low-level-server.md - - URI templates: advanced/uri-templates.md - Pagination: advanced/pagination.md - - Caching hints: advanced/caching.md - - Subscriptions: advanced/subscriptions.md - Middleware: advanced/middleware.md - Extensions: advanced/extensions.md - MCP Apps: advanced/apps.md - - OpenTelemetry: advanced/opentelemetry.md - - Authorization: advanced/authorization.md - - OAuth clients: advanced/oauth-clients.md - - Identity assertion: advanced/identity-assertion.md - - Session groups: advanced/session-groups.md - - Deprecated features: advanced/deprecated.md + - Troubleshooting: troubleshooting.md - Migration Guide: migration.md - API Reference: api/ diff --git a/pyproject.toml b/pyproject.toml index 7b947588fe..c46f81d8d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,7 +80,7 @@ docs = [ "mkdocs-gen-files>=0.5.0", "mkdocs-glightbox>=0.4.0", "mkdocs-literate-nav>=0.6.1", - "mkdocs-material[imaging]>=9.5.45", + "mkdocs-material[imaging]>=9.7.0", "mkdocstrings-python>=2.0.1", ] codegen = ["datamodel-code-generator==0.57.0"] diff --git a/tests/client/test_client_caching.py b/tests/client/test_client_caching.py index 708d83db4a..1feb34038d 100644 --- a/tests/client/test_client_caching.py +++ b/tests/client/test_client_caching.py @@ -981,7 +981,7 @@ async def on_message(message: IncomingMessage) -> None: async def test_the_modern_in_process_path_drops_the_eviction_notification() -> None: """Pins the documented gap: the default in-process path (DirectDispatcher) drops standalone notifications, so the warm entry survives. If this starts failing the - path gained delivery: flip the `docs/advanced/caching.md` caveat and the legacy-mode tests.""" + path gained delivery: flip the `docs/client/caching.md` caveat and the legacy-mode tests.""" fetches: list[str | None] = [] async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: diff --git a/tests/docs_src/test_authorization.py b/tests/docs_src/test_authorization.py index 4c7554ed75..cde0cea5fd 100644 --- a/tests/docs_src/test_authorization.py +++ b/tests/docs_src/test_authorization.py @@ -1,4 +1,4 @@ -"""`docs/advanced/authorization.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/run/authorization.md`: every claim the page makes, proved against the real SDK.""" import httpx import pytest diff --git a/tests/docs_src/test_caching.py b/tests/docs_src/test_caching.py index 58014879c7..2fafde0a1c 100644 --- a/tests/docs_src/test_caching.py +++ b/tests/docs_src/test_caching.py @@ -1,4 +1,4 @@ -"""`docs/advanced/caching.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/client/caching.md`: every claim the page makes, proved against the real SDK.""" from collections.abc import Mapping from typing import Any, cast diff --git a/tests/docs_src/test_completions.py b/tests/docs_src/test_completions.py index b1f5c18164..43b1262d57 100644 --- a/tests/docs_src/test_completions.py +++ b/tests/docs_src/test_completions.py @@ -1,4 +1,4 @@ -"""`docs/tutorial/completions.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/servers/completions.md`: every claim the page makes, proved against the real SDK.""" import pytest from inline_snapshot import snapshot diff --git a/tests/docs_src/test_context.py b/tests/docs_src/test_context.py index 2948b10f57..617d113b2b 100644 --- a/tests/docs_src/test_context.py +++ b/tests/docs_src/test_context.py @@ -1,4 +1,4 @@ -"""`docs/tutorial/context.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/handlers/context.md`: every claim the page makes, proved against the real SDK.""" import re diff --git a/tests/docs_src/test_dependencies.py b/tests/docs_src/test_dependencies.py index 06d8935853..6dba9277e4 100644 --- a/tests/docs_src/test_dependencies.py +++ b/tests/docs_src/test_dependencies.py @@ -1,4 +1,4 @@ -"""`docs/tutorial/dependencies.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/handlers/dependencies.md`: every claim the page makes, proved against the real SDK.""" from typing import Literal diff --git a/tests/docs_src/test_deploy.py b/tests/docs_src/test_deploy.py new file mode 100644 index 0000000000..dde5f648de --- /dev/null +++ b/tests/docs_src/test_deploy.py @@ -0,0 +1,230 @@ +"""`docs/run/deploy.md`: every claim the page makes, proved against the real SDK.""" + +import anyio +import httpx +import pytest +from mcp_types import ( + INVALID_PARAMS, + CallToolResult, + ElicitResult, + InputRequiredResult, + ResourceUpdatedNotification, + SubscriptionFilter, + SubscriptionsListenRequest, + SubscriptionsListenRequestParams, + SubscriptionsListenResult, + TextContent, +) + +from docs_src.deploy import tutorial001, tutorial002, tutorial003, tutorial004 +from mcp import Client, MCPError +from mcp.server import MCPServer +from mcp.server.mcpserver import Context, RequestStateSecurity +from mcp.server.subscriptions import InMemorySubscriptionBus + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + +_KEY = "0123456789abcdef0123456789abcdef" # 32 bytes: the smallest secret the SDK accepts. + +INITIALIZE = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "b", "version": "1"}}, +} +MCP_HEADERS = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"} + + +# -- the Host allowlist ---------------------------------------------------------------- + + +async def test_the_default_app_rejects_a_real_hostname_before_mcp_runs() -> None: + """The section's `!!! check`: without `transport_security=`, a deployed hostname gets the page's exact 421.""" + bare = MCPServer("Notes") + app = bare.streamable_http_app() + async with bare.session_manager.run(): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="https://api.example.com") as h: + response = await h.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS) + assert (response.status_code, response.text) == (421, "Invalid Host header") + + +async def test_the_allowlisted_app_serves_its_hostname_and_still_rejects_others() -> None: + """tutorial001: `allowed_hosts=` opens exactly the hostname you named, and nothing else.""" + transport = httpx.ASGITransport(app=tutorial001.app) + async with tutorial001.mcp.session_manager.run(): + async with httpx.AsyncClient(transport=transport, base_url="https://mcp.example.com") as http: + allowed = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS) + async with httpx.AsyncClient(transport=transport, base_url="https://api.example.com") as http: + rejected = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS) + assert allowed.status_code == 200 + assert allowed.headers["mcp-session-id"] + assert (rejected.status_code, rejected.text) == (421, "Invalid Host header") + + +# -- `requestState` across workers ----------------------------------------------------- + + +async def _first_round(client: Client, amount: int) -> str: + """Round one of `refund`: no answers yet, so the server returns the `InputRequiredResult`.""" + first = await client.session.call_tool("refund", {"amount": amount}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.request_state is not None + return first.request_state + + +async def _retry(client: Client, amount: int, token: str) -> CallToolResult | InputRequiredResult: + """The retry: same tool, same arguments, the elicited answer, and the echoed token.""" + return await client.session.call_tool( + "refund", + {"amount": amount}, + input_responses={"ok": ElicitResult(action="accept", content={"ok": True})}, + request_state=token, + allow_input_required=True, + ) + + +def _assert_frozen_rejection(exc: pytest.ExceptionInfo[MCPError]) -> None: + """The one wire shape every inbound `requestState` verification failure produces.""" + assert exc.value.error.code == INVALID_PARAMS + assert exc.value.error.message == "Invalid or expired requestState" + assert exc.value.error.data == {"reason": "invalid_request_state"} + + +async def test_a_retry_that_reaches_a_different_worker_is_rejected_by_default() -> None: + """tutorial002: two default servers hold two `os.urandom(32)` keys, so a cross-instance retry is refused.""" + worker_a = tutorial002.make_server() + worker_b = tutorial002.make_server() + + with anyio.fail_after(5): + async with Client(worker_a) as on_a, Client(worker_b) as on_b: + token = await _first_round(on_a, 120) + with pytest.raises(MCPError) as exc: + await _retry(on_b, 120, token) + # Land back on the worker that minted the token and the identical retry completes. + second = await _retry(on_a, 120, token) + + _assert_frozen_rejection(exc) + assert isinstance(second, CallToolResult) + assert second.content == [TextContent(type="text", text="refunded $120")] + + +async def test_a_refund_the_human_declined_is_not_issued() -> None: + """tutorial002/003: the second round reads the answer, so anything but an accepted ok is no refund.""" + server = tutorial002.make_server() + with anyio.fail_after(5): + async with Client(server) as client: + token = await _first_round(client, 120) + declined = await client.session.call_tool( + "refund", + {"amount": 120}, + input_responses={"ok": ElicitResult(action="decline")}, + request_state=token, + allow_input_required=True, + ) + assert isinstance(declined, CallToolResult) + assert declined.content == [TextContent(type="text", text="refund cancelled")] + + +async def test_a_shared_key_and_name_let_any_worker_finish_a_round_trip() -> None: + """tutorial003: instances built with the same key and the same name unseal what a sibling minted.""" + worker_a = tutorial003.make_server(_KEY) + worker_b = tutorial003.make_server(_KEY) + + with anyio.fail_after(5): + async with Client(worker_a) as on_a, Client(worker_b) as on_b: + token = await _first_round(on_a, 120) + second = await _retry(on_b, 120, token) + + assert isinstance(second, CallToolResult) + assert not second.is_error + assert second.content == [TextContent(type="text", text="refunded $120")] + + +async def test_a_shared_key_is_not_enough_without_a_shared_name() -> None: + """The `!!! warning`: the server name is the default `audience` claim, so keys alone don't cross instances.""" + + def named(name: str) -> MCPServer: + mcp = MCPServer(name, request_state_security=RequestStateSecurity(keys=[_KEY])) + + @mcp.tool() + async def refund(amount: int, ctx: Context) -> str | InputRequiredResult: + if ctx.input_responses is None: + return InputRequiredResult(input_requests={"ok": tutorial002.CONFIRM}, request_state="pending") + return f"refunded ${amount}" + + return mcp + + with anyio.fail_after(5): + async with Client(named("billing-1")) as on_one, Client(named("billing-2")) as on_two: + token = await _first_round(on_one, 120) + with pytest.raises(MCPError) as exc: + await _retry(on_two, 120, token) + # Same keys AND the same name: back on the instance that minted it, the retry completes. + second = await _retry(on_one, 120, token) + + _assert_frozen_rejection(exc) + assert isinstance(second, CallToolResult) + assert second.content == [TextContent(type="text", text="refunded $120")] + + +# -- change notifications across replicas ---------------------------------------------- + + +class _Stream: + """Collects a listen stream's frames and lets the test await arrival counts.""" + + def __init__(self) -> None: + self.received: list[object] = [] + self._arrival = anyio.Event() + + async def handler(self, message: object) -> None: + self.received.append(message) + self._arrival.set() + self._arrival = anyio.Event() + + async def wait_for(self, count: int) -> None: + with anyio.fail_after(5): + while len(self.received) < count: + await self._arrival.wait() + + +async def test_one_bus_carries_a_publish_on_one_replica_to_a_stream_on_another() -> None: + """tutorial004: a `subscriptions/listen` stream on replica A hears a publish that happened on replica B.""" + bus = InMemorySubscriptionBus() + replica_a = tutorial004.make_server(bus) + replica_b = tutorial004.make_server(bus) + stream = _Stream() + + with anyio.fail_after(10): + await _listen_and_edit(replica_a, replica_b, stream) + + +async def _listen_and_edit(replica_a: MCPServer, replica_b: MCPServer, stream: _Stream) -> None: + """Open a listen stream on replica A, edit on replica B, and wait for the update to cross the bus.""" + async with ( + Client(replica_a, mode="2026-07-28", message_handler=stream.handler) as on_a, + Client(replica_b) as on_b, + ): + async with anyio.create_task_group() as tg: + + async def listen() -> None: + await on_a.session.send_request( + SubscriptionsListenRequest( + params=SubscriptionsListenRequestParams( + notifications=SubscriptionFilter(resource_subscriptions=["note://todo"]) + ) + ), + SubscriptionsListenResult, + ) + + tg.start_soon(listen) + await stream.wait_for(1) # the acknowledgment: the stream is live on replica A + + await on_b.call_tool("edit_note", {"name": "todo", "text": "water plants"}) + await stream.wait_for(2) + updated = stream.received[1] + assert isinstance(updated, ResourceUpdatedNotification) + assert updated.params.uri == "note://todo" + + tg.cancel_scope.cancel() diff --git a/tests/docs_src/test_deprecated.py b/tests/docs_src/test_deprecated.py index 892a8f3627..090ca61643 100644 --- a/tests/docs_src/test_deprecated.py +++ b/tests/docs_src/test_deprecated.py @@ -1,4 +1,4 @@ -"""`docs/advanced/deprecated.md`: the page's behavioural claims, executed against the live SDK. +"""`docs/deprecated.md`: the page's behavioural claims, executed against the live SDK. This chapter has no `docs_src/` example by design: it is the one page allowed to name the deprecated methods, and a runnable example would teach exactly what the page tells diff --git a/tests/docs_src/test_elicitation.py b/tests/docs_src/test_elicitation.py index a28f1087fc..17933816bd 100644 --- a/tests/docs_src/test_elicitation.py +++ b/tests/docs_src/test_elicitation.py @@ -1,4 +1,4 @@ -"""`docs/tutorial/elicitation.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/handlers/elicitation.md`: every claim the page makes, proved against the real SDK.""" from typing import Literal diff --git a/tests/docs_src/test_first_steps.py b/tests/docs_src/test_first_steps.py index 2b1674a471..11989850a2 100644 --- a/tests/docs_src/test_first_steps.py +++ b/tests/docs_src/test_first_steps.py @@ -1,4 +1,4 @@ -"""`docs/tutorial/first-steps.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/get-started/first-steps.md`: every claim the page makes, proved against the real SDK.""" import pytest from inline_snapshot import snapshot diff --git a/tests/docs_src/test_handling_errors.py b/tests/docs_src/test_handling_errors.py index 1a76a7bb77..0c2629169c 100644 --- a/tests/docs_src/test_handling_errors.py +++ b/tests/docs_src/test_handling_errors.py @@ -1,4 +1,4 @@ -"""`docs/tutorial/handling-errors.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/servers/handling-errors.md`: every claim the page makes, proved against the real SDK.""" import pytest from mcp_types import INVALID_PARAMS, ErrorData, TextContent, TextResourceContents diff --git a/tests/docs_src/test_identity_assertion.py b/tests/docs_src/test_identity_assertion.py index afcfd83290..adfe23bad8 100644 --- a/tests/docs_src/test_identity_assertion.py +++ b/tests/docs_src/test_identity_assertion.py @@ -1,4 +1,4 @@ -"""`docs/advanced/identity-assertion.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/client/identity-assertion.md`: every claim the page makes, proved against the real SDK.""" import inspect from urllib.parse import parse_qsl diff --git a/tests/docs_src/test_legacy_clients.py b/tests/docs_src/test_legacy_clients.py new file mode 100644 index 0000000000..0dfd0af937 --- /dev/null +++ b/tests/docs_src/test_legacy_clients.py @@ -0,0 +1,136 @@ +"""`docs/run/legacy-clients.md`: every claim the page makes, proved against the real SDK.""" + +import inspect + +import httpx +import pytest +from mcp_types import INVALID_REQUEST, ResourceUpdatedNotification, TextContent + +from docs_src.legacy_clients import tutorial001, tutorial002, tutorial003 +from mcp import Client, MCPError +from mcp.client.streamable_http import streamable_http_client +from mcp.server import MCPServer + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + +INITIALIZE = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "b", "version": "1"}}, +} +LIST_TOOLS = {"jsonrpc": "2.0", "id": 2, "method": "tools/list"} +MCP_HEADERS = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"} +URL = "http://localhost:8000/mcp" + + +async def test_one_resolve_tool_serves_a_legacy_and_a_modern_client_at_once( + capsys: pytest.CaptureFixture[str], +) -> None: + """tutorial001's `main()`, exactly as the page renders it: two eras of client, one server, one answer.""" + await tutorial001.main() + assert capsys.readouterr().out == ( + """2025-11-25 {'result': "Reserved 2 of 'Dune'."}\n2026-07-28 {'result': "Reserved 2 of 'Dune'."}\n""" + ) + + +async def test_neither_era_of_client_sees_the_resolved_parameter() -> None: + """tutorial001: there is one tool schema. The `Resolve`-filled parameter is hidden from both eras.""" + async with Client(tutorial001.mcp, mode="legacy") as legacy, Client(tutorial001.mcp) as modern: + for client in (legacy, modern): + (tool,) = (await client.list_tools()).tools + assert set(tool.input_schema["properties"]) == {"title"} + + +def test_streamable_http_app_has_no_era_knob() -> None: + """The opener: nothing in `streamable_http_app()`'s signature selects, rejects, or configures an era.""" + parameters = set(inspect.signature(MCPServer.streamable_http_app).parameters) - {"self"} + assert parameters == { + "streamable_http_path", + "json_response", + "stateless_http", + "event_store", + "retry_interval", + "transport_security", + "host", + } + + +async def test_a_legacy_session_is_minted_in_process_and_a_stray_session_id_is_a_404() -> None: + """The cost section: a legacy `initialize` gets an `Mcp-Session-Id`, and a request naming a session + this process never minted gets a `404`. That miss is exactly what a load balancer without sticky + routing produces.""" + app = MCPServer("Bookshop").streamable_http_app() + async with ( + app.router.lifespan_context(app), + httpx.ASGITransport(app) as transport, + httpx.AsyncClient(transport=transport, base_url="http://localhost:8000") as http, + ): + opened = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS) + assert opened.status_code == 200 + assert opened.headers["mcp-session-id"] + + stray = await http.post("/mcp", json=LIST_TOOLS, headers={**MCP_HEADERS, "Mcp-Session-Id": 32 * "f"}) + assert stray.status_code == 404 + + +async def test_stateless_http_never_mints_a_session() -> None: + """The `stateless_http=True` section: the same legacy `initialize` no longer gets an `Mcp-Session-Id`.""" + app = MCPServer("Bookshop").streamable_http_app(stateless_http=True) + async with ( + app.router.lifespan_context(app), + httpx.ASGITransport(app) as transport, + httpx.AsyncClient(transport=transport, base_url="http://localhost:8000") as http, + ): + opened = await http.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS) + assert opened.status_code == 200 + assert "mcp-session-id" not in opened.headers + + +async def test_stateless_http_kills_the_legacy_back_channel_and_only_the_legacy_one() -> None: + """tutorial002: over the same `stateless_http=True` app, the modern client still gets its answer and + the legacy client's call fails as the top-level `MCPError` the `!!! check` quotes.""" + async with ( + tutorial002.app.router.lifespan_context(tutorial002.app), + httpx.ASGITransport(tutorial002.app) as transport, + httpx.AsyncClient(transport=transport) as http, + ): + modern_target = streamable_http_client(URL, http_client=http) + async with Client(modern_target, elicitation_callback=tutorial001.answer) as modern: + assert modern.protocol_version == "2026-07-28" + result = await modern.call_tool("reserve", {"title": "Dune"}) + assert result.content == [TextContent(type="text", text="Reserved 2 of 'Dune'.")] + + legacy_target = streamable_http_client(URL, http_client=http) + async with Client(legacy_target, mode="legacy", elicitation_callback=tutorial001.answer) as legacy: + assert legacy.protocol_version == "2025-11-25" + with pytest.raises(MCPError) as exc_info: # pragma: no branch + await legacy.call_tool("reserve", {"title": "Dune"}) + assert exc_info.value.error.code == INVALID_REQUEST + assert exc_info.value.error.message == ( + "Cannot send 'elicitation/create': this transport context has no back-channel for server-initiated requests." + ) + + +async def test_the_legacy_notification_verb_reaches_a_legacy_client() -> None: + """tutorial003: `ctx.session.send_resource_updated` lands on the legacy client's standalone stream.""" + received: list[object] = [] + + async def on_message(message: object) -> None: + received.append(message) + + async with Client(tutorial003.mcp, mode="legacy", message_handler=on_message) as client: + result = await client.call_tool("restock", {"title": "Dune", "copies": 2}) + assert not result.is_error + (notification,) = received + assert isinstance(notification, ResourceUpdatedNotification) + assert notification.params.uri == "stock://Dune" + + +async def test_calling_both_notification_verbs_is_safe_on_both_eras() -> None: + """tutorial003: the two-line fork never errors, whichever era the caller is on.""" + async with Client(tutorial003.mcp, mode="legacy") as legacy, Client(tutorial003.mcp) as modern: + for client in (legacy, modern): + result = await client.call_tool("restock", {"title": "Dune", "copies": 1}) + assert not result.is_error diff --git a/tests/docs_src/test_lifespan.py b/tests/docs_src/test_lifespan.py index d78764fd64..ec6e98d7de 100644 --- a/tests/docs_src/test_lifespan.py +++ b/tests/docs_src/test_lifespan.py @@ -1,4 +1,4 @@ -"""`docs/tutorial/lifespan.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/handlers/lifespan.md`: every claim the page makes, proved against the real SDK.""" import pytest from inline_snapshot import snapshot diff --git a/tests/docs_src/test_logging.py b/tests/docs_src/test_logging.py index fa4b995c6e..bed5c234b6 100644 --- a/tests/docs_src/test_logging.py +++ b/tests/docs_src/test_logging.py @@ -1,4 +1,4 @@ -"""`docs/tutorial/logging.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/handlers/logging.md`: every claim the page makes, proved against the real SDK.""" import logging diff --git a/tests/docs_src/test_media.py b/tests/docs_src/test_media.py index 96ea42a0b1..2ef5eb7e54 100644 --- a/tests/docs_src/test_media.py +++ b/tests/docs_src/test_media.py @@ -1,4 +1,4 @@ -"""`docs/tutorial/media.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/servers/media.md`: every claim the page makes, proved against the real SDK.""" import base64 diff --git a/tests/docs_src/test_mrtr.py b/tests/docs_src/test_mrtr.py index cf7842b0af..50a9e53d9d 100644 --- a/tests/docs_src/test_mrtr.py +++ b/tests/docs_src/test_mrtr.py @@ -1,4 +1,4 @@ -"""`docs/advanced/multi-round-trip.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/handlers/multi-round-trip.md`: every claim the page makes, proved against the real SDK.""" import pytest from inline_snapshot import snapshot diff --git a/tests/docs_src/test_oauth_clients.py b/tests/docs_src/test_oauth_clients.py index a85eab388f..abd45e0168 100644 --- a/tests/docs_src/test_oauth_clients.py +++ b/tests/docs_src/test_oauth_clients.py @@ -1,4 +1,4 @@ -"""`docs/advanced/oauth-clients.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/client/oauth-clients.md`: every claim the page makes, proved against the real SDK.""" import inspect diff --git a/tests/docs_src/test_opentelemetry.py b/tests/docs_src/test_opentelemetry.py index 00f3af8aac..17b153c265 100644 --- a/tests/docs_src/test_opentelemetry.py +++ b/tests/docs_src/test_opentelemetry.py @@ -1,4 +1,4 @@ -"""`docs/advanced/opentelemetry.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/run/opentelemetry.md`: every claim the page makes, proved against the real SDK.""" import pytest from logfire.testing import CaptureLogfire diff --git a/tests/docs_src/test_progress.py b/tests/docs_src/test_progress.py index 45cc4df8eb..a05577fbad 100644 --- a/tests/docs_src/test_progress.py +++ b/tests/docs_src/test_progress.py @@ -1,4 +1,4 @@ -"""`docs/tutorial/progress.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/handlers/progress.md`: every claim the page makes, proved against the real SDK.""" import inspect diff --git a/tests/docs_src/test_prompts.py b/tests/docs_src/test_prompts.py index 1cbab3af0a..3b0ad571a0 100644 --- a/tests/docs_src/test_prompts.py +++ b/tests/docs_src/test_prompts.py @@ -1,4 +1,4 @@ -"""`docs/tutorial/prompts.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/servers/prompts.md`: every claim the page makes, proved against the real SDK.""" import traceback diff --git a/tests/docs_src/test_protocol_versions.py b/tests/docs_src/test_protocol_versions.py index f8e5b19f16..73366a9840 100644 --- a/tests/docs_src/test_protocol_versions.py +++ b/tests/docs_src/test_protocol_versions.py @@ -1,4 +1,4 @@ -"""`docs/client/protocol-versions.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/protocol-versions.md`: every claim the page makes, proved against the real SDK.""" import re diff --git a/tests/docs_src/test_real_host.py b/tests/docs_src/test_real_host.py new file mode 100644 index 0000000000..36b0670d0d --- /dev/null +++ b/tests/docs_src/test_real_host.py @@ -0,0 +1,54 @@ +"""`docs/get-started/real-host.md`: the one server every host section on the page launches, driven in memory.""" + +import pytest +from inline_snapshot import snapshot +from mcp_types import TextContent, TextResourceContents + +from docs_src.real_host import tutorial001 +from mcp import Client + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_the_host_sees_exactly_what_the_decorators_registered() -> None: + """tutorial001: `tools/list` is what a host hands its model. Name, description, and schema come from the code.""" + async with Client(tutorial001.mcp) as client: + search, get = (await client.list_tools()).tools + assert search.name == "search_books" + assert search.description == "Search the catalog by title or author." + assert search.input_schema == snapshot( + { + "type": "object", + "properties": {"query": {"title": "Query", "type": "string"}}, + "required": ["query"], + "title": "search_booksArguments", + } + ) + assert get.name == "get_author" + + +async def test_a_tool_call_round_trips_the_way_a_host_drives_it() -> None: + """tutorial001: `tools/call` sends arguments in; the function's return value comes back as the result.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("search_books", {"query": "gibson"}) + assert not result.is_error + assert result.structured_content == {"result": ["Neuromancer"]} + + author = await client.call_tool("get_author", {"title": "Dune"}) + assert author.content == [TextContent(type="text", text="Frank Herbert")] + + +async def test_the_resource_a_host_can_attach_to_context() -> None: + """tutorial001: `catalog://titles` has no parameter, so it is a concrete, listable, readable resource.""" + async with Client(tutorial001.mcp) as client: + (resource,) = (await client.list_resources()).resources + assert str(resource.uri) == "catalog://titles" + result = await client.read_resource("catalog://titles") + assert result.contents == [ + TextResourceContents( + uri="catalog://titles", + mime_type="text/plain", + text="Dune\nNeuromancer\nThe Left Hand of Darkness", + ) + ] diff --git a/tests/docs_src/test_resources.py b/tests/docs_src/test_resources.py index 85e827833d..3fbde00fde 100644 --- a/tests/docs_src/test_resources.py +++ b/tests/docs_src/test_resources.py @@ -1,4 +1,4 @@ -"""`docs/tutorial/resources.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/servers/resources.md`: every claim the page makes, proved against the real SDK.""" import base64 diff --git a/tests/docs_src/test_session_groups.py b/tests/docs_src/test_session_groups.py index e6fee8ce92..79721c6138 100644 --- a/tests/docs_src/test_session_groups.py +++ b/tests/docs_src/test_session_groups.py @@ -1,4 +1,4 @@ -"""`docs/advanced/session-groups.md`: every claim the page makes, proved against the real SDK. +"""`docs/client/session-groups.md`: every claim the page makes, proved against the real SDK. `connect_to_server` opens a real transport (a subprocess or a socket), so these tests drive the exact same aggregation path through `connect_with_session` with in-memory sessions instead. diff --git a/tests/docs_src/test_structured_output.py b/tests/docs_src/test_structured_output.py index 795b0ccf1e..c0b900d2d3 100644 --- a/tests/docs_src/test_structured_output.py +++ b/tests/docs_src/test_structured_output.py @@ -1,4 +1,4 @@ -"""`docs/tutorial/structured-output.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/servers/structured-output.md`: every claim the page makes, proved against the real SDK.""" import pytest from inline_snapshot import snapshot diff --git a/tests/docs_src/test_subscriptions.py b/tests/docs_src/test_subscriptions.py index cdfe1d9354..b664afe983 100644 --- a/tests/docs_src/test_subscriptions.py +++ b/tests/docs_src/test_subscriptions.py @@ -1,4 +1,4 @@ -"""`docs/advanced/subscriptions.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/handlers/subscriptions.md`: every claim the page makes, proved against the real SDK.""" from typing import Any diff --git a/tests/docs_src/test_testing.py b/tests/docs_src/test_testing.py index 035f72312f..5ab73e2e94 100644 --- a/tests/docs_src/test_testing.py +++ b/tests/docs_src/test_testing.py @@ -1,4 +1,4 @@ -"""`docs/tutorial/testing.md`: the page's own test, run for real. +"""`docs/get-started/testing.md`: the page's own test, run for real. The page shows this test against a `server.py` next to it; here the import path is the only difference. diff --git a/tests/docs_src/test_tools.py b/tests/docs_src/test_tools.py index 08e2a5ca69..c4051794f4 100644 --- a/tests/docs_src/test_tools.py +++ b/tests/docs_src/test_tools.py @@ -1,4 +1,4 @@ -"""`docs/tutorial/tools.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/servers/tools.md`: every claim the page makes, proved against the real SDK.""" import pytest from inline_snapshot import snapshot diff --git a/tests/docs_src/test_troubleshooting.py b/tests/docs_src/test_troubleshooting.py new file mode 100644 index 0000000000..ee53c2649e --- /dev/null +++ b/tests/docs_src/test_troubleshooting.py @@ -0,0 +1,281 @@ +"""`docs/troubleshooting.md`: every error string the page names, reproduced against the real SDK.""" + +import logging +from typing import Any + +import httpx +import pytest +from mcp_types import ( + INVALID_PARAMS, + INVALID_REQUEST, + MISSING_REQUIRED_CLIENT_CAPABILITY, + ElicitRequestParams, + ElicitResult, + ErrorData, + TextContent, +) + +from docs_src.troubleshooting import ( + tutorial001, + tutorial002, + tutorial003, + tutorial004, + tutorial005, + tutorial006, + tutorial007, + tutorial008, +) +from mcp import Client, MCPError +from mcp.client import ClientRequestContext +from mcp.client.streamable_http import streamable_http_client +from mcp.server import MCPServer +from mcp.server.mcpserver import RequestStateSecurity + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + +INITIALIZE = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "b", "version": "1"}}, +} +MCP_HEADERS = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"} + + +async def _confirm(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + """The page's one `elicitation_callback`: always accept the booking.""" + return ElicitResult(action="accept", content={"confirm": True}) + + +async def test_an_error_leaving_the_async_with_block_arrives_wrapped_in_an_exception_group() -> None: + """The `unhandled errors in a TaskGroup` entry: anyio group-wraps whatever escapes the block.""" + with pytest.raises(Exception) as exc_info: + async with Client(tutorial001.mcp) as client: + await client.read_resource("weather://Atlantis") + assert not isinstance(exc_info.value, MCPError) + assert exc_info.group_contains(MCPError, match=r"^No forecast for 'Atlantis'\.$") + + +async def test_the_same_error_caught_inside_the_block_is_the_bare_mcp_error() -> None: + """The fix on the page: `except MCPError` inside the `async with` never sees an `ExceptionGroup`.""" + async with Client(tutorial001.mcp) as client: + with pytest.raises(MCPError) as exc_info: + await client.read_resource("weather://Atlantis") + assert str(exc_info.value) == "No forecast for 'Atlantis'." + assert exc_info.value.error.code == INVALID_PARAMS + + +async def test_a_client_outside_its_async_with_refuses_every_call() -> None: + """`Client(...)` only constructs. Nothing connects until `async with`, so every call refuses.""" + client = Client(tutorial001.mcp) + with pytest.raises(RuntimeError, match="^Client must be used within an async context manager$"): + await client.list_tools() + + +async def test_a_failing_tool_returns_is_error_true_instead_of_raising() -> None: + """The `Error executing tool` entry: it is a result, not an exception. Nothing to `except`.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("forecast", {"city": "Atlantis"}) + assert result.is_error + assert result.content == [ + TextContent(type="text", text="Error executing tool forecast: No forecast for 'Atlantis'.") + ] + + +async def test_an_unknown_tool_is_the_same_kind_of_result() -> None: + """`Unknown tool: ` travels the same `is_error=True` path as a failing tool.""" + async with Client(tutorial001.mcp) as client: + result = await client.call_tool("get_forecast", {"city": "London"}) + assert result.is_error + assert result.content == [TextContent(type="text", text="Unknown tool: get_forecast")] + + +async def test_the_tool_decorator_without_parentheses_raises_at_import_time() -> None: + """`@mcp.tool` (no parentheses) hands the function itself to `name=`; the SDK refuses immediately.""" + mcp = MCPServer("Weather") + undecorated: Any = mcp.tool + with pytest.raises(TypeError, match=r"Use @tool\(\) instead of @tool"): + + @undecorated + def forecast(city: str) -> None: + """Today's forecast for one city. Never called: the decoration itself is what raises.""" + + +async def test_a_duplicate_tool_name_keeps_the_first_and_drops_the_second() -> None: + """tutorial002: `tools/list` reports one `forecast`, and it is the first registration that won.""" + async with Client(tutorial002.mcp) as client: + (tool,) = (await client.list_tools()).tools + assert tool.name == "forecast" + assert tool.description == "Today's forecast for one city." + + +async def test_a_duplicate_registration_logs_tool_already_exists(caplog: pytest.LogCaptureFixture) -> None: + """The only signal for a dropped duplicate is the `Tool already exists:` warning in the server log.""" + with caplog.at_level(logging.WARNING, logger="mcp.server.mcpserver.tools.tool_manager"): + + @tutorial002.mcp.tool(name="forecast") + def forecast_weekly(city: str) -> None: + """The week ahead for one city. Never called: it is the duplicate that gets dropped.""" + + assert "Tool already exists: forecast" in caplog.messages + + +async def test_the_default_streamable_http_app_answers_a_real_hostname_with_421( + caplog: pytest.LogCaptureFixture, +) -> None: + """tutorial003: one 421, three spellings. The page presents all three as the same event.""" + transport = httpx.ASGITransport(app=tutorial003.app) + async with tutorial003.mcp.session_manager.run(): + # What curl (or the reverse proxy's access log) shows: the status and the plain-text body. + async with httpx.AsyncClient(transport=transport, base_url="http://mcp.example.com") as raw: + with caplog.at_level(logging.WARNING, logger="mcp.server.transport_security"): + response = await raw.post("/mcp", json=INITIALIZE, headers=MCP_HEADERS) + assert (response.status_code, response.text) == (421, "Invalid Host header") + # No `Content-Type: application/json`, which is exactly why the python client cannot show the body. + assert response.headers.get("content-type") is None + # What the server operator finds by grepping the server log. + assert "Invalid Host header: mcp.example.com" in caplog.messages + # What the python `Client` raises instead: the generic stand-in, wrapped by the task group. + async with httpx.AsyncClient(transport=transport) as http_client: + client = Client(streamable_http_client("http://mcp.example.com/mcp", http_client=http_client)) + with pytest.raises(Exception) as exc_info: # pragma: no branch + await client.__aenter__() # the connection attempt itself is what fails + assert not isinstance(exc_info.value, MCPError) + assert exc_info.group_contains(MCPError, match="^Server returned an error response$") + + +async def test_an_allowlisted_hostname_connects_and_calls_a_tool() -> None: + """tutorial004: `transport_security=` names the deployed hostname, and the same client connects.""" + transport = httpx.ASGITransport(app=tutorial004.app) + async with tutorial004.mcp.session_manager.run(): + async with httpx.AsyncClient(transport=transport) as http_client: + allowed = streamable_http_client("http://mcp.example.com/mcp", http_client=http_client) + async with Client(allowed) as c: # pragma: no branch + assert c.protocol_version == "2026-07-28" + result = await c.call_tool("forecast", {"city": "London"}) + assert result.structured_content == {"result": "London: Rain."} + + +async def test_a_mounted_app_without_a_lifespan_fails_on_the_first_request() -> None: + """tutorial005: Starlette never runs a mounted sub-app's lifespan, so nothing starts the manager.""" + transport = httpx.ASGITransport(app=tutorial005.app) + async with httpx.AsyncClient(transport=transport, base_url="http://127.0.0.1:8000") as http: + with pytest.raises(RuntimeError, match=r"Task group is not initialized\. Make sure to use run\(\)\."): + await http.post("/mcp") + + +async def test_a_session_id_the_server_never_issued_gets_a_404_session_not_found() -> None: + """`Session not found` is a 404 with a JSON-RPC body, so the python `Client` surfaces it verbatim.""" + mcp = MCPServer("Weather") + app = mcp.streamable_http_app() + async with mcp.session_manager.run(): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://127.0.0.1:8000") as h: + response = await h.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}, + headers={**MCP_HEADERS, "mcp-session-id": "deadbeef"}, + ) + assert response.status_code == 404 + assert response.headers["content-type"] == "application/json" + assert response.json() == {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Session not found"}} + + +async def test_ctx_elicit_at_2026_has_no_back_channel() -> None: + """tutorial006: at 2026-07-28 the server refuses to send `elicitation/create` at all.""" + async with Client(tutorial006.mcp) as client: + assert client.protocol_version == "2026-07-28" + with pytest.raises(MCPError) as exc_info: + await client.call_tool("book_table", {"date": "Friday"}) + assert exc_info.value.error == ErrorData( + code=INVALID_REQUEST, + message=( + "Cannot send 'elicitation/create': " + "this transport context has no back-channel for server-initiated requests." + ), + ) + + +async def test_an_elicitation_callback_does_not_fix_ctx_elicit_at_2026() -> None: + """The page's claim: registering the callback changes nothing. No request ever reaches the client.""" + async with Client(tutorial006.mcp, elicitation_callback=_confirm) as client: + with pytest.raises(MCPError, match="no back-channel for server-initiated requests"): + await client.call_tool("book_table", {"date": "Friday"}) + + +async def test_ctx_elicit_on_a_legacy_connection_works() -> None: + """The legacy aside: `ctx.elicit` is a server-to-client request, and only a legacy session has those.""" + async with Client(tutorial006.mcp, mode="legacy", elicitation_callback=_confirm) as client: + result = await client.call_tool("book_table", {"date": "Friday"}) + assert result.structured_content == {"result": "Booked for Friday."} + + +async def test_the_resolver_form_works_on_a_2026_connection() -> None: + """tutorial007: the fix. Same question, same callback, but the server returns it instead of calling back.""" + async with Client(tutorial007.mcp, elicitation_callback=_confirm) as client: + assert client.protocol_version == "2026-07-28" + result = await client.call_tool("book_table", {"date": "Friday"}) + assert result.structured_content == {"result": "Booked for Friday."} + + +async def test_the_resolver_form_without_a_callback_names_the_missing_capability() -> None: + """The `-32021` entry: the server refuses up front, and `data` names the capability to declare.""" + async with Client(tutorial007.mcp) as client: + with pytest.raises(MCPError) as exc_info: + await client.call_tool("book_table", {"date": "Friday"}) + assert exc_info.value.error == ErrorData( + code=MISSING_REQUIRED_CLIENT_CAPABILITY, + message=( + "Client did not declare the form elicitation capability required by resolver " + "'docs_src.troubleshooting.tutorial007:ask_to_confirm'" + ), + data={"requiredCapabilities": {"elicitation": {"form": {}}}}, + ) + + +async def test_a_legacy_ctx_elicit_without_a_callback_says_elicitation_not_supported() -> None: + """The `Elicitation not supported` entry: no `elicitation_callback` means nobody to ask.""" + async with Client(tutorial006.mcp, mode="legacy") as client: + with pytest.raises(MCPError) as exc_info: + await client.call_tool("book_table", {"date": "Friday"}) + assert exc_info.value.error == ErrorData(code=INVALID_REQUEST, message="Elicitation not supported") + + +async def test_ctx_elicit_over_stateless_http_has_no_back_channel() -> None: + """tutorial008: `stateless_http=True` leaves the server no channel to send `elicitation/create`.""" + transport = httpx.ASGITransport(app=tutorial008.app) + async with tutorial008.mcp.session_manager.run(): + async with httpx.AsyncClient(transport=transport) as http_client: + stateless = streamable_http_client("http://127.0.0.1:8000/mcp", http_client=http_client) + async with Client(stateless) as c: # pragma: no branch + with pytest.raises(MCPError) as exc_info: # pragma: no branch + await c.call_tool("book_table", {"date": "Friday"}) + assert exc_info.value.error == ErrorData( + code=INVALID_REQUEST, + message=( + "Cannot send 'elicitation/create': " + "this transport context has no back-channel for server-initiated requests." + ), + ) + + +async def test_a_request_state_the_server_did_not_mint_is_rejected(caplog: pytest.LogCaptureFixture) -> None: + """The wire message is deliberately frozen; the real reason goes only to the server log.""" + async with Client(tutorial001.mcp) as client: + with caplog.at_level(logging.WARNING, logger="mcp.server.request_state"): + with pytest.raises(MCPError) as exc_info: # pragma: no branch + await client.call_tool("forecast", {"city": "London"}, request_state="round-1-from-worker-a") + assert exc_info.value.error == ErrorData( + code=INVALID_PARAMS, message="Invalid or expired requestState", data={"reason": "invalid_request_state"} + ) + assert "requestState rejected on tools/call: malformed" in caplog.messages + + +async def test_a_short_request_state_key_is_rejected_at_construction() -> None: + """`RequestStateSecurity(keys=[...])` refuses anything under 32 bytes and says how to make one.""" + with pytest.raises(ValueError) as exc_info: + RequestStateSecurity(keys=[b"hunter2"]) + assert str(exc_info.value) == ( + "request-state keys must be at least 32 bytes of secret randomness; keys[0] is 7 bytes. " + 'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"' + ) diff --git a/tests/docs_src/test_uri_templates.py b/tests/docs_src/test_uri_templates.py index b90e099c19..4b2b6edaf1 100644 --- a/tests/docs_src/test_uri_templates.py +++ b/tests/docs_src/test_uri_templates.py @@ -1,4 +1,4 @@ -"""`docs/advanced/uri-templates.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/servers/uri-templates.md`: every claim the page makes, proved against the real SDK.""" from pathlib import Path diff --git a/tests/server/test_caching.py b/tests/server/test_caching.py index abfcfba975..0a6adc2aa1 100644 --- a/tests/server/test_caching.py +++ b/tests/server/test_caching.py @@ -168,7 +168,7 @@ async def test_every_page_of_a_paginated_list_carries_the_configured_scope() -> """Spec-mandated: the same `cacheScope` MUST apply to all pages of one list. The map is keyed by method, not cursor, so a handler that leaves scope unset gets the same scope on every page. (A handler that overrides the scope owns - that consistency itself - see `docs/advanced/caching.md`.)""" + that consistency itself - see `docs/client/caching.md`.)""" names = [f"r-{n}" for n in range(4)] async def list_resources( diff --git a/tests/test_examples.py b/tests/test_examples.py index f139f418a1..9236503a90 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -102,8 +102,12 @@ async def test_desktop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): find_examples( "README.md", "docs/index.md", - "docs/installation.md", - "docs/tutorial", + "docs/protocol-versions.md", + "docs/deprecated.md", + "docs/troubleshooting.md", + "docs/get-started", + "docs/servers", + "docs/handlers", "docs/run", "docs/client", "docs/advanced", diff --git a/uv.lock b/uv.lock index a1e8a7e356..2646eda9d5 100644 --- a/uv.lock +++ b/uv.lock @@ -1020,7 +1020,7 @@ docs = [ { name = "mkdocs-gen-files", specifier = ">=0.5.0" }, { name = "mkdocs-glightbox", specifier = ">=0.4.0" }, { name = "mkdocs-literate-nav", specifier = ">=0.6.1" }, - { name = "mkdocs-material", extras = ["imaging"], specifier = ">=9.5.45" }, + { name = "mkdocs-material", extras = ["imaging"], specifier = ">=9.7.0" }, { name = "mkdocstrings-python", specifier = ">=2.0.1" }, ] From e4d95e0d44496059dbbc62fc39a17f5db12336eb Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:01:30 +0100 Subject: [PATCH 045/100] docs: add a "What's new in v2" page (#3054) --- README.md | 4 +- docs/index.md | 2 + docs/migration.md | 30 +++++ docs/whats-new.md | 210 ++++++++++++++++++++++++++++++ docs_src/whats_new/__init__.py | 0 docs_src/whats_new/tutorial001.py | 37 ++++++ mkdocs.yml | 1 + tests/docs_src/test_whats_new.py | 65 +++++++++ tests/test_examples.py | 1 + 9 files changed, 348 insertions(+), 2 deletions(-) create mode 100644 docs/whats-new.md create mode 100644 docs_src/whats_new/__init__.py create mode 100644 docs_src/whats_new/tutorial001.py create mode 100644 tests/docs_src/test_whats_new.py diff --git a/README.md b/README.md index 1324ac57ef..3822976640 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,13 @@ > > **v1.x is the only stable release line and remains recommended for production.** It lives on the [`v1.x` branch](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x) and continues to receive critical bug fixes and security patches; see [the v1.x README](https://github.com/modelcontextprotocol/python-sdk/blob/v1.x/README.md) for its documentation. `pip` and `uv` don't select a pre-release unless you explicitly request one, so existing installs are unaffected. **If your package depends on `mcp`, add a `<2` upper bound to your version constraint (for example `mcp>=1.27,<2`) before the stable release lands.** > -> v2 is a major rework of the SDK, both to support the [2026-07-28 MCP specification release](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) and to fix long-standing architectural issues. See the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/) for what's changed. Stable v2 is targeted for 2026-07-27, alongside the spec release. Try the pre-releases and [tell us what breaks](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml) — or discuss in [#python-sdk-dev on the MCP Contributors Discord](https://discord.gg/6CSzBmMkjX). +> v2 is a major rework of the SDK, both to support the [2026-07-28 MCP specification release](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) and to fix long-standing architectural issues. See [What's new in v2](https://py.sdk.modelcontextprotocol.io/v2/whats-new/) for the tour of what changed, and the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/) for every breaking change. Stable v2 is targeted for 2026-07-27, alongside the spec release. Try the pre-releases and [tell us what breaks](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml), or discuss in [#python-sdk-dev on the MCP Contributors Discord](https://discord.gg/6CSzBmMkjX). ## Documentation **The documentation lives at .** -It has a [Get started guide](https://py.sdk.modelcontextprotocol.io/v2/get-started/), the [API reference](https://py.sdk.modelcontextprotocol.io/v2/api/mcp/), and the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/). +It has a [Get started guide](https://py.sdk.modelcontextprotocol.io/v2/get-started/), [What's new in v2](https://py.sdk.modelcontextprotocol.io/v2/whats-new/), the [API reference](https://py.sdk.modelcontextprotocol.io/v2/api/mcp/), and the [migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/). ## What is MCP? diff --git a/docs/index.md b/docs/index.md index a729cfba23..8aa1a5b671 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,6 +2,7 @@ !!! info "You are viewing the in-development v2 documentation" For the current stable release, see the [v1.x documentation](https://py.sdk.modelcontextprotocol.io/). + New to v2, or coming from v1? **[What's new in v2](whats-new.md)** is the five-minute tour of what changed. Trying v2? [Tell us what you find](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml) — it is the most useful thing you can do for the SDK right now. The **Model Context Protocol (MCP)** lets applications provide context to LLMs in a standardized way, separating the concern of *providing* context from the LLM interaction itself. @@ -93,6 +94,7 @@ You wrote two Python functions with type hints and a docstring. The SDK does the * Building an application that *uses* MCP servers? Start with **[Clients](client/index.md)**. * Already have a FastAPI or Starlette app? **[Add to an existing app](run/asgi.md)** mounts an MCP server inside it. * Hunting an exact error message? **[Troubleshooting](troubleshooting.md)** is keyed by the verbatim text. +* Wondering what changed in v2? **[What's new in v2](whats-new.md)** is the five-minute tour. * Migrating from v1? Start with the **[Migration Guide](migration.md)**. * Hunting for an exact signature? The **[API Reference](api/mcp/index.md)** is generated from the source. * Reading with an LLM? This documentation is also published in the [llms.txt](https://llmstxt.org/) format: diff --git a/docs/migration.md b/docs/migration.md index 186f3d40e2..9ff5a054c9 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -706,6 +706,26 @@ async def my_tool(x: int, ctx: Context) -> str: return str(x) ``` +### Sync handler functions now run on a worker thread + +In v1, a synchronous (`def`) tool, resource, or prompt function was called inline on the event +loop, so a body that blocked (an HTTP call with a sync client, `time.sleep()`, heavy +computation) stalled every other in-flight request on the server. In v2 the SDK runs +synchronous handler functions in a worker thread via `anyio.to_thread.run_sync()`; +`async def` handlers are unchanged. Resolver functions (`Resolve(...)`) follow the same rule. + +Most servers simply gain concurrency. Port with care if a synchronous handler relied on +running on the event-loop thread: + +- Thread-affine state (thread locals shared with startup code, non-thread-safe objects that + were only ever touched from the event loop's thread) is now touched from a worker thread. +- `asyncio.get_running_loop()` inside a synchronous handler body raises `RuntimeError`; there + is no running loop in a worker thread. +- Synchronous handlers can run concurrently with each other, up to anyio's default + worker-thread limit. + +Declare the handler `async def` to keep it on the event loop. + ### `MCPServer.call_tool()`, `read_resource()`, `get_prompt()` now accept a `context` parameter `MCPServer.call_tool()`, `MCPServer.read_resource()`, and `MCPServer.get_prompt()` now accept an optional `context: Context | None = None` parameter. The framework passes this automatically during normal request handling. If you call these methods directly and omit `context`, a Context with no active request is constructed for you — tools that don't use `ctx` work normally, but any attempt to use `ctx.session`, `ctx.request_id`, etc. will raise. @@ -1607,6 +1627,16 @@ params = CallToolRequestParams( If you relied on extra fields round-tripping through MCP types, move that data into `_meta`. +### `mcp dev` and `mcp install` pin the spawned environment to your SDK version + +Both commands run your server through a fresh `uv run --with ...` environment. In v1 the +`mcp` requirement in that command was unpinned, so the spawned environment resolved to the +newest stable release rather than the version you had installed; with a v2 pre-release +installed, `mcp dev server.py` built a v1 environment that could not import a v2 server. +Both commands now pin the requirement to the version you are running +(`mcp==`). Source builds and other unpublished versions, which have +nothing on PyPI to pin to, keep the unpinned form. + ## New Features ### OAuth client credentials are bound to their authorization server ([SEP-2352](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2352)) diff --git a/docs/whats-new.md b/docs/whats-new.md new file mode 100644 index 0000000000..d197833db8 --- /dev/null +++ b/docs/whats-new.md @@ -0,0 +1,210 @@ +# What's new in v2 + +Two things happened at once in v2. The **SDK was rebuilt**: a new engine under both the client and the server, a first-class `Client`, and a set of renames that a v1 codebase meets on its first import. And the **protocol moved**: v2 speaks the 2026-07-28 revision of MCP, which removes the connection handshake, the session, and every server-initiated request, without stranding the clients you already have. + +This page is the tour of both halves, one section per headline, each ending in the page that owns the topic. It is not the porting manual. That is the **[Migration Guide](migration.md)**: every breaking change, with before and after code. + +!!! note "v2 is a beta" + `pip install mcp` still installs v1.x: you opt into v2 with an exact version pin, and the + API can still move before the stable release, which lands alongside the spec release. + **[Installation](get-started/installation.md)** has the copy-paste install line and the + pinning rules. And if anything in v2 breaks, surprises, or slows you down, + [tell us](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml): + while v2 is in beta, that is the most useful thing you can send us. + +## The SDK: v1 to v2 + +### `FastMCP` is now `MCPServer` + +The high-level server class was renamed, and its module with it. This is the first thing every v1 server hits, because the old import path is gone rather than deprecated: + +```python +from mcp.server import MCPServer # v1: from mcp.server.fastmcp import FastMCP + +mcp = MCPServer("Demo") # v1: FastMCP("Demo") +``` + +It is also, for a decorator-built server, most of the port. `@mcp.tool()`, `@mcp.resource()`, and `@mcp.prompt()` accept what they accepted in v1 (`@mcp.resource()` adds one optional `security=` keyword), and the input schema still comes from your type hints. Around the edges: everything under `mcp.server.fastmcp.*` now lives under `mcp.server.mcpserver.*`, `ctx.fastmcp` is `ctx.mcp_server`, `get_context()` is gone (declare a `ctx: Context` parameter instead), and the exception base `FastMCPError` is `MCPServerError`. The **[Migration Guide](migration.md#fastmcp-renamed-to-mcpserver)** has the import table. + +### `Resolve`: the new way to ask the user for input + +Not everything a tool needs should come from the model. New in v2, a tool parameter annotated with `Resolve(fn)` is filled by a function you write instead, invisibly to the model, and that function can return `Elicit(...)` to put a question in front of the user. This is the preferred way to get anything from the client mid-call: the SDK carries the question over whichever mechanism the connection supports (a live elicitation request for a legacy client, a multi-round-trip on 2026-07-28), so one tool body serves both eras. **[Dependencies](handlers/dependencies.md)** is the page. + +!!! note + The other two forms remain when you need them: `ctx.elicit()` still works for clients on + legacy connections (**[Elicitation](handlers/elicitation.md)**), and a handler can return an + `InputRequiredResult` itself and drive the rounds by hand, which is also how sampling and + roots requests travel at 2026-07-28 (**[Multi-round-trip requests](handlers/multi-round-trip.md)**). + +### A first-class `Client` + +v1 handed you three nested layers: a transport context manager yielding raw streams, a `ClientSession` wrapped around them, and a hand-called `await session.initialize()`. v2 has one object: + +```python title="client.py" hl_lines="14-18" +--8<-- "docs_src/client/tutorial001.py" +``` + +`Client` takes a server object (in memory, no transport: the testing story), a URL (Streamable HTTP), or any transport context manager such as `stdio_client(...)`. Entering `async with` connects and negotiates the protocol version, whichever era the server speaks; `client.server_info`, `client.server_capabilities`, and `client.protocol_version` are simply there afterwards. The sampling and elicitation callbacks you registered in v1 still work (their bodies see the same snake_case attribute rename as everything else on this page), they now also answer the 2026-style requests-inside-results (below), and they run concurrently instead of one at a time. `ClientSession` is still underneath for anyone who wants the low-level surface, and `client.session` hands it to you; it moved too (it runs on the new dispatcher engine, and some of its own signatures changed), so read the **[Migration Guide](migration.md#clientsession-now-runs-on-jsonrpcdispatcher-basesession-removed)** before you drop down. + +**[The Client](client/index.md)** introduces it, **[Client transports](client/transports.md)** covers the three connection forms, **[Client callbacks](client/callbacks.md)** covers the callbacks themselves, and **[Testing](get-started/testing.md)** shows the in-memory pattern that replaces v1's `create_connected_server_and_client_session()` helper. + +### The low-level `Server` was rebuilt, not renamed + +If you work at the JSON-RPC layer, this is the "everything is different" part of v2. Here is the same one-tool server both ways; click the markers for what moved. + + + +```python title="v1" +from typing import Any + +import mcp.types as types +from mcp.server.lowlevel import Server + +server = Server("Bookshop") + + +@server.list_tools() # (1)! +async def list_tools() -> list[types.Tool]: + return [ # (2)! + types.Tool( + name="search_books", + description="Search the catalog by title or author.", + inputSchema={ # (3)! + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, + ) + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.ContentBlock]: # (4)! + if name != "search_books": + raise ValueError(f"Unknown tool: {name}") # (5)! + ctx = server.request_context # (6)! + return [types.TextContent(type="text", text=f"Found 3 books matching {arguments['query']!r}.")] # (7)! +``` + +1. Handlers are registered with decorators (called, with parentheses), any time after the server exists. +2. You return a bare `list[Tool]` and the SDK wraps it into a `ListToolsResult`. +3. Fields are camelCase in Python, and the schema is **enforced**: the SDK jsonschema-validates `call_tool` arguments against it before your function runs, which is why `arguments["query"]` below is safe. +4. One `call_tool` handler serves every tool, and it receives the tool name and the already-validated arguments, unpacked and never `None`. +5. Raising is how a v1 tool signals failure: any exception is caught and returned as `CallToolResult(isError=True)` with `str(e)` as its text, so the calling model reads this message and can retry. +6. The context comes from an ambient ContextVar, reached through the server object mid-request. +7. Bare content blocks are wrapped into a `CallToolResult` for you. + +```python title="v2" +--8<-- "docs_src/whats_new/tutorial001.py" +``` + +1. Fields are snake_case now, and the schema is **advertised but never applied**: nothing checks the arguments before your handler runs. +2. Every handler has the same shape: `async (ctx, params) -> result`. The context is the first argument (`ctx.session`, `ctx.request_id`, `ctx.protocol_version` live on it); this is where `server.request_context` went. +3. You build the full `ListToolsResult` yourself. Returning a bare list is a server-side `TypeError` now, not something the SDK wraps. +4. Typed params in (`params.name`, `params.arguments`), a full result out. Nothing is unpacked, wrapped, or converted for you. +5. Same check, different verb. A `ValueError` here would reach the model as an opaque `-32603` (see below), so a deliberate wire error is raised as `MCPError`: it passes through with its code and message intact, and `-32602` with this text is the spec's own answer for an unknown tool. +6. `params.arguments` can be `None`; v1 defaulted it to `{}` before your code ever saw it. With no validation in front of the handler, this line is load-bearing. +7. An unexpected exception raised here becomes a **sanitized** protocol error, `-32603` `"Internal server error"`: the model never sees the message. For a failure the model should read and react to, return `CallToolResult(is_error=True, ...)`. +8. Handlers are constructor arguments, so the server's surface is complete the moment it exists; `add_request_handler()` is the post-construction escape hatch, and the door to custom methods. + +The example is the pattern. More generally: every handler has the same shape, with typed params in and a full result type out; the old jsonschema check of tool arguments is gone; an exception is a protocol error, never an `is_error=True` tool result; and the ambient `server.request_context` ContextVar is gone. Custom, vendor-namespaced methods are first class through `add_request_handler(method, params_type, handler)`, which validates inbound params against your model before your handler runs. And a `middleware` list (deliberately marked provisional) wraps every inbound message, replacing the private `_handle_*` methods people used to override. + +Underneath, the v1 `BaseSession` receive loop was replaced by a dispatcher engine that the client and the server now share, and it is what makes several things on this page true at once: one `Server` object serves both protocol eras, `Client(server)` dispatches in process with no JSON-RPC framing, and a timed-out client request now actually cancels the server-side handler. + +**[The low-level Server](advanced/low-level-server.md)** is the page; the **[Migration Guide](migration.md#lowlevel-server-decorator-based-handlers-replaced-with-constructor-on_-params)** walks every removed hook. If you never dropped below `MCPServer`, none of this touches you. + +### The wire types moved to `mcp-types`, and every field is snake_case + +The protocol types now live in their own distribution, `mcp-types`, imported as `mcp_types`. It depends on nothing but pydantic and typing-extensions, so a gateway, a proxy, or a code generator can consume MCP's wire shapes without installing an HTTP stack. `mcp` depends on it at an exact version and re-exports the common names, so `from mcp import Tool` still works; `import mcp.types` does not. + +On those types, every Python attribute is now snake_case: `result.is_error`, `tool.input_schema`, `listing.next_cursor`. The JSON on the wire is camelCase, exactly as before; only the attribute spelling changed. Two stricter defaults ride along: unknown fields are ignored instead of round-tripped (put extras in `_meta`), and both sides validate traffic against the protocol version they negotiated. See the **[Migration Guide](migration.md#field-names-changed-from-camelcase-to-snake_case)** for the rename table. + +### Transport configuration moved to `run()` + +`MCPServer(...)` is about what your server *is*: its name, its instructions, its lifespan, its auth. How it is *served* now belongs to `run()` and the app builders, which is where `host`, `port`, `stateless_http`, `json_response`, the endpoint paths, and `transport_security` went (`MCPServer("x", port=9000)` is a `TypeError`). The overloads are typed per transport, so your editor tells you which options `stdio` takes and which `streamable-http` takes. One removal worth knowing: `mount_path` is gone; mounting the ASGI app is the supported way to serve under a prefix. + +**[Running your server](run/index.md)** covers the options; **[Add to an existing app](run/asgi.md)** covers mounting. + +### Behavior that changes without an import error + +The renames announce themselves. These do not: + +* **Sync functions run on a worker thread.** A `def` tool (or resource, prompt, or resolver) no longer blocks the event loop; the trade is that its body no longer runs *on* the event-loop thread, which matters to thread-affine code. `async def` handlers are untouched. **[Migration Guide](migration.md#sync-handler-functions-now-run-on-a-worker-thread)**. +* **`MCPError` (v1's `McpError`) raised inside a tool is a protocol error now.** The model never sees it. Every other exception still becomes an `is_error=True` result the model can read and react to. **[Handling errors](servers/handling-errors.md)** is the split. +* **Results are validated before they leave.** A hand-built `Tool` whose `input_schema` is `{}` now fails `tools/list` (the spec requires `"type": "object"`). Servers built on `@mcp.tool()` never see this; the SDK writes their schemas. +* **Your client validates what it receives.** `list_tools()` and `call_tool()` check the server's answer against the negotiated protocol version, so a not-quite-valid server that v1's lenient parse tolerated now raises `pydantic.ValidationError`. If you connect to servers you do not control, expect to be the one who finds them; the **[Migration Guide](migration.md#client-validates-inbound-traffic-against-the-protocol-schema)** has the details. +* **URI templates are real RFC 6570 now.** `{+path}`, `{?query}` and friends work, matching is exact instead of regex-loose, and path traversal in extracted values is rejected by default. Stricter templates fail at decoration time, not on the first request. **[URI templates](servers/uri-templates.md)**. +* **The streamable HTTP lifespan runs once**, at startup, and its state is shared by every session and request. In v1 it ran once per session, and once per request under `stateless_http=True`. Pools and caches built in a lifespan get dramatically cheaper; anything that acquired a per-connection resource there belongs in the handler body now. **[Lifespan](handlers/lifespan.md)**. +* **`mcp dev` and `mcp install` pin the environment they spawn** to your installed SDK version. Both commands run your server in a fresh `uv run --with ...` environment, which used to resolve `mcp` to the newest stable release rather than the version you are developing against. **[Migration Guide](migration.md#mcp-dev-and-mcp-install-pin-the-spawned-environment-to-your-sdk-version)**. + +### Removed outright + +Each of these is a section in the **[Migration Guide](migration.md)**: + +* The **WebSocket transport**, both sides, and the `mcp[ws]` extra. It was never part of the MCP specification. +* The **experimental Tasks** API (`mcp.*.experimental`). 2026-07-28 moves tasks out of the core protocol and into an official extension ([SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663)), which this SDK does not implement yet. +* `mcp.types`, `mcp.shared.version`, and `mcp.shared.progress` as import paths. +* The deprecated `streamablehttp_client` spelling, and the `get_session_id` callback from `streamable_http_client` (which now yields exactly two streams). +* `McpError`, renamed **`MCPError`** with a direct `(code, message, data)` constructor. +* `MCPServer.get_context()`, `mount_path=`, and the lowlevel `Server`'s decorator methods, ContextVar, and handler dicts. + +## The protocol: 2025-11-25 to 2026-07-28 + +v2 implements the 2026-07-28 revision, and it serves **both** revisions at once: the same `streamable_http_app()` (and the same stdio server) answers a 2025-era client's `initialize` and a 2026-era client's requests with nothing to configure, no flag to flip, and no separate deployment. Serving the new revision does not strand a client on the old one. What follows is what the new revision itself changes. + +### No handshake, no session + +A 2026-07-28 client does not open a connection, negotiate, and then talk. Every request carries its protocol version, client info, and client capabilities in `_meta`, and the one discovery call, `server/discover`, is a plain request like any other. `Client` does the right thing by default: it probes `server/discover` once and falls back to the `initialize` handshake if the server is older. + +Over Streamable HTTP there is no `Mcp-Session-Id` on the 2026 path, which is the operational headline: **nothing ties a modern request to a worker**, so any replica behind a plain round-robin load balancer can answer it. Two honest qualifiers. Your 2025-era clients (today, that is most clients) still open sessions and still need whatever stickiness they needed on v1; nothing changes for them. And the one thing a *multi-round-trip* retry has to carry across workers is its sealed `request_state`, whose default key is minted per process, so a scaled-out deployment passes `RequestStateSecurity(keys=[...])`. (`stateless_http=True` is unrelated: it only affects how 2025-era clients are served, and 2026 traffic never reads it; if you already set it in v1, nothing changes.) + +**[Protocol versions](protocol-versions.md)** is the client's side of this, **[Deploy & scale](run/deploy.md)** is the operator's checklist (the Host allowlist, the `request_state` key, notifications across replicas), and **[Serving legacy clients](run/legacy-clients.md)** is the both-eras-at-once story. + +### The server cannot call the client: multi-round-trip requests + +Every server-initiated request is gone at 2026-07-28: push elicitation, sampling, `roots/list`. On a 2026 connection there is no channel for them, so `ctx.elicit()` and `ctx.session.create_message()` fail there with `NoBackChannelError` (they still work for legacy clients). + +The replacement turns the call around. A tool that needs something from the user *returns* the question (`InputRequiredResult`), the client answers it with the same callbacks it always had, and the call is retried with the answers attached. `Client` drives that loop for you. On the server you rarely build the result yourself, because a **[dependency](handlers/dependencies.md)** does it: annotate a parameter with `Resolve(ask_quantity)`, where `ask_quantity` is an ordinary function you write, and the SDK asks over whichever mechanism the connection supports, a live elicitation request on a legacy session or a multi-round-trip on 2026. One tool body, both eras: + +```python title="dual_era.py" hl_lines="24 37-38" +--8<-- "docs_src/legacy_clients/tutorial001.py" +``` + +That file is the pitch in one place: one server, one `Resolve`-backed tool, and a legacy client plus a modern client both getting their answer, in memory. **[Multi-round-trip requests](handlers/multi-round-trip.md)** explains the mechanism (including `request_state`, which the SDK seals and verifies for you); **[Elicitation](handlers/elicitation.md)** covers the asking. + +!!! warning "This is the one place a ported v1 server changes behavior" + Your own tests hit it first: `Client(mcp)` negotiates 2026-07-28 against your v2 server by + default, so a tool that calls `ctx.elicit()` fails in a test that passed on v1. Move the + question into a `Resolve(...)` parameter (era-portable), or pin the test client to + `mode="legacy"` if you genuinely want the push behavior. + +### Roots, sampling, and protocol logging are deprecated; `ping` is removed + +[SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) deprecates three whole *capabilities*, on every protocol version: roots, sampling, and MCP-level logging (`ctx.info()` and friends). That is a separate axis from the missing back-channel above; deprecated is advisory, everything keeps working against 2025-era sessions, and nothing changes on the wire. What you notice is `MCPDeprecationWarning`, which is a `UserWarning`, so it prints by default; expect your first `ctx.info(...)` after the upgrade to say so. + +`ping` is stricter: removed from the protocol, not deprecated. Two of the deprecated features' standalone methods are removed at 2026-07-28 the same way, `logging/setLevel` and the client's `notifications/roots/list_changed`, and progress notifications are now server-to-client only. + +**[Deprecated features](deprecated.md)** has the full table, the replacement for each, and the one-line filter if you need a quiet log while you serve legacy clients. + +### Change notifications become one stream + +At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are replaced by `subscriptions/listen`: the client opens one long-lived stream and names the notification kinds it wants. `MCPServer` serves it out of the box; you publish with `await ctx.notify_resource_updated(uri)` (and `notify_tools_changed()`, and so on), and multi-replica deployments plug in a shared `SubscriptionBus`. Two honest caveats as of `2.0.0b1`: the Python `Client` cannot open the listen stream yet (the driver ships in a later pre-release), and over stdio the server does not serve it. The net for a Python *client* on that release is that nothing delivers change notifications on a 2026-07-28 connection; a host that relies on `resources/updated` should connect with `mode="legacy"` until the driver lands. + +**[Subscriptions](handlers/subscriptions.md)** on the server, and **[Deploy & scale](run/deploy.md)** for the bus. + +### The rest, quickly + +* **Requests are routable without parsing bodies.** Modern HTTP requests carry `Mcp-Method` (and, for the three tool-ish calls, `Mcp-Name`); a tool input-schema property annotated with `x-mcp-header` is mirrored into an `Mcp-Param-*` header and cross-checked by the server ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)). Gateways and rate limiters can route on headers alone; the **[Migration Guide](migration.md#servers-validate-mcp-param-headers-against-the-request-body-sep-2243)** has the rules. +* **Results carry cache hints.** List and read results declare `ttlMs` and `cacheScope` ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)); you set them per method with `cache_hints=`, and `Client` honors them with a built-in response cache. A server that sends no hints (every pre-2026 server) sees identical, uncached traffic. **[Caching hints](client/caching.md)**. +* **Extensions are first class.** Servers and clients declare optional capability bundles under reverse-DNS identifiers ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)); the built-in `Apps` extension (MCP Apps) is the reference. **[Extensions](advanced/extensions.md)** and **[MCP Apps](advanced/apps.md)**. +* **Error codes got standardized.** A missing resource is `-32602` with the URI in `error.data`, and the new spec-reserved codes appear as `-32020` (header mismatch), `-32021` (missing required capability), and `-32022` (unsupported protocol version). **[Troubleshooting](troubleshooting.md)** is keyed by the exact messages. +* **Authorization got harder to hold wrong.** The client validates the `iss` returned with the authorization code ([RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207); your `callback_handler` now returns an `AuthorizationCodeResult`), sends `application_type` when it registers, and never replays credentials against a different authorization server. New in the enterprise corner: the [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990) identity-assertion flow. The **[Migration Guide](migration.md)** lists every OAuth change; **[OAuth for clients](client/oauth-clients.md)** and **[Identity assertion](client/identity-assertion.md)** are the pages. +* **Every server is traceable.** OpenTelemetry ships on by default as middleware: every request gets a server span, at no cost until the process configures an exporter. When both ends run the SDK, the client also propagates W3C trace context in `_meta`, so the traces join up. **[OpenTelemetry](run/opentelemetry.md)**. + +## Upgrading from v1? + +* The **[Migration Guide](migration.md)** is the complete, exact list of what to change; this page was the why. +* **v1.x is not going anywhere.** It stays the stable line, with critical fixes and security patches, and nothing about the 2026-07-28 spec release breaks it. If you publish a library that depends on `mcp`, add an upper bound (for example `mcp>=1.27,<2`) so stable v2 does not surprise your users. +* Something rough, confusing, or broken? **[File v2 feedback](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml)**; it all gets read. diff --git a/docs_src/whats_new/__init__.py b/docs_src/whats_new/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/whats_new/tutorial001.py b/docs_src/whats_new/tutorial001.py new file mode 100644 index 0000000000..5e41ae1c04 --- /dev/null +++ b/docs_src/whats_new/tutorial001.py @@ -0,0 +1,37 @@ +from mcp_types import ( + INVALID_PARAMS, + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) + +from mcp import MCPError +from mcp.server import Server, ServerRequestContext + +SEARCH_BOOKS = Tool( + name="search_books", + description="Search the catalog by title or author.", + input_schema={ # (1)! + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + }, +) + + +async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: # (2)! + return ListToolsResult(tools=[SEARCH_BOOKS]) # (3)! + + +async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: # (4)! + if params.name != "search_books": + raise MCPError(INVALID_PARAMS, f"Unknown tool: {params.name}") # (5)! + args = params.arguments or {} # (6)! + text = f"Found 3 books matching {args['query']!r}." + return CallToolResult(content=[TextContent(type="text", text=text)]) # (7)! + + +server = Server("Bookshop", on_list_tools=list_tools, on_call_tool=call_tool) # (8)! diff --git a/mkdocs.yml b/mkdocs.yml index 5da05cc42a..2d4754f1c9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,6 +12,7 @@ site_url: https://py.sdk.modelcontextprotocol.io/v2/ nav: - MCP Python SDK: index.md + - "What's new in v2": whats-new.md - Get started: - get-started/index.md - Installation: get-started/installation.md diff --git a/tests/docs_src/test_whats_new.py b/tests/docs_src/test_whats_new.py new file mode 100644 index 0000000000..d9c6143e25 --- /dev/null +++ b/tests/docs_src/test_whats_new.py @@ -0,0 +1,65 @@ +"""`docs/whats-new.md`: the v2 half of the low-level before/after example, proved against the real SDK. + +The v1 half of that example targets the 1.x line and cannot run here; it was +validated by running it verbatim against a real `mcp==1.28.1` install. +""" + +import pytest +from mcp_types import INTERNAL_ERROR, INVALID_PARAMS, TextContent + +from docs_src.whats_new import tutorial001 +from mcp import Client, MCPError + +# See test_index.py for why this is a per-module mark and not a conftest hook. +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +async def test_the_advertised_schema_is_the_literal_dict() -> None: + """Annotation 1: the schema is advertised to clients exactly as written.""" + async with Client(tutorial001.server) as client: + (tool,) = (await client.list_tools()).tools + assert tool.name == "search_books" + assert tool.input_schema == { + "type": "object", + "properties": {"query": {"type": "string"}}, + "required": ["query"], + } + + +async def test_a_valid_call_answers() -> None: + """The example works end to end through the in-process `Client`.""" + async with Client(tutorial001.server) as client: + result = await client.call_tool("search_books", {"query": "dune"}) + assert not result.is_error + assert result.content == [TextContent(type="text", text="Found 3 books matching 'dune'.")] + + +async def test_arguments_are_not_validated_and_a_handler_exception_is_sanitized() -> None: + """Annotations 1, 6, and 7, in one flow. + + A call missing the required `query` REACHES the handler (nothing validates + arguments against `input_schema`; v1 rejected this call before the handler + ran). The handler's own `KeyError` then comes back as a sanitized protocol + error, never an `is_error=True` result the model could read. A call with no + arguments at all exercises `params.arguments or {}` the same way. + """ + async with Client(tutorial001.server) as client: + with pytest.raises(MCPError) as excinfo: + await client.call_tool("search_books", {"limit": 5}) + assert excinfo.value.code == INTERNAL_ERROR + assert excinfo.value.message == "Internal server error" + + with pytest.raises(MCPError) as excinfo: + await client.call_tool("search_books") + assert excinfo.value.code == INTERNAL_ERROR + assert excinfo.value.message == "Internal server error" + + +async def test_an_unknown_tool_is_a_deliberate_wire_error() -> None: + """Annotation 5: a raised `MCPError` passes through with its code and message + intact (the spec's answer for an unknown tool), unlike the sanitized path.""" + async with Client(tutorial001.server) as client: + with pytest.raises(MCPError) as excinfo: + await client.call_tool("shelve_book", {"query": "dune"}) + assert excinfo.value.code == INVALID_PARAMS + assert excinfo.value.message == "Unknown tool: shelve_book" diff --git a/tests/test_examples.py b/tests/test_examples.py index 9236503a90..0104d5398b 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -102,6 +102,7 @@ async def test_desktop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): find_examples( "README.md", "docs/index.md", + "docs/whats-new.md", "docs/protocol-versions.md", "docs/deprecated.md", "docs/troubleshooting.md", From 2359b40285850c6f8aac24c99c3756d7893166f7 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:17:33 +0100 Subject: [PATCH 046/100] docs: modernize the site theme (#3057) --- docs/.overrides/.icons/mcp.svg | 1 + docs/extra.css | 74 ++++++++++++++++++++++++++++++++++ docs/favicon.svg | 11 +++++ mkdocs.yml | 23 ++++++++--- pyproject.toml | 4 +- uv.lock | 2 +- 6 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 docs/.overrides/.icons/mcp.svg create mode 100644 docs/extra.css create mode 100644 docs/favicon.svg diff --git a/docs/.overrides/.icons/mcp.svg b/docs/.overrides/.icons/mcp.svg new file mode 100644 index 0000000000..67d800b07c --- /dev/null +++ b/docs/.overrides/.icons/mcp.svg @@ -0,0 +1 @@ + diff --git a/docs/extra.css b/docs/extra.css new file mode 100644 index 0000000000..8625b05d52 --- /dev/null +++ b/docs/extra.css @@ -0,0 +1,74 @@ +/* Sidebar hierarchy + density for MkDocs Material 9.7.x. + All rules scoped to the desktop sidebar breakpoint (>= 76.25em), matching + Material's own scoping for navigation.sections, so the mobile drill-down + drawer keeps stock styling. Colors use Material tokens, so the light and + slate schemes both work without extra palette handling. */ + +@media screen and (min-width: 76.25em) { + /* Section labels: smaller, uppercase, letter-spaced, muted. Covers both the + clickable index-page headers and the bare API Reference label. */ + .md-sidebar--primary .md-nav__item--section > .md-nav__link { + font-size: 0.62rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--md-default-fg-color--light); + } + + /* Indent section children and hang a guide line. Material outdents section + children with [dir=ltr] ... margin-left: -0.6rem, which is why they sit + flush under the header; restoring the margin re-exposes the stock 0.6rem + list padding. The logical property ties Material's physical one at + (0,3,0) specificity and wins on source order (extra_css loads last), + while staying direction-agnostic. */ + .md-sidebar--primary .md-nav__item--section > .md-nav { + margin-inline-start: 0.1rem; + border-inline-start: 0.05rem solid var(--md-default-fg-color--lightest); + } + + /* Same guide line for collapsible groups inside the API Reference subtree + (section items also carry --nested, so exclude them). */ + .md-sidebar--primary .md-nav__item--nested:not(.md-nav__item--section) > .md-nav { + border-inline-start: 0.05rem solid var(--md-default-fg-color--lightest); + } + + /* Tighten vertical rhythm (stock: 0.625em link margins, 1.25em sections). + The child combinator keeps this off anchors inside md-nav__container, + which carry their own margin-top: 0 stock rule. */ + .md-sidebar--primary .md-nav__item > .md-nav__link { + margin-top: 0.45em; + } + .md-sidebar--primary .md-nav__item--section { + margin: 1em 0; + } + .md-sidebar--primary .md-nav__item--section > .md-nav__link { + margin-top: 0; + } + + /* The current page stands out from its siblings. 700 because Material only + loads Inter at 300/400/700; a 600 would silently substitute the 700 face + anyway, but render lighter on the system-font fallback stack. */ + .md-sidebar--primary .md-nav__link--active { + font-weight: 700; + } + + /* The sidebar repeats the site name right above the homepage nav entry; + drop the title row on desktop (the mobile drawer still needs it for its + drill-down back-navigation, hence the media-query scope). */ + .md-sidebar--primary .md-nav--primary > .md-nav__title { + display: none; + } +} + +/* Headings: Material's 300-weight light-gray defaults read washed out; use + the full foreground color and a solid weight instead. 700, not 600: the + Google Fonts request only carries Inter 300/400/700 (see the nav__link + note above). */ +.md-typeset h1, +.md-typeset h2 { + font-weight: 700; + color: var(--md-default-fg-color); +} +.md-typeset h3 { + font-weight: 700; +} diff --git a/docs/favicon.svg b/docs/favicon.svg new file mode 100644 index 0000000000..a280d7fdb5 --- /dev/null +++ b/docs/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/mkdocs.yml b/mkdocs.yml index 2d4754f1c9..5f19b89822 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -69,6 +69,13 @@ nav: theme: name: "material" + custom_dir: docs/.overrides + font: + text: Inter + code: JetBrains Mono + icon: + logo: mcp + favicon: favicon.svg palette: - media: "(prefers-color-scheme)" scheme: default @@ -86,7 +93,7 @@ theme: name: "Switch to dark mode" - media: "(prefers-color-scheme: dark)" scheme: slate - primary: white + primary: black accent: white toggle: icon: material/lightbulb-auto-outline @@ -98,14 +105,20 @@ theme: - content.code.annotate - content.code.copy - content.code.select - - navigation.path + - navigation.footer - navigation.indexes + - navigation.instant + - navigation.instant.prefetch + - navigation.instant.progress + - navigation.path + - navigation.prune - navigation.sections + - navigation.top - navigation.tracking - toc.follow - # logo: "img/logo-white.svg" - # TODO(Marcelo): Add a favicon. - # favicon: "favicon.ico" + +extra_css: + - extra.css # https://www.mkdocs.org/user-guide/configuration/#validation validation: diff --git a/pyproject.toml b/pyproject.toml index c46f81d8d6..2260ea2e27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,7 +80,9 @@ docs = [ "mkdocs-gen-files>=0.5.0", "mkdocs-glightbox>=0.4.0", "mkdocs-literate-nav>=0.6.1", - "mkdocs-material[imaging]>=9.7.0", + # docs/extra.css overrides Material-internal nav selectors; revisit it on a + # major bump before raising this cap. + "mkdocs-material[imaging]>=9.7.0,<10", "mkdocstrings-python>=2.0.1", ] codegen = ["datamodel-code-generator==0.57.0"] diff --git a/uv.lock b/uv.lock index 2646eda9d5..e9abba117d 100644 --- a/uv.lock +++ b/uv.lock @@ -1020,7 +1020,7 @@ docs = [ { name = "mkdocs-gen-files", specifier = ">=0.5.0" }, { name = "mkdocs-glightbox", specifier = ">=0.4.0" }, { name = "mkdocs-literate-nav", specifier = ">=0.6.1" }, - { name = "mkdocs-material", extras = ["imaging"], specifier = ">=9.7.0" }, + { name = "mkdocs-material", extras = ["imaging"], specifier = ">=9.7.0,<10" }, { name = "mkdocstrings-python", specifier = ">=2.0.1" }, ] From bf4402725dbc38d555008794920d7bb243cf7afc Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:54:21 +0100 Subject: [PATCH 047/100] docs: restructure the migration guide around topical groups with a navigation layer (#3058) --- docs/migration.md | 2066 ++++++++++++++++++++++++++------------------- 1 file changed, 1206 insertions(+), 860 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 9ff5a054c9..3c544d00ed 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -2,233 +2,104 @@ This guide covers the breaking changes introduced in v2 of the MCP Python SDK and how to update your code. -## Overview - Version 2 of the MCP Python SDK introduces several breaking changes to improve the API, align with the MCP specification, and provide better type safety. -## Breaking Changes - -### `MCPServer.call_tool()` returns `CallToolResult` - -`MCPServer.call_tool()` now returns a `CallToolResult` (or an -`InputRequiredResult` when a multi-round tool requests further input). It previously -advertised `Sequence[ContentBlock] | dict[str, Any]` and leaked the internal -conversion shapes (a bare content sequence or a `(content, structured_content)` -tuple), forcing callers to re-assemble a `CallToolResult` themselves. - -If you call `MCPServer.call_tool()` directly, read `.content` and -`.structured_content` off the returned `CallToolResult` instead of branching on -the result type. - -### `MCPServer.get_prompt()` and `read_resource()` may return `InputRequiredResult` - -Like `call_tool()` above, `MCPServer.get_prompt()` now returns -`GetPromptResult | InputRequiredResult` and `MCPServer.read_resource()` returns -`Iterable[ReadResourceContents] | InputRequiredResult`: at 2026-07-28 an -`@mcp.prompt()` function or an `@mcp.resource()` template function may answer -with an `InputRequiredResult` to request client input first (see -[Multi-round-trip requests](handlers/multi-round-trip.md)). If you call these -methods directly, narrow with `isinstance` (or -`assert not isinstance(result, InputRequiredResult)` when your prompt and -resource functions never return one). `Prompt.render()` and -`ResourceTemplate.create_resource()` carry the same union. - -`ctx.read_resource()` inside a handler is unchanged: it still returns content, -and raises `RuntimeError` if the resource requests input. A handler that wants -to receive the `InputRequiredResult` and forward it as its own result calls -`MCPServer.read_resource(uri, context)` directly — but not from a tool whose -dependencies elicit via `Resolve(...)`: the resolver owns that tool's -`request_state` channel, and a forwarded result's state would clobber it. - -### `MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error - -Raising `MCPError` (or a subclass such as `UrlElicitationRequiredError`) inside -an `@mcp.tool()` handler now produces a top-level JSON-RPC error response with -the raised `code`, `message`, and `data` intact. Previously the tool wrapper -caught it like any other exception and returned `CallToolResult(isError=True)`, -which discarded the error code and structured `data`. - -`MCPError` carries `ErrorData` and is the SDK's protocol-error type — raise it -when the request itself should be rejected (missing client capability, -elicitation required, invalid parameters). For tool *execution* failures the -calling LLM should see and react to, raise any other exception or return -`CallToolResult(is_error=True, ...)` directly; that path is unchanged. - -### `streamablehttp_client` removed - -The deprecated `streamablehttp_client` function has been removed. Use `streamable_http_client` instead. - -**Before (v1):** - -```python -from mcp.client.streamable_http import streamablehttp_client - -async with streamablehttp_client( - url="http://localhost:8000/mcp", - headers={"Authorization": "Bearer token"}, - timeout=30, - sse_read_timeout=300, - auth=my_auth, -) as (read_stream, write_stream, get_session_id): - ... -``` - -**After (v2):** - -```python -import httpx -from mcp.client.streamable_http import streamable_http_client - -# Configure headers, timeout, and auth on the httpx.AsyncClient -http_client = httpx.AsyncClient( - headers={"Authorization": "Bearer token"}, - timeout=httpx.Timeout(30, read=300), - auth=my_auth, - follow_redirects=True, -) - -async with http_client: - async with streamable_http_client( - url="http://localhost:8000/mcp", - http_client=http_client, - ) as (read_stream, write_stream): - ... -``` - -v1's internal client set `follow_redirects=True`; set it explicitly when supplying your own `httpx.AsyncClient` to preserve that behavior. - -### OAuth `callback_handler` returns `AuthorizationCodeResult` - -The `callback_handler` passed to `OAuthClientProvider` now returns an `AuthorizationCodeResult` instead of a `tuple[str, str | None]` of `(code, state)`. The new object adds an `iss` field so the client can validate the [RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207) authorization-response issuer ([SEP-2468](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468)): when the redirect carries an `iss` query parameter it must match the authorization server's issuer, and a missing `iss` is rejected when the server advertised `authorization_response_iss_parameter_supported`. - -**Before (v1):** - -```python -async def callback_handler() -> tuple[str, str | None]: - params = parse_qs(urlparse(await wait_for_redirect()).query) - return params["code"][0], params.get("state", [None])[0] -``` - -**After (v2):** - -```python -from mcp.client.auth import AuthorizationCodeResult - - -async def callback_handler() -> AuthorizationCodeResult: - params = parse_qs(urlparse(await wait_for_redirect()).query) - return AuthorizationCodeResult( - code=params["code"][0], - state=params.get("state", [None])[0], - iss=params.get("iss", [None])[0], - ) -``` - -Forward the `iss` query parameter from the redirect so the validation can run: omitting it makes the flow fail with `OAuthFlowError` against servers that advertise `authorization_response_iss_parameter_supported`, and silently skips the check for servers that send `iss` without advertising it. - -### `get_session_id` callback removed from `streamable_http_client` - -The `get_session_id` callback (third element of the returned tuple) has been removed from `streamable_http_client`. The function now returns a 2-tuple `(read_stream, write_stream)` instead of a 3-tuple. - -If you need to capture the session ID (e.g., for session resumption testing), you can use httpx event hooks to capture it from the response headers: +## Find your changes + +Every section heading below names the API it affects, so searching this page for the symbol your code uses is the fastest route to the change that broke it. + +### Changes almost every project hits + +| Change | First symptom | Section | +|---|---|---| +| `FastMCP` renamed to `MCPServer` | `ModuleNotFoundError: No module named 'mcp.server.fastmcp'` | [`FastMCP` renamed](#fastmcp-renamed-to-mcpserver) | +| Fields renamed from camelCase to snake_case | `AttributeError: 'Tool' object has no attribute 'inputSchema'` | [snake_case fields](#field-names-changed-from-camelcase-to-snake_case) | +| `mcp.types` moved to the `mcp-types` package | `ModuleNotFoundError: No module named 'mcp.types'` | [`mcp.types` moved](#mcptypes-moved-to-the-mcp-types-package) | +| `McpError` renamed to `MCPError` | `ImportError: cannot import name 'McpError' from 'mcp'` | [`McpError` renamed](#mcperror-renamed-to-mcperror) | +| Resource URIs are `str`, not `AnyUrl` | `AttributeError: 'str' object has no attribute 'host'` | [URI type](#resource-uri-type-changed-from-anyurl-to-str) | +| `streamablehttp_client` removed | `ImportError: cannot import name 'streamablehttp_client'` | [`streamablehttp_client`](#streamablehttp_client-removed) | +| `Client` defaults to `mode='auto'` | servers log an unexpected `server/discover` request | [`mode='auto'`](#client-defaults-to-modeauto) | +| Transport parameters moved off the `MCPServer` constructor | `TypeError: MCPServer.__init__() got an unexpected keyword argument 'port'` | [constructor parameters](#transport-specific-parameters-moved-from-mcpserver-constructor-to-runapp-methods) | +| Sync handlers run on a worker thread | `asyncio.get_running_loop()` in a `def` handler raises `RuntimeError` | [worker threads](#sync-handler-functions-now-run-on-a-worker-thread) | +| Lowlevel decorators replaced with `on_*` constructor params | `AttributeError: 'Server' object has no attribute 'list_tools'` | [`on_*` handlers](#lowlevel-server-decorator-based-handlers-replaced-with-constructor-on_-params) | +| Lowlevel return value wrapping removed | bare list or dict returns fail result validation instead of being wrapped | [wrapping removed](#lowlevel-server-automatic-return-value-wrapping-removed) | +| Lowlevel tool exceptions no longer become `isError: true` results | clients raise a JSON-RPC error instead of seeing the error text | [tool exceptions](#lowlevel-server-tool-handler-exceptions-no-longer-become-calltoolresultis_errortrue) | +| Roots, Sampling, and Logging deprecated (SEP-2577) | `MCPDeprecationWarning` at call sites | [SEP-2577](#roots-sampling-and-logging-methods-deprecated-sep-2577) | + +### Find your area + +| If you... | Read | +|---|---| +| pin dependencies or use the `mcp` CLI | [Packaging, dependencies, and CLI](#packaging-dependencies-and-cli) | +| import `mcp.types` or touch protocol types (everyone does) | [Types and wire format](#types-and-wire-format) | +| run `FastMCP`/`MCPServer` servers | [MCPServer (formerly FastMCP)](#mcpserver-formerly-fastmcp) | +| use the lowlevel `Server` | [Lowlevel Server](#lowlevel-server), plus [Timeouts take `float` seconds](#timeouts-take-float-seconds-instead-of-timedelta) and [Experimental Tasks support removed](#experimental-tasks-support-removed) under Clients | +| write client code with `Client` or `ClientSession` | [Clients](#clients), plus [`streamablehttp_client` removed](#streamablehttp_client-removed) under Transports | +| use stdio or streamable HTTP directly, or maintain a custom transport | [Transports](#transports) | +| maintain OAuth client auth or a protected server | [OAuth and server auth](#oauth-and-server-auth) | +| relied on lenient handling of off-schema traffic, or assert on exact wire bytes | [Stricter protocol validation and wire behavior](#stricter-protocol-validation-and-wire-behavior) | +| test against in-memory server/client pairs | [Testing utilities](#testing-utilities) | +| use roots, sampling, logging, or client-to-server progress | [Deprecations](#deprecations) | +| operate servers that 2026-era clients will also connect to | [Notes for 2026-era connections](#notes-for-2026-era-connections) | + +## Suggested migration order + +1. Update your dependency pins and CLI usage: [Packaging, dependencies, and CLI](#packaging-dependencies-and-cli). +2. Apply the mechanical renames and import moves: [Types and wire format](#types-and-wire-format). +3. Port your server surface: [MCPServer (formerly FastMCP)](#mcpserver-formerly-fastmcp) or [Lowlevel Server](#lowlevel-server). +4. Port your client code: [Clients](#clients). +5. Update transport setup and auth: [Transports](#transports) and [OAuth and server auth](#oauth-and-server-auth). +6. Run your tests and check anything that now errors against [Stricter protocol validation and wire behavior](#stricter-protocol-validation-and-wire-behavior) and [Testing utilities](#testing-utilities). +7. Address deprecation warnings: [Deprecations](#deprecations). + +## Packaging, dependencies, and CLI + +### Dependency floors raised and new required dependencies + +v2 raises the minimum versions of several shared dependencies and adds new required ones. A project that pins any of these below the new floor fails dependency resolution before anything installs (uv reports "No solution found when resolving dependencies"; pip fails similarly). + +| Dependency | v1.28.1 | v2 | Change | +|---|---|---|---| +| anyio | `>=4.5` | `>=4.9` (Python <3.14) / `>=4.10` (Python >=3.14) | floor raised | +| pydantic | `>=2.11,<3` (Python <3.14) | `>=2.12` | floor raised on Python <3.14; `<3` cap dropped | +| sse-starlette | `>=1.6.1` | `>=3.0.0` | floor raised across two majors | +| typing-extensions | `>=4.9.0` | `>=4.13.0` | floor raised | +| pywin32 (Windows) | `>=310` (Python <3.14) | `>=311` | floor raised on Python <3.14 | +| opentelemetry-api | not a dependency | `>=1.28.0` | new required dependency | +| mcp-types | not a dependency | `==` | new, exact-pinned | +| `ws` extra | `websockets>=15.0.1` | removed | see [WebSocket transport removed](#websocket-transport-removed) | **Before (v1):** -```python -from mcp.client.streamable_http import streamable_http_client - -async with streamable_http_client(url) as (read_stream, write_stream, get_session_id): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - session_id = get_session_id() # Get session ID via callback +```toml +dependencies = [ + "mcp==1.28.1", + "sse-starlette>=2,<3", # own SSE endpoints, pinned to the 2.x API +] ``` **After (v2):** -```python -import httpx -from mcp.client.streamable_http import streamable_http_client - -# Option 1: Simply ignore if you don't need the session ID -async with streamable_http_client(url) as (read_stream, write_stream): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - -# Option 2: Capture session ID via httpx event hooks if needed -captured_session_ids: list[str] = [] - -async def capture_session_id(response: httpx.Response) -> None: - session_id = response.headers.get("mcp-session-id") - if session_id: - captured_session_ids.append(session_id) - -http_client = httpx.AsyncClient( - event_hooks={"response": [capture_session_id]}, - follow_redirects=True, -) - -async with http_client: - async with streamable_http_client(url, http_client=http_client) as (read_stream, write_stream): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - session_id = captured_session_ids[0] if captured_session_ids else None +```toml +dependencies = [ + "mcp>=2,<3", + "sse-starlette>=3", # absorb sse-starlette's own 2.x -> 3.x changes +] ``` -### `StreamableHTTPTransport` parameters removed - -The `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters have been removed from `StreamableHTTPTransport`. Configure these on the `httpx.AsyncClient` instead (see example above). - -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. - -The `MCP_PROTOCOL_VERSION` header-name constant has moved: import `MCP_PROTOCOL_VERSION_HEADER` from `mcp.shared.inbound` instead of `MCP_PROTOCOL_VERSION` from `mcp.client.streamable_http`. - -### `terminate_windows_process` removed - -The deprecated `mcp.os.win32.utilities.terminate_windows_process` function has been -removed. Process termination is handled internally by the `stdio_client` context -manager; there is no replacement API. The Windows tree-termination helper -`terminate_windows_process_tree` no longer accepts a `timeout_seconds` argument — -the value was never used (Job Object termination is immediate). - -### `stdio_client` no longer kills children of a gracefully-exited server on POSIX - -When a server exits on its own after `stdio_client` closes its stdin, background -child processes the server leaves behind are no longer killed on POSIX — their -lifetime is the server's business. The old behavior was a side effect of a shutdown -wait gated on the stdio pipes closing rather than on process exit: a child holding -an inherited pipe made a well-behaved server look hung, so its whole process tree -was killed. (That gating is an asyncio behavior specific to Python 3.11+ — on -Python 3.10 and the trio backend the old wait already resolved on process exit, so -the spurious kill never fired there.) A server that does not exit within the grace -period is still terminated -along with its entire process group. On Windows, children stay in the server's Job -Object and are still killed at shutdown — now deterministically when the job handle -is closed, rather than whenever the handle happened to be garbage-collected. +Relax or bump any conflicting pins when upgrading. sse-starlette jumps two majors, so a project that imports `sse_starlette` itself must also work through that library's own breaking changes to co-install with mcp v2. `opentelemetry-api` is a new hard dependency because every outbound request now carries a `_meta` envelope used for OpenTelemetry trace propagation; see [Every outbound request now carries a `_meta` envelope](#every-outbound-request-now-carries-a-_meta-envelope-opentelemetry-is-on-by-default). `mcp-types` is exact-pinned to the SDK version; nothing in a v1 tree can conflict with it, but do not pin `mcp-types` independently of `mcp`. -If you relied on `stdio_client` killing everything the server spawned, make the -server terminate its own children on shutdown (its stdin reaching EOF is the -shutdown signal), or clean up the process tree from the host application after -`stdio_client` exits. - -Two related shutdown refinements: `stdio_client` now closes its end of the pipes -deterministically at shutdown, so a surviving child that keeps writing to an -inherited stdout receives `EPIPE`/`SIGPIPE` once the client is gone (previously the -pipe lingered until garbage collection); and a failed write to a server that is -still running now surfaces as a closed connection (`CONNECTION_CLOSED`) on the read -side instead of leaving requests waiting indefinitely. - -`terminate_posix_process_tree` now requires the process to lead its own process -group (spawned with `start_new_session=True`); the `getpgid()` lookup and the -per-process terminate/kill fallback are gone. The win32 utilities logger is now -named `mcp.os.win32.utilities` (was `client.stdio.win32`). +### `mcp dev` and `mcp install` pin the spawned environment to your SDK version -### WebSocket transport removed +Both commands run your server through a fresh `uv run --with ...` environment. In v1 the +`mcp` requirement in that command was unpinned, so the spawned environment resolved to the +newest stable release rather than the version you had installed; with a v2 pre-release +installed, `mcp dev server.py` built a v1 environment that could not import a v2 server. +Both commands now pin the requirement to the version you are running +(`mcp==`). Source builds and other unpublished versions, which have +nothing on PyPI to pin to, keep the unpinned form. -The WebSocket transport has been removed: `mcp.client.websocket.websocket_client`, `mcp.server.websocket.websocket_server`, and the `ws` optional dependency extra (`mcp[ws]`) no longer exist. WebSocket was never part of the MCP specification. Use the streamable HTTP transport instead (`mcp.client.streamable_http.streamable_http_client` on the client, `streamable_http_app()` on the server), which supports bidirectional communication with server-to-client streaming over standard HTTP. +## Types and wire format ### `mcp.types` moved to the `mcp-types` package @@ -262,7 +133,7 @@ from mcp import Tool, Resource ### Removed type aliases and classes -The following deprecated type aliases and classes have been removed from `mcp_types`: +The following type aliases and classes have been removed from `mcp_types`: | Removed | Replacement | |---------|-------------| @@ -272,6 +143,9 @@ The following deprecated type aliases and classes have been removed from `mcp_ty | `MethodT` | Internal TypeVar, not intended for public use | | `RequestParamsT` | Internal TypeVar, not intended for public use | | `NotificationParamsT` | Internal TypeVar, not intended for public use | +| `AnyFunction` | Use `Callable[..., Any]` directly | +| `ClientRequestType`, `ClientNotificationType`, `ClientResultType`, `ServerRequestType`, `ServerNotificationType`, `ServerResultType` | The union is now the bare name: `ClientRequest`, `ClientNotification`, `ClientResult`, `ServerRequest`, `ServerNotification`, `ServerResult` | +| `TaskExecutionMode`, `TASK_FORBIDDEN`, `TASK_OPTIONAL`, `TASK_REQUIRED`, `TASK_STATUS_*` | Use string literals; `TaskStatus` remains as the literal-union type | **Before (v1):** @@ -288,7 +162,7 @@ from mcp_types import ContentBlock, ResourceTemplateReference ### Field names changed from camelCase to snake_case -All Pydantic model fields in `mcp_types` now use snake_case names for Python attribute access. The JSON wire format is unchanged — serialization still uses camelCase via Pydantic aliases. +All Pydantic model fields in `mcp_types` now use snake_case names for Python attribute access. The JSON wire format is unchanged — traffic the SDK sends still uses camelCase via Pydantic aliases, but your own `model_dump()` calls now need `by_alias=True` to produce it. **Before (v1):** @@ -330,191 +204,210 @@ Common renames: | `listChanged` | `list_changed` | | `progressToken` | `progress_token` | -Because `populate_by_name=True` is set, the old camelCase names still work as constructor kwargs (e.g., `Tool(inputSchema={...})` is accepted), but attribute access must use snake_case (`tool.input_schema`). - -### Server handler results are validated against the protocol schema +The models accept both spellings at construction time, so the old camelCase names still work as constructor kwargs (e.g., `Tool(inputSchema={...})` is accepted), but attribute access must use snake_case (`tool.input_schema`). -Results returned from server handlers are now validated against the negotiated protocol version's schema before being sent. A result that does not conform raises on the server side and the client receives an `INTERNAL_ERROR` response. The case most existing code will hit is `Tool.inputSchema`: the spec requires it to contain `"type": "object"`, so an empty `{}` is now rejected. +**If you serialize models yourself, pass `by_alias=True`.** In v1, `model_dump()` produced wire-format camelCase keys because the fields themselves were camelCase. In v2 the same call emits snake_case keys (`input_schema`, not `inputSchema`), which peers and other MCP implementations will not recognize. No error is raised; the output is silently in the wrong shape. -### Client validates inbound traffic against the protocol schema +```python +tool.model_dump() # {"name": ..., "input_schema": ...} +tool.model_dump(by_alias=True, mode="json") # {"name": ..., "inputSchema": ...} (wire format) +``` -`ClientSession` now validates server requests, notifications, and results against the negotiated protocol version's schema before parsing them into `mcp_types` models. Spec-invalid server output that the previous monolith parse tolerated may now raise `pydantic.ValidationError` from `list_tools()`, `call_tool()`, and similar calls. `_meta` remains the sanctioned place for result extras (and `experimental` for capability extras). +Parsing is unaffected: `model_validate()` accepts both camelCase wire JSON and snake_case dumps. -### `args` parameter removed from `ClientSessionGroup.call_tool()` +### Extra fields on MCP types are no longer preserved -The deprecated `args` parameter has been removed from `ClientSessionGroup.call_tool()`. Use `arguments` instead. +In v1, MCP protocol types were configured with `extra="allow"`: unknown fields passed to a constructor or received from a peer were kept on the model and re-serialized on output. -**Before (v1):** +In v2, MCP types silently ignore extra fields. Unknown constructor keyword arguments and unknown keys in wire data are dropped during validation — no error is raised, and the values do not round-trip: ```python -result = await session_group.call_tool("my_tool", args={"key": "value"}) -``` +from mcp_types import CallToolRequestParams -**After (v2):** +params = CallToolRequestParams( + name="my_tool", + arguments={}, + unknown_field="value", # silently ignored, not stored +) +"unknown_field" in params.model_dump() # False -```python -result = await session_group.call_tool("my_tool", arguments={"key": "value"}) +# _meta remains the supported place for custom data, per the MCP spec +params = CallToolRequestParams( + name="my_tool", + arguments={}, + _meta={"my_custom_key": "value", "another": 123}, # OK, preserved +) ``` -### `cursor` parameter removed from `ClientSession` list methods - -The deprecated `cursor` parameter has been removed from the following `ClientSession` methods: +If you relied on extra fields round-tripping through MCP types, move that data into `_meta`. -- `list_resources()` -- `list_resource_templates()` -- `list_prompts()` -- `list_tools()` +### Resource URI type changed from `AnyUrl` to `str` -Use `params=PaginatedRequestParams(cursor=...)` instead. +The `uri` field on resource-related types now uses `str` instead of Pydantic's `AnyUrl`. This aligns with the [MCP specification schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/2025-11-25/schema.ts) which defines URIs as plain strings (`uri: string`) without strict URL validation. This change allows relative paths like `users/me` that were previously rejected. **Before (v1):** ```python -result = await session.list_resources(cursor="next_page_token") -result = await session.list_tools(cursor="next_page_token") +from pydantic import AnyUrl +from mcp.types import Resource + +# uri was typed as AnyUrl; relative paths were rejected +resource = Resource(name="test", uri=AnyUrl("users/me")) # Would fail validation ``` **After (v2):** ```python -from mcp_types import PaginatedRequestParams +from mcp_types import Resource -result = await session.list_resources(params=PaginatedRequestParams(cursor="next_page_token")) -result = await session.list_tools(params=PaginatedRequestParams(cursor="next_page_token")) +# Plain strings accepted +resource = Resource(name="test", uri="users/me") # Works +resource = Resource(name="test", uri="custom://scheme") # Works +resource = Resource(name="test", uri="https://example.com") # Works ``` -### `ClientSession.get_server_capabilities()` replaced by era-neutral accessors - -`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):** +If your code passes `AnyUrl` objects to URI fields, convert them to strings: ```python -capabilities = session.get_server_capabilities() -# server_info, instructions, protocol_version were not stored — had to capture initialize() return value +# If you have an AnyUrl from elsewhere +uri = str(my_any_url) # Convert to string ``` -**After (v2):** +Affected types: -```python -capabilities = session.server_capabilities -server_info = session.server_info -instructions = session.instructions -version = session.protocol_version -``` +- `Resource.uri` (and subclass `ResourceLink`) +- `ReadResourceRequestParams.uri` +- `ResourceContents.uri` (and subclasses `TextResourceContents`, `BlobResourceContents`) +- `SubscribeRequestParams.uri` +- `UnsubscribeRequestParams.uri` +- `ResourceUpdatedNotificationParams.uri` -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`. +The `Client` and `ClientSession` methods `read_resource()`, `subscribe_resource()`, and `unsubscribe_resource()` now only accept `str` for the `uri` parameter. If you were passing `AnyUrl` objects, convert them to strings: -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 connects on enter — by default it probes `server/discover` and falls back to the initialize handshake.) +```python +# Before (v1) +from pydantic import AnyUrl -### `Client` defaults to `mode='auto'` +await client.read_resource(AnyUrl("test://resource")) -In v1, connecting to a server always performed the `initialize` handshake. In v2, `Client` defaults to `mode='auto'`: on enter it probes `server/discover` and, if the server doesn't support it, falls back to the `initialize` handshake. Pass `mode='legacy'` to force the initialize handshake and reproduce v1's byte-identical pre-2026 behavior, or pass a modern protocol-version string (e.g. `mode='2026-07-28'`) to pin a version without probing. +# After (v2) +await client.read_resource("test://resource") +# Or if you have an AnyUrl from elsewhere: +await client.read_resource(str(my_any_url)) +``` -The probe is transport-independent: v2 servers answer it over stdio (and any other stream-pair transport) as well as streamable HTTP, so `mode='auto'` lands on `2026-07-28` against a v2 server on every transport. If your stdio workflow relies on server-initiated requests (sampling, push elicitation), pass `mode='legacy'` — a 2026-07-28 connection refuses them on every transport. +URI values you read back are also plain strings now. In v1, fields like `Resource.uri` and `ResourceContents.uri` were `AnyUrl` objects, so attribute access such as `uri.scheme` or `uri.host` worked; in v2 that code raises `AttributeError`. Use `urllib.parse` if you need to parse them. Note that v1 also normalized URIs during validation (for example `https://example.com` became `https://example.com/`), while v2 preserves the string exactly as given, so URIs sent on the wire may differ byte-for-byte from what v1 sent. -For an in-process `Client(server)` (where `server` is a `Server` or `MCPServer` instance), `mode='auto'` dispatches calls directly through `DirectDispatcher` with no JSON-RPC framing. Pass `mode='legacy'` if you need the in-memory JSON-RPC transport that v1 used. +### Replace `RootModel` by union types with `TypeAdapter` validation -`Client.send_ping()` is deprecated (ping is removed in 2026-07-28); pin `mode='legacy'` if you need it. +The following union types are no longer `RootModel` subclasses: -### `InputRequiredResult` handling differs between `Client` and `ClientSession` +- `ClientRequest` +- `ServerRequest` +- `ClientNotification` +- `ServerNotification` +- `ClientResult` +- `ServerResult` +- `JSONRPCMessage` -For protocol 2026-07-28, `tools/call`, `prompts/get`, and `resources/read` may return an `InputRequiredResult` asking the client to supply additional input (sampling, elicitation, roots) and retry. +This means you can no longer access `.root` on these types or use `model_validate()` directly on them. Instead, use the provided `TypeAdapter` instances for validation. -On the high-level `Client`, `call_tool`, `get_prompt`, and `read_resource` resolve this automatically: they dispatch each requested input to the matching callback (`sampling_callback`, `elicitation_callback`, `list_roots_callback`) and retry until a final result is returned, so the call still returns the bare `CallToolResult` / `GetPromptResult` / `ReadResourceResult`. The round limit is `Client(input_required_max_rounds=...)` (default 10). Earlier v2 prereleases exposed an `allow_input_required` parameter on these `Client` methods; that parameter has been removed. For manual control use `client.session.call_tool(..., allow_input_required=True)`. Note that `read_timeout_seconds` now bounds each underlying round, not the whole loop; wrap the call in `anyio.fail_after(...)` for a whole-loop bound. +**Before (v1):** -On `ClientSession`, `call_tool` / `get_prompt` / `read_resource` still return the bare result and raise `RuntimeError` if the server requests input. Pass `allow_input_required=True` to receive the `InputRequiredResult` instead, then drive the loop yourself with `input_responses=` / `request_state=`. `ClientSessionGroup.call_tool` accepts the same flag. +```python +from mcp.types import ClientRequest, ServerNotification -### `call_tool` mirrors `x-mcp-header` arguments into `Mcp-Param-*` headers ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)) +# Using RootModel.model_validate() +request = ClientRequest.model_validate(data) +actual_request = request.root # Accessing the wrapped value -For protocol 2026-07-28 over Streamable HTTP, a tool's input-schema property may carry an `x-mcp-header` annotation. When a tool the client has listed is called, each annotated argument is mirrored into an `Mcp-Param-` request header (string verbatim, integer as decimal, boolean as `true`/`false`, base64-sentinel-wrapped when not header-safe; `null`/absent arguments — and values with no scalar rendering, such as objects or arrays — are omitted). The argument is also left in the request body. `list_tools` caches a tool's annotations, so list a tool before calling it to enable mirroring; a tool the client never listed emits no `Mcp-Param-*` headers. Other transports ignore the annotation. +notification = ServerNotification.model_validate(data) +actual_notification = notification.root +``` -### Servers validate `Mcp-Param-*` headers against the request body ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)) +**After (v2):** -The server half of the same contract: on the 2026-07-28 Streamable HTTP path, a `tools/call` whose tool declares `x-mcp-header` annotations is validated before dispatch — each annotated argument and its mirroring `Mcp-Param-*` header must be present together and agree (after base64-sentinel decoding; integers compare numerically), or absent together. A violation is rejected with HTTP 400 and JSON-RPC error `-32020` (`HeaderMismatch`), as the spec requires. A client that sends an annotated argument *without* its header — for example one that never listed the tool — is therefore rejected instead of silently served; the spec's recovery is to re-list and retry. +```python +from mcp_types import client_request_adapter, server_notification_adapter -There is nothing to configure. The server resolves the called tool's schema through its own registered `tools/list` handler (for `MCPServer`, the built-in one), so the validated catalog is exactly what that caller would be shown. Two consequences worth knowing: the listing runs internally on validated calls, so middleware and an expensive or paginated `tools/list` handler see extra invocations; and validation is skipped — never failing the call — when no `tools/list` handler is registered, the tool isn't in the listing, the handler raises (logged as an error), or the call has no arguments and no `Mcp-Param-*` headers. Headers with no matching annotation are ignored; a recognized header supplied more than once is rejected, as is a duplicated `MCP-Protocol-Version`, `Mcp-Method`, or `Mcp-Name` line. The codec and validator are public in `mcp.shared.inbound` (`decode_header_value`, `validate_mcp_param_headers`) for low-level servers hosting their own HTTP entry. +# Using TypeAdapter.validate_python() +request = client_request_adapter.validate_python(data) +# No .root access needed - request is the actual type -Base64-sentinel decoding is strict everywhere it applies, including the `Mcp-Name` header: a `=?base64?...?=` value whose payload is not canonical base64 (wrong padding, stray characters, non-zero trailing bits) or not valid UTF-8 is rejected as malformed rather than leniently decoded. +notification = server_notification_adapter.validate_python(data) +# No .root access needed - notification is the actual type +``` -### `Client` verbs may serve cached responses ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549)) +The same applies when constructing values — the wrapper call is no longer needed: -On protocol 2026-07-28, servers attach caching hints (`ttlMs`, `cacheScope`) to the cacheable results, and `Client` now honors them: `list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, and `read_resource` may serve a cached response instead of making a round trip, for as long as the server's `ttlMs` says the result is fresh. With the default configuration, servers that send no hints, including every pre-2026 server, see identical call-for-call behavior, because hint-less results are not cached (a `CacheConfig.default_ttl_ms` above zero caches them too). Pass `Client(..., cache=False)` to disable the cache and restore v1 behavior exactly; per-call control (`cache_mode`) and configuration (`CacheConfig`) are described in [Caching hints](client/caching.md). +**Before (v1):** -### Server extensions API ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)) +```python +await session.send_notification(ClientNotification(InitializedNotification())) +await session.send_request(ClientRequest(PingRequest()), EmptyResult) +``` -`MCPServer` now accepts opt-in extensions that bundle MCP behaviour behind a -reverse-DNS identifier and advertise it under `ServerCapabilities.extensions` -(the 2026-07-28 capability map). An extension subclasses `mcp.server.extension.Extension` -and overrides only the contribution methods it needs: `tools()`/`resources()`/`methods()` -(additive) and `intercept_tool_call()` (wraps `tools/call`). The `identifier` must be a -`vendor-prefix/name` string following the spec's `_meta` key grammar; a class-level -`identifier` is validated when the subclass is defined, one assigned in `__init__` when -the extension is registered. Pass instances at construction: +**After (v2):** ```python -from mcp.server.mcpserver import MCPServer -from mcp.server.apps import Apps - -mcp = MCPServer("demo", extensions=[Apps()]) +await session.send_notification(InitializedNotification()) +await session.send_request(PingRequest(), EmptyResult) ``` -The reference extension is `mcp.server.apps.Apps` (`io.modelcontextprotocol/ui`): -it binds a tool to a `ui://` UI resource via `_meta.ui.resourceUri`, and -`client_supports_apps(ctx)` gates the SEP-2133 text-only fallback — `True` only -when the client's ui-extension settings list the `text/html;profile=mcp-app` -MIME type, per the Apps spec's required `mimeTypes` field. Every -`@apps.tool(resource_uri=...)` must have a matching resource registered on the -same `Apps` instance (`add_html_resource` for inline HTML, `add_resource` for a -pre-built `Resource`); a tool bound to an unregistered URI raises at -`MCPServer(...)` construction rather than 404ing on `resources/read` at runtime. +**Available adapters:** -Extension methods are strictly additive: a `MethodBinding` cannot name a -spec-defined request method, and registering one whose method collides with -another handler raises at construction. A `MethodBinding` may set -`protocol_versions` to scope an extension method to specific wire versions -(`frozenset()` is rejected — use `None` to admit every version); a request at -any other version is `METHOD_NOT_FOUND`. An -extension handler can call `mcp.server.mcpserver.require_client_extension(ctx, identifier)` -to reject a request with the `-32021` (missing required client capability) error -when the client did not declare the extension. +| Union Type | Adapter | +|------------|---------| +| `ClientRequest` | `client_request_adapter` | +| `ServerRequest` | `server_request_adapter` | +| `ClientNotification` | `client_notification_adapter` | +| `ServerNotification` | `server_notification_adapter` | +| `ClientResult` | `client_result_adapter` | +| `ServerResult` | `server_result_adapter` | +| `JSONRPCMessage` | `jsonrpc_message_adapter` | -On the client, `Client(extensions=...)` takes a sequence of -`mcp.client.ClientExtension` instances. A client extension contributes its -capability ad (mirrored into `ClientCapabilities.extensions`), its result -claims (extra `tools/call` result shapes that `Client.call_tool` resolves -transparently through the claim's resolver), and its notification bindings -(handlers for vendor server notifications). The capability map rides -`server/discover` and every modern request's `_meta` envelope; a legacy -`initialize` handshake carries only the claim-less identifiers, since claimed -result shapes cannot be delivered on a legacy wire. Extensions are off by -default and never alter behaviour unless registered. (The low-level -`ClientSession(extensions=...)` keeps the raw identifier-to-settings dict.) +All adapters are exported from `mcp_types`. -Changed in the v2 pre-releases: earlier alphas took -`Client(extensions={identifier: settings})`, an advertisement-only dict. -Extensions now contribute behaviour (claims and notification handlers), not -just an ad, so the argument is a sequence of declaration objects. An ad-only -entry becomes an `advertise()` call: +### `RequestParams.Meta` replaced with `RequestParamsMeta` TypedDict -**Before (v2 alphas):** +The nested `RequestParams.Meta` Pydantic model class has been replaced with a top-level `RequestParamsMeta` TypedDict. This affects the `ctx.meta` field in request handlers and any code that imports or references this type. -```python -client = Client(server, extensions={"com.example/ui": {"mimeTypes": [...]}}) -``` +**Key changes:** + +- `RequestParams.Meta` (Pydantic model) → `RequestParamsMeta` (TypedDict) +- Attribute access (`meta.progressToken`) → Dictionary access (`meta.get("progress_token")`) +- The `progressToken: ProgressToken | None = None` field is now the `progress_token: NotRequired[ProgressToken]` key -**After:** +**In request context handlers:** ```python -from mcp.client import advertise +# Before (v1) +@server.call_tool() +async def handle_tool(name: str, arguments: dict) -> list[TextContent]: + ctx = server.request_context + if ctx.meta and ctx.meta.progressToken: + await ctx.session.send_progress_notification(ctx.meta.progressToken, 0.5, 100) -client = Client(server, extensions=[advertise("com.example/ui", {"mimeTypes": [...]})]) +# After (v2) +async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + if ctx.meta and "progress_token" in ctx.meta: + await ctx.session.send_progress_notification(ctx.meta["progress_token"], 0.5, 100) + ... + +server = Server("my-server", on_call_tool=handle_call_tool) ``` -`advertise()` is only for identifiers with no client-side behaviour. For a -behavioural extension (e.g. tasks, once its extension ships), construct that -extension's object instead; advertising an identifier you do not implement -asserts wire support you don't have. +The nested `NotificationParams.Meta` class is gone as well. Notification `_meta` is +now a plain `dict[str, Any]`: pass a dict when constructing params +(`ProgressNotificationParams(progress_token=..., progress=0.5, _meta={"traceparent": ...})`) +and read extras with dictionary access (`params.meta["traceparent"]`) instead of +attribute access. The JSON wire format is unchanged. + +### `SUPPORTED_PROTOCOL_VERSIONS` deprecated; `LATEST_PROTOCOL_VERSION` changed meaning + +`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. All of these live in `mcp_types.version` (previously `mcp.shared.version`): `from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS`. + +`LATEST_PROTOCOL_VERSION` also changed value and meaning. In v1 it was `"2025-11-25"`, the version the client offered during initialization. In v2 it is the newest revision the SDK speaks in any era, currently `"2026-07-28"`, which the initialize handshake cannot negotiate. If you offered it in a hand-built `initialize` request or compared the negotiated version against it, use `LATEST_HANDSHAKE_VERSION` instead. These tuples really are tuples now (`SUPPORTED_PROTOCOL_VERSIONS` was a `list` in v1), so list-only operations such as concatenating with a list raise `TypeError`. ### `McpError` renamed to `MCPError` @@ -570,6 +463,8 @@ raise MCPError(INVALID_REQUEST, "bad input") raise MCPError.from_error_data(error_data) ``` +## MCPServer (formerly FastMCP) + ### `FastMCP` renamed to `MCPServer` The `FastMCP` class has been renamed to `MCPServer` to better reflect its role as the main server class in the SDK. This is a simple rename with no functional changes to the class itself. @@ -590,13 +485,61 @@ from mcp.server.mcpserver import MCPServer, Context mcp = MCPServer("Demo") ``` -`Context` is the type annotation for the `ctx` parameter injected into tools, resources, and prompts (see [`get_context()` removed](#mcpserverget_context-removed) below). +`Context` is the type annotation for the `ctx` parameter injected into tools, resources, and prompts (see [`get_context()` removed](#mcpserverget_context-removed) below). The `ctx.fastmcp` property is now `ctx.mcp_server`. All submodules under `mcp.server.fastmcp.*` are now under `mcp.server.mcpserver.*` with the same structure. Common imports: - `Image`, `Audio` — from `mcp.server.mcpserver` (or `.utilities.types`) - `UserMessage`, `AssistantMessage` — from `mcp.server.mcpserver.prompts.base` - `ToolError`, `ResourceError` — from `mcp.server.mcpserver.exceptions` +- `MCPServerError` (renamed from `FastMCPError`) — from `mcp.server.mcpserver.exceptions` + +### Default server name changed from `FastMCP` to `mcp-server` + +A server constructed without a name now defaults to `mcp-server` instead of `FastMCP`. This is the name reported to clients as `serverInfo.name` in the initialize result, so it is visible in client UIs, logs, and monitoring. Nothing raises when this changes; the migrated server simply reports a different identity. + +**Before (v1):** + +```python +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP() # serverInfo.name == "FastMCP" +``` + +**After (v2):** + +```python +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer() # serverInfo.name == "mcp-server" +``` + +If test suites assert on the initialize result, or anything keys configuration or allow-lists off `serverInfo.name`, pass a name explicitly: `MCPServer("FastMCP")` preserves the old value, though a real name for your server is better. + +### `MCPServer` constructor: `title`, `description`, and `version` added to the positional parameters + +The constructor's positional parameter order changed. v2 inserts `title` and `description` before `instructions`, and `version` after `icons`, so the order is now `name`, `title`, `description`, `instructions`, `website_url`, `icons`, `version`. In v1 the order was `name`, `instructions`, `website_url`, `icons`. + +A v1 call that passed `instructions` positionally still runs without error on v2, because both slots are `str | None`. The text silently lands in `title` instead: the server sends it as `serverInfo.title` and stops sending `instructions` in the initialize result, which clients feed to the model. + +**Before (v1):** + +```python +from mcp.server.fastmcp import FastMCP + +# Second positional parameter is instructions +mcp = FastMCP("Demo", "You answer questions about the weather.") +``` + +**After (v2):** + +```python +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("Demo", instructions="You answer questions about the weather.") +``` + +Keep `name` positional and pass everything else by keyword. ### `mount_path` parameter removed from MCPServer @@ -671,13 +614,7 @@ If you were mutating these via `mcp.settings` after construction (e.g., `mcp.set When serving streamable HTTP (stateful or `stateless_http=True`), the server's `lifespan` context manager is now entered once when `StreamableHTTPSessionManager.run()` starts, and the resulting state is shared across all sessions and requests. Previously each session (stateful) or each request (stateless) entered and exited `lifespan` independently. -Lifespans that set up process-wide state (connection pools, caches, background tasks) are unaffected — they now run once instead of per session/request. If your lifespan was acquiring per-connection resources, move that acquisition into the handler body; per-connection cleanup belongs on the connection's `exit_stack` (the public surface for reaching it from high-level `@mcp.tool()` handlers is being finalised as part of the public-surface review). - -### `Server.run()` no longer takes a `stateless` flag; `StatelessModeNotSupported` removed - -The `stateless: bool` parameter on the lowlevel `Server.run()` has been removed. Stateless serving is now a property of how the connection is constructed (the streamable-HTTP manager builds a born-ready `Connection` per request), not a flag the loop driver inspects. - -`StatelessModeNotSupported` has been removed. Server-initiated requests that have no channel to travel on now raise `NoBackChannelError` (an `MCPError` subclass) — the same exception regardless of why the channel is absent. If you were catching `StatelessModeNotSupported`, catch `NoBackChannelError` instead. +Lifespans that set up process-wide state (connection pools, caches, background tasks) are unaffected — they now run once instead of per session/request. If your lifespan was acquiring per-connection resources, move that acquisition into the handler body; per-connection cleanup belongs on the connection's `exit_stack` (a public way to reach it from high-level `@mcp.tool()` handlers is planned). ### `MCPServer.get_context()` removed @@ -726,12 +663,56 @@ running on the event-loop thread: Declare the handler `async def` to keep it on the event loop. +### `MCPServer.call_tool()` returns `CallToolResult` + +`MCPServer.call_tool()` now returns a `CallToolResult` (or an +`InputRequiredResult` when a multi-round tool requests further input). It previously +advertised `Sequence[ContentBlock] | dict[str, Any]` and leaked the internal +conversion shapes (a bare content sequence or a `(content, structured_content)` +tuple), forcing callers to re-assemble a `CallToolResult` themselves. + +If you call `MCPServer.call_tool()` directly, read `.content` and +`.structured_content` off the returned `CallToolResult` instead of branching on +the result type. + +### `MCPServer.get_prompt()` and `read_resource()` may return `InputRequiredResult` + +Like `call_tool()` above, `MCPServer.get_prompt()` now returns +`GetPromptResult | InputRequiredResult` and `MCPServer.read_resource()` returns +`Iterable[ReadResourceContents] | InputRequiredResult`: at 2026-07-28 an +`@mcp.prompt()` function or an `@mcp.resource()` template function may answer +with an `InputRequiredResult` to request client input first (see +[Multi-round-trip requests](handlers/multi-round-trip.md)). If you call these +methods directly, narrow with `isinstance` (or +`assert not isinstance(result, InputRequiredResult)` when your prompt and +resource functions never return one). `Prompt.render()` and +`ResourceTemplate.create_resource()` carry the same union. + +`ctx.read_resource()` inside a handler is unchanged: it still returns content, +and raises `RuntimeError` if the resource requests input. + ### `MCPServer.call_tool()`, `read_resource()`, `get_prompt()` now accept a `context` parameter `MCPServer.call_tool()`, `MCPServer.read_resource()`, and `MCPServer.get_prompt()` now accept an optional `context: Context | None = None` parameter. The framework passes this automatically during normal request handling. If you call these methods directly and omit `context`, a Context with no active request is constructed for you — tools that don't use `ctx` work normally, but any attempt to use `ctx.session`, `ctx.request_id`, etc. will raise. The internal layers (`ToolManager.call_tool`, `Tool.run`, `Prompt.render`, `ResourceTemplate.create_resource`, etc.) now require `context` as a positional argument. +### `MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error + +Raising `MCPError` (or any subclass) inside an `@mcp.tool()` handler now +produces a top-level JSON-RPC error response with the raised `code`, `message`, +and `data` intact. Previously the tool wrapper caught it like any other +exception and returned `CallToolResult(isError=True)`, which discarded the +error code and structured `data`. The one exception was +`UrlElicitationRequiredError`, which v1 already re-raised as a JSON-RPC error; +its behavior is unchanged. + +`MCPError` carries `ErrorData` and is the SDK's protocol-error type — raise it +when the request itself should be rejected (missing client capability, +elicitation required, invalid parameters). For tool *execution* failures the +calling LLM should see and react to, raise any other exception or return +`CallToolResult(is_error=True, ...)` directly; that path is unchanged. + ### Resource not found returns `-32602` and resource lookups raise typed exceptions (SEP-2164) Reading a missing resource now returns JSON-RPC error code `-32602` (invalid params) with the requested URI in `error.data` (`{"uri": ...}`), per [SEP-2164](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2164). Previously the server returned code `0` with no `data`. Clients can now reliably distinguish not-found from other errors; a template handler that raises `ResourceNotFoundError` (from `mcp.server.mcpserver.exceptions`) produces this same response. @@ -808,357 +789,340 @@ means. See [URI templates](servers/uri-templates.md) for the full template syntax, security configuration, and filesystem safety utilities. -### Registering lowlevel handlers from `MCPServer` +### `MCPServer`'s `Context` logging: `message` renamed to `data`, `extra` removed -`MCPServer` does not expose public APIs for `subscribe_resource`, `unsubscribe_resource`, or `set_logging_level` handlers. In v1, the workaround was to reach into the private lowlevel server and use its decorator methods: +On the high-level `Context` object (`mcp.server.mcpserver.Context`), `log()`, `.debug()`, `.info()`, `.warning()`, and `.error()` now take `data: Any` instead of `message: str`, matching the MCP spec's `LoggingMessageNotificationParams.data` field which allows any JSON-serializable value. The `extra` parameter has been removed from the convenience-method signatures. Note that `extra` never worked at runtime in v1 (the kwargs were forwarded to `log()`, which did not accept them, raising `TypeError`), so this only affects code that type-checked but never exercised that path. Pass structured data directly as `data`. -**Before (v1):** +The lowlevel `ServerSession.send_log_message(data: Any)` already accepted arbitrary data and is unchanged. + +`Context.log()` also now accepts all eight [RFC 5424](https://datatracker.ietf.org/doc/html/rfc5424) log levels (`debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, `emergency`) via the `LoggingLevel` type, not just the four it previously allowed. ```python -@mcp._mcp_server.set_logging_level() # pyright: ignore[reportPrivateUsage] -async def handle_set_logging_level(level: str) -> None: - ... +# Before +await ctx.info("Connection failed", extra={"host": "localhost", "port": 5432}) # extra= type-checked but raised TypeError at runtime in v1 +await ctx.log(level="info", message="hello") -mcp._mcp_server.subscribe_resource()(handle_subscribe) # pyright: ignore[reportPrivateUsage] +# After +await ctx.info({"message": "Connection failed", "host": "localhost", "port": 5432}) +await ctx.log(level="info", data="hello") ``` -In v2, the lowlevel `Server` supports arbitrary request handlers directly via `add_request_handler` (the decorator methods are gone; handlers are otherwise constructor-only). From `MCPServer`, access it via `_lowlevel_server`: - -**After (v2):** - -```python -from mcp.server import ServerRequestContext -from mcp_types import EmptyResult, SetLevelRequestParams, SubscribeRequestParams - +Positional calls (`await ctx.info("hello")`) are unaffected. -async def handle_set_logging_level(ctx: ServerRequestContext, params: SetLevelRequestParams) -> EmptyResult: - ... - return EmptyResult() +### `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. -async def handle_subscribe(ctx: ServerRequestContext, params: SubscribeRequestParams) -> EmptyResult: - ... - return EmptyResult() +**Before (v1):** +```python +from mcp.shared.progress import progress -mcp._lowlevel_server.add_request_handler("logging/setLevel", SetLevelRequestParams, handle_set_logging_level) # pyright: ignore[reportPrivateUsage] -mcp._lowlevel_server.add_request_handler("resources/subscribe", SubscribeRequestParams, handle_subscribe) # pyright: ignore[reportPrivateUsage] +with progress(ctx, total=100) as p: + await p.progress(25) ``` -`_lowlevel_server` is private and may change. A public way to register these handlers on `MCPServer` is planned; until then, use this workaround or use the lowlevel `Server` directly. - -### `MCPServer`'s `Context` logging: `message` renamed to `data`, `extra` removed - -On the high-level `Context` object (`mcp.server.mcpserver.Context`), `log()`, `.debug()`, `.info()`, `.warning()`, and `.error()` now take `data: Any` instead of `message: str`, matching the MCP spec's `LoggingMessageNotificationParams.data` field which allows any JSON-serializable value. The `extra` parameter has been removed — pass structured data directly as `data`. +**After — use `Context.report_progress()` (recommended):** -The lowlevel `ServerSession.send_log_message(data: Any)` already accepted arbitrary data and is unchanged. +```python +@mcp.tool() +async def my_tool(x: int, ctx: Context) -> str: + await ctx.report_progress(25, 100) + return "done" +``` -`Context.log()` also now accepts all eight [RFC-5424](https://datatracker.ietf.org/doc/html/rfc5424) log levels (`debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, `emergency`) via the `LoggingLevel` type, not just the four it previously allowed. +**After — use `session.send_progress_notification()` (low-level):** ```python -# Before -await ctx.info("Connection failed", extra={"host": "localhost", "port": 5432}) -await ctx.log(level="info", message="hello") - -# After -await ctx.info({"message": "Connection failed", "host": "localhost", "port": 5432}) -await ctx.log(level="info", data="hello") +await session.send_progress_notification( + progress_token=progress_token, + progress=25, + total=100, +) ``` -Positional calls (`await ctx.info("hello")`) are unaffected. - ### `Context.elicit()` schema gate validates the rendered schema `Context.elicit()` (and `elicit_with_validation()`) now render the schema first and validate each property against the spec's `PrimitiveSchemaDefinition`, raising `TypeError` at the call site for anything outside it. `Optional[T]` fields render as `{"type": ...}` with the field omitted from `required` (previously the non-spec `anyOf` shape). A bare `list[str]` field is rejected because it renders without the required enum items; use `list[Literal[...]]` or `list[str]` with `json_schema_extra` supplying the items. Unions of multiple primitives (e.g. `int | str`) and nested models are rejected. A schema-mismatched *accepted* answer also fails differently: the call now raises `ValueError` with a stable message ("Received an accepted elicitation whose content does not match the requested schema") instead of letting pydantic's `ValidationError` escape with its internals. Code that caught `ValidationError` around `ctx.elicit()` should catch `ValueError` (or rely on the tool's error result). -### Replace `RootModel` by union types with `TypeAdapter` validation - -The following union types are no longer `RootModel` subclasses: - -- `ClientRequest` -- `ServerRequest` -- `ClientNotification` -- `ServerNotification` -- `ClientResult` -- `ServerResult` -- `JSONRPCMessage` +### `isinstance()` checks against `ElicitationResult` raise `TypeError` -This means you can no longer access `.root` on these types or use `model_validate()` directly on them. Instead, use the provided `TypeAdapter` instances for validation. +`ElicitationResult` is now a `TypeAliasType` instead of a plain union, so `ElicitationResult[Confirm]` works as an annotation (resolver dependency injection consumes it that way - see [Dependencies](handlers/dependencies.md)). The members are unchanged: `AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation`. -**Before (v1):** +The one behavioral change: a runtime `isinstance(result, ElicitationResult)` now raises `TypeError`. Check against the member classes directly instead: ```python -from mcp.types import ClientRequest, ServerNotification - -# Using RootModel.model_validate() -request = ClientRequest.model_validate(data) -actual_request = request.root # Accessing the wrapped value - -notification = ServerNotification.model_validate(data) -actual_notification = notification.root +result = await ctx.elicit("Proceed?", Confirm) +if isinstance(result, AcceptedElicitation): + ... # result.data is a Confirm ``` -**After (v2):** - -```python -from mcp_types import client_request_adapter, server_notification_adapter - -# Using TypeAdapter.validate_python() -request = client_request_adapter.validate_python(data) -# No .root access needed - request is the actual type +Narrowing on `result.action` (`"accept"` / `"decline"` / `"cancel"`) is unaffected. -notification = server_notification_adapter.validate_python(data) -# No .root access needed - notification is the actual type -``` +### Registering lowlevel handlers from `MCPServer` -The same applies when constructing values — the wrapper call is no longer needed: +`MCPServer` does not expose public APIs for `subscribe_resource`, `unsubscribe_resource`, or `set_logging_level` handlers. In v1, the workaround was to reach into the private lowlevel server and use its decorator methods: **Before (v1):** ```python -await session.send_notification(ClientNotification(InitializedNotification())) -await session.send_request(ClientRequest(PingRequest()), EmptyResult) -``` - -**After (v2):** +@mcp._mcp_server.set_logging_level() # pyright: ignore[reportPrivateUsage] +async def handle_set_logging_level(level: str) -> None: + ... -```python -await session.send_notification(InitializedNotification()) -await session.send_request(PingRequest(), EmptyResult) +mcp._mcp_server.subscribe_resource()(handle_subscribe) # pyright: ignore[reportPrivateUsage] ``` -**Available adapters:** - -| Union Type | Adapter | -|------------|---------| -| `ClientRequest` | `client_request_adapter` | -| `ServerRequest` | `server_request_adapter` | -| `ClientNotification` | `client_notification_adapter` | -| `ServerNotification` | `server_notification_adapter` | -| `ClientResult` | `client_result_adapter` | -| `ServerResult` | `server_result_adapter` | -| `JSONRPCMessage` | `jsonrpc_message_adapter` | - -All adapters are exported from `mcp_types`. - -### `RequestParams.Meta` replaced with `RequestParamsMeta` TypedDict +In v2, the lowlevel `Server` supports arbitrary request handlers directly via `add_request_handler` (the decorator methods are gone; handlers are otherwise constructor-only). From `MCPServer`, access it via `_lowlevel_server`: -The nested `RequestParams.Meta` Pydantic model class has been replaced with a top-level `RequestParamsMeta` TypedDict. This affects the `ctx.meta` field in request handlers and any code that imports or references this type. +**After (v2):** -**Key changes:** +```python +from mcp.server import ServerRequestContext +from mcp_types import EmptyResult, SetLevelRequestParams, SubscribeRequestParams -- `RequestParams.Meta` (Pydantic model) → `RequestParamsMeta` (TypedDict) -- Attribute access (`meta.progress_token`) → Dictionary access (`meta.get("progress_token")`) -- `progress_token` field changed from `ProgressToken | None = None` to `NotRequired[ProgressToken]` -**In request context handlers:** +async def handle_set_logging_level(ctx: ServerRequestContext, params: SetLevelRequestParams) -> EmptyResult: + ... + return EmptyResult() -```python -# Before (v1) -@server.call_tool() -async def handle_tool(name: str, arguments: dict) -> list[TextContent]: - ctx = server.request_context - if ctx.meta and ctx.meta.progress_token: - await ctx.session.send_progress_notification(ctx.meta.progress_token, 0.5, 100) -# After (v2) -async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: - if ctx.meta and "progress_token" in ctx.meta: - await ctx.session.send_progress_notification(ctx.meta["progress_token"], 0.5, 100) +async def handle_subscribe(ctx: ServerRequestContext, params: SubscribeRequestParams) -> EmptyResult: ... + return EmptyResult() -server = Server("my-server", on_call_tool=handle_call_tool) + +mcp._lowlevel_server.add_request_handler("logging/setLevel", SetLevelRequestParams, handle_set_logging_level) # pyright: ignore[reportPrivateUsage] +mcp._lowlevel_server.add_request_handler("resources/subscribe", SubscribeRequestParams, handle_subscribe) # pyright: ignore[reportPrivateUsage] ``` -### `RequestContext` type parameters simplified +`_lowlevel_server` is private and may change. A public way to register these handlers on `MCPServer` is planned; until then, use this workaround or use the lowlevel `Server` directly. -The `mcp.shared.context` module has been removed. `RequestContext` is now split into `ClientRequestContext` (in `mcp.client.context`) and `ServerRequestContext` (in `mcp.server.context`). +## Lowlevel Server -**`RequestContext` changes:** +### Lowlevel `Server`: decorator-based handlers replaced with constructor `on_*` params -- The `RequestContext[SessionT, LifespanContextT, RequestT]` generic no longer exists; use `ClientRequestContext` or `ServerRequestContext[LifespanContextT, RequestT]` -- Server-specific fields (`lifespan_context`, `request`, `close_sse_stream`, `close_standalone_sse_stream`) moved to new `ServerRequestContext` class in `mcp.server.context` +The lowlevel `Server` class no longer uses decorator methods for handler registration. Instead, handlers are passed as `on_*` keyword arguments to the constructor. **Before (v1):** ```python -from mcp.client.session import ClientSession -from mcp.shared.context import RequestContext, LifespanContextT, RequestT +from mcp.server.lowlevel.server import Server +import mcp.types as types -# RequestContext with 3 type parameters -ctx: RequestContext[ClientSession, LifespanContextT, RequestT] +server = Server("my-server") + +@server.list_tools() +async def handle_list_tools(): + return [types.Tool(name="my_tool", description="A tool", inputSchema={})] + +@server.call_tool() +async def handle_call_tool(name: str, arguments: dict): + return [types.TextContent(type="text", text=f"Called {name}")] ``` **After (v2):** ```python -from mcp.client.context import ClientRequestContext -from mcp.server.context import ServerRequestContext, LifespanContextT, RequestT - -# For client-side context (sampling, elicitation, list_roots callbacks) -ctx: ClientRequestContext - -# For server-specific context with lifespan and request types -server_ctx: ServerRequestContext[LifespanContextT, RequestT] -``` +from mcp.server import Server, ServerRequestContext +from mcp_types import ( + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) -`ServerRequestContext` is now a standalone dataclass — it no longer subclasses `RequestContext[ServerSession]`. It carries the same fields (`session`, `request_id`, `meta`, `lifespan_context`, `request`, `close_sse_stream`, `close_standalone_sse_stream`) plus new `protocol_version: str`, `method: str`, and raw `params: Mapping[str, Any] | None` fields (the last two let middleware read and rewrite the inbound message), so handler code is unaffected, but `isinstance(ctx, RequestContext)` checks and `RequestContext[ServerSession]` annotations need updating to `ServerRequestContext`. +async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[Tool(name="my_tool", description="A tool", input_schema={"type": "object"})]) -The high-level `Context` class (injected into `@mcp.tool()` etc.) similarly dropped its `ServerSessionT` parameter: `Context[ServerSessionT, LifespanContextT, RequestT]` → `Context[LifespanContextT, RequestT]`. Both remaining parameters have defaults, so bare `Context` is usually sufficient: -**Before (v1):** +async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + return CallToolResult( + content=[TextContent(type="text", text=f"Called {params.name}")], + is_error=False, + ) -```python -async def my_tool(ctx: Context[ServerSession, None]) -> str: ... +server = Server("my-server", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool) ``` -**After (v2):** +**Key differences:** -```python -async def my_tool(ctx: Context) -> str: ... -# or, with an explicit lifespan type: -async def my_tool(ctx: Context[MyLifespanState]) -> str: ... -``` +- Handlers receive `(ctx, params)` instead of the full request object or unpacked arguments. `ctx` is a `ServerRequestContext` with `session` and `lifespan_context` fields (plus `request_id`, `meta`, etc. for request handlers). `params` is the typed request params object. +- Handlers return the full result type (e.g. `ListToolsResult`) rather than unwrapped values (e.g. `list[Tool]`). +- The automatic `jsonschema` input/output validation that the old `call_tool()` decorator performed has been removed. There is no built-in replacement — if you relied on schema validation in the lowlevel server, you will need to validate inputs yourself in your handler. -### Version constants +**Complete handler reference:** -`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. All of these live in `mcp_types.version` (previously `mcp.shared.version`): `from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS`. +All handlers receive `ctx: ServerRequestContext` as the first argument. The second argument and return type are: -### `ProgressContext` and `progress()` context manager removed +| v1 decorator | v2 constructor kwarg | `params` type | return type | +|---|---|---|---| +| `@server.list_tools()` | `on_list_tools` | `PaginatedRequestParams \| None` | `ListToolsResult` | +| `@server.call_tool()` | `on_call_tool` | `CallToolRequestParams` | `CallToolResult` | +| `@server.list_resources()` | `on_list_resources` | `PaginatedRequestParams \| None` | `ListResourcesResult` | +| `@server.list_resource_templates()` | `on_list_resource_templates` | `PaginatedRequestParams \| None` | `ListResourceTemplatesResult` | +| `@server.read_resource()` | `on_read_resource` | `ReadResourceRequestParams` | `ReadResourceResult` | +| `@server.subscribe_resource()` | `on_subscribe_resource` | `SubscribeRequestParams` | `EmptyResult` | +| `@server.unsubscribe_resource()` | `on_unsubscribe_resource` | `UnsubscribeRequestParams` | `EmptyResult` | +| `@server.list_prompts()` | `on_list_prompts` | `PaginatedRequestParams \| None` | `ListPromptsResult` | +| `@server.get_prompt()` | `on_get_prompt` | `GetPromptRequestParams` | `GetPromptResult` | +| `@server.completion()` | `on_completion` | `CompleteRequestParams` | `CompleteResult` | +| `@server.set_logging_level()` | `on_set_logging_level` | `SetLevelRequestParams` | `EmptyResult` | +| — | `on_ping` | `RequestParams \| None` | `EmptyResult` | +| `@server.progress_notification()` | `on_progress` | `ProgressNotificationParams` | `None` | +| — | `on_roots_list_changed` | `NotificationParams \| None` | `None` | -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. +All `params` and return types are importable from `mcp_types`. -**Before (v1):** +**Notification handlers:** ```python -from mcp.shared.progress import progress +from mcp.server import Server, ServerRequestContext +from mcp_types import ProgressNotificationParams -with progress(ctx, total=100) as p: - await p.progress(25) -``` -**After — use `Context.report_progress()` (recommended):** +async def handle_progress(ctx: ServerRequestContext, params: ProgressNotificationParams) -> None: + print(f"Progress: {params.progress}/{params.total}") -```python -@server.tool() -async def my_tool(x: int, ctx: Context) -> str: - await ctx.report_progress(25, 100) - return "done" +server = Server("my-server", on_progress=handle_progress) ``` -**After — use `session.send_progress_notification()` (low-level):** - -```python -await session.send_progress_notification( - progress_token=progress_token, - progress=25, - total=100, -) -``` +Registering `on_progress` emits a deprecation warning because the 2026-07-28 spec deprecates client-to-server progress; see [Client-to-server progress deprecated (2026-07-28)](#client-to-server-progress-deprecated-2026-07-28). -### Handler progress reporting: prefer `ctx.report_progress()` over manual `progress_token` +### Lowlevel `Server`: automatic return value wrapping removed -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. +The old decorator-based handlers performed significant automatic wrapping of return values. This magic has been removed — handlers now return fully constructed result types. If you want these conveniences, use `MCPServer` (previously `FastMCP`) instead of the lowlevel `Server`. -`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. +**`call_tool()` — structured output wrapping removed:** -### `create_connected_server_and_client_session` removed +The old decorator accepted several return types and auto-wrapped them into `CallToolResult`: -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. +```python +# Before (v1) — returning a dict auto-wrapped into structured_content + JSON TextContent +@server.call_tool() +async def handle(name: str, arguments: dict) -> dict: + return {"temperature": 22.5, "city": "London"} -**Before (v1):** +# Before (v1) — returning a list auto-wrapped into CallToolResult.content +@server.call_tool() +async def handle(name: str, arguments: dict) -> list[TextContent]: + return [TextContent(type="text", text="Done")] +``` ```python -from mcp.shared.memory import create_connected_server_and_client_session +# After (v2) — construct the full result yourself +import json -async with create_connected_server_and_client_session(server) as session: - result = await session.call_tool("my_tool", {"x": 1}) +async def handle(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + data = {"temperature": 22.5, "city": "London"} + return CallToolResult( + content=[TextContent(type="text", text=json.dumps(data, indent=2))], + structured_content=data, + ) ``` -**After (v2):** +Note: `params.arguments` can be `None` (the old decorator defaulted it to `{}`). Use `params.arguments or {}` to preserve the old behavior. + +**`read_resource()` — content type wrapping removed:** + +The old decorator auto-wrapped `Iterable[ReadResourceContents]` (and the deprecated `str`/`bytes` shorthand) into `TextResourceContents`/`BlobResourceContents`, handling base64 encoding and mime-type defaulting: ```python -from mcp.client import Client +# Before (v1) — Iterable[ReadResourceContents] auto-wrapped +from mcp.server.lowlevel.helper_types import ReadResourceContents -async with Client(server) as client: - result = await client.call_tool("my_tool", {"x": 1}) -``` +@server.read_resource() +async def handle(uri: AnyUrl) -> Iterable[ReadResourceContents]: + return [ReadResourceContents(content="file contents", mime_type="text/plain")] -`Client` accepts the same callback parameters the old helper did (`sampling_callback`, `list_roots_callback`, `logging_callback`, `message_handler`, `elicitation_callback`, `client_info`) plus `raise_exceptions` to surface server-side errors and `mode` to control version negotiation (`'auto'` by default; `'legacy'` reproduces v1's initialize-only handshake). +# Before (v1) — str/bytes shorthand (already deprecated in v1) +@server.read_resource() +async def handle(uri: str) -> str: + return "file contents" -If you need direct access to the underlying `ClientSession` and memory streams (e.g., for low-level transport testing), `create_client_server_memory_streams` is still available in `mcp.shared.memory`: +@server.read_resource() +async def handle(uri: str) -> bytes: + return b"\x89PNG..." +``` ```python -import anyio -from mcp.client.session import ClientSession -from mcp.shared.memory import create_client_server_memory_streams +# After (v2) — construct TextResourceContents or BlobResourceContents yourself +import base64 -async with create_client_server_memory_streams() as (client_streams, server_streams): - async with anyio.create_task_group() as tg: - tg.start_soon(lambda: server.run(*server_streams, server.create_initialization_options())) - async with ClientSession(*client_streams) as session: - await session.initialize() - ... - tg.cancel_scope.cancel() -``` +async def handle_read(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult: + # Text content + return ReadResourceResult( + contents=[TextResourceContents(uri=str(params.uri), text="file contents", mime_type="text/plain")] + ) -### Resource URI type changed from `AnyUrl` to `str` +async def handle_read(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult: + # Binary content — you must base64-encode it yourself + return ReadResourceResult( + contents=[BlobResourceContents( + uri=str(params.uri), + blob=base64.b64encode(b"\x89PNG...").decode("utf-8"), + mime_type="image/png", + )] + ) +``` -The `uri` field on resource-related types now uses `str` instead of Pydantic's `AnyUrl`. This aligns with the [MCP specification schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.ts) which defines URIs as plain strings (`uri: string`) without strict URL validation. This change allows relative paths like `users/me` that were previously rejected. +**`list_tools()`, `list_resources()`, `list_prompts()` — list wrapping removed:** -**Before (v1):** +The old decorators accepted bare lists and wrapped them into the result type: ```python -from pydantic import AnyUrl -from mcp.types import Resource +# Before (v1) +@server.list_tools() +async def handle() -> list[Tool]: + return [Tool(name="my_tool", ...)] -# Required wrapping in AnyUrl -resource = Resource(name="test", uri=AnyUrl("users/me")) # Would fail validation +# After (v2) +async def handle(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[Tool(name="my_tool", ...)]) ``` -**After (v2):** +**Using `MCPServer` instead:** -```python -from mcp_types import Resource +If you prefer the convenience of automatic wrapping, use `MCPServer` which still provides these features through its `@mcp.tool()`, `@mcp.resource()`, and `@mcp.prompt()` decorators. The lowlevel `Server` is intentionally minimal — it provides no magic and gives you full control over the MCP protocol types. -# Plain strings accepted -resource = Resource(name="test", uri="users/me") # Works -resource = Resource(name="test", uri="custom://scheme") # Works -resource = Resource(name="test", uri="https://example.com") # Works -``` +### Lowlevel `Server`: tool handler exceptions no longer become `CallToolResult(is_error=True)` -If your code passes `AnyUrl` objects to URI fields, convert them to strings: +The v1 `@server.call_tool()` decorator caught any exception raised by the handler and returned it to the client as an error-flagged tool result (`isError: true`), so the calling LLM saw the error text as a tool result and could self-correct. In v2, `on_call_tool` is registered with no exception wrapping: a non-`MCPError` exception propagates to the dispatcher and is answered as a top-level JSON-RPC **error response** with `code=0` and `message=str(exc)`. Typical clients (including the SDK's own) raise on a protocol error instead of returning a result, so the error text is no longer LLM-visible. The server also logs a `handler for 'tools/call' raised` traceback that v1 never emitted. + +**Before (v1):** ```python -# If you have an AnyUrl from elsewhere -uri = str(my_any_url) # Convert to string +@server.call_tool() +async def call_tool(name: str, arguments: dict): + raise ValueError("kaboom") # client receives CallToolResult(isError=True) ``` -Affected types: +**After (v2):** catch exceptions in the handler and build the error result yourself: -- `Resource.uri` -- `ReadResourceRequestParams.uri` -- `ResourceContents.uri` (and subclasses `TextResourceContents`, `BlobResourceContents`) -- `SubscribeRequestParams.uri` -- `UnsubscribeRequestParams.uri` -- `ResourceUpdatedNotificationParams.uri` +```python +from mcp.server import Server +from mcp_types import CallToolResult, TextContent -The `Client` and `ClientSession` methods `read_resource()`, `subscribe_resource()`, and `unsubscribe_resource()` now only accept `str` for the `uri` parameter. If you were passing `AnyUrl` objects, convert them to strings: -```python -# Before (v1) -from pydantic import AnyUrl +async def handle_call_tool(ctx, params) -> CallToolResult: + try: + ... # tool logic + except Exception as e: + return CallToolResult( + content=[TextContent(type="text", text=str(e))], + is_error=True, + ) -await client.read_resource(AnyUrl("test://resource")) -# After (v2) -await client.read_resource("test://resource") -# Or if you have an AnyUrl from elsewhere: -await client.read_resource(str(my_any_url)) +server = Server("my-server", on_call_tool=handle_call_tool) ``` +Raise `MCPError` only when the request itself should be rejected as a protocol error; that path is deliberate in v2. Alternatively, use `MCPServer`, whose `@mcp.tool()` wrapper still converts generic exceptions into `is_error=True` results (see [`MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error](#mcperror-raised-from-an-mcptool-handler-now-surfaces-as-a-json-rpc-error)). + ### Lowlevel `Server`: constructor parameters are now keyword-only All parameters after `name` are now keyword-only. If you were passing `version` or other parameters positionally, use keyword arguments instead: @@ -1173,13 +1137,13 @@ server = Server("my-server", version="1.0") ### Lowlevel `Server`: type parameter reduced from 2 to 1 -The `Server` class previously had two type parameters: `Server[LifespanResultT, RequestT]`. The `RequestT` parameter has been removed — handlers now receive typed params directly rather than a generic request type. +The `Server` class previously had two type parameters: `Server[LifespanResultT, RequestT]`. The `RequestT` parameter has been removed. In v1 it typed the transport-level request object exposed as `server.request_context.request`, not anything handlers received directly. ```python # Before (v1) from typing import Any -from mcp.server.lowlevel.server import Server +from mcp.server import Server server: Server[dict[str, Any], Any] = Server(...) @@ -1193,34 +1157,29 @@ server: Server[dict[str, Any]] = Server(...) ### Lowlevel `Server`: `request_handlers` and `notification_handlers` attributes removed -The public `server.request_handlers` and `server.notification_handlers` dictionaries have been removed. Handler registration is now done exclusively through constructor `on_*` keyword arguments. There is no public API to register handlers after construction. +The public `server.request_handlers` and `server.notification_handlers` dictionaries have been removed. Handler registration is now done through constructor `on_*` keyword arguments, or through the public `add_request_handler` / `add_notification_handler` methods. ```python # Before (v1) — direct dict access from mcp.types import ListToolsRequest +server.request_handlers[ListToolsRequest] = handle_list_tools + if ListToolsRequest in server.request_handlers: ... # After (v2) — no public access to handler dicts -# Use the on_* constructor params to register handlers server = Server("my-server", on_list_tools=handle_list_tools) -``` -If you need to check whether a handler is registered, track this yourself — there is currently no public introspection API. - -### Lowlevel `Server`: `add_request_handler` is now public and takes `params_type` +if server.get_request_handler("tools/list") is not None: + ... +``` -The private `_add_request_handler(method, handler)` escape hatch is now the public `add_request_handler(method, params_type, handler)`, alongside a matching `add_notification_handler`. Each takes a `params_type` model that incoming params are validated against before the handler runs. A message with no `params` member validates `{}` against the model, so handlers never receive `None`: all-optional models arrive with their defaults, and models with required fields reject the message as `INVALID_PARAMS` before the handler runs (matching the Go SDK). +If you need to check whether a handler is registered, use `server.get_request_handler(method)` or `server.get_notification_handler(method)`, which return the registered entry or `None`. Note the lookup key is now the method string (for example `"tools/list"`), not the request type. -```python -# Before (v1 / earlier v2 prereleases) -server._add_request_handler("custom/method", my_handler) +### Lowlevel `Server`: `subscribe` capability now correctly reported -# After (v2) -server.add_request_handler("custom/method", MyParams, my_handler) -server.add_notification_handler("notifications/custom", MyNotifyParams, my_notify_handler) -``` +Previously, the lowlevel `Server` hardcoded `subscribe=False` in resource capabilities even when a `subscribe_resource()` handler was registered. The `subscribe` capability is now dynamically set to `True` when an `on_subscribe_resource` handler is provided. Clients that previously didn't see `subscribe: true` in capabilities will now see it when a handler is registered, which may change client behavior. ### Lowlevel `Server`: private `_handle_*` dispatch methods removed @@ -1254,318 +1213,554 @@ The method and the raw inbound params are `ctx.method` and `ctx.params` (`params Previously it also re-raised exceptions yielded by the transport onto the read stream (e.g. JSON parse errors). Those are now debug-logged and dropped regardless of `raise_exceptions`. If you relied on `run()` exiting on a transport-level parse error, that no longer happens. -### Lowlevel `Server`: decorator-based handlers replaced with constructor `on_*` params +### `Server.run()` no longer takes a `stateless` flag + +The `stateless: bool` parameter on the lowlevel `Server.run()` has been removed. Stateless serving is now a property of how the connection is constructed (the streamable-HTTP manager builds a born-ready `Connection` per request), not a flag the loop driver inspects. + +Server-initiated requests that have no channel to travel on now raise `NoBackChannelError` (an `MCPError` subclass) — the same exception regardless of why the channel is absent. In v1 there was no dedicated exception for this case: the transport silently dropped the outbound message and the awaiting call stalled. + +### Lowlevel `Server`: `request_context` property removed + +The `server.request_context` property has been removed. Request context is now passed directly to handlers as the first argument (`ctx`). The `request_ctx` module-level contextvar has been removed entirely. + +**Before (v1):** + +```python +from mcp.server.lowlevel.server import request_ctx + +@server.call_tool() +async def handle_call_tool(name: str, arguments: dict): + ctx = server.request_context # or request_ctx.get() + await ctx.session.send_log_message(level="info", data="Processing...") + return [types.TextContent(type="text", text="Done")] +``` + +**After (v2):** + +```python +from mcp.server import ServerRequestContext +from mcp_types import CallToolRequestParams, CallToolResult, TextContent + + +async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + await ctx.session.send_log_message(level="info", data="Processing...") + return CallToolResult( + content=[TextContent(type="text", text="Done")], + is_error=False, + ) +``` + +### `RequestContext` type parameters simplified + +`RequestContext` has been removed from `mcp.shared.context` (importing it now raises `ImportError`; the module now holds an unrelated internal class). It is split into `ClientRequestContext` (in `mcp.client.context`) and `ServerRequestContext` (in `mcp.server.context`). + +**`RequestContext` changes:** + +- The `RequestContext[SessionT, LifespanContextT, RequestT]` generic no longer exists; use `ClientRequestContext` or `ServerRequestContext[LifespanContextT, RequestT]` +- Server-specific fields (`lifespan_context`, `request`, `close_sse_stream`, `close_standalone_sse_stream`) moved to new `ServerRequestContext` class in `mcp.server.context` + +**Before (v1):** + +```python +from mcp.client.session import ClientSession +from mcp.shared.context import RequestContext, LifespanContextT, RequestT + +# RequestContext with 3 type parameters +ctx: RequestContext[ClientSession, LifespanContextT, RequestT] +``` + +**After (v2):** + +```python +from mcp.client.context import ClientRequestContext +from mcp.server.context import ServerRequestContext, LifespanContextT, RequestT + +# For client-side context (sampling, elicitation, list_roots callbacks) +ctx: ClientRequestContext + +# For server-specific context with lifespan and request types +server_ctx: ServerRequestContext[LifespanContextT, RequestT] +``` + +`ServerRequestContext` is a standalone dataclass rather than a specialization of a shared base class. It carries the same fields (`session`, `request_id`, `meta`, `lifespan_context`, `request`, `close_sse_stream`, `close_standalone_sse_stream`) plus new `protocol_version: str`, `method: str`, and raw `params: Mapping[str, Any] | None` fields, so handler code is mostly unaffected, but `isinstance(ctx, RequestContext)` checks and `RequestContext[ServerSession]` annotations need updating to `ServerRequestContext`. + +One field is newly optional: `request_id` is now `RequestId | None` (in v1 it was always a `RequestId`). The same context class is passed to notification handlers, where `request_id` is `None`, so code that forwards `ctx.request_id` as a definite `RequestId` needs a `None` check to satisfy type checkers. + +The high-level `Context` class (injected into `@mcp.tool()` etc.) similarly dropped its `ServerSessionT` parameter: `Context[ServerSessionT, LifespanContextT, RequestT]` → `Context[LifespanContextT, RequestT]`. Both remaining parameters have defaults, so bare `Context` is usually sufficient: + +**Before (v1):** + +```python +async def my_tool(ctx: Context[ServerSession, None]) -> str: ... +``` + +**After (v2):** + +```python +async def my_tool(ctx: Context) -> str: ... +# or, with an explicit lifespan type: +async def my_tool(ctx: Context[MyLifespanState]) -> str: ... +``` + +### `ServerSession` is now a thin proxy (no longer a `BaseSession`) + +`ServerSession` no longer subclasses `BaseSession`. It is now a small per-request proxy that exposes `send_request`, `send_notification`, the typed convenience helpers (`create_message`, `elicit_form`, `send_log_message`, `send_tool_list_changed`, ...), `client_params`, `protocol_version`, and `check_client_capability`. The receive loop, `initialize` handling, and per-request task isolation that previously lived in `ServerSession` have moved to `JSONRPCDispatcher` and `ServerRunner`. + +`ServerSession` is normally constructed for you by `Server.run()` and reached via `ctx.session` in handlers, so most servers are unaffected. If you were constructing or subclassing it directly: + +**Constructor change:** + +```python +# Before (v1) +session = ServerSession(read_stream, write_stream, init_options, stateless=False) + +# After (v2) +session = ServerSession(request_outbound, connection) +# where `request_outbound` is a DispatchContext and `connection` is a Connection +``` + +In practice, replace direct `ServerSession` use with `Server.run(read_stream, write_stream, init_options)` and let the framework wire it up. + +**Removed from `mcp.server.session`:** + +- `InitializationState` enum and `ServerSession._initialization_state` — initialization tracking is now on `Connection` (`connection.initialized` is an `anyio.Event`, `connection.client_params` holds the init params). +- `ServerRequestResponder` type alias. +- `ServerSession.incoming_messages` stream — there is no longer a public stream of inbound messages to iterate. Register handlers via the `on_*` constructor params (or `add_request_handler`) and use `Server.middleware` to observe every inbound request and notification (`initialize`, unknown methods, validation failures, and `notifications/initialized` included). +- `ServerSession.__aenter__` / `__aexit__` — `ServerSession` is no longer an async context manager. +- The private `_receive_loop`, `_received_request`, `_received_notification`, and `_handle_incoming` overrides — there is nothing to override on `ServerSession` anymore. To intercept inbound messages, use `Server.middleware` (see [Lowlevel `Server`: private `_handle_*` dispatch methods removed](#lowlevel-server-private-_handle_-dispatch-methods-removed)). + +### `ServerSession.elicit()` and `elicit_form()` take `requested_schema`, not `requestedSchema` + +The schema parameter of `ServerSession.elicit()` and `ServerSession.elicit_form()` was renamed from `requestedSchema` to `requested_schema`. This is a plain method parameter, so the `populate_by_name` alias support that keeps camelCase field names working on Pydantic models does not apply here. Keyword callers raise on every call, before any wire traffic (nothing fails at import; the client sees a tool error): + +```text +TypeError: ServerSession.elicit_form() got an unexpected keyword argument 'requestedSchema'. Did you mean 'requested_schema'? +``` + +**Before (v1):** + +```python +result = await ctx.session.elicit_form( + message="Your name?", + requestedSchema={ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + }, +) +``` + +**After (v2):** + +```python +result = await ctx.session.elicit_form( + message="Your name?", + requested_schema={ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + }, +) +``` + +Positional callers (`session.elicit_form(message, schema)`) are unaffected. `elicit_url()` already used snake_case parameters in v1; only `elicit()` and `elicit_form()` changed. + +## Clients + +### `Client` defaults to `mode='auto'` + +In v1, connecting to a server always performed the `initialize` handshake. In v2, `Client` defaults to `mode='auto'`: on enter it probes `server/discover` and, if the server doesn't support it, falls back to the `initialize` handshake. Pass `mode='legacy'` to force the initialize handshake and reproduce v1's pre-2026 connection sequence (the per-request wire shape still differs from v1; see [Every outbound request now carries a `_meta` envelope](#every-outbound-request-now-carries-a-_meta-envelope-opentelemetry-is-on-by-default)), or pass a modern protocol-version string (e.g. `mode='2026-07-28'`) to pin a version without probing. + +The probe is transport-independent: v2 servers answer it over stdio (and any other stream-pair transport) as well as streamable HTTP, so `mode='auto'` lands on `2026-07-28` against a v2 server on every transport. If your stdio workflow relies on server-initiated requests (sampling, push elicitation), pass `mode='legacy'` — a 2026-07-28 connection refuses them on every transport. + +For an in-process `Client(server)` (where `server` is a `Server` or `MCPServer` instance), `mode='auto'` dispatches calls directly through `DirectDispatcher` with no JSON-RPC framing. Pass `mode='legacy'` if you need the in-memory JSON-RPC transport that v1 used. + +`Client.send_ping()` is deprecated (ping is removed in 2026-07-28); pin `mode='legacy'` if you need it. + +### `ClientSession.get_server_capabilities()` replaced by era-neutral accessors + +`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):** + +```python +capabilities = session.get_server_capabilities() +# server_info, instructions, protocol_version were not stored — had to capture initialize() return value +``` + +**After (v2):** + +```python +capabilities = session.server_capabilities +server_info = session.server_info +instructions = session.instructions +version = session.protocol_version +``` + +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 connects on enter — by default it probes `server/discover` and falls back to the initialize handshake.) + +### `cursor` parameter removed from `ClientSession` list methods + +The deprecated `cursor` parameter has been removed from the following `ClientSession` methods: + +- `list_resources()` +- `list_resource_templates()` +- `list_prompts()` +- `list_tools()` -The lowlevel `Server` class no longer uses decorator methods for handler registration. Instead, handlers are passed as `on_*` keyword arguments to the constructor. +Use `params=PaginatedRequestParams(cursor=...)` instead. **Before (v1):** ```python -from mcp.server.lowlevel.server import Server - -server = Server("my-server") - -@server.list_tools() -async def handle_list_tools(): - return [types.Tool(name="my_tool", description="A tool", inputSchema={})] - -@server.call_tool() -async def handle_call_tool(name: str, arguments: dict): - return [types.TextContent(type="text", text=f"Called {name}")] +result = await session.list_resources(cursor="next_page_token") +result = await session.list_tools(cursor="next_page_token") ``` **After (v2):** ```python -from mcp.server import Server, ServerRequestContext -from mcp_types import ( - CallToolRequestParams, - CallToolResult, - ListToolsResult, - PaginatedRequestParams, - TextContent, - Tool, -) +from mcp_types import PaginatedRequestParams -async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: - return ListToolsResult(tools=[Tool(name="my_tool", description="A tool", input_schema={"type": "object"})]) +result = await session.list_resources(params=PaginatedRequestParams(cursor="next_page_token")) +result = await session.list_tools(params=PaginatedRequestParams(cursor="next_page_token")) +``` + +### `args` parameter removed from `ClientSessionGroup.call_tool()` +The deprecated `args` parameter has been removed from `ClientSessionGroup.call_tool()`. Use `arguments` instead. -async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: - return CallToolResult( - content=[TextContent(type="text", text=f"Called {params.name}")], - is_error=False, - ) +**Before (v1):** -server = Server("my-server", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool) +```python +result = await session_group.call_tool("my_tool", args={"key": "value"}) ``` -**Key differences:** +**After (v2):** -- Handlers receive `(ctx, params)` instead of the full request object or unpacked arguments. `ctx` is a `ServerRequestContext` with `session` and `lifespan_context` fields (plus `request_id`, `meta`, etc. for request handlers). `params` is the typed request params object. -- Handlers return the full result type (e.g. `ListToolsResult`) rather than unwrapped values (e.g. `list[Tool]`). -- The automatic `jsonschema` input/output validation that the old `call_tool()` decorator performed has been removed. There is no built-in replacement — if you relied on schema validation in the lowlevel server, you will need to validate inputs yourself in your handler. +```python +result = await session_group.call_tool("my_tool", arguments={"key": "value"}) +``` -**Complete handler reference:** +### Timeouts take `float` seconds instead of `timedelta` -All handlers receive `ctx: ServerRequestContext` as the first argument. The second argument and return type are: +Every timeout parameter that took a `datetime.timedelta` in v1 now takes plain seconds as a `float`: -| v1 decorator | v2 constructor kwarg | `params` type | return type | -|---|---|---|---| -| `@server.list_tools()` | `on_list_tools` | `PaginatedRequestParams \| None` | `ListToolsResult` | -| `@server.call_tool()` | `on_call_tool` | `CallToolRequestParams` | `CallToolResult` | -| `@server.list_resources()` | `on_list_resources` | `PaginatedRequestParams \| None` | `ListResourcesResult` | -| `@server.list_resource_templates()` | `on_list_resource_templates` | `PaginatedRequestParams \| None` | `ListResourceTemplatesResult` | -| `@server.read_resource()` | `on_read_resource` | `ReadResourceRequestParams` | `ReadResourceResult` | -| `@server.subscribe_resource()` | `on_subscribe_resource` | `SubscribeRequestParams` | `EmptyResult` | -| `@server.unsubscribe_resource()` | `on_unsubscribe_resource` | `UnsubscribeRequestParams` | `EmptyResult` | -| `@server.list_prompts()` | `on_list_prompts` | `PaginatedRequestParams \| None` | `ListPromptsResult` | -| `@server.get_prompt()` | `on_get_prompt` | `GetPromptRequestParams` | `GetPromptResult` | -| `@server.completion()` | `on_completion` | `CompleteRequestParams` | `CompleteResult` | -| `@server.set_logging_level()` | `on_set_logging_level` | `SetLevelRequestParams` | `EmptyResult` | -| — | `on_ping` | `RequestParams \| None` | `EmptyResult` | -| `@server.progress_notification()` | `on_progress` | `ProgressNotificationParams` | `None` | -| — | `on_roots_list_changed` | `NotificationParams \| None` | `None` | +| Surface | v1 type | v2 type | +|---|---|---| +| `ClientSession(read_timeout_seconds=...)` | `timedelta \| None` | `float \| None` | +| `ClientSession.call_tool(read_timeout_seconds=...)` | `timedelta \| None` | `float \| None` | +| `ClientSession.send_request(request_read_timeout_seconds=...)` | `timedelta \| None` | `float \| None` | +| `ClientSessionGroup.call_tool(read_timeout_seconds=...)` | `timedelta \| None` | `float \| None` | +| `ClientSessionParameters.read_timeout_seconds` | `timedelta \| None` | `float \| None` | +| `StreamableHttpParameters.timeout` / `.sse_read_timeout` | `timedelta` | `float` | +| `ServerSession.send_request(request_read_timeout_seconds=...)` | `timedelta \| None` | `float \| None` | -All `params` and return types are importable from `mcp_types`. +`SseServerParameters` already used `float` in v1 and is unaffected. -**Notification handlers:** +**Before (v1):** ```python -from mcp.server import Server, ServerRequestContext -from mcp_types import ProgressNotificationParams +from datetime import timedelta +session = ClientSession(read_stream, write_stream, read_timeout_seconds=timedelta(seconds=30)) +result = await session.call_tool("slow_tool", {}, read_timeout_seconds=timedelta(minutes=2)) -async def handle_progress(ctx: ServerRequestContext, params: ProgressNotificationParams) -> None: - print(f"Progress: {params.progress}/{params.total}") - -server = Server("my-server", on_progress=handle_progress) +params = StreamableHttpParameters( + url="https://example.com/mcp", + timeout=timedelta(seconds=30), + sse_read_timeout=timedelta(seconds=300), +) ``` -### Lowlevel `Server`: automatic return value wrapping removed - -The old decorator-based handlers performed significant automatic wrapping of return values. This magic has been removed — handlers now return fully constructed result types. If you want these conveniences, use `MCPServer` (previously `FastMCP`) instead of the lowlevel `Server`. - -**`call_tool()` — structured output wrapping removed:** - -The old decorator accepted several return types and auto-wrapped them into `CallToolResult`: +**After (v2):** ```python -# Before (v1) — returning a dict auto-wrapped into structured_content + JSON TextContent -@server.call_tool() -async def handle(name: str, arguments: dict) -> dict: - return {"temperature": 22.5, "city": "London"} +session = ClientSession(read_stream, write_stream, read_timeout_seconds=30) +result = await session.call_tool("slow_tool", {}, read_timeout_seconds=120) -# Before (v1) — returning a list auto-wrapped into CallToolResult.content -@server.call_tool() -async def handle(name: str, arguments: dict) -> list[TextContent]: - return [TextContent(type="text", text="Done")] +params = StreamableHttpParameters( + url="https://example.com/mcp", + timeout=30, + sse_read_timeout=300, +) ``` -```python -# After (v2) — construct the full result yourself -import json +The failure mode depends on the surface. `StreamableHttpParameters` is a pydantic model, so a leftover timedelta fails loudly at construction (`ValidationError: Input should be a valid number`). The session-path parameters still accept the timedelta at construction or call time; the first request that arms the timeout then crashes inside anyio with `TypeError: unsupported operand type(s) for +: 'float' and 'datetime.timedelta'`, an error that never names the parameter. One narrowing note: v1's `StreamableHttpParameters` coerced bare numbers into timedelta, so v1 code that already passed numbers there keeps working; only explicit-timedelta code breaks. -async def handle(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: - data = {"temperature": 22.5, "city": "London"} - return CallToolResult( - content=[TextContent(type="text", text=json.dumps(data, indent=2))], - structured_content=data, - ) -``` +The same change applies server-side: `ServerSession.send_request(request_read_timeout_seconds=...)`, called from a lowlevel handler via `ctx.session`, is now `float | None`. A v1-style timedelta raises the same anyio `TypeError`, after the request has already been written to the wire, so the handler crashes instead of receiving the response. -Note: `params.arguments` can be `None` (the old decorator defaulted it to `{}`). Use `params.arguments or {}` to preserve the old behavior. +To migrate, replace `timedelta(...)` with plain seconds, or mechanically append `.total_seconds()` to an existing timedelta value. -**`read_resource()` — content type wrapping removed:** +### Client request timeouts now raise `-32001` (`REQUEST_TIMEOUT`) instead of `408` -The old decorator auto-wrapped `Iterable[ReadResourceContents]` (and the deprecated `str`/`bytes` shorthand) into `TextResourceContents`/`BlobResourceContents`, handling base64 encoding and mime-type defaulting: +A client request that exceeds `read_timeout_seconds` still raises the SDK's protocol error (`MCPError`, previously `McpError`), but the error code changed from the HTTP status `408` (`httpx.codes.REQUEST_TIMEOUT`) to the JSON-RPC code `-32001` (`REQUEST_TIMEOUT`, importable from `mcp_types`), matching the TypeScript SDK. The message changed too: v1 said `"Timed out while waiting for response to ClientRequest. Waited 5.0 seconds."`, v2 says `"Request 'tools/call' timed out"`. `MCPError.error` still exists, so a migrated `e.error.code == 408` check runs without error and silently never matches; timeouts fall through to whatever generic-error handling follows. Code that matched on the old message text breaks too. Compare against `REQUEST_TIMEOUT` instead. + +**Before (v1):** ```python -# Before (v1) — Iterable[ReadResourceContents] auto-wrapped -from mcp.server.lowlevel.helper_types import ReadResourceContents +import httpx +from mcp.shared.exceptions import McpError -@server.read_resource() -async def handle(uri: AnyUrl) -> Iterable[ReadResourceContents]: - return [ReadResourceContents(content="file contents", mime_type="text/plain")] +try: + result = await session.call_tool("slow_tool", {}) +except McpError as e: + if e.error.code == httpx.codes.REQUEST_TIMEOUT: # 408 + ... # retry / back off + else: + raise +``` -# Before (v1) — str/bytes shorthand (already deprecated in v1) -@server.read_resource() -async def handle(uri: str) -> str: - return "file contents" +**After (v2):** -@server.read_resource() -async def handle(uri: str) -> bytes: - return b"\x89PNG..." +```python +from mcp.shared.exceptions import MCPError +from mcp_types import REQUEST_TIMEOUT # -32001 + +try: + result = await client.call_tool("slow_tool", {}) +except MCPError as e: + if e.code == REQUEST_TIMEOUT: + ... # retry / back off + else: + raise ``` -```python -# After (v2) — construct TextResourceContents or BlobResourceContents yourself -import base64 +`e.error.code` also still works; `e.code` is the v2 convenience property. `mcp.types` no longer exists, so the constant comes from `mcp_types`. The example uses the high-level `Client`; `ClientSession.call_tool()` raises the same `MCPError`. -async def handle_read(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult: - # Text content - return ReadResourceResult( - contents=[TextResourceContents(uri=str(params.uri), text="file contents", mime_type="text/plain")] - ) +### `ClientSession` now runs on `JSONRPCDispatcher`; `BaseSession` removed -async def handle_read(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult: - # Binary content — you must base64-encode it yourself - return ReadResourceResult( - contents=[BlobResourceContents( - uri=str(params.uri), - blob=base64.b64encode(b"\x89PNG...").decode("utf-8"), - mime_type="image/png", - )] - ) -``` +`ClientSession`'s public surface is unchanged — same constructor apart from timeout parameters (see [Timeouts take `float` seconds instead of `timedelta`](#timeouts-take-float-seconds-instead-of-timedelta)), typed methods, manual `initialize()`, and async context-manager lifecycle — but `BaseSession`, the v1 receive loop underneath it, is removed with no shim. The engine now lives in `JSONRPCDispatcher` (`mcp.shared.jsonrpc_dispatcher`). To customize client behavior, use the `ClientSession` constructor callbacks, or pass a pre-built dispatcher via the new keyword-only `dispatcher=` constructor argument (e.g. a `DirectDispatcher` for in-process embedding). -**`list_tools()`, `list_resources()`, `list_prompts()` — list wrapping removed:** +Behavior changes: -The old decorators accepted bare lists and wrapped them into the result type: +- **Callbacks and notifications now run concurrently.** In v1 the receive loop processed one inbound message at a time, so callbacks ran inline and in order. Now each delivery starts in arrival order but runs as its own task. Server-initiated request callbacks (`sampling`, `elicitation`, `roots`) no longer block other traffic, may themselves send requests without deadlocking, and are interrupted if the server sends `notifications/cancelled` (the request is then answered with an error). Notification callbacks (`logging_callback`, `progress_callback`, `message_handler`) may interleave, and a `progress_callback` may run after the request it reports on has returned; there is no built-in bound on concurrent deliveries. Transport-level errors reach `message_handler` the same way, and a `message_handler` that raises is logged rather than fatal to the session. Callbacks that need strict sequencing must coordinate themselves. +- **Timeouts**: a timed-out or abandoned request is now followed by `notifications/cancelled`, so the server stops the handler instead of leaving it running. +- **A raising request callback** is answered with `code=0` and the exception text; v1 flattened every callback exception to `INVALID_PARAMS`. For a specific error response, return `ErrorData` (unchanged) or raise `MCPError`. One carve-out: pydantic's `ValidationError` is still answered with `INVALID_PARAMS`, as in v1. +- **`send_request` before entering the context manager** raises `RuntimeError` immediately; v1 wrote to the transport and hung until the timeout. After the connection has closed it raises `MCPError` (`CONNECTION_CLOSED`) instead. `send_notification` before entry still works. +- **`send_notification` after the connection has closed is dropped with a debug log instead of raising.** In v1 the send raised `anyio.BrokenResourceError` (peer gone) or `anyio.ClosedResourceError` (session torn down), and this applied to the typed helpers (`send_roots_list_changed`, `send_progress_notification`) too. Code that used the exception as its disconnect signal should probe with a request instead (`send_request` still raises `MCPError` after close, see above) or scope the sending task to the session's lifetime. +- **`send_notification` no longer takes `related_request_id`, and `send_request` no longer accepts `ServerMessageMetadata`.** No client transport ever serialized these hints; progress and response correlation via `progressToken` and the request id is unaffected. +- **Client callbacks now receive `mcp.client.ClientRequestContext`** (its `request_id` is always populated); the `mcp.shared.context.RequestContext` generic is deleted. Annotations spelled `RequestContext[ClientSession, Any]` become `ClientRequestContext` (details in [`RequestContext` type parameters simplified](#requestcontext-type-parameters-simplified)). -```python -# Before (v1) -@server.list_tools() -async def handle() -> list[Tool]: - return [Tool(name="my_tool", ...)] +`mcp.shared.session` is now a compatibility module: `ProgressFnT` is re-exported (its home is `mcp.shared.dispatcher`), and `RequestResponder` remains as a typing-only stub so `MessageHandlerFnT` annotations keep importing. `RequestResponder.respond()` no longer exists, and neither do the cancellation-tracking members (`cancel()`, the `cancelled` and `in_flight` properties, the `on_complete` constructor argument) or `BaseSession._in_flight`; inbound cancellation is handled by `JSONRPCDispatcher`. -# After (v2) -async def handle(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: - return ListToolsResult(tools=[Tool(name="my_tool", ...)]) -``` +### Experimental Tasks support removed -**Using `MCPServer` instead:** +Tasks ([SEP-1686](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1686)) have been removed from the MCP specification and are no longer part of this SDK. The `mcp.client.experimental`, `mcp.server.experimental`, `mcp.shared.experimental`, and `mcp.server.lowlevel.experimental` modules have been removed, along with the `experimental` properties on `ClientSession`, `ServerSession`, `Server`, and `ServerRequestContext`. The corresponding `Task*` types remain in `mcp_types` as types-only definitions, except the `TaskExecutionMode` alias, whose literal is now inlined on `ToolExecution.task_support`. -If you prefer the convenience of automatic wrapping, use `MCPServer` which still provides these features through its `@mcp.tool()`, `@mcp.resource()`, and `@mcp.prompt()` decorators. The lowlevel `Server` is intentionally minimal — it provides no magic and gives you full control over the MCP protocol types. +The 2026-07-28 revision reintroduces Tasks as an official extension: [SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663), `io.modelcontextprotocol/tasks`, redesigned around polling (`tasks/get`) instead of a blocking `tasks/result`. This SDK does not implement the extension yet. -### Lowlevel `Server`: `request_context` property removed +## Transports -The `server.request_context` property has been removed. Request context is now passed directly to handlers as the first argument (`ctx`). The `request_ctx` module-level contextvar has been removed entirely. +### `streamablehttp_client` removed + +The deprecated `streamablehttp_client` function has been removed. Use `streamable_http_client` instead. **Before (v1):** ```python -from mcp.server.lowlevel.server import request_ctx +from mcp.client.streamable_http import streamablehttp_client -@server.call_tool() -async def handle_call_tool(name: str, arguments: dict): - ctx = server.request_context # or request_ctx.get() - await ctx.session.send_log_message(level="info", data="Processing...") - return [types.TextContent(type="text", text="Done")] +async with streamablehttp_client( + url="http://localhost:8000/mcp", + headers={"Authorization": "Bearer token"}, + timeout=30, + sse_read_timeout=300, + auth=my_auth, +) as (read_stream, write_stream, get_session_id): + ... ``` **After (v2):** ```python -from mcp.server import ServerRequestContext -from mcp_types import CallToolRequestParams, CallToolResult, TextContent +import httpx +from mcp.client.streamable_http import streamable_http_client +# Configure headers, timeout, and auth on the httpx.AsyncClient +http_client = httpx.AsyncClient( + headers={"Authorization": "Bearer token"}, + timeout=httpx.Timeout(30, read=300), + auth=my_auth, + follow_redirects=True, +) -async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: - await ctx.session.send_log_message(level="info", data="Processing...") - return CallToolResult( - content=[TextContent(type="text", text="Done")], - is_error=False, - ) +async with http_client: + async with streamable_http_client( + url="http://localhost:8000/mcp", + http_client=http_client, + ) as (read_stream, write_stream): + ... ``` -### `ServerRequestContext`: request-specific fields are now optional +v1's internal client set `follow_redirects=True`; set it explicitly when supplying your own `httpx.AsyncClient` to preserve that behavior. + +### `get_session_id` callback removed from `streamable_http_client` + +The `get_session_id` callback (third element of the returned tuple) has been removed from `streamable_http_client`. The function now returns a 2-tuple `(read_stream, write_stream)` instead of a 3-tuple. + +The `GetSessionIdCallback` type alias is gone as well, so `from mcp.client.streamable_http import GetSessionIdCallback` now raises `ImportError`. Drop the annotation, or inline `Callable[[], str | None]` if your own wrapper code still needs the type. + +If you need to capture the session ID (e.g., for session resumption testing), you can use httpx event hooks to capture it from the response headers: -`ServerRequestContext` now uses optional fields for request-specific data (`request_id`, `meta`, etc.) so it can be used for both request and notification handlers. In notification handlers, these fields are `None`. +**Before (v1):** ```python -from mcp.server import ServerRequestContext +from mcp.client.streamable_http import streamable_http_client -# request_id, meta, etc. are available in request handlers -# but None in notification handlers +async with streamable_http_client(url) as (read_stream, write_stream, get_session_id): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + session_id = get_session_id() # Get session ID via callback ``` -### `ServerSession` is now a thin proxy (no longer a `BaseSession`) +**After (v2):** -`ServerSession` no longer subclasses `BaseSession`. It is now a small connection-scoped proxy that exposes `send_request`, `send_notification`, the typed convenience helpers (`create_message`, `elicit_form`, `send_log_message`, `send_tool_list_changed`, ...), `client_params`, `protocol_version`, and `check_client_capability`. The receive loop, `initialize` handling, and per-request task isolation that previously lived in `ServerSession` have moved to `JSONRPCDispatcher` and `ServerRunner`. +```python +import httpx +from mcp.client.streamable_http import streamable_http_client -`ServerSession` is normally constructed for you by `Server.run()` and reached via `ctx.session` in handlers, so most servers are unaffected. If you were constructing or subclassing it directly: +# Option 1: Simply ignore if you don't need the session ID +async with streamable_http_client(url) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() -**Constructor change:** +# Option 2: Capture session ID via httpx event hooks if needed +captured_session_ids: list[str] = [] -```python -# Before (v1) -session = ServerSession(read_stream, write_stream, init_options, stateless=False) +async def capture_session_id(response: httpx.Response) -> None: + session_id = response.headers.get("mcp-session-id") + if session_id: + captured_session_ids.append(session_id) + +http_client = httpx.AsyncClient( + event_hooks={"response": [capture_session_id]}, + follow_redirects=True, +) -# After (v2) -session = ServerSession(request_outbound, connection) -# where `request_outbound` is an Outbound and `connection` is a Connection +async with http_client: + async with streamable_http_client(url, http_client=http_client) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + session_id = captured_session_ids[0] if captured_session_ids else None ``` -In practice, replace direct `ServerSession` use with `Server.run(read_stream, write_stream, init_options)` and let the framework wire it up. - -**Removed from `mcp.server.session`:** +### `StreamableHTTPTransport` parameters removed -- `InitializationState` enum and `ServerSession._initialization_state` — initialization tracking is now on `Connection` (`connection.initialized` is an `anyio.Event`, `connection.client_params` holds the init params). -- `ServerRequestResponder` type alias. -- `ServerSession.incoming_messages` stream — there is no longer a public stream of inbound messages to iterate. Register handlers via the `on_*` constructor params (or `add_request_handler`) and use `Server.middleware` to observe every inbound request and notification (`initialize`, unknown methods, validation failures, and `notifications/initialized` included). -- `ServerSession.__aenter__` / `__aexit__` — `ServerSession` is no longer an async context manager. -- The private `_receive_loop`, `_received_request`, `_received_notification`, and `_handle_incoming` overrides — there is nothing to override on `ServerSession` anymore. To intercept inbound messages, use `Server.middleware` (see the `_handle_*` removal section above). +The `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters have been removed from `StreamableHTTPTransport`. Configure these on the `httpx.AsyncClient` instead (see example above). -### `BaseSession` / `RequestResponder`: server-side cancellation tracking removed +Note: `sse_client` retains its `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters — only the streamable HTTP transport changed. -`BaseSession._in_flight` and the `RequestResponder` members that supported it (`cancel()`, the `cancelled` and `in_flight` properties, the `on_complete` constructor argument, and the internal `CancelScope`) have been removed. These existed to let `ServerSession` cancel a handler when a `CancelledNotification` arrived; `ServerSession` no longer drives a receive loop, so they were dead code. Inbound-cancellation handling for the server now lives in `JSONRPCDispatcher`. +### `StreamableHTTPTransport.protocol_version` attribute removed -`BaseSession` itself has since been removed entirely; see the next section. +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. -### `ClientSession` now runs on `JSONRPCDispatcher`; `BaseSession` removed +The `MCP_PROTOCOL_VERSION` header-name constant has moved: import `MCP_PROTOCOL_VERSION_HEADER` from `mcp.shared.inbound` instead of `MCP_PROTOCOL_VERSION` from `mcp.client.streamable_http`. -`ClientSession`'s public surface is unchanged — same constructor, typed methods, manual `initialize()`, and async context-manager lifecycle — but `BaseSession`, the v1 receive loop underneath it, is removed with no shim. The engine now lives in `JSONRPCDispatcher` (`mcp.shared.jsonrpc_dispatcher`). To customize client behavior, use the `ClientSession` constructor callbacks, or pass a pre-built dispatcher via the new keyword-only `dispatcher=` constructor argument (e.g. a `DirectDispatcher` for in-process embedding). +### Streamable HTTP: non-2xx responses now surface as per-request JSON-RPC errors -Behavior changes: +In v1, a non-2xx response to a message POST (other than 404) raised `httpx.HTTPStatusError` inside the transport's task group, so it escaped the `streamable_http_client` context as an `ExceptionGroup` and failed every pending request; a 404 raised `McpError` with the positive literal code `32600`. In v2 the transport no longer raises for HTTP status errors: the failing request gets a JSON-RPC error, raised as `MCPError` from that one call, and the connection stays usable. After a 500 fails one `tools/list`, the next call on the same session succeeds. -- **Callbacks and notifications now run concurrently.** In v1 the receive loop processed one inbound message at a time, so callbacks ran inline and in order. Now each delivery starts in arrival order but runs as its own task. Server-initiated request callbacks (`sampling`, `elicitation`, `roots`) no longer block other traffic, may themselves send requests without deadlocking, and are interrupted if the server sends `notifications/cancelled` (the request is then answered with an error). Notification callbacks (`logging_callback`, `progress_callback`, `message_handler`) may interleave, and a `progress_callback` may run after the request it reports on has returned; there is no built-in bound on concurrent deliveries. Transport-level errors reach `message_handler` the same way, and a `message_handler` that raises is logged rather than fatal to the session. Callbacks that need strict sequencing must coordinate themselves. -- **Timeouts**: a timed-out or abandoned request is now followed by `notifications/cancelled`, so the server stops the handler instead of leaving it running. -- **A raising request callback** is answered with `code=0` and the exception text; v1 flattened every callback exception to `INVALID_PARAMS`. For a specific error response, return `ErrorData` (unchanged) or raise `MCPError`. One carve-out: pydantic's `ValidationError` is still answered with `INVALID_PARAMS`, as in v1. -- **`send_request` before entering the context manager** raises `RuntimeError` immediately; v1 wrote to the transport and hung until the timeout. After the connection has closed it raises `MCPError` (`CONNECTION_CLOSED`) instead. `send_notification` before entry still works. -- **`send_notification` no longer takes `related_request_id`, and `send_request` no longer accepts `ServerMessageMetadata`.** No client transport ever serialized these hints; progress and response correlation via `progressToken` and the request id is unaffected. -- **Client callbacks now receive `mcp.client.ClientRequestContext`** (its `request_id` is always populated); the private `mcp.shared._context.RequestContext` generic is deleted. Annotations spelled `RequestContext[ClientSession]` become `ClientRequestContext`. +| Server response | v1 | v2 | +| --- | --- | --- | +| Non-2xx with a JSON-RPC error body | body discarded; `httpx.HTTPStatusError` escapes the context | body's error surfaced verbatim, e.g. `MCPError(-32602, 'Invalid params')` | +| 404, session established | `McpError` with positive code `32600` | `MCPError(-32600, 'Session terminated')` | +| 404, no session yet | `McpError` with positive code `32600` | `MCPError(-32601, 'Not Found')` | +| Any other 4xx/5xx | `httpx.HTTPStatusError` escapes as `ExceptionGroup` | `MCPError(-32603, 'Server returned an error response')` | -`mcp.shared.session` is now a compatibility module: `ProgressFnT` is re-exported (its home is `mcp.shared.dispatcher`), and `RequestResponder` remains as a typing-only stub so `MessageHandlerFnT` annotations keep importing. `RequestResponder.respond()` no longer exists. +Both common v1 patterns silently stop working: an `except* httpx.HTTPStatusError` around the transport context becomes dead code because status errors no longer escape the context, and a session-expiry check on `error.code == 32600` never matches again because the code is now the standard negative `-32600`. -### Experimental Tasks support removed +**Before (v1):** -Tasks ([SEP-1686](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1686)) have been removed from the MCP specification and are no longer part of this SDK. The `mcp.client.experimental`, `mcp.server.experimental`, `mcp.shared.experimental`, and `mcp.server.lowlevel.experimental` modules have been removed, along with the `experimental` properties on `ClientSession`, `ServerSession`, `Server`, and `ServerRequestContext`. The corresponding `Task*` types remain in `mcp_types` as types-only definitions. +```python +import httpx +from mcp.shared.exceptions import McpError -The 2026-07-28 revision reintroduces Tasks as an official extension: [SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2663), `io.modelcontextprotocol/tasks`, redesigned around polling (`tasks/get`) instead of a blocking `tasks/result`. This SDK does not implement the extension yet. +while True: + try: + async with streamable_http_client(url) as (read, write, _get_id): + async with ClientSession(read, write) as session: + await session.initialize() + try: + await session.list_tools() + except McpError as exc: + if exc.error.code == 32600: # v1's "Session terminated" + continue # session expired: rebuild the connection + raise + except* httpx.HTTPStatusError: + pass # server returned 4xx/5xx: the loop rebuilds the connection +``` -## Deprecations +**After (v2):** -### Roots, Sampling, and Logging methods deprecated (SEP-2577) +```python +from mcp import ClientSession, MCPError +from mcp.client.streamable_http import streamable_http_client +from mcp_types import INVALID_REQUEST # -32600 -[SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) deprecates the Roots, Sampling, and Logging features as of the 2026-07-28 spec. The deprecation is advisory only: there are no wire-level changes, capability negotiation is unchanged, and every method keeps working for sessions negotiating 2025-11-25 and earlier. +async with streamable_http_client(url) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + try: + await session.list_tools() + except MCPError as exc: + if exc.code == INVALID_REQUEST and exc.message == "Session terminated": + await reconnect() # session expired: rebuild the connection + else: + raise +``` -The user-facing methods for these features now carry `typing_extensions.deprecated`, so type checkers, IDEs, and the runtime surface a deprecation warning where they are called: +Move HTTP-status failure handling from around the transport context to around the individual calls, catching `MCPError` (see [`McpError` renamed to `MCPError`](#mcperror-renamed-to-mcperror)). Connect-level failures such as `httpx.ConnectError` still escape the transport context as before; keep context-level handling for those only. -- Sampling: `ServerSession.create_message()`, `ClientPeer.sample()` -- Roots: `ServerSession.list_roots()`, `ClientPeer.list_roots()`, `ClientSession.send_roots_list_changed()`, `Client.send_roots_list_changed()` -- Logging: `ServerSession.send_log_message()`, `Connection.log()`, `ClientSession.set_logging_level()`, `Client.set_logging_level()`, `mcp.server.context.Context.log()` (the lowlevel `Context`), and the `MCPServer` `Context` helpers `log()`, `debug()`, `info()`, `warning()`, `error()` +### `terminate_windows_process` removed -Registering a handler for a deprecated capability is deprecated too. The `Server.__init__` parameters `on_set_logging_level` (Logging) and `on_roots_list_changed` (Roots) are now split out into a `typing_extensions.deprecated` overload, so passing either is flagged by type checkers and emits `mcp.MCPDeprecationWarning` at construction time. `on_progress` follows the same pattern (see below). The non-deprecated overload omits these parameters, so the common case stays warning-free. +The deprecated `mcp.os.win32.utilities.terminate_windows_process` function has been +removed. Process termination is handled internally by the `stdio_client` context +manager; there is no replacement API. The Windows tree-termination helper +`terminate_windows_process_tree` no longer accepts a `timeout_seconds` argument — +the value was never used (Job Object termination is immediate). -The runtime warning is emitted as `mcp.MCPDeprecationWarning`, which subclasses `UserWarning` (not `DeprecationWarning`) so it is visible by default. To silence it, filter that category: +### `stdio_client` shutdown reworked: a gracefully-exited server's children are left alive on POSIX -```python -import warnings -from mcp import MCPDeprecationWarning +When a server exits on its own after `stdio_client` closes its stdin, background +child processes the server leaves behind are deliberately left alive on POSIX: +their lifetime is the server's business. The old shutdown wait was gated on the +stdio pipes closing rather than on process exit, so a child holding an inherited +pipe made a well-behaved server look hung: shutdown stalled for the full grace +period, then attempted a tree-kill that in practice failed against the +already-exited server (its process group could no longer be looked up) and logged +a warning, leaving the children alive anyway. (That gating is an asyncio behavior +specific to Python 3.11+; on Python 3.10 and the trio backend the old wait already +resolved on process exit, so the spurious stall never happened there.) A server that does not exit within the grace +period is still terminated +along with its entire process group. On Windows, children stay in the server's Job +Object and are still killed at shutdown — now deterministically when the job handle +is closed, rather than whenever the handle happened to be garbage-collected. -warnings.filterwarnings("ignore", category=MCPDeprecationWarning) -``` +If you relied on `stdio_client` killing everything the server spawned, make the +server terminate its own children on shutdown (its stdin reaching EOF is the +shutdown signal), or clean up the process tree from the host application after +`stdio_client` exits. -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. +Two related shutdown refinements: `stdio_client` now closes its end of the pipes +deterministically at shutdown, so a surviving child that keeps writing to an +inherited stdout receives `EPIPE`/`SIGPIPE` once the client is gone (previously the +pipe lingered until garbage collection); and a failed write to a server that is +still running now surfaces as a closed connection (`CONNECTION_CLOSED`) on the read +side instead of a raw `BrokenResourceError` escaping the `stdio_client` context. -### Client-to-server progress deprecated (2026-07-28) +`terminate_posix_process_tree` now requires the process to lead its own process +group (spawned with `start_new_session=True`); the `getpgid()` lookup and the +per-process terminate/kill fallback are gone. The win32 utilities logger is now +named `mcp.os.win32.utilities` (was `client.stdio.win32`). -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. +### WebSocket transport removed -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. +The WebSocket transport has been removed: `mcp.client.websocket.websocket_client`, `mcp.server.websocket.websocket_server`, and the `ws` optional dependency extra (`mcp[ws]`) no longer exist. WebSocket was never part of the MCP specification. Use the streamable HTTP transport instead (`mcp.client.streamable_http.streamable_http_client` on the client, `streamable_http_app()` on the server), which supports bidirectional communication with server-to-client streaming over standard HTTP. -## Bug Fixes +## OAuth and server auth ### OAuth metadata URLs no longer gain a trailing slash @@ -1593,51 +1788,105 @@ issuer inconsistent with what clients compare against under RFC 8414 / RFC 9207. already-built `AnyHttpUrl` object still normalizes at construction; pass a string to get the preserved form. -### Lowlevel `Server`: `subscribe` capability now correctly reported +### OAuth `callback_handler` returns `AuthorizationCodeResult` -Previously, the lowlevel `Server` hardcoded `subscribe=False` in resource capabilities even when a `subscribe_resource()` handler was registered. The `subscribe` capability is now dynamically set to `True` when an `on_subscribe_resource` handler is provided. Clients that previously didn't see `subscribe: true` in capabilities will now see it when a handler is registered, which may change client behavior. +The `callback_handler` passed to `OAuthClientProvider` now returns an `AuthorizationCodeResult` instead of a `tuple[str, str | None]` of `(code, state)`. The new object adds an `iss` field so the client can validate the [RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207) authorization-response issuer ([SEP-2468](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468)): when the redirect carries an `iss` query parameter it must match the authorization server's issuer, and a missing `iss` is rejected when the server advertised `authorization_response_iss_parameter_supported`. -### Unknown request methods now return `-32601` (Method not found) +**Before (v1):** + +```python +async def callback_handler() -> tuple[str, str | None]: + params = parse_qs(urlparse(await wait_for_redirect()).query) + return params["code"][0], params.get("state", [None])[0] +``` -In v1, a request for a method the SDK didn't recognize failed request-union validation and was answered with `-32602` (`"Invalid request parameters"`, empty `data`). Any method the receiver doesn't serve — unrecognized, or a spec method with no registered handler — is now answered with the JSON-RPC-specified `-32601` (`"Method not found"`), with the method name in `data`, on both the server and the client side, in every initialization state. Update anything that matched on the old code for this case. +**After (v2):** -### Extra fields on MCP types are no longer preserved +```python +from mcp.client.auth import AuthorizationCodeResult -In v1, MCP protocol types were configured with `extra="allow"`: unknown fields passed to a constructor or received from a peer were kept on the model and re-serialized on output. -In v2, MCP types silently ignore extra fields. Unknown constructor keyword arguments and unknown keys in wire data are dropped during validation — no error is raised, and the values do not round-trip: +async def callback_handler() -> AuthorizationCodeResult: + params = parse_qs(urlparse(await wait_for_redirect()).query) + return AuthorizationCodeResult( + code=params["code"][0], + state=params.get("state", [None])[0], + iss=params.get("iss", [None])[0], + ) +``` -```python -from mcp_types import CallToolRequestParams +Forward the `iss` query parameter from the redirect so the validation can run: omitting it makes the flow fail with `OAuthFlowError` against servers that advertise `authorization_response_iss_parameter_supported`, and silently skips the check for servers that send `iss` without advertising it. -params = CallToolRequestParams( - name="my_tool", - arguments={}, - unknown_field="value", # silently ignored, not stored -) -"unknown_field" in params.model_dump() # False +### Client rejects authorization server metadata with a mismatched `issuer` -# _meta remains the supported place for custom data, per the MCP spec -params = CallToolRequestParams( - name="my_tool", - arguments={}, - _meta={"my_custom_key": "value", "another": 123}, # OK, preserved -) +During OAuth discovery, `OAuthClientProvider` now validates that the authorization server +metadata's `issuer` exactly matches the authorization server URL advertised in the protected +resource metadata, as required by [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) +section 3.3 ([SEP-2468](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468)). +The comparison is a simple string comparison ([RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986) +section 6.2.1), so even a trailing-slash disagreement counts as a mismatch. v1 accepted the +metadata without checking, so a server pairing whose two values disagree authenticated fine +under v1 and now fails the entire flow. For example, when the MCP server's protected resource +metadata advertises + +```json +{"authorization_servers": ["https://as.example.com"]} ``` -If you relied on extra fields round-tripping through MCP types, move that data into `_meta`. +while the authorization server's RFC 8414 metadata says `"issuer": "https://as.example.com/"`, +v1 completes discovery and proceeds with the flow; v2 aborts with: -### `mcp dev` and `mcp install` pin the spawned environment to your SDK version +```text +OAuthFlowError: Authorization server metadata issuer mismatch: https://as.example.com/ != https://as.example.com +``` -Both commands run your server through a fresh `uv run --with ...` environment. In v1 the -`mcp` requirement in that command was unpinned, so the spawned environment resolved to the -newest stable release rather than the version you had installed; with a v2 pre-release -installed, `mcp dev server.py` built a v1 environment that could not import a v2 server. -Both commands now pin the requirement to the version you are running -(`mcp==`). Source builds and other unpublished versions, which have -nothing on PyPI to pin to, keep the unpinned form. +There is no client-side override. Fix the deployment instead: make the authorization server's +`issuer` string-equal the URL in the protected resource metadata's `authorization_servers` +list. See [OAuth metadata URLs no longer gain a trailing slash](#oauth-metadata-urls-no-longer-gain-a-trailing-slash) +for how v2 preserves the exact string form of these URLs. + +### OAuth client requests `offline_access` and adds `prompt=consent` when the authorization server supports it ([SEP-2207](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207)) + +The OAuth client now augments its requested scope with `offline_access` whenever the +authorization server's metadata advertises that scope in `scopes_supported` and the client's +`grant_types` include `refresh_token`, which is the default. When `offline_access` ends up in +the requested scope, the authorization request also carries `prompt=consent`, as OIDC requires +for offline access. Against an authorization server that advertises `offline_access` (Keycloak +and Auth0 do by default), an unchanged v1 client sends a different authorization URL: + +**Before (v1):** + +```text +https://as.example.com/authorize?...&scope=read +``` + +**After (v2):** + +```text +https://as.example.com/authorize?...&scope=read offline_access&prompt=consent +``` + +Three observable consequences: end users see an interactive consent screen on every +authorization where OIDC providers previously re-authorized returning users silently, the +granted scope is broader with refresh tokens issued and persisted through `TokenStorage` where +v1 never requested them, and strict authorization servers that reject un-allowlisted scopes may +fail the flow with `invalid_scope`. The `prompt=consent` half applies even when +`offline_access` was already part of the scope selection in v1. + +To keep the v1 behavior (no `offline_access` request, no consent prompt, no refresh tokens), +restrict the client's grant types: + +```python +client_metadata = OAuthClientMetadata( + client_name="my-client", + redirect_uris=["http://localhost:3000/callback"], + grant_types=["authorization_code"], +) +``` -## New Features +Note this also registers the client without the `refresh_token` grant, so token refresh is +disabled; there is no knob for refresh tokens without the forced consent screen, since +`prompt=consent` is keyed off the final scope. ### OAuth client credentials are bound to their authorization server ([SEP-2352](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2352)) @@ -1662,78 +1911,175 @@ client_metadata = OAuthClientMetadata( Under OIDC, omitting `application_type` defaults to `"web"`, which an authorization server may reject for the `localhost` redirect URIs native clients use; sending `"native"` avoids that. Non-OIDC servers ignore the parameter. -### Identity Assertion Authorization Grant for enterprise IdP flows (SEP-990) +### Stricter client authentication at `/token` and `/revoke` -The SDK now supports [SEP-990](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990)'s enterprise identity-provider policy controls. The client presents an Identity Assertion Authorization Grant (ID-JAG) - a signed JWT issued by the enterprise IdP - to the MCP authorization server using the [RFC 7523](https://datatracker.ietf.org/doc/html/rfc7523) jwt-bearer grant (`grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer`, the ID-JAG as `assertion`), and receives an MCP access token. This matches the SEP-990 normative profile and interoperates with the other MCP SDKs. (Leg 1 - exchanging the user's IdP ID token for the ID-JAG against the IdP - is deployment-specific and out of scope for the SDK.) This is additive and opt-in on both sides; existing flows are unchanged. +v2 hardens client authentication on SDK-hosted authorization servers (`create_auth_routes`) in two ways. Both apply automatically; server code only needs changing if you hand-provision client records. -On the client, `IdentityAssertionOAuthProvider` (in `mcp.client.auth.extensions.identity_assertion`) is an `httpx.Auth` that posts the jwt-bearer request. The ID-JAG is supplied lazily through an async `assertion_provider(audience, resource)` callback - `audience` is the authorization server's issuer (the ID-JAG `aud`) and `resource` is the MCP server's identifier (the ID-JAG `resource` claim): +**Client-auth failures now return `invalid_client`.** In v1, every `ClientAuthenticator` failure at `/token` (unknown `client_id`, wrong secret, expired secret) returned HTTP 401 with `unauthorized_client`. v2 returns `invalid_client`, the code [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) §5.2 assigns to failed client authentication: -```python -from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider +```text +# v1 +401 {"error":"unauthorized_client","error_description":"Invalid client_id"} +# v2 +401 {"error":"invalid_client","error_description":"Invalid client_id"} +``` + +`unauthorized_client` is now reserved for a client that authenticated successfully but is not permitted the requested grant. Update any client code, integration tests, or alerting that string-matches the old error code, or accept both while clients and servers migrate at different times. + +**Secret-based clients without a stored secret are rejected.** In v1, `ClientAuthenticator` only validated a secret when one was stored, so a hand-provisioned client record with a secret-based auth method but no secret authenticated with no credentials at all. v2 rejects such clients before any grant processing: `/token` returns 401 `invalid_client` and `/revoke` returns 401 `unauthorized_client`, both with the description "Client is registered for secret-based authentication but has no stored secret". Only records that explicitly set `client_secret_post` or `client_secret_basic` with no secret are affected: records left at the default `token_endpoint_auth_method=None` fail in both versions, and DCR-registered clients always receive a generated secret. + +**Before (v1):** +```python +from mcp.shared.auth import OAuthClientInformationFull -async def fetch_id_jag(audience: str, resource: str) -> str: - # The ID-JAG must carry `audience` as `aud` and `resource` as its `resource` claim. - return await my_idp.issue_id_jag(audience=audience, resource=resource) +LEGACY_CLIENT = OAuthClientInformationFull( + client_id="legacy-client", + client_secret=None, # no secret stored + token_endpoint_auth_method="client_secret_post", # but a secret-based method + redirect_uris=["http://localhost:1234/cb"], +) +``` +**After (v2):** either register the client as public, or store a secret that clients must then present: -provider = IdentityAssertionOAuthProvider( - server_url="https://mcp.example.com/mcp", - storage=my_token_storage, - client_id="enterprise-mcp-client", - client_secret="enterprise-mcp-secret", - issuer="https://auth.example.com", - assertion_provider=fetch_id_jag, +```python +LEGACY_CLIENT = OAuthClientInformationFull( + client_id="legacy-client", + token_endpoint_auth_method="none", # public client, no secret expected + redirect_uris=["http://localhost:1234/cb"], ) ``` -SEP-990 §5.1 requires the client to authenticate; this SDK currently requires a shared secret, so `client_secret` is mandatory (`token_endpoint_auth_method` chooses `client_secret_post` (default) or `client_secret_basic`; the spec also permits `private_key_jwt`). The authorization server is configuration, not discovery: `issuer` is the AS the client is provisioned for, authorization-server metadata is fetched from that issuer's [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) well-known, and the resource server is never asked which AS to use - so a hostile resource server cannot redirect the ID-JAG or secret. +## Stricter protocol validation and wire behavior + +### Server handler results are validated against the protocol schema + +Results returned from server handlers are now validated against the negotiated protocol version's schema before being sent. A result that does not conform raises on the server side and the client receives an `INTERNAL_ERROR` response. The case most existing code will hit is `Tool.inputSchema`: the spec requires it to contain `"type": "object"`, so an empty `{}` is now rejected. + +### Client validates inbound traffic against the protocol schema + +`ClientSession` now validates server requests, notifications, and results against the negotiated protocol version's schema before parsing them into `mcp_types` models. Spec-invalid server output that the previous monolith parse tolerated may now raise `pydantic.ValidationError` from `list_tools()`, `call_tool()`, and similar calls. `_meta` remains the sanctioned place for result extras (and `experimental` for capability extras). + +### Unknown request methods now return `-32601` (Method not found) + +In v1, a request for a method the SDK didn't recognize failed request-union validation and was answered with `-32602` (`"Invalid request parameters"`, empty `data`). Any method the receiver doesn't serve — unrecognized on either side, or a spec method the server has no registered handler for — is now answered with the JSON-RPC-specified `-32601` (`"Method not found"`), with the method name in `data`, in every initialization state. Clients still decline sampling, elicitation, and roots requests with `-32600` when no callback is registered, as in v1. Update anything that matched on the old code for this case. + +### Every outbound request now carries a `_meta` envelope; OpenTelemetry is on by default + +v2 sends `"_meta": {}` in the params of every request it emits, at every negotiated protocol version. Requests that had no params in v1, such as `ping` and `tools/list`, now carry `"params": {"_meta": {}}`; server-initiated requests get the same envelope. This is spec-valid and accepted by all peers, but wire traffic differs from v1 on every call, and no configuration restores the v1 wire shape. Update any test or tooling that asserts on raw outbound request bytes. + +**Before (v1):** same client code, 2025-11-25 peer: + +```text +{"method":"ping","jsonrpc":"2.0","id":1} +{"method":"tools/list","jsonrpc":"2.0","id":2} +``` + +**After (v2):** + +```text +{"jsonrpc":"2.0","id":2,"method":"ping","params":{"_meta":{}}} +{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{"_meta":{}}} +``` -On the authorization server, set `AuthSettings(identity_assertion_enabled=True)` (or pass `identity_assertion_enabled=True` to `create_auth_routes`) and implement `exchange_identity_assertion` on your `OAuthAuthorizationServerProvider`. The method receives an `IdentityAssertionParams` (the ID-JAG `assertion`, requested scopes, and request `resource`) and returns a plain [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) `OAuthToken`. The flag gates both metadata advertisement and the token endpoint: when off, `/token` rejects the grant with `unsupported_grant_type` even if the provider implements the hook. When on, the metadata advertises the jwt-bearer grant and the `urn:ietf:params:oauth:grant-profile:id-jag` profile in `authorization_grant_profiles_supported` (the discovery mechanism per ext-auth §6). +The envelope exists for OpenTelemetry trace propagation ([SEP-414](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/414)), which now ships enabled: every server installs a tracing middleware and the client opens a span per outbound request. With no OpenTelemetry SDK configured these are no-ops and only the empty envelope is visible. If your application already configures a global tracer provider, it starts recording MCP client and server spans with no code change, and a W3C `traceparent` field is injected into outbound `_meta`, propagating your trace ids to the servers you call. To suppress the spans, filter the `mcp-python-sdk` tracer in your pipeline; [OpenTelemetry](run/opentelemetry.md) has the recipe for removing the server middleware. There is no public switch for the client-side span and `traceparent` injection. -The implementation is responsible for validating the assertion per RFC 7523 §3 and SEP-990 §5.1 - verify the signature/`iss`/`exp`/`typ`, require `aud` to be this AS, require the ID-JAG's `client_id` claim to match the authenticated client, audience-restrict the issued token to the ID-JAG's `resource` claim (not the client-controlled request `resource`), and derive scopes from the ID-JAG rather than granting the request verbatim. See `examples/snippets/servers/identity_assertion_server.py`, which fails closed. Two hardening points are enforced by the SDK: the handler rejects clients without a stored secret before calling the hook (and `ClientAuthenticator` itself now refuses a secret-based auth method registered without a secret), and Dynamic Client Registration refuses the jwt-bearer grant so the ID-JAG flow requires a pre-registered confidential client. +The SDK's new `opentelemetry-api` runtime dependency is covered under [Packaging, dependencies, and CLI](#packaging-dependencies-and-cli). -### 2025-11-25 and 2026-07-28 protocol fields modeled +## Testing utilities -`mcp_types` models the 2025-11-25 and 2026-07-28 protocol fields (e.g. `resultType`, `ttlMs`/`cacheScope` on cacheable results, `inputResponses`/`requestState` on retried requests), so inbound payloads carrying these keys parse into typed fields and round-trip. `ttlMs`/`cacheScope` default to `0`/`"private"` (immediately stale, not shared-cacheable); `resultType` defaults to `"complete"` on concrete results (`None` on `EmptyResult`); the server strips all of them from the wire at pre-2026 versions. Servers set per-method values with `cache_hints={method: CacheHint(...)}` on the `Server`/`MCPServer` constructor. See [Caching hints](client/caching.md) for details. +### `create_connected_server_and_client_session` removed -### `streamable_http_app()` available on lowlevel Server +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. -The `streamable_http_app()` method is now available directly on the lowlevel `Server` class, not just `MCPServer`. This allows using the streamable HTTP transport without the MCPServer wrapper. +**Before (v1):** ```python -from mcp.server import Server, ServerRequestContext -from mcp_types import ListToolsResult, PaginatedRequestParams +from mcp.shared.memory import create_connected_server_and_client_session + +async with create_connected_server_and_client_session(server) as session: + result = await session.call_tool("my_tool", {"x": 1}) +``` +**After (v2):** -async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: - return ListToolsResult(tools=[...]) +```python +from mcp.client import Client + +async with Client(server) as client: + result = await client.call_tool("my_tool", {"x": 1}) +``` +`Client` accepts the same callback parameters the old helper did (`sampling_callback`, `list_roots_callback`, `logging_callback`, `message_handler`, `elicitation_callback`, `client_info`), keeps `raise_exceptions` for surfacing server-side errors and `read_timeout_seconds` (now a plain `float` of seconds rather than a `timedelta`; see [Timeouts take `float` seconds instead of `timedelta`](#timeouts-take-float-seconds-instead-of-timedelta)), and adds `mode` to control version negotiation (`'auto'` by default; `'legacy'` reproduces v1's initialize-only handshake). -server = Server("my-server", on_list_tools=handle_list_tools) +If you need direct access to the underlying `ClientSession` and memory streams (e.g., for low-level transport testing), `create_client_server_memory_streams` is still available in `mcp.shared.memory`: -app = server.streamable_http_app( - streamable_http_path="/mcp", - json_response=False, - stateless_http=False, -) +```python +import anyio +from mcp.client.session import ClientSession +from mcp.shared.memory import create_client_server_memory_streams + +async with create_client_server_memory_streams() as (client_streams, server_streams): + async with anyio.create_task_group() as tg: + tg.start_soon(lambda: server.run(*server_streams, server.create_initialization_options())) + async with ClientSession(*client_streams) as session: + await session.initialize() + ... + tg.cancel_scope.cancel() ``` -The lowlevel `Server` also now exposes a `session_manager` property to access the `StreamableHTTPSessionManager` after calling `streamable_http_app()`. +Note that the streams it yields are now context-propagating wrappers (`ContextReceiveStream`/`ContextSendStream`) rather than plain anyio memory streams. They support `send`, `receive`, async iteration, `close`, `aclose`, and `clone`, but the anyio-only methods `send_nowait`, `receive_nowait`, and `statistics()` are gone and raise `AttributeError`; use `await send(...)`/`await receive()` instead, or create plain `anyio.create_memory_object_stream` pairs yourself if you need the full anyio API. -### `ElicitationResult` is now a subscriptable generic alias +One behavioral caveat when moving progress-reporting handlers onto `Client(server)`: 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. -`ElicitationResult` is now a `TypeAliasType` instead of a plain union, so `ElicitationResult[Confirm]` works as an annotation (resolver dependency injection consumes it that way - see [Dependencies](handlers/dependencies.md)). The members are unchanged: `AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation`. +`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 (see also [Client-to-server progress deprecated](#client-to-server-progress-deprecated-2026-07-28)). `session.send_progress_notification(progress_token, ...)` is unchanged and still works on JSON-RPC transports for code that already holds a token. -The one behavioral change: a runtime `isinstance(result, ElicitationResult)` now raises `TypeError`. Check against the member classes directly instead: +## Deprecations + +### Roots, Sampling, and Logging methods deprecated (SEP-2577) + +[SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) deprecates the Roots, Sampling, and Logging features as of the 2026-07-28 spec. The deprecation is advisory only: there are no wire-level changes, capability negotiation is unchanged, and every method keeps working for sessions negotiating 2025-11-25 and earlier. + +The user-facing methods for these features now carry `typing_extensions.deprecated`, so type checkers, IDEs, and the runtime surface a deprecation warning where they are called: + +- Sampling: `ServerSession.create_message()`, `ClientPeer.sample()` +- Roots: `ServerSession.list_roots()`, `ClientPeer.list_roots()`, `ClientSession.send_roots_list_changed()`, `Client.send_roots_list_changed()` +- Logging: `ServerSession.send_log_message()`, `Connection.log()`, `ClientSession.set_logging_level()`, `Client.set_logging_level()`, `mcp.server.context.Context.log()` (the lowlevel `Context`), and the `MCPServer` `Context` helpers `log()`, `debug()`, `info()`, `warning()`, `error()` + +Registering a handler for a deprecated capability is deprecated too. The `Server.__init__` parameters `on_set_logging_level` (Logging) and `on_roots_list_changed` (Roots) are now split out into a `typing_extensions.deprecated` overload, so passing either is flagged by type checkers and emits `mcp.MCPDeprecationWarning` at construction time. `on_progress` follows the same pattern (see below). The non-deprecated overload omits these parameters, so the common case stays warning-free. + +The runtime warning is emitted as `mcp.MCPDeprecationWarning`, which subclasses `UserWarning` (not `DeprecationWarning`) so it is visible by default. To silence it, filter that category: ```python -result = await ctx.elicit("Proceed?", Confirm) -if isinstance(result, AcceptedElicitation): - ... # result.data is a Confirm +import warnings +from mcp import MCPDeprecationWarning + +warnings.filterwarnings("ignore", category=MCPDeprecationWarning) ``` -Narrowing on `result.action` (`"accept"` / `"decline"` / `"cancel"`) is unaffected. +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 the spec's `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. Registering a lowlevel `Server` `on_progress` handler is deprecated the same way as the SEP-2577 handler parameters above: it sits in the `typing_extensions.deprecated` `Server.__init__` overload and passing it emits `mcp.MCPDeprecationWarning` at construction time. + +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. + +## Notes for 2026-era connections + +Everything below this heading describes behavior that only activates on connections +negotiated at protocol 2026-07-28 or later. Migrated v1 code talking to 2025-11-25 (or +earlier) peers is unaffected. It is collected here so the rest of this guide stays +focused on the v1-to-v2 upgrade itself. + +### Servers validate `Mcp-Param-*` headers against the request body ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)) + +On the 2026-07-28 Streamable HTTP path, a `tools/call` whose tool declares `x-mcp-header` annotations is validated before dispatch — each annotated argument and its mirroring `Mcp-Param-*` header must be present together and agree (after base64-sentinel decoding; integers compare numerically), or absent together. A violation is rejected with HTTP 400 and JSON-RPC error `-32020` (`HeaderMismatch`), as the spec requires. A client that sends an annotated argument *without* its header — for example one that never listed the tool — is therefore rejected instead of silently served; the spec's recovery is to re-list and retry. On the client side, `ClientSession.call_tool` emits these headers automatically for annotated arguments of any tool it has listed; list the tool first, and note that pre-2026 connections and non-HTTP transports never emit them. + +There is nothing to configure. The server resolves the called tool's schema through its own registered `tools/list` handler (for `MCPServer`, the built-in one), so the validated catalog is exactly what that caller would be shown. Two consequences worth knowing: the listing runs internally on validated calls, so middleware and an expensive or paginated `tools/list` handler see extra invocations; and validation is skipped — never failing the call — when no `tools/list` handler is registered, the tool isn't in the listing, the handler raises (logged as an error), or the call has no arguments and no `Mcp-Param-*` headers. Headers with no matching annotation are ignored; a recognized header supplied more than once is rejected, as is a duplicated `MCP-Protocol-Version`, `Mcp-Method`, or `Mcp-Name` line. The codec and validator are public in `mcp.shared.inbound` (`decode_header_value`, `validate_mcp_param_headers`) for low-level servers hosting their own HTTP entry. + +Base64-sentinel decoding is strict everywhere it applies, including the `Mcp-Name` header: a `=?base64?...?=` value whose payload is not canonical base64 (wrong padding, stray characters, non-zero trailing bits) or not valid UTF-8 is rejected as malformed rather than leniently decoded. ## Need Help? From 53117cb3a9011da841112a569bbd8fdcfd6dcfd2 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:21:04 +0100 Subject: [PATCH 048/100] Make client-side cancellation work over the 2026 transports (#3046) --- src/mcp/client/session.py | 11 +- src/mcp/client/streamable_http.py | 111 ++++- src/mcp/shared/direct_dispatcher.py | 33 +- src/mcp/shared/dispatcher.py | 27 ++ src/mcp/shared/jsonrpc_dispatcher.py | 76 +-- tests/client/test_session.py | 83 ++-- tests/client/test_streamable_http.py | 435 +++++++++++++++++- tests/interaction/README.md | 5 +- tests/interaction/_requirements.py | 83 +++- .../interaction/lowlevel/test_cancellation.py | 129 +++++- tests/interaction/lowlevel/test_wire.py | 55 +++ .../transports/test_client_transport_http.py | 104 +++++ tests/shared/test_dispatcher.py | 113 +++++ tests/shared/test_jsonrpc_dispatcher.py | 50 +- 14 files changed, 1213 insertions(+), 102 deletions(-) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 804180e05e..5c09304e42 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -93,7 +93,16 @@ def stamp(data: dict[str, Any], opts: CallOptions) -> None: 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 + # `cancel_on_abandon` stays at the dispatcher default (True): the + # courtesy `notifications/cancelled` is the abandon signal. On the + # stream transports it is the 2026 wire's cancellation spelling; the + # streamable-HTTP transport translates it into aborting the request's + # own POST instead of writing it (the 2026 HTTP wire has no + # client-to-server notifications - closing the stream is the signal). + # The negotiation methods still opt out, mirroring `_preconnect_stamp`: + # the spec forbids cancelling them. + if data["method"] in ("initialize", "server/discover"): + opts["cancel_on_abandon"] = False headers = opts.setdefault("headers", {}) headers[MCP_PROTOCOL_VERSION_HEADER] = protocol_version headers[MCP_METHOD_HEADER] = data["method"] diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index f28eb7c7ab..09e5048cc7 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -26,6 +26,7 @@ RequestId, jsonrpc_message_adapter, ) +from mcp_types.version import MODERN_PROTOCOL_VERSIONS from pydantic import ValidationError from mcp.client._transport import TransportStreams @@ -33,6 +34,7 @@ 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.jsonrpc_dispatcher import cancelled_request_id_from_params from mcp.shared.message import ClientMessageMetadata, SessionMessage logger = logging.getLogger(__name__) @@ -70,6 +72,19 @@ class RequestContext: read_stream_writer: StreamWriter +@dataclass(slots=True) +class _InFlightPost: + """A request POST in flight: its abort scope and the era it was sent under. + + `modern` is the negotiated-version cache as of this request's dequeue, so a + later cancel frame is interpreted under the era the request actually ran + with, not whatever the cache says by then. + """ + + scope: anyio.CancelScope + modern: bool + + class StreamableHTTPTransport: """StreamableHTTP client transport implementation.""" @@ -81,11 +96,18 @@ def __init__(self, url: str) -> None: """ self.url = url self.session_id: str | None = None - # Captured from each stamped POST's metadata. Reused on outbound HTTP that carries - # no per-message header (transport-internal GET/DELETE, and dispatcher-written - # response/error/cancel POSTs that bypass the session's stamp). Cleared when an - # `initialize` POST goes out so a probe-stamped value cannot leak onto the handshake. + # Captured from each stamped message's metadata, synchronously in the + # post_writer loop so the cache always reflects wire order (a POST task's + # scheduling is arbitrary). Reused on outbound HTTP that carries no + # per-message header (transport-internal GET/DELETE, and dispatcher-written + # response/error POSTs that bypass the session's stamp), and consulted by + # `_consume_modern_cancellation`. Cleared when an `initialize` message is + # dequeued so a probe-stamped value cannot leak onto the handshake. self._protocol_version_header: str | None = None + # Every request's POST runs inside one of these so an outbound + # `notifications/cancelled` at 2026 can abort it; see + # `_consume_modern_cancellation`. Keys are verbatim-typed ("1" is not 1). + self._in_flight_posts: dict[RequestId, _InFlightPost] = {} def _prepare_headers(self) -> dict[str, str]: """Build MCP-specific request headers for any outbound HTTP request. @@ -93,9 +115,9 @@ def _prepare_headers(self) -> dict[str, str]: These are merged with the ``httpx.AsyncClient`` defaults (these take precedence). The cached ``MCP-Protocol-Version`` is included whenever present so messages that don't pass through the session's stamp — - response/error/cancel POSTs, transport-internal GET/DELETE — still - carry the negotiated version. Per-message headers are layered on top - by the caller. + response/error POSTs, legacy cancel frames, transport-internal + GET/DELETE — still carry the negotiated version. Per-message headers + are layered on top by the caller. """ headers: dict[str, str] = { "accept": "application/json, text/event-stream", @@ -245,19 +267,57 @@ async def _handle_resumption_request(self, ctx: RequestContext) -> None: await event_source.response.aclose() break + def _consume_modern_cancellation(self, session_message: SessionMessage) -> bool: + """Translate an outbound `notifications/cancelled` at 2026; True means "do not POST". + + The 2026 wire defines no client-to-server notifications over streamable + HTTP: closing a request's response stream IS its cancellation signal. + The dispatcher still emits the courtesy frame as its abandon signal + (every outbound cancel names one of our own request ids - the spec + forbids cancelling a request the sender did not issue), so this + transport translates it: when the named request's POST is in flight, + that POST's own recorded era decides - abort-and-swallow at 2026, POST + the frame below it (where the frame is the signal and a disconnect + explicitly is not). With no POST to consult, the cached negotiated + version decides; at 2026 the frame is swallowed even unmatched, so a + late cancel racing the response cannot leak onto the wire. + """ + message = session_message.message + if not (isinstance(message, JSONRPCNotification) and message.method == "notifications/cancelled"): + return False + request_id = cancelled_request_id_from_params(message.params) + post = self._in_flight_posts.get(request_id) if request_id is not None else None + if post is not None: + if not post.modern: + return False + logger.debug("aborting in-flight POST for cancelled request %r", request_id) + post.scope.cancel() + return True + return self._protocol_version_header in MODERN_PROTOCOL_VERSIONS + + async def _run_request_post( + self, + post_fn: Callable[[], Awaitable[None]], + post: _InFlightPost, + request_id: RequestId, + ) -> None: + """Run one request's POST inside its abort scope (see `_consume_modern_cancellation`).""" + try: + with post.scope: + await post_fn() + finally: + # Identity-guarded: a reused id may already have a successor + # registered while this task unwinds - popping by key alone would + # evict the live entry and leave the new POST unabortable. + if self._in_flight_posts.get(request_id) is post: + del self._in_flight_posts[request_id] + async def _handle_post_request(self, ctx: RequestContext) -> None: """Handle a POST request with response processing.""" message = ctx.session_message.message - is_initialization = self._is_initialization_request(message) - if is_initialization: - # `initialize` is the negotiation, not a "subsequent request" — discard any - # probe-stamped value so the discover→fallback path can't leak it onto the handshake. - self._protocol_version_header = None headers = self._prepare_headers() 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] async with ctx.client.stream( "POST", @@ -302,7 +362,7 @@ async def _handle_post_request(self, ctx: RequestContext) -> None: await ctx.read_stream_writer.send(session_message) return - if is_initialization: + if self._is_initialization_request(message): self._maybe_extract_session_id_from_response(response) # Per https://modelcontextprotocol.io/specification/2025-06-18/basic#notifications: @@ -455,6 +515,8 @@ async def post_writer( async def _handle_message(session_message: SessionMessage) -> None: message = session_message.message + if self._consume_modern_cancellation(session_message): + return metadata = ( session_message.metadata if isinstance(session_message.metadata, ClientMessageMetadata) @@ -470,6 +532,15 @@ async def _handle_message(session_message: SessionMessage) -> None: if self._is_initialized_notification(message): start_get_stream() + if self._is_initialization_request(message): + # `initialize` is the negotiation, not a "subsequent request" — discard any + # probe-stamped value so the discover→fallback path can't leak it onto the handshake. + self._protocol_version_header = None + elif metadata is not None and metadata.headers is not None: + stamped_version = metadata.headers.get(MCP_PROTOCOL_VERSION_HEADER) + if stamped_version is not None: + self._protocol_version_header = stamped_version + ctx = RequestContext( client=client, session_id=self.session_id, @@ -486,7 +557,15 @@ async def handle_request_async(): # If this is a request, start a new task to handle it if isinstance(message, JSONRPCRequest): - tg.start_soon(handle_request_async) + # Register the abort scope before the spawn: the next + # message through this loop can already be the abandon + # signal for this id, ahead of the task ever running. + post = _InFlightPost( + scope=anyio.CancelScope(), + modern=self._protocol_version_header in MODERN_PROTOCOL_VERSIONS, + ) + self._in_flight_posts[message.id] = post + tg.start_soon(self._run_request_post, handle_request_async, post, message.id) else: await handle_request_async() diff --git a/src/mcp/shared/direct_dispatcher.py b/src/mcp/shared/direct_dispatcher.py index fd3e69d493..62c74b808e 100644 --- a/src/mcp/shared/direct_dispatcher.py +++ b/src/mcp/shared/direct_dispatcher.py @@ -28,7 +28,7 @@ from pydantic import ValidationError from mcp.shared._compat import resync_tracer -from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest, ProgressFnT +from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest, ProgressFnT, coerce_request_id from mcp.shared.exceptions import MCPError, NoBackChannelError from mcp.shared.message import MessageMetadata from mcp.shared.transport_context import TransportContext @@ -56,7 +56,8 @@ class _DirectDispatchContext: _back_request: _Request _back_notify: _Notify request_id: RequestId | None = None - """A dispatcher-synthesized id for requests; `None` for notifications.""" + """The caller-supplied `CallOptions["request_id"]`, else a dispatcher-synthesized + id for requests; `None` for notifications.""" message_metadata: MessageMetadata = None # TODO(maxisbey): remove for Context rework """Always `None`: in-memory dispatch attaches no transport metadata.""" _on_progress: ProgressFnT | None = None @@ -106,6 +107,7 @@ def __init__(self, transport_ctx: TransportContext, *, raise_handler_exceptions: self._on_request: OnRequest | None = None self._on_notify: OnNotify | None = None self._next_id = 0 + self._in_flight_ids: set[RequestId] = set() self._ready = anyio.Event() self._close_event = anyio.Event() self._running = False @@ -227,9 +229,28 @@ async def _dispatch_request( # waiting on a peer whose run() has not started yet. await self._wait_ready() assert self._on_request is not None - # Synthesize an id: the DispatchContext contract reserves None for notifications. - self._next_id += 1 - dctx = self._make_context(on_progress=opts.get("on_progress"), request_id=self._next_id) + supplied_id = opts.get("request_id") + if supplied_id is not None: + request_id: RequestId = supplied_id + # Collisions use the same coerced domain as JSONRPCDispatcher's + # pending keys, so this in-memory stand-in raises for exactly + # the ids the wire dispatcher would; the context still sees + # the verbatim value. + in_flight_key = coerce_request_id(request_id) + if in_flight_key in self._in_flight_ids: + raise ValueError(f"request id {request_id!r} is already in flight") + else: + # Synthesize an id (the DispatchContext contract reserves None + # for notifications), minting past any key a supplied id + # occupies: the collision error is reserved for the caller + # who actually chose the id. + self._next_id += 1 + while self._next_id in self._in_flight_ids: + self._next_id += 1 + request_id = self._next_id + in_flight_key = request_id + self._in_flight_ids.add(in_flight_key) + dctx = self._make_context(on_progress=opts.get("on_progress"), request_id=request_id) try: return await self._on_request(dctx, method, params) except MCPError: @@ -247,6 +268,8 @@ async def _dispatch_request( raise MCPError(code=INTERNAL_ERROR, message=str(e)) from e logger.exception("request handler raised") raise MCPError(code=INTERNAL_ERROR, message="Internal server error") from None + finally: + self._in_flight_ids.discard(in_flight_key) except TimeoutError: raise MCPError( code=REQUEST_TIMEOUT, diff --git a/src/mcp/shared/dispatcher.py b/src/mcp/shared/dispatcher.py index de83189f13..16360d3142 100644 --- a/src/mcp/shared/dispatcher.py +++ b/src/mcp/shared/dispatcher.py @@ -34,11 +34,26 @@ "OnRequest", "Outbound", "ProgressFnT", + "coerce_request_id", ] TransportT_co = TypeVar("TransportT_co", bound=TransportContext, covariant=True) +def coerce_request_id(request_id: RequestId) -> RequestId: + """Coerce a stringified int request id back to int so a peer-echoed id still correlates (matches the TS SDK). + + This is the collision/correlation domain dispatchers share: "7" and 7 are one + id for correlation purposes, even where the wire carries the verbatim value. + """ + if isinstance(request_id, str): + try: + return int(request_id) + except ValueError: + pass + return request_id + + class ProgressFnT(Protocol): """Callback invoked when a progress notification arrives for a pending request.""" @@ -51,6 +66,18 @@ class CallOptions(TypedDict, total=False): All keys are optional. Dispatchers ignore keys they do not understand. """ + request_id: RequestId + """Send the request under this caller-supplied id instead of a dispatcher-minted one. + + The peer sees the value verbatim ("7" stays a string). A value that collides + with one of the sender's own in-flight request ids raises `ValueError`. + Callers that need to know a request's id before its result arrives (a + `subscriptions/listen` stream is demultiplexed by it) mint their own ids + here; string ids that don't parse as integers can never collide with the + dispatcher's minted sequence. Per the class contract, dispatchers that + predate this key ignore it and mint as usual. + """ + timeout: float """Seconds to wait for a result before raising and sending `notifications/cancelled`.""" diff --git a/src/mcp/shared/jsonrpc_dispatcher.py b/src/mcp/shared/jsonrpc_dispatcher.py index 64fcd3298d..793c59bc7b 100644 --- a/src/mcp/shared/jsonrpc_dispatcher.py +++ b/src/mcp/shared/jsonrpc_dispatcher.py @@ -39,7 +39,15 @@ from mcp.shared._compat import resync_tracer from mcp.shared._otel import inject_trace_context, otel_span from mcp.shared._stream_protocols import ReadStream, WriteStream -from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, OnNotify, OnRequest, ProgressFnT +from mcp.shared.dispatcher import ( + CallOptions, + DispatchContext, + Dispatcher, + OnNotify, + OnRequest, + ProgressFnT, + coerce_request_id, +) from mcp.shared.exceptions import MCPError, NoBackChannelError from mcp.shared.message import ( ClientMessageMetadata, @@ -49,7 +57,12 @@ ) from mcp.shared.transport_context import TransportContext -__all__ = ["JSONRPCDispatcher", "handler_exception_to_error_data", "progress_token_from_params"] +__all__ = [ + "JSONRPCDispatcher", + "cancelled_request_id_from_params", + "handler_exception_to_error_data", + "progress_token_from_params", +] logger = logging.getLogger(__name__) @@ -93,14 +106,13 @@ def progress_token_from_params(params: Mapping[str, Any] | None) -> ProgressToke return None -def _coerce_id(request_id: RequestId) -> RequestId: - """Coerce a stringified int request ID back to int so a peer-echoed ID still correlates (matches the TS SDK).""" - if isinstance(request_id, str): - try: - return int(request_id) - except ValueError: - pass - return request_id +def cancelled_request_id_from_params(params: Mapping[str, Any] | None) -> RequestId | None: + """Read `params.requestId` from a `notifications/cancelled`; reject bool (True would alias request id 1).""" + match params: + case {"requestId": str() | int() as request_id} if not isinstance(request_id, bool): + return request_id + case _: + return None @dataclass(slots=True) @@ -314,7 +326,22 @@ async def send_raw_request( if not self._running: raise RuntimeError("JSONRPCDispatcher.send_raw_request called before run()") opts = opts or {} - request_id = self._allocate_id() + supplied_id = opts.get("request_id") + if supplied_id is not None: + request_id: RequestId = supplied_id + # The pending key gets the same coercion `_resolve_pending` applies + # to inbound response ids, so a supplied "7" still correlates + # whether the peer echoes "7" or 7. The wire id stays verbatim. + pending_key = coerce_request_id(request_id) + if pending_key in self._pending: + raise ValueError(f"request id {request_id!r} is already in flight") + else: + # Mint past any key a supplied id occupies: the collision error is + # reserved for the caller who actually chose the id. + request_id = self._allocate_id() + while request_id in self._pending: + request_id = self._allocate_id() + pending_key = request_id out_params = dict(params) if params is not None else {} out_meta = dict(out_params.get("_meta") or {}) on_progress = opts.get("on_progress") @@ -327,7 +354,7 @@ async def send_raw_request( # a WouldBlock later just means the waiter already has its one outcome. send, receive = anyio.create_memory_object_stream[dict[str, Any] | ErrorData](1) pending = _Pending(send=send, receive=receive, on_progress=on_progress) - self._pending[request_id] = pending + self._pending[pending_key] = pending plan = _plan_outbound(_related_request_id, opts) # Spec MUST: only previously-issued requests may be cancelled. A write @@ -398,7 +425,7 @@ async def send_raw_request( raise finally: # Remove the waiter on every path so a late response is dropped, not leaked. - self._pending.pop(request_id, None) + self._pending.pop(pending_key, None) send.close() receive.close() @@ -548,7 +575,7 @@ async def _dispatch_request( # TODO(maxisbey): duplicate ids blind-overwrite (v1/TS parity); revisit # rejecting with INVALID_REQUEST. Key coerced so a stringified # `notifications/cancelled` id still correlates. - self._in_flight[_coerce_id(req.id)] = _InFlight(scope=scope, dctx=dctx) + self._in_flight[coerce_request_id(req.id)] = _InFlight(scope=scope, dctx=dctx) if req.method in self._inline_methods: # Spawn so `sender_ctx` applies, but park the read loop until the # handler returns - that's the inline ordering guarantee. @@ -579,22 +606,17 @@ def _dispatch_notification( layer owns) and still teed to `on_notify` afterwards. """ if msg.method == "notifications/cancelled": - match msg.params: - # bool subclasses int: the guards keep True from aliasing request id 1. - case {"requestId": str() | int() as rid} if ( - not isinstance(rid, bool) and (in_flight := self._in_flight.get(_coerce_id(rid))) is not None - ): - in_flight.dctx.cancel_requested.set() - if self._peer_cancel_mode == "interrupt": - in_flight.scope.cancel() - case _: - pass + rid = cancelled_request_id_from_params(msg.params) + if rid is not None and (in_flight := self._in_flight.get(coerce_request_id(rid))) is not None: + in_flight.dctx.cancel_requested.set() + if self._peer_cancel_mode == "interrupt": + in_flight.scope.cancel() elif msg.method == "notifications/progress": match msg.params: case {"progressToken": str() | int() as token, "progress": int() | float() as progress} if ( not isinstance(token, bool) and not isinstance(progress, bool) - and (pending := self._pending.get(_coerce_id(token))) is not None + and (pending := self._pending.get(coerce_request_id(token))) is not None and pending.on_progress is not None ): total = msg.params.get("total") @@ -620,7 +642,7 @@ def _dispatch_notification( self._spawn(_contained_notify(on_notify), dctx, msg.method, msg.params, sender_ctx=sender_ctx) def _resolve_pending(self, request_id: RequestId | None, outcome: dict[str, Any] | ErrorData) -> None: - pending = self._pending.get(_coerce_id(request_id)) if request_id is not None else None + pending = self._pending.get(coerce_request_id(request_id)) if request_id is not None else None if pending is None: logger.debug("dropping response for unknown/late request id %r", request_id) return @@ -680,7 +702,7 @@ async def _handle_request( # since handler return, so a peer cancel can't interleave. # Identity guard: don't evict a duplicate id's newer entry. dctx.close() - key = _coerce_id(req.id) + key = coerce_request_id(req.id) if (entry := self._in_flight.get(key)) is not None and entry.dctx is dctx: del self._in_flight[key] # A write interrupted by cancellation may still have delivered diff --git a/tests/client/test_session.py b/tests/client/test_session.py index f76991f65d..2a53f67cea 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -1330,43 +1330,43 @@ def test_adopt_raises_when_no_mutual_modern_version_is_supported() -> None: 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 - cancelling it — and leaves the option unset for every other method.""" +class _OptsRecordingDispatcher: + """Records `send_raw_request` opts and answers from a per-method script (default `{}`).""" - class RecordingDispatcher: - """Records `send_raw_request` opts and answers with canned results.""" + def __init__(self, answers: dict[str, dict[str, Any]] | None = None) -> None: + self.calls: list[tuple[str, CallOptions]] = [] + self._answers = answers or {} - def __init__(self) -> None: - self.calls: list[tuple[str, CallOptions]] = [] + 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 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, opts or {})) + return self._answers.get(method, {}) - async def send_raw_request( - self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None - ) -> dict[str, Any]: - self.calls.append((method, opts or {})) - if method == "initialize": - return InitializeResult( - 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, opts: CallOptions | None = None) -> None: + pass - async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: - pass - dispatcher = RecordingDispatcher() +@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 + cancelling it — and leaves the option unset for every other method.""" + init_answer = InitializeResult( + 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) + dispatcher = _OptsRecordingDispatcher({"initialize": init_answer}) with anyio.fail_after(5): async with ClientSession(dispatcher=dispatcher) as session: await session.initialize() @@ -1376,6 +1376,27 @@ async def notify(self, method: str, params: Mapping[str, Any] | None, opts: Call assert "cancel_on_abandon" not in opts_by_method["ping"] +@pytest.mark.anyio +async def test_modern_stamp_leaves_cancel_on_abandon_at_the_dispatcher_default(): + """Post-adopt modern requests leave `cancel_on_abandon` unset (the dispatcher default, + True): the courtesy frame is the abandon signal — the 2026 cancellation spelling on + stream transports, and the streamable-HTTP transport's cue to abort the request's own + POST. The negotiation methods still opt out on every path: `send_discover`'s explicit + opts, and the stamp's own carve-out for a `server/discover` sent through the generic + `send_request`.""" + dispatcher = _OptsRecordingDispatcher({"server/discover": _discover_result_dict()}) + with anyio.fail_after(5): + async with ClientSession(dispatcher=dispatcher) as session: + await session.discover() + await session.send_ping() + await session.send_request(types.DiscoverRequest(params=types.RequestParams()), types.DiscoverResult) + assert [method for method, _ in dispatcher.calls] == ["server/discover", "ping", "server/discover"] + negotiation_opts, ping_opts, stamped_negotiation_opts = (opts for _, opts in dispatcher.calls) + assert negotiation_opts.get("cancel_on_abandon") is False + assert "cancel_on_abandon" not in ping_opts + assert stamped_negotiation_opts.get("cancel_on_abandon") is False + + def test_constructor_rejects_streams_and_dispatcher_together(): client_side, _server_side = create_direct_dispatcher_pair() s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](1) diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index 99ff6f03e5..defda41f85 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -8,16 +8,37 @@ import base64 import json +from collections.abc import AsyncIterator, Callable, Mapping +from typing import Any import anyio import httpx import pytest from inline_snapshot import snapshot -from mcp_types import METHOD_NOT_FOUND, JSONRPCError, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse +from mcp_types import ( + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + METHOD_NOT_FOUND, + PROTOCOL_VERSION_META_KEY, + JSONRPCError, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, +) +from mcp_types.version import LATEST_MODERN_VERSION +from starlette.types import Receive, Scope, Send 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.server import Server +from mcp.server._streamable_http_modern import handle_modern_request +from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, ServerEvent +from mcp.shared.dispatcher import CallOptions, DispatchContext +from mcp.shared.inbound import MCP_METHOD_HEADER, MCP_PROTOCOL_VERSION_HEADER, encode_header_value +from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher +from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata, SessionMessage +from mcp.shared.transport_context import TransportContext +from tests.interaction.transports import StreamingASGITransport +from tests.shared.test_dispatcher import Recorder, echo_handlers @pytest.mark.parametrize( @@ -154,3 +175,411 @@ def handler(request: httpx.Request) -> httpx.Response: assert MCP_PROTOCOL_VERSION_HEADER not in recorded[1].headers assert recorded[2].headers[MCP_PROTOCOL_VERSION_HEADER] == "2025-11-25" assert recorded[3].headers[MCP_PROTOCOL_VERSION_HEADER] == "2025-11-25" + + +class _ParkedSSEStream(httpx.AsyncByteStream): + """An SSE response body that emits one comment line, then parks until closed. + + `opened` fires once the transport is iterating the body (the POST is truly in + flight); `closed` fires when httpx tears the body down — the observable proof + that an abort, not a response, ended the stream. + """ + + def __init__(self) -> None: + self.opened = anyio.Event() + self.closed = anyio.Event() + self._release = anyio.Event() + + async def __aiter__(self) -> AsyncIterator[bytes]: + self.opened.set() + yield b": parked\n\n" + await self._release.wait() + + async def aclose(self) -> None: + self.closed.set() + self._release.set() + + +def _sse_or_ack_handler( + parked: _ParkedSSEStream, posted: list[dict[str, Any]], frame_posted: anyio.Event +) -> Callable[[httpx.Request], httpx.Response]: + """Requests get the parked SSE body; notifications get 202 and set `frame_posted`.""" + + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + posted.append(body) + if "id" in body: + return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked) + frame_posted.set() + return httpx.Response(202) + + return handler + + +@pytest.mark.anyio +async def test_modern_cancelled_frame_aborts_the_matching_in_flight_post() -> None: + """At 2026 an outbound `notifications/cancelled` never POSTs — closing the named + request's response stream IS the wire's cancellation signal — so the transport + aborts the in-flight POST and swallows the frame.""" + parked = _ParkedSSEStream() + posted: list[dict[str, Any]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + posted.append(json.loads(request.content)) + return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked) + + 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="listen-1", method="subscriptions/listen", params={}), + metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}), + ) + ) + await parked.opened.wait() + await write.send( + SessionMessage( + JSONRPCNotification( + jsonrpc="2.0", method="notifications/cancelled", params={"requestId": "listen-1"} + ) + ) + ) + await parked.closed.wait() + assert [body["method"] for body in posted] == ["subscriptions/listen"] + + +@pytest.mark.anyio +@pytest.mark.parametrize("stamped_version", [None, "2025-11-25"], ids=["no-version-yet", "2025-11-25"]) +async def test_legacy_cancelled_frame_posts_and_leaves_the_stream_open(stamped_version: str | None) -> None: + """Below 2026 — or before any stamped POST has revealed the version — the frame is + the spec's cancellation signal: it POSTs, and the request's stream stays open + (a 2025 disconnect is explicitly not a cancel).""" + parked = _ParkedSSEStream() + posted: list[dict[str, Any]] = [] + frame_posted = anyio.Event() + handler = _sse_or_ack_handler(parked, posted, frame_posted) + + 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), + ): + metadata = ( + ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: stamped_version}) + if stamped_version is not None + else None + ) + await write.send( + SessionMessage( + message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={}), + metadata=metadata, + ) + ) + await parked.opened.wait() + await write.send( + SessionMessage( + JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1}) + ) + ) + await frame_posted.wait() + # Checked before teardown: exiting the transport cancels the parked POST. + assert not parked.closed.is_set() + assert [body["method"] for body in posted] == ["tools/call", "notifications/cancelled"] + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "params", + [ + pytest.param({"requestId": 999}, id="unknown-id"), + pytest.param({"requestId": True}, id="bool-must-not-alias-request-id-1"), + pytest.param({"requestId": "1"}, id="string-1-must-not-match-int-1"), + pytest.param({}, id="no-request-id"), + pytest.param(None, id="no-params"), + ], +) +async def test_modern_cancelled_frames_matching_no_post_are_swallowed(params: dict[str, Any] | None) -> None: + """At 2026 the frame is swallowed even when it aborts nothing — the wire defines no + client-to-server notifications, so a late cancel racing the response must not leak + a POST — and a mismatched id must not abort someone else's stream.""" + parked = _ParkedSSEStream() + posted: list[dict[str, Any]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + posted.append(body) + if body.get("id") == 1: + return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked) + 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="subscriptions/listen", params={}), + metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}), + ) + ) + await parked.opened.wait() + await write.send( + SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params=params)) + ) + # A follow-up request completing proves the loop moved past the swallowed frame. + await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=2, method="ping", params={}))) + reply = await read.receive() + # Checked before teardown: exiting the transport cancels the parked POST. + assert not parked.closed.is_set() + assert isinstance(reply, SessionMessage) + assert isinstance(reply.message, JSONRPCResponse) + assert reply.message.id == 2 + assert [body["method"] for body in posted] == ["subscriptions/listen", "ping"] + + +@pytest.mark.anyio +async def test_handler_scoped_cancelled_frames_are_translated_at_modern_too() -> None: + """A cancel carrying `ServerMessageMetadata` (a handler abandoning its own + back-channel request) still names one of OUR outbound ids — every spec-legal + cancel names a request its sender issued — so at 2026 it aborts that POST and + stays off the wire like any other.""" + parked = _ParkedSSEStream() + posted: list[dict[str, Any]] = [] + frame_posted = anyio.Event() + handler = _sse_or_ack_handler(parked, posted, frame_posted) + + 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/call", params={}), + metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}), + ) + ) + await parked.opened.wait() + await write.send( + SessionMessage( + message=JSONRPCNotification( + jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1} + ), + metadata=ServerMessageMetadata(related_request_id=99), + ) + ) + await parked.closed.wait() + assert [body["method"] for body in posted] == ["tools/call"] + assert not frame_posted.is_set() + + +@pytest.mark.anyio +async def test_cancel_for_a_request_sent_under_2025_still_posts_after_modern_adoption() -> None: + """The translation follows the era the NAMED request was sent under, not the + cache at cancel time: a request POSTed under 2025 keeps 2025 cancellation + semantics (frame on the wire, stream left open) even after a later message + flips the negotiated version to 2026.""" + parked = _ParkedSSEStream() + posted: list[dict[str, Any]] = [] + frame_posted = anyio.Event() + + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content) + posted.append(body) + if body.get("id") == 1: + return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=parked) + if "id" in body: + return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}}) + frame_posted.set() + return httpx.Response(202) + + 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/call", params={}), + metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: "2025-11-25"}), + ) + ) + await parked.opened.wait() + # A modern-stamped request flips the cached negotiated version. + await write.send( + SessionMessage( + message=JSONRPCRequest(jsonrpc="2.0", id=2, method="ping", params={}), + metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}), + ) + ) + reply = await read.receive() + assert isinstance(reply, SessionMessage) + assert isinstance(reply.message, JSONRPCResponse) + await write.send( + SessionMessage( + JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params={"requestId": 1}) + ) + ) + await frame_posted.wait() + # Checked before teardown: exiting the transport cancels the parked POST. + assert not parked.closed.is_set() + assert [body["method"] for body in posted] == ["tools/call", "ping", "notifications/cancelled"] + + +class _SignalingBus(InMemorySubscriptionBus): + """Signals subscribe/unsubscribe so a test observes the stream lifecycle through + the bus Protocol (the public seam) instead of polling handler internals.""" + + def __init__(self) -> None: + super().__init__() + self.subscribed = anyio.Event() + self.unsubscribed = anyio.Event() + + def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]: + unsubscribe = super().subscribe(listener) + self.subscribed.set() + + def unsubscribe_and_signal() -> None: + unsubscribe() + self.unsubscribed.set() + + return unsubscribe_and_signal + + +@pytest.mark.anyio +async def test_scope_cancel_aborts_a_modern_listen_post_end_to_end() -> None: + """Over a real ASGI bridge: cancelling the caller of a parked `subscriptions/listen` + closes the POST's response stream — the server treats the disconnect as the cancel + and releases the subscription — and no `notifications/cancelled` crosses the wire.""" + bus = _SignalingBus() + server = Server("test", on_subscriptions_listen=ListenHandler(bus)) + + async def app(scope: Scope, receive: Receive, send: Send) -> None: + async with server.lifespan(server) as lifespan_state: + await handle_modern_request(server, None, False, lifespan_state, scope, receive, send) + + posted_methods: list[str] = [] + + async def record_request(request: httpx.Request) -> None: + posted_methods.append(json.loads(request.content)["method"]) + + acked = anyio.Event() + + async def on_notify(dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None) -> None: + assert method == "notifications/subscriptions/acknowledged" + acked.set() + + on_request, _ = echo_handlers(Recorder()) + + with anyio.fail_after(15): + async with ( + httpx.AsyncClient( + transport=StreamingASGITransport(app), + base_url="http://testserver", + event_hooks={"request": [record_request]}, + ) as http, + streamable_http_client("http://testserver/mcp", http_client=http) as (read, write), + ): + dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(read, write) + async with anyio.create_task_group() as tg: # pragma: no branch + await tg.start(dispatcher.run, on_request, on_notify) + listen_scope = anyio.CancelScope() + + async def send_listen() -> None: + params: dict[str, Any] = { + "_meta": { + PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION, + CLIENT_INFO_META_KEY: {"name": "test-client", "version": "0"}, + CLIENT_CAPABILITIES_META_KEY: {}, + }, + "notifications": {"toolsListChanged": True}, + } + opts: CallOptions = { + "request_id": "listen-1", + "headers": { + MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION, + MCP_METHOD_HEADER: "subscriptions/listen", + }, + } + with listen_scope: + await dispatcher.send_raw_request("subscriptions/listen", params, opts) + + tg.start_soon(send_listen) + await acked.wait() + assert bus.subscribed.is_set() + assert not bus.unsubscribed.is_set() + listen_scope.cancel() + await bus.unsubscribed.wait() + tg.cancel_scope.cancel() + assert posted_methods == ["subscriptions/listen"] + + +class _CompletingSSEStream(httpx.AsyncByteStream): + """An SSE body that delivers one JSON-RPC response, then parks in `aclose`. + + Holding `aclose` keeps the finished POST task alive past its response, so a + test can re-register the same request id underneath it before releasing. + """ + + def __init__(self, response_body: dict[str, Any]) -> None: + self._event = f"data: {json.dumps(response_body)}\n\n".encode() + self.release = anyio.Event() + + async def __aiter__(self) -> AsyncIterator[bytes]: + yield self._event + + async def aclose(self) -> None: + await self.release.wait() + + +@pytest.mark.anyio +async def test_a_finished_post_task_does_not_evict_a_reused_ids_new_registration() -> None: + """Request ids are reusable once resolved; a finished POST task unwinding late + must not pop the successor's registration, or a cancel for the reused id would + find nothing to abort and the live POST would leak past the cancellation.""" + completing = _CompletingSSEStream({"jsonrpc": "2.0", "id": "dup-1", "result": {}}) + parked = _ParkedSSEStream() + posted: list[dict[str, Any]] = [] + streams = [completing, parked] + + def handler(request: httpx.Request) -> httpx.Response: + posted.append(json.loads(request.content)) + return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=streams.pop(0)) + + 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), + ): + modern = ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: LATEST_MODERN_VERSION}) + await write.send( + SessionMessage( + message=JSONRPCRequest(jsonrpc="2.0", id="dup-1", method="tools/call", params={}), + metadata=modern, + ) + ) + reply = await read.receive() + assert isinstance(reply, SessionMessage) + assert isinstance(reply.message, JSONRPCResponse) + # The first task is now parked in `aclose`; reuse its id underneath it. + await write.send( + SessionMessage( + message=JSONRPCRequest(jsonrpc="2.0", id="dup-1", method="subscriptions/listen", params={}), + metadata=modern, + ) + ) + await parked.opened.wait() + completing.release.set() + await anyio.wait_all_tasks_blocked() + # The successor's registration survived: a cancel still aborts it. + await write.send( + SessionMessage( + JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled", params={"requestId": "dup-1"}) + ) + ) + await parked.closed.wait() + assert [body["method"] for body in posted] == ["tools/call", "subscriptions/listen"] diff --git a/tests/interaction/README.md b/tests/interaction/README.md index feb5ca5d15..666ee5a424 100644 --- a/tests/interaction/README.md +++ b/tests/interaction/README.md @@ -27,7 +27,10 @@ flows — with a single subprocess test for stdio. the constants in `mcp_types`; error *message strings* are pinned only where they are the SDK's own deliberate output. - **No sleeps, no real I/O.** Concurrency is coordinated with `anyio.Event`; every wait that - could hang is bounded by `anyio.fail_after(5)`. The HTTP and OAuth tests drive the Starlette + could hang is bounded by `anyio.fail_after(5)`. A test that must let in-flight deliveries + settle before teardown (an abandoned request's late error response, say) may use + `anyio.wait_all_tasks_blocked()`: the whole suite is single-loop and task-driven, so + quiescence is deterministic. The HTTP and OAuth tests drive the Starlette app in-process through the suite's streaming ASGI bridge (`transports/_bridge.py`), which delivers each response chunk as the server produces it — full duplex, but still no sockets, threads, or subprocesses anywhere outside the one stdio test. diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index ada4b7fa05..b7b7465f03 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -474,6 +474,25 @@ def __post_init__(self) -> None: "never reused within the session." ), ), + "protocol:request-id:caller-supplied": Requirement( + source="sdk", + behavior=( + "A caller can supply the id of a request it sends, so the id is known before any response " + "arrives; subscriptions/listen streams are demultiplexed by exactly that id." + ), + note=( + f"The demux-by-listen-request-id obligation is the spec's " + f"({SPEC_2026_BASE_URL}/basic/patterns/subscriptions#receiving-notifications); supplying the " + "id up front is the SDK surface that makes it satisfiable." + ), + added_in="2026-07-28", + deferred=( + "No public API surface yet: the capability exists at the dispatcher seam " + "(CallOptions['request_id'], unit-tested there), but ClientSession.send_request does not " + "expose it. The public consumer arrives with the client-side listen driver (Client.listen), " + "whose interaction tests will exercise it end to end." + ), + ), "protocol:notifications:no-response": Requirement( source=f"{SPEC_BASE_URL}/basic#notifications", behavior=( @@ -484,15 +503,33 @@ def __post_init__(self) -> None: "protocol:cancel:abort-signal": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#cancellation-flow", behavior=( - "Cancelling an in-flight request through the client API sends notifications/cancelled with " - "the request id and fails the local call." + "Abandoning an in-flight request client-side (cancelling the task awaiting it) cancels the " + "request itself: the server-side handler stops and the session serves later requests " + "normally." ), - deferred=( - "Not implemented in the SDK: there is no public client-side API to cancel an in-flight " - "request; cancellation requires hand-constructing the notification (which is how " - "protocol:cancel:in-flight exercises the receiving side)." + note=( + "The per-transport wire spelling (frame vs response-stream close) is pinned separately by " + "protocol:cancel:stream-frame and the client-transport:http:cancel-* pair." + ), + arm_exclusions=( + ArmExclusion( + reason="requires-session", + transport="streamable-http-stateless", + note=( + "The 2025-era cancel frame POSTs on a fresh per-request transport that shares no " + "in-flight state with the blocked request, so the handler is never interrupted." + ), + ), ), ), + "protocol:cancel:abort-scoped": Requirement( + source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements", + behavior=( + "Abandoning one in-flight request cancels only that request: a concurrent request on the " + "same connection keeps running and returns its result." + ), + arm_exclusions=(ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),), + ), "protocol:cancel:handler-abort-propagates": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements", behavior="On the receiving side, a cancellation notification stops the running request handler.", @@ -550,6 +587,16 @@ def __post_init__(self) -> None: ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), ), ), + "protocol:cancel:stream-frame": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/cancellation#transport-specific-cancellation", + behavior=( + "On stream (stdio-shaped) wires at 2026-07-28, abandoning an in-flight request sends exactly " + "one notifications/cancelled naming its request id - streams keep the frame spelling of " + "cancellation that streamable HTTP dropped." + ), + added_in="2026-07-28", + note="Exercised over the in-memory stream pair, the same dual-era wire stdio serves.", + ), "protocol:cancel:unknown-id-ignored": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#error-handling", behavior=( @@ -3256,6 +3303,30 @@ def __post_init__(self) -> None: transports=("streamable-http",), note="Only observable over HTTP: Accept is an HTTP request header.", ), + "client-transport:http:cancel-closes-stream": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#cancellation", + behavior=( + "At 2026-07-28, abandoning an in-flight request closes that request's own POST stream and " + "posts nothing further: no notifications/cancelled reaches the server (the revision defines " + "no client-to-server notifications), and the server treats the disconnect as cancellation " + "of exactly that request." + ), + transports=("streamable-http",), + added_in="2026-07-28", + supersedes=("client-transport:http:cancel-posts-frame",), + note="HTTP-only by nature: the response stream that closing constitutes the signal is an HTTP exchange.", + ), + "client-transport:http:cancel-posts-frame": Requirement( + source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#cancellation-flow", + behavior=( + "At 2025-era revisions, abandoning an in-flight request POSTs exactly one " + "notifications/cancelled naming its request id." + ), + transports=("streamable-http",), + removed_in="2026-07-28", + superseded_by="client-transport:http:cancel-closes-stream", + note="HTTP-only by nature: pins that the frame travels as its own POST on the legacy HTTP wire.", + ), "client-transport:http:concurrent-streams": Requirement( source="sdk", behavior="Multiple concurrent POST-initiated SSE streams each deliver their response to the right caller.", diff --git a/tests/interaction/lowlevel/test_cancellation.py b/tests/interaction/lowlevel/test_cancellation.py index 247e1135a6..0e9d81afbc 100644 --- a/tests/interaction/lowlevel/test_cancellation.py +++ b/tests/interaction/lowlevel/test_cancellation.py @@ -1,9 +1,10 @@ """Cancellation interactions against the low-level Server, driven through the public Client API. -There is no client-side cancellation API: cancelling means sending a CancelledNotification -carrying the request id, which only the server-side handler can observe (`ctx.request_id`), so -these tests capture the id from inside the blocked handler before cancelling. The handler blocks -on an Event rather than a sleep, and every wait is bounded by `anyio.fail_after`. +Client-side, cancelling means abandoning: cancelling the task that awaits a call makes the SDK +carry the signal in the transport's own spelling (a cancelled frame on stream wires, closing the +request's own response stream at 2026-07-28 streamable HTTP). The receiving-side tests instead +script a CancelledNotification by hand, capturing the request id from inside the blocked handler. +Handlers block on an Event rather than a sleep, and every wait is bounded by `anyio.fail_after`. """ import anyio @@ -20,9 +21,11 @@ JSONRPCNotification, JSONRPCRequest, JSONRPCResponse, + ListToolsResult, PingRequest, ServerCapabilities, TextContent, + Tool, ) from mcp import MCPError @@ -344,3 +347,121 @@ async def scripted_server(streams: MessageStream) -> None: assert pong == snapshot(EmptyResult()) # The stream is ordered, so a courtesy cancel would have arrived ahead of the ping. assert received_methods == snapshot(["initialize", "ping"]) + + +@requirement("protocol:cancel:abort-signal") +async def test_abandoning_a_call_stops_the_server_handler(connect: Connect) -> None: + """Cancelling the task that awaits a call cancels the request itself, not just the local wait: + the server-side handler is interrupted, and the session serves later requests normally. + + Spec-mandated (cancellation flow): the sender cancels requests it abandons; the wire spelling + is per-transport (frame on stream wires, response-stream close at 2026 streamable HTTP). + """ + handler_started = anyio.Event() + handler_cancelled = anyio.Event() + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + if params.name == "block": + handler_started.set() + try: + await anyio.Event().wait() # parked until the client's abandonment cancels it + except anyio.get_cancelled_exc_class(): + handler_cancelled.set() + raise + assert params.name == "echo" + return CallToolResult(content=[TextContent(text="ok")]) + + async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[Tool(name=name, input_schema={"type": "object"}) for name in ("block", "echo")]) + + server = Server("blocker", on_list_tools=list_tools, on_call_tool=call_tool) + + async with connect(server) as client: + abandon = anyio.CancelScope() + + async def call_and_abandon() -> None: + with abandon: + await client.call_tool("block", {}) + raise NotImplementedError # unreachable: the call never resolves + assert abandon.cancelled_caught + + async with anyio.create_task_group() as tg: + tg.start_soon(call_and_abandon) + with anyio.fail_after(5): + await handler_started.wait() + abandon.cancel() + with anyio.fail_after(5): + await handler_cancelled.wait() + + # Let the abandoned call's late error response (sent on the legacy arms) arrive and be + # dropped while the client is still open, so teardown never races its delivery. + await anyio.wait_all_tasks_blocked() + result = await client.call_tool("echo", {}) + assert result == snapshot(CallToolResult(content=[TextContent(text="ok")])) + + +@requirement("protocol:cancel:abort-scoped") +async def test_abandoning_one_call_leaves_a_concurrent_call_running(connect: Connect) -> None: + """Cancellation is scoped to the request it names: with two calls genuinely in flight, + abandoning the first interrupts only its handler and the second returns its result. + + Steps: + 1. `doomed` and `survivor` are both mid-flight (each handler has started). + 2. The client abandons `doomed`; its handler observes cancellation. + 3. `survivor` is released and completes normally. + """ + doomed_started = anyio.Event() + doomed_cancelled = anyio.Event() + survivor_started = anyio.Event() + release_survivor = anyio.Event() + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + if params.name == "doomed": + doomed_started.set() + try: + await anyio.Event().wait() # parked until the client's abandonment cancels it + except anyio.get_cancelled_exc_class(): + doomed_cancelled.set() + raise + assert params.name == "survivor" + survivor_started.set() + with anyio.fail_after(5): + await release_survivor.wait() + return CallToolResult(content=[TextContent(text="survived")]) + + async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult( + tools=[Tool(name=name, input_schema={"type": "object"}) for name in ("doomed", "survivor")] + ) + + server = Server("pair", on_list_tools=list_tools, on_call_tool=call_tool) + + async with connect(server) as client: + abandon = anyio.CancelScope() + results: list[CallToolResult] = [] + + async def doomed_call() -> None: + with abandon: + await client.call_tool("doomed", {}) + raise NotImplementedError # unreachable: the call never resolves + + async def survivor_call() -> None: + results.append(await client.call_tool("survivor", {})) + + async with anyio.create_task_group() as tg: + tg.start_soon(doomed_call) + with anyio.fail_after(5): + await doomed_started.wait() + tg.start_soon(survivor_call) + with anyio.fail_after(5): + await survivor_started.wait() + abandon.cancel() + with anyio.fail_after(5): + await doomed_cancelled.wait() + release_survivor.set() + + # Let the abandoned call's late error response (sent on the legacy arms) arrive and be + # dropped while the client is still open, so teardown never races its delivery. + await anyio.wait_all_tasks_blocked() + + assert results == snapshot([CallToolResult(content=[TextContent(text="survived")])]) diff --git a/tests/interaction/lowlevel/test_wire.py b/tests/interaction/lowlevel/test_wire.py index 73452f1afb..b3d286ca1d 100644 --- a/tests/interaction/lowlevel/test_wire.py +++ b/tests/interaction/lowlevel/test_wire.py @@ -308,3 +308,58 @@ async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelReq assert len(errors) == 1 assert errors[0].code == INVALID_PARAMS + + +@requirement("protocol:cancel:stream-frame") +async def test_abandoning_a_call_on_a_modern_stream_wire_sends_one_cancelled_frame() -> None: + """At 2026-07-28 over a stream (stdio-shaped) wire, abandoning an in-flight call puts exactly + one notifications/cancelled naming that request on the wire, and the frame interrupts the + server-side handler - stream wires keep the frame spelling that 2026 streamable HTTP dropped. + """ + handler_started = anyio.Event() + handler_cancelled = anyio.Event() + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + raise NotImplementedError # registered so tools/call is served; the stream wire never lists + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + assert params.name == "block" + handler_started.set() + try: + await anyio.Event().wait() # parked until the client's abandonment cancels it + except anyio.get_cancelled_exc_class(): + handler_cancelled.set() + raise + raise NotImplementedError # unreachable + + server = Server("blocker", on_list_tools=list_tools, on_call_tool=call_tool) + recording = RecordingTransport(InMemoryTransport(server)) + + async with Client(recording, mode="2026-07-28") as client: + abandon = anyio.CancelScope() + + async def call_and_abandon() -> None: + with abandon: + await client.call_tool("block", {}) + raise NotImplementedError # unreachable: the call never resolves + + async with anyio.create_task_group() as tg: + tg.start_soon(call_and_abandon) + with anyio.fail_after(5): + await handler_started.wait() + abandon.cancel() + with anyio.fail_after(5): + await handler_cancelled.wait() + + # Let the cancelled call's late error response arrive and be dropped while the client + # is still open, so teardown never races its delivery. + await anyio.wait_all_tasks_blocked() + + call, cancel = [message.message for message in recording.sent] + assert isinstance(call, JSONRPCRequest) + assert call.method == "tools/call" + assert isinstance(cancel, JSONRPCNotification) + assert cancel.method == "notifications/cancelled" + assert cancel.params == {"requestId": call.id, "reason": "caller cancelled"} diff --git a/tests/interaction/transports/test_client_transport_http.py b/tests/interaction/transports/test_client_transport_http.py index 5508d3e8f9..61b0e21c9b 100644 --- a/tests/interaction/transports/test_client_transport_http.py +++ b/tests/interaction/transports/test_client_transport_http.py @@ -6,6 +6,7 @@ wire-level instrument; the SDK client never exposes these details. """ +import json from collections.abc import AsyncIterator import anyio @@ -246,3 +247,106 @@ async def first_post_then_404(scope: Scope, receive: Receive, send: Send) -> Non await client.list_tools() assert exc_info.value.error == snapshot(ErrorData(code=INVALID_REQUEST, message="Session terminated")) + + +def _blocking_server(started: anyio.Event, cancelled: anyio.Event) -> Server: + """A server whose `block` tool parks until cancelled; `echo` answers normally.""" + + async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[Tool(name=name, input_schema={"type": "object"}) for name in ("block", "echo")]) + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + if params.name == "block": + started.set() + try: + await anyio.Event().wait() # parked until the client's abandonment cancels it + except anyio.get_cancelled_exc_class(): + cancelled.set() + raise + assert params.name == "echo" + return CallToolResult(content=[TextContent(text="ok")]) + + return Server("blocker", on_list_tools=list_tools, on_call_tool=call_tool) + + +@requirement("client-transport:http:cancel-closes-stream") +async def test_at_2026_abandoning_a_call_closes_its_stream_and_posts_nothing() -> None: + """At 2026-07-28, abandoning an in-flight call aborts that call's own POST - the server sees + the disconnect and cancels exactly that handler - and no notifications/cancelled is POSTed. + + The follow-up echo call bounds the negative: POSTs leave the client's writer serially, so a + cancel frame would have to appear before the echo's POST. + """ + handler_started = anyio.Event() + handler_cancelled = anyio.Event() + requests: list[tuple[str, bytes]] = [] + + async def record(request: httpx.Request) -> None: + requests.append((request.method, request.content)) + + server = _blocking_server(handler_started, handler_cancelled) + async with mounted_app(server, on_request=record) as (http, _): + transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http) + async with Client(transport, mode="2026-07-28") as client: + await client.list_tools() # settles the schema cache so the calls below add no refresh POST + abandon = anyio.CancelScope() + + async def call_and_abandon() -> None: + with abandon: + await client.call_tool("block", {}) + raise NotImplementedError # unreachable: the call never resolves + + async with anyio.create_task_group() as tg: + tg.start_soon(call_and_abandon) + with anyio.fail_after(5): + await handler_started.wait() + abandon.cancel() + with anyio.fail_after(5): + await handler_cancelled.wait() + + result = await client.call_tool("echo", {}) + assert result.content == [TextContent(text="ok")] + + wire = [(method, json.loads(body)["method"] if body else None) for method, body in requests] + assert wire == snapshot([("POST", "tools/list"), ("POST", "tools/call"), ("POST", "tools/call")]) + + +@requirement("client-transport:http:cancel-posts-frame") +async def test_at_2025_abandoning_a_call_posts_exactly_one_cancelled_frame() -> None: + """At 2025-era revisions, abandoning an in-flight call POSTs one notifications/cancelled + naming the abandoned request's id - the frame is the legacy HTTP spelling of cancellation, + and it interrupts the server-side handler. + """ + handler_started = anyio.Event() + handler_cancelled = anyio.Event() + requests: list[tuple[str, bytes]] = [] + + async def record(request: httpx.Request) -> None: + requests.append((request.method, request.content)) + + server = _blocking_server(handler_started, handler_cancelled) + async with mounted_app(server, on_request=record) as (http, _): + async with client_via_http(http) as client: + abandon = anyio.CancelScope() + + async def call_and_abandon() -> None: + with abandon: + await client.call_tool("block", {}) + raise NotImplementedError # unreachable: the call never resolves + + async with anyio.create_task_group() as tg: + tg.start_soon(call_and_abandon) + with anyio.fail_after(5): + await handler_started.wait() + abandon.cancel() + with anyio.fail_after(5): + await handler_cancelled.wait() + # Let the abandoned call's late error response arrive and be dropped while the + # client is still open, so teardown never races its delivery. + await anyio.wait_all_tasks_blocked() + + posts = [json.loads(body) for method, body in requests if method == "POST" and body] + block_calls = [p for p in posts if p.get("method") == "tools/call" and p["params"]["name"] == "block"] + cancels = [p for p in posts if p.get("method") == "notifications/cancelled"] + assert len(block_calls) == 1 + assert [c["params"]["requestId"] for c in cancels] == [block_calls[0]["id"]] diff --git a/tests/shared/test_dispatcher.py b/tests/shared/test_dispatcher.py index 1f82083379..03ef27c8db 100644 --- a/tests/shared/test_dispatcher.py +++ b/tests/shared/test_dispatcher.py @@ -19,6 +19,7 @@ INVALID_REQUEST, REQUEST_TIMEOUT, ErrorData, + RequestId, Tool, ) @@ -396,6 +397,118 @@ async def test_direct_close_makes_run_return(): server.close() +@pytest.mark.anyio +async def test_send_raw_request_honors_caller_supplied_request_id_verbatim_typed(pair_factory: PairFactory): + """A caller-supplied `CallOptions["request_id"]` reaches the peer's context verbatim — + "7" stays a string, never the integer 7 — and the next call without one still mints + a dispatcher id as before.""" + async with running_pair(pair_factory) as (client, _server, _crec, srec): + with anyio.fail_after(5): + await client.send_raw_request("first", None, {"request_id": "7"}) + await client.send_raw_request("second", None) + supplied, minted = (ctx.request_id for ctx in srec.contexts) + assert supplied == "7" + assert type(supplied) is str + assert type(minted) is int + + +@pytest.mark.anyio +async def test_send_raw_request_with_in_flight_request_id_raises_and_frees_id_on_completion( + pair_factory: PairFactory, +): + """Reusing an id while it is in flight is a loud `ValueError` — silent reuse would + corrupt response correlation. Once the first request completes, the id is free + again: the reservation is in-flight-scoped, not permanent.""" + entered = anyio.Event() + release = anyio.Event() + + async def parked( + ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None + ) -> dict[str, Any]: + entered.set() + await release.wait() + return {"served": method} + + async with running_pair(pair_factory, server_on_request=parked) as (client, *_): + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: + + async def first() -> None: + await client.send_raw_request("slow", None, {"request_id": "listen-1"}) + + tg.start_soon(first) + await entered.wait() + with pytest.raises(ValueError, match="already in flight"): + await client.send_raw_request("duplicate", None, {"request_id": "listen-1"}) + release.set() + result = await client.send_raw_request("again", None, {"request_id": "listen-1"}) + assert result == {"served": "again"} + + +@pytest.mark.anyio +async def test_minted_ids_skip_a_caller_supplied_id_still_in_flight(pair_factory: PairFactory): + """The dispatcher mints PAST a key a supplied id occupies — the collision error + is reserved for the caller who chose the id, never an innocent minted request.""" + entered = anyio.Event() + release = anyio.Event() + seen_ids: list[RequestId | None] = [] + + async def maybe_park( + ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None + ) -> dict[str, Any]: + seen_ids.append(ctx.request_id) + if method == "park": + entered.set() + await release.wait() + return {} + + async with running_pair(pair_factory, server_on_request=maybe_park) as (client, *_): + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: + + async def parked() -> None: + await client.send_raw_request("park", None, {"request_id": "3"}) + + tg.start_soon(parked) + await entered.wait() + # The counter mints 1 and 2, then skips the occupied 3 to 4. + for _ in range(3): + await client.send_raw_request("plain", None) + release.set() + assert [request_id for request_id in seen_ids if request_id != "3"] == [1, 2, 4] + + +@pytest.mark.anyio +async def test_supplied_numeric_string_id_collides_with_its_int_twin(pair_factory: PairFactory): + """ "7" and 7 are one id in the collision domain on BOTH dispatchers, so the + in-memory pair raises exactly where the wire dispatcher (whose pending keys + are coerced for response correlation) would.""" + entered = anyio.Event() + release = anyio.Event() + + async def parked( + ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None + ) -> dict[str, Any]: + entered.set() + await release.wait() + return {} + + async with running_pair(pair_factory, server_on_request=parked) as (client, *_): + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: + + async def first() -> None: + await client.send_raw_request("slow", None, {"request_id": 7}) + + tg.start_soon(first) + await entered.wait() + with pytest.raises(ValueError, match="already in flight"): + await client.send_raw_request("duplicate", None, {"request_id": "7"}) + release.set() + # Completion frees the id for either spelling. + assert await client.send_raw_request("again", None, {"request_id": "7"}) == {} + + if TYPE_CHECKING: _d: Dispatcher[TransportContext] = DirectDispatcher(TransportContext(kind="direct", can_send_request=True)) _o: Outbound = _d diff --git a/tests/shared/test_jsonrpc_dispatcher.py b/tests/shared/test_jsonrpc_dispatcher.py index 82d16bc4b9..e91fc2de27 100644 --- a/tests/shared/test_jsonrpc_dispatcher.py +++ b/tests/shared/test_jsonrpc_dispatcher.py @@ -34,11 +34,10 @@ from mcp.server import Server, ServerRequestContext from mcp.shared._compat import resync_tracer from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream -from mcp.shared.dispatcher import CallOptions, DispatchContext +from mcp.shared.dispatcher import CallOptions, DispatchContext, coerce_request_id from mcp.shared.exceptions import MCPError, NoBackChannelError from mcp.shared.jsonrpc_dispatcher import ( # pyright: ignore[reportPrivateUsage] JSONRPCDispatcher, - _coerce_id, _OutboundPlan, _Pending, _plan_outbound, @@ -1821,7 +1820,7 @@ async def respond_stringly() -> None: @pytest.mark.anyio async def test_error_response_with_string_id_correlates_to_int_keyed_pending_request(): - """A JSONRPCError echoing the request ID as a JSON string still resolves the waiter (same `_coerce_id` path).""" + """A JSONRPCError echoing the request ID as a JSON string still resolves the waiter (`coerce_request_id` path).""" 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) @@ -1900,10 +1899,10 @@ async def on_progress(progress: float, total: float | None, message: str | None) assert seen == [0.5] -def test_coerce_id_passes_through_non_numeric_string_and_int(): - assert _coerce_id("7") == 7 - assert _coerce_id("not-an-int") == "not-an-int" - assert _coerce_id(42) == 42 +def test_coerce_request_id_passes_through_non_numeric_string_and_int(): + assert coerce_request_id("7") == 7 + assert coerce_request_id("not-an-int") == "not-an-int" + assert coerce_request_id(42) == 42 @pytest.mark.anyio @@ -2154,7 +2153,7 @@ async def on_notify(ctx: DCtx, method: str, params: Mapping[str, Any] | None) -> ids=["string-cancel-for-int-request", "int-cancel-for-string-request"], ) async def test_cancelled_correlates_across_string_and_int_request_id_forms(request_id: RequestId, cancel_id: object): - """A peer that stringifies the id between request and cancel still cancels (same `_coerce_id` path).""" + """A peer that stringifies the id between request and cancel still cancels (same `coerce_request_id` path).""" c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32) server: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(c2s_recv, s2c_send) @@ -2381,3 +2380,38 @@ async def call() -> None: assert observed[0][0] == "notifications/cancelled" assert observed[0][1]["requestId"] == request_id assert observed[0][1]["reason"] == "user clicked stop" + + +@pytest.mark.anyio +async def test_send_raw_request_with_caller_supplied_string_id_is_verbatim_on_the_wire(): + """A supplied "7" goes on the wire as the string "7", and the response still + correlates when the peer echoes it back as the integer 7 — the pending key gets + the same coercion `_resolve_pending` applies to inbound ids.""" + c2s_send, c2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](4) + s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](4) + client: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(s2c_recv, c2s_send) + on_request, on_notify = echo_handlers(Recorder()) + result_box: list[dict[str, Any]] = [] + done = anyio.Event() + try: + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: + + async def call() -> None: + result_box.append(await client.send_raw_request("tools/list", None, {"request_id": "7"})) + done.set() + + await tg.start(client.run, on_request, on_notify) + tg.start_soon(call) + wire = await c2s_recv.receive() + assert isinstance(wire, SessionMessage) + assert isinstance(wire.message, JSONRPCRequest) + assert wire.message.id == "7" + assert type(wire.message.id) is str + await s2c_send.send(SessionMessage(JSONRPCResponse(jsonrpc="2.0", id=7, result={"ok": True}))) + await done.wait() + tg.cancel_scope.cancel() + finally: + for stream in (c2s_send, c2s_recv, s2c_send, s2c_recv): + stream.close() + assert result_box == [{"ok": True}] From d287c9868f0d6fefadb2c868323a8aa3850730a9 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:25:57 +0100 Subject: [PATCH 049/100] Extend resolver DI to sampling and roots requests (#3049) --- docs/client/callbacks.md | 2 + docs/handlers/dependencies.md | 13 + docs/handlers/index.md | 3 + docs/handlers/multi-round-trip.md | 2 +- docs/handlers/sampling-and-roots.md | 46 ++ docs/migration.md | 18 + docs_src/dependencies/tutorial004.py | 26 + docs_src/sampling_and_roots/__init__.py | 0 docs_src/sampling_and_roots/tutorial001.py | 22 + docs_src/sampling_and_roots/tutorial002.py | 20 + mkdocs.yml | 1 + src/mcp/client/client.py | 4 + src/mcp/server/mcpserver/__init__.py | 4 + src/mcp/server/mcpserver/resolve.py | 253 +++++-- src/mcp/server/session.py | 33 +- src/mcp/server/validation.py | 7 +- src/mcp/shared/peer.py | 22 +- tests/docs_src/test_dependencies.py | 22 +- tests/docs_src/test_sampling_and_roots.py | 62 ++ tests/server/mcpserver/test_resolve.py | 743 +++++++++++++++++---- tests/server/test_session.py | 15 + tests/shared/test_peer.py | 16 + 22 files changed, 1134 insertions(+), 200 deletions(-) create mode 100644 docs/handlers/sampling-and-roots.md create mode 100644 docs_src/dependencies/tutorial004.py create mode 100644 docs_src/sampling_and_roots/__init__.py create mode 100644 docs_src/sampling_and_roots/tutorial001.py create mode 100644 docs_src/sampling_and_roots/tutorial002.py create mode 100644 tests/docs_src/test_sampling_and_roots.py diff --git a/docs/client/callbacks.md b/docs/client/callbacks.md index e9787da8da..6b4e934cf9 100644 --- a/docs/client/callbacks.md +++ b/docs/client/callbacks.md @@ -78,6 +78,8 @@ When a client connects it declares its `capabilities`, the mirror image of the s | `list_roots_callback=` | `"roots": {"listChanged": true}` | | none of them | `{}` | +Sampling sub-capabilities are the one refinement: pass `sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability())` alongside `sampling_callback` when your sampler handles the `tools` / `tool_choice` parameters. Servers must see `sampling.tools` declared before they can send them. + `logging_callback` and `message_handler` are not in the table. They handle notifications, and notifications need no capability. The server reads the declaration back with `ctx.session.check_client_capability(...)`. Add a tool that does: diff --git a/docs/handlers/dependencies.md b/docs/handlers/dependencies.md index 6260b72f5a..509b2635f0 100644 --- a/docs/handlers/dependencies.md +++ b/docs/handlers/dependencies.md @@ -134,6 +134,18 @@ That's the right default for a precondition: no answer, no order. When declining to bind to. A question built from such volatile data makes every recorded answer look stale, so the server re-asks it on every round until the client's round limit ends the call. +## Ask the client, not the user + +Elicitation is one of the three questions a resolver can ask, and the multi-round-trip flow allows no others. The other two go to the **client** rather than the user: return `Sample(...)` to run an LLM call through the client (a `sampling/createMessage` request), or `ListRoots()` to fetch the client's current roots. Neither has an accept/decline outcome; the consumer annotates the result type directly, `CreateMessageResult` (`CreateMessageResultWithTools` when the request carries `tools` or `tool_choice`) or `ListRootsResult`: + +```python title="server.py" hl_lines="11-16 22" +--8<-- "docs_src/dependencies/tutorial004.py" +``` + +* The framework routes these exactly like `Elicit`: inside the multi-round-trip `tools/call` on **2026-07-28**, over the standalone server->client request on **2025-11-25**. An undeclared capability refuses the call with a `-32021` protocol error (`sampling`, `roots`, form-mode `elicitation`; `sampling.tools` when the request carries `tools` or `tool_choice`). +* Everything the info box above says about questions applies unchanged: a `Sample` request is matched to its recorded result by its exact rendering, so build it deterministically from the tool's arguments and earlier answers; the client then pays for the LLM call once per tool call, not once per round. The recorded result rides `request_state` for the rest of the call, so a very large completion makes every remaining round-trip heavier. +* The standalone sampling and roots *features* are deprecated at 2026-07-28 (SEP-2577). New servers that need the client's model ask through this carrier; servers that don't should integrate with an LLM provider directly. `include_context` values other than `"none"` are themselves deprecated; avoid them. + ## Recap * `Annotated[T, Resolve(fn)]` on a tool parameter: the SDK runs `fn` and injects its return value. @@ -141,5 +153,6 @@ That's the right default for a precondition: no answer, no order. When declining * A resolver's parameters are resolved the same way: the `Context`, another `Resolve(...)`, or a tool argument by name. The graph runs each resolver at most once per round, however many consumers it has; each question is asked exactly once, and any resolver may run again when a call resumes after a question. * Bad graphs fail at registration with `InvalidSignature`, not mid-call. * Return `Elicit(message, Model)` to ask the user, only when you have to. Unwrapped annotations abort on decline; `ElicitationResult[T]` lets the tool branch. +* Return `Sample(...)` or `ListRoots()` to ask the client for an LLM completion or the roots list; the plain result is injected. The state your server builds once at startup, and how a handler reaches it, is the **[Lifespan](lifespan.md)** page. diff --git a/docs/handlers/index.md b/docs/handlers/index.md index eb2b5be414..daf9fde19a 100644 --- a/docs/handlers/index.md +++ b/docs/handlers/index.md @@ -18,6 +18,9 @@ What it can do while it runs: * Ask the user for more input with **[Elicitation](elicitation.md)**, and **[Multi-round-trip requests](multi-round-trip.md)**, the 2026-07-28 pattern that carries it. +* Ask the client for an LLM completion or its workspace folders with + **[Sampling and roots](sampling-and-roots.md)**, deprecated but still + served. * Report **[Progress](progress.md)** on something slow. * Write logs (to standard error, for whoever operates the server) with **[Logging](logging.md)**. diff --git a/docs/handlers/multi-round-trip.md b/docs/handlers/multi-round-trip.md index d5451e2311..e08903444b 100644 --- a/docs/handlers/multi-round-trip.md +++ b/docs/handlers/multi-round-trip.md @@ -19,7 +19,7 @@ That's the whole protocol. Every leg is an ordinary request from the client to t ## The server side -On `@mcp.tool()` you rarely build this by hand: declare a dependency that asks the user and the SDK returns the `InputRequiredResult` for you - that form is the **[Dependencies](dependencies.md)** page. The two forms don't mix: a call has one `input_responses`/`request_state` channel, so a tool that uses `Resolve(...)` parameters cannot also return `InputRequiredResult` from its body. A declared `InputRequiredResult` return is rejected at registration (`InvalidSignature`), and an undeclared one fails the call at runtime. The manual form is the **low-level** `Server`, whose `on_call_tool` handler is allowed to return either result type: +On `@mcp.tool()` you rarely build this by hand: declare a dependency that asks the user (`Elicit`), samples the client's LLM (`Sample`), or lists its roots (`ListRoots`) and the SDK returns the `InputRequiredResult` for you; that form is the **[Dependencies](dependencies.md)** page. The two forms don't mix: a call has one `input_responses`/`request_state` channel, so a tool that uses `Resolve(...)` parameters cannot also return `InputRequiredResult` from its body. A declared `InputRequiredResult` return is rejected at registration (`InvalidSignature`), and an undeclared one fails the call at runtime. The manual form is the **low-level** `Server`, whose `on_call_tool` handler is allowed to return either result type: ```python title="server.py" hl_lines="44-47" --8<-- "docs_src/mrtr/tutorial001.py" diff --git a/docs/handlers/sampling-and-roots.md b/docs/handlers/sampling-and-roots.md new file mode 100644 index 0000000000..6174f42585 --- /dev/null +++ b/docs/handlers/sampling-and-roots.md @@ -0,0 +1,46 @@ +# Sampling and roots + +A handler can ask the connected client for two more things: a completion from the client's own model (**sampling**), and the client's workspace folders (**roots**). + +Both still work, on every protocol version the SDK speaks. But read the warning before you design around them: + +!!! warning "Deprecated by the 2026-07-28 specification" + Sampling and roots are deprecated as of `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2577)). They remain fully functional and stay in the specification for at least twelve months before becoming eligible for removal, but new implementations should not build on them. The suggested migrations: integrate directly with your LLM provider's API instead of sampling, and pass directories via tool parameters, resource URIs, or server configuration instead of roots. The SDK-wide list is in **[Deprecated features](../deprecated.md)**. + +## Sampling: borrow the client's model + +A resolver returns `Sample(...)` and the tool receives the completion, through the same dependency mechanism that runs `Elicit` in **[Dependencies](dependencies.md)**: + +```python title="server.py" hl_lines="11-16 20" +--8<-- "docs_src/sampling_and_roots/tutorial001.py" +``` + +* `Sample(messages, max_tokens=...)` mirrors the `sampling/createMessage` parameters. The injected value is the client's `CreateMessageResult`; pass `tools` or `tool_choice` and it becomes a `CreateMessageResultWithTools` instead. +* The client must have declared the `sampling` capability (`sampling.tools` if you pass `tools` or `tool_choice`). If it didn't, the call fails with a `-32021` protocol error instead of sending a request the client cannot handle. A pre-2026 session with no back-channel fails with its usual no-back-channel error, since there is nothing to send on. +* At `2026-07-28` the request is delivered inside the multi-round-trip flow (**[Multi-round-trip requests](multi-round-trip.md)**); on `2025-11-25` it is a standalone request to the client. The code is the same either way, but mind the multi-round-trip rule: the request must render identically across retry rounds, so build it only from the tool's arguments and other stable data. +* Leave `include_context` alone: values other than `"none"` are themselves deprecated (SEP-2596) and need a capability almost no client declares. + +## Roots: where should this go? + +Roots are the folders the client says the server may operate on. They are informational guidance, not an access-control mechanism. A resolver returns `ListRoots()`: + +```python title="server.py" hl_lines="11-12 16" +--8<-- "docs_src/sampling_and_roots/tutorial002.py" +``` + +* The injected `ListRootsResult` carries a list of `Root`s: a `file://` URI and an optional display name. +* The gate is the same as for sampling: without a declared `roots` capability the call fails with `-32021` instead of sending the request. + +On the other side of the wire, the client answers both requests with the callbacks it already has: `sampling_callback` and `list_roots_callback`, covered in **[Client callbacks](../client/callbacks.md)**. + +## On 2025-era connections + +`ctx.session.create_message(...)` and `ctx.session.list_roots()` still exist for code that drives the session directly. They only work where a back-channel exists (2025-era, non-stateless connections), and calling them raises a deprecation warning. The resolver markers above are the supported form: they pick the delivery from the negotiated version and don't warn. + +## Recap + +* Return `Sample(...)` or `ListRoots()` from a resolver; the tool receives the `CreateMessageResult` or `ListRootsResult` like any other dependency. +* The client must declare the matching capability, or the call fails with `-32021` instead of a request being sent. +* Both features are deprecated at `2026-07-28`: fully functional for now, wrong for new designs. Prefer provider APIs over sampling and explicit parameters over roots. + +Reporting how far along a slow tool is: **[Progress](progress.md)**. diff --git a/docs/migration.md b/docs/migration.md index 3c544d00ed..811fa17d99 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -697,6 +697,24 @@ and raises `RuntimeError` if the resource requests input. The internal layers (`ToolManager.call_tool`, `Tool.run`, `Prompt.render`, `ResourceTemplate.create_resource`, etc.) now require `context` as a positional argument. +### Resolver-routed requests require the client capability on every protocol version + +A v1 server could send elicitation, sampling, and roots requests to clients +that never declared the matching capability; only tools-bearing sampling was +checked. In v2 the `Resolve(...)` markers (`Elicit`, `Sample`, `ListRoots`) +enforce the spec's egress rule: an undeclared capability (form-mode `elicitation`, +`sampling`, or `roots`, plus `sampling.tools` when the request carries `tools` +or `tool_choice`) fails the call with a `-32021` +`MISSING_REQUIRED_CLIENT_CAPABILITY` JSON-RPC error instead of sending a +request the client cannot handle. This applies on 2025-11-25 sessions with a +live back-channel too; a session with no back-channel keeps failing with its +no-back-channel error. To migrate, declare the capability: the SDK client +declares `elicitation`, `sampling`, and `roots` when the matching callback is +set, and `sampling.tools` needs an explicit +`Client(sampling_capabilities=SamplingCapability(tools=...))`. Direct +`ctx.elicit()` and `ctx.session.*` calls outside resolvers keep their previous +behavior, including the pre-existing tools check on `create_message`. + ### `MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error Raising `MCPError` (or any subclass) inside an `@mcp.tool()` handler now diff --git a/docs_src/dependencies/tutorial004.py b/docs_src/dependencies/tutorial004.py new file mode 100644 index 0000000000..ff55e5ce15 --- /dev/null +++ b/docs_src/dependencies/tutorial004.py @@ -0,0 +1,26 @@ +from typing import Annotated + +from mcp_types import CreateMessageResult, SamplingMessage, TextContent + +from mcp.server import MCPServer +from mcp.server.mcpserver import Resolve, Sample + +mcp = MCPServer("Bookshop") + + +def suggest_title(genre: str) -> Sample: + prompt = f"Suggest one {genre} book title. Answer with the title only." + return Sample( + [SamplingMessage(role="user", content=TextContent(type="text", text=prompt))], + max_tokens=50, + ) + + +@mcp.tool() +async def recommend_book( + genre: str, + suggestion: Annotated[CreateMessageResult, Resolve(suggest_title)], +) -> str: + """Recommend a book in the given genre.""" + title = suggestion.content.text if suggestion.content.type == "text" else "the classics" + return f"Today's {genre} pick: {title}" diff --git a/docs_src/sampling_and_roots/__init__.py b/docs_src/sampling_and_roots/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs_src/sampling_and_roots/tutorial001.py b/docs_src/sampling_and_roots/tutorial001.py new file mode 100644 index 0000000000..c1e041c328 --- /dev/null +++ b/docs_src/sampling_and_roots/tutorial001.py @@ -0,0 +1,22 @@ +from typing import Annotated + +from mcp_types import CreateMessageResult, SamplingMessage, TextContent + +from mcp.server import MCPServer +from mcp.server.mcpserver import Resolve, Sample + +mcp = MCPServer("Bookshop") + + +def draft_blurb(title: str) -> Sample: + prompt = f"Write a one-sentence blurb for the book {title!r}." + return Sample( + [SamplingMessage(role="user", content=TextContent(type="text", text=prompt))], + max_tokens=60, + ) + + +@mcp.tool() +async def blurb(title: str, draft: Annotated[CreateMessageResult, Resolve(draft_blurb)]) -> str: + """Draft a blurb for a book.""" + return draft.content.text if draft.content.type == "text" else "No blurb." diff --git a/docs_src/sampling_and_roots/tutorial002.py b/docs_src/sampling_and_roots/tutorial002.py new file mode 100644 index 0000000000..44a1d10578 --- /dev/null +++ b/docs_src/sampling_and_roots/tutorial002.py @@ -0,0 +1,20 @@ +from typing import Annotated + +from mcp_types import ListRootsResult + +from mcp.server import MCPServer +from mcp.server.mcpserver import ListRoots, Resolve + +mcp = MCPServer("Bookshop") + + +def workspace_roots() -> ListRoots: + return ListRoots() + + +@mcp.tool() +async def catalog_folder(roots: Annotated[ListRootsResult, Resolve(workspace_roots)]) -> str: + """Pick the folder the catalog export should go to.""" + if not roots.roots: + return "No workspace folders shared." + return str(roots.roots[0].uri) diff --git a/mkdocs.yml b/mkdocs.yml index 5f19b89822..ae0c57f3ca 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -36,6 +36,7 @@ nav: - Lifespan: handlers/lifespan.md - Elicitation: handlers/elicitation.md - Multi-round-trip requests: handlers/multi-round-trip.md + - Sampling and roots: handlers/sampling-and-roots.md - Progress: handlers/progress.md - Logging: handlers/logging.md - Subscriptions: handlers/subscriptions.md diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index d581fe6a5e..fa78f15ea7 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -303,6 +303,9 @@ async def main(): sampling_callback: SamplingFnT | None = None """Callback for handling sampling requests.""" + sampling_capabilities: types.SamplingCapability | None = None + """Sampling sub-capabilities (e.g. tools) declared alongside `sampling_callback`; no effect without it.""" + list_roots_callback: ListRootsFnT | None = None """Callback for handling list roots requests.""" @@ -418,6 +421,7 @@ async def _build_session(self, exit_stack: AsyncExitStack) -> ClientSession: dispatcher=dispatcher, read_timeout_seconds=self.read_timeout_seconds, sampling_callback=self.sampling_callback, + sampling_capabilities=self.sampling_capabilities, list_roots_callback=self.list_roots_callback, logging_callback=self.logging_callback, message_handler=message_handler, diff --git a/src/mcp/server/mcpserver/__init__.py b/src/mcp/server/mcpserver/__init__.py index 0205df1920..56d1c23cba 100644 --- a/src/mcp/server/mcpserver/__init__.py +++ b/src/mcp/server/mcpserver/__init__.py @@ -19,7 +19,9 @@ DeclinedElicitation, Elicit, ElicitationResult, + ListRoots, Resolve, + Sample, ) from .resources import DEFAULT_RESOURCE_SECURITY, ResourceSecurity from .server import MCPServer, require_client_extension @@ -33,6 +35,8 @@ "Icon", "Resolve", "Elicit", + "Sample", + "ListRoots", "ElicitationResult", "AcceptedElicitation", "DeclinedElicitation", diff --git a/src/mcp/server/mcpserver/resolve.py b/src/mcp/server/mcpserver/resolve.py index d752afc10c..d4a744af37 100644 --- a/src/mcp/server/mcpserver/resolve.py +++ b/src/mcp/server/mcpserver/resolve.py @@ -3,17 +3,14 @@ A tool parameter annotated `Annotated[T, Resolve(fn)]` is filled by running the resolver `fn` before the tool body, instead of from the LLM-supplied arguments. Resolvers form a DAG: a resolver may declare its own `Resolve(...)` dependencies, -take tool arguments by name, and take the `Context`. A resolver may return -`Elicit[T]` to ask the client; the framework runs the elicitation and injects the -answer. - -The framework picks the elicitation transport from the negotiated protocol. At ->= 2026-07-28 it returns an `InputRequiredResult` carrying the batched questions -and resumes when the client retries with `input_responses`/`request_state` -(independent resolvers are asked in one round; a resolver depending on another's -answer is asked in a later round). At <= 2025-11-25 it issues a synchronous -`elicitation/create` request mid-call. Only *elicited* outcomes are carried in -`request_state` across rounds (so the user is asked each question once). Resolver +take tool arguments by name, and take the `Context`. A resolver may return a +request marker (`Elicit[T]` to ask the user, `Sample` to sample the client's +LLM, `ListRoots` to fetch its roots); the framework injects the response. + +The transport follows the negotiated protocol: >= 2026-07-28 batches the requests +into an `InputRequiredResult` and resumes when the client retries with +`input_responses`/`request_state`; <= 2025-11-25 sends each standalone server-to-client +request mid-call. Only *asked* outcomes ride `request_state`, so each question is asked once. Resolver bodies may re-run on every round; a recorded outcome is consulted only when the body asks its question again, so a resolver's own computation always wins over anything the client echoes back in `request_state`. @@ -24,6 +21,8 @@ - `Annotated[T, Resolve(fn)]` -> unwrapped `T`; decline/cancel aborts the call. - `Annotated[ElicitationResult[T], Resolve(fn)]` (or a specific member) -> the full outcome; the consumer branches on accept/decline/cancel. + +`Sample` and `ListRoots` have no decline arm; their consumers annotate the result type directly. """ from __future__ import annotations @@ -42,16 +41,30 @@ from mcp_types import ( MISSING_REQUIRED_CLIENT_CAPABILITY, ClientCapabilities, + CreateMessageRequest, + CreateMessageRequestParams, + CreateMessageResult, + CreateMessageResultWithTools, ElicitationCapability, ElicitRequest, ElicitRequestFormParams, ElicitResult, FormElicitationCapability, + IncludeContext, InputRequest, InputRequests, InputRequiredResult, InputResponses, + ListRootsRequest, + ListRootsResult, MissingRequiredClientCapabilityErrorData, + ModelPreferences, + RootsCapability, + SamplingCapability, + SamplingMessage, + SamplingToolsCapability, + Tool, + ToolChoice, ) from mcp_types.version import is_version_at_least from pydantic import BaseModel, ValidationError @@ -67,8 +80,10 @@ from mcp.server.mcpserver.context import Context from mcp.server.mcpserver.exceptions import InvalidSignature, ToolError from mcp.server.request_state import compact_json +from mcp.server.validation import validate_tool_use_result_messages, wants_sampling_tools from mcp.shared._callable_inspection import is_async_callable from mcp.shared.exceptions import MCPError +from mcp.shared.message import ServerMessageMetadata T = TypeVar("T", bound=BaseModel) @@ -103,6 +118,53 @@ def __init__(self, message: str, schema: type[T]) -> None: self.schema = schema +class Sample: + """A resolver's request to sample the client's LLM via `sampling/createMessage`. + + The framework injects a `CreateMessageResult` (`CreateMessageResultWithTools` when `tools` or + `tool_choice` are given, which also requires the client's `sampling.tools`); requires the + `sampling` capability. On >= 2026-07-28 the request must render identically across retry + rounds, and the sampled result rides `request_state` on every later round. `include_context` + other than "none" is deprecated in the draft spec. + """ + + def __init__( + self, + messages: list[SamplingMessage], + *, + max_tokens: int, + system_prompt: str | None = None, + include_context: IncludeContext | None = None, + temperature: float | None = None, + stop_sequences: list[str] | None = None, + metadata: dict[str, Any] | None = None, + model_preferences: ModelPreferences | None = None, + tools: list[Tool] | None = None, + tool_choice: ToolChoice | None = None, + ) -> None: + validate_tool_use_result_messages(messages) + self.params = CreateMessageRequestParams( + messages=messages, + max_tokens=max_tokens, + system_prompt=system_prompt, + include_context=include_context, + temperature=temperature, + stop_sequences=stop_sequences, + metadata=metadata, + model_preferences=model_preferences, + tools=tools, + tool_choice=tool_choice, + ) + + +class ListRoots: + """A resolver's request for the client's roots via `roots/list`; the framework injects the `ListRootsResult`.""" + + +_Marker = Elicit[Any] | Sample | ListRoots +"""The request markers a resolver may return.""" + + class _ParamPlan: """How to fill one resolver parameter, decided once at registration.""" @@ -221,19 +283,23 @@ def _contains_resolve(annotation: Any) -> bool: def _check_elicit_return(return_annotation: Any, name: str) -> None: - """Validate the `Elicit[...]` arms of a resolver's return annotation. + """Validate the request-marker arms of a resolver's return annotation. Raises: - InvalidSignature: If the annotation has more than one `Elicit[...]` arm; - a resolver asks one question - a second arm means it should be split. + InvalidSignature: If the annotation has more than one marker arm. """ - # A bare `Elicit[T]` is itself a candidate; a union contributes its members. candidates = get_args(return_annotation) if _is_union(return_annotation) else (return_annotation,) # Typing dedupes equal union members, so two arms here are genuinely distinct. - arms = [c for c in candidates if get_origin(c) is Elicit] + arms: list[Any] = [ + c + for c in candidates + # Origin guard for 3.10: `dict[str, Any]` passes `isinstance(c, type)` there and would crash `issubclass`. + if get_origin(c) is Elicit + or (get_origin(c) is None and isinstance(c, type) and issubclass(c, Elicit | Sample | ListRoots)) + ] if len(arms) > 1: raise InvalidSignature( - f"Resolver {name!r} return annotation has multiple Elicit arms; " + f"Resolver {name!r} return annotation has multiple Elicit/Sample/ListRoots arms; " "a resolver asks one question - split it into separate resolvers" ) @@ -360,9 +426,9 @@ class _Pending(Exception): class _Resolution: """Per-`tools/call` resolution state, shared across the DAG walk. - `input_required` selects the transport: at >= 2026-07-28 elicitations are + `input_required` selects the transport: at >= 2026-07-28 requests are batched into `pending` and surfaced as an `InputRequiredResult`; at older - revisions each `Elicit` is answered synchronously via `ctx.elicit`. + revisions each marker is answered synchronously over the back-channel. """ def __init__( @@ -384,10 +450,9 @@ def __init__( self.asked = decoded.asked # In-call dedup keyed by resolver identity (distinguishes two instances of # the same bound method); `persist` holds the wire-shaped record of each - # elicited outcome, keyed by its wire key - exactly what the next round's - # `request_state` carries. Entries are the client's own (validated) wire - # data, never re-derived from a model, so encode-restore is the identity. - # Pure resolvers are cheap to re-run each round and are not persisted. + # asked outcome, keyed by its wire key - exactly what the next round's `request_state` + # carries: the client's own validated content (elicitation) or the validated result's + # dump (sample/roots). Pure resolvers are cheap to re-run each round and are not persisted. self.cache: dict[Hashable, ElicitationResult[Any]] = {} self.persist: dict[str, _StateEntry] = {} self.pending: InputRequests = {} @@ -490,8 +555,8 @@ async def _resolve(fn: Callable[..., Any], res: _Resolution) -> ElicitationResul else: result = await anyio.to_thread.run_sync(lambda: fn(**kwargs)) - if _is_elicit(result): - outcome = await _elicit(result, wire_key, res) + if _is_marker(result): + outcome = await _fulfil(result, wire_key, res) else: # A resolver may return any type (not just `BaseModel`), so accept it as the # outcome without validating against the schema bound. Plain outcomes are not @@ -502,18 +567,29 @@ async def _resolve(fn: Callable[..., Any], res: _Resolution) -> ElicitationResul return outcome -async def _elicit(elicit: Elicit[Any], key: str, res: _Resolution) -> ElicitationResult[Any]: - """Turn a resolver's `Elicit` into an outcome via the negotiated transport.""" +async def _fulfil(marker: _Marker, key: str, res: _Resolution) -> ElicitationResult[Any]: + """Turn a resolver's request marker into an outcome via the negotiated transport.""" if not res.input_required: - return await res.context.elicit(elicit.message, elicit.schema) + # Gate wherever the request could actually be sent; otherwise the send path + # itself reports the failure. + if res.context.session.can_send_request: + _require_capability(res.context, marker, key) + if isinstance(marker, Elicit): + return await res.context.elicit(marker.message, marker.schema) + result = await res.context.session.send_request( + _render_request(marker), + _result_type(marker), + metadata=ServerMessageMetadata(related_request_id=res.context.request_id), + ) + return _accepted(result) - request = _elicit_request(elicit) + request = _render_request(marker) q = _request_digest(request) # A recorded outcome from a prior round is consulted only here, after the body # decided to ask, so a `request_state` entry can never stand in for a resolver's # own computation. A recorded outcome wins over a re-sent answer. - outcome = _restore_outcome(res, key, elicit.schema, q) + outcome = _restore_outcome(res, key, marker, q) if outcome is not None: return outcome @@ -524,16 +600,25 @@ async def _elicit(elicit: Elicit[Any], key: str, res: _Resolution) -> Elicitatio logger.info("Discarding the answer for resolver %r: the question changed since it was asked", key) answer = None if answer is None: - _require_form_elicitation(res.context, key) + _require_capability(res.context, marker, key) res.pending[key] = request raise _Pending + if not isinstance(marker, Elicit): + # A no-tool-use answer to a tools request parses as the plain result; validate against the marker's model. + wire = answer.model_dump(mode="json", by_alias=True, exclude_none=True) + try: + result = _result_type(marker).model_validate(wire) + except ValidationError as e: + raise ToolError(f"Resolver {key!r} received a response of the wrong kind") from e + res.persist[key] = _StateEntry(action="accept", data=wire, q=q) + return _accepted(result) if not isinstance(answer, ElicitResult): raise ToolError(f"Resolver {key!r} received a non-elicitation response") if answer.action == "accept": if answer.content is None: raise ToolError(f"Resolver {key!r} received an accepted elicitation with no content") try: - data = elicit.schema.model_validate(answer.content) + data = marker.schema.model_validate(answer.content) except ValidationError as e: raise ToolError( f"Resolver {key!r} received an accepted elicitation whose content does not match the requested schema" @@ -555,9 +640,8 @@ def _unwrap(outcome: ElicitationResult[Any], name: str) -> Any: raise ToolError(f"Resolver for parameter {name!r} could not resolve: elicitation was {outcome.action}") -def _is_elicit(value: Any) -> TypeGuard[Elicit[Any]]: - """Runtime narrow of a resolver's return value to a (parameter-erased) `Elicit`.""" - return isinstance(value, Elicit) +def _is_marker(value: Any) -> TypeGuard[_Marker]: + return isinstance(value, Elicit | Sample | ListRoots) def _accepted(data: Any) -> AcceptedElicitation[Any]: @@ -578,35 +662,65 @@ def _uses_input_required(protocol_version: str | None) -> bool: return protocol_version is not None and is_version_at_least(protocol_version, _INPUT_REQUIRED_VERSION) -def _require_form_elicitation(context: Context[Any, Any], key: str) -> None: - """Assert the client declared form elicitation before queueing a question for it. +def _require_capability(context: Context[Any, Any], marker: _Marker, key: str) -> None: + """Assert the client declared the capability `marker`'s request needs. - The spec forbids sending an `input_requests` entry the client has not declared a - capability for. A bare `elicitation: {}` declaration (the only shape before modes - existed) counts as form support; an explicit url-only declaration does not. + A bare `elicitation: {}` (the only shape before modes existed) counts as form support; url-only does not. Raises: MCPError: With code `MISSING_REQUIRED_CLIENT_CAPABILITY` and a - `requiredCapabilities` payload when form elicitation is not declared. + `requiredCapabilities` payload when the capability is not declared. """ capabilities = context.client_capabilities - elicitation = capabilities.elicitation if capabilities is not None else None - if elicitation is not None and (elicitation.form is not None or elicitation.url is None): - return - data = MissingRequiredClientCapabilityErrorData( - required_capabilities=ClientCapabilities(elicitation=ElicitationCapability(form=FormElicitationCapability())) - ) + if isinstance(marker, Elicit): + elicitation = capabilities.elicitation if capabilities is not None else None + if elicitation is not None and (elicitation.form is not None or elicitation.url is None): + return + required = ClientCapabilities(elicitation=ElicitationCapability(form=FormElicitationCapability())) + name = "form elicitation" + elif isinstance(marker, Sample): + sampling = capabilities.sampling if capabilities is not None else None + wants_tools = wants_sampling_tools(marker.params.tools, marker.params.tool_choice) + if sampling is not None and (not wants_tools or sampling.tools is not None): + return + required = ClientCapabilities( + sampling=SamplingCapability(tools=SamplingToolsCapability() if wants_tools else None) + ) + name = "sampling.tools" if wants_tools else "sampling" + else: + if capabilities is not None and capabilities.roots is not None: + return + required = ClientCapabilities(roots=RootsCapability()) + name = "roots" + data = MissingRequiredClientCapabilityErrorData(required_capabilities=required) raise MCPError( code=MISSING_REQUIRED_CLIENT_CAPABILITY, - message=f"Client did not declare the form elicitation capability required by resolver {key!r}", + message=f"Client did not declare the {name} capability required by resolver {key!r}", data=data.model_dump(by_alias=True, mode="json", exclude_none=True), ) -def _elicit_request(elicit: Elicit[Any]) -> ElicitRequest: - """Render an `Elicit[T]` as the embedded `elicitation/create` request for `input_requests`.""" - json_schema = render_elicitation_schema(elicit.schema) - return ElicitRequest(params=ElicitRequestFormParams(message=elicit.message, requested_schema=json_schema)) +def _render_request(marker: _Marker) -> InputRequest: + """Render a marker as its wire request - the same shape on both transports.""" + if isinstance(marker, Elicit): + json_schema = render_elicitation_schema(marker.schema) + return ElicitRequest(params=ElicitRequestFormParams(message=marker.message, requested_schema=json_schema)) + if isinstance(marker, Sample): + return CreateMessageRequest(params=marker.params) + return ListRootsRequest() + + +def _result_type( + marker: Sample | ListRoots, +) -> type[CreateMessageResult] | type[CreateMessageResultWithTools] | type[ListRootsResult]: + """The result model a `Sample`/`ListRoots` response must validate against.""" + if isinstance(marker, ListRoots): + return ListRootsResult + return ( + CreateMessageResultWithTools + if wants_sampling_tools(marker.params.tools, marker.params.tool_choice) + else CreateMessageResult + ) class _StateEntry(BaseModel): @@ -660,34 +774,33 @@ def _decode_state(request_state: str | None) -> _State: def _encode_state(outcomes: Mapping[str, _StateEntry], asked: Mapping[str, str]) -> str: """Encode recorded outcomes and asked-question digests for the next round. - Outcome entries already hold the client's wire-shaped data exactly as it was - sent (and validated), so encoding is pure wrapping: encode-restore is the - identity. + Outcome entries are already wire-shaped, so encoding is pure wrapping. """ state = _State(v=_STATE_VERSION, outcomes=dict(outcomes), asked=dict(asked)) return compact_json(state.model_dump(mode="json")) -def _outcome_from_state(entry: _StateEntry, schema: type[BaseModel]) -> ElicitationResult[Any]: - """Rebuild an `ElicitationResult` from a decoded `request_state` entry. +def _outcome_from_state(entry: _StateEntry, marker: _Marker) -> ElicitationResult[Any]: + """Rebuild an outcome from a decoded `request_state` entry. Raises: - ValidationError: If an accepted entry's data does not validate against - `schema` (the live `Elicit.schema` of the question being asked). + ValidationError: If the entry does not fit the live marker. """ - if entry.action == "decline": - return DeclinedElicitation() - if entry.action == "cancel": - return CancelledElicitation() - return _accepted(schema.model_validate(entry.data)) + if isinstance(marker, Elicit): + if entry.action == "decline": + return DeclinedElicitation() + if entry.action == "cancel": + return CancelledElicitation() + return _accepted(marker.schema.model_validate(entry.data)) + return _accepted(_result_type(marker).model_validate(entry.data)) -def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel], q: str) -> ElicitationResult[Any] | None: +def _restore_outcome(res: _Resolution, key: str, marker: _Marker, q: str) -> ElicitationResult[Any] | None: """Restore `key`'s recorded outcome from a prior round, or `None` when absent. - An entry pinned to a question digest other than `q`, or whose accepted - data fails validation against the live `schema`, is dropped as if no - progress was recorded, so the question is asked again. + An entry pinned to a question digest other than `q`, or that fails + validation against the live marker, is dropped as if no progress was + recorded, so the question is asked again. Carries the original decoded entry forward unchanged in `res.persist`: if a later resolver is still pending, the next round's `request_state` is built from @@ -701,7 +814,7 @@ def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel], q: str del res.state[key] return None try: - outcome = _outcome_from_state(entry, schema) + outcome = _outcome_from_state(entry, marker) except ValidationError: del res.state[key] return None @@ -712,6 +825,8 @@ def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel], q: str __all__ = [ "Resolve", "Elicit", + "Sample", + "ListRoots", "ElicitationResult", "AcceptedElicitation", "DeclinedElicitation", diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index ca62fb9c8e..0a61689eb5 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -14,7 +14,7 @@ from typing_extensions import deprecated from mcp.server.connection import Connection -from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages +from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages, wants_sampling_tools from mcp.shared.dispatcher import CallOptions, DispatchContext, ProgressFnT from mcp.shared.exceptions import MCPDeprecationWarning from mcp.shared.message import ServerMessageMetadata @@ -45,6 +45,11 @@ def client_params(self) -> types.InitializeRequestParams | None: """The client's `initialize` request params; `None` when no client info was supplied.""" return self._connection.client_params + @property + def can_send_request(self) -> bool: + """Whether this request's channel can currently deliver a server-initiated request.""" + return self._request_outbound.can_send_request + @property def protocol_version(self) -> str: """The protocol version this connection speaks. @@ -141,10 +146,10 @@ async def create_message( metadata: dict[str, Any] | None = None, model_preferences: types.ModelPreferences | None = None, tools: None = None, - tool_choice: types.ToolChoice | None = None, + tool_choice: None = None, related_request_id: types.RequestId | None = None, ) -> types.CreateMessageResult: - """Overload: Without tools, returns single content.""" + """Overload: Without tools or tool_choice, returns single content.""" ... @overload @@ -167,6 +172,26 @@ async def create_message( """Overload: With tools, returns array-capable content.""" ... + @overload + @deprecated("The sampling capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) + async def create_message( + self, + messages: list[types.SamplingMessage], + *, + max_tokens: int, + system_prompt: str | None = None, + include_context: types.IncludeContext | None = None, + temperature: float | None = None, + stop_sequences: list[str] | None = None, + metadata: dict[str, Any] | None = None, + model_preferences: types.ModelPreferences | None = None, + tools: list[types.Tool] | None = None, + tool_choice: types.ToolChoice, + related_request_id: types.RequestId | None = None, + ) -> types.CreateMessageResultWithTools: + """Overload: With tool_choice, returns array-capable content.""" + ... + @deprecated("The sampling capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) async def create_message( self, @@ -231,7 +256,7 @@ async def create_message( ) metadata_obj = ServerMessageMetadata(related_request_id=related_request_id) - if tools is not None: + if wants_sampling_tools(tools, tool_choice): return await self.send_request( request=request, result_type=types.CreateMessageResultWithTools, diff --git a/src/mcp/server/validation.py b/src/mcp/server/validation.py index fd16beb957..a281f4d08f 100644 --- a/src/mcp/server/validation.py +++ b/src/mcp/server/validation.py @@ -26,6 +26,11 @@ def check_sampling_tools_capability(client_caps: ClientCapabilities | None) -> b return True +def wants_sampling_tools(tools: list[Tool] | None, tool_choice: ToolChoice | None) -> bool: + """Whether a sampling request is tools-mode: `sampling.tools` gated, array-capable answer.""" + return tools is not None or tool_choice is not None + + def validate_sampling_tools( client_caps: ClientCapabilities | None, tools: list[Tool] | None, @@ -41,7 +46,7 @@ def validate_sampling_tools( Raises: MCPError: If tools/tool_choice are provided but client doesn't support them """ - if tools is not None or tool_choice is not None: + if wants_sampling_tools(tools, tool_choice): if not check_sampling_tools_capability(client_caps): raise MCPError(code=INVALID_PARAMS, message="Client does not support sampling tools capability") diff --git a/src/mcp/shared/peer.py b/src/mcp/shared/peer.py index ca59b56af6..14e8fe1c29 100644 --- a/src/mcp/shared/peer.py +++ b/src/mcp/shared/peer.py @@ -98,7 +98,7 @@ async def sample( metadata: dict[str, Any] | None = None, model_preferences: ModelPreferences | None = None, tools: None = None, - tool_choice: ToolChoice | None = None, + tool_choice: None = None, meta: Meta | None = None, opts: CallOptions | None = None, ) -> CreateMessageResult: ... @@ -120,6 +120,24 @@ async def sample( meta: Meta | None = None, opts: CallOptions | None = None, ) -> CreateMessageResultWithTools: ... + @overload + @deprecated("The sampling capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) + async def sample( + self, + messages: list[SamplingMessage], + *, + max_tokens: int, + system_prompt: str | None = None, + include_context: IncludeContext | None = None, + temperature: float | None = None, + stop_sequences: list[str] | None = None, + metadata: dict[str, Any] | None = None, + model_preferences: ModelPreferences | None = None, + tools: list[Tool] | None = None, + tool_choice: ToolChoice, + meta: Meta | None = None, + opts: CallOptions | None = None, + ) -> CreateMessageResultWithTools: ... @deprecated("The sampling capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning) async def sample( self, @@ -157,7 +175,7 @@ async def sample( tool_choice=tool_choice, ) result = await self.send_raw_request("sampling/createMessage", dump_params(params, meta), opts) - if tools is not None: + if tools is not None or tool_choice is not None: return CreateMessageResultWithTools.model_validate(result, by_name=False) return CreateMessageResult.model_validate(result, by_name=False) diff --git a/tests/docs_src/test_dependencies.py b/tests/docs_src/test_dependencies.py index 6dba9277e4..8474d55e4f 100644 --- a/tests/docs_src/test_dependencies.py +++ b/tests/docs_src/test_dependencies.py @@ -4,9 +4,9 @@ import pytest from inline_snapshot import snapshot -from mcp_types import ElicitRequestParams, ElicitResult, TextContent +from mcp_types import CreateMessageRequestParams, CreateMessageResult, ElicitRequestParams, ElicitResult, TextContent -from docs_src.dependencies import tutorial001, tutorial002, tutorial003 +from docs_src.dependencies import tutorial001, tutorial002, tutorial003, tutorial004 from mcp import Client from mcp.client import ClientRequestContext @@ -138,3 +138,21 @@ async def decline(context: ClientRequestContext, params: ElicitRequestParams) -> assert result.content[0].text == ( "Error executing tool order_book: Resolver for parameter 'backorder' could not resolve: elicitation was decline" ) + + +@pytest.mark.parametrize("mode", ["legacy", "auto"]) +async def test_a_resolver_can_sample_the_clients_llm(mode: Literal["legacy", "auto"]) -> None: + """tutorial004: `suggest_title` runs through the client's sampling callback on both eras.""" + prompts: list[str] = [] + + async def sampler(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult: + content = params.messages[0].content + assert isinstance(content, TextContent) + prompts.append(content.text) + return CreateMessageResult(role="assistant", content=TextContent(type="text", text="Dune"), model="m") + + async with Client(tutorial004.mcp, mode=mode, sampling_callback=sampler) as client: + result = await client.call_tool("recommend_book", {"genre": "sci-fi"}) + + assert result.content == [TextContent(type="text", text="Today's sci-fi pick: Dune")] + assert prompts == ["Suggest one sci-fi book title. Answer with the title only."] diff --git a/tests/docs_src/test_sampling_and_roots.py b/tests/docs_src/test_sampling_and_roots.py new file mode 100644 index 0000000000..7e4b9aea39 --- /dev/null +++ b/tests/docs_src/test_sampling_and_roots.py @@ -0,0 +1,62 @@ +"""`docs/handlers/sampling-and-roots.md`: every claim the page makes, proved against the real SDK.""" + +from typing import Literal + +import pytest +from mcp_types import ( + MISSING_REQUIRED_CLIENT_CAPABILITY, + CreateMessageRequestParams, + CreateMessageResult, + ListRootsResult, + Root, + TextContent, +) +from pydantic import FileUrl + +from docs_src.sampling_and_roots import tutorial001, tutorial002 +from mcp import Client +from mcp.client import ClientRequestContext +from mcp.shared.exceptions import MCPError + +pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] + + +@pytest.mark.parametrize("mode", ["legacy", "auto"]) +async def test_a_sampling_dependency_receives_the_clients_completion(mode: Literal["legacy", "auto"]) -> None: + """tutorial001: `draft_blurb` runs through the client's model on both protocol versions.""" + prompts: list[str] = [] + + async def sampler(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult: + content = params.messages[0].content + assert isinstance(content, TextContent) + prompts.append(content.text) + return CreateMessageResult( + role="assistant", content=TextContent(type="text", text="A desert planet holds the key."), model="m" + ) + + async with Client(tutorial001.mcp, mode=mode, sampling_callback=sampler) as client: + result = await client.call_tool("blurb", {"title": "Dune"}) + + assert result.content == [TextContent(type="text", text="A desert planet holds the key.")] + assert prompts == ["Write a one-sentence blurb for the book 'Dune'."] + + +@pytest.mark.parametrize("mode", ["legacy", "auto"]) +async def test_a_roots_dependency_receives_the_clients_folders(mode: Literal["legacy", "auto"]) -> None: + """tutorial002: `workspace_roots` fetches the client's roots list.""" + + async def client_roots(context: ClientRequestContext) -> ListRootsResult: + return ListRootsResult(roots=[Root(uri=FileUrl("file:///workspace/catalog"), name="catalog")]) + + async with Client(tutorial002.mcp, mode=mode, list_roots_callback=client_roots) as client: + result = await client.call_tool("catalog_folder", {}) + + assert result.content == [TextContent(type="text", text="file:///workspace/catalog")] + + +async def test_an_undeclared_capability_fails_before_a_request_is_sent() -> None: + """The page's gate claim: no `sampling` capability means a -32021 protocol error.""" + async with Client(tutorial001.mcp) as client: + with pytest.raises(MCPError) as exc_info: + await client.call_tool("blurb", {"title": "Dune"}) + assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY diff --git a/tests/server/mcpserver/test_resolve.py b/tests/server/mcpserver/test_resolve.py index c28f12481c..aa5ced266a 100644 --- a/tests/server/mcpserver/test_resolve.py +++ b/tests/server/mcpserver/test_resolve.py @@ -7,22 +7,40 @@ import anyio import pytest +from inline_snapshot import snapshot from mcp_types import ( MISSING_REQUIRED_CLIENT_CAPABILITY, CallToolResult, + CreateMessageRequest, + CreateMessageRequestParams, CreateMessageResult, + CreateMessageResultWithTools, + ElicitRequest, ElicitRequestFormParams, ElicitRequestParams, ElicitResult, InputRequiredResult, InputResponses, + JSONRPCError, + JSONRPCNotification, + JSONRPCRequest, + ListRootsResult, + Root, + SamplingCapability, + SamplingMessage, + SamplingToolsCapability, TextContent, + ToolChoice, ) -from pydantic import BaseModel, Field, ValidationError, create_model +from mcp_types import ( + Tool as SamplingTool, +) +from pydantic import BaseModel, Field, FileUrl, ValidationError, create_model from typing_extensions import TypeAliasType from mcp import Client, InputRequiredRoundsExceededError from mcp.client import ClientRequestContext +from mcp.client._memory import InMemoryTransport from mcp.server.context import ServerRequestContext from mcp.server.mcpserver import ( AcceptedElicitation, @@ -32,18 +50,20 @@ DeclinedElicitation, Elicit, ElicitationResult, + ListRoots, MCPServer, RequestStateBoundary, RequestStateSecurity, Resolve, + Sample, ) from mcp.server.mcpserver.exceptions import InvalidSignature from mcp.server.mcpserver.resolve import ( _check_elicit_return, _decode_state, - _elicit_request, _encode_state, _outcome_from_state, + _render_request, _request_digest, _resolver_key, _state_key, @@ -54,11 +74,12 @@ ) from mcp.server.mcpserver.tools.base import Tool from mcp.shared.exceptions import MCPError +from mcp.shared.message import SessionMessage def _question_digest(elicit: Elicit[Any]) -> str: - """The digest `_elicit` pins: the rendered request the client would be shown.""" - return _request_digest(_elicit_request(elicit)) + """The digest `_fulfil` pins: the rendered request the client would be shown.""" + return _request_digest(_render_request(elicit)) class Login(BaseModel): @@ -177,42 +198,6 @@ def _wire_key(fn: Callable[..., Any]) -> str: return f"{fn.__module__}:{fn.__qualname__}" -@pytest.mark.anyio -async def test_resolver_returns_value_directly_without_eliciting(): - mcp = MCPServer(name="Direct", request_state_security=RequestStateSecurity.ephemeral()) - - async def login(ctx: Context) -> Login | Elicit[Login]: - username = (ctx.headers or {}).get("x-github-user") - if username: # pragma: no cover - no headers on in-memory transport - return Login(username=username) - return Login(username="from-resolver") - - @mcp.tool() - async def whoami(login: Annotated[Login, Resolve(login)]) -> str: - return login.username - - async def never(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: # pragma: no cover - raise AssertionError("should not elicit") - - async with Client(mcp, mode="legacy", elicitation_callback=never) as client: - assert await _text(client, "whoami", {}) == "from-resolver" - - -@pytest.mark.anyio -async def test_resolver_elicits_and_injects_unwrapped_model_on_accept(): - mcp = MCPServer(name="Accept", request_state_security=RequestStateSecurity.ephemeral()) - - async def login(ctx: Context) -> Login | Elicit[Login]: - return Elicit("GitHub username?", Login) - - @mcp.tool() - async def whoami(login: Annotated[Login, Resolve(login)]) -> str: - return login.username - - async with Client(mcp, mode="legacy", elicitation_callback=_accept({"username": "octocat"})) as client: - assert await _text(client, "whoami", {}) == "octocat" - - @pytest.mark.anyio async def test_consumer_receives_result_union_and_branches(): mcp = MCPServer(name="Union", request_state_security=RequestStateSecurity.ephemeral()) @@ -232,43 +217,6 @@ async def whoami(login: Annotated[ElicitationResult[Login], Resolve(login)]) -> assert await _text(client, "whoami", {}) == "hi octocat" -@pytest.mark.anyio -async def test_decline_reaches_union_consumer_without_aborting(): - mcp = MCPServer(name="UnionDecline", request_state_security=RequestStateSecurity.ephemeral()) - - async def login(ctx: Context) -> Login | Elicit[Login]: - return Elicit("GitHub username?", Login) - - @mcp.tool() - async def whoami( - login: Annotated[AcceptedElicitation[Login] | DeclinedElicitation | CancelledElicitation, Resolve(login)], - ) -> str: - if isinstance(login, DeclinedElicitation): - return "declined gracefully" - raise NotImplementedError - - async with Client(mcp, mode="legacy", elicitation_callback=_decline) as client: - assert await _text(client, "whoami", {}) == "declined gracefully" - - -@pytest.mark.anyio -async def test_decline_aborts_when_consumer_wants_unwrapped(): - mcp = MCPServer(name="UnwrappedDecline", request_state_security=RequestStateSecurity.ephemeral()) - - async def login(ctx: Context) -> Login | Elicit[Login]: - return Elicit("GitHub username?", Login) - - @mcp.tool() - async def whoami(login: Annotated[Login, Resolve(login)]) -> str: - raise NotImplementedError # pragma: no cover - never reached - - async with Client(mcp, mode="legacy", elicitation_callback=_decline) as client: - result = await client.call_tool("whoami", {}) - assert result.is_error - assert isinstance(result.content[0], TextContent) - assert "decline" in result.content[0].text - - @pytest.mark.anyio async def test_nested_resolver_sees_dependency_and_tool_args(): mcp = MCPServer(name="Nested", request_state_security=RequestStateSecurity.ephemeral()) @@ -348,22 +296,6 @@ async def never(context: ClientRequestContext, params: ElicitRequestParams) -> E assert await _text(client, "whoami", {}) == "sync-user" -def test_resolved_params_absent_from_input_schema(): - async def login(ctx: Context) -> Login: - return Login(username="x") # pragma: no cover - only the schema is inspected - - async def tool( - repo: Annotated[str, Field(description="repo name")], - login: Annotated[Login, Resolve(login)], - ) -> str: - return repo # pragma: no cover - only the schema is inspected - - built = Tool.from_function(tool) - properties = built.parameters["properties"] - assert "repo" in properties - assert "login" not in properties - - def test_cycle_detection_raises_at_registration(): async def a(dep: Login) -> Login: return dep # pragma: no cover @@ -425,7 +357,7 @@ async def ambiguous(ctx: Context) -> Elicit[Login] | Elicit[Confirm]: async def tool(login: Annotated[Login, Resolve(ambiguous)]) -> str: return login.username # pragma: no cover - with pytest.raises(InvalidSignature, match="multiple Elicit arms"): + with pytest.raises(InvalidSignature, match="multiple Elicit/Sample/ListRoots arms"): Tool.from_function(tool) @@ -938,15 +870,16 @@ def test_state_round_trips_accept_decline_cancel(): assert decoded == entries # encode-restore is the identity on the stored entries assert state.asked == {"e": "asked-digest"} - accepted = _outcome_from_state(decoded["a"], Login) + ask = Elicit("q", Login) + accepted = _outcome_from_state(decoded["a"], ask) assert isinstance(accepted, AcceptedElicitation) and accepted.data == Login(username="octocat") # Decline/cancel entries carry no data; the schema is not consulted for them. - assert isinstance(_outcome_from_state(decoded["b"], Login), DeclinedElicitation) - assert isinstance(_outcome_from_state(decoded["c"], Login), CancelledElicitation) + assert isinstance(_outcome_from_state(decoded["b"], ask), DeclinedElicitation) + assert isinstance(_outcome_from_state(decoded["c"], ask), CancelledElicitation) # An accepted restore always validates against the question's live schema - # data that doesn't fit is rejected, never passed through raw. with pytest.raises(ValidationError): - _outcome_from_state(decoded["d"], Login) + _outcome_from_state(decoded["d"], ask) def test_check_elicit_return_allows_one_arm_and_rejects_two(): @@ -955,7 +888,7 @@ def test_check_elicit_return_allows_one_arm_and_rejects_two(): _check_elicit_return(Login, "r") # no Elicit arm _check_elicit_return(None, "r") # unannotated # A resolver asks one question: two distinct Elicit arms mean it should be split. - with pytest.raises(InvalidSignature, match="'r' return annotation has multiple Elicit arms"): + with pytest.raises(InvalidSignature, match="'r' return annotation has multiple Elicit/Sample/ListRoots arms"): _check_elicit_return(Elicit[Login] | Elicit[Confirm], "r") @@ -1118,28 +1051,6 @@ async def empty_accept(context: ClientRequestContext, params: ElicitRequestParam assert "no content" in result.content[0].text -@pytest.mark.anyio -async def test_eliciting_tool_without_client_capability_is_a_protocol_error(): - # The server must not send an `input_requests` entry the client has not declared - # capability for: with no `elicitation` declared (no callback), the call fails as - # a -32021 protocol error, not a CallToolResult execution failure. - mcp = MCPServer(name="NoElicitationCapability", request_state_security=RequestStateSecurity.ephemeral()) - - async def ask(ctx: Context) -> Elicit[Login]: - return Elicit("user?", Login) - - @mcp.tool() - async def tool(login: Annotated[Login, Resolve(ask)]) -> str: - return login.username # pragma: no cover - - async with Client(mcp) as client: - with pytest.raises(MCPError) as exc_info: - await client.session.call_tool("tool", {}, allow_input_required=True) - assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY - assert exc_info.value.error.data is not None - assert "elicitation" in exc_info.value.error.data["requiredCapabilities"] - - @pytest.mark.anyio async def test_independent_nested_deps_batch_into_one_round(): mcp = MCPServer(name="NestedBatch", request_state_security=RequestStateSecurity.ephemeral()) @@ -2365,3 +2276,593 @@ async def act(go: Annotated[Confirm, Resolve(ask)]) -> str: assert isinstance(final, CallToolResult) assert isinstance(final.content[0], TextContent) assert final.content[0].text == "went:True" + + +# --- Sample / ListRoots markers --- + + +async def _sample_never( # pragma: no cover - declares the capability; never invoked + context: ClientRequestContext, params: CreateMessageRequestParams +) -> CreateMessageResult: + raise AssertionError("should not be called") + + +async def _roots_never(context: ClientRequestContext) -> ListRootsResult: # pragma: no cover - see _sample_never + raise AssertionError("should not be called") + + +def _sample_capital(ctx: Context) -> Sample: + return Sample( + [SamplingMessage(role="user", content=TextContent(type="text", text="Capital of France?"))], + max_tokens=16, + ) + + +@pytest.mark.anyio +@pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning") +@pytest.mark.parametrize("mode", ["legacy", "auto"]) +async def test_sample_resolver_injects_result(mode: Literal["legacy", "auto"]): + # The marker form is the 2026-blessed carrier: no SEP-2577 deprecation warning on either mode. + mcp = MCPServer(name="Sampler", request_state_security=RequestStateSecurity.ephemeral()) + prompts: list[str] = [] + + async def sampler(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult: + content = params.messages[0].content + assert isinstance(content, TextContent) + prompts.append(content.text) + return CreateMessageResult(role="assistant", content=TextContent(type="text", text="Paris"), model="m") + + @mcp.tool() + async def capital(answer: Annotated[CreateMessageResult, Resolve(_sample_capital)]) -> str: + assert isinstance(answer.content, TextContent) + return answer.content.text + + async with Client(mcp, mode=mode, sampling_callback=sampler) as client: + assert await _text(client, "capital", {}) == "Paris" + assert prompts == ["Capital of France?"] + + +@pytest.mark.anyio +@pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning") +@pytest.mark.parametrize("mode", ["legacy", "auto"]) +async def test_list_roots_resolver_injects_result(mode: Literal["legacy", "auto"]): + mcp = MCPServer(name="Rooted", request_state_security=RequestStateSecurity.ephemeral()) + + async def client_roots(context: ClientRequestContext) -> ListRootsResult: + return ListRootsResult(roots=[Root(uri=FileUrl("file:///workspace"))]) + + def fetch_roots(ctx: Context) -> ListRoots: + return ListRoots() + + @mcp.tool() + async def workspace(roots: Annotated[ListRootsResult, Resolve(fetch_roots)]) -> str: + return str(len(roots.roots)) + + async with Client(mcp, mode=mode, list_roots_callback=client_roots) as client: + assert await _text(client, "workspace", {}) == "1" + + +@pytest.mark.anyio +async def test_mixed_kinds_batch_into_one_round(): + mcp = MCPServer(name="Mixed", request_state_security=RequestStateSecurity.ephemeral()) + + async def ask_name(ctx: Context) -> Elicit[Login]: + return Elicit("user?", Login) + + async def fetch_roots(ctx: Context) -> ListRoots: + return ListRoots() + + @mcp.tool() + async def combo( + login: Annotated[Login, Resolve(ask_name)], + answer: Annotated[CreateMessageResult, Resolve(_sample_capital)], + roots: Annotated[ListRootsResult, Resolve(fetch_roots)], + ) -> str: + assert isinstance(answer.content, TextContent) + return f"{login.username}/{answer.content.text}/{len(roots.roots)}" + + async with Client( + mcp, elicitation_callback=_never, sampling_callback=_sample_never, list_roots_callback=_roots_never + ) as client: + first = await client.session.call_tool("combo", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.input_requests is not None + kinds = sorted(type(req).__name__ for req in first.input_requests.values()) + assert kinds == ["CreateMessageRequest", "ElicitRequest", "ListRootsRequest"] + responses: InputResponses = {} + for key, req in first.input_requests.items(): + if isinstance(req, ElicitRequest): + responses[key] = ElicitResult(action="accept", content={"username": "octocat"}) + elif isinstance(req, CreateMessageRequest): + responses[key] = CreateMessageResult( + role="assistant", content=TextContent(type="text", text="hey"), model="m" + ) + else: + responses[key] = ListRootsResult(roots=[]) + final = await client.session.call_tool( + "combo", {}, input_responses=responses, request_state=first.request_state, allow_input_required=True + ) + assert isinstance(final, CallToolResult) + assert isinstance(final.content[0], TextContent) + assert final.content[0].text == "octocat/hey/0" + + +@pytest.mark.anyio +async def test_sampling_tool_without_client_capability_is_a_protocol_error(): + mcp = MCPServer(name="NoSamplingCapability", request_state_security=RequestStateSecurity.ephemeral()) + + @mcp.tool() + async def capital(answer: Annotated[CreateMessageResult, Resolve(_sample_capital)]) -> str: + return "unreachable" # pragma: no cover + + async with Client(mcp) as client: + with pytest.raises(MCPError) as exc_info: + await client.session.call_tool("capital", {}, allow_input_required=True) + assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY + assert exc_info.value.error.data is not None + assert "sampling" in exc_info.value.error.data["requiredCapabilities"] + + +@pytest.mark.anyio +async def test_roots_tool_without_client_capability_is_a_protocol_error(): + mcp = MCPServer(name="NoRootsCapability", request_state_security=RequestStateSecurity.ephemeral()) + + def fetch_roots(ctx: Context) -> ListRoots: + return ListRoots() + + @mcp.tool() + async def workspace(roots: Annotated[ListRootsResult, Resolve(fetch_roots)]) -> str: + return "unreachable" # pragma: no cover + + async with Client(mcp) as client: + with pytest.raises(MCPError) as exc_info: + await client.session.call_tool("workspace", {}, allow_input_required=True) + assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY + assert exc_info.value.error.data is not None + assert "roots" in exc_info.value.error.data["requiredCapabilities"] + + +@pytest.mark.anyio +async def test_legacy_eliciting_tool_without_capability_is_a_protocol_error(): + # Same egress gate as the input_requests leg; the session stays usable after the refusal. + mcp = MCPServer(name="LegacyGate", request_state_security=RequestStateSecurity.ephemeral()) + + async def ask(ctx: Context) -> Elicit[Login]: + return Elicit("user?", Login) + + @mcp.tool() + async def tool(login: Annotated[Login, Resolve(ask)]) -> str: + return login.username # pragma: no cover + + @mcp.tool() + def plain() -> str: + return "ok" + + async with Client(mcp, mode="legacy") as client: + with pytest.raises(MCPError) as exc_info: + await client.call_tool("tool", {}) + assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY + assert await _text(client, "plain", {}) == "ok" + + +def _ask_with_tools(ctx: Context) -> Sample: + return Sample( + [SamplingMessage(role="user", content=TextContent(type="text", text="2+2?"))], + max_tokens=16, + tools=[SamplingTool(name="calc", input_schema={"type": "object"})], + ) + + +def _ask_with_tool_choice(ctx: Context) -> Sample: + return Sample( + [SamplingMessage(role="user", content=TextContent(type="text", text="2+2?"))], + max_tokens=16, + tool_choice=ToolChoice(mode="none"), + ) + + +@pytest.mark.anyio +@pytest.mark.parametrize("ask", [_ask_with_tools, _ask_with_tool_choice]) +async def test_sample_tools_require_the_tools_subcapability(ask: Callable[[Context], Sample]): + mcp = MCPServer(name="NoToolsSubcapability", request_state_security=RequestStateSecurity.ephemeral()) + + @mcp.tool() + async def calc(answer: Annotated[CreateMessageResultWithTools, Resolve(ask)]) -> str: + return "unreachable" # pragma: no cover + + # The callback declares base `sampling` but not `sampling.tools`. + async with Client(mcp, sampling_callback=_sample_never) as client: + with pytest.raises(MCPError) as exc_info: + await client.session.call_tool("calc", {}, allow_input_required=True) + assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY + assert exc_info.value.error.data is not None + assert exc_info.value.error.data["requiredCapabilities"] == {"sampling": {"tools": {}}} + + +@pytest.mark.anyio +async def test_sample_with_tools_round_trips_with_declared_subcapability(): + mcp = MCPServer(name="ToolsSampling", request_state_security=RequestStateSecurity.ephemeral()) + + async def sampler( + context: ClientRequestContext, params: CreateMessageRequestParams + ) -> CreateMessageResultWithTools: + assert params.tools is not None and params.tools[0].name == "calc" + return CreateMessageResultWithTools(role="assistant", content=[TextContent(type="text", text="4")], model="m") + + @mcp.tool() + async def calc(answer: Annotated[CreateMessageResultWithTools, Resolve(_ask_with_tools)]) -> str: + assert isinstance(answer.content, list) and isinstance(answer.content[0], TextContent) + return answer.content[0].text + + async with Client( + mcp, + sampling_callback=sampler, + sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability()), + ) as client: + assert await _text(client, "calc", {}) == "4" + + +@pytest.mark.anyio +async def test_no_tool_use_answer_to_a_tools_request_is_accepted(): + # The answer parses off the wire as plain CreateMessageResult but must inject as CreateMessageResultWithTools. + mcp = MCPServer(name="NoToolUse", request_state_security=RequestStateSecurity.ephemeral()) + + @mcp.tool() + async def calc(answer: Annotated[CreateMessageResultWithTools, Resolve(_ask_with_tools)]) -> str: + assert isinstance(answer, CreateMessageResultWithTools) + assert isinstance(answer.content, TextContent) + return answer.content.text + + async with Client( + mcp, + sampling_callback=_sample_never, + sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability()), + ) as client: + first = await client.session.call_tool("calc", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.input_requests is not None + (key,) = first.input_requests + final = await client.session.call_tool( + "calc", + {}, + input_responses={ + key: CreateMessageResult(role="assistant", content=TextContent(type="text", text="4"), model="m") + }, + request_state=first.request_state, + allow_input_required=True, + ) + assert isinstance(final, CallToolResult) + assert not final.is_error + assert isinstance(final.content[0], TextContent) + assert final.content[0].text == "4" + + +@pytest.mark.anyio +async def test_sample_outcome_persists_across_rounds(): + # The confirm arm depends on the sample, forcing extra rounds that restore the result instead of re-sampling. + mcp = MCPServer(name="Chain", request_state_security=RequestStateSecurity.ephemeral()) + samples = 0 + + async def sampler(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult: + nonlocal samples + samples += 1 + return CreateMessageResult(role="assistant", content=TextContent(type="text", text="Paris"), model="m") + + async def confirm( + answer: Annotated[CreateMessageResult, Resolve(_sample_capital)], ctx: Context + ) -> Elicit[Confirm]: + return Elicit("Accept the model's answer?", Confirm) + + @mcp.tool() + async def tool( + ok: Annotated[Confirm, Resolve(confirm)], + answer: Annotated[CreateMessageResult, Resolve(_sample_capital)], + ) -> str: + assert isinstance(answer.content, TextContent) + return f"{answer.content.text}:{ok.ok}" + + async with Client(mcp, sampling_callback=sampler, elicitation_callback=_accept({"ok": True})) as client: + assert await _text(client, "tool", {}) == "Paris:True" + assert samples == 1 + + +@pytest.mark.anyio +async def test_wrong_kind_response_for_sample_raises(): + mcp = MCPServer(name="WrongKind", request_state_security=RequestStateSecurity.ephemeral()) + + @mcp.tool() + async def capital(answer: Annotated[CreateMessageResult, Resolve(_sample_capital)]) -> str: + return "unreachable" # pragma: no cover + + async with Client(mcp, sampling_callback=_sample_never) as client: + first = await client.session.call_tool("capital", {}, allow_input_required=True) + assert isinstance(first, InputRequiredResult) + assert first.input_requests is not None + (key,) = first.input_requests + final = await client.session.call_tool( + "capital", + {}, + input_responses={key: ElicitResult(action="accept", content={"x": "y"})}, + request_state=first.request_state, + allow_input_required=True, + ) + assert isinstance(final, CallToolResult) + assert final.is_error + assert isinstance(final.content[0], TextContent) + assert "wrong kind" in final.content[0].text + + +def test_mixed_marker_arms_raise_at_registration(): + async def ambiguous(ctx: Context) -> Sample | Elicit[Login]: + raise NotImplementedError # pragma: no cover + + async def tool(login: Annotated[Login, Resolve(ambiguous)]) -> str: + return login.username # pragma: no cover + + with pytest.raises(InvalidSignature, match="multiple Elicit/Sample/ListRoots arms"): + Tool.from_function(tool) + + +def test_marker_union_with_generic_alias_member_registers(): + # dict[str, Any] passes isinstance(c, type) on Python 3.10; the arm filter must not feed it to issubclass. + async def maybe_ask(ctx: Context) -> Sample | dict[str, Any]: + raise NotImplementedError # pragma: no cover + + async def tool(answer: Annotated[CreateMessageResult, Resolve(maybe_ask)]) -> str: + return "ok" # pragma: no cover + + Tool.from_function(tool) + + +def test_decline_entry_for_a_sample_marker_is_invalid(): + # Decline outcomes exist only for elicitations; for a Sample the entry's None data fails validation. + with pytest.raises(ValidationError): + _outcome_from_state(_StateEntry(action="decline"), _sample_capital(cast(Context, None))) + + +@pytest.mark.anyio +async def test_bare_initialized_session_is_still_gated(): + # notifications/initialized alone commits the handshake: a live back-channel, no declared capabilities. + mcp = MCPServer(name="BareInit", request_state_security=RequestStateSecurity.ephemeral()) + + async def ask(ctx: Context) -> Elicit[Login]: + return Elicit("user?", Login) + + @mcp.tool() + async def tool(login: Annotated[Login, Resolve(ask)]) -> str: + return login.username # pragma: no cover + + async with InMemoryTransport(mcp) as (read, write): + await write.send(SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized"))) + await write.send( + SessionMessage( + JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={"name": "tool", "arguments": {}}) + ) + ) + with anyio.fail_after(5): + message = await read.receive() + assert isinstance(message, SessionMessage) + assert isinstance(message.message, JSONRPCError) + assert message.message.error.code == MISSING_REQUIRED_CLIENT_CAPABILITY + + +@pytest.mark.anyio +@pytest.mark.parametrize("mode", ["legacy", "auto"]) +async def test_tool_choice_only_sample_validates_as_tools_mode(mode: Literal["legacy", "auto"]): + # Gate and answer model share one predicate: tool_choice alone is tools-mode, + # so a single-content answer still validates (WithTools accepts both shapes). + mcp = MCPServer(name="ToolChoiceOnly", request_state_security=RequestStateSecurity.ephemeral()) + + async def sampler(context: ClientRequestContext, params: CreateMessageRequestParams) -> CreateMessageResult: + assert params.tool_choice is not None and params.tools is None + return CreateMessageResult(role="assistant", content=TextContent(type="text", text="4"), model="m") + + @mcp.tool() + async def calc(answer: Annotated[CreateMessageResultWithTools, Resolve(_ask_with_tool_choice)]) -> str: + assert isinstance(answer, CreateMessageResultWithTools) + assert isinstance(answer.content, TextContent) + return answer.content.text + + async with Client( + mcp, + mode=mode, + sampling_callback=sampler, + sampling_capabilities=SamplingCapability(tools=SamplingToolsCapability()), + ) as client: + assert await _text(client, "calc", {}) == "4" + + +# --- Feature walk: interaction-level tests (public API only, one behaviour per test) --- + + +@pytest.mark.anyio +async def test_a_resolver_fills_its_parameter_without_any_client_interaction(): + """A resolver that computes its value server-side injects it with no client + callbacks involved. SDK-defined resolver contract.""" + mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral()) + + def price_of(title: str) -> int: + return 42 if title == "Dune" else 0 + + @mcp.tool() + async def quote(title: str, price: Annotated[int, Resolve(price_of)]) -> str: + return f"{title}: {price}" + + async with Client(mcp) as client: + result = await client.call_tool("quote", {"title": "Dune"}) + + assert not result.is_error + assert result.content == [TextContent(type="text", text="Dune: 42")] + + +@pytest.mark.anyio +async def test_a_resolver_may_depend_on_another_resolvers_value(): + """A resolver declares its own Resolve dependency and receives that resolver's + value before the tool body runs. SDK-defined resolver contract.""" + mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral()) + + def base_price(title: str) -> int: + return 10 if title == "Dune" else 1 + + def with_tax(price: Annotated[int, Resolve(base_price)]) -> int: + return price * 2 + + @mcp.tool() + async def quote(title: str, total: Annotated[int, Resolve(with_tax)]) -> str: + return f"{title} costs {total}" + + async with Client(mcp) as client: + result = await client.call_tool("quote", {"title": "Dune"}) + + assert not result.is_error + assert result.content == [TextContent(type="text", text="Dune costs 20")] + + +@pytest.mark.anyio +async def test_a_client_supplied_value_for_a_resolved_parameter_is_discarded(): + """A value the client sends under a resolved parameter's name never reaches the + tool; the resolver's value wins. SDK-defined: resolved parameters are server-side only.""" + mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral()) + + def price_of(title: str) -> int: + return 42 + + @mcp.tool() + async def quote(title: str, price: Annotated[int, Resolve(price_of)]) -> str: + return f"{title}: {price}" + + async with Client(mcp) as client: + result = await client.call_tool("quote", {"title": "Dune", "price": 999}) + + assert not result.is_error + assert result.content == [TextContent(type="text", text="Dune: 42")] + + +@pytest.mark.anyio +async def test_resolved_parameters_are_absent_from_the_advertised_tool_schema(): + """tools/list advertises only the model-facing parameters; resolved ones are + invisible to the client. SDK-defined: resolvers are server-side dependency injection.""" + mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral()) + + def price_of(title: str) -> int: + raise NotImplementedError # pragma: no cover - only the schema is inspected + + @mcp.tool() + async def quote(title: str, price: Annotated[int, Resolve(price_of)]) -> str: + raise NotImplementedError # pragma: no cover - only the schema is inspected + + async with Client(mcp) as client: + (advertised,) = (await client.list_tools()).tools + + assert advertised.input_schema == snapshot( + { + "type": "object", + "properties": {"title": {"title": "Title", "type": "string"}}, + "required": ["title"], + "title": "quoteArguments", + } + ) + + +@pytest.mark.anyio +@pytest.mark.parametrize("mode", ["legacy", "auto"]) +async def test_an_elicit_marker_asks_the_client_and_injects_the_accepted_answer(mode: Literal["legacy", "auto"]): + """A resolver returning Elicit puts its question to the client's elicitation + callback and the tool receives the validated model, identically over the 2025 + back-channel and the 2026 multi-round-trip flow. SDK-defined injection contract.""" + mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral()) + + def ask(ctx: Context) -> Elicit[Login]: + return Elicit("GitHub username?", Login) + + @mcp.tool() + async def whoami(login: Annotated[Login, Resolve(ask)]) -> str: + return login.username + + asked: list[str] = [] + + async def on_elicit(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + asked.append(params.message) + return ElicitResult(action="accept", content={"username": "octocat"}) + + async with Client(mcp, mode=mode, elicitation_callback=on_elicit) as client: + # The parametrize is only a real era matrix while the arms negotiate different revisions. + assert client.protocol_version == ("2025-11-25" if mode == "legacy" else "2026-07-28") + result = await client.call_tool("whoami", {}) + + assert not result.is_error + assert result.content == [TextContent(type="text", text="octocat")] + assert asked == ["GitHub username?"] + + +@pytest.mark.anyio +@pytest.mark.parametrize("mode", ["legacy", "auto"]) +async def test_a_declined_elicitation_fails_the_call_for_a_plain_model_consumer(mode: Literal["legacy", "auto"]): + """Declining the question aborts a tool whose consumer asked for the unwrapped + model, on either protocol era. Decline semantics are spec-defined; the abort is + the SDK's contract for plain-model consumers.""" + mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral()) + + def ask(ctx: Context) -> Elicit[Login]: + return Elicit("GitHub username?", Login) + + @mcp.tool() + async def whoami(login: Annotated[Login, Resolve(ask)]) -> str: + raise NotImplementedError # pragma: no cover - the decline aborts before the body + + async with Client(mcp, mode=mode, elicitation_callback=_decline) as client: + result = await client.call_tool("whoami", {}) + + assert result.is_error + assert isinstance(result.content[0], TextContent) + assert result.content[0].text == snapshot( + "Error executing tool whoami: Resolver for parameter 'login' could not resolve: elicitation was decline" + ) + + +@pytest.mark.anyio +@pytest.mark.parametrize("mode", ["legacy", "auto"]) +async def test_a_declined_elicitation_reaches_a_consumer_that_asked_for_the_outcome_union( + mode: Literal["legacy", "auto"], +): + """Annotating the outcome union hands the tool the decline to branch on instead + of aborting the call. SDK-defined consumer-annotation contract.""" + mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral()) + + def ask(ctx: Context) -> Elicit[Login]: + return Elicit("GitHub username?", Login) + + @mcp.tool() + async def whoami(login: Annotated[ElicitationResult[Login], Resolve(ask)]) -> str: + if isinstance(login, AcceptedElicitation): + return login.data.username # pragma: no cover - declined in this test + return "anonymous" + + async with Client(mcp, mode=mode, elicitation_callback=_decline) as client: + result = await client.call_tool("whoami", {}) + + assert not result.is_error + assert result.content == [TextContent(type="text", text="anonymous")] + + +@pytest.mark.anyio +@pytest.mark.parametrize("mode", ["legacy", "auto"]) +async def test_an_undeclared_capability_refuses_the_call_instead_of_asking(mode: Literal["legacy", "auto"]): + """A client that never declared the elicitation capability is refused with the + missing-capability protocol error rather than sent a question it cannot handle. + The egress rule is spec-mandated; the SDK applies it on both eras.""" + mcp = MCPServer(name="Walk", request_state_security=RequestStateSecurity.ephemeral()) + + def ask(ctx: Context) -> Elicit[Login]: + return Elicit("GitHub username?", Login) + + @mcp.tool() + async def whoami(login: Annotated[Login, Resolve(ask)]) -> str: + raise NotImplementedError # pragma: no cover - the gate refuses before the body + + async with Client(mcp, mode=mode) as client: + with pytest.raises(MCPError) as exc_info: + await client.call_tool("whoami", {}) + + assert exc_info.value.code == MISSING_REQUIRED_CLIENT_CAPABILITY + assert exc_info.value.error.data == {"requiredCapabilities": {"elicitation": {"form": {}}}} diff --git a/tests/server/test_session.py b/tests/server/test_session.py index 25b8257eb2..49e3b4615c 100644 --- a/tests/server/test_session.py +++ b/tests/server/test_session.py @@ -225,6 +225,21 @@ async def test_create_message_with_tools_returns_with_tools_result(): assert params is not None and params["tools"][0]["name"] == "t" +@pytest.mark.anyio +async def test_create_message_with_tool_choice_only_returns_with_tools_result(): + # tool_choice alone is tools-mode: the answer may carry array content. + outbound = StubOutbound(result={"role": "assistant", "content": [{"type": "text", "text": "ok"}], "model": "m"}) + session = _make_session( + outbound, capabilities=ClientCapabilities(sampling=SamplingCapability(tools=SamplingToolsCapability())) + ) + result = await session.create_message( # pyright: ignore[reportDeprecated] + messages=[types.SamplingMessage(role="user", content=types.TextContent(type="text", text="hi"))], + max_tokens=10, + tool_choice=types.ToolChoice(mode="none"), + ) + assert isinstance(result, types.CreateMessageResultWithTools) + + def test_check_client_capability_delegates_to_connection(): outbound = StubOutbound() session = _make_session(outbound, capabilities=ClientCapabilities(sampling=SamplingCapability())) diff --git a/tests/shared/test_peer.py b/tests/shared/test_peer.py index 2fc92e2c8b..0bc990f519 100644 --- a/tests/shared/test_peer.py +++ b/tests/shared/test_peer.py @@ -18,6 +18,7 @@ SamplingMessage, TextContent, Tool, + ToolChoice, ) from mcp.shared.dispatcher import DispatchContext @@ -91,6 +92,21 @@ async def test_peer_sample_with_tools_returns_with_tools_result(): assert isinstance(result, CreateMessageResultWithTools) +@pytest.mark.anyio +async def test_peer_sample_with_tool_choice_only_returns_with_tools_result(): + # tool_choice alone is tools-mode: the answer may carry array content. + rec = _Recorder({"role": "assistant", "content": [{"type": "text", "text": "x"}], "model": "m"}) + async with running_pair(direct_pair, server_on_request=rec.on_request) as (client, *_): + peer = ClientPeer(client) + with anyio.fail_after(5): + result = await peer.sample( # pyright: ignore[reportDeprecated] + [SamplingMessage(role="user", content=TextContent(type="text", text="q"))], + max_tokens=5, + tool_choice=ToolChoice(mode="none"), + ) + assert isinstance(result, CreateMessageResultWithTools) + + @pytest.mark.anyio async def test_peer_elicit_form_sends_elicitation_create_with_form_params(): rec = _Recorder({"action": "accept", "content": {"name": "Max"}}) From 867bba62630b2c0ac5451467b9b8eb0e1176a761 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:19:04 +0100 Subject: [PATCH 050/100] Share one event loop per test module to stop Windows socketpair churn (#3070) --- tests/client/test_input_required.py | 5 ++++ tests/client/test_stdio.py | 8 ++++++ tests/conftest.py | 30 ++++++++++++++++++--- tests/interaction/lowlevel/test_timeouts.py | 5 ++++ tests/server/test_streamable_http_modern.py | 5 ++++ tests/shared/test_jsonrpc_dispatcher.py | 5 ++++ tests/transports/stdio/test_windows.py | 5 ++++ 7 files changed, 60 insertions(+), 3 deletions(-) diff --git a/tests/client/test_input_required.py b/tests/client/test_input_required.py index cc58cf8dbe..fe8dc7f471 100644 --- a/tests/client/test_input_required.py +++ b/tests/client/test_input_required.py @@ -37,6 +37,11 @@ pytestmark = pytest.mark.anyio +@pytest.fixture(autouse=True) +def _module_runner_lease() -> None: + """Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`.""" + + def _elicit(message: str = "What is your name?") -> ElicitRequest: schema = {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]} return ElicitRequest(params=ElicitRequestFormParams(message=message, requested_schema=schema)) diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index 0b0695378b..91f829ff98 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -44,6 +44,14 @@ from mcp.shared.exceptions import MCPError from mcp.shared.message import SessionMessage + +@pytest.fixture(autouse=True) +def _module_runner_lease() -> None: + """Opt out of the shared per-module event loop: this module parametrizes `anyio_backend` + and calls `trio.run` directly (see the tests/conftest.py original for the Windows hazard). + """ + + # --------------------------------------------------------------------------- # In-process fake of the spawned server process # --------------------------------------------------------------------------- diff --git a/tests/conftest.py b/tests/conftest.py index 2278c9939e..9ade27e7f3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,5 @@ import os -from collections.abc import Iterator +from collections.abc import AsyncIterator, Iterator import pytest @@ -18,11 +18,35 @@ import mcp.shared._otel # noqa: E402 -@pytest.fixture -def anyio_backend(): +@pytest.fixture(scope="session") +def anyio_backend() -> str: return "asyncio" +@pytest.fixture(scope="module", autouse=True) +async def _module_runner_lease(anyio_backend: str) -> AsyncIterator[None]: + """Share one event loop across each module's tests instead of one per test. + + anyio's pytest plugin tears its runner down whenever the last lease is + released, so with only function-scoped async fixtures every async test + creates and destroys its own event loop. On Windows each loop's self-pipe + is an emulated loopback-TCP socketpair, and churning thousands of those per + run can transiently exhaust kernel socket buffers — surfacing in CI as + `OSError: [WinError 10055]` raised from `asyncio.new_event_loop()` before + an arbitrary test's body even starts. Holding a module-scoped lease caps + the churn at one loop per module per xdist worker. + + Modules that parametrize `anyio_backend` or call `trio.run(...)` directly + must shadow this fixture with a sync no-op: a module-scoped lease cannot + depend on the function-scoped parameter (pytest raises ScopeMismatch at + setup), and the lease's live asyncio loop lingers over direct trio runs, + whose signal handling collides with the loop's wakeup fd on Windows. The + lease also makes sniffio report asyncio to the module's sync tests, so a + sync test must not call `anyio.run()` itself. + """ + yield + + @pytest.fixture(name="capfire") def _capfire_isolated(capfire: CaptureLogfire) -> Iterator[CaptureLogfire]: """Override of logfire's `capfire` that scopes the MCP tracer to the test. diff --git a/tests/interaction/lowlevel/test_timeouts.py b/tests/interaction/lowlevel/test_timeouts.py index 316c69245c..0c20fd7c6e 100644 --- a/tests/interaction/lowlevel/test_timeouts.py +++ b/tests/interaction/lowlevel/test_timeouts.py @@ -27,6 +27,11 @@ pytestmark = pytest.mark.anyio +@pytest.fixture(autouse=True) +def _module_runner_lease() -> None: + """Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`.""" + + @requirement("protocol:timeout:basic") @requirement("protocol:timeout:sends-cancellation") async def test_request_timeout_fails_the_pending_call() -> None: diff --git a/tests/server/test_streamable_http_modern.py b/tests/server/test_streamable_http_modern.py index 19ad33f194..b85566f8a0 100644 --- a/tests/server/test_streamable_http_modern.py +++ b/tests/server/test_streamable_http_modern.py @@ -55,6 +55,11 @@ pytestmark = pytest.mark.anyio +@pytest.fixture(autouse=True) +def _module_runner_lease() -> None: + """Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`.""" + + async def test_single_exchange_dispatch_context_has_no_back_channel() -> None: """The per-request dispatch context refuses server-initiated requests; without an SSE sink, notify/progress are no-ops.""" diff --git a/tests/shared/test_jsonrpc_dispatcher.py b/tests/shared/test_jsonrpc_dispatcher.py index e91fc2de27..5c29c7e3ce 100644 --- a/tests/shared/test_jsonrpc_dispatcher.py +++ b/tests/shared/test_jsonrpc_dispatcher.py @@ -51,6 +51,11 @@ DCtx = DispatchContext[TransportContext] +@pytest.fixture(autouse=True) +def _module_runner_lease() -> None: + """Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`.""" + + class RecordingWriteStream: """Records sends without a checkpoint, so a pending cancellation cannot interrupt the write or mask it.""" diff --git a/tests/transports/stdio/test_windows.py b/tests/transports/stdio/test_windows.py index 656fc8124d..2d4eeac826 100644 --- a/tests/transports/stdio/test_windows.py +++ b/tests/transports/stdio/test_windows.py @@ -37,6 +37,11 @@ ] +@pytest.fixture(autouse=True) +def _module_runner_lease() -> None: + """Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`.""" + + async def test_a_gracefully_exited_servers_child_is_reaped_when_the_job_handle_closes( # pragma: no cover tmp_path: Path, spawned_processes: list[anyio.abc.Process | FallbackProcess], From 6d2e908f2bd7935fb8a3f1d1013f99d6d18e2e68 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Tue, 7 Jul 2026 13:45:30 +0100 Subject: [PATCH 051/100] docs: pin mkdocs<2 and silence the mkdocs-material advisory banner in CI (#3072) --- .github/workflows/deploy-docs.yml | 3 +++ .github/workflows/docs-preview.yml | 3 +++ .github/workflows/shared.yml | 3 +++ pyproject.toml | 4 +++- uv.lock | 8 ++++---- 5 files changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index b958fd23ab..fb16310757 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -49,6 +49,9 @@ jobs: - name: Build combined docs (v1.x at /, main at /v2/) run: bash scripts/build-docs.sh site + env: + # Silence mkdocs-material's MkDocs 2.0 warning banner in CI logs. + NO_MKDOCS_2_WARNING: "1" - name: Configure Pages uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml index 05e8a877f0..bf10369cc9 100644 --- a/.github/workflows/docs-preview.yml +++ b/.github/workflows/docs-preview.yml @@ -130,6 +130,9 @@ jobs: - run: uv sync --frozen --group docs - run: uv run --frozen --no-sync mkdocs build + env: + # Silence mkdocs-material's MkDocs 2.0 warning banner in CI logs. + NO_MKDOCS_2_WARNING: "1" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index 1dc2692eb1..36ef4f2377 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -125,3 +125,6 @@ jobs: - name: Build the docs in strict mode run: uv run --frozen --no-sync mkdocs build --strict + env: + # Silence mkdocs-material's MkDocs 2.0 warning banner in CI logs. + NO_MKDOCS_2_WARNING: "1" diff --git a/pyproject.toml b/pyproject.toml index 2260ea2e27..e41416b8ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,7 +76,9 @@ dev = [ "opentelemetry-sdk>=1.39.1", ] docs = [ - "mkdocs>=1.6.1", + # MkDocs 2.0 is a ground-up rewrite (no plugin system) that is incompatible + # with mkdocs-material and every plugin below; stay on the 1.x line. + "mkdocs>=1.6.1,<2", "mkdocs-gen-files>=0.5.0", "mkdocs-glightbox>=0.4.0", "mkdocs-literate-nav>=0.6.1", diff --git a/uv.lock b/uv.lock index e9abba117d..d574f1b089 100644 --- a/uv.lock +++ b/uv.lock @@ -1016,7 +1016,7 @@ dev = [ { name = "trio", specifier = ">=0.26.2" }, ] docs = [ - { name = "mkdocs", specifier = ">=1.6.1" }, + { name = "mkdocs", specifier = ">=1.6.1,<2" }, { name = "mkdocs-gen-files", specifier = ">=0.5.0" }, { name = "mkdocs-glightbox", specifier = ">=0.4.0" }, { name = "mkdocs-literate-nav", specifier = ">=0.6.1" }, @@ -1593,7 +1593,7 @@ wheels = [ [[package]] name = "mkdocs-material" -version = "9.7.2" +version = "9.7.6" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "babel" }, @@ -1608,9 +1608,9 @@ dependencies = [ { name = "pymdown-extensions" }, { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/57/5d3c8c9e2ff9d66dc8f63aa052eb0bac5041fecff7761d8689fe65c39c13/mkdocs_material-9.7.2.tar.gz", hash = "sha256:6776256552290b9b7a7aa002780e25b1e04bc9c3a8516b6b153e82e16b8384bd", size = 4097818, upload-time = "2026-02-18T15:53:07.763Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/29/6d2bcf41ae40802c4beda2432396fff97b8456fb496371d1bc7aad6512ec/mkdocs_material-9.7.6.tar.gz", hash = "sha256:00bdde50574f776d328b1862fe65daeaf581ec309bd150f7bff345a098c64a69", size = 4097959, upload-time = "2026-03-19T15:41:58.161Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/19/d194e75e82282b1d688f0720e21b5ac250ed64ddea333a228aaf83105f2e/mkdocs_material-9.7.2-py3-none-any.whl", hash = "sha256:9bf6f53452d4a4d527eac3cef3f92b7b6fc4931c55d57766a7d87890d47e1b92", size = 9305052, upload-time = "2026-02-18T15:53:05.221Z" }, + { url = "https://files.pythonhosted.org/packages/2c/01/bc663630c510822c95c47a66af9fa7a443c295b47d5f041e5e6ae62ef659/mkdocs_material-9.7.6-py3-none-any.whl", hash = "sha256:71b84353921b8ea1ba84fe11c50912cc512da8fe0881038fcc9a0761c0e635ba", size = 9305470, upload-time = "2026-03-19T15:41:55.217Z" }, ] [package.optional-dependencies] From 9bdc03d54e59e28f08307c2dba0e429a64b171c9 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:26:09 +0100 Subject: [PATCH 052/100] Add the client-side subscriptions/listen driver (#3047) --- docs/client/caching.md | 2 +- docs/client/index.md | 2 +- docs/client/subscriptions.md | 86 +++ docs/handlers/subscriptions.md | 140 ++-- docs/migration.md | 17 + docs/run/deploy.md | 2 +- docs/whats-new.md | 4 +- docs_src/subscriptions/tutorial001.py | 33 +- docs_src/subscriptions/tutorial002.py | 29 +- docs_src/subscriptions/tutorial003.py | 30 + docs_src/subscriptions/tutorial004_anyio.py | 32 + docs_src/subscriptions/tutorial004_asyncio.py | 32 + docs_src/subscriptions/tutorial004_trio.py | 32 + docs_src/subscriptions/tutorial005.py | 20 + examples/stories/subscriptions/README.md | 38 +- examples/stories/subscriptions/client.py | 80 +-- mkdocs.yml | 1 + src/mcp/client/client.py | 68 +- src/mcp/client/session.py | 84 ++- src/mcp/client/streamable_http.py | 55 +- src/mcp/client/subscriptions.py | 282 ++++++++ src/mcp/server/mcpserver/context.py | 6 +- src/mcp/server/subscriptions.py | 94 +-- src/mcp/shared/direct_dispatcher.py | 15 +- src/mcp/shared/dispatcher.py | 37 +- src/mcp/shared/jsonrpc_dispatcher.py | 20 +- src/mcp/shared/subscriptions.py | 106 +++ tests/client/test_client.py | 8 +- tests/client/test_send_request_mcp_name.py | 3 +- tests/client/test_session.py | 141 +++- tests/client/test_session_claims.py | 3 +- tests/client/test_streamable_http.py | 167 ++++- tests/client/test_subscriptions.py | 666 ++++++++++++++++++ tests/docs_src/test_client.py | 6 +- tests/docs_src/test_subscriptions.py | 201 +++++- tests/interaction/_requirements.py | 59 +- tests/interaction/lowlevel/test_resources.py | 9 +- .../lowlevel/test_subscriptions.py | 142 ++++ .../mcpserver/test_subscriptions.py | 62 ++ tests/shared/test_dispatcher.py | 68 +- 40 files changed, 2587 insertions(+), 295 deletions(-) create mode 100644 docs/client/subscriptions.md create mode 100644 docs_src/subscriptions/tutorial003.py create mode 100644 docs_src/subscriptions/tutorial004_anyio.py create mode 100644 docs_src/subscriptions/tutorial004_asyncio.py create mode 100644 docs_src/subscriptions/tutorial004_trio.py create mode 100644 docs_src/subscriptions/tutorial005.py create mode 100644 src/mcp/client/subscriptions.py create mode 100644 src/mcp/shared/subscriptions.py create mode 100644 tests/client/test_subscriptions.py create mode 100644 tests/interaction/lowlevel/test_subscriptions.py create mode 100644 tests/interaction/mcpserver/test_subscriptions.py diff --git a/docs/client/caching.md b/docs/client/caching.md index 5e0976fb5f..dc4ae97acc 100644 --- a/docs/client/caching.md +++ b/docs/client/caching.md @@ -53,7 +53,7 @@ One rule sits above `"use"`: **calls carrying `meta` always reach the server.** To turn caching off entirely, construct with `Client(server, cache=False)`: every call is a round trip again, and `cache_mode`, while still accepted, does nothing. -Scope is honored automatically too: `"private"` entries are keyed to the cache's *partition* (below), while `"public"` ones may opt into wider sharing. And **notifications beat TTL** for the exact entries they name: a `list_changed` notification evicts the matching cached listing, and `resources/updated` evicts the cached read stored under exactly its URI, however fresh they were. +Scope is honored automatically too: `"private"` entries are keyed to the cache's *partition* (below), while `"public"` ones may opt into wider sharing. And **notifications beat TTL** for the exact entries they name: a `list_changed` notification evicts the matching cached listing, and `resources/updated` evicts the cached read stored under exactly its URI, however fresh they were. On a 2026-07-28 connection those notifications arrive on a `subscriptions/listen` stream you open with `client.listen(...)`, and eviction completes before your watcher sees the event; **[Subscriptions](subscriptions.md)** is that page. One caveat on `resources/updated`: eviction is exact-URI only. The store contract has no enumerate or scan operation (same as the reference TypeScript implementation), so a notification carrying a *sub*-resource URI does not evict a cached read of its parent. If your server signals sub-resources this way, refetch the parent with `cache_mode="refresh"`. diff --git a/docs/client/index.md b/docs/client/index.md index 01287da054..ae47508359 100644 --- a/docs/client/index.md +++ b/docs/client/index.md @@ -145,7 +145,7 @@ The resource verbs come in pairs: two ways to list, one way to read. `read_resource` returns `contents`, a list of `TextResourceContents` or `BlobResourceContents`. Same idea as tool content: narrow with `isinstance`, then read `.text` (or `.blob`). -A client can also be told when a resource changes. On 2025-era connections that is `subscribe_resource(uri)` / `unsubscribe_resource(uri)` - a method pair `MCPServer` doesn't implement, so on the 2026-07-28 wire (where those verbs no longer exist) the request answers `-32601`, *Method not found*. The 2026 replacement is a `subscriptions/listen` stream, which `MCPServer` *does* serve - `server_capabilities.resources.subscribe` is `True` there, and the server side of the story is **[Subscriptions](../handlers/subscriptions.md)**. +A client can also be told when a resource changes. On 2025-era connections that is `subscribe_resource(uri)` / `unsubscribe_resource(uri)` - a method pair `MCPServer` doesn't implement, so on the 2026-07-28 wire (where those verbs no longer exist) the request answers `-32601`, *Method not found*. The 2026 replacement is a `subscriptions/listen` stream, which `MCPServer` *does* serve - `server_capabilities.resources.subscribe` is `True` there - and consuming it with `client.listen(...)` is this section's **[Subscriptions](subscriptions.md)** page. ## Prompts diff --git a/docs/client/subscriptions.md b/docs/client/subscriptions.md new file mode 100644 index 0000000000..fc7d01308d --- /dev/null +++ b/docs/client/subscriptions.md @@ -0,0 +1,86 @@ +# Subscriptions + +A server's catalog is not fixed. Tools appear at runtime, and the content behind a resource URI changes. A client hears about it through `client.listen(...)`: one `subscriptions/listen` request whose response *is* the stream. It stays open and carries the change notifications the client asked for. + +This page is the client end: opening the stream, watching it beside your main flow, and handling its endings. Publishing changes, filtering, and serving the method are the server's side of the story, told in **[Subscriptions](../handlers/subscriptions.md)** under *Inside your handler*. The examples here talk to the sprint-board server built there. + +## Watching the stream + +A subscription is one context manager. Entering it sends the request, with your keyword arguments as the subscription filter, and waits for the server's acknowledgment, so the stream is live by the time the block starts. + +```python title="client.py" hl_lines="16 19 29" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +Iteration yields four typed events: `ToolsListChanged`, `PromptsListChanged`, `ResourcesListChanged`, and `ResourceUpdated(uri=...)`. + +An event says *what* changed, never *how*. That is why `follow_board` calls `read_resource` and `list_tools`: the event is a cue to refetch. Read `event.uri` rather than assuming which resource moved: a filter can name several URIs, and a server may report a change on a sub-resource of one of them. + +Duplicate events waiting to be consumed collapse into one, and refetching still gets you the current state. Only identical events collapse: two `ResourceUpdated` for different URIs are two events. + +Two more properties of the handle: + +* `sub.honored` is the filter the server acknowledged: a `SubscriptionFilter` with the fields you passed, read as attributes (`sub.honored.prompts_list_changed`). `MCPServer` honors every kind you ask for, so it echoes your request back. A server that narrows the filter (see the [filter warning](../handlers/subscriptions.md#only-what-was-asked-for) on the server page) acknowledges less, and an honored kind may still never fire. +* `sub.subscription_id` is the listen request's id, the one stamped on every frame of this stream. Several subscriptions can be open at once, each demultiplexed by its own id. + +## Watching without blocking + +`follow_board` runs until the server closes the stream, which may be never, so on its own it owns your program. Real clients want the watcher *beside* the main flow: an agent calls tools while a watcher keeps a cache or a UI current. + +Open the subscription first, then start the watcher and get on with your work. + +=== "asyncio" + + ```python title="app.py" hl_lines="18 20" + --8<-- "docs_src/subscriptions/tutorial004_asyncio.py" + ``` + +=== "trio" + + ```python title="app.py" hl_lines="18 21" + --8<-- "docs_src/subscriptions/tutorial004_trio.py" + ``` + +=== "anyio" + + ```python title="app.py" hl_lines="18 21" + --8<-- "docs_src/subscriptions/tutorial004_anyio.py" + ``` + +!!! note + `app.py` imports `BOARD` and `read_board` from the first example, which this repo stores as + `tutorial003.py`. If you save the rendered files side by side as `client.py` and `app.py`, + write `from client import BOARD, read_board` instead. The `watch.py` example further down + imports `read_board` the same way. + +The order is the point. Nothing is replayed, so an event published before your stream existed is missed. Entering `client.listen(...)` waits for the acknowledgment, so every change from that moment on reaches your watcher, and the snapshot you take inside the block cannot miss one. + +Requests run freely beside an open stream, from the watcher task or any other, on the same client. Because *duplicate* unconsumed events coalesce, a busy main flow may produce one refetch rather than three. Events that differ do not coalesce: a filter naming many URIs queues one pending event per URI. + +To stop watching, leave the block: there is no `unsubscribe` call. Cancelling the task that owns the block does that for you, and the SDK cancels the listen request the way the transport expects: over streamable HTTP, by closing that request's stream. A watcher that runs for the life of your app never returns on its own, so cancel it, or its task group's scope, at shutdown. + +## Streams end + +A stream ends in one of two ways, both ordinary control flow. A graceful server close ends the `async for`; an abrupt drop raises `SubscriptionLost`. + +The difference is diagnostic, not a difference in what to do next: the stream is gone, nothing was replayed, and a watcher that still cares re-listens and refetches. + +```python title="watch.py" hl_lines="16 20" +--8<-- "docs_src/subscriptions/tutorial005.py" +``` + +Servers close streams gracefully for their own reasons, including shedding a subscriber whose backlog grew too large, so a clean end is not a signal to stop watching. Back off before re-listening. + +`SubscriptionLost` has one local cause too. The client holds at most 1024 unconsumed events, and a consumer that falls that far behind loses the subscription rather than grow without bound. Keep the body of the `async for` short and do slow work elsewhere. + +`keep_following` catches only `SubscriptionLost`. Entering `listen()` can also raise `MCPError` (the connection failed, or the server does not serve the method), `TimeoutError` (no acknowledgment arrived), and `ListenNotSupportedError` (a pre-2026 connection). Decide which of those your watcher should retry: the last never heals. + +## Recap + +* Enter `async with client.listen(...)`; entering waits for the acknowledgment, so nothing published after it is missed. +* Iterate with `async for event in sub`. Events are cues to refetch, never payloads. +* Open the subscription, then run the watcher as a task, and tool calls keep flowing beside it. +* A clean end stops the loop; a drop raises `SubscriptionLost`. Either way: re-listen, refetch, back off first. +* Leaving the block is the unsubscribe. + +Publishing these events, narrowing the filter, and scaling past one process are the server's story: **[Subscriptions](../handlers/subscriptions.md)**. These same events also keep a client-side cache honest, and **[Caching](caching.md)** is the next page. diff --git a/docs/handlers/subscriptions.md b/docs/handlers/subscriptions.md index 6ff85dd86a..85b9632786 100644 --- a/docs/handlers/subscriptions.md +++ b/docs/handlers/subscriptions.md @@ -1,94 +1,146 @@ # Subscriptions -A server's catalog is not fixed. Tools get registered at runtime, resources change behind their URIs. The client side of that story is a subscription: on the 2026-07-28 protocol, a client that wants to hear about changes sends one `subscriptions/listen` request, and the response to that request *is* the stream — it stays open, carrying exactly the notification kinds the client asked for. +A server's catalog is not fixed. Tools appear at runtime, and the content behind a resource URI changes. + +**Subscriptions** are how a client hears about it. The client sends one `subscriptions/listen` request, and the response to that request *is* the stream: it stays open and carries the change notifications the client asked for. + +## Publish it from the tool Your side of it is one line: publish the change. -```python title="server.py" hl_lines="16 27" +```python title="server.py" hl_lines="20 32" --8<-- "docs_src/subscriptions/tutorial001.py" ``` -* `await ctx.notify_resource_updated("note://todo")` delivers `notifications/resources/updated` to every open listen stream that subscribed to that URI. Not to anyone else. -* `await ctx.notify_tools_changed()` delivers `notifications/tools/list_changed` to every stream that asked for tool-list changes. A client that receives it calls `tools/list` again — and now sees `search`. -* The siblings are `notify_prompts_changed()` and `notify_resources_changed()`, for the other two list-changed kinds. -* No subscribers, no work: publishing to an idle server is a no-op. You don't check whether anyone is listening; you state what changed. +* `await ctx.notify_resource_updated("board://sprint")` reaches every open stream that subscribed to that URI. Nobody else. +* `await ctx.notify_tools_changed()` reaches every stream that asked for tool-list changes. A client that receives it calls `tools/list` again, and now sees `sprint_report`. +* The siblings are `notify_prompts_changed()` and `notify_resources_changed()`. +* No subscribers, no work. Publishing to an idle server is a no-op, so you never check whether anyone is listening. You state what changed. -The SDK serves `subscriptions/listen` for you — `MCPServer` registers the handler at construction, and the wire obligations (the acknowledgment as the first frame, the per-stream filtering, the subscription id tagged onto every frame) are its job, not yours. +`MCPServer` serves `subscriptions/listen` for you. The wire obligations (the acknowledgment as the first frame, per-stream filtering, the subscription id on every frame) are the SDK's job. !!! check - On the wire, a stream whose filter named `note://todo` looks like this after `edit_note` runs: + On the wire, a stream whose filter named `board://sprint` looks like this after `complete_task` runs: ```json {"method": "notifications/subscriptions/acknowledged", - "params": {"notifications": {"resourceSubscriptions": ["note://todo"]}, "_meta": {"io.modelcontextprotocol/subscriptionId": 7}}} + "params": {"notifications": {"resourceSubscriptions": ["board://sprint"]}, "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} {"method": "notifications/resources/updated", - "params": {"uri": "note://todo", "_meta": {"io.modelcontextprotocol/subscriptionId": 7}}} + "params": {"uri": "board://sprint", "_meta": {"io.modelcontextprotocol/subscriptionId": "listen-1"}}} ``` - The acknowledgment echoes the filter the server agreed to honor, and every frame carries the - listen request's JSON-RPC id under `_meta` — that id *is* the subscription id. + Note what the update does *not* carry: the board. Every frame carries the listen request's JSON-RPC id under `_meta`, and that id is the subscription id. The client mints it: the Python `Client` uses strings like `"listen-1"`; other clients may use integers. ## Only what was asked for -The filter is a contract. A stream that requested tool-list changes and one resource URI receives those two kinds and nothing else — publish a prompt change and that stream stays silent. Resource URIs are matched as exact strings: `note://todo` does not cover `note://todo/draft`. +The filter is a contract. A stream that requested tool-list changes and one resource URI receives those two kinds and nothing else. Publish a prompt change and that stream stays silent. -!!! warning - Filters are honored without per-client authorization: any client may name any URI — - including one it cannot read — and will receive update notifications for it (resource - existence and change timing, never content). On a multi-tenant server, don't publish - sensitive per-user URIs through `notify_resource_updated`, or serve the method with - your own handler on the low-level `Server` and narrow the filter there before acking — - the honored subset exists in the protocol precisely so servers can do this. +`MCPServer` matches resource URIs as exact strings, so a stream that named `board://sprint` hears nothing about `board://sprint/tasks/1`. The spec lets a server report a change on a sub-resource of a subscribed URI; `MCPServer` never does, but clients are built to expect it. -Two more things the stream is *not*: +Two things the stream is *not*: -* **It is not a replay log.** A dropped stream is gone; events published while nobody was connected are not queued. The client's contract is to re-listen and re-fetch what it cares about. -* **It is not the 2025 path.** Clients on earlier protocol versions that called `resources/subscribe` are served by `ctx.session.send_resource_updated(uri)` — the `notify_*` methods reach `subscriptions/listen` streams only. +* **It is not a replay log.** A dropped stream is gone, and events published while nobody was connected are not queued. Clients re-listen and refetch. +* **It is not the 2025 path.** Clients that called `resources/subscribe` are served by `ctx.session.send_resource_updated(uri)`. The `notify_*` methods reach `subscriptions/listen` streams only. + +!!! warning + Don't publish sensitive per-user URIs through `notify_resource_updated` on a multi-tenant + server. Any client may name any URI in its filter, and `MCPServer` honors it. The exposure + is narrow but real: a subscriber learns that a URI it can guess changed, and when. It never + learns content, and it cannot probe what exists, because an unknown URI is honored too and + simply never fires. To narrow the filter per client today, serve the method with your own + handler on the low-level `Server` and acknowledge a smaller filter than the client asked + for; the acknowledgment is how the client learns what it actually got. !!! warning "Streamable HTTP only, for now" - `subscriptions/listen` is served on the streamable-HTTP transport. Over stdio (and other - stream-pair transports) a 2026-07-28 connection rejects it with METHOD_NOT_FOUND — the - open-stream semantics haven't been built for that transport yet, even though - `server/discover` still advertises the subscription capabilities there. + `subscriptions/listen` needs a transport that can stream a request's response, which today + means streamable HTTP. Over stdio a 2026-07-28 connection rejects the method with + METHOD_NOT_FOUND, even though `server/discover` advertises the subscription capabilities + there. Serving it over stdio is planned; the open-stream semantics for that transport are + not built yet. + +## The client end + +Here is a client on the other side of that stream, following the board: -## One process is the default. More takes a bus +```python title="client.py" hl_lines="16" +--8<-- "docs_src/subscriptions/tutorial003.py" +``` + +Entering `client.listen(...)` sends the request and waits for your acknowledgment, so the stream is live when the block starts, and each typed event is a cue to refetch, never a payload. That is the whole contract in one screen. Everything else about the client end lives on its own page: watching beside a main flow, stream endings, and re-listening. See **[Subscriptions](../client/subscriptions.md)** under *Clients*. -Publishes travel from your handler to the open streams over a `SubscriptionBus`. The default is in-memory: one process, every stream in it. That is the right answer until you run replicas behind a load balancer — then a client's stream is pinned to one replica, and a publish on another replica has to reach it. +## Scaling past one process + +Publishes travel from your handler to the open streams over a `SubscriptionBus`. The default is in-memory: one process, every stream in it. That is the right answer until you run replicas behind a load balancer, because then a client's stream is pinned to one replica, and a publish on another replica has to reach it. That seam is yours to implement: two methods over your pub/sub backend. ```python +from collections.abc import Callable + +from redis.asyncio import Redis + +from mcp.server.mcpserver import MCPServer +from mcp.server.subscriptions import ServerEvent # SubscriptionBus is a Protocol: no base class + + class RedisSubscriptionBus: + def __init__(self, redis: Redis) -> None: + self._redis = redis + self._listeners: dict[object, Callable[[ServerEvent], None]] = {} + async def publish(self, event: ServerEvent) -> None: - await self.redis.publish("mcp-events", encode(event)) # to every replica + await self._redis.publish("mcp-events", encode(event)) # to every replica def subscribe(self, listener: Callable[[ServerEvent], None]) -> Callable[[], None]: - ... # register the local listener; a reader task calls it for arriving events + token = object() + self._listeners[token] = listener + + def unsubscribe() -> None: + self._listeners.pop(token, None) + + return unsubscribe + + +mcp = MCPServer("Sprint Board", subscriptions=RedisSubscriptionBus(redis)) ``` +`encode` is yours, and so is the reader task on each replica that decodes arriving messages and calls every registered listener. Listeners are synchronous, must not raise, and run on the server's event loop. + +The bus carries typed `ServerEvent` values, four small dataclasses, never JSON-RPC. Stamping, filtering, and stream lifecycles stay in the SDK, so a bus implementation cannot break the protocol. It can only move events between processes. + +To publish from outside a request, construct the bus yourself so you hold the reference. `MCPServer` builds one internally when you pass nothing, and does not expose it. + ```python -mcp = MCPServer("Notebook", subscriptions=RedisSubscriptionBus(...)) -``` +from mcp.server.subscriptions import InMemorySubscriptionBus, ToolsListChanged + +bus = InMemorySubscriptionBus() +mcp = MCPServer("Sprint Board", subscriptions=bus) -The bus carries typed `ServerEvent` values — four small dataclasses — never JSON-RPC. Stamping, filtering, and stream lifecycles stay in the SDK, so a bus implementation cannot break the protocol; it can only move events between processes. To publish from outside a request, keep a reference to the bus you constructed and `await bus.publish(ToolsListChanged())` — the server holds the same instance. + +async def tools_reloaded() -> None: + await bus.publish(ToolsListChanged()) # from a lifespan task, a webhook, anywhere +``` ## The low-level composition -Down on the low-level `Server` there is no pre-wired anything — and the same parts assemble in three lines: +Down on the low-level `Server` there is no pre-wired anything, and the same parts assemble in three lines: -```python title="server.py" hl_lines="9 31 39" +```python title="server.py" hl_lines="9-10 48" --8<-- "docs_src/subscriptions/tutorial002.py" ``` -* You own the bus, so you publish to it directly: `await bus.publish(ResourceUpdated(uri=...))`. Put it wherever your handlers can reach it — module scope here, the lifespan in a bigger app. -* `ListenHandler(bus)` is the same handler `MCPServer` registers; `on_subscriptions_listen=` is an ordinary handler slot. Don't want the SDK's semantics? Write your own handler for the slot — the spec obligations come with it. -* `ListenHandler.close()` gracefully ends every open stream: each one receives the listen request's result as its final frame, the spec's signal that the server ended the subscription deliberately — a clean end, as opposed to the abrupt drop a client may treat as a cue to reconnect. Without it, streams end when the client disconnects. +* You own the bus, so you publish to it directly: `await bus.publish(ResourceUpdated(uri=...))`. Put it wherever your handlers can reach it: module scope here, the lifespan in a bigger app. +* `ListenHandler(bus)` is the same handler `MCPServer` registers, and `on_subscriptions_listen=` is an ordinary handler slot. Put your own callable in that slot for different semantics, and the spec obligations move to you: acknowledge first, stamp every frame with the subscription id, deliver nothing outside the filter. +* `ListenHandler.close()` ends every open stream gracefully. Each one receives the listen request's result as its final frame, which is the spec's way of saying the server ended the subscription deliberately. It returns before those streams finish flushing, so give them a moment before you tear the transport down. Without it, streams end when the client disconnects. ## Recap -* A client opts in with one `subscriptions/listen` request; the response is the stream. There is nothing to configure server-side — serving it is built in. -* You publish: `await ctx.notify_resource_updated(uri)`, `notify_tools_changed()`, `notify_prompts_changed()`, `notify_resources_changed()`. Idle servers make these free. -* Streams receive only what their filter requested; URIs match exactly; nothing is replayed. -* Scaling out means implementing `SubscriptionBus` — two methods — over your own pub/sub, and passing it as `MCPServer(subscriptions=...)`. -* The low-level spelling is the same machinery held in your hands: a bus, `ListenHandler(bus)`, one constructor argument. +* A client opts in with one `subscriptions/listen` request, and the response is the stream. Serving it is built in. +* You publish with `ctx.notify_*`, and the SDK does the stamping, filtering, and lifecycle work. +* Events are cues, not payloads. Both ends refetch. +* The client end is `async with client.listen(...)`: **[Subscriptions](../client/subscriptions.md)** under *Clients* is that story. +* On the low-level `Server` you assemble the same parts yourself: a bus, `ListenHandler(bus)`, the `on_subscriptions_listen` slot. +* Scaling out means implementing `SubscriptionBus`, two methods, and passing it as `MCPServer(subscriptions=...)`. + +Running the server that serves all this, behind one replica or twenty, is **[Deploy & scale](../run/deploy.md)**. diff --git a/docs/migration.md b/docs/migration.md index 811fa17d99..8822d449d0 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -2055,6 +2055,23 @@ One behavioral caveat when moving progress-reporting handlers onto `Client(serve ## Deprecations +### Client resource-subscription methods deprecated (SEP-2575) + +[SEP-2575](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2575) removes `resources/subscribe` and `resources/unsubscribe` from the 2026-07-28 wire; per-URI subscriptions travel in the `subscriptions/listen` filter instead. The client verbs now carry `typing_extensions.deprecated`: + +- `Client.subscribe_resource()` / `Client.unsubscribe_resource()` +- `ClientSession.subscribe_resource()` / `ClientSession.unsubscribe_resource()` + +They keep working against 2025-era servers; a 2026-07-28 server answers them with `-32601` (method not found). Migrate to the listen driver: + +```python +async with client.listen(resource_subscriptions=["board://sprint"]) as sub: + async for event in sub: # ResourceUpdated(uri="board://sprint") + ... +``` + +See the [Subscriptions](client/subscriptions.md#watching-the-stream) page under Clients for the full client-side contract (typed events, the honored filter, clean end vs `SubscriptionLost`). + ### Roots, Sampling, and Logging methods deprecated (SEP-2577) [SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577) deprecates the Roots, Sampling, and Logging features as of the 2026-07-28 spec. The deprecation is advisory only: there are no wire-level changes, capability negotiation is unchanged, and every method keeps working for sessions negotiating 2025-11-25 and earlier. diff --git a/docs/run/deploy.md b/docs/run/deploy.md index cad564c421..7cec58163b 100644 --- a/docs/run/deploy.md +++ b/docs/run/deploy.md @@ -149,7 +149,7 @@ The seam between the two is the `SubscriptionBus`. Whatever bus you give a serve Nothing about the fan-out cares which server object a stream is attached to. Two servers holding one `InMemorySubscriptionBus` already behave this way: open a listen stream on one, `edit_note` on the other, and the stream hears about it. That in-memory bus only spans server objects inside one process, which makes it the model, not the deployment: -* Across real processes, **the SDK ships no bus that can help you.** `SubscriptionBus` is a two-method `Protocol` (`publish` and `subscribe`) that you implement over your own pub/sub backend (Redis, NATS, whatever you already run) and pass as `MCPServer(subscriptions=...)`. **[Subscriptions](../handlers/subscriptions.md#one-process-is-the-default-more-takes-a-bus)** has the sketch and the contract. +* Across real processes, **the SDK ships no bus that can help you.** `SubscriptionBus` is a two-method `Protocol` (`publish` and `subscribe`) that you implement over your own pub/sub backend (Redis, NATS, whatever you already run) and pass as `MCPServer(subscriptions=...)`. **[Subscriptions](../handlers/subscriptions.md#scaling-past-one-process)** has the sketch and the contract. * The bus carries four small typed events, never JSON-RPC. Acknowledgment, filtering, and stream lifecycle stay in the SDK, so your bus cannot break the protocol; it can only move events between processes. * Streams are **not** resumable and events are **not** replayed. Losing a replica drops its streams; the clients re-listen and re-fetch. There is no event store to share and nothing else to configure. This is the one place where scaling out is genuinely just more of the same. diff --git a/docs/whats-new.md b/docs/whats-new.md index d197833db8..ff9cfdeeb2 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -190,9 +190,9 @@ That file is the pitch in one place: one server, one `Resolve`-backed tool, and ### Change notifications become one stream -At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are replaced by `subscriptions/listen`: the client opens one long-lived stream and names the notification kinds it wants. `MCPServer` serves it out of the box; you publish with `await ctx.notify_resource_updated(uri)` (and `notify_tools_changed()`, and so on), and multi-replica deployments plug in a shared `SubscriptionBus`. Two honest caveats as of `2.0.0b1`: the Python `Client` cannot open the listen stream yet (the driver ships in a later pre-release), and over stdio the server does not serve it. The net for a Python *client* on that release is that nothing delivers change notifications on a 2026-07-28 connection; a host that relies on `resources/updated` should connect with `mode="legacy"` until the driver lands. +At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are replaced by `subscriptions/listen`: the client opens one long-lived stream and names the notification kinds it wants. `MCPServer` serves it out of the box; you publish with `await ctx.notify_resource_updated(uri)` (and `notify_tools_changed()`, and so on), and multi-replica deployments plug in a shared `SubscriptionBus`. On the client (since `2.0.0b2`), `async with client.listen(...)` opens the stream: the filter goes in as keyword arguments, typed change events come back, and `sub.honored` is the subset the server agreed to deliver. One honest caveat: over stdio the server does not serve the stream yet. -**[Subscriptions](handlers/subscriptions.md)** on the server, and **[Deploy & scale](run/deploy.md)** for the bus. +**[Subscriptions](handlers/subscriptions.md)** covers publishing and serving, **[its Clients twin](client/subscriptions.md)** the watching end, and **[Deploy & scale](run/deploy.md)** the bus. ### The rest, quickly diff --git a/docs_src/subscriptions/tutorial001.py b/docs_src/subscriptions/tutorial001.py index 5063fceed4..45c858f494 100644 --- a/docs_src/subscriptions/tutorial001.py +++ b/docs_src/subscriptions/tutorial001.py @@ -1,28 +1,33 @@ from mcp.server.mcpserver import Context, MCPServer -mcp = MCPServer("Notebook") +mcp = MCPServer("Sprint Board") -NOTES = {"todo": "buy milk", "journal": "day one"} +BOARDS = { + "sprint": {"design": False, "build": False, "ship": False}, + "backlog": {"tidy docs": False}, +} -@mcp.resource("note://{name}") -def note(name: str) -> str: - return NOTES[name] +@mcp.resource("board://{name}") +def board(name: str) -> str: + tasks = BOARDS[name] + return "\n".join(f"[{'x' if done else ' '}] {task}" for task, done in tasks.items()) @mcp.tool() -async def edit_note(name: str, text: str, ctx: Context) -> str: - NOTES[name] = text - await ctx.notify_resource_updated(f"note://{name}") - return "saved" +async def complete_task(board: str, task: str, ctx: Context) -> str: + BOARDS[board][task] = True + await ctx.notify_resource_updated(f"board://{board}") + return f"{task}: done" -def search(query: str) -> list[str]: - return [name for name, text in NOTES.items() if query in text] +def sprint_report() -> str: + done = sum(done for tasks in BOARDS.values() for done in tasks.values()) + return f"{done} task(s) done" @mcp.tool() -async def enable_search(ctx: Context) -> str: - mcp.add_tool(search) +async def enable_reports(ctx: Context) -> str: + mcp.add_tool(sprint_report) await ctx.notify_tools_changed() - return "search is live" + return "reporting is live" diff --git a/docs_src/subscriptions/tutorial002.py b/docs_src/subscriptions/tutorial002.py index c0b04f64db..39e42dcc04 100644 --- a/docs_src/subscriptions/tutorial002.py +++ b/docs_src/subscriptions/tutorial002.py @@ -7,34 +7,43 @@ from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, ResourceUpdated bus = InMemorySubscriptionBus() +listen_handler = ListenHandler(bus) -NOTES = {"todo": "buy milk"} +BOARD = {"design": False, "build": False} -EDIT_NOTE_SCHEMA: dict[str, Any] = { +COMPLETE_TASK_SCHEMA: dict[str, Any] = { "type": "object", - "properties": {"name": {"type": "string"}, "text": {"type": "string"}}, - "required": ["name", "text"], + "properties": {"task": {"type": "string"}}, + "required": ["task"], } +async def read_resource( + ctx: ServerRequestContext[Any], params: types.ReadResourceRequestParams +) -> types.ReadResourceResult: + board = "\n".join(f"[{'x' if done else ' '}] {task}" for task, done in BOARD.items()) + return types.ReadResourceResult(contents=[types.TextResourceContents(uri=params.uri, text=board)]) + + async def list_tools( ctx: ServerRequestContext[Any], params: types.PaginatedRequestParams | None ) -> types.ListToolsResult: return types.ListToolsResult( - tools=[types.Tool(name="edit_note", description="Replace a note's text.", input_schema=EDIT_NOTE_SCHEMA)] + tools=[types.Tool(name="complete_task", description="Mark a task done.", input_schema=COMPLETE_TASK_SCHEMA)] ) async def call_tool(ctx: ServerRequestContext[Any], params: types.CallToolRequestParams) -> types.CallToolResult: args = params.arguments or {} - NOTES[args["name"]] = args["text"] - await bus.publish(ResourceUpdated(uri=f"note://{args['name']}")) - return types.CallToolResult(content=[types.TextContent(type="text", text="saved")]) + BOARD[args["task"]] = True + await bus.publish(ResourceUpdated(uri="board://sprint")) + return types.CallToolResult(content=[types.TextContent(type="text", text="done")]) server = Server( - "notebook", + "sprint-board", + on_read_resource=read_resource, on_list_tools=list_tools, on_call_tool=call_tool, - on_subscriptions_listen=ListenHandler(bus), + on_subscriptions_listen=listen_handler, ) diff --git a/docs_src/subscriptions/tutorial003.py b/docs_src/subscriptions/tutorial003.py new file mode 100644 index 0000000000..811f6944bd --- /dev/null +++ b/docs_src/subscriptions/tutorial003.py @@ -0,0 +1,30 @@ +from mcp_types import TextResourceContents + +from mcp import Client +from mcp.client.subscriptions import ResourceUpdated, ToolsListChanged + +BOARD = "board://sprint" + + +async def read_board(client: Client, uri: str = BOARD) -> str: + [contents] = (await client.read_resource(uri)).contents + assert isinstance(contents, TextResourceContents) + return contents.text + + +async def follow_board(client: Client) -> None: + async with client.listen(tools_list_changed=True, resource_subscriptions=[BOARD]) as sub: + async for event in sub: + match event: + case ResourceUpdated(uri=uri): + print(await read_board(client, uri)) + case ToolsListChanged(): + tools = await client.list_tools() + print("tools:", [tool.name for tool in tools.tools]) + case _: + pass # kinds the filter did not ask for never arrive + + +async def main() -> None: + async with Client("http://localhost:8000/mcp") as client: + await follow_board(client) diff --git a/docs_src/subscriptions/tutorial004_anyio.py b/docs_src/subscriptions/tutorial004_anyio.py new file mode 100644 index 0000000000..1ca499562b --- /dev/null +++ b/docs_src/subscriptions/tutorial004_anyio.py @@ -0,0 +1,32 @@ +import anyio + +from mcp import Client +from mcp.client.subscriptions import Subscription + +from .tutorial003 import BOARD, read_board + + +async def watch(client: Client, sub: Subscription) -> None: + async for _event in sub: + board = await read_board(client) + print(board) + if "[ ]" not in board: + return # sprint finished: the stream closes when run_sprint leaves the block + + +async def run_sprint(client: Client) -> None: + async with client.listen(resource_subscriptions=[BOARD]) as sub: + print(await read_board(client)) # snapshot: acknowledged, so nothing after this is missed + async with anyio.create_task_group() as tg: + tg.start_soon(watch, client, sub) + for task in ("design", "build", "ship"): + await client.call_tool("complete_task", {"board": "sprint", "task": task}) + + +async def main() -> None: + async with Client("http://localhost:8000/mcp") as client: + await run_sprint(client) + + +if __name__ == "__main__": + anyio.run(main) diff --git a/docs_src/subscriptions/tutorial004_asyncio.py b/docs_src/subscriptions/tutorial004_asyncio.py new file mode 100644 index 0000000000..2e4c685117 --- /dev/null +++ b/docs_src/subscriptions/tutorial004_asyncio.py @@ -0,0 +1,32 @@ +import asyncio + +from mcp import Client +from mcp.client.subscriptions import Subscription + +from .tutorial003 import BOARD, read_board + + +async def watch(client: Client, sub: Subscription) -> None: + async for _event in sub: + board = await read_board(client) + print(board) + if "[ ]" not in board: + return # sprint finished: the stream closes when run_sprint leaves the block + + +async def run_sprint(client: Client) -> None: + async with client.listen(resource_subscriptions=[BOARD]) as sub: + print(await read_board(client)) # snapshot: acknowledged, so nothing after this is missed + watcher = asyncio.create_task(watch(client, sub)) + for task in ("design", "build", "ship"): + await client.call_tool("complete_task", {"board": "sprint", "task": task}) + await watcher # returns once the watcher has seen the finished board + + +async def main() -> None: + async with Client("http://localhost:8000/mcp") as client: + await run_sprint(client) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docs_src/subscriptions/tutorial004_trio.py b/docs_src/subscriptions/tutorial004_trio.py new file mode 100644 index 0000000000..da40715762 --- /dev/null +++ b/docs_src/subscriptions/tutorial004_trio.py @@ -0,0 +1,32 @@ +import trio + +from mcp import Client +from mcp.client.subscriptions import Subscription + +from .tutorial003 import BOARD, read_board + + +async def watch(client: Client, sub: Subscription) -> None: + async for _event in sub: + board = await read_board(client) + print(board) + if "[ ]" not in board: + return # sprint finished: the stream closes when run_sprint leaves the block + + +async def run_sprint(client: Client) -> None: + async with client.listen(resource_subscriptions=[BOARD]) as sub: + print(await read_board(client)) # snapshot: acknowledged, so nothing after this is missed + async with trio.open_nursery() as nursery: + nursery.start_soon(watch, client, sub) + for task in ("design", "build", "ship"): + await client.call_tool("complete_task", {"board": "sprint", "task": task}) + + +async def main() -> None: + async with Client("http://localhost:8000/mcp") as client: + await run_sprint(client) + + +if __name__ == "__main__": + trio.run(main) diff --git a/docs_src/subscriptions/tutorial005.py b/docs_src/subscriptions/tutorial005.py new file mode 100644 index 0000000000..17387fab17 --- /dev/null +++ b/docs_src/subscriptions/tutorial005.py @@ -0,0 +1,20 @@ +import anyio + +from mcp import Client +from mcp.client.subscriptions import SubscriptionLost + +from .tutorial003 import read_board + + +async def keep_following(client: Client) -> None: + while True: + try: + async with client.listen(resource_subscriptions=["board://sprint"]) as sub: + print(await read_board(client)) # refetch: no replay across streams + async for _event in sub: + print(await read_board(client)) + except SubscriptionLost: + pass + # Either ending means the stream is gone. Back off before re-listening: + # a graceful close may be the server shedding load. + await anyio.sleep(1) diff --git a/examples/stories/subscriptions/README.md b/examples/stories/subscriptions/README.md index c7f1a44369..bc309d97ea 100644 --- a/examples/stories/subscriptions/README.md +++ b/examples/stories/subscriptions/README.md @@ -7,16 +7,16 @@ server publishes with `ctx.notify_resource_updated(uri)` / per-stream filtering, subscription-id tagging). Replaces the handshake-era `resources/subscribe` + standalone-GET notification path. -The client edits a note it did not subscribe to (silence), edits the one it -did (a tagged `notifications/resources/updated`), registers a tool at runtime -(`notifications/tools/list_changed`, then re-lists and calls it), and finally -stops listening - cancelling the parked request releases the local task, and -closing the connection ends the stream server-side. +The client opens the stream with `client.listen(...)`, edits a note it did +not subscribe to (silence), edits the one it did (a typed `ResourceUpdated`), +registers a tool at runtime (a typed `ToolsListChanged`, then re-lists and +calls it), and finally leaves the `async with` block, which ends the +subscription while the connection lives on. ## Run it ```bash -# HTTP — the client self-hosts the server on a free port, runs, then tears it +# HTTP: the client self-hosts the server on a free port, runs, then tears it # down (subscriptions/listen is 2026-era only) uv run python -m stories.subscriptions.client --http # same, against the lowlevel-API server variant @@ -25,17 +25,18 @@ uv run python -m stories.subscriptions.client --http --server server_lowlevel ## What to look at -- `client.py` — stream frames arrive as ordinary server notifications via the - constructor-only `message_handler=`. There is no client-side listen API yet, - so opening the stream drops to the `client.session` escape hatch; the request - parks for the stream's lifetime. Cancelling it releases the local task; over - HTTP the server-side stream ends when the connection closes. Every frame's - `_meta["io.modelcontextprotocol/subscriptionId"]` is the listen request's - JSON-RPC id. -- `server.py` — publishing is one `await ctx.notify_*()` line per change; the +- `client.py`: the whole subscription is one context manager, + `async with client.listen(...) as sub`. Entering waits for the server's + acknowledgment, so `sub.honored` is already in hand on the first line of the + block. Events arrive as typed values from `anext(sub)`; the edit to the + unsubscribed note never shows up, because the filter is enforced + server-side. Leaving the block ends the subscription (over HTTP the SDK + closes that request's response stream) and the session carries on, which the + final `search` call proves. +- `server.py`: publishing is one `await ctx.notify_*()` line per change; the filter, the tagging, and the ack ordering are the SDK's job. Publishing with no subscribers is a no-op. -- `server_lowlevel.py` — the same machinery held by hand: an +- `server_lowlevel.py`: the same machinery held by hand: an `InMemorySubscriptionBus`, handlers that `await bus.publish(...)`, and `ListenHandler(bus)` passed as `on_subscriptions_listen=`. A multi-replica deployment swaps the bus for one backed by its own pub/sub @@ -51,10 +52,11 @@ uv run python -m stories.subscriptions.client --http --server server_lowlevel ## Spec -[Subscriptions — basic utilities](https://modelcontextprotocol.io/specification/draft/basic/utilities/subscriptions) +[Subscriptions, basic utilities](https://modelcontextprotocol.io/specification/draft/basic/utilities/subscriptions) ## See also `streaming/` (request-scoped notifications), `events/` (the events extension -on top of this channel, deferred), and `docs/handlers/subscriptions.md` (the -narrative version). +on top of this channel, deferred), and the narrative versions: +`docs/handlers/subscriptions.md` (server) and `docs/client/subscriptions.md` +(client). diff --git a/examples/stories/subscriptions/client.py b/examples/stories/subscriptions/client.py index 379d69bc65..d2053aaf7c 100644 --- a/examples/stories/subscriptions/client.py +++ b/examples/stories/subscriptions/client.py @@ -4,88 +4,34 @@ import mcp_types as types from mcp.client import Client +from mcp.client.subscriptions import ResourceUpdated, ToolsListChanged from stories._harness import Target, run_client -SUBSCRIPTION_ID = "io.modelcontextprotocol/subscriptionId" - async def main(target: Target, *, mode: str = "auto") -> None: - # Stream frames arrive as ordinary server notifications; `message_handler` - # is constructor-only on `Client`, so the list it fills exists first. - received: list[types.ServerNotification] = [] - arrival = anyio.Event() - - async def on_message(message: object) -> None: - nonlocal arrival - if isinstance( - message, - types.SubscriptionsAcknowledgedNotification - | types.ResourceUpdatedNotification - | types.ToolListChangedNotification, - ): - received.append(message) - arrival.set() - arrival = anyio.Event() - - async def wait_for(count: int) -> None: - with anyio.fail_after(10): - while len(received) < count: - await arrival.wait() - - async with Client(target, mode=mode, message_handler=on_message) as client: + async with Client(target, mode=mode) as client: before = await client.list_tools() assert "search" not in {tool.name for tool in before.tools} - async with anyio.create_task_group() as tg: - # There is no client-side listen API yet, so the story drops to the - # `client.session` escape hatch. The request parks for the stream's - # lifetime, so it runs as a task; cancelling it releases the local - # awaiting scope. In-memory that also ends the server's stream; over - # HTTP today nothing aborts the POST, so the server-side stream ends - # when the connection closes (the `Client` exit right below). - async def listen() -> None: - request = types.SubscriptionsListenRequest( - params=types.SubscriptionsListenRequestParams( - notifications=types.SubscriptionFilter( - tools_list_changed=True, resource_subscriptions=["note://todo"] - ) - ) - ) - await client.session.send_request(request, types.SubscriptionsListenResult) - - tg.start_soon(listen) - - # ── the ack is the first frame: it echoes the honored filter, tagged ── - await wait_for(1) - ack = received[0] - assert isinstance(ack, types.SubscriptionsAcknowledgedNotification), ack - assert ack.params.notifications.tools_list_changed is True - assert ack.params.notifications.resource_subscriptions == ["note://todo"] - assert ack.params.meta is not None and SUBSCRIPTION_ID in ack.params.meta + async with client.listen(tools_list_changed=True, resource_subscriptions=["note://todo"]) as sub: + # ── entering waited for the ack: the honored filter is already in hand ── + assert sub.honored.tools_list_changed is True + assert sub.honored.resource_subscriptions == ["note://todo"] # ── exact-URI filtering: an unsubscribed note edit stays silent ── await client.call_tool("edit_note", {"name": "journal", "text": "day two"}) - # ── the subscribed URI delivers, carrying the same subscription id ── + # ── the subscribed URI delivers ── await client.call_tool("edit_note", {"name": "todo", "text": "water plants"}) - await wait_for(2) - updated = received[1] - assert isinstance(updated, types.ResourceUpdatedNotification), updated - assert updated.params.uri == "note://todo" - assert updated.params.meta is not None - assert updated.params.meta[SUBSCRIPTION_ID] == ack.params.meta[SUBSCRIPTION_ID] - assert len(received) == 2, "the journal edit must not have been delivered" + with anyio.fail_after(10): + event = await anext(sub) + assert event == ResourceUpdated(uri="note://todo"), "the journal edit must not have been delivered" # ── a runtime tool registration announces itself ── await client.call_tool("enable_search", {}) - await wait_for(3) - assert isinstance(received[2], types.ToolListChangedNotification), received[2] - - # The client is done listening: cancel the parked request and let - # the connection teardown below end the stream server-side. - tg.cancel_scope.cancel() + with anyio.fail_after(10): + assert await anext(sub) == ToolsListChanged() - # list_changed told us to re-fetch - the new tool is callable, and the - # session outlives the closed stream. + # ── leaving the block closed the stream; the session lives on ── tools = await client.list_tools() assert "search" in {tool.name for tool in tools.tools} result = await client.call_tool("search", {"query": "water"}) diff --git a/mkdocs.yml b/mkdocs.yml index ae0c57f3ca..f40fcb3726 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -54,6 +54,7 @@ nav: - OAuth: client/oauth-clients.md - Identity assertion: client/identity-assertion.md - Multiple servers: client/session-groups.md + - Subscriptions: client/subscriptions.md - Caching: client/caching.md - Protocol versions: protocol-versions.md - Deprecated features: deprecated.md diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index fa78f15ea7..d519106a63 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -6,7 +6,7 @@ import logging import uuid from collections.abc import Awaitable, Callable, Mapping, Sequence -from contextlib import AsyncExitStack +from contextlib import AbstractAsyncContextManager, AsyncExitStack from dataclasses import KW_ONLY, dataclass, field from typing import Any, Literal, TypeVar, cast @@ -58,6 +58,8 @@ SamplingFnT, ) from mcp.client.streamable_http import streamable_http_client +from mcp.client.subscriptions import ServerEvent, Subscription +from mcp.client.subscriptions import listen as _listen from mcp.server import Server from mcp.server.mcpserver import MCPServer from mcp.server.runner import modern_on_request @@ -67,6 +69,7 @@ from mcp.shared.extension import validate_extension_identifier from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher from mcp.shared.session import RequestResponder +from mcp.shared.subscriptions import event_to_notification logger = logging.getLogger(__name__) @@ -666,13 +669,68 @@ async def retry(r: InputResponses | None, s: str | None) -> ReadResourceResult | # Driver rounds carry inputResponses, so a terminal result reached through them is never cached (spec MUST). return await self._drive_input_required(first, retry) + def listen( + self, + *, + tools_list_changed: bool = False, + prompts_list_changed: bool = False, + resources_list_changed: bool = False, + resource_subscriptions: Sequence[str] = (), + ) -> AbstractAsyncContextManager[Subscription]: + """Open a `subscriptions/listen` stream of typed change events (2026-07-28 only). + + Keyword args mirror the wire `SubscriptionFilter`; entering waits for the ack (honored subset: `sub.honored`): + + async with client.listen(tools_list_changed=True) as sub: + async for event in sub: + tools = await client.list_tools() # refetch on change + + A graceful close ends the loop; an abrupt drop raises `SubscriptionLost`. No replay: re-listen and refetch. + + Raises: + ListenNotSupportedError: The negotiated protocol version predates 2026-07-28. + MCPError: The server rejected the request or the connection failed first. + SubscriptionLost: The stream ended before it was acknowledged. + TimeoutError: The read timeout elapsed before the acknowledgment. + """ + return _listen( + self.session, + tools_list_changed=tools_list_changed, + prompts_list_changed=prompts_list_changed, + resources_list_changed=resources_list_changed, + resource_subscriptions=resource_subscriptions, + on_event=self._evict_for_listen_event if self._response_cache is not None else None, + ) + + async def _evict_for_listen_event(self, event: ServerEvent) -> None: + """Finish response-cache eviction before a listen consumer can refetch. + + Without it the iterator wakes first and refetches a still-warm entry, with no + corrective wake (events are deduplicated level triggers). The tee path repeats + the eviction; deliberate: idempotent, and it covers non-iterating consumers. + """ + cache = self._response_cache + assert cache is not None # installed as the event barrier only when a cache exists + try: + await cache.evict_for_notification(event_to_notification(event, {})) + except Exception: # boundary: eviction reaches user store code; a cache fault must not block delivery + logger.exception("Response cache eviction failed; the event is still delivered") + + @deprecated( + "resources/subscribe is removed as of 2026-07-28; use Client.listen() instead.", + category=MCPDeprecationWarning, + ) async def subscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> EmptyResult: - """Subscribe to resource updates.""" - return await self.session.subscribe_resource(uri, meta=meta) + """Subscribe to resource updates (2025-era servers only).""" + return await self.session.subscribe_resource(uri, meta=meta) # pyright: ignore[reportDeprecated] + @deprecated( + "resources/unsubscribe is removed as of 2026-07-28; use Client.listen() instead.", + category=MCPDeprecationWarning, + ) async def unsubscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> EmptyResult: - """Unsubscribe from resource updates.""" - return await self.session.unsubscribe_resource(uri, meta=meta) + """Unsubscribe from resource updates (2025-era servers only).""" + return await self.session.unsubscribe_resource(uri, meta=meta) # pyright: ignore[reportDeprecated] async def call_tool( self, diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 5c09304e42..097ade1c91 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -16,6 +16,7 @@ from mcp_types import ( CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, + CONNECTION_CLOSED, INTERNAL_ERROR, METHOD_NOT_FOUND, PROTOCOL_VERSION_META_KEY, @@ -35,8 +36,9 @@ from mcp.client._transport import ReadStream, WriteStream from mcp.client.extension import NotificationBinding, ResultClaim, UnexpectedClaimedResult +from mcp.client.subscriptions import ListenRoute from mcp.shared._compat import resync_tracer -from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, ProgressFnT +from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, ProgressFnT, as_request_id from mcp.shared.exceptions import MCPDeprecationWarning, MCPError from mcp.shared.inbound import ( MCP_METHOD_HEADER, @@ -48,9 +50,10 @@ mcp_param_headers, x_mcp_header_map, ) -from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher +from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher, cancelled_request_id_from_params from mcp.shared.message import ClientMessageMetadata, SessionMessage from mcp.shared.session import RequestResponder +from mcp.shared.subscriptions import SUBSCRIPTION_ID_META_KEY, event_from_wire from mcp.shared.transport_context import TransportContext DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0") @@ -360,6 +363,8 @@ def __init__( self._negotiated_version: str | None = None self._stamp: Callable[[dict[str, Any], CallOptions], None] = _preconnect_stamp self._task_group: anyio.abc.TaskGroup | None = None + # subscriptions/listen demux routes; membership decides ack consumption (raw listens are never registered) + self._listen_routes: dict[RequestId, ListenRoute] = {} 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") @@ -388,7 +393,9 @@ async def __aenter__(self) -> Self: for binding in self._notification_bindings.values(): send, receive = anyio.create_memory_object_stream[BaseModel](_NOTIFICATION_QUEUE_SIZE) self._binding_queues[binding.method] = (send, receive) - await self._task_group.start(self._dispatcher.run, self._on_request, self._on_notify) + await self._task_group.start( + self._dispatcher.run, self._on_request, self._on_notify, self._intercept_notification + ) for binding in self._notification_bindings.values(): _, receive = self._binding_queues[binding.method] self._task_group.start_soon(self._deliver_bound_notifications, binding, receive) @@ -422,6 +429,7 @@ async def __aexit__( result = await self._task_group.__aexit__(exc_type, exc_val, exc_tb) finally: self._close_binding_queues() + self._settle_listen_routes_closed() await resync_tracer() return result @@ -859,15 +867,23 @@ async def read_resource( raise _input_required_unexpected("read_resource") return result + @deprecated( + "resources/subscribe is removed as of 2026-07-28; use Client.listen() instead.", + category=MCPDeprecationWarning, + ) async def subscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult: - """Send a resources/subscribe request.""" + """Send a resources/subscribe request (2025-era servers only).""" return await self.send_request( types.SubscribeRequest(params=types.SubscribeRequestParams(uri=uri, _meta=meta)), types.EmptyResult, ) + @deprecated( + "resources/unsubscribe is removed as of 2026-07-28; use Client.listen() instead.", + category=MCPDeprecationWarning, + ) async def unsubscribe_resource(self, uri: str, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult: - """Send a resources/unsubscribe request.""" + """Send a resources/unsubscribe request (2025-era servers only).""" return await self.send_request( types.UnsubscribeRequest(params=types.UnsubscribeRequestParams(uri=uri, _meta=meta)), types.EmptyResult, @@ -1225,6 +1241,62 @@ async def dispatch_input_request( case types.ListRootsRequest(): # pragma: no branch return await self._list_roots_callback(ctx) + def _register_listen_route(self, request_id: RequestId) -> ListenRoute: + """Create the demux route for a listen request id; the caller registers BEFORE sending.""" + route = ListenRoute() + self._listen_routes[request_id] = route + return route + + def _unregister_listen_route(self, request_id: RequestId) -> None: + """Drop a listen route; the handle owns membership, so a missing key is a no-op.""" + self._listen_routes.pop(request_id, None) + + def _settle_listen_routes_closed(self) -> None: + """Settle all open listen routes as lost on session exit; cancelled driver tasks cannot.""" + closed = MCPError(code=CONNECTION_CLOSED, message="Connection closed") + for route in self._listen_routes.values(): + route.settle("lost", error=closed) + self._listen_routes.clear() + + def _intercept_notification(self, method: str, params: Mapping[str, Any] | None) -> bool: + """Wire-order listen demux, run synchronously on the dispatcher's receive path. + + Bookkeeping must advance in receive order with the listen result (resolved on + this same path); the spawned `_on_notify` path would race it and drop events. + Returns True to consume the frame: a live route's ack is driver state, never surfaced. + """ + if not self._listen_routes: + return False + if method == "notifications/cancelled": + request_id = cancelled_request_id_from_params(params) + if request_id is not None and (listen_route := self._listen_routes.get(request_id)) is not None: + # a server-sent cancel naming a listen request is that stream's teardown signal + listen_route.settle("lost") + return False # _on_notify swallows every cancelled either way (v1 parity) + if params is None: + return False + meta = params.get("_meta") + if not isinstance(meta, Mapping): + return False + # as_request_id is not a tripwire: raw wire _meta can carry a non-id (even unhashable) value + subscription_id = as_request_id(cast("Mapping[str, Any]", meta).get(SUBSCRIPTION_ID_META_KEY)) + if subscription_id is None or (listen_route := self._listen_routes.get(subscription_id)) is None: + return False + if method == "notifications/subscriptions/acknowledged": + raw_filter = params.get("notifications") + if raw_filter is None: + # malformed, not an empty filter: leave it to the spawned path's validation warning + return False + try: + honored = types.SubscriptionFilter.model_validate(raw_filter) + except ValidationError: + return False + listen_route.set_acked(honored) + return True + if (event := event_from_wire(method, params)) is not None: + listen_route.deliver(event) + return False # events (and any other stamped frame) still tee as usual + async def _on_notify( self, dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None ) -> None: @@ -1259,7 +1331,7 @@ async def _on_notify( logger.warning("Failed to validate notification: %s", method, exc_info=True) return if isinstance(notification, types.CancelledNotification): - # The dispatcher already applied the cancellation; not surfaced to message_handler. + # Never surfaced (v1 parity): the dispatcher already applied it; listen cancels settled by the intercept. return try: if isinstance(notification, types.LoggingMessageNotification): diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index 09e5048cc7..5c3501d8c4 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -13,6 +13,7 @@ from anyio.abc import TaskGroup from httpx_sse import EventSource, ServerSentEvent, aconnect_sse from mcp_types import ( + CONNECTION_CLOSED, INTERNAL_ERROR, INVALID_REQUEST, METHOD_NOT_FOUND, @@ -327,6 +328,15 @@ async def _handle_post_request(self, ctx: RequestContext) -> None: ) as response: if response.status_code == 202: logger.debug("Received 202 Accepted") + if isinstance(message, JSONRPCRequest): + # A request's response arrives on this POST's body; 202 says + # none will follow. Resolve rather than park the caller forever. + await self._resolve_abandoned_request( + ctx.read_stream_writer, + message.id, + "server answered a request with 202 Accepted", + code=INVALID_REQUEST, + ) return if response.status_code >= 400: @@ -438,9 +448,29 @@ async def _handle_sse_response( logger.debug("SSE stream ended", exc_info=True) # pragma: lax no cover # Stream ended without response - reconnect if we received an event with ID - if last_event_id is not None: # pragma: no branch + if last_event_id is not None: logger.info("SSE stream disconnected, reconnecting...") await self._handle_reconnection(ctx, last_event_id, retry_interval_ms) + else: + # Not resumable: resolve the waiter, else a listen stream's consumer + # would hang forever instead of learning the subscription is lost. + await self._resolve_abandoned_request( + ctx.read_stream_writer, original_request_id, "SSE stream ended without a response" + ) + + async def _resolve_abandoned_request( + self, read_stream_writer: StreamWriter, request_id: RequestId, message: str, *, code: int = CONNECTION_CLOSED + ) -> None: + """Resolve a request whose response can never arrive with a synthesized error. + + Best-effort: a closed read stream means the session is tearing down. + """ + error_data = ErrorData(code=code, message=message) + error_msg = SessionMessage(JSONRPCError(jsonrpc="2.0", id=request_id, error=error_data)) + try: + await read_stream_writer.send(error_msg) + except (anyio.BrokenResourceError, anyio.ClosedResourceError): + logger.debug("read stream closed before request %r could be resolved", request_id) async def _handle_reconnection( self, @@ -450,9 +480,17 @@ async def _handle_reconnection( attempt: int = 0, ) -> None: """Reconnect with Last-Event-ID to resume stream after server disconnect.""" - # Bail if max retries exceeded - if attempt >= MAX_RECONNECTION_ATTEMPTS: # pragma: no cover + # Only requests reconnect: every caller arrives from a request's response stream. + assert isinstance(ctx.session_message.message, JSONRPCRequest) + original_request_id = ctx.session_message.message.id + + if attempt >= MAX_RECONNECTION_ATTEMPTS: + # Resolve on give-up: a request with no read timeout (a listen + # stream) would otherwise hang its caller forever. logger.debug(f"Max reconnection attempts ({MAX_RECONNECTION_ATTEMPTS}) exceeded") + await self._resolve_abandoned_request( + ctx.read_stream_writer, original_request_id, "SSE stream ended and reconnection attempts were exhausted" + ) return # Always wait - use server value or default @@ -462,11 +500,6 @@ async def _handle_reconnection( headers = self._prepare_headers() headers[LAST_EVENT_ID] = last_event_id - # Extract original request ID to map responses - original_request_id = None - if isinstance(ctx.session_message.message, JSONRPCRequest): # pragma: no branch - original_request_id = ctx.session_message.message.id - try: async with aconnect_sse(ctx.client, "GET", self.url, headers=headers) as event_source: event_source.response.raise_for_status() @@ -564,6 +597,12 @@ async def handle_request_async(): scope=anyio.CancelScope(), modern=self._protocol_version_header in MODERN_PROTOCOL_VERSIONS, ) + superseded = self._in_flight_posts.get(message.id) + if superseded is not None: + # A reused id means the waiter belongs to this attempt now: + # sever the old POST so its zombie stream cannot answer, + # fail, or resolve the successor's request. + superseded.scope.cancel() self._in_flight_posts[message.id] = post tg.start_soon(self._run_request_post, handle_request_async, post, message.id) else: diff --git a/src/mcp/client/subscriptions.py b/src/mcp/client/subscriptions.py new file mode 100644 index 0000000000..27283909be --- /dev/null +++ b/src/mcp/client/subscriptions.py @@ -0,0 +1,282 @@ +"""Client-side `subscriptions/listen` driver (2026-07-28, SEP-2575). + +`listen()` opens the stream as an async context manager: entering waits for +the server's acknowledgment, iteration yields typed change events, a graceful +server close ends the loop, and an abrupt drop raises `SubscriptionLost`. +There is no replay and no automatic re-listen: a client that re-opens a +subscription refetches what it depends on. +""" + +from __future__ import annotations + +from collections.abc import AsyncIterator, Awaitable, Callable, Sequence +from contextlib import asynccontextmanager +from itertools import count +from typing import TYPE_CHECKING, Literal + +import anyio +import mcp_types as types +from mcp_types.version import MODERN_PROTOCOL_VERSIONS + +from mcp.shared.dispatcher import CallOptions +from mcp.shared.exceptions import MCPError +from mcp.shared.subscriptions import ( + PromptsListChanged, + ResourcesListChanged, + ResourceUpdated, + ServerEvent, + ToolsListChanged, + event_matches, +) + +if TYPE_CHECKING: + from mcp.client.session import ClientSession + +__all__ = [ + "ListenNotSupportedError", + "OnEvent", + "PromptsListChanged", + "ResourceUpdated", + "ResourcesListChanged", + "ServerEvent", + "Subscription", + "SubscriptionLost", + "ToolsListChanged", + "listen", +] + +_listen_ids = count(1) +"""Process-wide `listen-N` sequence: string ids can never collide with a dispatcher's minted ints.""" + +_MAX_PENDING_EVENTS = 1024 +"""Backlog backstop: the spec allows sub-resource URIs, so distinct pending +`ResourceUpdated` events are unbounded; overflowing this cap settles the +subscription lost rather than growing client memory.""" + +_SubscriptionEnd = Literal["graceful", "lost", "local"] + + +class ListenNotSupportedError(RuntimeError): + """`subscriptions/listen` requires a 2026-07-28 connection.""" + + def __init__(self, negotiated_version: str | None) -> None: + self.negotiated_version = negotiated_version + super().__init__( + f"subscriptions/listen is not available at protocol version {negotiated_version!r}; it requires " + "2026-07-28. On earlier versions use subscribe_resource() and the change notifications delivered " + "through message_handler." + ) + + +class SubscriptionLost(RuntimeError): + """The stream ended without the server's graceful close; re-listen and refetch.""" + + +class ListenRoute: + """Package-internal demux state for one listen stream, fed synchronously in receive order by the session.""" + + def __init__(self) -> None: + self.honored: types.SubscriptionFilter | None = None + self.acked = anyio.Event() + self.error: MCPError | None = None + self.end: _SubscriptionEnd | None = None + self._honored_uris: frozenset[str] = frozenset() + self._pending: dict[ServerEvent, None] = {} + self._wake = anyio.Event() + + def set_acked(self, honored: types.SubscriptionFilter) -> None: + """Record the acknowledged filter; the first ack wins.""" + if not self.acked.is_set(): + self.honored = honored + self._honored_uris = frozenset(honored.resource_subscriptions or ()) + self.acked.set() + + def deliver(self, event: ServerEvent) -> None: + """Queue an event within the honored filter, deduplicated against the backlog. + + Any `ResourceUpdated` is admitted once URI subscriptions were honored at + all: the spec allows the stamped URI to be a sub-resource of a subscribed one. + """ + if self.end is not None or self.honored is None: + return + if isinstance(event, ResourceUpdated): + admitted = bool(self._honored_uris) + else: + admitted = event_matches(self.honored, self._honored_uris, event) + if not admitted or event in self._pending: + return + if len(self._pending) >= _MAX_PENDING_EVENTS: + self.settle( + "lost", + error=MCPError( + types.INTERNAL_ERROR, + f"subscription backlog exceeded {_MAX_PENDING_EVENTS} unconsumed events; re-listen and refetch", + ), + ) + return + self._pending[event] = None + self._wake.set() + + def settle(self, end: _SubscriptionEnd, error: MCPError | None = None) -> None: + """Record the stream's end; the first reason wins and wakes both waiters.""" + if self.end is None: + self.end = end + self.error = error + self.acked.set() + self._wake.set() + + async def next_event(self) -> ServerEvent | _SubscriptionEnd: + """Peek the next pending event, or the stream's end once the backlog drains. + + A "local" end short-circuits the backlog; the other endings drain it first, + so a graceful close never swallows events that preceded it. + """ + while True: + # Snapshot the wake event before checking state so a deliver landing after the checks cannot be missed. + wake = self._wake + if self.end == "local": + return self.end + if self._pending: + return next(iter(self._pending)) + if self.end is not None: + return self.end + await wake.wait() + self._wake = anyio.Event() + + def consume(self, event: ServerEvent) -> None: + """Remove a peeked event from the backlog.""" + self._pending.pop(event, None) + + +OnEvent = Callable[[ServerEvent], Awaitable[None]] +"""Per-event barrier awaited before a `Subscription` returns each event to its consumer.""" + + +class Subscription: + """One open `subscriptions/listen` stream: an async iterator of typed events. + + Produced by `listen()` / `Client.listen()`, not constructed directly. + """ + + def __init__( + self, + route: ListenRoute, + subscription_id: types.RequestId, + honored: types.SubscriptionFilter, + on_event: OnEvent | None = None, + ): + self._route = route + self._on_event = on_event + self.subscription_id = subscription_id + """The listen request's JSON-RPC id, stamped into every frame's `_meta`.""" + self.honored = honored + """The subset of the requested filter the server agreed to deliver.""" + + def __aiter__(self) -> Subscription: + return self + + async def __anext__(self) -> ServerEvent: + """Yield the next change event; the loop ends when the stream does. + + Raises: + SubscriptionLost: the stream dropped without the server's graceful close. + """ + outcome = await self._route.next_event() + if isinstance(outcome, str): + if outcome == "lost": + raise SubscriptionLost( + f"subscription {self.subscription_id!r} ended without the server's graceful close;" + " re-listen and refetch" + ) from self._route.error + raise StopAsyncIteration + if self._on_event is not None: + # The event stays pending while the barrier runs: a cancellation or a + # raising barrier leaves it for the next anext instead of dropping it. + await self._on_event(outcome) + self._route.consume(outcome) + return outcome + + +@asynccontextmanager +async def listen( + session: ClientSession, + *, + tools_list_changed: bool = False, + prompts_list_changed: bool = False, + resources_list_changed: bool = False, + resource_subscriptions: Sequence[str] = (), + on_event: OnEvent | None = None, +) -> AsyncIterator[Subscription]: + """Open one `subscriptions/listen` stream on `session` (2026-07-28 only). + + Entering sends the request and returns once the server's acknowledgment + arrives; exiting ends the subscription. `on_event` is awaited before each + event is returned - the seam `Client.listen` uses to finish cache eviction + before the consumer can refetch. + + Raises: + ListenNotSupportedError: negotiated version predates 2026-07-28. + MCPError: the server rejected the request, or the connection failed pre-ack. + SubscriptionLost: the stream ended before it was acknowledged. + TimeoutError: the session's read timeout elapsed before the acknowledgment. + """ + if session.protocol_version not in MODERN_PROTOCOL_VERSIONS: + raise ListenNotSupportedError(session.protocol_version) + if isinstance(resource_subscriptions, str): + raise TypeError("resource_subscriptions takes a sequence of URIs, not a bare string") + request = types.SubscriptionsListenRequest( + params=types.SubscriptionsListenRequestParams( + notifications=types.SubscriptionFilter( + tools_list_changed=tools_list_changed or None, + prompts_list_changed=prompts_list_changed or None, + resources_list_changed=resources_list_changed or None, + resource_subscriptions=list(resource_subscriptions) or None, + ) + ) + ) + task_group = session._task_group # pyright: ignore[reportPrivateUsage] + if task_group is None: + raise RuntimeError("listen() requires an entered session") + request_id: types.RequestId = f"listen-{next(_listen_ids)}" + data = request.model_dump(by_alias=True, mode="json", exclude_none=True) + opts: CallOptions = {"request_id": request_id} + session._stamp(data, opts) # pyright: ignore[reportPrivateUsage] + driver_scope = anyio.CancelScope() + + async def drive() -> None: + # Deliberately no result timeout: the response arrives when the stream ends. + with driver_scope: + try: + await session._dispatcher.send_raw_request( # pyright: ignore[reportPrivateUsage] + data["method"], data.get("params"), opts + ) + except MCPError as error: + route.settle("lost", error=error) + return + except ValueError as error: + # A raw request id collided with our minted listen id: fail this subscription + # and release the route in this same slice, so it cannot consume the raw caller's ack. + session._unregister_listen_route(request_id) # pyright: ignore[reportPrivateUsage] + route.settle("lost", error=MCPError(types.INTERNAL_ERROR, str(error))) + return + # A result, whatever its body, is the spec's graceful close; with no prior ack + # it opens the subscription already closed. + route.set_acked(types.SubscriptionFilter()) + route.settle("graceful") + + # Register the demux route before the request is written so the ack cannot race it. + route = session._register_listen_route(request_id) # pyright: ignore[reportPrivateUsage] + try: + task_group.start_soon(drive) + with anyio.fail_after(session._session_read_timeout_seconds): # pyright: ignore[reportPrivateUsage] + await route.acked.wait() + if route.honored is None: + # Only reachable on failure paths: a graceful no-ack result acked an empty filter in drive(). + if route.error is not None: + raise route.error + raise SubscriptionLost(f"subscription {request_id!r} ended before it was acknowledged") + yield Subscription(route, request_id, route.honored, on_event) + finally: + route.settle("local") + driver_scope.cancel() + session._unregister_listen_route(request_id) # pyright: ignore[reportPrivateUsage] diff --git a/src/mcp/server/mcpserver/context.py b/src/mcp/server/mcpserver/context.py index 28d06761d3..2b7fdf35ee 100644 --- a/src/mcp/server/mcpserver/context.py +++ b/src/mcp/server/mcpserver/context.py @@ -16,14 +16,14 @@ elicit_with_validation, ) from mcp.server.lowlevel.helper_types import ReadResourceContents -from mcp.server.subscriptions import ( +from mcp.server.subscriptions import SubscriptionBus +from mcp.shared.exceptions import MCPDeprecationWarning +from mcp.shared.subscriptions import ( PromptsListChanged, ResourcesListChanged, ResourceUpdated, - SubscriptionBus, ToolsListChanged, ) -from mcp.shared.exceptions import MCPDeprecationWarning if TYPE_CHECKING: from mcp.server.mcpserver.server import MCPServer diff --git a/src/mcp/server/subscriptions.py b/src/mcp/server/subscriptions.py index d071cfdbf4..6b0b3d49b5 100644 --- a/src/mcp/server/subscriptions.py +++ b/src/mcp/server/subscriptions.py @@ -13,6 +13,8 @@ `MCPServer` registers one automatically; lowlevel `Server` users pass an instance as `on_subscriptions_listen=`. +The event vocabulary lives in `mcp.shared.subscriptions`, shared with the client driver, and is re-exported here. + Per the spec, the handler acknowledges first (the ack is the first frame on the stream), tags every frame with the listen request's JSON-RPC id under `_meta["io.modelcontextprotocol/subscriptionId"]`, and never delivers an @@ -24,7 +26,6 @@ import logging from collections.abc import Callable -from dataclasses import dataclass from typing import Any, Protocol import anyio @@ -33,56 +34,39 @@ from mcp_types import ( INTERNAL_ERROR, INVALID_REQUEST, - NotificationParams, - PromptListChangedNotification, - ResourceListChangedNotification, - ResourceUpdatedNotification, - ResourceUpdatedNotificationParams, - ServerNotification, SubscriptionFilter, SubscriptionsAcknowledgedNotification, SubscriptionsAcknowledgedNotificationParams, SubscriptionsListenRequestParams, SubscriptionsListenResult, - ToolListChangedNotification, ) from mcp.server.context import ServerRequestContext from mcp.shared.exceptions import MCPError +from mcp.shared.subscriptions import ( + SUBSCRIPTION_ID_META_KEY, + PromptsListChanged, + ResourcesListChanged, + ResourceUpdated, + ServerEvent, + ToolsListChanged, + event_matches, + event_to_notification, +) -logger = logging.getLogger(__name__) - -SUBSCRIPTION_ID_META_KEY = "io.modelcontextprotocol/subscriptionId" -"""The `_meta` key carrying the subscription id on every listen-stream frame. - -The value is the `subscriptions/listen` request's JSON-RPC id, verbatim. -""" - - -@dataclass(frozen=True) -class ToolsListChanged: - """The server's tool list changed.""" - - -@dataclass(frozen=True) -class PromptsListChanged: - """The server's prompt list changed.""" - - -@dataclass(frozen=True) -class ResourcesListChanged: - """The server's resource list changed.""" - - -@dataclass(frozen=True) -class ResourceUpdated: - """The resource at `uri` changed and may need to be read again.""" - - uri: str - +__all__ = [ + "SUBSCRIPTION_ID_META_KEY", + "InMemorySubscriptionBus", + "ListenHandler", + "PromptsListChanged", + "ResourceUpdated", + "ResourcesListChanged", + "ServerEvent", + "SubscriptionBus", + "ToolsListChanged", +] -ServerEvent = ToolsListChanged | PromptsListChanged | ResourcesListChanged | ResourceUpdated -"""An event a server publishes for delivery to listen subscribers.""" +logger = logging.getLogger(__name__) class SubscriptionBus(Protocol): @@ -170,32 +154,6 @@ def _honored_subset(requested: SubscriptionFilter) -> SubscriptionFilter: ) -def _event_matches(honored: SubscriptionFilter, uris: frozenset[str], event: ServerEvent) -> bool: - """Whether `event` is within the stream's honored filter. - - `uris` is the honored `resource_subscriptions` as a set: matching runs on - every publish, and the wire filter may name many URIs. - """ - if isinstance(event, ToolsListChanged): - return honored.tools_list_changed is True - if isinstance(event, PromptsListChanged): - return honored.prompts_list_changed is True - if isinstance(event, ResourcesListChanged): - return honored.resources_list_changed is True - return event.uri in uris - - -def _event_to_notification(event: ServerEvent, meta: dict[str, Any]) -> ServerNotification: - """Build the stamped wire notification for `event`.""" - if isinstance(event, ToolsListChanged): - return ToolListChangedNotification(params=NotificationParams(_meta=meta)) - if isinstance(event, PromptsListChanged): - return PromptListChangedNotification(params=NotificationParams(_meta=meta)) - if isinstance(event, ResourcesListChanged): - return ResourceListChangedNotification(params=NotificationParams(_meta=meta)) - return ResourceUpdatedNotification(params=ResourceUpdatedNotificationParams(uri=event.uri, _meta=meta)) - - class ListenHandler: """Serves `subscriptions/listen`: one call is one subscription stream. @@ -244,7 +202,7 @@ async def __call__( send, recv = anyio.create_memory_object_stream[ServerEvent](self._max_buffered_events) def deliver(event: ServerEvent) -> None: - if _event_matches(honored, honored_uris, event): + if event_matches(honored, honored_uris, event): try: send.send_nowait(event) except anyio.ClosedResourceError: @@ -273,7 +231,7 @@ def deliver(event: ServerEvent) -> None: ) async for event in recv: await ctx.session.send_notification( - _event_to_notification(event, meta), related_request_id=subscription_id + event_to_notification(event, meta), related_request_id=subscription_id ) finally: _safe_unsubscribe(unsubscribe) diff --git a/src/mcp/shared/direct_dispatcher.py b/src/mcp/shared/direct_dispatcher.py index 62c74b808e..e17283afa2 100644 --- a/src/mcp/shared/direct_dispatcher.py +++ b/src/mcp/shared/direct_dispatcher.py @@ -28,7 +28,15 @@ from pydantic import ValidationError from mcp.shared._compat import resync_tracer -from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest, ProgressFnT, coerce_request_id +from mcp.shared.dispatcher import ( + CallOptions, + OnNotify, + OnNotifyIntercept, + OnRequest, + ProgressFnT, + coerce_request_id, + run_notify_intercept, +) from mcp.shared.exceptions import MCPError, NoBackChannelError from mcp.shared.message import MessageMetadata from mcp.shared.transport_context import TransportContext @@ -106,6 +114,7 @@ def __init__(self, transport_ctx: TransportContext, *, raise_handler_exceptions: self._peer: DirectDispatcher | None = None self._on_request: OnRequest | None = None self._on_notify: OnNotify | None = None + self._on_notify_intercept: OnNotifyIntercept | None = None self._next_id = 0 self._in_flight_ids: set[RequestId] = set() self._ready = anyio.Event() @@ -158,6 +167,7 @@ async def run( self, on_request: OnRequest, on_notify: OnNotify, + on_notify_intercept: OnNotifyIntercept | None = None, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ) -> None: @@ -169,6 +179,7 @@ async def run( try: self._on_request = on_request self._on_notify = on_notify + self._on_notify_intercept = on_notify_intercept self._running = True self._ready.set() task_status.started() @@ -286,6 +297,8 @@ async def _dispatch_notify(self, method: str, params: Mapping[str, Any] | None) # dropped, not raised back into the sender's call. logger.debug("dropped notification %r to closed DirectDispatcher", method) return + if run_notify_intercept(self._on_notify_intercept, method, params): + return assert self._on_notify is not None dctx = self._make_context() await self._on_notify(dctx, method, params) diff --git a/src/mcp/shared/dispatcher.py b/src/mcp/shared/dispatcher.py index 16360d3142..f109638f2a 100644 --- a/src/mcp/shared/dispatcher.py +++ b/src/mcp/shared/dispatcher.py @@ -16,6 +16,7 @@ embedding a server in-process. """ +import logging from collections.abc import Awaitable, Callable, Mapping from typing import Any, Protocol, TypedDict, TypeVar, runtime_checkable @@ -26,20 +27,32 @@ from mcp.shared.message import MessageMetadata from mcp.shared.transport_context import TransportContext +logger = logging.getLogger(__name__) + __all__ = [ "CallOptions", "DispatchContext", "Dispatcher", "OnNotify", + "OnNotifyIntercept", "OnRequest", "Outbound", "ProgressFnT", + "as_request_id", "coerce_request_id", + "run_notify_intercept", ] TransportT_co = TypeVar("TransportT_co", bound=TransportContext, covariant=True) +def as_request_id(value: object) -> RequestId | None: + """Narrow an untyped wire value to a `RequestId`, or None; rejects bool (True would alias request id 1).""" + if isinstance(value, str | int) and not isinstance(value, bool): + return value + return None + + def coerce_request_id(request_id: RequestId) -> RequestId: """Coerce a stringified int request id back to int so a peer-echoed id still correlates (matches the TS SDK). @@ -211,6 +224,25 @@ async def progress(self, progress: float, total: float | None = None, message: s OnNotify = Callable[[DispatchContext[TransportContext], str, Mapping[str, Any] | None], Awaitable[None]] """Handler for inbound notifications: `(ctx, method, params)`.""" +OnNotifyIntercept = Callable[[str, Mapping[str, Any] | None], bool] +"""Synchronous receive-order intercept for inbound notifications: `(method, params) -> consumed`. + +Runs before `on_notify` is scheduled so correlation state advances in wire order +relative to response resolution (the client's listen demux depends on this). +Returning True consumes the notification. Must not block the receive path. +""" + + +def run_notify_intercept(intercept: OnNotifyIntercept | None, method: str, params: Mapping[str, Any] | None) -> bool: + """Invoke `intercept`, containing a raise to that one notification (never the receive loop).""" + if intercept is None: + return False + try: + return intercept(method, params) + except Exception: + logger.exception("notification intercept raised; passing %r through", method) + return False + class Dispatcher(Outbound, Protocol[TransportT_co]): """A duplex request/notification channel with call-return semantics. @@ -225,6 +257,7 @@ async def run( self, on_request: OnRequest, on_notify: OnNotify, + on_notify_intercept: OnNotifyIntercept | None = None, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ) -> None: @@ -232,7 +265,9 @@ async def run( Each inbound request is dispatched to `on_request` in its own task; the returned dict (or raised `MCPError`) is sent back as the response. - Inbound notifications go to `on_notify`. + Implementations MUST offer every inbound notification to + `on_notify_intercept` synchronously in receive order (via + `run_notify_intercept`), handing only unconsumed ones to `on_notify`. `task_status.started()` is called once the dispatcher is ready to accept `send_request`/`notify` calls, so callers can use diff --git a/src/mcp/shared/jsonrpc_dispatcher.py b/src/mcp/shared/jsonrpc_dispatcher.py index 793c59bc7b..42798fdc54 100644 --- a/src/mcp/shared/jsonrpc_dispatcher.py +++ b/src/mcp/shared/jsonrpc_dispatcher.py @@ -44,9 +44,12 @@ DispatchContext, Dispatcher, OnNotify, + OnNotifyIntercept, OnRequest, ProgressFnT, + as_request_id, coerce_request_id, + run_notify_intercept, ) from mcp.shared.exceptions import MCPError, NoBackChannelError from mcp.shared.message import ( @@ -107,12 +110,8 @@ def progress_token_from_params(params: Mapping[str, Any] | None) -> ProgressToke def cancelled_request_id_from_params(params: Mapping[str, Any] | None) -> RequestId | None: - """Read `params.requestId` from a `notifications/cancelled`; reject bool (True would alias request id 1).""" - match params: - case {"requestId": str() | int() as request_id} if not isinstance(request_id, bool): - return request_id - case _: - return None + """Read `params.requestId` from a `notifications/cancelled` (`as_request_id` shape rules).""" + return as_request_id((params or {}).get("requestId")) @dataclass(slots=True) @@ -297,6 +296,7 @@ def __init__( self._next_id = 0 self._pending: dict[RequestId, _Pending] = {} self._in_flight: dict[RequestId, _InFlight[TransportT]] = {} + self._on_notify_intercept: OnNotifyIntercept | None = None self._tg: anyio.abc.TaskGroup | None = None self._running = False self._closed = False @@ -466,6 +466,7 @@ async def run( self, on_request: OnRequest, on_notify: OnNotify, + on_notify_intercept: OnNotifyIntercept | None = None, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ) -> None: @@ -474,6 +475,7 @@ async def run( `task_status.started()` fires once `send_raw_request` is usable. Single-shot: once the loop ends the dispatcher stays closed and cannot be restarted. """ + self._on_notify_intercept = on_notify_intercept try: # LIFO exits: the write stream closes only after the task-group join, so teardown writes still land. async with self._write_stream: @@ -603,7 +605,9 @@ def _dispatch_notification( `notifications/cancelled` and `notifications/progress` are intercepted here (they correlate against the `_in_flight`/`_pending` tables this - layer owns) and still teed to `on_notify` afterwards. + layer owns) and still teed to `on_notify` afterwards. The caller's + `on_notify_intercept` then runs in receive order; only unconsumed + notifications reach the spawned `on_notify`. """ if msg.method == "notifications/cancelled": rid = cancelled_request_id_from_params(msg.params) @@ -630,6 +634,8 @@ def _dispatch_notification( ) case _: pass + if run_notify_intercept(self._on_notify_intercept, msg.method, msg.params): + return try: transport_ctx = self._transport_builder(metadata) except Exception: diff --git a/src/mcp/shared/subscriptions.py b/src/mcp/shared/subscriptions.py new file mode 100644 index 0000000000..ba50917fa4 --- /dev/null +++ b/src/mcp/shared/subscriptions.py @@ -0,0 +1,106 @@ +"""Typed event vocabulary for `subscriptions/listen` (2026-07-28, SEP-2575), shared by server and client. + +Every event is a level trigger ("this changed, refetch if you care"), so both sides bound buffers by dedupe. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any + +from mcp_types import ( + NotificationParams, + PromptListChangedNotification, + ResourceListChangedNotification, + ResourceUpdatedNotification, + ResourceUpdatedNotificationParams, + ServerNotification, + SubscriptionFilter, + ToolListChangedNotification, +) + +__all__ = [ + "SUBSCRIPTION_ID_META_KEY", + "PromptsListChanged", + "ResourceUpdated", + "ResourcesListChanged", + "ServerEvent", + "ToolsListChanged", + "event_from_wire", + "event_matches", + "event_to_notification", +] + +SUBSCRIPTION_ID_META_KEY = "io.modelcontextprotocol/subscriptionId" +"""The `_meta` key on every listen-stream frame; the value is the `subscriptions/listen` request's JSON-RPC id.""" + + +@dataclass(frozen=True) +class ToolsListChanged: + """The server's tool list changed.""" + + +@dataclass(frozen=True) +class PromptsListChanged: + """The server's prompt list changed.""" + + +@dataclass(frozen=True) +class ResourcesListChanged: + """The server's resource list changed.""" + + +@dataclass(frozen=True) +class ResourceUpdated: + """The resource at `uri` changed and may need to be read again.""" + + uri: str + + +ServerEvent = ToolsListChanged | PromptsListChanged | ResourcesListChanged | ResourceUpdated +"""An event a server publishes for delivery to listen subscribers.""" + + +def event_to_notification(event: ServerEvent, meta: dict[str, Any]) -> ServerNotification: + """Build the stamped wire notification for `event` (the server's direction).""" + if isinstance(event, ToolsListChanged): + return ToolListChangedNotification(params=NotificationParams(_meta=meta)) + if isinstance(event, PromptsListChanged): + return PromptListChangedNotification(params=NotificationParams(_meta=meta)) + if isinstance(event, ResourcesListChanged): + return ResourceListChangedNotification(params=NotificationParams(_meta=meta)) + return ResourceUpdatedNotification(params=ResourceUpdatedNotificationParams(uri=event.uri, _meta=meta)) + + +_LIST_CHANGED_EVENTS: dict[str, ServerEvent] = { + "notifications/tools/list_changed": ToolsListChanged(), + "notifications/prompts/list_changed": PromptsListChanged(), + "notifications/resources/list_changed": ResourcesListChanged(), +} + + +def event_from_wire(method: str, params: Mapping[str, Any] | None) -> ServerEvent | None: + """The event a raw listen-stream frame announces, or None if it carries none. + + Takes the raw wire dict: the client demultiplexes before the typed notification parse.""" + if (event := _LIST_CHANGED_EVENTS.get(method)) is not None: + return event + if method == "notifications/resources/updated": + uri = (params or {}).get("uri") + if isinstance(uri, str): + return ResourceUpdated(uri=uri) + return None + + +def event_matches(honored: SubscriptionFilter, uris: frozenset[str], event: ServerEvent) -> bool: + """Whether `event` is within the stream's honored filter (`uris`: the honored resource subscriptions as a set). + + The admission predicate both sides share: server delivery and client intake honor only what was acknowledged.""" + if isinstance(event, ToolsListChanged): + return honored.tools_list_changed is True + if isinstance(event, PromptsListChanged): + return honored.prompts_list_changed is True + if isinstance(event, ResourcesListChanged): + return honored.resources_list_changed is True + return event.uri in uris diff --git a/tests/client/test_client.py b/tests/client/test_client.py index f8c02c9734..6c78503b97 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -35,7 +35,7 @@ from mcp_types.version import LATEST_HANDSHAKE_VERSION from pydantic import FileUrl -from mcp import MCPError +from mcp import MCPDeprecationWarning, MCPError from mcp.client._memory import InMemoryTransport from mcp.client._transport import TransportStreams from mcp.client.client import Client @@ -310,13 +310,15 @@ async def handle_progress(ctx: ServerRequestContext, params: types.ProgressNotif async def test_client_subscribe_resource(simple_server: Server): async with Client(simple_server, mode="legacy") as client: - result = await client.subscribe_resource("memory://test") + with pytest.warns(MCPDeprecationWarning, match="use Client.listen"): + result = await client.subscribe_resource("memory://test") # pyright: ignore[reportDeprecated] assert result == snapshot(EmptyResult()) async def test_client_unsubscribe_resource(simple_server: Server): async with Client(simple_server, mode="legacy") as client: - result = await client.unsubscribe_resource("memory://test") + with pytest.warns(MCPDeprecationWarning, match="use Client.listen"): + result = await client.unsubscribe_resource("memory://test") # pyright: ignore[reportDeprecated] assert result == snapshot(EmptyResult()) diff --git a/tests/client/test_send_request_mcp_name.py b/tests/client/test_send_request_mcp_name.py index 4088108148..e22ec4015b 100644 --- a/tests/client/test_send_request_mcp_name.py +++ b/tests/client/test_send_request_mcp_name.py @@ -22,7 +22,7 @@ from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION from mcp.client.session import ClientSession -from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest +from mcp.shared.dispatcher import CallOptions, OnNotify, OnNotifyIntercept, OnRequest from mcp.shared.inbound import MCP_NAME_HEADER, MCP_PROTOCOL_VERSION_HEADER, encode_header_value @@ -36,6 +36,7 @@ async def run( self, on_request: OnRequest, on_notify: OnNotify, + on_notify_intercept: OnNotifyIntercept | None = None, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ) -> None: diff --git a/tests/client/test_session.py b/tests/client/test_session.py index 2a53f67cea..507c8f69e3 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -39,11 +39,13 @@ from mcp.client import ClientRequestContext from mcp.client.client import Client from mcp.client.session import DEFAULT_CLIENT_INFO, ClientSession +from mcp.client.subscriptions import ToolsListChanged, listen from mcp.server import Server, ServerRequestContext from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair -from mcp.shared.dispatcher import CallOptions, DispatchContext, OnNotify, OnRequest +from mcp.shared.dispatcher import CallOptions, DispatchContext, OnNotify, OnNotifyIntercept, OnRequest from mcp.shared.message import SessionMessage from mcp.shared.session import RequestResponder +from mcp.shared.subscriptions import SUBSCRIPTION_ID_META_KEY from mcp.shared.transport_context import TransportContext _SendToClient = anyio.streams.memory.MemoryObjectSendStream[SessionMessage | Exception] @@ -1341,6 +1343,7 @@ async def run( self, on_request: OnRequest, on_notify: OnNotify, + on_notify_intercept: OnNotifyIntercept | None = None, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ) -> None: @@ -1428,6 +1431,7 @@ async def run( self, on_request: OnRequest, on_notify: OnNotify, + on_notify_intercept: OnNotifyIntercept | None = None, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ) -> None: @@ -1486,6 +1490,7 @@ async def run( self, on_request: OnRequest, on_notify: OnNotify, + on_notify_intercept: OnNotifyIntercept | None = None, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ) -> None: @@ -1808,3 +1813,137 @@ async def handler(ctx: ServerRequestContext, params: types.ReadResourceRequestPa result = await client.session.read_resource("memory://r", allow_input_required=True) assert isinstance(result, types.InputRequiredResult) assert result.request_state == "resource-state" + + +@pytest.mark.anyio +async def test_a_late_ack_for_a_closed_driver_listen_reaches_message_handler(): + """Ack consumption is keyed on the live route registry alone: a stray ack for a + closed subscription's id surfaces through message_handler like any other unowned frame.""" + seen: list[object] = [] + follow_up = anyio.Event() + + async def handler(msg: object) -> None: + seen.append(msg) + if len(seen) == 2: + follow_up.set() + + async with raw_client_session(message_handler=handler) as (session, to_client, _): + _set_negotiated_version(session, "2026-07-28") + session._register_listen_route("listen-99") # pyright: ignore[reportPrivateUsage] + session._unregister_listen_route("listen-99") # pyright: ignore[reportPrivateUsage] + await to_client.send( + SessionMessage( + JSONRPCNotification( + jsonrpc="2.0", + method="notifications/subscriptions/acknowledged", + params={ + "notifications": {"toolsListChanged": True}, + "_meta": {SUBSCRIPTION_ID_META_KEY: "listen-99"}, + }, + ) + ) + ) + await to_client.send( + SessionMessage(JSONRPCNotification(jsonrpc="2.0", method="notifications/tools/list_changed", params={})) + ) + with anyio.fail_after(5): + await follow_up.wait() + assert [type(message).__name__ for message in seen] == [ + "SubscriptionsAcknowledgedNotification", + "ToolListChangedNotification", + ] + + +@pytest.mark.anyio +async def test_a_graceful_result_does_not_outrun_the_events_that_preceded_it(): + """[ack, event, result] written back-to-back: the event delivers and the wire ack's filter + survives a parked message_handler tee, because routes settle on the dispatcher's receive path in wire order.""" + + async def parked_handler(message: object) -> None: + await anyio.sleep_forever() + + events: list[object] = [] + honored: list[types.SubscriptionFilter] = [] + async with raw_client_session(message_handler=parked_handler) as (session, to_client, from_client): + _set_negotiated_version(session, "2026-07-28") + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: # pragma: no branch + + async def consume() -> None: + async with listen(session, tools_list_changed=True) as sub: # pragma: no branch + honored.append(sub.honored) + events.extend([event async for event in sub]) + + tg.start_soon(consume) + request = await from_client.receive() + assert isinstance(request.message, JSONRPCRequest) + meta = {SUBSCRIPTION_ID_META_KEY: request.message.id} + for message in ( + JSONRPCNotification( + jsonrpc="2.0", + method="notifications/subscriptions/acknowledged", + params={"notifications": {"toolsListChanged": True}, "_meta": meta}, + ), + JSONRPCNotification( + jsonrpc="2.0", method="notifications/tools/list_changed", params={"_meta": meta} + ), + JSONRPCResponse(jsonrpc="2.0", id=request.message.id, result={"_meta": meta}), + ): + await to_client.send(SessionMessage(message)) + assert honored == [types.SubscriptionFilter(tools_list_changed=True)] + assert events == [ToolsListChanged()] + + +def _intercept_only_session() -> ClientSession: + """A never-entered session whose intercept can be driven directly (it is synchronous).""" + dispatcher, _peer = create_direct_dispatcher_pair() + return ClientSession(dispatcher=dispatcher) + + +def test_intercept_settles_only_the_named_listen_route_on_cancelled(): + """SDK demux contract: a server-sent cancel settles exactly the listen route it names and is never consumed.""" + session = _intercept_only_session() + route = session._register_listen_route("listen-1") # pyright: ignore[reportPrivateUsage] + intercept = session._intercept_notification # pyright: ignore[reportPrivateUsage] + assert intercept("notifications/cancelled", {"requestId": "unrelated"}) is False + assert route.end is None + assert intercept("notifications/cancelled", {"requestId": "listen-1"}) is False + assert route.end == "lost" + + +def test_intercept_ignores_frames_without_a_route_or_with_broken_meta(): + """SDK demux contract: frames that correlate to no live route flow through to the normal notification path.""" + session = _intercept_only_session() + intercept = session._intercept_notification # pyright: ignore[reportPrivateUsage] + assert intercept("notifications/tools/list_changed", {"_meta": {SUBSCRIPTION_ID_META_KEY: "listen-1"}}) is False + route = session._register_listen_route("listen-1") # pyright: ignore[reportPrivateUsage] + route.set_acked(types.SubscriptionFilter(tools_list_changed=True)) + assert intercept("notifications/tools/list_changed", None) is False + # A non-mapping `_meta` is constructible on pre-2026 wires. + assert intercept("notifications/tools/list_changed", {"_meta": "oops"}) is False + assert intercept("notifications/tools/list_changed", {"_meta": {SUBSCRIPTION_ID_META_KEY: "other"}}) is False + # A non-string uri is not an event; surface validation owns it. + meta = {"_meta": {SUBSCRIPTION_ID_META_KEY: "listen-1"}} + assert intercept("notifications/resources/updated", {"uri": 7, **meta}) is False + assert route._pending == {} # pyright: ignore[reportPrivateUsage] + + +def test_intercept_consumes_acks_for_live_routes_and_leaves_malformed_ones(): + """SDK demux contract: a well-formed ack for a live route is consumed as driver state; malformed acks pass on.""" + session = _intercept_only_session() + route = session._register_listen_route("listen-1") # pyright: ignore[reportPrivateUsage] + intercept = session._intercept_notification # pyright: ignore[reportPrivateUsage] + meta = {"_meta": {SUBSCRIPTION_ID_META_KEY: "listen-1"}} + assert intercept("notifications/subscriptions/acknowledged", {"notifications": ["nope"], **meta}) is False + assert route.honored is None + # A missing `notifications` field must not be read as an (all-refusing) empty filter. + assert intercept("notifications/subscriptions/acknowledged", dict(meta)) is False + assert route.honored is None + assert ( + intercept("notifications/subscriptions/acknowledged", {"notifications": {"toolsListChanged": True}, **meta}) + is True + ) + assert route.honored == types.SubscriptionFilter(tools_list_changed=True) + # Events deliver but are never consumed - they still tee to message_handler. + assert intercept("notifications/tools/list_changed", meta) is False + assert list(route._pending) == [ToolsListChanged()] # pyright: ignore[reportPrivateUsage] diff --git a/tests/client/test_session_claims.py b/tests/client/test_session_claims.py index 21cf2fa691..94ebd7946e 100644 --- a/tests/client/test_session_claims.py +++ b/tests/client/test_session_claims.py @@ -28,7 +28,7 @@ from mcp.client.extension import ClaimContext, ResultClaim, UnexpectedClaimedResult from mcp.client.session import ClientSession, _CallToolResultAdapter -from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest +from mcp.shared.dispatcher import CallOptions, OnNotify, OnNotifyIntercept, OnRequest _TASKS_EXT = "com.example/tasks" _AD_ONLY_EXT = "com.example/flags" @@ -75,6 +75,7 @@ async def run( self, on_request: OnRequest, on_notify: OnNotify, + on_notify_intercept: OnNotifyIntercept | None = None, *, task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, ) -> None: diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index defda41f85..da17a71c1e 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -18,6 +18,8 @@ from mcp_types import ( CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, + CONNECTION_CLOSED, + INVALID_REQUEST, METHOD_NOT_FOUND, PROTOCOL_VERSION_META_KEY, JSONRPCError, @@ -28,10 +30,16 @@ from mcp_types.version import LATEST_MODERN_VERSION from starlette.types import Receive, Scope, Send -from mcp.client.streamable_http import streamable_http_client +from mcp.client.streamable_http import ( + MAX_RECONNECTION_ATTEMPTS, + RequestContext, + StreamableHTTPTransport, + streamable_http_client, +) from mcp.server import Server from mcp.server._streamable_http_modern import handle_modern_request from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, ServerEvent +from mcp.shared._context_streams import ContextSendStream, create_context_streams from mcp.shared.dispatcher import CallOptions, DispatchContext from mcp.shared.inbound import MCP_METHOD_HEADER, MCP_PROTOCOL_VERSION_HEADER, encode_header_value from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher @@ -583,3 +591,160 @@ def handler(request: httpx.Request) -> httpx.Response: ) await parked.closed.wait() assert [body["method"] for body in posted] == ["tools/call", "subscriptions/listen"] + + +class _DyingSSEStream(httpx.AsyncByteStream): + """Emits one id-less comment then breaks - a non-resumable stream dropping.""" + + def __init__(self) -> None: + self.opened = anyio.Event() + + async def __aiter__(self) -> AsyncIterator[bytes]: + self.opened.set() + yield b": hello\n\n" + raise httpx.ReadError("connection reset") + + async def aclose(self) -> None: + pass + + +@pytest.mark.anyio +async def test_a_non_resumable_sse_drop_resolves_the_request_with_an_error() -> None: + """A per-request SSE stream that dies having carried no event ids can never deliver its + response; the transport resolves the waiter with CONNECTION_CLOSED instead of hanging forever.""" + dying = _DyingSSEStream() + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=dying) + + 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="listen-1", method="subscriptions/listen", params={})) + ) + reply = await read.receive() + assert isinstance(reply, SessionMessage) + assert isinstance(reply.message, JSONRPCError) + assert reply.message.id == "listen-1" + assert reply.message.error.code == CONNECTION_CLOSED + + +class _DeliverOnCommandSSEStream(httpx.AsyncByteStream): + """Parks after opening, then delivers one JSON-RPC response when told.""" + + def __init__(self, response_body: dict[str, Any]) -> None: + self._event = f"data: {json.dumps(response_body)}\n\n".encode() + self.opened = anyio.Event() + self.deliver = anyio.Event() + + async def __aiter__(self) -> AsyncIterator[bytes]: + self.opened.set() + await self.deliver.wait() + yield self._event + + async def aclose(self) -> None: + pass + + +@pytest.mark.anyio +async def test_a_superseded_posts_late_real_response_cannot_answer_the_successor() -> None: + """SDK-defined: re-issuing an id severs the superseded POST, so nothing from its + stream (a late real response, or a synthesized error for its death) can resolve + the reused id's waiter; only the successor's own response arrives.""" + stale = _DeliverOnCommandSSEStream({"jsonrpc": "2.0", "id": "dup-1", "result": {"origin": "stale"}}) + succeeding = _DeliverOnCommandSSEStream({"jsonrpc": "2.0", "id": "dup-1", "result": {"origin": "fresh"}}) + streams: list[httpx.AsyncByteStream] = [stale, succeeding] + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, headers={"content-type": "text/event-stream"}, stream=streams.pop(0)) + + 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="dup-1", method="tools/call", params={}))) + await stale.opened.wait() + await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id="dup-1", method="tools/call", params={}))) + await succeeding.opened.wait() + stale.deliver.set() + await anyio.wait_all_tasks_blocked() + succeeding.deliver.set() + reply = await read.receive() + assert isinstance(reply, SessionMessage) + assert isinstance(reply.message, JSONRPCResponse), reply.message + assert reply.message.result == {"origin": "fresh"} + + +@pytest.mark.anyio +async def test_a_202_to_a_request_resolves_the_waiter_with_an_error() -> None: + """SDK-defined: a server that answers a request with 202 Accepted has declared no + response will follow (the spec requires SSE or JSON for requests); the transport + resolves the waiter with INVALID_REQUEST instead of parking the caller forever.""" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(202) + + 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="listen-1", method="subscriptions/listen", params={})) + ) + reply = await read.receive() + assert isinstance(reply, SessionMessage) + assert isinstance(reply.message, JSONRPCError) + assert reply.message.id == "listen-1" + assert reply.message.error.code == INVALID_REQUEST + + +def _abandoned_request_context( + http: httpx.AsyncClient, send: ContextSendStream[SessionMessage | Exception] +) -> RequestContext: + return RequestContext( + client=http, + session_id=None, + session_message=SessionMessage( + JSONRPCRequest(jsonrpc="2.0", id="listen-1", method="subscriptions/listen", params={}) + ), + metadata=None, + read_stream_writer=send, + ) + + +@pytest.mark.anyio +async def test_exhausted_reconnection_attempts_resolve_the_request_with_an_error() -> None: + """An id-bearing stream that exhausts its reconnection budget also resolves the waiter with CONNECTION_CLOSED.""" + transport = StreamableHTTPTransport("http://test/mcp") + send, receive = create_context_streams[SessionMessage | Exception](1) + async with httpx.AsyncClient() as http: + with anyio.fail_after(5): + await transport._handle_reconnection( # pyright: ignore[reportPrivateUsage] + _abandoned_request_context(http, send), "evt-7", None, MAX_RECONNECTION_ATTEMPTS + ) + reply = await receive.receive() + assert isinstance(reply, SessionMessage) + assert isinstance(reply.message, JSONRPCError) + assert reply.message.id == "listen-1" + assert reply.message.error.code == CONNECTION_CLOSED + send.close() + receive.close() + + +@pytest.mark.anyio +async def test_resolving_an_abandoned_request_after_the_reader_closed_is_contained() -> None: + """Teardown race: a stream dying after the reader closed resolves best-effort and must not crash.""" + transport = StreamableHTTPTransport("http://test/mcp") + send, receive = create_context_streams[SessionMessage | Exception](1) + receive.close() + async with httpx.AsyncClient() as http: + with anyio.fail_after(5): + await transport._handle_reconnection( # pyright: ignore[reportPrivateUsage] + _abandoned_request_context(http, send), "evt-7", None, MAX_RECONNECTION_ATTEMPTS + ) + send.close() diff --git a/tests/client/test_subscriptions.py b/tests/client/test_subscriptions.py new file mode 100644 index 0000000000..0cc4f133e4 --- /dev/null +++ b/tests/client/test_subscriptions.py @@ -0,0 +1,666 @@ +"""Behavioral tests for the client-side `subscriptions/listen` driver (SDK-defined contract). + +Public API only, against in-process servers; wire-shape assertions live in the interaction suite. +""" + +from itertools import count +from typing import Any + +import anyio +import mcp_types as types +import pytest +from mcp_types import SubscriptionFilter + +import mcp.client.subscriptions as subscriptions_module +from mcp import Client, MCPError +from mcp.client.session import ClientSession +from mcp.client.subscriptions import ( + ListenNotSupportedError, + ListenRoute, + PromptsListChanged, + ResourcesListChanged, + ResourceUpdated, + ServerEvent, + Subscription, + SubscriptionLost, + ToolsListChanged, + listen, +) +from mcp.server import Server, ServerRequestContext +from mcp.server.subscriptions import ( + SUBSCRIPTION_ID_META_KEY, + InMemorySubscriptionBus, + ListenHandler, +) +from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair +from mcp.shared.dispatcher import CallOptions + +pytestmark = pytest.mark.anyio + + +def _bus_server(bus: InMemorySubscriptionBus, *, max_subscriptions: int | None = None) -> Server[Any]: + """A lowlevel server whose only feature is serving listen streams from `bus`.""" + handler = ( + ListenHandler(bus) if max_subscriptions is None else ListenHandler(bus, max_subscriptions=max_subscriptions) + ) + return Server("subs", on_subscriptions_listen=handler) + + +async def _ack(ctx: ServerRequestContext[Any, Any], honored: SubscriptionFilter) -> dict[str, Any]: + """Send a hand-rolled ack for a scripted listen handler; returns the stamped meta.""" + assert ctx.request_id is not None + meta: dict[str, Any] = {SUBSCRIPTION_ID_META_KEY: ctx.request_id} + await ctx.session.send_notification( + types.SubscriptionsAcknowledgedNotification( + params=types.SubscriptionsAcknowledgedNotificationParams(notifications=honored, _meta=meta) + ), + related_request_id=ctx.request_id, + ) + return meta + + +async def test_listen_surfaces_the_honored_filter_and_subscription_id(): + """Entering waits for the server ack and surfaces the honored filter and subscription id.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus)) as client: + with anyio.fail_after(5): + async with client.listen( # pragma: no branch + tools_list_changed=True, resource_subscriptions=["note://todo"] + ) as sub: + assert isinstance(sub, Subscription) + assert sub.honored.tools_list_changed is True + assert sub.honored.resource_subscriptions == ["note://todo"] + assert isinstance(sub.subscription_id, str) + assert sub.subscription_id.startswith("listen-") + + +async def test_listen_delivers_all_four_typed_event_kinds(): + """Bus publishes come back as the same typed event values, in order.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus)) as client: + with anyio.fail_after(5): + async with client.listen( # pragma: no branch + tools_list_changed=True, + prompts_list_changed=True, + resources_list_changed=True, + resource_subscriptions=["note://todo"], + ) as sub: + for event in ( + ToolsListChanged(), + PromptsListChanged(), + ResourcesListChanged(), + ResourceUpdated(uri="note://todo"), + ): + await bus.publish(event) + assert await anext(sub) == event + + +async def test_unconsumed_duplicate_events_coalesce(): + """Events are level triggers: duplicates pending consumption collapse to one.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus)) as client: + with anyio.fail_after(5): + async with client.listen( # pragma: no branch + tools_list_changed=True, resource_subscriptions=["note://todo"] + ) as sub: + for _ in range(3): + await bus.publish(ToolsListChanged()) + await bus.publish(ResourceUpdated(uri="note://todo")) + await anyio.wait_all_tasks_blocked() + assert await anext(sub) == ToolsListChanged() + assert await anext(sub) == ResourceUpdated(uri="note://todo") + + +async def test_graceful_server_close_ends_the_loop_cleanly(): + """The server's deliberate close ends iteration cleanly, after draining prior events.""" + bus = InMemorySubscriptionBus() + handler = ListenHandler(bus) + server = Server("subs", on_subscriptions_listen=handler) + events: list[object] = [] + async with Client(server) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + await bus.publish(ToolsListChanged()) + handler.close() + events.extend([event async for event in sub]) + assert events == [ToolsListChanged()] + + +async def test_abrupt_stream_end_raises_subscription_lost(): + """A stream dying without the graceful result raises `SubscriptionLost` with the cause chained.""" + proceed = anyio.Event() + + async def dropping_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + await _ack(ctx, params.notifications) + await proceed.wait() + raise MCPError(types.INTERNAL_ERROR, "stream torn down") + + server = Server("subs", on_subscriptions_listen=dropping_listen) + async with Client(server) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + proceed.set() + with pytest.raises(SubscriptionLost) as exc_info: # pragma: no branch + await anext(sub) + assert isinstance(exc_info.value.__cause__, MCPError) + assert exc_info.value.__cause__.error.message == "stream torn down" + + +async def test_listen_on_a_legacy_connection_raises_the_typed_steer(): + """On a 2025 connection `listen` fails fast with the typed error steering to the legacy verbs.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus), mode="legacy") as client: + with anyio.fail_after(5): + # Entering is where the guard fires; __aenter__ directly avoids an unreachable with-body. + with pytest.raises(ListenNotSupportedError) as exc_info: # pragma: no branch + await client.listen(tools_list_changed=True).__aenter__() + assert exc_info.value.negotiated_version == "2025-11-25" + assert "subscribe_resource" in str(exc_info.value) + + +async def test_server_rejection_raises_from_enter_not_from_iteration(): + """A server without the listen handler fails the open from entering the context.""" + server = Server("no-listen") + async with Client(server) as client: + with anyio.fail_after(5): + with pytest.raises(MCPError) as exc_info: # pragma: no branch + await client.listen(tools_list_changed=True).__aenter__() + assert exc_info.value.error.code == types.METHOD_NOT_FOUND + + +async def test_immediate_result_without_ack_opens_already_closed(): + """A bare result with no ack yields a subscription already gracefully over: no filter, no events.""" + + async def degenerate_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + assert ctx.request_id is not None + return types.SubscriptionsListenResult(_meta={SUBSCRIPTION_ID_META_KEY: ctx.request_id}) + + server = Server("subs", on_subscriptions_listen=degenerate_listen) + async with Client(server) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + assert sub.honored == SubscriptionFilter() + with pytest.raises(StopAsyncIteration): # pragma: no branch + await anext(sub) + + +async def test_server_sent_cancelled_for_the_listen_id_raises_subscription_lost(): + """Server-sent notifications/cancelled for the listen id surfaces as a lost subscription.""" + proceed = anyio.Event() + + async def cancelling_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + assert ctx.request_id is not None + await _ack(ctx, params.notifications) + await proceed.wait() + await ctx.session.send_notification( + types.CancelledNotification(params=types.CancelledNotificationParams(request_id=ctx.request_id)), + related_request_id=ctx.request_id, + ) + await anyio.sleep_forever() + raise AssertionError("unreachable") # pragma: no cover + + server = Server("subs", on_subscriptions_listen=cancelling_listen) + async with Client(server) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + proceed.set() + with pytest.raises(SubscriptionLost): # pragma: no branch + await anext(sub) + + +async def test_exiting_the_context_frees_the_server_slot(): + """Leaving the block ends the subscription server-side: a one-slot handler admits a second listen.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus, max_subscriptions=1)) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as first: + assert first.honored.tools_list_changed is True + async with client.listen(tools_list_changed=True) as second: # pragma: no branch + assert second.honored.tools_list_changed is True + assert second.subscription_id != first.subscription_id + + +async def test_concurrent_subscriptions_demux_independently(): + """Two open subscriptions each receive only their own filter's events.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus)) as client: + with anyio.fail_after(5): + async with ( # pragma: no branch + client.listen(tools_list_changed=True) as tools_sub, + client.listen(resource_subscriptions=["note://todo"]) as notes_sub, + ): + await bus.publish(ToolsListChanged()) + await bus.publish(ResourceUpdated(uri="note://todo")) + assert await anext(tools_sub) == ToolsListChanged() + assert await anext(notes_sub) == ResourceUpdated(uri="note://todo") + # Neither stream received the other's event. + await bus.publish(ToolsListChanged()) + assert await anext(tools_sub) == ToolsListChanged() + + +async def test_change_notifications_still_reach_message_handler(): + """The demux tees: a delivered event's notification still reaches message_handler; the ack never does.""" + bus = InMemorySubscriptionBus() + seen: list[str] = [] + + async def on_message(message: object) -> None: + assert not isinstance(message, types.SubscriptionsAcknowledgedNotification) + if isinstance(message, types.ToolListChangedNotification): # pragma: no branch + seen.append("tools-changed") + + async with Client(_bus_server(bus), message_handler=on_message) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + await bus.publish(ToolsListChanged()) + assert await anext(sub) == ToolsListChanged() + await anyio.wait_all_tasks_blocked() + assert seen == ["tools-changed"] + + +async def test_enter_times_out_when_the_ack_never_arrives(): + """The ack wait rides the session's read timeout, so a wedged server cannot hang the open.""" + + async def silent_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + await anyio.sleep_forever() + raise AssertionError("unreachable") # pragma: no cover + + server = Server("subs", on_subscriptions_listen=silent_listen) + async with Client(server, read_timeout_seconds=0.05) as client: + with anyio.fail_after(5): + with pytest.raises(TimeoutError): # pragma: no branch + await client.listen(tools_list_changed=True).__aenter__() + + +async def test_an_open_stream_outlives_the_session_read_timeout(): + """The listen request is exempt from the read timeout: the stream delivers after the deadline.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus), read_timeout_seconds=0.05) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + # Real clock on purpose: this pins a timeout feature. + await anyio.sleep(0.2) + await bus.publish(ToolsListChanged()) + assert await anext(sub) == ToolsListChanged() + + +async def test_a_duplicate_ack_does_not_overwrite_the_honored_filter(): + """The first ack wins; a later conflicting ack is a no-op.""" + proceed = anyio.Event() + + async def double_acking_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + assert ctx.request_id is not None + await _ack(ctx, params.notifications) + await _ack(ctx, SubscriptionFilter()) + await proceed.wait() + return types.SubscriptionsListenResult(_meta={SUBSCRIPTION_ID_META_KEY: ctx.request_id}) + + server = Server("subs", on_subscriptions_listen=double_acking_listen) + async with Client(server) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + assert sub.honored.tools_list_changed is True + proceed.set() + + +async def test_a_non_event_frame_with_the_subscription_id_is_teed_not_delivered(): + """A stamped non-event notification never surfaces as an event; it flows to message_handler.""" + proceed = anyio.Event() + + async def logging_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + assert ctx.request_id is not None + meta = await _ack(ctx, params.notifications) + await ctx.session.send_notification( + types.LoggingMessageNotification( + params=types.LoggingMessageNotificationParams(level="info", data="not an event", _meta=meta) + ), + related_request_id=ctx.request_id, + ) + await proceed.wait() + return types.SubscriptionsListenResult(_meta=meta) + + logged: list[str] = [] + + async def on_message(message: object) -> None: + if isinstance(message, types.LoggingMessageNotification): # pragma: no branch + logged.append(str(message.params.data)) + + server = Server("subs", on_subscriptions_listen=logging_listen) + async with Client(server, message_handler=on_message) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + await anyio.wait_all_tasks_blocked() + proceed.set() + with pytest.raises(StopAsyncIteration): # pragma: no branch + await anext(sub) + assert logged == ["not an event"] + + +async def test_session_teardown_unblocks_a_sibling_consumer_with_subscription_lost(): + """Session teardown settles every open route as lost, unblocking parked consumers.""" + bus = InMemorySubscriptionBus() + outcome: list[str] = [] + entered = anyio.Event() + + async def consume(client: Client) -> None: + with pytest.raises(SubscriptionLost): + async with client.listen(tools_list_changed=True) as sub: + entered.set() + await anext(sub) + outcome.append("lost") + + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: + async with Client(_bus_server(bus)) as client: # pragma: no branch + tg.start_soon(consume, client) + await entered.wait() + assert outcome == ["lost"] + + +async def test_server_cancel_before_the_ack_raises_subscription_lost_from_enter(): + """A stream torn down before it was ever acknowledged is a failed open: enter raises.""" + + async def cancel_first_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + assert ctx.request_id is not None + await ctx.session.send_notification( + types.CancelledNotification(params=types.CancelledNotificationParams(request_id=ctx.request_id)), + related_request_id=ctx.request_id, + ) + await anyio.sleep_forever() + raise AssertionError("unreachable") # pragma: no cover + + server = Server("subs", on_subscriptions_listen=cancel_first_listen) + async with Client(server) as client: + with anyio.fail_after(5): + with pytest.raises(SubscriptionLost, match="before it was acknowledged"): # pragma: no branch + await client.listen(tools_list_changed=True).__aenter__() + + +async def test_listen_on_an_exited_session_raises_and_leaks_no_route(): + """Opening on an exited session fails loudly and leaves no demux registration behind.""" + bus = InMemorySubscriptionBus() + client = Client(_bus_server(bus)) + async with client: + session = client.session + with pytest.raises(RuntimeError): + await listen(session, tools_list_changed=True).__aenter__() + assert session._listen_routes == {} # pyright: ignore[reportPrivateUsage] + + +async def test_listen_on_a_never_entered_session_raises_runtime_error(): + """An adopted-but-never-entered session has no task group to drive the stream.""" + dispatcher, _peer = create_direct_dispatcher_pair() + session = ClientSession(dispatcher=dispatcher) + session.adopt( + types.DiscoverResult( + supported_versions=["2026-07-28"], + capabilities=types.ServerCapabilities(), + server_info=types.Implementation(name="stub", version="0"), + ) + ) + with pytest.raises(RuntimeError, match="entered session"): + await listen(session, tools_list_changed=True).__aenter__() + assert session._listen_routes == {} # pyright: ignore[reportPrivateUsage] + + +async def test_a_retained_handle_after_exit_does_not_serve_stale_events(): + """Leaving the block abandons the backlog: a stashed handle must not replay buffered events.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus)) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: + await bus.publish(ToolsListChanged()) + await anyio.wait_all_tasks_blocked() + with pytest.raises(StopAsyncIteration): # pragma: no branch + await anext(sub) + + +async def test_a_stray_ack_outside_the_driver_namespace_still_reaches_message_handler(): + """Acks for ids the driver never minted flow to message_handler (the raw-listen escape hatch).""" + proceed = anyio.Event() + + async def stray_acking_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + assert ctx.request_id is not None + await _ack(ctx, params.notifications) + await ctx.session.send_notification( + types.SubscriptionsAcknowledgedNotification( + params=types.SubscriptionsAcknowledgedNotificationParams( + notifications=SubscriptionFilter(), _meta={SUBSCRIPTION_ID_META_KEY: 424242} + ) + ), + related_request_id=ctx.request_id, + ) + await proceed.wait() + return types.SubscriptionsListenResult(_meta={SUBSCRIPTION_ID_META_KEY: ctx.request_id}) + + handled: list[str] = [] + + async def on_message(message: object) -> None: + handled.append(type(message).__name__) + + server = Server("subs", on_subscriptions_listen=stray_acking_listen) + async with Client(server, message_handler=on_message) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + await anyio.wait_all_tasks_blocked() + proceed.set() + with pytest.raises(StopAsyncIteration): # pragma: no branch + await anext(sub) + assert "SubscriptionsAcknowledgedNotification" in handled + + +async def test_a_bare_string_for_resource_subscriptions_is_rejected(): + """A bare string would explode into per-character URIs; it is rejected before touching the wire.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus)) as client: + with pytest.raises(TypeError, match="sequence of URIs"): + await client.listen(resource_subscriptions="note://todo").__aenter__() # pyright: ignore[reportArgumentType] + + +def test_the_route_admits_only_honored_events_and_only_while_live(): + """Route admission: nothing before the ack, only honored events while live, nothing after the end.""" + route = ListenRoute() + route.deliver(ToolsListChanged()) + assert route._pending == {} # pyright: ignore[reportPrivateUsage] + route.set_acked(SubscriptionFilter(tools_list_changed=True, resource_subscriptions=["note://todo"])) + route.deliver(PromptsListChanged()) # kind not honored + route.deliver(ResourceUpdated(uri="note://todo/draft")) # sub-resource of a subscribed URI: spec says admit + route.deliver(ResourceUpdated(uri="note://todo")) + route.deliver(ToolsListChanged()) + route.deliver(ToolsListChanged()) # duplicate pending consumption collapses + assert list(route._pending) == [ # pyright: ignore[reportPrivateUsage] + ResourceUpdated(uri="note://todo/draft"), + ResourceUpdated(uri="note://todo"), + ToolsListChanged(), + ] + route.settle("graceful") + route.deliver(ResourceUpdated(uri="note://todo")) # post-close noise is refused + assert len(route._pending) == 3 # pyright: ignore[reportPrivateUsage] + + +def test_a_peer_flooding_distinct_uris_costs_the_subscription_not_client_memory(): + """A peer flooding distinct URIs trips the `_MAX_PENDING_EVENTS` backstop: the route + settles lost instead of growing client memory without bound.""" + route = ListenRoute() + route.set_acked(SubscriptionFilter(resource_subscriptions=["note://todo"])) + for n in range(subscriptions_module._MAX_PENDING_EVENTS): # pyright: ignore[reportPrivateUsage] + route.deliver(ResourceUpdated(uri=f"note://todo/{n}")) + assert route.end is None + route.deliver(ResourceUpdated(uri="note://todo/one-too-many")) + assert route.end == "lost" + assert route.error is not None + assert "backlog" in route.error.error.message + # The overflowing event was not queued. + assert len(route._pending) == subscriptions_module._MAX_PENDING_EVENTS # pyright: ignore[reportPrivateUsage] + + +async def test_a_cancelled_on_event_barrier_does_not_lose_the_event(): + """Cancelling `anext` mid-barrier leaves the event queued; the next `anext` re-runs the + idempotent barrier and returns it.""" + bus = InMemorySubscriptionBus() + entered = anyio.Event() + release = anyio.Event() + + async def parked_barrier(event: ServerEvent) -> None: + entered.set() + await release.wait() + + async with Client(_bus_server(bus)) as client: + with anyio.fail_after(5): + async with listen( + client.session, tools_list_changed=True, on_event=parked_barrier + ) as sub: # pragma: no branch + await bus.publish(ToolsListChanged()) + async with anyio.create_task_group() as tg: + cancel_scope = anyio.CancelScope() + + async def first_attempt() -> None: + with cancel_scope: + await anext(sub) + raise AssertionError("must be cancelled mid-barrier") # pragma: no cover + + tg.start_soon(first_attempt) + await entered.wait() + cancel_scope.cancel() + release.set() + assert await anext(sub) == ToolsListChanged() + + +async def test_events_outside_the_honored_filter_are_never_delivered(): + """A server violating its acknowledged filter cannot reach the consumer or grow the backlog.""" + proceed = anyio.Event() + + async def overreaching_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + meta = await _ack(ctx, params.notifications) # honors exactly what was requested: tools only + await ctx.session.send_notification( + types.ResourceUpdatedNotification( + params=types.ResourceUpdatedNotificationParams(uri="note://uninvited", _meta=meta) + ), + related_request_id=ctx.request_id, + ) + await ctx.session.send_notification( + types.ToolListChangedNotification(params=types.NotificationParams(_meta=meta)), + related_request_id=ctx.request_id, + ) + await proceed.wait() + return types.SubscriptionsListenResult(_meta=meta) + + server = Server("subs", on_subscriptions_listen=overreaching_listen) + async with Client(server) as client: + with anyio.fail_after(5): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + assert await anext(sub) == ToolsListChanged() + proceed.set() + with pytest.raises(StopAsyncIteration): # pragma: no branch + await anext(sub) + + +async def test_the_on_event_barrier_completes_before_each_event_is_returned(): + """`on_event` is awaited before the iterator returns each event (the Client wires cache eviction here).""" + bus = InMemorySubscriptionBus() + order: list[str] = [] + + async def barrier(event: ServerEvent) -> None: + order.append(f"barrier:{type(event).__name__}") + + async with Client(_bus_server(bus)) as client: + with anyio.fail_after(5): + async with listen(client.session, tools_list_changed=True, on_event=barrier) as sub: # pragma: no branch + await bus.publish(ToolsListChanged()) + event = await anext(sub) + order.append(f"returned:{type(event).__name__}") + assert order == ["barrier:ToolsListChanged", "returned:ToolsListChanged"] + + +async def test_client_listen_installs_the_cache_eviction_barrier_exactly_when_a_cache_exists(): + """`Client.listen` wires the response-cache evictor as the barrier only when a cache exists.""" + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus)) as cached_client: + with anyio.fail_after(5): + async with cached_client.listen(tools_list_changed=True) as sub: # pragma: no branch + assert sub._on_event == cached_client._evict_for_listen_event # pyright: ignore[reportPrivateUsage] + async with Client(_bus_server(bus), cache=False) as uncached_client: + with anyio.fail_after(5): + async with uncached_client.listen(tools_list_changed=True) as sub: # pragma: no branch + assert sub._on_event is None # pyright: ignore[reportPrivateUsage] + + +async def test_the_cache_eviction_barrier_maps_events_and_contains_store_faults( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The barrier evicts through the same notification mapping as the message_handler wrapper; + a raising store costs a log line, not the delivery.""" + client = Client(_bus_server(InMemorySubscriptionBus())) + cache = client._response_cache # pyright: ignore[reportPrivateUsage] + assert cache is not None + evicted: list[types.ServerNotification] = [] + + async def record(notification: types.ServerNotification) -> None: + evicted.append(notification) + + monkeypatch.setattr(cache, "evict_for_notification", record) + await client._evict_for_listen_event(ResourceUpdated(uri="note://x")) # pyright: ignore[reportPrivateUsage] + assert isinstance(evicted[0], types.ResourceUpdatedNotification) + assert evicted[0].params.uri == "note://x" + + async def broken(notification: types.ServerNotification) -> None: + raise RuntimeError("store down") + + monkeypatch.setattr(cache, "evict_for_notification", broken) + # Contained: a cache fault must not block delivery. + await client._evict_for_listen_event(ToolsListChanged()) # pyright: ignore[reportPrivateUsage] + + +async def test_a_raw_request_id_collision_fails_the_subscription_not_the_session( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A raw caller occupying the driver's next minted id fails that one listen from enter; + the session survives and the next listen opens normally.""" + monkeypatch.setattr(subscriptions_module, "_listen_ids", count(7000)) + bus = InMemorySubscriptionBus() + async with Client(_bus_server(bus)) as client: + with anyio.fail_after(5): + async with anyio.create_task_group() as tg: # pragma: no branch + raw_scope = anyio.CancelScope() + + async def raw_listen() -> None: + request = types.SubscriptionsListenRequest( + params=types.SubscriptionsListenRequestParams( + notifications=SubscriptionFilter(tools_list_changed=True) + ) + ) + data = request.model_dump(by_alias=True, mode="json", exclude_none=True) + opts: CallOptions = {"request_id": "listen-7000"} + client.session._stamp(data, opts) # pyright: ignore[reportPrivateUsage] + with raw_scope: + await client.session._dispatcher.send_raw_request( # pyright: ignore[reportPrivateUsage] + data["method"], data.get("params"), opts + ) + + tg.start_soon(raw_listen) + await anyio.wait_all_tasks_blocked() + with pytest.raises(MCPError) as exc_info: + await client.listen(tools_list_changed=True).__aenter__() + assert "already in flight" in exc_info.value.error.message + # The failed open released the colliding id's demux registration. + assert client.session._listen_routes == {} # pyright: ignore[reportPrivateUsage] + raw_scope.cancel() + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + assert sub.subscription_id == "listen-7001" diff --git a/tests/docs_src/test_client.py b/tests/docs_src/test_client.py index af5e692491..3d70371f53 100644 --- a/tests/docs_src/test_client.py +++ b/tests/docs_src/test_client.py @@ -5,7 +5,7 @@ from mcp_types import Prompt, PromptArgument, PromptReference, TextContent, TextResourceContents, Tool from docs_src.client import tutorial001, tutorial002, tutorial003, tutorial004, tutorial005, tutorial006, tutorial007 -from mcp import Client, MCPError +from mcp import Client, MCPDeprecationWarning, MCPError from mcp.shared.metadata_utils import get_display_name # See test_index.py for why this is a per-module mark and not a conftest hook. @@ -128,7 +128,9 @@ async def test_resource_subscriptions_are_listen_based_on_the_modern_wire() -> N assert client.server_capabilities.resources is not None assert client.server_capabilities.resources.subscribe is True with pytest.raises(MCPError) as exc_info: - await client.subscribe_resource("catalog://genres") + # The verb is itself deprecated; the modern wire also rejects it. + with pytest.warns(MCPDeprecationWarning, match="use Client.listen"): + await client.subscribe_resource("catalog://genres") # pyright: ignore[reportDeprecated] assert exc_info.value.error.code == -32601 assert exc_info.value.error.message == "Method not found" diff --git a/tests/docs_src/test_subscriptions.py b/tests/docs_src/test_subscriptions.py index b664afe983..7a7b75157b 100644 --- a/tests/docs_src/test_subscriptions.py +++ b/tests/docs_src/test_subscriptions.py @@ -1,19 +1,40 @@ -"""`docs/handlers/subscriptions.md`: every claim the page makes, proved against the real SDK.""" +"""`docs/{handlers,client}/subscriptions.md`: every claim the two pages make, proved against the real SDK.""" +from collections.abc import Awaitable, Callable from typing import Any import anyio import mcp_types as types import pytest +from trio.testing import MockClock -from docs_src.subscriptions import tutorial001, tutorial002 +from docs_src.subscriptions import ( + tutorial001, + tutorial002, + tutorial003, + tutorial004_anyio, + tutorial004_asyncio, + tutorial004_trio, + tutorial005, +) from mcp import Client -from mcp.server.subscriptions import SUBSCRIPTION_ID_META_KEY, ToolsListChanged +from mcp.server.context import ServerRequestContext +from mcp.server.lowlevel import Server +from mcp.server.subscriptions import SUBSCRIPTION_ID_META_KEY, ListenHandler, ToolsListChanged + +_ReadResource = Callable[ + [ServerRequestContext[Any], types.ReadResourceRequestParams], Awaitable[types.ReadResourceResult] +] # See test_index.py for why this is a per-module mark and not a conftest hook. pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")] +@pytest.fixture(autouse=True) +def _module_runner_lease() -> None: + """Opt out of the shared per-module event loop: this module parametrizes `anyio_backend`.""" + + class _Stream: """Collects listen-stream notifications and lets tests await arrival counts.""" @@ -42,12 +63,56 @@ async def wait_for(self, count: int) -> None: await self._arrival.wait() +class _Reads: + """Counts server-side resource reads so a test can await the Nth refetch.""" + + def __init__(self) -> None: + self.count = 0 + self._bump = anyio.Event() + + def counting(self, handler: _ReadResource) -> _ReadResource: + async def counted( + ctx: ServerRequestContext[Any], params: types.ReadResourceRequestParams + ) -> types.ReadResourceResult: + result = await handler(ctx, params) + self.count += 1 + self._bump.set() + self._bump = anyio.Event() + return result + + return counted + + async def wait_for(self, count: int) -> None: + with anyio.fail_after(5): + while self.count < count: + await self._bump.wait() + + def _listen_request(**fields: Any) -> types.SubscriptionsListenRequest: return types.SubscriptionsListenRequest( params=types.SubscriptionsListenRequestParams(notifications=types.SubscriptionFilter(**fields)) ) +@pytest.fixture(autouse=True) +def _fresh_server_state() -> Any: + """Each test starts from an all-unfinished board and the base tool set. + + The tutorials mutate module state deliberately (that is what publishes events), so the + board contents and the `enable_reports` registration have to be undone between tests. + """ + boards = {name: dict(tasks) for name, tasks in tutorial001.BOARDS.items()} + lowlevel_board = dict(tutorial002.BOARD) + tools = dict(tutorial001.mcp._tool_manager._tools) # pyright: ignore[reportPrivateUsage] + yield + tutorial001.BOARDS.clear() + tutorial001.BOARDS.update(boards) + tutorial002.BOARD.clear() + tutorial002.BOARD.update(lowlevel_board) + tutorial001.mcp._tool_manager._tools.clear() # pyright: ignore[reportPrivateUsage] + tutorial001.mcp._tool_manager._tools.update(tools) # pyright: ignore[reportPrivateUsage] + + async def test_publishes_reach_the_stream_filtered_and_tagged() -> None: """tutorial001: the full arc - ack first, exact-URI filtering, list_changed leading to a refreshed tool list, and client-side close.""" @@ -57,7 +122,7 @@ async def test_publishes_reach_the_stream_filtered_and_tagged() -> None: async def listen() -> None: await client.session.send_request( - _listen_request(tools_list_changed=True, resource_subscriptions=["note://todo"]), + _listen_request(tools_list_changed=True, resource_subscriptions=["board://sprint"]), types.SubscriptionsListenResult, ) @@ -67,21 +132,21 @@ async def listen() -> None: ack = stream.received[0] assert isinstance(ack, types.SubscriptionsAcknowledgedNotification) assert ack.params.notifications == types.SubscriptionFilter( - tools_list_changed=True, resource_subscriptions=["note://todo"] + tools_list_changed=True, resource_subscriptions=["board://sprint"] ) assert ack.params.meta is not None and SUBSCRIPTION_ID_META_KEY in ack.params.meta # An edit to a URI the stream did not subscribe to stays silent... - await client.call_tool("edit_note", {"name": "journal", "text": "day two"}) + await client.call_tool("complete_task", {"board": "backlog", "task": "tidy docs"}) # ...and the subscribed URI delivers, tagged with the same subscription id. - await client.call_tool("edit_note", {"name": "todo", "text": "water plants"}) + await client.call_tool("complete_task", {"board": "sprint", "task": "design"}) await stream.wait_for(2) updated = stream.received[1] assert isinstance(updated, types.ResourceUpdatedNotification) - assert updated.params.uri == "note://todo" + assert updated.params.uri == "board://sprint" assert updated.params.meta == ack.params.meta - await client.call_tool("enable_search", {}) + await client.call_tool("enable_reports", {}) await stream.wait_for(3) assert isinstance(stream.received[2], types.ToolListChangedNotification) @@ -91,16 +156,16 @@ async def listen() -> None: # The list_changed told us to re-fetch: the new tool is there, and the # session outlives the closed stream. tools = await client.list_tools() - assert "search" in {tool.name for tool in tools.tools} - contents = (await client.read_resource("note://todo")).contents[0] + assert "sprint_report" in {tool.name for tool in tools.tools} + contents = (await client.read_resource("board://sprint")).contents[0] assert isinstance(contents, types.TextResourceContents) - assert contents.text == "water plants" + assert contents.text == "[x] design\n[ ] build\n[ ] ship" async def test_publish_with_no_subscribers_is_a_no_op() -> None: """tutorial001: publishing to an idle server does nothing and breaks nothing.""" async with Client(tutorial001.mcp, mode="2026-07-28") as client: - result = await client.call_tool("edit_note", {"name": "todo", "text": "buy milk"}) + result = await client.call_tool("complete_task", {"board": "sprint", "task": "design"}) assert result.is_error is not True @@ -109,30 +174,130 @@ async def test_lowlevel_composition_serves_the_same_stream() -> None: stream = _Stream() async with Client(tutorial002.server, mode="2026-07-28", message_handler=stream.handler) as client: tools = await client.list_tools() - assert [tool.name for tool in tools.tools] == ["edit_note"] + assert [tool.name for tool in tools.tools] == ["complete_task"] async with anyio.create_task_group() as tg: async def listen() -> None: await client.session.send_request( - _listen_request(resource_subscriptions=["note://todo"]), + _listen_request(resource_subscriptions=["board://sprint"]), types.SubscriptionsListenResult, ) tg.start_soon(listen) await stream.wait_for(1) - await client.call_tool("edit_note", {"name": "todo", "text": "water plants"}) + await client.call_tool("complete_task", {"task": "design"}) await stream.wait_for(2) updated = stream.received[1] assert isinstance(updated, types.ResourceUpdatedNotification) - assert updated.params.uri == "note://todo" + assert updated.params.uri == "board://sprint" # The bus you constructed is also the publish surface outside a # request; an unrequested kind never reaches this stream. await tutorial002.bus.publish(ToolsListChanged()) - await client.call_tool("edit_note", {"name": "todo", "text": "done"}) + await client.call_tool("complete_task", {"task": "build"}) await stream.wait_for(3) assert isinstance(stream.received[2], types.ResourceUpdatedNotification) tg.cancel_scope.cancel() + + +async def test_follow_board_prints_the_refetched_board_and_the_new_tool_list( + capsys: pytest.CaptureFixture[str], +) -> None: + """tutorial003: each event drives a refetch - the board reprints, and a tools change reprints the tool names.""" + async with Client(tutorial001.mcp) as client: + async with anyio.create_task_group() as tg: + tg.start_soon(tutorial003.follow_board, client) + # Let the watcher park on its stream (ack complete) before publishing. + await anyio.wait_all_tasks_blocked() + await client.call_tool("complete_task", {"board": "sprint", "task": "design"}) + await anyio.wait_all_tasks_blocked() + await client.call_tool("enable_reports", {}) + await anyio.wait_all_tasks_blocked() + tg.cancel_scope.cancel() + + printed = capsys.readouterr().out + assert "[x] design\n[ ] build\n[ ] ship" in printed + assert "sprint_report" in printed + + +EMPTY_BOARD = "[ ] design\n[ ] build\n[ ] ship" +FINISHED_BOARD = "[x] design\n[x] build\n[x] ship" + + +def _assert_snapshot_then_current_board(printed: str) -> None: + """The snapshot taken inside the open subscription came first, and the watcher ended up current. + + How many times the watcher printed is deliberately not asserted: identical events that pile up + unconsumed coalesce, so a fast main flow can turn three completions into one refetch. What the + stream guarantees is that no change after the acknowledgment is missed. + """ + assert printed.startswith(EMPTY_BOARD), printed + assert printed.strip().endswith(FINISHED_BOARD), printed + + +async def test_the_asyncio_watcher_runs_beside_the_main_flow(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial004 (asyncio tab): run_sprint opens the subscription, snapshots the board, then a watcher + task reprints it while the main flow keeps calling tools. + + The example connects over HTTP; the in-memory client here is the maintainer-side stand-in.""" + async with Client(tutorial001.mcp) as client: + await tutorial004_asyncio.run_sprint(client) + _assert_snapshot_then_current_board(capsys.readouterr().out) + + +@pytest.mark.parametrize("anyio_backend", [pytest.param("trio", id="trio")]) +async def test_the_trio_watcher_runs_beside_the_main_flow(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial004 (trio tab): the same shape as the asyncio tab, with a nursery owning the watcher.""" + async with Client(tutorial001.mcp) as client: + await tutorial004_trio.run_sprint(client) + _assert_snapshot_then_current_board(capsys.readouterr().out) + + +async def test_the_anyio_watcher_runs_beside_the_main_flow(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial004 (anyio tab): the same shape again, with a task group owning the watcher.""" + async with Client(tutorial001.mcp) as client: + await tutorial004_anyio.run_sprint(client) + _assert_snapshot_then_current_board(capsys.readouterr().out) + + +@pytest.mark.parametrize( + "anyio_backend", + [pytest.param(("trio", {"clock": MockClock(autojump_threshold=0)}), id="trio-mockclock")], +) +async def test_the_follower_re_listens_after_the_stream_ends(capsys: pytest.CaptureFixture[str]) -> None: + """tutorial005: a graceful server close ends one stream; the loop backs off, re-listens, and refetches. + + Runs on trio's autojumping MockClock so the loop's backoff sleep takes no wall-clock time. + """ + reads = _Reads() + handler = ListenHandler(tutorial002.bus) + server = Server( + "sprint-board", + on_read_resource=reads.counting(tutorial002.read_resource), + on_list_tools=tutorial002.list_tools, + on_call_tool=tutorial002.call_tool, + on_subscriptions_listen=handler, + ) + + async with Client(server) as client: + async with anyio.create_task_group() as tg: + tg.start_soon(tutorial005.keep_following, client) + # First stream: the entry refetch reads the board, then an event reads it again. + await reads.wait_for(1) + await client.call_tool("complete_task", {"task": "design"}) + await reads.wait_for(2) + + # End that stream gracefully. The loop backs off (the mock clock jumps the + # sleep), re-listens, and refetches on entry: that is the third read. + handler.close() + await reads.wait_for(3) + await client.call_tool("complete_task", {"task": "build"}) + await reads.wait_for(4) + tg.cancel_scope.cancel() + + printed = capsys.readouterr().out + assert "[x] design\n[ ] build" in printed # first stream, after design + assert "[x] design\n[x] build" in printed # second stream, after build diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index b7b7465f03..0ce1239f4e 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -486,12 +486,6 @@ def __post_init__(self) -> None: "id up front is the SDK surface that makes it satisfiable." ), added_in="2026-07-28", - deferred=( - "No public API surface yet: the capability exists at the dispatcher seam " - "(CallOptions['request_id'], unit-tested there), but ClientSession.send_request does not " - "expose it. The public consumer arrives with the client-side listen driver (Client.listen), " - "whose interaction tests will exercise it end to end." - ), ), "protocol:notifications:no-response": Requirement( source=f"{SPEC_BASE_URL}/basic#notifications", @@ -1229,6 +1223,59 @@ def __post_init__(self) -> None: removed_in="2026-07-28", note="removed in 2026-07-28 (SEP-2575); resources/unsubscribe replaced by subscriptions/listen.", ), + "subscriptions:listen:client:honored-surfacing": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/subscriptions#acknowledgment", + behavior=( + "Entering Client.listen() waits for the server's acknowledgment and surfaces the honored " + "filter subset on the handle, so the client can check it against what it requested (spec SHOULD)." + ), + added_in="2026-07-28", + ), + "subscriptions:listen:client:concurrent-demux": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/subscriptions#multiple-concurrent-subscriptions", + behavior=( + "Concurrently open subscriptions each surface their own acknowledgment: with both listen " + "requests in flight before either ack arrives, each handle's honored filter is the subset " + "for its own request, routed by subscription id rather than broadcast to every open route." + ), + added_in="2026-07-28", + ), + "subscriptions:listen:client:iteration": Requirement( + source="sdk", + behavior=( + "An open subscription is an async iterator of typed change events; delivered notifications " + "still tee to message_handler so caching and observers keep working." + ), + added_in="2026-07-28", + ), + "subscriptions:listen:client:graceful-close": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/subscriptions#cancellation", + behavior=( + "The server's empty subscriptions/listen result (its deliberate close) ends iteration cleanly " + "after buffered events drain; no exception is raised." + ), + added_in="2026-07-28", + ), + "subscriptions:listen:client:lost": Requirement( + source="sdk", + behavior=( + "A listen stream that ends without the graceful result raises SubscriptionLost from iteration; " + "there is no automatic re-listen." + ), + added_in="2026-07-28", + ), + "subscriptions:listen:client:era-guard": Requirement( + source="sdk", + behavior=( + "Client.listen() on a pre-2026 connection raises ListenNotSupportedError steering to " + "subscribe_resource/message_handler instead of leaking a wire -32601." + ), + removed_in="2026-07-28", + note=( + "removed_in scopes the matrix to the 2025 cells deliberately: the behavior under test is the " + "guard on connections where the method does not exist." + ), + ), "resources:updated-notification": Requirement( source=f"{SPEC_BASE_URL}/server/resources#subscriptions", behavior=( diff --git a/tests/interaction/lowlevel/test_resources.py b/tests/interaction/lowlevel/test_resources.py index 44ab33e64a..db7d4dfe60 100644 --- a/tests/interaction/lowlevel/test_resources.py +++ b/tests/interaction/lowlevel/test_resources.py @@ -203,6 +203,7 @@ async def list_resource_templates( ) +@pytest.mark.filterwarnings("ignore::mcp.MCPDeprecationWarning") @requirement("resources:subscribe") async def test_subscribe_resource_delivers_uri_to_handler(connect: Connect) -> None: """Subscribing to a resource delivers the URI to the server's subscribe handler and returns an empty result.""" @@ -214,11 +215,12 @@ async def subscribe_resource(ctx: ServerRequestContext, params: types.SubscribeR server = Server("library", on_subscribe_resource=subscribe_resource) async with connect(server) as client: - result = await client.subscribe_resource("file:///watched.txt") + result = await client.subscribe_resource("file:///watched.txt") # pyright: ignore[reportDeprecated] assert result == snapshot(EmptyResult()) +@pytest.mark.filterwarnings("ignore::mcp.MCPDeprecationWarning") @requirement("resources:subscribe:capability-required") async def test_subscribe_without_a_subscribe_handler_is_method_not_found(connect: Connect) -> None: """Subscribing to a server that registered no subscribe handler is rejected with METHOD_NOT_FOUND. @@ -237,13 +239,14 @@ async def list_resources( async with connect(server) as client: with pytest.raises(MCPError) as exc_info: - await client.subscribe_resource("file:///watched.txt") + await client.subscribe_resource("file:///watched.txt") # pyright: ignore[reportDeprecated] assert exc_info.value.error == snapshot( ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="resources/subscribe") ) +@pytest.mark.filterwarnings("ignore::mcp.MCPDeprecationWarning") @requirement("resources:unsubscribe") async def test_unsubscribe_resource_delivers_uri_to_handler(connect: Connect) -> None: """Unsubscribing from a resource delivers the URI to the server's unsubscribe handler.""" @@ -255,7 +258,7 @@ async def unsubscribe_resource(ctx: ServerRequestContext, params: types.Unsubscr server = Server("library", on_unsubscribe_resource=unsubscribe_resource) async with connect(server) as client: - result = await client.unsubscribe_resource("file:///watched.txt") + result = await client.unsubscribe_resource("file:///watched.txt") # pyright: ignore[reportDeprecated] assert result == snapshot(EmptyResult()) diff --git a/tests/interaction/lowlevel/test_subscriptions.py b/tests/interaction/lowlevel/test_subscriptions.py new file mode 100644 index 0000000000..e22590c9e1 --- /dev/null +++ b/tests/interaction/lowlevel/test_subscriptions.py @@ -0,0 +1,142 @@ +"""Client.listen stream endings against lowlevel servers over the connect matrix.""" + +from typing import Any + +import anyio +import mcp_types as types +import pytest + +from mcp import MCPError +from mcp.client.subscriptions import SubscriptionLost, ToolsListChanged +from mcp.server import Server, ServerRequestContext +from mcp.server.subscriptions import SUBSCRIPTION_ID_META_KEY, InMemorySubscriptionBus, ListenHandler +from tests.interaction._connect import Connect +from tests.interaction._requirements import requirement + +pytestmark = pytest.mark.anyio + + +@requirement("subscriptions:listen:client:graceful-close") +async def test_a_graceful_server_close_ends_iteration_after_buffered_events(connect: Connect) -> None: + """`ListenHandler.close()` sends the result last; iteration drains published events, then ends cleanly.""" + bus = InMemorySubscriptionBus() + handler = ListenHandler(bus) + server = Server("subs", on_subscriptions_listen=handler) + events: list[object] = [] + async with connect(server) as client: + with anyio.fail_after(10): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + await bus.publish(ToolsListChanged()) + handler.close() + events.extend([event async for event in sub]) + assert events == [ToolsListChanged()] + + +@requirement("subscriptions:listen:client:lost") +async def test_a_stream_dropped_after_the_ack_raises_subscription_lost(connect: Connect) -> None: + """Erroring the listen request after the ack (abrupt, not graceful) raises SubscriptionLost from iteration.""" + proceed = anyio.Event() + + async def dropping_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + assert ctx.request_id is not None + await ctx.session.send_notification( + types.SubscriptionsAcknowledgedNotification( + params=types.SubscriptionsAcknowledgedNotificationParams( + notifications=params.notifications, + _meta={SUBSCRIPTION_ID_META_KEY: ctx.request_id}, + ) + ), + related_request_id=ctx.request_id, + ) + await proceed.wait() + raise MCPError(types.INTERNAL_ERROR, "stream torn down") + + server = Server("subs", on_subscriptions_listen=dropping_listen) + async with connect(server) as client: + with anyio.fail_after(10): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + proceed.set() + with pytest.raises(SubscriptionLost): # pragma: no branch + await anext(sub) + + +@requirement("protocol:request-id:caller-supplied") +async def test_the_subscription_id_is_the_listen_request_id_the_server_saw(connect: Connect) -> None: + """The handle's `subscription_id` is the listen request's own JSON-RPC id, known to the caller + while the request is still in flight - the key the server stamps every frame with for demux. + + The assertion runs inside the open stream: the ack has arrived but the listen request's + response has not, so the id cannot have come from a response. + """ + bus = InMemorySubscriptionBus() + stock = ListenHandler(bus) + seen: list[types.RequestId] = [] + + async def recording_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + assert ctx.request_id is not None + seen.append(ctx.request_id) + return await stock(ctx, params) + + server = Server("subs", on_subscriptions_listen=recording_listen) + async with connect(server) as client: + with anyio.fail_after(10): + async with client.listen(tools_list_changed=True) as sub: # pragma: no branch + assert seen == [sub.subscription_id] + stock.close() + async for _event in sub: + raise NotImplementedError # unreachable: nothing was published + + +@requirement("subscriptions:listen:client:concurrent-demux") +@requirement("protocol:request-id:caller-supplied") +async def test_concurrent_listen_streams_each_receive_their_own_ack(connect: Connect) -> None: + """Two subscriptions opened concurrently each surface the honored filter of their own request: + ack frames route by subscription id, not broadcast to every open route. + + The server gates both acks until both listen requests have arrived, so both client routes are + live and unacknowledged when the first ack lands - a client that broadcast subscription frames + would cross-pollute that ack into both handles. + """ + bus = InMemorySubscriptionBus() + stock = ListenHandler(bus) + arrived: list[types.RequestId] = [] + both_arrived = anyio.Event() + + async def gated_listen( + ctx: ServerRequestContext[Any, Any], params: types.SubscriptionsListenRequestParams + ) -> types.SubscriptionsListenResult: + assert ctx.request_id is not None + arrived.append(ctx.request_id) + if len(arrived) == 2: + both_arrived.set() + with anyio.fail_after(10): + await both_arrived.wait() + return await stock(ctx, params) + + server = Server("subs", on_subscriptions_listen=gated_listen) + honored: dict[str, types.SubscriptionFilter] = {} + + async with connect(server) as client: + + async def open_tools() -> None: + async with client.listen(tools_list_changed=True) as sub: + honored["tools"] = sub.honored + + async def open_prompts() -> None: + async with client.listen(prompts_list_changed=True) as sub: + honored["prompts"] = sub.honored + + with anyio.fail_after(10): + async with anyio.create_task_group() as tg: # pragma: no branch + tg.start_soon(open_tools) + tg.start_soon(open_prompts) + + assert honored == { + "tools": types.SubscriptionFilter(tools_list_changed=True), + "prompts": types.SubscriptionFilter(prompts_list_changed=True), + } + assert len(set(arrived)) == 2 diff --git a/tests/interaction/mcpserver/test_subscriptions.py b/tests/interaction/mcpserver/test_subscriptions.py new file mode 100644 index 0000000000..047b049d9a --- /dev/null +++ b/tests/interaction/mcpserver/test_subscriptions.py @@ -0,0 +1,62 @@ +"""Client.listen against MCPServer over the connect matrix (2026-07-28).""" + +import anyio +import pytest + +from mcp.client.subscriptions import ListenNotSupportedError, ResourceUpdated, ToolsListChanged +from mcp.server.mcpserver import Context, MCPServer +from tests.interaction._connect import Connect +from tests.interaction._requirements import requirement + +pytestmark = pytest.mark.anyio + + +def _notebook() -> MCPServer: + mcp = MCPServer("notebook") + + @mcp.tool() + async def touch_tools(ctx: Context) -> str: + await ctx.notify_tools_changed() + return "ok" + + @mcp.tool() + async def edit_note(name: str, ctx: Context) -> str: + await ctx.notify_resource_updated(f"note://{name}") + return "saved" + + return mcp + + +@requirement("subscriptions:listen:client:honored-surfacing") +@requirement("subscriptions:listen:client:iteration") +async def test_listen_surfaces_the_ack_and_iterates_typed_events(connect: Connect) -> None: + """Entering waits for the ack (honored is set before any event); iteration yields + only the typed event kinds this stream opted in to.""" + mcp = _notebook() + async with connect(mcp) as client: + with anyio.fail_after(10): + async with client.listen( # pragma: no branch + tools_list_changed=True, resource_subscriptions=["note://todo"] + ) as sub: + assert sub.honored.tools_list_changed is True + assert sub.honored.resource_subscriptions == ["note://todo"] + + await client.call_tool("edit_note", {"name": "journal"}) # unsubscribed URI: silent + await client.call_tool("edit_note", {"name": "todo"}) + assert await anext(sub) == ResourceUpdated(uri="note://todo") + + await client.call_tool("touch_tools", {}) + assert await anext(sub) == ToolsListChanged() + + +@requirement("subscriptions:listen:client:era-guard") +async def test_listen_on_a_pre_2026_connection_raises_the_typed_steer(connect: Connect) -> None: + """On 2025-era connections the guard fires before anything touches the wire, steering to the legacy verbs.""" + mcp = _notebook() + async with connect(mcp) as client: + with anyio.fail_after(10): + # Entering is where the guard fires; __aenter__ directly avoids an unreachable with-body. + with pytest.raises(ListenNotSupportedError) as exc_info: + await client.listen(tools_list_changed=True).__aenter__() + assert exc_info.value.negotiated_version == client.session.protocol_version + assert "subscribe_resource" in str(exc_info.value) diff --git a/tests/shared/test_dispatcher.py b/tests/shared/test_dispatcher.py index 03ef27c8db..c6ebb401ff 100644 --- a/tests/shared/test_dispatcher.py +++ b/tests/shared/test_dispatcher.py @@ -25,7 +25,7 @@ from mcp.shared._compat import resync_tracer from mcp.shared.direct_dispatcher import DirectDispatcher, create_direct_dispatcher_pair -from mcp.shared.dispatcher import DispatchContext, Dispatcher, OnNotify, OnRequest, Outbound +from mcp.shared.dispatcher import DispatchContext, Dispatcher, OnNotify, OnNotifyIntercept, OnRequest, Outbound from mcp.shared.exceptions import MCPError from mcp.shared.transport_context import TransportContext @@ -66,6 +66,7 @@ async def running_pair( server_on_notify: OnNotify | None = None, client_on_request: OnRequest | None = None, client_on_notify: OnNotify | None = None, + client_on_notify_intercept: OnNotifyIntercept | None = None, can_send_request: bool = True, ) -> AsyncIterator[tuple[Dispatcher[TransportContext], Dispatcher[TransportContext], Recorder, Recorder]]: """Yield `(client, server, client_recorder, server_recorder)` with both `run()` loops live.""" @@ -75,7 +76,9 @@ async def running_pair( s_req, s_notify = echo_handlers(server_rec) try: async with anyio.create_task_group() as tg: - await tg.start(client.run, client_on_request or c_req, client_on_notify or c_notify) + await tg.start( + client.run, client_on_request or c_req, client_on_notify or c_notify, client_on_notify_intercept + ) await tg.start(server.run, server_on_request or s_req, server_on_notify or s_notify) try: yield client, server, client_rec, server_rec @@ -509,6 +512,67 @@ async def first() -> None: assert await client.send_raw_request("again", None, {"request_id": "7"}) == {} +@pytest.mark.anyio +async def test_notify_intercept_sees_every_notification_and_consumes_on_true(pair_factory: PairFactory): + """The intercept sees every inbound notification; a frame it consumes never reaches `on_notify`, the rest do.""" + intercepted: list[str] = [] + + def intercept(method: str, params: Mapping[str, Any] | None) -> bool: + intercepted.append(method) + return method == "notifications/consumed" + + async with running_pair(pair_factory, client_on_notify_intercept=intercept) as (_client, server, crec, _srec): + with anyio.fail_after(5): + await server.notify("notifications/consumed", None) + await server.notify("notifications/passed", None) + await crec.notified.wait() + assert intercepted == ["notifications/consumed", "notifications/passed"] + assert [method for method, _ in crec.notifications] == ["notifications/passed"] + + +@pytest.mark.anyio +async def test_notify_intercept_completes_before_a_later_response_resolves(pair_factory: PairFactory): + """Notifications written before a response are intercepted before it resolves, whatever spawned handlers do.""" + seen: list[str] = [] + + def intercept(method: str, params: Mapping[str, Any] | None) -> bool: + seen.append(method) + return False + + async def notify_then_answer( + ctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None + ) -> dict[str, Any]: + await ctx.notify("notifications/first", None) + await ctx.notify("notifications/second", None) + return {} + + async with running_pair( + pair_factory, server_on_request=notify_then_answer, client_on_notify_intercept=intercept + ) as (client, *_): + with anyio.fail_after(5): + await client.send_raw_request("burst", None) + assert seen == ["notifications/first", "notifications/second"] + + +@pytest.mark.anyio +async def test_a_raising_notify_intercept_is_contained_and_passes_the_frame_through(pair_factory: PairFactory): + """An intercept exception costs only that interception: the frame still reaches `on_notify`, the loop survives.""" + + def broken_intercept(method: str, params: Mapping[str, Any] | None) -> bool: + raise RuntimeError("intercept exploded") + + async with running_pair(pair_factory, client_on_notify_intercept=broken_intercept) as ( + _client, + server, + crec, + _srec, + ): + with anyio.fail_after(5): + await server.notify("notifications/survives", None) + await crec.notified.wait() + assert [method for method, _ in crec.notifications] == ["notifications/survives"] + + if TYPE_CHECKING: _d: Dispatcher[TransportContext] = DirectDispatcher(TransportContext(kind="direct", can_send_request=True)) _o: Outbound = _d From 148278e07fbb316cdcf2a4624677f03abe8703bd Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:32:53 +0100 Subject: [PATCH 053/100] Gate the test matrix and retry setup-uv's flaky manifest fetch (#3080) --- .github/workflows/shared.yml | 56 +++++++++++++++++++++++++++++++++--- RELEASE.md | 6 ++-- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index 36ef4f2377..511d0340d1 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -17,10 +17,23 @@ jobs: with: persist-credentials: false - - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + # setup-uv's manifest fetch is a single request with a hard 5s timeout + # (astral-sh/setup-uv#869); retry once. Drop when upstream adds a retry. + - name: Install uv + id: setup-uv + continue-on-error: true + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + version: 0.9.5 + + - name: Install uv (retry) + if: steps.setup-uv.outcome == 'failure' + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true version: 0.9.5 + - name: Install dependencies run: uv sync --frozen --all-extras --python 3.10 @@ -44,8 +57,8 @@ jobs: name: test (${{ matrix.python-version }}, ${{ matrix.dep-resolution.name }}, ${{ matrix.os }}) runs-on: ${{ matrix.os }} timeout-minutes: 10 - continue-on-error: true strategy: + fail-fast: false matrix: python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] dep-resolution: @@ -60,7 +73,18 @@ jobs: with: persist-credentials: false + # setup-uv's manifest fetch is a single request with a hard 5s timeout + # (astral-sh/setup-uv#869); retry once. Drop when upstream adds a retry. - name: Install uv + id: setup-uv + continue-on-error: true + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + version: 0.9.5 + + - name: Install uv (retry) + if: steps.setup-uv.outcome == 'failure' uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true @@ -92,7 +116,19 @@ jobs: with: persist-credentials: false - - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + # setup-uv's manifest fetch is a single request with a hard 5s timeout + # (astral-sh/setup-uv#869); retry once. Drop when upstream adds a retry. + - name: Install uv + id: setup-uv + continue-on-error: true + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + version: 0.9.5 + + - name: Install uv (retry) + if: steps.setup-uv.outcome == 'failure' + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true version: 0.9.5 @@ -115,7 +151,19 @@ jobs: with: persist-credentials: false - - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + # setup-uv's manifest fetch is a single request with a hard 5s timeout + # (astral-sh/setup-uv#869); retry once. Drop when upstream adds a retry. + - name: Install uv + id: setup-uv + continue-on-error: true + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + version: 0.9.5 + + - name: Install uv (retry) + if: steps.setup-uv.outcome == 'failure' + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true version: 0.9.5 diff --git a/RELEASE.md b/RELEASE.md index 70eef5d692..f86da2ea67 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -39,9 +39,9 @@ the publish job — `skip-existing` makes it skip whatever already landed. The commit — and therefore the README PyPI publishes — names the version being released. When entering a new phase (alpha → beta → rc), update the banner wording too. -2. Check the full test matrix is green on the release commit. The matrix runs - with `continue-on-error`, so a green workflow run does not mean the tests - passed — check the individual jobs. +2. Check the full test matrix is green on the release commit. The publish + workflow re-runs the checks and blocks publishing until they pass, so a + red leg there means re-running the failed jobs on the Publishing run. 3. Create the release as a pre-release, passing the exact commit verified in step 2 as `--target` (otherwise the tag is created from whatever `main`'s HEAD is by then). The tagged commit determines everything about the From 74a242ae7f52aee21cce7128a54ae8522c2f7c50 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:57:14 +0100 Subject: [PATCH 054/100] ci: pick the docs-preview toolchain from the PR checkout (#3081) --- .github/workflows/docs-preview.yml | 39 +++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml index bf10369cc9..6f9ec2cc34 100644 --- a/.github/workflows/docs-preview.yml +++ b/.github/workflows/docs-preview.yml @@ -1,13 +1,14 @@ name: Docs Preview -# Builds the mkdocs site for a PR and deploys it to Cloudflare Pages. +# Builds the docs site for a PR and deploys it to Cloudflare Pages. # -# Security: mkdocs executes Python from the PR (mkdocstrings imports src/mcp, -# `!!python/name:` directives). The build is gated by `authorize` (admin sender -# for auto-preview, admin/maintainer commenter for /preview-docs) and isolated -# from Cloudflare secrets — `build` runs PR code with no secrets and hands the -# static site to `deploy` via an artifact, so PR code never shares a runner -# with the Cloudflare token. +# Security: the build executes Python from the PR (mkdocstrings imports +# src/mcp, `!!python/name:` config directives run, and heads may ship their +# own build scripts). The build is gated by `authorize` (admin sender for +# auto-preview, admin/maintainer commenter for /preview-docs) and isolated +# from Cloudflare secrets — `build` runs PR code with no secrets and hands +# the static site to `deploy` via an artifact, so PR code never shares a +# runner with the Cloudflare token. # # Required configuration: # - secrets.CLOUDFLARE_API_TOKEN (scope: Account → Cloudflare Pages → Edit) @@ -21,6 +22,7 @@ on: - docs/** - docs_src/** - mkdocs.yml + - scripts/docs/** - pyproject.toml issue_comment: types: [created] @@ -128,17 +130,30 @@ jobs: enable-cache: false version: 0.9.5 - - run: uv sync --frozen --group docs - - run: uv run --frozen --no-sync mkdocs build - env: - # Silence mkdocs-material's MkDocs 2.0 warning banner in CI logs. - NO_MKDOCS_2_WARNING: "1" + # pull_request_target runs this workflow file from the base branch, so + # the whole recipe — dependency sync included — must come from the + # checkout itself: heads that ship scripts/docs/build.sh (the Zensical + # toolchain) build with it; older heads, and v1.x heads previewed via + # /preview-docs, still build with MkDocs. Both arms must write the site + # to site/. Keep the detection in sync with build_site() in + # scripts/build-docs.sh. + - run: | + if [ -f scripts/docs/build.sh ]; then + bash scripts/docs/build.sh + else + uv sync --frozen --group docs + # The env var silences mkdocs-material's MkDocs 2.0 warning banner. + NO_MKDOCS_2_WARNING=1 uv run --frozen --no-sync mkdocs build + fi - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: site path: site/ retention-days: 1 + # An empty site/ means the build arm broke its output contract; fail + # here instead of surfacing as a confusing download error in deploy. + if-no-files-found: error deploy: needs: [authorize, build] From 4fc8882c0239ecfbe5659f0a9faf7e5cfdc07e5c Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Fri, 10 Jul 2026 13:48:46 +0200 Subject: [PATCH 055/100] docs: replace MkDocs with Zensical (#3073) Co-authored-by: Claude Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com> --- .github/workflows/deploy-docs.yml | 4 +- .github/workflows/shared.yml | 23 +- .gitignore | 6 +- docs/extra.css | 107 +++++--- docs/hooks/gen_ref_pages.py | 40 --- docs/hooks/llms_txt.py | 184 -------------- mkdocs.yml | 61 +++-- pyproject.toml | 33 ++- scripts/build-docs.sh | 36 ++- scripts/docs/build.sh | 40 +++ scripts/docs/build_config.py | 90 +++++++ scripts/docs/check_crossrefs.py | 170 +++++++++++++ scripts/docs/gen_ref_pages.py | 232 ++++++++++++++++++ scripts/docs/llms_txt.py | 355 +++++++++++++++++++++++++++ scripts/serve-docs.sh | 19 ++ tests/docs_src/test_shape.py | 2 +- uv.lock | 395 +++++++++--------------------- 17 files changed, 1185 insertions(+), 612 deletions(-) delete mode 100644 docs/hooks/gen_ref_pages.py delete mode 100644 docs/hooks/llms_txt.py create mode 100755 scripts/docs/build.sh create mode 100644 scripts/docs/build_config.py create mode 100644 scripts/docs/check_crossrefs.py create mode 100644 scripts/docs/gen_ref_pages.py create mode 100644 scripts/docs/llms_txt.py create mode 100755 scripts/serve-docs.sh diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index fb16310757..6da800a727 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -14,6 +14,7 @@ on: - src/mcp/** - src/mcp-types/** - scripts/build-docs.sh + - scripts/docs/** - pyproject.toml - uv.lock - .github/workflows/deploy-docs.yml @@ -49,9 +50,6 @@ jobs: - name: Build combined docs (v1.x at /, main at /v2/) run: bash scripts/build-docs.sh site - env: - # Silence mkdocs-material's MkDocs 2.0 warning banner in CI logs. - NO_MKDOCS_2_WARNING: "1" - name: Configure Pages uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 diff --git a/.github/workflows/shared.yml b/.github/workflows/shared.yml index 511d0340d1..c859bbab1d 100644 --- a/.github/workflows/shared.yml +++ b/.github/workflows/shared.yml @@ -139,11 +139,16 @@ jobs: - name: Check README snippets are up to date run: uv run --frozen scripts/update_readme_snippets.py --check - # `mkdocs.yml` sets `strict: true` and `pymdownx.snippets: check_paths: true`, - # but until this job existed the docs were only ever built post-merge by - # `deploy-docs.yml`, so a broken link, a missing nav target, or a deleted - # `docs_src/` include went green on the PR and broke the next deploy of main. - # This is the check path; `deploy-docs.yml` stays the deploy path. + # `scripts/docs/build.sh` is the whole gauntlet: build_config.py fails on + # nav entries without a page and pages without a nav entry, `zensical build + # --strict` fails on broken .md links, `pymdownx.snippets: check_paths: + # true` fails on a deleted `docs_src/` include, and the post-build steps + # fail on unresolved cross-references, inventory download failures, and + # broken non-markdown link targets. + # Until this job existed the docs were only ever built post-merge by + # `deploy-docs.yml`, so those failures went green on the PR and broke the next + # deploy of main. This is the check path; `deploy-docs.yml` stays the deploy + # path. docs: runs-on: ubuntu-latest steps: @@ -168,11 +173,5 @@ jobs: enable-cache: true version: 0.9.5 - - name: Install dependencies - run: uv sync --frozen --all-extras --python 3.10 - - name: Build the docs in strict mode - run: uv run --frozen --no-sync mkdocs build --strict - env: - # Silence mkdocs-material's MkDocs 2.0 warning banner in CI logs. - NO_MKDOCS_2_WARNING: "1" + run: bash scripts/docs/build.sh diff --git a/.gitignore b/.gitignore index 684f8d7b01..2e788e71d8 100644 --- a/.gitignore +++ b/.gitignore @@ -141,9 +141,13 @@ venv.bak/ # Rope project settings .ropeproject -# mkdocs documentation +# documentation /site /.worktrees/ +# Generated at build time by scripts/docs/ (the API reference tree and the +# concrete Zensical config spliced from mkdocs.yml). +/docs/api/ +/mkdocs.gen.yml # mypy .mypy_cache/ diff --git a/docs/extra.css b/docs/extra.css index 8625b05d52..fb7e123888 100644 --- a/docs/extra.css +++ b/docs/extra.css @@ -1,12 +1,42 @@ -/* Sidebar hierarchy + density for MkDocs Material 9.7.x. - All rules scoped to the desktop sidebar breakpoint (>= 76.25em), matching - Material's own scoping for navigation.sections, so the mobile drill-down - drawer keeps stock styling. Colors use Material tokens, so the light and - slate schemes both work without extra palette handling. */ +/* Sidebar hierarchy + density for Zensical's UI (Material-compatible md-* + DOM, but different stock spacing: nav links are 8px-radius pills with + 7px 16px padding). All rules scoped to the desktop sidebar breakpoint + (>= 76.25em) so the mobile drill-down drawer keeps stock styling. Colors + use the md-* tokens, so the light and slate schemes both work without + extra palette handling. */ @media screen and (min-width: 76.25em) { - /* Section labels: smaller, uppercase, letter-spaced, muted. Covers both the - clickable index-page headers and the bare API Reference label. */ + /* The sidebar is one coordinate system derived from the pill inset: + every row — page links, group rows, section labels — is a direct + .md-nav__link child of its item with the same 10px horizontal padding, + so all text shares one column, and hover/active pills always paint + 10px of breathing room inside the scroll container (never clipped). + The padding lives on the elements Zensical paints hover/active pills + on (.md-nav__link[href] anchors and [for] labels — leaf links, bare + section labels, and the inner anchor of an .md-nav__container + wrapper); wrappers stay geometry-neutral, as stock. The 10px inset + also stays >= the 0.4rem pill radius, so the corner curve never + crowds the text. Vertical rhythm has a single knob: the nav list's + flex gap (stock 0.2rem reads airy; 2px matches the density the site + shipped with on Material, ~30px row pitch). */ + .md-sidebar--primary .md-nav__list { + gap: 2px; + } + .md-sidebar--primary .md-nav__item > .md-nav__link:not(.md-nav__container), + .md-sidebar--primary .md-nav__container > .md-nav__link { + padding: 3px 10px; + margin: 0; + } + .md-sidebar--primary .md-nav__item > .md-nav__container { + padding: 0; + margin: 0; + } + + /* Section labels: typography only — geometry comes from the row rule + above, so no specificity coordination is needed. */ + .md-sidebar--primary .md-nav__item--section { + margin: 0.8em 0; + } .md-sidebar--primary .md-nav__item--section > .md-nav__link { font-size: 0.62rem; font-weight: 700; @@ -15,39 +45,19 @@ color: var(--md-default-fg-color--light); } - /* Indent section children and hang a guide line. Material outdents section - children with [dir=ltr] ... margin-left: -0.6rem, which is why they sit - flush under the header; restoring the margin re-exposes the stock 0.6rem - list padding. The logical property ties Material's physical one at - (0,3,0) specificity and wins on source order (extra_css loads last), - while staying direction-agnostic. */ + /* Guide lines: 12px from the item box = 2px right of the label text + (which sits at box + 10px pill inset); children indent past them. */ .md-sidebar--primary .md-nav__item--section > .md-nav { - margin-inline-start: 0.1rem; + margin-inline-start: 12px; border-inline-start: 0.05rem solid var(--md-default-fg-color--lightest); } - - /* Same guide line for collapsible groups inside the API Reference subtree - (section items also carry --nested, so exclude them). */ .md-sidebar--primary .md-nav__item--nested:not(.md-nav__item--section) > .md-nav { + margin-inline-start: 12px; border-inline-start: 0.05rem solid var(--md-default-fg-color--lightest); } - /* Tighten vertical rhythm (stock: 0.625em link margins, 1.25em sections). - The child combinator keeps this off anchors inside md-nav__container, - which carry their own margin-top: 0 stock rule. */ - .md-sidebar--primary .md-nav__item > .md-nav__link { - margin-top: 0.45em; - } - .md-sidebar--primary .md-nav__item--section { - margin: 1em 0; - } - .md-sidebar--primary .md-nav__item--section > .md-nav__link { - margin-top: 0; - } - - /* The current page stands out from its siblings. 700 because Material only - loads Inter at 300/400/700; a 600 would silently substitute the 700 face - anyway, but render lighter on the system-font fallback stack. */ + /* The current page stands out from its siblings (on top of the stock + pill highlight). 700 because only Inter 300/400/700 are loaded. */ .md-sidebar--primary .md-nav__link--active { font-weight: 700; } @@ -60,10 +70,33 @@ } } -/* Headings: Material's 300-weight light-gray defaults read washed out; use - the full foreground color and a solid weight instead. 700, not 600: the - Google Fonts request only carries Inter 300/400/700 (see the nav__link - note above). */ +/* Dark scheme: Zensical's slate canvas is near-black (hsla(225,15%,5%)), + harsher than the Material slate this site shipped with; restore that + blue-grey. Code blocks and other surfaces keep Zensical's own tokens. */ +@media screen { + [data-md-color-scheme="slate"] { + --md-default-bg-color: #1e2129; + } +} + +/* Inline code inside admonitions: the chip token is an absolute dark + surface designed for the page canvas, so on a tinted admonition panel it + sits as an opaque slab (Zensical's own docs share this bug). Re-tint it + tone-on-tone instead — translucent foreground, composited over whatever + the panel color is — the same pattern Starlight and Docusaurus ship for + code inside callouts. Prose chips keep the block-matching dark surface; + block code inside admonitions keeps its own surface too. The first + declaration is the fallback where color-mix is unsupported. */ +.md-typeset .admonition :not(pre) > code, +.md-typeset details :not(pre) > code { + background-color: var(--md-default-fg-color--lightest); + background-color: color-mix(in srgb, currentcolor 11%, transparent); + color: inherit; +} + +/* Headings: the 300-weight light-gray defaults read washed out; use the + full foreground color and a solid weight instead. 700, not 600: only + Inter 300/400/700 are loaded (see the nav__link note above). */ .md-typeset h1, .md-typeset h2 { font-weight: 700; diff --git a/docs/hooks/gen_ref_pages.py b/docs/hooks/gen_ref_pages.py deleted file mode 100644 index 8e1afeee68..0000000000 --- a/docs/hooks/gen_ref_pages.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Generate the code reference pages and navigation.""" - -from pathlib import Path - -import mkdocs_gen_files - -nav = mkdocs_gen_files.Nav() - -root = Path(__file__).parent.parent.parent -src = root / "src" - -# `src/mcp-types` is a distribution directory, not an import package, so each -# package's dotted module path is taken relative to its own parent: deriving it -# from `src/` would emit the unimportable `mcp-types.mcp_types.*`. -for package in (src / "mcp", src / "mcp-types" / "mcp_types"): - base = package.parent - for path in sorted(package.rglob("*.py")): - module_path = path.relative_to(base).with_suffix("") - doc_path = path.relative_to(base).with_suffix(".md") - full_doc_path = Path("api", doc_path) - - parts = tuple(module_path.parts) - - if parts[-1] == "__init__": - parts = parts[:-1] - doc_path = doc_path.with_name("index.md") - full_doc_path = full_doc_path.with_name("index.md") - elif parts[-1].startswith("_"): - continue - - nav[parts] = doc_path.as_posix() - - with mkdocs_gen_files.open(full_doc_path, "w") as fd: - ident = ".".join(parts) - fd.write(f"::: {ident}") - - mkdocs_gen_files.set_edit_path(full_doc_path, path.relative_to(root)) - -with mkdocs_gen_files.open("api/SUMMARY.md", "w") as nav_file: - nav_file.writelines(nav.build_literate_nav()) diff --git a/docs/hooks/llms_txt.py b/docs/hooks/llms_txt.py deleted file mode 100644 index c6dea3196a..0000000000 --- a/docs/hooks/llms_txt.py +++ /dev/null @@ -1,184 +0,0 @@ -"""Generate llms.txt, llms-full.txt, and per-page markdown (https://llmstxt.org/). - -The hook publishes three artifacts into the built site: - -- `llms.txt`: a markdown index of the documentation, one link per page, - grouped by nav section. -- a `.md` rendition of every prose page next to its HTML (e.g. - `servers/tools/index.md`), which is what the llms.txt links point at. -- `llms-full.txt`: every prose page concatenated for single-fetch consumption. - -Page markdown is the source markdown with `--8<--` snippet includes resolved -(so the `docs_src/` code examples appear inline) and relative links rewritten -to absolute URLs. The API reference pages under `api/` are mkdocstrings stubs -with no markdown source, so they are linked as rendered HTML from an Optional -section instead of being embedded. - -Incremental builds (`mkdocs build --dirty`) are rejected: they skip unmodified -pages, which would silently truncate the generated artifacts. -""" - -from __future__ import annotations - -import posixpath -import re -from dataclasses import dataclass, field -from pathlib import Path - -from mkdocs.config.defaults import MkDocsConfig -from mkdocs.exceptions import PluginError -from mkdocs.structure.files import File, Files -from mkdocs.structure.nav import Navigation, Section -from mkdocs.structure.pages import Page - -# Pages with no markdown source, linked as HTML under "## Optional". -_OPTIONAL_PAGES = [ - ("api/mcp/index.md", "mcp API reference", "Auto-generated API reference for the mcp package (rendered HTML)"), - ( - "api/mcp_types/index.md", - "mcp-types API reference", - "Auto-generated API reference for the mcp-types package (rendered HTML)", - ), -] - -_SNIPPET_LINE = re.compile(r'^(?P[ \t]*)--8<-- "(?P[^"\n]+)"$', flags=re.MULTILINE) -_MD_LINK = re.compile(r'(\]\()([^)\s]+\.md)(#[^)\s]*)?( +"[^"]*")?(\))') - - -@dataclass -class _State: - page_markdown: dict[str, str] = field(default_factory=dict) - rendition_uris: set[str] = field(default_factory=set) - nav: Navigation | None = None - files: Files | None = None - - -_state = _State() - - -def _site_url(config: MkDocsConfig) -> str: - assert config.site_url is not None - return config.site_url.rstrip("/") + "/" - - -def _md_uri(file: File) -> str: - return re.sub(r"\.html$", ".md", file.dest_uri) - - -def on_config(config: MkDocsConfig) -> None: - # `mkdocs serve` rebuilds reuse the imported module; start each build clean. - _state.page_markdown.clear() - _state.rendition_uris.clear() - _state.nav = _state.files = None - - -def on_nav(nav: Navigation, config: MkDocsConfig, files: Files) -> None: - _state.nav = nav - _state.files = files - _state.rendition_uris.update(page.file.src_uri for page in nav.pages if not page.file.src_uri.startswith("api/")) - - -def on_page_markdown(markdown: str, page: Page, config: MkDocsConfig, files: Files) -> str | None: - if page.file.src_uri not in _state.rendition_uris: - return None - - # Same anchor as the pymdownx.snippets `base_path` in mkdocs.yml. - repo_root = Path(config.config_file_path).parent - - def include(match: re.Match[str]) -> str: - indent, path = match["indent"], match["path"] - # Mirror the snippets extension's restrict_base_path: reject paths - # that resolve outside the repo root. - resolved_path = (repo_root / path).resolve() - if not resolved_path.is_relative_to(repo_root.resolve()): - raise PluginError(f"llms_txt: snippet path {path!r} in {page.file.src_uri} escapes the repo root") - try: - content = resolved_path.read_text(encoding="utf-8").rstrip("\n") - except OSError as exc: - raise PluginError(f"llms_txt: cannot read snippet {path!r} in {page.file.src_uri}") from exc - # Keep a pointer to the embedded file so readers can find it on disk. - if path.endswith(".py"): - content = f"# {path}\n{content}" - if indent: - content = "\n".join(indent + line if line else line for line in content.split("\n")) - return content - - resolved, substitutions = _SNIPPET_LINE.subn(include, markdown) - if substitutions != sum("--8<--" in line for line in markdown.splitlines()): - raise PluginError(f"llms_txt: unresolved snippet include in {page.file.src_uri}") - - site_url = _site_url(config) - src_dir = posixpath.dirname(page.file.src_uri) - - def rewrite(match: re.Match[str]) -> str: - opening, target, anchor, title, closing = match.groups() - if "://" in target: - return match.group(0) - linked = files.get_file_from_path(posixpath.normpath(posixpath.join(src_dir, target))) - if linked is None: - raise PluginError(f"llms_txt: cannot resolve link target {target!r} in {page.file.src_uri}") - # Pages without a markdown rendition (the api/ stubs) link to their HTML instead. - url = _md_uri(linked) if linked.src_uri in _state.rendition_uris else linked.url - return f"{opening}{site_url}{url}{anchor or ''}{title or ''}{closing}" - - _state.page_markdown[page.file.src_uri] = _MD_LINK.sub(rewrite, resolved) - return None - - -def _section_pages(section: Section) -> list[Page]: - pages: list[Page] = [] - for child in section.children: - if isinstance(child, Page) and child.file.src_uri in _state.rendition_uris: - pages.append(child) - elif isinstance(child, Section): - pages.extend(_section_pages(child)) - return pages - - -def on_post_build(config: MkDocsConfig) -> None: - assert _state.nav is not None and _state.files is not None - missing = _state.rendition_uris - _state.page_markdown.keys() - if missing: - raise PluginError(f"llms_txt: pages skipped this build (is this a --dirty build?): {sorted(missing)}") - - site_dir = Path(config.site_dir) - site_url = _site_url(config) - - top_level = [ - item for item in _state.nav.items if isinstance(item, Page) and item.file.src_uri in _state.rendition_uris - ] - sections: list[tuple[str, list[Page]]] = [("Docs", top_level)] if top_level else [] - for item in _state.nav.items: - if isinstance(item, Section): - pages = _section_pages(item) - if pages: - sections.append((item.title, pages)) - - index = [f"# {config.site_name}", "", f"> {config.site_description}", ""] - full: list[str] = [] - for title, pages in sections: - index += [f"## {title}", ""] - for page in pages: - markdown = _state.page_markdown[page.file.src_uri] - (site_dir / _md_uri(page.file)).write_text(markdown, encoding="utf-8") - - description = page.meta.get("description") - tail = f": {description}" if description else "" - index.append(f"- [{page.title}]({site_url}{_md_uri(page.file)}){tail}") - - body, h1_found = re.subn(r"\A\s*# .+\n", "", markdown) - if not h1_found: - raise PluginError(f"llms_txt: page {page.file.src_uri} does not start with an H1") - full += [f"# {page.title}", "", f"Source: {page.canonical_url}", "", body.strip(), ""] - index.append("") - - index += ["## Optional", ""] - for src_uri, title, description in _OPTIONAL_PAGES: - linked = _state.files.get_file_from_path(src_uri) - if linked is None: - raise PluginError(f"llms_txt: optional page {src_uri} not found") - index.append(f"- [{title}]({site_url}{linked.url}): {description}") - index.append("") - - (site_dir / "llms.txt").write_text("\n".join(index), encoding="utf-8") - (site_dir / "llms-full.txt").write_text("\n".join(full), encoding="utf-8") diff --git a/mkdocs.yml b/mkdocs.yml index f40fcb3726..5b3f777994 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,6 +1,5 @@ site_name: MCP Python SDK site_description: The official Python SDK for the Model Context Protocol -strict: true repo_name: modelcontextprotocol/python-sdk repo_url: https://github.com/modelcontextprotocol/python-sdk @@ -10,6 +9,9 @@ site_url: https://py.sdk.modelcontextprotocol.io/v2/ # TODO(Marcelo): Add Anthropic copyright? # copyright: © Model Context Protocol 2025 to present +# The "API Reference" entry is a placeholder: `scripts/docs/gen_ref_pages.py` +# generates the `docs/api/` tree and `scripts/docs/build_config.py` splices the +# real nested nav in before the build. See `scripts/docs/build.sh`. nav: - MCP Python SDK: index.md - "What's new in v2": whats-new.md @@ -122,45 +124,41 @@ theme: extra_css: - extra.css -# https://www.mkdocs.org/user-guide/configuration/#validation -validation: - omitted_files: warn - absolute_links: warn - unrecognized_links: warn - anchors: warn - markdown_extensions: - tables + - abbr - admonition - attr_list + - def_list + - footnotes - md_in_html + - pymdownx.betterem - pymdownx.details - pymdownx.caret - pymdownx.critic - pymdownx.mark - - pymdownx.superfences # Code examples live as complete, importable, tested files under `docs_src/` # and are included into pages with `--8<-- "docs_src//tutorialNNN.py"` - # (resolved against the repo root regardless of the build's working - # directory; the extension's default base_path is the CWD). - # `check_paths: true` + `strict: true` turn a renamed/deleted example into a - # build failure instead of a silently empty code block. + # (resolved against the repo root, which is the build's working directory). + # `check_paths: true` turns a renamed/deleted example into a build failure + # instead of a silently empty code block. - pymdownx.snippets: - base_path: !relative $config_dir + base_path: [.] check_paths: true - pymdownx.tilde - pymdownx.inlinehilite - pymdownx.highlight: pygments_lang_class: true - - pymdownx.extra: - pymdownx.superfences: - custom_fences: - - name: mermaid - class: mermaid - format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: pymdownx.superfences.fence_code_format + # Zensical re-implements the emoji extension; the generator/index functions + # live under `zensical.extensions.emoji`, not `material.extensions.emoji`. - pymdownx.emoji: - emoji_index: !!python/name:material.extensions.emoji.twemoji - emoji_generator: !!python/name:material.extensions.emoji.to_svg + emoji_index: zensical.extensions.emoji.twemoji + emoji_generator: zensical.extensions.emoji.to_svg options: custom_icons: - docs/.overrides/.icons @@ -170,23 +168,22 @@ markdown_extensions: custom_checkbox: true - sane_lists # this means you can start a list from any number +# Zensical natively watches these beyond docs/: page content is assembled +# from src/ (mkdocstrings) and docs_src/ (snippet includes), so serve +# live-reload must react to both. watch: - src - docs_src -hooks: - - docs/hooks/llms_txt.py - +# Zensical natively re-implements `search`, `glightbox` and `mkdocstrings`; it +# does not run arbitrary MkDocs plugins or hooks. The former `gen-files`, +# `literate-nav` and `llms_txt` hook are handled by the standalone scripts +# under `scripts/docs/` (see scripts/docs/build.sh). The `social` plugin was +# dropped: Zensical has no social-card support, and the cards were gated on +# ENABLE_SOCIAL_CARDS, which no workflow ever set. plugins: - search - - social: - enabled: !ENV [ENABLE_SOCIAL_CARDS, false] - glightbox - - gen-files: - scripts: - - docs/hooks/gen_ref_pages.py - - literate-nav: - nav_file: SUMMARY.md - mkdocstrings: handlers: python: diff --git a/pyproject.toml b/pyproject.toml index e41416b8ac..7c4e4ceed1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,16 +76,29 @@ dev = [ "opentelemetry-sdk>=1.39.1", ] docs = [ - # MkDocs 2.0 is a ground-up rewrite (no plugin system) that is incompatible - # with mkdocs-material and every plugin below; stay on the 1.x line. - "mkdocs>=1.6.1,<2", - "mkdocs-gen-files>=0.5.0", - "mkdocs-glightbox>=0.4.0", - "mkdocs-literate-nav>=0.6.1", - # docs/extra.css overrides Material-internal nav selectors; revisit it on a - # major bump before raising this cap. - "mkdocs-material[imaging]>=9.7.0,<10", - "mkdocstrings-python>=2.0.1", + # Zensical is the Material team's successor to MkDocs; it natively + # re-implements search, glightbox and mkdocstrings but runs no arbitrary + # MkDocs plugins or hooks, so the API reference (formerly gen-files + + # literate-nav) and llms.txt (formerly a hook) are generated by the + # standalone scripts under scripts/docs/. See scripts/docs/build.sh. + # 0.0.48 fixed relative/scoped cross-references for mkdocstrings-python + # (which the mkdocstrings config in mkdocs.yml relies on) but broke + # search; 0.0.50 fixes it. The toolchain is pinned exactly: Zensical is + # pre-1.0 and the build guards key on its rendering behavior, so bumps + # should be deliberate. + "zensical==0.0.50", + # Zensical's mkdocstrings compatibility layer targets the mkdocstrings 1.x / + # mkdocstrings-python 2.0.5+ API (griffe 2 / griffelib); the older + # mkdocstrings 0.30 / python 2.0.1 line renders API pages with an + # unregistered-autorefs KeyError under Zensical. + "mkdocstrings==1.0.4", + "mkdocstrings-python==2.0.5", + # scripts/docs/build_config.py and llms_txt.py read mkdocs.yml directly. + "pyyaml>=6.0.2", + # gen_ref_pages.py imports griffe directly. griffelib is not a typo: it is + # griffe's successor distribution (same author) and still imports as + # `griffe`; the old `griffe` distribution is the incompatible 1.x line. + "griffelib==2.1.0", ] codegen = ["datamodel-code-generator==0.57.0"] diff --git a/scripts/build-docs.sh b/scripts/build-docs.sh index 5a61309acf..8286786091 100755 --- a/scripts/build-docs.sh +++ b/scripts/build-docs.sh @@ -1,13 +1,17 @@ #!/usr/bin/env bash # -# Build combined v1 + v2 MkDocs documentation for GitHub Pages. +# Build combined v1 + v2 documentation for GitHub Pages. # # v1 docs (from the v1.x branch) are placed at the site root. # v2 docs (from main) are placed under /v2/. # -# Both branches are fetched fresh from origin, so the output is identical -# regardless of which branch triggered the workflow. This script is intended -# to run in CI; for local single-branch preview use `uv run mkdocs serve`. +# The two lines use different toolchains: v1.x still builds with MkDocs, while +# main builds with Zensical (which needs a pre-build step to materialise the API +# reference and a post-build step for llms.txt — see scripts/docs/). Each branch +# is fetched fresh from origin and built with its own synced `docs` group, so +# the output is identical regardless of which branch triggered the workflow. +# This script is intended to run in CI; for a local v2 preview use +# `scripts/serve-docs.sh`. # # Usage: # scripts/build-docs.sh [output-dir] @@ -30,7 +34,21 @@ cleanup() { } trap cleanup EXIT -rm -rf "${OUTPUT_DIR:?}"/* +# Build the checked-out worktree into its local `site/`, picking the toolchain +# from the branch's own files rather than hard-coding it here: a branch that +# ships the Zensical build recipe (scripts/docs/build.sh) builds with it, +# otherwise it falls back to MkDocs. This keeps the combined build correct +# regardless of which branch triggered it. Zensical requires site_dir to live +# within the project root, so both paths build to the local `site/` and let +# the caller copy it to its destination. +build_site() { + if [[ -f scripts/docs/build.sh ]]; then + bash scripts/docs/build.sh + else + uv sync --frozen --group docs + NO_MKDOCS_2_WARNING=1 uv run --frozen --no-sync mkdocs build --site-dir site + fi +} build_branch() { local branch="$1" worktree="$2" dest="$3" @@ -43,11 +61,15 @@ build_branch() { ( cd "$worktree" - uv sync --frozen --group docs - uv run --frozen --no-sync mkdocs build --site-dir "$dest" + rm -rf site + build_site + mkdir -p "$dest" + cp -a site/. "$dest/" ) } +rm -rf "${OUTPUT_DIR:?}"/* + build_branch v1.x "$V1_WORKTREE" "$OUTPUT_DIR" build_branch main "$V2_WORKTREE" "$OUTPUT_DIR/v2" diff --git a/scripts/docs/build.sh b/scripts/docs/build.sh new file mode 100755 index 0000000000..8dce3afd4f --- /dev/null +++ b/scripts/docs/build.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# +# Build the v2 documentation site for this checkout into `site/`. +# +# Zensical runs no MkDocs plugins or hooks, so the build is three steps: +# materialise the API reference pages and the concrete config, build the +# site strictly, then generate llms.txt and the per-page markdown +# renditions. This script is the single owner of that recipe, dependency +# sync included — CI (shared.yml, docs-preview.yml) and scripts/build-docs.sh +# all call it. The toolchain detection in docs-preview.yml and build-docs.sh +# keys on this file's path and expects the site under site/. +# +# Usage: +# scripts/docs/build.sh +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Snippet includes (`--8<--`) resolve against the working directory, which +# must therefore be the repo root. +cd "$SCRIPT_DIR/../.." + +uv sync --frozen --group docs + +# Zensical's incremental cache is unsound: a warm rebuild where only some +# pages re-render silently drops cross-references to cache-hit pages, and +# HTML for since-deleted pages lingers in site/. Build cold so the output +# (and the checks below) are deterministic. +rm -rf .cache site + +uv run --frozen --no-sync python scripts/docs/build_config.py +uv run --frozen --no-sync zensical build -f mkdocs.gen.yml --strict + +# Zensical stays green even under --strict when a cross-reference fails to +# resolve (rendered as literal bracket text) or an objects.inv inventory +# fails to download (every link through it silently degrades to plain text); +# MkDocs strict mode aborted on both. Validate the built site instead. +uv run --frozen --no-sync python scripts/docs/check_crossrefs.py --site-dir site + +uv run --frozen --no-sync python scripts/docs/llms_txt.py --site-dir site diff --git a/scripts/docs/build_config.py b/scripts/docs/build_config.py new file mode 100644 index 0000000000..daba648344 --- /dev/null +++ b/scripts/docs/build_config.py @@ -0,0 +1,90 @@ +"""Produce the concrete Zensical build config from `mkdocs.yml`. + +Zensical builds from `mkdocs.yml` directly, but it has no equivalent of +mkdocs-literate-nav: the "API Reference" navigation has to be materialised +as explicit entries. This script regenerates the `docs/api/` tree (via +gen_ref_pages) and writes `mkdocs.gen.yml` with the real API nav spliced +in — that generated file is what `zensical build`/`serve` consumes. + +Usage: + python scripts/docs/build_config.py +""" + +from __future__ import annotations + +import posixpath +import re +from pathlib import Path + +# Both scripts live in this directory, which Python puts on sys.path[0] when +# `build_config.py` is run directly (its documented invocation). +import gen_ref_pages +import yaml + +ROOT = Path(__file__).parent.parent.parent + +# A scheme-prefixed nav value (https:, mailto:, ...) is an external link, not +# a page path (same classifier as llms_txt.py; a `://` test would misread +# scheme-only URIs as pages). +_EXTERNAL = re.compile(r"[a-zA-Z][a-zA-Z0-9+.-]*:") + + +def _nav_pages(nav: list) -> set[str]: + """Collect every local page reference in the nav (external links excluded).""" + pages: set[str] = set() + for entry in nav: + value = next(iter(entry.values())) if isinstance(entry, dict) else entry + if isinstance(value, list): + pages |= _nav_pages(value) + elif not _EXTERNAL.match(value): + pages.add(value) + return pages + + +def _validate_nav(nav: list, docs_dir: Path) -> None: + """Fail on nav/page drift in either direction. + + Zensical (0.0.48) ships a nav entry for a nonexistent page as a broken + link without any diagnostic even under --strict, and publishes a page + that no nav entry reaches as unreachable orphan HTML; MkDocs aborted the + build on both (--strict with `validation.omitted_files: warn`). + Validating here keeps those guarantees. The generated `api/` tree is + exempt from the orphan check: its nav is spliced in from the same + generator that writes the files, so it cannot drift. + """ + pages = _nav_pages(nav) + # Containment before existence: `docs_dir / page` would happily resolve + # an absolute value or a `../` escape against the wrong root. + if escaping := sorted(p for p in pages if p.startswith("/") or posixpath.normpath(p).startswith("..")): + raise SystemExit(f"build_config: nav references pages outside docs/: {escaping}") + if missing := sorted(page for page in pages if not (docs_dir / page).is_file()): + raise SystemExit(f"build_config: nav references pages that don't exist under docs/: {missing}") + # Dot-directories (e.g. `.overrides` theme files) are not pages: the site + # builder ignores them, so the orphan check must too. + relative = (page.relative_to(docs_dir) for page in docs_dir.rglob("*.md")) + on_disk = {page.as_posix() for page in relative if not any(part.startswith(".") for part in page.parts)} + if orphaned := sorted(page for page in on_disk - pages if not page.startswith("api/")): + raise SystemExit(f"build_config: pages under docs/ that no nav entry reaches: {orphaned}") + + +def build_config() -> None: + config = yaml.safe_load((ROOT / "mkdocs.yml").read_text(encoding="utf-8")) + + api_nav = gen_ref_pages.generate() + if not api_nav: + raise SystemExit("build_config: gen_ref_pages produced no API pages — did the src/ layout move?") + for entry in config["nav"]: + if isinstance(entry, dict) and "API Reference" in entry: + entry["API Reference"] = api_nav + break + else: + raise SystemExit("build_config: no 'API Reference' entry found in mkdocs.yml nav") + + _validate_nav(config["nav"], ROOT / "docs") + + output = ROOT / "mkdocs.gen.yml" + output.write_text(yaml.safe_dump(config, sort_keys=False, allow_unicode=True), encoding="utf-8") + + +if __name__ == "__main__": + build_config() diff --git a/scripts/docs/check_crossrefs.py b/scripts/docs/check_crossrefs.py new file mode 100644 index 0000000000..39f866a00f --- /dev/null +++ b/scripts/docs/check_crossrefs.py @@ -0,0 +1,170 @@ +"""Fail the docs build when a page's cross-references did not resolve. + +Zensical (0.0.48) stays green even under `--strict` on two failure modes +MkDocs strict mode aborted on: + +- An unresolvable `[text][identifier]` cross-reference renders as literal + bracket text (mkdocs-autorefs used to warn). The generated API index and + the docstring cross-references rely on such references resolving. +- A failed `objects.inv` inventory download is logged as an ERROR record and + otherwise ignored, silently degrading every link through that inventory + (thousands of standard-library links alone) to plain text. + +Both are caught from the built site itself, so no log-wording change can +disarm the check: an unresolved reference leaves a tell-tale bracket +sequence in prose text (code blocks legitimately contain `][`, e.g. dict +indexing, so only text outside `
`/`` counts), and every inventory
+declared in `mkdocs.yml` must contribute at least one resolved reference —
+an `autorefs-external` anchor, which hand-authored prose links to the same
+host never carry — to the site (an inventory that contributes none is dead
+config and fails too).
+
+Offline contributors can skip the inventory check by setting
+`DOCS_ALLOW_INVENTORY_FAILURE=1`; CI (`CI=true`) never skips it.
+
+Usage:
+    python scripts/docs/check_crossrefs.py --site-dir site
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import re
+import sys
+from html.parser import HTMLParser
+from pathlib import Path
+from urllib.parse import urlsplit
+
+import yaml
+
+ROOT = Path(__file__).parent.parent.parent
+
+# Unresolved cross-reference tell-tales in extracted prose (`\x00` marks a
+# skipped code element, see _ProseTextExtractor): the two-part
+# `[text][identifier]` reconstruction — the identifier part is always plain
+# text, so a code mark inside the second brackets means indexing prose like
+# `data[`x`][`y`]`, not a reference — and the shortcut `[`identifier`]` form,
+# which extracts as `[\x00]` unless a preceding word character or bracket
+# makes it a subscript like `list[`str`]`.
+_UNRESOLVED = re.compile(r"\]\[[^\]\s\x00]*\]|(?]*autorefs-external[^>]*>")
+
+
+class _ProseTextExtractor(HTMLParser):
+    """Collect text outside 
//