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 01/97] 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 00000000..3c967887 --- /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 35f8b6dc..dd132698 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 3443adf7..684f8d7b 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 90dc1f64..4b56a671 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 02/97] 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 46014ef7..6ff85dd8 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 0d4b9ab9..43624549 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 a671ea49..6cf4913f 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 39a5c529..7e075411 100644 --- a/src/mcp/client/_probe.py +++ b/src/mcp/client/_probe.py @@ -11,6 +11,12 @@ Every ``MCPError`` falls back except ``-32022`` with a disjoint modern-only 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 52a9a701..f6125115 100644 --- a/src/mcp/server/_streamable_http_modern.py +++ b/src/mcp/server/_streamable_http_modern.py @@ -22,7 +22,7 @@ import json 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 @@ 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, JSONRPCError, JSONRPCNotification, JSONRPCRequest, @@ -45,13 +42,13 @@ from mcp_types import ( 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 @@ from mcp.shared.inbound import ( 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 @@ if TYPE_CHECKING: logger = logging.getLogger(__name__) -_ModelT = TypeVar("_ModelT", bound=BaseModel) _OK_STATUS = 200 @@ -125,37 +121,20 @@ class _SingleExchangeDispatchContext: 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 73e775a9..8cb7dc42 100644 --- a/src/mcp/server/connection.py +++ b/src/mcp/server/connection.py @@ -42,7 +42,7 @@ from mcp_types import ( ) 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 @@ _RESULT_FOR: dict[type[Request[Any, Any]], type[BaseModel]] = { } +_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 @@ class _NoChannelOutbound: _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 @@ class Connection: 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 @@ class Connection: 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 @@ class Connection: 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 @@ class Connection: 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 81eaa2b8..dc835639 100644 --- a/src/mcp/server/lowlevel/server.py +++ b/src/mcp/server/lowlevel/server.py @@ -691,8 +691,8 @@ class Server(Generic[LifespanResultT]): 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 d5783a59..3b53335a 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -15,7 +15,7 @@ from __future__ import annotations 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._stream_protocols import ReadStream, WriteStream 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_dual_era_loop( 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_dual_era_loop( 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 @@ def modern_on_request(server: Server[LifespanT], lifespan_state: LifespanT) -> O 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 a1baf0f6..c3e0ea33 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 5b4cc547..f8c02c97 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -243,6 +243,26 @@ async def test_raise_exceptions_false_sanitizes_handler_error_on_modern_inproc_p 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 34a347fa..3b5977bf 100644 --- a/tests/client/test_probe.py +++ b/tests/client/test_probe.py @@ -24,6 +24,7 @@ from mcp_types import ( 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 @@ class _StubSession: 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 b615c470..420bfe8d 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 8281e889..29d3f07f 100644 --- a/tests/server/test_runner.py +++ b/tests/server/test_runner.py @@ -30,6 +30,7 @@ from mcp_types import ( 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 test_dual_era_loop_modern_refuses_server_initiated_requests(server: Sr 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 f0c8b1c2..218e34d5 100644 --- a/tests/server/test_stdio.py +++ b/tests/server/test_stdio.py @@ -16,6 +16,7 @@ from mcp_types import ( 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 @@ class _GatedStdin(io.RawIOBase): 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 @@ class _NotifyingStdout(io.RawIOBase): 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 8ba9cb93..2bf9c364 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 03/97] 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 6c51e898..43fbb887 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 0c0876bb..1324ac57 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 cfd4d927..70eef5d6 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 00000000..92af6d17 --- /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 123c85dd..26df8f61 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 7cc15ce3..4e57bae8 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 aac63f4c..381f7fae 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 ba979ccc..5e0976fb 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 31a4d635..e9787da8 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 7e731836..908f08a1 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 7712e062..01287da0 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 698a08f4..bde925d4 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 952231b8..c7a1434f 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 1503979a..1587a1a7 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 18bcc794..9b879f1f 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 5328d12b..ad2f4c63 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 00000000..6a317692 --- /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 00000000..159c1f56 --- /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 f5fe1676..82a113cf 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 96bdd077..f43521aa 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 8d6d9141..6260b72f 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 7bd27a78..3f3f5a6c 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 00000000..eb2b5be4 --- /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 796462f9..35b9bd08 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 bea34f1c..945aa60d 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 62734b38..d5451e23 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 d553de47..57bbb59e 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 d8ac13eb..c6dea319 100644 --- a/docs/hooks/llms_txt.py +++ b/docs/hooks/llms_txt.py @@ -5,7 +5,7 @@ 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. + `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 fe700a0a..a729cfba 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 6cf4913f..186f3d40 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 43624549..221a87dc 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 c72becb8..2eca9273 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 9b5a32a4..b7d731b1 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 00000000..cad564c4 --- /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 aafb1f33..b3cea554 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 00000000..c7a1096d --- /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 80fb83f0..c36f5cea 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 31cc8f08..b7b8750f 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 90efddc2..0cb0a7df 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 00000000..72eda00a --- /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 06fde160..e5e8a765 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 c512e96f..c49860df 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 8c63053a..407f1d9a 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 65ab1794..a146e014 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 120b96e0..8b7ee057 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 51208d72..6cda30eb 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 00000000..621b32c6 --- /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 e7c7ba79..00000000 --- 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 00000000..e69de29b diff --git a/docs_src/deploy/tutorial001.py b/docs_src/deploy/tutorial001.py new file mode 100644 index 00000000..7b002593 --- /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 00000000..8b61aaca --- /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 00000000..8d9d126c --- /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 00000000..5f32c65b --- /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 00000000..e69de29b diff --git a/docs_src/legacy_clients/tutorial001.py b/docs_src/legacy_clients/tutorial001.py new file mode 100644 index 00000000..2f8b1191 --- /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 00000000..53c6475a --- /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 00000000..52f8f1ea --- /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 00000000..e69de29b diff --git a/docs_src/real_host/tutorial001.py b/docs_src/real_host/tutorial001.py new file mode 100644 index 00000000..1cd39c8c --- /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 00000000..e69de29b diff --git a/docs_src/troubleshooting/tutorial001.py b/docs_src/troubleshooting/tutorial001.py new file mode 100644 index 00000000..e83a552d --- /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 00000000..ec687256 --- /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 00000000..e2e07f68 --- /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 00000000..b78fa1d9 --- /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 00000000..ca990da7 --- /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 00000000..2c995f56 --- /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 00000000..051c29c0 --- /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 00000000..1779cffd --- /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 4bfa140b..68c8ac90 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 22b947ba..c7f1a443 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 fda76471..5da05cc4 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 7b947588..c46f81d8 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 708d83db..1feb3403 100644 --- a/tests/client/test_client_caching.py +++ b/tests/client/test_client_caching.py @@ -981,7 +981,7 @@ async def test_a_resource_updated_notification_evicts_that_uris_read_entry() -> 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 4c7554ed..cde0cea5 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 58014879..2fafde0a 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 b1f5c181..43b1262d 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 2948b10f..617d113b 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 06d89358..6dba9277 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 00000000..dde5f648 --- /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 892a8f36..090ca616 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 a28f1087..17933816 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 2b1674a4..11989850 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 1a76a7bb..0c262916 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 afcfd832..adfe23ba 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 00000000..0dfd0af9 --- /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 d78764fd..ec6e98d7 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 fa4b995c..bed5c234 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 96ea42a0..2ef5eb7e 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 cf7842b0..50a9e53d 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 a85eab38..abd45e01 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 00f3af8a..17b153c2 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 45cc4df8..a05577fb 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 1cbab3af..3b0ad571 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 f8e5b19f..73366a98 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 00000000..36b0670d --- /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 85e82783..3fbde00f 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 e6fee8ce..79721c61 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 795b0ccf..c0b900d2 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 cdfe1d93..b664afe9 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 035f7231..5ab73e2e 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 08e2a5ca..c4051794 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 00000000..ee53c264 --- /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 b90e099c..4b2b6eda 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 abfcfba9..0a6adc2a 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 f139f418..9236503a 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 a1e8a7e3..2646eda9 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 04/97] 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 1324ac57..38229766 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 a729cfba..8aa1a5b6 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 186f3d40..9ff5a054 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 00000000..d197833d --- /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 00000000..e69de29b diff --git a/docs_src/whats_new/tutorial001.py b/docs_src/whats_new/tutorial001.py new file mode 100644 index 00000000..5e41ae1c --- /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 5da05cc4..2d4754f1 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 00000000..d9c6143e --- /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 9236503a..0104d539 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 05/97] 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 00000000..67d800b0 --- /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 00000000..8625b05d --- /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 00000000..a280d7fd --- /dev/null +++ b/docs/favicon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/mkdocs.yml b/mkdocs.yml index 2d4754f1..5f19b898 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 c46f81d8..2260ea2e 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 2646eda9..e9abba11 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 06/97] docs: restructure the migration guide around topical groups with a navigation layer (#3058) --- docs/migration.md | 2598 +++++++++++++++++++++++++-------------------- 1 file changed, 1472 insertions(+), 1126 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index 9ff5a054..3c544d00 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 +## Find your changes -### `MCPServer.call_tool()` returns `CallToolResult` +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. -`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. +### Changes almost every project hits -If you call `MCPServer.call_tool()` directly, read `.content` and -`.structured_content` off the returned `CallToolResult` instead of branching on -the result type. +| 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) | -### `MCPServer.get_prompt()` and `read_resource()` may return `InputRequiredResult` +### Find your area -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. +| 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) | -`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. +## Suggested migration order -### `MCPError` raised from an `@mcp.tool()` handler now surfaces as a JSON-RPC error +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). -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`. +## Packaging, dependencies, and CLI -`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. +### Dependency floors raised and new required dependencies -### `streamablehttp_client` removed +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). -The deprecated `streamablehttp_client` function has been removed. Use `streamable_http_client` instead. +| 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 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): - ... +```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 - -# 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): - ... +```toml +dependencies = [ + "mcp>=2,<3", + "sse-starlette>=3", # absorb sse-starlette's own 2.x -> 3.x changes +] ``` -v1's internal client set `follow_redirects=True`; set it explicitly when supplying your own `httpx.AsyncClient` to preserve that behavior. +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`. -### OAuth `callback_handler` returns `AuthorizationCodeResult` +### `mcp dev` and `mcp install` pin the spawned environment to your SDK version -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`. +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. -**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: - -**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 -``` - -**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 -``` - -### `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. - -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`). - -### WebSocket transport removed - -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,548 +204,99 @@ 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`). +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`). -### Server handler results are validated against the protocol schema +**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. -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. +```python +tool.model_dump() # {"name": ..., "input_schema": ...} +tool.model_dump(by_alias=True, mode="json") # {"name": ..., "inputSchema": ...} (wire format) +``` -### Client validates inbound traffic against the protocol schema +Parsing is unaffected: `model_validate()` accepts both camelCase wire JSON and snake_case dumps. -`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). +### Extra fields on MCP types are no longer preserved -### `args` parameter removed from `ClientSessionGroup.call_tool()` +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. -The deprecated `args` parameter has been removed from `ClientSessionGroup.call_tool()`. Use `arguments` instead. +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 +from mcp_types import CallToolRequestParams + +params = CallToolRequestParams( + name="my_tool", + arguments={}, + unknown_field="value", # silently ignored, not stored +) +"unknown_field" in params.model_dump() # False + +# _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 +) +``` + +If you relied on extra fields round-tripping through MCP types, move that data into `_meta`. + +### Resource URI type changed from `AnyUrl` to `str` + +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_group.call_tool("my_tool", args={"key": "value"}) +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 -result = await session_group.call_tool("my_tool", arguments={"key": "value"}) +from mcp_types import Resource + +# 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 ``` -### `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()` - -Use `params=PaginatedRequestParams(cursor=...)` instead. - -**Before (v1):** +If your code passes `AnyUrl` objects to URI fields, convert them to strings: ```python -result = await session.list_resources(cursor="next_page_token") -result = await session.list_tools(cursor="next_page_token") +# If you have an AnyUrl from elsewhere +uri = str(my_any_url) # Convert to string ``` -**After (v2):** +Affected types: -```python -from mcp_types import PaginatedRequestParams +- `Resource.uri` (and subclass `ResourceLink`) +- `ReadResourceRequestParams.uri` +- `ResourceContents.uri` (and subclasses `TextResourceContents`, `BlobResourceContents`) +- `SubscribeRequestParams.uri` +- `UnsubscribeRequestParams.uri` +- `ResourceUpdatedNotificationParams.uri` -result = await session.list_resources(params=PaginatedRequestParams(cursor="next_page_token")) -result = await session.list_tools(params=PaginatedRequestParams(cursor="next_page_token")) -``` - -### `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.) - -### `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 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. - -### `InputRequiredResult` handling differs between `Client` and `ClientSession` - -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](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 — 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)) - -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)) - -`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. - -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` - -The `McpError` exception class has been renamed to `MCPError` for consistent naming with the MCP acronym style used throughout the SDK. - -**Before (v1):** - -```python -from mcp.shared.exceptions import McpError - -try: - result = await session.call_tool("my_tool") -except McpError as e: - print(f"Error: {e.error.message}") -``` - -**After (v2):** - -```python -from mcp.shared.exceptions import MCPError - -try: - result = await session.call_tool("my_tool") -except MCPError as e: - print(f"Error: {e.message}") -``` - -`MCPError` is also exported from the top-level `mcp` package: - -```python -from mcp import MCPError -``` - -The constructor signature also changed — it now takes `code`, `message`, and optional `data` directly instead of wrapping an `ErrorData`: - -**Before (v1):** - -```python -from mcp.shared.exceptions import McpError -from mcp.types import ErrorData, INVALID_REQUEST - -raise McpError(ErrorData(code=INVALID_REQUEST, message="bad input")) -``` - -**After (v2):** - -```python -from mcp.shared.exceptions import MCPError -from mcp_types import INVALID_REQUEST - -raise MCPError(INVALID_REQUEST, "bad input") -# or, if you already have an ErrorData: -raise MCPError.from_error_data(error_data) -``` - -### `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. - -**Before (v1):** - -```python -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP("Demo") -``` - -**After (v2):** - -```python -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). - -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` - -### `mount_path` parameter removed from MCPServer - -The `mount_path` parameter has been removed from `MCPServer.__init__()`, `MCPServer.run()`, `MCPServer.run_sse_async()`, and `MCPServer.sse_app()`. It was also removed from the `Settings` class. - -This parameter was redundant because the SSE transport already handles sub-path mounting via ASGI's standard `root_path` mechanism. When using Starlette's `Mount("/path", app=mcp.sse_app())`, Starlette automatically sets `root_path` in the ASGI scope, and the `SseServerTransport` uses this to construct the correct message endpoint path. - -### Transport-specific parameters moved from MCPServer constructor to run()/app methods - -Transport-specific parameters have been moved from the `MCPServer` constructor to the `run()`, `sse_app()`, and `streamable_http_app()` methods. This provides better separation of concerns - the constructor now only handles server identity and authentication, while transport configuration is passed when starting the server. - -**Parameters moved:** - -- `host`, `port` - HTTP server binding -- `sse_path`, `message_path` - SSE transport paths -- `streamable_http_path` - StreamableHTTP endpoint path -- `json_response`, `stateless_http` - StreamableHTTP behavior -- `event_store`, `retry_interval` - StreamableHTTP event handling -- `transport_security` - DNS rebinding protection - -**Before (v1):** - -```python -from mcp.server.fastmcp import FastMCP - -# Transport params in constructor -mcp = FastMCP("Demo", json_response=True, stateless_http=True) -mcp.run(transport="streamable-http") - -# Or for SSE -mcp = FastMCP("Server", host="0.0.0.0", port=9000, sse_path="/events") -mcp.run(transport="sse") -``` - -**After (v2):** - -```python -from mcp.server.mcpserver import MCPServer - -# Transport params passed to run() -mcp = MCPServer("Demo") -mcp.run(transport="streamable-http", json_response=True, stateless_http=True) - -# Or for SSE -mcp = MCPServer("Server") -mcp.run(transport="sse", host="0.0.0.0", port=9000, sse_path="/events") -``` - -**For mounted apps:** - -When mounting in a Starlette app, pass transport params to the app methods: +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 mcp.server.fastmcp import FastMCP +from pydantic import AnyUrl -mcp = FastMCP("App", json_response=True) -app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())]) +await client.read_resource(AnyUrl("test://resource")) # After (v2) -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer("App") -app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app(json_response=True))]) +await client.read_resource("test://resource") +# Or if you have an AnyUrl from elsewhere: +await client.read_resource(str(my_any_url)) ``` -**Note:** DNS rebinding protection is automatically enabled when `host` is `127.0.0.1`, `localhost`, or `::1`. This now happens in `sse_app()` and `streamable_http_app()` instead of the constructor. - -If you were mutating these via `mcp.settings` after construction (e.g., `mcp.settings.port = 9000`), pass them to `run()` / `sse_app()` / `streamable_http_app()` instead — these fields no longer exist on `Settings`. The `debug` and `log_level` parameters remain on the constructor. - -### Streamable HTTP: lifespan now entered once at manager startup - -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. - -### `MCPServer.get_context()` removed - -`MCPServer.get_context()` has been removed. Context is now injected by the framework and passed explicitly — there is no ambient ContextVar to read from. - -**If you were calling `get_context()` from inside a tool/resource/prompt:** use the `ctx: Context` parameter injection instead. - -**Before (v1):** - -```python -@mcp.tool() -async def my_tool(x: int) -> str: - ctx = mcp.get_context() - await ctx.info("Processing...") - return str(x) -``` - -**After (v2):** - -```python -from mcp.server.mcpserver import Context - -@mcp.tool() -async def my_tool(x: int, ctx: Context) -> str: - await ctx.info("Processing...") - 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. - -The internal layers (`ToolManager.call_tool`, `Tool.run`, `Prompt.render`, `ResourceTemplate.create_resource`, etc.) now require `context` as a positional argument. - -### 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. - -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](https://datatracker.ietf.org/doc/html/rfc6570) 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](servers/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: - -**Before (v1):** - -```python -@mcp._mcp_server.set_logging_level() # pyright: ignore[reportPrivateUsage] -async def handle_set_logging_level(level: str) -> None: - ... - -mcp._mcp_server.subscribe_resource()(handle_subscribe) # pyright: ignore[reportPrivateUsage] -``` - -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 - - -async def handle_set_logging_level(ctx: ServerRequestContext, params: SetLevelRequestParams) -> EmptyResult: - ... - return EmptyResult() - - -async def handle_subscribe(ctx: ServerRequestContext, params: SubscribeRequestParams) -> EmptyResult: - ... - return EmptyResult() - - -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] -``` - -`_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`. - -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 -# 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") -``` - -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). +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. ### Replace `RootModel` by union types with `TypeAdapter` validation @@ -950,8 +375,8 @@ The nested `RequestParams.Meta` Pydantic model class has been replaced with a to **Key changes:** - `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]` +- Attribute access (`meta.progressToken`) → Dictionary access (`meta.get("progress_token")`) +- The `progressToken: ProgressToken | None = None` field is now the `progress_token: NotRequired[ProgressToken]` key **In request context handlers:** @@ -960,8 +385,8 @@ The nested `RequestParams.Meta` Pydantic model class has been replaced with a to @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) + if ctx.meta and ctx.meta.progressToken: + await ctx.session.send_progress_notification(ctx.meta.progressToken, 0.5, 100) # After (v2) async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: @@ -972,60 +397,418 @@ async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestPar server = Server("my-server", on_call_tool=handle_call_tool) ``` -### `RequestContext` type parameters simplified +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. -The `mcp.shared.context` module has been removed. `RequestContext` is now 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 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`. - -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: ... -``` - -### Version constants +### `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` + +The `McpError` exception class has been renamed to `MCPError` for consistent naming with the MCP acronym style used throughout the SDK. + +**Before (v1):** + +```python +from mcp.shared.exceptions import McpError + +try: + result = await session.call_tool("my_tool") +except McpError as e: + print(f"Error: {e.error.message}") +``` + +**After (v2):** + +```python +from mcp.shared.exceptions import MCPError + +try: + result = await session.call_tool("my_tool") +except MCPError as e: + print(f"Error: {e.message}") +``` + +`MCPError` is also exported from the top-level `mcp` package: + +```python +from mcp import MCPError +``` + +The constructor signature also changed — it now takes `code`, `message`, and optional `data` directly instead of wrapping an `ErrorData`: + +**Before (v1):** + +```python +from mcp.shared.exceptions import McpError +from mcp.types import ErrorData, INVALID_REQUEST + +raise McpError(ErrorData(code=INVALID_REQUEST, message="bad input")) +``` + +**After (v2):** + +```python +from mcp.shared.exceptions import MCPError +from mcp_types import INVALID_REQUEST + +raise MCPError(INVALID_REQUEST, "bad input") +# or, if you already have an ErrorData: +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. + +**Before (v1):** + +```python +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("Demo") +``` + +**After (v2):** + +```python +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). 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 + +The `mount_path` parameter has been removed from `MCPServer.__init__()`, `MCPServer.run()`, `MCPServer.run_sse_async()`, and `MCPServer.sse_app()`. It was also removed from the `Settings` class. + +This parameter was redundant because the SSE transport already handles sub-path mounting via ASGI's standard `root_path` mechanism. When using Starlette's `Mount("/path", app=mcp.sse_app())`, Starlette automatically sets `root_path` in the ASGI scope, and the `SseServerTransport` uses this to construct the correct message endpoint path. + +### Transport-specific parameters moved from MCPServer constructor to run()/app methods + +Transport-specific parameters have been moved from the `MCPServer` constructor to the `run()`, `sse_app()`, and `streamable_http_app()` methods. This provides better separation of concerns - the constructor now only handles server identity and authentication, while transport configuration is passed when starting the server. + +**Parameters moved:** + +- `host`, `port` - HTTP server binding +- `sse_path`, `message_path` - SSE transport paths +- `streamable_http_path` - StreamableHTTP endpoint path +- `json_response`, `stateless_http` - StreamableHTTP behavior +- `event_store`, `retry_interval` - StreamableHTTP event handling +- `transport_security` - DNS rebinding protection + +**Before (v1):** + +```python +from mcp.server.fastmcp import FastMCP + +# Transport params in constructor +mcp = FastMCP("Demo", json_response=True, stateless_http=True) +mcp.run(transport="streamable-http") + +# Or for SSE +mcp = FastMCP("Server", host="0.0.0.0", port=9000, sse_path="/events") +mcp.run(transport="sse") +``` + +**After (v2):** + +```python +from mcp.server.mcpserver import MCPServer + +# Transport params passed to run() +mcp = MCPServer("Demo") +mcp.run(transport="streamable-http", json_response=True, stateless_http=True) + +# Or for SSE +mcp = MCPServer("Server") +mcp.run(transport="sse", host="0.0.0.0", port=9000, sse_path="/events") +``` + +**For mounted apps:** + +When mounting in a Starlette app, pass transport params to the app methods: + +```python +# Before (v1) +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("App", json_response=True) +app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())]) + +# After (v2) +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("App") +app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app(json_response=True))]) +``` + +**Note:** DNS rebinding protection is automatically enabled when `host` is `127.0.0.1`, `localhost`, or `::1`. This now happens in `sse_app()` and `streamable_http_app()` instead of the constructor. + +If you were mutating these via `mcp.settings` after construction (e.g., `mcp.settings.port = 9000`), pass them to `run()` / `sse_app()` / `streamable_http_app()` instead — these fields no longer exist on `Settings`. The `debug` and `log_level` parameters remain on the constructor. + +### Streamable HTTP: lifespan now entered once at manager startup + +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` (a public way to reach it from high-level `@mcp.tool()` handlers is planned). + +### `MCPServer.get_context()` removed + +`MCPServer.get_context()` has been removed. Context is now injected by the framework and passed explicitly — there is no ambient ContextVar to read from. + +**If you were calling `get_context()` from inside a tool/resource/prompt:** use the `ctx: Context` parameter injection instead. + +**Before (v1):** + +```python +@mcp.tool() +async def my_tool(x: int) -> str: + ctx = mcp.get_context() + await ctx.info("Processing...") + return str(x) +``` + +**After (v2):** + +```python +from mcp.server.mcpserver import Context + +@mcp.tool() +async def my_tool(x: int, ctx: Context) -> str: + await ctx.info("Processing...") + 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()` 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. + +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](https://datatracker.ietf.org/doc/html/rfc6570) 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](servers/uri-templates.md) for the full template syntax, +security configuration, and filesystem safety utilities. + +### `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 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`. + +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 +# 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") + +# After +await ctx.info({"message": "Connection failed", "host": "localhost", "port": 5432}) +await ctx.log(level="info", data="hello") +``` + +Positional calls (`await ctx.info("hello")`) are unaffected. + ### `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. @@ -1042,7 +825,7 @@ with progress(ctx, total=100) as p: **After — use `Context.report_progress()` (recommended):** ```python -@server.tool() +@mcp.tool() async def my_tool(x: int, ctx: Context) -> str: await ctx.report_progress(25, 100) return "done" @@ -1058,201 +841,66 @@ await session.send_progress_notification( ) ``` -### Handler progress reporting: prefer `ctx.report_progress()` over manual `progress_token` +### `Context.elicit()` schema gate validates the rendered schema -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. +`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. -`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. +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). -### `create_connected_server_and_client_session` removed +### `isinstance()` checks against `ElicitationResult` raise `TypeError` -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. +`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: + +```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. + +### 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: **Before (v1):** ```python -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):** - -```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`) plus `raise_exceptions` to surface server-side errors and `mode` to control version negotiation (`'auto'` by default; `'legacy'` reproduces v1's initialize-only handshake). - -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`: - -```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() -``` - -### Resource URI type changed from `AnyUrl` to `str` - -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. - -**Before (v1):** - -```python -from pydantic import AnyUrl -from mcp.types import Resource - -# Required wrapping in AnyUrl -resource = Resource(name="test", uri=AnyUrl("users/me")) # Would fail validation -``` - -**After (v2):** - -```python -from mcp_types import Resource - -# 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 -``` - -If your code passes `AnyUrl` objects to URI fields, convert them to strings: - -```python -# If you have an AnyUrl from elsewhere -uri = str(my_any_url) # Convert to string -``` - -Affected types: - -- `Resource.uri` -- `ReadResourceRequestParams.uri` -- `ResourceContents.uri` (and subclasses `TextResourceContents`, `BlobResourceContents`) -- `SubscribeRequestParams.uri` -- `UnsubscribeRequestParams.uri` -- `ResourceUpdatedNotificationParams.uri` - -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 - -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)) -``` - -### 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: - -```python -# Before (v1) -server = Server("my-server", "1.0") - -# After (v2) -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. - -```python -# Before (v1) -from typing import Any - -from mcp.server.lowlevel.server import Server - -server: Server[dict[str, Any], Any] = Server(...) - -# After (v2) -from typing import Any - -from mcp.server import Server - -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. - -```python -# Before (v1) — direct dict access -from mcp.types import ListToolsRequest - -if ListToolsRequest in server.request_handlers: +@mcp._mcp_server.set_logging_level() # pyright: ignore[reportPrivateUsage] +async def handle_set_logging_level(level: str) -> None: ... -# 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) +mcp._mcp_server.subscribe_resource()(handle_subscribe) # pyright: ignore[reportPrivateUsage] ``` -If you need to check whether a handler is registered, track this yourself — there is currently no public introspection API. +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`: -### Lowlevel `Server`: `add_request_handler` is now public and takes `params_type` - -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). +**After (v2):** ```python -# Before (v1 / earlier v2 prereleases) -server._add_request_handler("custom/method", my_handler) +from mcp.server import ServerRequestContext +from mcp_types import EmptyResult, SetLevelRequestParams, SubscribeRequestParams -# After (v2) -server.add_request_handler("custom/method", MyParams, my_handler) -server.add_notification_handler("notifications/custom", MyNotifyParams, my_notify_handler) + +async def handle_set_logging_level(ctx: ServerRequestContext, params: SetLevelRequestParams) -> EmptyResult: + ... + return EmptyResult() + + +async def handle_subscribe(ctx: ServerRequestContext, params: SubscribeRequestParams) -> EmptyResult: + ... + return EmptyResult() + + +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] ``` -### Lowlevel `Server`: private `_handle_*` dispatch methods removed +`_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. -`Server._handle_message`, `_handle_request`, and `_handle_notification` have been removed. The receive loop and per-message dispatch now live in `JSONRPCDispatcher` and `ServerRunner`, which `Server.run()` drives internally. - -These were private, but some users subclassed `Server` and overrode them to intercept requests. Use middleware instead: - -```python -from typing import Any - -from mcp.server import Server, ServerRequestContext -from mcp.server.context import CallNext, HandlerResult - - -async def logging_middleware(ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult: - print(f"handling {ctx.method}") - result = await call_next(ctx) - print(f"done {ctx.method}") - return result - - -server = Server("my-server", on_call_tool=...) -server.middleware.append(logging_middleware) -``` - -The method and the raw inbound params are `ctx.method` and `ctx.params` (`params` is `None` when the message carries none). Middleware runs before params validation and also wraps unknown methods. To rewrite the method or params before the handler runs, pass an adjusted context through: `await call_next(replace(ctx, params=...))`. - -### Lowlevel `Server.run(raise_exceptions=True)`: transport errors no longer re-raised - -`raise_exceptions=True` now only governs handler exceptions: an exception raised by an `on_*` handler propagates out of `run()`. The JSON-RPC error response is still written to the client first, regardless of the flag. - -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 ### Lowlevel `Server`: decorator-based handlers replaced with constructor `on_*` params @@ -1262,6 +910,7 @@ The lowlevel `Server` class no longer uses decorator methods for handler registr ```python from mcp.server.lowlevel.server import Server +import mcp.types as types server = Server("my-server") @@ -1342,6 +991,8 @@ async def handle_progress(ctx: ServerRequestContext, params: ProgressNotificatio server = Server("my-server", on_progress=handle_progress) ``` +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). + ### 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`. @@ -1438,6 +1089,136 @@ async def handle(ctx: ServerRequestContext, params: PaginatedRequestParams | Non 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. +### Lowlevel `Server`: tool handler exceptions no longer become `CallToolResult(is_error=True)` + +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 +@server.call_tool() +async def call_tool(name: str, arguments: dict): + raise ValueError("kaboom") # client receives CallToolResult(isError=True) +``` + +**After (v2):** catch exceptions in the handler and build the error result yourself: + +```python +from mcp.server import Server +from mcp_types import CallToolResult, TextContent + + +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, + ) + + +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: + +```python +# Before (v1) +server = Server("my-server", "1.0") + +# After (v2) +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. 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 import Server + +server: Server[dict[str, Any], Any] = Server(...) + +# After (v2) +from typing import Any + +from mcp.server import Server + +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 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 +server = Server("my-server", on_list_tools=handle_list_tools) + +if server.get_request_handler("tools/list") is not None: + ... +``` + +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. + +### 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. + +### Lowlevel `Server`: private `_handle_*` dispatch methods removed + +`Server._handle_message`, `_handle_request`, and `_handle_notification` have been removed. The receive loop and per-message dispatch now live in `JSONRPCDispatcher` and `ServerRunner`, which `Server.run()` drives internally. + +These were private, but some users subclassed `Server` and overrode them to intercept requests. Use middleware instead: + +```python +from typing import Any + +from mcp.server import Server, ServerRequestContext +from mcp.server.context import CallNext, HandlerResult + + +async def logging_middleware(ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult: + print(f"handling {ctx.method}") + result = await call_next(ctx) + print(f"done {ctx.method}") + return result + + +server = Server("my-server", on_call_tool=...) +server.middleware.append(logging_middleware) +``` + +The method and the raw inbound params are `ctx.method` and `ctx.params` (`params` is `None` when the message carries none). Middleware runs before params validation and also wraps unknown methods. To rewrite the method or params before the handler runs, pass an adjusted context through: `await call_next(replace(ctx, params=...))`. + +### Lowlevel `Server.run(raise_exceptions=True)`: transport errors no longer re-raised + +`raise_exceptions=True` now only governs handler exceptions: an exception raised by an `on_*` handler propagates out of `run()`. The JSON-RPC error response is still written to the client first, regardless of the flag. + +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. + +### `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. @@ -1469,20 +1250,61 @@ async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestPar ) ``` -### `ServerRequestContext`: request-specific fields are now optional +### `RequestContext` type parameters simplified -`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`. +`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.server import ServerRequestContext +from mcp.client.session import ClientSession +from mcp.shared.context import RequestContext, LifespanContextT, RequestT -# request_id, meta, etc. are available in request handlers -# but None in notification handlers +# 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 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`. +`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: @@ -1494,7 +1316,7 @@ session = ServerSession(read_stream, write_stream, init_options, stateless=False # After (v2) session = ServerSession(request_outbound, connection) -# where `request_outbound` is an Outbound and `connection` is a 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. @@ -1505,17 +1327,212 @@ In practice, replace direct `ServerSession` use with `Server.run(read_stream, wr - `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 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)). -### `BaseSession` / `RequestResponder`: server-side cancellation tracking removed +### `ServerSession.elicit()` and `elicit_form()` take `requested_schema`, not `requestedSchema` -`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`. +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): -`BaseSession` itself has since been removed entirely; see the next section. +```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()` + +Use `params=PaginatedRequestParams(cursor=...)` instead. + +**Before (v1):** + +```python +result = await session.list_resources(cursor="next_page_token") +result = await session.list_tools(cursor="next_page_token") +``` + +**After (v2):** + +```python +from mcp_types import PaginatedRequestParams + +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. + +**Before (v1):** + +```python +result = await session_group.call_tool("my_tool", args={"key": "value"}) +``` + +**After (v2):** + +```python +result = await session_group.call_tool("my_tool", arguments={"key": "value"}) +``` + +### Timeouts take `float` seconds instead of `timedelta` + +Every timeout parameter that took a `datetime.timedelta` in v1 now takes plain seconds as a `float`: + +| 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` | + +`SseServerParameters` already used `float` in v1 and is unaffected. + +**Before (v1):** + +```python +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)) + +params = StreamableHttpParameters( + url="https://example.com/mcp", + timeout=timedelta(seconds=30), + sse_read_timeout=timedelta(seconds=300), +) +``` + +**After (v2):** + +```python +session = ClientSession(read_stream, write_stream, read_timeout_seconds=30) +result = await session.call_tool("slow_tool", {}, read_timeout_seconds=120) + +params = StreamableHttpParameters( + url="https://example.com/mcp", + timeout=30, + sse_read_timeout=300, +) +``` + +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. + +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. + +To migrate, replace `timedelta(...)` with plain seconds, or mechanically append `.total_seconds()` to an existing timedelta value. + +### Client request timeouts now raise `-32001` (`REQUEST_TIMEOUT`) instead of `408` + +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 +import httpx +from mcp.shared.exceptions import McpError + +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 +``` + +**After (v2):** + +```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 +``` + +`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`. ### `ClientSession` now runs on `JSONRPCDispatcher`; `BaseSession` removed -`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). +`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). Behavior changes: @@ -1523,17 +1540,501 @@ Behavior changes: - **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 private `mcp.shared._context.RequestContext` generic is deleted. Annotations spelled `RequestContext[ClientSession]` become `ClientRequestContext`. +- **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)). -`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. +`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`. ### Experimental Tasks support removed -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 ([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`. 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. +## Transports + +### `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. + +### `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: + +**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 +``` + +**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 +``` + +### `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`. + +### Streamable HTTP: non-2xx responses now surface as per-request JSON-RPC errors + +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. + +| 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')` | + +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`. + +**Before (v1):** + +```python +import httpx +from mcp.shared.exceptions import McpError + +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 +``` + +**After (v2):** + +```python +from mcp import ClientSession, MCPError +from mcp.client.streamable_http import streamable_http_client +from mcp_types import INVALID_REQUEST # -32600 + +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 +``` + +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. + +### `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` shutdown reworked: a gracefully-exited server's children are left alive on POSIX + +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. + +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 a raw `BrokenResourceError` escaping the `stdio_client` context. + +`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`). + +### WebSocket transport removed + +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. + +## OAuth and server auth + +### OAuth metadata URLs no longer gain a trailing slash + +`OAuthMetadata`, `ProtectedResourceMetadata`, and `OAuthClientMetadata` now set +`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](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](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. + +`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. + +### 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. + +### Client rejects authorization server metadata with a mismatched `issuer` + +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"]} +``` + +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: + +```text +OAuthFlowError: Authorization server metadata issuer mismatch: https://as.example.com/ != https://as.example.com +``` + +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"], +) +``` + +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)) + +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](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](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"`: + +```python +from mcp.shared.auth import OAuthClientMetadata + +client_metadata = OAuthClientMetadata( + redirect_uris=["https://app.example.com/callback"], + application_type="web", +) +``` + +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. + +### Stricter client authentication at `/token` and `/revoke` + +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. + +**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: + +```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 + +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: + +```python +LEGACY_CLIENT = OAuthClientInformationFull( + client_id="legacy-client", + token_endpoint_auth_method="none", # public client, no secret expected + redirect_uris=["http://localhost:1234/cb"], +) +``` + +## 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":{}}} +``` + +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 SDK's new `opentelemetry-api` runtime dependency is covered under [Packaging, dependencies, and CLI](#packaging-dependencies-and-cli). + +## Testing utilities + +### `create_connected_server_and_client_session` removed + +The `create_connected_server_and_client_session` helper in `mcp.shared.memory` has been removed. Use `mcp.client.Client` instead — it accepts a `Server` or `MCPServer` instance directly and handles the in-memory transport and session setup for you. + +**Before (v1):** + +```python +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):** + +```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). + +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`: + +```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() +``` + +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. + +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. + +`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. + ## Deprecations ### Roots, Sampling, and Logging methods deprecated (SEP-2577) @@ -1561,179 +2062,24 @@ No migration is required during the deprecation window. New code should avoid bu ### Client-to-server progress deprecated (2026-07-28) -The 2026-07-28 spec restricts `notifications/progress` to the server-to-client direction only — `ProgressNotification` is no longer in `ClientNotification`. `Client.send_progress_notification()` and `ClientSession.send_progress_notification()` now carry `typing_extensions.deprecated` and emit `mcp.MCPDeprecationWarning` at runtime. They continue to work against servers negotiating 2025-11-25 or earlier. +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. -## Bug Fixes +## Notes for 2026-era connections -### OAuth metadata URLs no longer gain a trailing slash +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. -`OAuthMetadata`, `ProtectedResourceMetadata`, and `OAuthClientMetadata` now set -`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](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. +### Servers validate `Mcp-Param-*` headers against the request body ([SEP-2243](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2243)) -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](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. +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. -`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. +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. -### 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. - -### 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, 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. - -### Extra fields on MCP types are no longer preserved - -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: - -```python -from mcp_types import CallToolRequestParams - -params = CallToolRequestParams( - name="my_tool", - arguments={}, - unknown_field="value", # silently ignored, not stored -) -"unknown_field" in params.model_dump() # False - -# _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 -) -``` - -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)) - -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](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](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"`: - -```python -from mcp.shared.auth import OAuthClientMetadata - -client_metadata = OAuthClientMetadata( - redirect_uris=["https://app.example.com/callback"], - application_type="web", -) -``` - -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](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): - -```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](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](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. - -### 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](client/caching.md) for details. - -### `streamable_http_app()` available on lowlevel Server - -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. - -```python -from mcp.server import Server, ServerRequestContext -from mcp_types import ListToolsResult, PaginatedRequestParams - - -async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: - return ListToolsResult(tools=[...]) - - -server = Server("my-server", on_list_tools=handle_list_tools) - -app = server.streamable_http_app( - streamable_http_path="/mcp", - json_response=False, - stateless_http=False, -) -``` - -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](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: - -```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. +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 07/97] 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 | 87 ++-- 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, 1215 insertions(+), 104 deletions(-) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 804180e0..5c09304e 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -93,7 +93,16 @@ def _make_modern_stamp( 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 f28eb7c7..09e5048c 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -26,6 +26,7 @@ from mcp_types import ( 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._compat import resync_tracer from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams from mcp.shared._httpx_utils import create_mcp_http_client from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER +from mcp.shared.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 @@ class StreamableHTTPTransport: """ 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 @@ class StreamableHTTPTransport: 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 @@ class StreamableHTTPTransport: 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 @@ class StreamableHTTPTransport: 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 @@ class StreamableHTTPTransport: 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 @@ class StreamableHTTPTransport: 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 @@ class StreamableHTTPTransport: # 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 fd3e69d4..62c74b80 100644 --- a/src/mcp/shared/direct_dispatcher.py +++ b/src/mcp/shared/direct_dispatcher.py @@ -28,7 +28,7 @@ from mcp_types import CONNECTION_CLOSED, INTERNAL_ERROR, INVALID_PARAMS, REQUEST 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 @@ class DirectDispatcher: 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 @@ class DirectDispatcher: # 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 @@ class DirectDispatcher: 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 de83189f..16360d31 100644 --- a/src/mcp/shared/dispatcher.py +++ b/src/mcp/shared/dispatcher.py @@ -34,11 +34,26 @@ __all__ = [ "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 64fcd329..793c59bc 100644 --- a/src/mcp/shared/jsonrpc_dispatcher.py +++ b/src/mcp/shared/jsonrpc_dispatcher.py @@ -39,7 +39,15 @@ from typing_extensions import TypeVar 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.message import ( ) 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 @@ class JSONRPCDispatcher(Dispatcher[TransportT]): 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 @@ class JSONRPCDispatcher(Dispatcher[TransportT]): # 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 @@ class JSONRPCDispatcher(Dispatcher[TransportT]): 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 @@ class JSONRPCDispatcher(Dispatcher[TransportT]): # 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 @@ class JSONRPCDispatcher(Dispatcher[TransportT]): 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 @@ class JSONRPCDispatcher(Dispatcher[TransportT]): 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 @@ class JSONRPCDispatcher(Dispatcher[TransportT]): # 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 f76991f6..2a53f67c 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 +class _OptsRecordingDispatcher: + """Records `send_raw_request` opts and answers from a per-method script (default `{}`).""" + + def __init__(self, answers: dict[str, dict[str, Any]] | None = None) -> None: + self.calls: list[tuple[str, CallOptions]] = [] + self._answers = answers or {} + + 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 notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: + pass + + @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 RecordingDispatcher: - """Records `send_raw_request` opts and answers with canned 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 == "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 - - dispatcher = RecordingDispatcher() + 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 test_initialize_opts_out_of_cancel_on_abandon_while_other_requests_lea 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 99ff6f03..defda41f 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -8,16 +8,37 @@ the public client never exposes. 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 @@ async def test_initialize_post_clears_cached_pv_header_and_unstamped_posts_read_ 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 feb5ca5d..666ee5a4 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 ada4b7fa..b7b7465f 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -474,6 +474,25 @@ REQUIREMENTS: dict[str, Requirement] = { "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,14 +503,32 @@ REQUIREMENTS: dict[str, Requirement] = { "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", @@ -550,6 +587,16 @@ REQUIREMENTS: dict[str, Requirement] = { 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 @@ REQUIREMENTS: dict[str, Requirement] = { 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 247e1135..0e9d81af 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 @@ from mcp_types import ( JSONRPCNotification, JSONRPCRequest, JSONRPCResponse, + ListToolsResult, PingRequest, ServerCapabilities, TextContent, + Tool, ) from mcp import MCPError @@ -344,3 +347,121 @@ async def test_timed_out_initialize_sends_no_cancellation() -> 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 73452f1a..b3d286ca 100644 --- a/tests/interaction/lowlevel/test_wire.py +++ b/tests/interaction/lowlevel/test_wire.py @@ -308,3 +308,58 @@ async def test_set_level_with_an_unrecognized_value_is_answered_with_invalid_par 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 5508d3e8..61b0e21c 100644 --- a/tests/interaction/transports/test_client_transport_http.py +++ b/tests/interaction/transports/test_client_transport_http.py @@ -6,6 +6,7 @@ methods, ordering) rather than what the protocol layer on top of it returns. The 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 test_a_404_mid_session_surfaces_as_a_session_terminated_error() -> 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 1f820833..03ef27c8 100644 --- a/tests/shared/test_dispatcher.py +++ b/tests/shared/test_dispatcher.py @@ -19,6 +19,7 @@ from mcp_types import ( 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 82d16bc4..e91fc2de 100644 --- a/tests/shared/test_jsonrpc_dispatcher.py +++ b/tests/shared/test_jsonrpc_dispatcher.py @@ -34,11 +34,10 @@ from mcp import Client 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 test_response_with_string_id_correlates_to_int_keyed_pending_request() @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 test_progress_with_string_token_reaches_callback_for_int_keyed_request 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 test_request_with_bool_meta_progress_token_is_not_adopted(): 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 test_server_middleware_observes_cancelled_notification(): 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 08/97] 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 | 251 +++++-- 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, 1133 insertions(+), 199 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 e9787da8..6b4e934c 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 6260b72f..509b2635 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 eb2b5be4..daf9fde1 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 d5451e23..e0890344 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 00000000..6174f425 --- /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 3c544d00..811fa17d 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 00000000..ff55e5ce --- /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 00000000..e69de29b diff --git a/docs_src/sampling_and_roots/tutorial001.py b/docs_src/sampling_and_roots/tutorial001.py new file mode 100644 index 00000000..c1e041c3 --- /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 00000000..44a1d105 --- /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 5f19b898..ae0c57f3 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 d581fe6a..fa78f15e 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -303,6 +303,9 @@ class Client: 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 @@ class Client: 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 0205df19..56d1c23c 100644 --- a/src/mcp/server/mcpserver/__init__.py +++ b/src/mcp/server/mcpserver/__init__.py @@ -19,7 +19,9 @@ from .resolve import ( DeclinedElicitation, Elicit, ElicitationResult, + ListRoots, Resolve, + Sample, ) from .resources import DEFAULT_RESOURCE_SECURITY, ResourceSecurity from .server import MCPServer, require_client_extension @@ -33,6 +35,8 @@ __all__ = [ "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 d752afc1..d4a744af 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. +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 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 +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 @@ Whether the consumer receives the unwrapped model or the full - `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 @@ import anyio.to_thread 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.elicitation import ( 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 @@ class Elicit(Generic[T]): 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 @@ class _Resolution: 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 ca62fb9c..0a61689e 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -14,7 +14,7 @@ from pydantic import AnyUrl, BaseModel 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 @@ class ServerSession: """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 @@ class ServerSession: 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 @@ class ServerSession: """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 @@ class ServerSession: ) 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 fd16beb9..a281f4d0 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 ca59b56a..14e8fe1c 100644 --- a/src/mcp/shared/peer.py +++ b/src/mcp/shared/peer.py @@ -98,7 +98,7 @@ class ClientPeer: 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 @@ class ClientPeer: 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 @@ class ClientPeer: 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 6dba9277..8474d55e 100644 --- a/tests/docs_src/test_dependencies.py +++ b/tests/docs_src/test_dependencies.py @@ -4,9 +4,9 @@ from typing import Literal 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 test_declining_an_unwrapped_dependency_aborts_the_call(mode: Literal[" 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 00000000..7e4b9aea --- /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 c28f1248..aa5ced26 100644 --- a/tests/server/mcpserver/test_resolve.py +++ b/tests/server/mcpserver/test_resolve.py @@ -7,22 +7,40 @@ from typing import Annotated, Any, Literal, TypeVar, cast 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 @@ from mcp.server.mcpserver import ( 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.resolve import ( ) 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 test_consumer_receives_result_union_and_branches(): 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 test_sync_resolver(): 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 @@ def test_multiple_elicit_arms_raise_at_registration(): 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 test_accept_with_no_content_is_an_error_not_a_cancel(mode: Literal["le 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 test_resolver_elicitation_seals_and_completes_on_a_fully_default_serve 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 25b8257e..49e3b461 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 2fc92e2c..0bc990f5 100644 --- a/tests/shared/test_peer.py +++ b/tests/shared/test_peer.py @@ -18,6 +18,7 @@ from mcp_types import ( 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 09/97] 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 cc58cf8d..fe8dc7f4 100644 --- a/tests/client/test_input_required.py +++ b/tests/client/test_input_required.py @@ -37,6 +37,11 @@ from mcp.client._input_required import ( 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 0b069537..91f829ff 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -44,6 +44,14 @@ from mcp.os.win32.utilities import FallbackProcess 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 2278c993..9ade27e7 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 @@ from logfire.testing import CaptureLogfire # noqa: E402 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 316c6924..0c20fd7c 100644 --- a/tests/interaction/lowlevel/test_timeouts.py +++ b/tests/interaction/lowlevel/test_timeouts.py @@ -27,6 +27,11 @@ from tests.interaction._requirements import requirement 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 19ad33f1..b85566f8 100644 --- a/tests/server/test_streamable_http_modern.py +++ b/tests/server/test_streamable_http_modern.py @@ -55,6 +55,11 @@ from tests.interaction.transports import StreamingASGITransport 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 e91fc2de..5c29c7e3 100644 --- a/tests/shared/test_jsonrpc_dispatcher.py +++ b/tests/shared/test_jsonrpc_dispatcher.py @@ -51,6 +51,11 @@ from .test_dispatcher import Recorder, echo_handlers, running_pair 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 656fc812..2d4eeac8 100644 --- a/tests/transports/stdio/test_windows.py +++ b/tests/transports/stdio/test_windows.py @@ -37,6 +37,11 @@ pytestmark = [ ] +@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 10/97] 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 b958fd23..fb163107 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 05e8a877..bf10369c 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 1dc2692e..36ef4f23 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 2260ea2e..e41416b8 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 e9abba11..d574f1b0 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 11/97] 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 | 142 ++-- 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 | 70 +- 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, 2589 insertions(+), 297 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 5e0976fb..dc4ae97a 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 01287da0..ae475083 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 00000000..fc7d0130 --- /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 6ff85dd8..85b96327 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. + +`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 things the stream is *not*: + +* **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 - 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. + 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. -## One process is the default. More takes a bus +## The client end -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. +Here is a client on the other side of that stream, following the board: + +```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*. + +## 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 -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. +bus = InMemorySubscriptionBus() +mcp = MCPServer("Sprint Board", subscriptions=bus) + + +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 811fa17d..8822d449 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 cad564c4..7cec5816 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 d197833d..ff9cfdee 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 5063fcee..45c858f4 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 c0b04f64..39e42dcc 100644 --- a/docs_src/subscriptions/tutorial002.py +++ b/docs_src/subscriptions/tutorial002.py @@ -7,34 +7,43 @@ from mcp.server.lowlevel import Server 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 00000000..811f6944 --- /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 00000000..1ca49956 --- /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 00000000..2e4c6851 --- /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 00000000..da407157 --- /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 00000000..17387fab --- /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 c7f1a443..bc309d97 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 379d69bc..d2053aaf 100644 --- a/examples/stories/subscriptions/client.py +++ b/examples/stories/subscriptions/client.py @@ -4,88 +4,34 @@ import anyio 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] + with anyio.fail_after(10): + assert await anext(sub) == ToolsListChanged() - # 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. + # ── 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 ae0c57f3..f40fcb37 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 fa78f15e..d519106a 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -6,7 +6,7 @@ import hashlib 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 @@ from mcp.client.session import ( 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.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 +from mcp.shared.subscriptions import event_to_notification logger = logging.getLogger(__name__) @@ -666,13 +669,68 @@ class Client: # 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.""" - return await self.session.subscribe_resource(uri, meta=meta) + 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 (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 5c09304e..097ade1c 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -16,6 +16,7 @@ from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStre 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 typing_extensions import Self, TypeVar, deprecated 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 @@ from mcp.shared.inbound import ( 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 @@ class ClientSession: 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 @@ class ClientSession: 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 @@ class ClientSession: 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 @@ class ClientSession: 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 @@ class ClientSession: 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 @@ class ClientSession: 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 09e5048c..5c3501d8 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -13,6 +13,7 @@ import httpx 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 @@ class StreamableHTTPTransport: ) 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 @@ class StreamableHTTPTransport: 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 @@ class StreamableHTTPTransport: 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 @@ class StreamableHTTPTransport: 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 @@ class StreamableHTTPTransport: 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 00000000..27283909 --- /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 28d06761..2b7fdf35 100644 --- a/src/mcp/server/mcpserver/context.py +++ b/src/mcp/server/mcpserver/context.py @@ -16,14 +16,14 @@ from mcp.server.elicitation import ( 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 d071cfdb..6b0b3d49 100644 --- a/src/mcp/server/subscriptions.py +++ b/src/mcp/server/subscriptions.py @@ -13,6 +13,8 @@ the stream. This module provides the two pieces a server needs: `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 @@ from __future__ import annotations import logging from collections.abc import Callable -from dataclasses import dataclass from typing import Any, Protocol import anyio @@ -33,57 +34,40 @@ 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 +from mcp.shared.subscriptions import ( + SUBSCRIPTION_ID_META_KEY, + PromptsListChanged, + ResourcesListChanged, + ResourceUpdated, + ServerEvent, + ToolsListChanged, + event_matches, + event_to_notification, +) + +__all__ = [ + "SUBSCRIPTION_ID_META_KEY", + "InMemorySubscriptionBus", + "ListenHandler", + "PromptsListChanged", + "ResourceUpdated", + "ResourcesListChanged", + "ServerEvent", + "SubscriptionBus", + "ToolsListChanged", +] 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. @@ -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 @@ class ListenHandler: 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 @@ class ListenHandler: ) 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 62c74b80..e17283af 100644 --- a/src/mcp/shared/direct_dispatcher.py +++ b/src/mcp/shared/direct_dispatcher.py @@ -28,7 +28,15 @@ from mcp_types import CONNECTION_CLOSED, INTERNAL_ERROR, INVALID_PARAMS, REQUEST 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 @@ class DirectDispatcher: 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 @@ class DirectDispatcher: 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 @@ class DirectDispatcher: 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 @@ class DirectDispatcher: # 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 16360d31..f109638f 100644 --- a/src/mcp/shared/dispatcher.py +++ b/src/mcp/shared/dispatcher.py @@ -16,6 +16,7 @@ See `JSONRPCDispatcher` for the production implementation and 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_types import RequestId 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 @@ OnRequest = Callable[[DispatchContext[TransportContext], str, Mapping[str, Any] 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 @@ class Dispatcher(Outbound, Protocol[TransportT_co]): 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 @@ class Dispatcher(Outbound, Protocol[TransportT_co]): 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 793c59bc..42798fdc 100644 --- a/src/mcp/shared/jsonrpc_dispatcher.py +++ b/src/mcp/shared/jsonrpc_dispatcher.py @@ -44,9 +44,12 @@ from mcp.shared.dispatcher import ( 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 @@ class JSONRPCDispatcher(Dispatcher[TransportT]): 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 @@ class JSONRPCDispatcher(Dispatcher[TransportT]): 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 @@ class JSONRPCDispatcher(Dispatcher[TransportT]): `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 @@ class JSONRPCDispatcher(Dispatcher[TransportT]): `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 @@ class JSONRPCDispatcher(Dispatcher[TransportT]): ) 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 00000000..ba50917f --- /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 f8c02c97..6c78503b 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -35,7 +35,7 @@ from mcp_types import ( 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 test_client_send_progress_notification(): 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 40881081..e22ec401 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 import ( 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 @@ class _RecordingDispatcher: 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 2a53f67c..507c8f69 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -39,11 +39,13 @@ from mcp import MCPError 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 @@ class _OptsRecordingDispatcher: 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 test_aenter_cancelled_while_dispatcher_starts_unwinds_cleanly(): 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 @@ class _ScriptedDispatcher: 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 test_session_read_resource_returns_input_required_result_when_opted_in 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 21cf2fa6..94ebd794 100644 --- a/tests/client/test_session_claims.py +++ b/tests/client/test_session_claims.py @@ -28,7 +28,7 @@ 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 +from mcp.shared.dispatcher import CallOptions, OnNotify, OnNotifyIntercept, OnRequest _TASKS_EXT = "com.example/tasks" _AD_ONLY_EXT = "com.example/flags" @@ -75,6 +75,7 @@ class _RecordingDispatcher: 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 defda41f..da17a71c 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -18,6 +18,8 @@ from inline_snapshot import snapshot 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 import ( 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 @@ async def test_a_finished_post_task_does_not_evict_a_reused_ids_new_registration ) 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 00000000..0cc4f133 --- /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 af5e6924..3d70371f 100644 --- a/tests/docs_src/test_client.py +++ b/tests/docs_src/test_client.py @@ -5,7 +5,7 @@ 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 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 b664afe9..7a7b7515 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 @@ class _Stream: 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 test_publishes_reach_the_stream_filtered_and_tagged() -> 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 test_publishes_reach_the_stream_filtered_and_tagged() -> 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 b7b7465f..0ce1239f 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -486,12 +486,6 @@ REQUIREMENTS: dict[str, Requirement] = { "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 @@ REQUIREMENTS: dict[str, Requirement] = { 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 44ab33e6..db7d4dfe 100644 --- a/tests/interaction/lowlevel/test_resources.py +++ b/tests/interaction/lowlevel/test_resources.py @@ -203,6 +203,7 @@ async def test_list_resource_templates_returns_registered_templates(connect: Con ) +@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 test_subscribe_resource_delivers_uri_to_handler(connect: Connect) -> N 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 test_subscribe_without_a_subscribe_handler_is_method_not_found(connect 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 test_unsubscribe_resource_delivers_uri_to_handler(connect: Connect) -> 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 00000000..e22590c9 --- /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 00000000..047b049d --- /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 03ef27c8..c6ebb401 100644 --- a/tests/shared/test_dispatcher.py +++ b/tests/shared/test_dispatcher.py @@ -25,7 +25,7 @@ from mcp_types import ( 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 test_supplied_numeric_string_id_collides_with_its_int_twin(pair_factor 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 12/97] 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 36ef4f23..511d0340 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 70eef5d6..f86da2ea 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 13/97] 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 bf10369c..6f9ec2cc 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 14/97] 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 | 391 +++++++++--------------------- 17 files changed, 1183 insertions(+), 610 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 fb163107..6da800a7 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 511d0340..c859bbab 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 684f8d7b..2e788e71 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 8625b05d..fb7e1238 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 8e1afeee..00000000 --- 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 c6dea319..00000000 --- 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 f40fcb37..5b3f7779 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 e41416b8..7c4e4cee 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 5a61309a..82867860 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 00000000..8dce3afd --- /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 00000000..daba6483 --- /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 00000000..39f866a0 --- /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 
//