diff --git a/tests/interaction/README.md b/tests/interaction/README.md index feb5ca5d1..1a0cd2ed2 100644 --- a/tests/interaction/README.md +++ b/tests/interaction/README.md @@ -61,6 +61,12 @@ stateless configurations), and over the legacy SSE transport the same way. A tes `async with connect(server, ...) as client:` and asserts the same output on every leg, because the transport is not supposed to change observable behaviour. Requirements that need a server-to-client back-channel or persisted session state are carved out of the stateless arm via `arm_exclusions`. + +The 2026 cells run the client's response cache in its default-on configuration. Servers stamp +`ttlMs: 0` by default, so nothing is served from cache unless a test opts in server-side by +authoring a positive `ttl_ms` — a test that does so and then repeats a call must expect the +repeat to be served from cache instead of reaching the handler. + Tests that are tied to one transport do not use the fixture: the wire-recording tests (their seam is the in-memory stream pair), the bare-`ClientSession` lifecycle tests, the real-clock timeout tests (the timeout machinery is transport-independent and must not race @@ -163,10 +169,11 @@ What admits or excludes a cell: closes, grep for the reason string to find every cell to re-admit. - **`known_failures`** keep a cell in the grid but mark it as a strict xfail — the test runs and must fail; an unexpected pass fails the suite. -- **`TRANSPORT_SPEC_VERSIONS`** era-locks a transport to a subset of spec versions (currently only - `sse` is locked to `2025-11-25`). A `(transport, version)` cell is dropped if the version is not - in the transport's entry; transports absent from the map serve every spec version. This is the - mechanism for cutting an entire transport off from a new revision (or admitting it). +- **`TRANSPORT_SPEC_VERSIONS`** era-locks a transport to a subset of spec versions (currently + `sse` and `streamable-http-stateless` are locked to `2025-11-25`). A `(transport, version)` + cell is dropped if the version is not in the transport's entry; transports absent from the + map serve every spec version. This is the mechanism for cutting an entire transport off from + a new revision (or admitting it). - **`transports`** is descriptive metadata for the non-`connect` transport-specific suites under `transports/` and does **not** drive cell generation. Only `arm_exclusions`, `added_in`, `removed_in`, and `TRANSPORT_SPEC_VERSIONS` filter the grid. diff --git a/tests/interaction/_connect.py b/tests/interaction/_connect.py index 5da269ba4..9b9fb5367 100644 --- a/tests/interaction/_connect.py +++ b/tests/interaction/_connect.py @@ -388,7 +388,9 @@ def httpx_client_factory( transport = sse_client(f"{BASE_URL}/sse", httpx_client_factory=httpx_client_factory) async with Client( transport, - # SSE is a legacy-only transport; the modern path has no SSE story. + # A policy lock, not a capability one: the dual-era server loop behind build_sse_app + # would negotiate 2026 if probed, but SSE is the deprecated legacy transport and its + # clients run the handshake era by design. mode="legacy", read_timeout_seconds=read_timeout_seconds, sampling_callback=sampling_callback, diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index ada4b7fa0..0791564ad 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -187,6 +187,35 @@ def __post_init__(self) -> None: "sending notifications or serving callbacks." ), ), + "lifecycle:capability:experimental-passthrough": Requirement( + source=f"{SPEC_BASE_URL}/basic/lifecycle#capability-negotiation", + behavior=( + "Declared capabilities.experimental entries (vendor-namespaced keys, arbitrary object values) " + "survive negotiation verbatim in both directions: the client reads the server's via " + "server_capabilities, and server handlers see the client's." + ), + deferred=( + "Not implemented in the SDK: the client cannot declare experimental capabilities -- " + "_build_capabilities (src/mcp/client/session.py) hard-codes experimental=None with no public " + "override -- so the client-to-server half cannot be driven; the server-to-client half " + "(experimental_capabilities on get_capabilities, src/mcp/server/lowlevel/server.py) exists " + "and a later slice may split it out." + ), + ), + "lifecycle:capability:list-empty-when-not-advertised": Requirement( + source="sdk", + behavior=( + "Client list calls (list_tools, list_prompts, list_resources, list_resource_templates) " + "resolve with empty lists, without sending a request, when the server did not advertise the " + "corresponding capability." + ), + deferred=( + "Not implemented in the SDK: the client sends every list request regardless of the server's " + "advertised capabilities and surfaces whatever the server answers (the same gap recorded on " + "lifecycle:capability:server-not-advertised, whose spec-MUST arm is reject rather than " + "soft-empty)." + ), + ), "lifecycle:capability:server-not-advertised": Requirement( source=f"{SPEC_BASE_URL}/basic/lifecycle#operation", behavior=( @@ -203,6 +232,27 @@ def __post_init__(self) -> None: "advertised capabilities and surfaces whatever the server answers." ), ), + "lifecycle:extensions:peer-unsupported-fallback": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/versioning#extension-negotiation", + behavior=( + "When one party supports an extension and its peer does not declare it in " + "capabilities.extensions, the supporting party reverts to core protocol behavior or rejects " + "the request with an appropriate error." + ), + added_in="2026-07-28", + deferred=( + "Not yet covered here: the extension runtime landed on both sides -- clients declare " + "via Client(extensions=[...]) (src/mcp/client/extension.py) and servers via " + "MCPServer(extensions=[...]) (src/mcp/server/extension.py). The reject arm is pinned by " + "extensions:client:capability-ad:gates-server-behaviour (require_client_extension " + "refuses a non-declaring client with -32021 in tests/interaction/mcpserver/" + "test_extensions.py), and the revert-to-core arm is publicly drivable: an extension's " + "intercept_tool_call can branch on the peer's capabilities.extensions ad and call_next " + "for a non-declaring client; no test drives that fallback yet. Whether this entry " + "survives as the negotiation umbrella or retires into the extensions:client:* family " + "is an open owner ruling." + ), + ), "lifecycle:initialize:basic": Requirement( source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization", behavior=( @@ -216,13 +266,15 @@ def __post_init__(self) -> None: source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization", behavior="The initialize result identifies the server: name and version, plus title when declared.", removed_in="2026-07-28", - note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.", + superseded_by="lifecycle:discover:basic", + note="initialize handshake removed at 2026-07-28; server identity moved to the server/discover result.", ), "lifecycle:initialize:instructions": Requirement( source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization", behavior="A server may include an instructions string in the initialize result; the client exposes it.", removed_in="2026-07-28", - note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.", + superseded_by="lifecycle:discover:instructions", + note="initialize handshake removed at 2026-07-28; instructions moved to the server/discover result.", ), "lifecycle:initialize:capabilities:from-handlers": Requirement( source=f"{SPEC_BASE_URL}/basic/lifecycle#capability-negotiation", @@ -231,13 +283,21 @@ def __post_init__(self) -> None: "and omits the capability for areas it does not." ), removed_in="2026-07-28", - note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.", + superseded_by="lifecycle:discover:capabilities:from-handlers", + note=( + "initialize handshake removed at 2026-07-28; server capability advertisement moved to the " + "server/discover result." + ), ), "lifecycle:initialize:capabilities:minimal": Requirement( source=f"{SPEC_BASE_URL}/basic/lifecycle#capability-negotiation", behavior="A server with no feature handlers advertises no feature capabilities.", removed_in="2026-07-28", - note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.", + superseded_by="lifecycle:discover:capabilities:from-handlers", + note=( + "initialize handshake removed at 2026-07-28; server capability advertisement moved to the " + "server/discover result." + ), ), "lifecycle:initialize:client-info": Requirement( source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization", @@ -322,6 +382,7 @@ def __post_init__(self) -> None: "and the connection succeeds at that version." ), removed_in="2026-07-28", + superseded_by="lifecycle:discover:retry-on-32022", note="initialize-time version negotiation removed at 2026-07-28; version carried per-request in _meta.", ), "lifecycle:version:match": Requirement( @@ -340,6 +401,7 @@ def __post_init__(self) -> None: "with another version the server supports — the latest one — rather than an error." ), removed_in="2026-07-28", + superseded_by="lifecycle:version:unsupported-32022", note="initialize-time version negotiation removed at 2026-07-28; version carried per-request in _meta.", ), "lifecycle:version:reject-unsupported": Requirement( @@ -349,27 +411,45 @@ def __post_init__(self) -> None: "support fails initialization with an error rather than proceeding with the session." ), removed_in="2026-07-28", + superseded_by="lifecycle:discover:retry-on-32022", note="initialize-time version negotiation removed at 2026-07-28; version carried per-request in _meta.", ), - "lifecycle:stateless:request-envelope": Requirement( - source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#stateless-operation", + "lifecycle:version:custom-supported-versions": Requirement( + source="sdk", behavior=( - "At protocol_version 2026-07-28, every request carries io.modelcontextprotocol/protocolVersion, " - "/clientInfo, and /clientCapabilities in params._meta; no initialize handshake occurs." + "A supported-versions list passed in client or server options overrides the negotiation " + "set: a client requesting a version the server supports gets that version back, and both " + "sides report the negotiated version after connect." + ), + deferred=( + "Not implemented in the SDK: there is no supported-versions option on either side -- the " + "client negotiates against the module-level MODERN_PROTOCOL_VERSIONS constant " + "(src/mcp/client/session.py), the server advertises list(MODERN_PROTOCOL_VERSIONS) " + "(src/mcp/server/lowlevel/server.py), and neither constructor accepts a versions list." + ), + ), + "lifecycle:version:no-overlap-rejects": Requirement( + source="sdk", + behavior=( + "When the negotiated protocol version is not in the client's configured supported-versions " + "list, connecting fails and no session is established." + ), + deferred=( + "Not implemented in the SDK: the client has no configurable supported-versions list (see " + "lifecycle:version:custom-supported-versions); rejection against the built-in set is pinned " + "by lifecycle:version:reject-unsupported and lifecycle:version:unsupported-32022." ), - added_in="2026-07-28", ), - "lifecycle:stateless:no-initialize": Requirement( - source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#stateless-operation", + "lifecycle:stateless:request-envelope": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic#_meta", behavior=( - "A ClientSession pinned to 2026-07-28 is born initialized: initialize() is idempotent " - "and returns the synthesized result without any frame sent." + "At protocol_version 2026-07-28, every request carries io.modelcontextprotocol/protocolVersion, " + "/clientInfo, and /clientCapabilities in params._meta; no initialize handshake occurs." ), added_in="2026-07-28", - deferred="covered by a tests/client/ unit test; not observable as an interaction", ), "lifecycle:stateless:caller-meta-preserved": Requirement( - source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#stateless-operation", + source=f"{SPEC_2026_BASE_URL}/basic#_meta", behavior=( "Caller-supplied _meta keys on a request survive the per-request envelope merge: the " "three io.modelcontextprotocol/* envelope keys overwrite any caller-supplied values for " @@ -392,54 +472,211 @@ def __post_init__(self) -> None: source=f"{SPEC_2026_BASE_URL}/basic#_meta", behavior=( "Every client→server request on a modern-negotiated session carries " - "_meta.{protocolVersion,clientInfo,clientCapabilities}; notifications do not." + "_meta.{protocolVersion,clientInfo,clientCapabilities}." ), added_in="2026-07-28", - supersedes=("lifecycle:initialize:client-info", "lifecycle:initialize:client-capabilities"), + supersedes=( + "lifecycle:initialize:client-info", + "lifecycle:initialize:client-capabilities", + "sampling:capability:declare", + ), + note=( + "The spec MUST covers requests only. The session's modern stamp is message-agnostic, so " + "session-sent notifications carry the envelope too, while dispatcher-built frames (the " + "courtesy cancel) do not; neither notification arm is asserted here." + ), ), "lifecycle:envelope:header-matches-meta": Requirement( - source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#headers", + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#protocol-version-header", behavior="On HTTP, the MCP-Protocol-Version header on every POST matches _meta.protocolVersion in the body.", transports=("streamable-http", "streamable-http-stateless"), added_in="2026-07-28", note="HTTP-only: the header is a streamable-http transport concern; stdio and in-memory carry no headers.", ), "lifecycle:discover:basic": Requirement( - source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#discover", + source=f"{SPEC_2026_BASE_URL}/server/discover", behavior=( "Calling discover() sends server/discover with no params and returns a typed DiscoverResult " - "carrying protocolVersion, capabilities, serverInfo and the cache hint fields." + "carrying supportedVersions, capabilities and serverInfo." ), added_in="2026-07-28", + supersedes=("lifecycle:initialize:server-info",), ), "lifecycle:discover:retry-on-32022": Requirement( - source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#version-errors", + source=f"{SPEC_2026_BASE_URL}/basic/versioning#protocol-version-negotiation", behavior=( "When server/discover returns -32022 UnsupportedProtocolVersion, the client retries once with " "the intersection of error.data.supported and its own modern versions; an empty intersection raises." ), added_in="2026-07-28", + supersedes=("lifecycle:version:downgrade", "lifecycle:version:reject-unsupported"), + ), + "lifecycle:discover:instructions": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/discover#discoverresult", + behavior=( + "A server-configured instructions string is returned in the server/discover result and exposed " + "to the client." + ), + added_in="2026-07-28", + supersedes=("lifecycle:initialize:instructions",), + ), + "lifecycle:discover:capabilities:from-handlers": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/discover#response", + behavior=( + "The capabilities object in the server/discover result advertises a capability for each feature " + "area with a registered handler and omits feature areas without one." + ), + added_in="2026-07-28", + supersedes=( + "lifecycle:initialize:capabilities:from-handlers", + "lifecycle:initialize:capabilities:minimal", + "tools:capability:declared", + "resources:capability:declared", + "prompts:capability:declared", + "completion:capability:declared", + "logging:capability:declared", + "mcpserver:completion:capability-auto", + ), + ), + "lifecycle:discover:era-cached": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/versioning#backward-compatibility-with-initialization-based-versions", + behavior=( + "An auto-negotiating client probes server/discover exactly once per connection and " + "reuses the adopted result for every subsequent request; an explicit discover() " + "returns the cached result with no new wire traffic." + ), + added_in="2026-07-28", + note=( + "A SHOULD: cache the era verdict for the lifetime of the server process (stdio) or " + "origin (HTTP). The SDK's cache is the session's adopted DiscoverResult, so the " + "pinned lifetime is the connection. The MAY-persist-across-restarts clause is " + "carried by lifecycle:mode:prior-discover-zero-rtt; the re-probe-on-stale follow-on " + "is lifecycle:mode:prior-discover-stale-reprobe (deferred)." + ), + ), + "lifecycle:version:unsupported-32022": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/versioning#protocol-version-negotiation", + behavior=( + "A request declaring a protocol version the server does not implement is answered with -32022 " + "UnsupportedProtocolVersionError whose data.supported lists the versions the server does support." + ), + added_in="2026-07-28", + supersedes=("lifecycle:version:server-fallback-latest",), + note=( + "Only the unknown-version half of the MUST is constructible: the server's " + "supported-version set has no public knob (the modern entry always passes the " + "MODERN_PROTOCOL_VERSIONS default, a singleton at this pin), so a server that " + "declines a known version cannot be built." + ), + ), + "lifecycle:version:era-method-gate": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/versioning", + behavior=( + "A request whose method exists at an earlier protocol revision but is removed at " + "the negotiated 2026-07-28 era (e.g. resources/subscribe) is answered " + "METHOD_NOT_FOUND even when a handler for it is registered." + ), + added_in="2026-07-28", + note=( + "No single spec sentence: the gate is the method-registry consequence of the 2026 " + "removals (key absence in the per-version surface map is the gate). Transport-" + "independent, pinned on both 2026 cells. Instances pinned elsewhere: " + "hosting:http:modern:initialize-removed (initialize) and " + "hosting:http:modern:removed-method-status-404 (ping + the HTTP status half). The " + "same call's 2025 success arm is resources:subscribe (removed_in 2026-07-28). The " + "NC's other two legs are not entries: capability derivation is era-honest for the " + "subscription bits (at modern versions the listChanged flags and resources.subscribe " + "derive from whether subscriptions/listen is served, src/mcp/server/lowlevel/server.py) " + "but logging remains era-agnostic -- a 2026 discover result can still advertise " + "logging for the era-removed logging/setLevel, ruled conformant (schema.ts keeps " + "logging deprecated-but-valid) and deliberately unpinned -- and a client-side " + "typed local era error is a TS surface python does not have." + ), + ), + "lifecycle:version:dual-era-precedence": Requirement( + source="sdk", + behavior=( + "A request that is simultaneously a valid modern envelope-bearing frame and a " + "legacy handshake method -- initialize carrying a full _meta envelope and modern " + "headers -- is classified modern and answered METHOD_NOT_FOUND, never served as a " + "handshake." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "Only observable over streamable HTTP: python's only dual-era serving entry is the " + "session manager, which keys classification on the MCP-Protocol-Version header and " + "the envelope ladder behind it. source='sdk' because the spec's dual-era-server " + "bullets (basic/versioning, Compatibility Matrix) define each signal separately and " + "never say which wins on a frame carrying both; TS implements the identical " + "precedence (NC-dual-era-precedence -- the spec-prose ambiguity is an upstream " + "issue candidate). The headerless half of the precedence is " + "hosting:http:modern:legacy-fallthrough." + ), ), "lifecycle:discover:fallback-method-not-found": Requirement( source=f"{SPEC_2026_BASE_URL}/basic/transports/stdio#backward-compatibility", behavior=( - "When server/discover returns any JSON-RPC error or a bare HTTP 4xx, an auto-negotiating " - "client falls back to the legacy initialize handshake and the connection succeeds at a " - "handshake-era version (legacy servers reject the probe with various codes)." + "When server/discover returns a JSON-RPC error that is not a recognized modern negotiation " + "error (-32022 retries or raises instead; see lifecycle:discover:retry-on-32022), or a bare " + "HTTP 4xx, an auto-negotiating client falls back to the legacy initialize handshake and the " + "connection succeeds at a handshake-era version; the fallback is not keyed to specific codes " + "(legacy servers reject the probe with various codes)." + ), + added_in="2026-07-28", + note=( + "The SDK keys its no-fallback carve-out to -32022 alone, while the spec's carve-out is any " + "recognized modern JSON-RPC error (an open set); no test drives a modern-error probe " + "rejection other than -32022. A handshake-bearing -32022 supported list is a second " + "unpinned reading: the SDK initializes when the intersection is empty but supported names " + "a handshake-era version, which the stdio no-initialize-fallback-on--32022 bullet reads " + "as forbidding, while the spec's own -32022 example lists 2025-11-25 in supported -- left " + "unpinned until the spec text settles which bullet wins." + ), + ), + "lifecycle:discover:timeout-falls-back": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/stdio#backward-compatibility", + behavior=( + "When server/discover does not respond within a reasonable timeout, the " + "auto-negotiating client treats the server as legacy and falls back to the " + "initialize handshake." ), added_in="2026-07-28", + deferred=( + "Not yet covered here: the server/discover probe timeout is the module-level " + "constant DISCOVER_TIMEOUT_SECONDS = 10.0 (src/mcp/client/session.py) with no " + "public override -- send_discover ignores read_timeout_seconds -- so observing the " + "silent-server timeout trigger end-to-end is real-time-bound and is deliberately " + "excluded from this suite; the fallback arm it feeds (any non--32022 MCPError from " + "the probe leads to initialize) is covered by " + "lifecycle:discover:fallback-method-not-found in " + "tests/interaction/lowlevel/test_client_connect.py and by tests/client/test_probe.py." + ), ), "lifecycle:discover:network-error-raises": Requirement( source="sdk", behavior=( "A network/connection error during server/discover propagates to the caller without " - "falling back to initialize; any rpc-error or 4xx falls back (legacy servers reject the " - "probe with various codes). An outage is never an era verdict." + "falling back to initialize; fallback is reserved for server rejections (see " + "lifecycle:discover:fallback-method-not-found). An outage is never an era verdict." ), transports=("streamable-http", "streamable-http-stateless"), added_in="2026-07-28", note="HTTP-only: distinguishes transport-level failures from server-side rejection.", ), + "lifecycle:mode:auto-probes-first": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/stdio#backward-compatibility", + behavior=( + "A dual-era (mode='auto') client sends server/discover before any other request, " + "carrying its preferred modern version in the probe's _meta protocolVersion." + ), + added_in="2026-07-28", + note=( + "A SHOULD. The spec sentence lives on the stdio page but binds the client's " + "connect-time ordering, which is transport-independent code; asserted at the " + "in-process HTTP seam like the sibling stdio#backward-compatibility entries." + ), + ), "lifecycle:mode:legacy-never-probes": Requirement( source="sdk", behavior=( @@ -456,6 +693,32 @@ def __post_init__(self) -> None: ), added_in="2026-07-28", ), + "lifecycle:mode:modern-only-legacy-peer": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/versioning", + behavior=( + "A modern-only client (a version-pinned Client) probes server/discover first on " + "stdio so a legacy peer fails deterministically, and surfaces an actionable era " + "error to the user." + ), + added_in="2026-07-28", + divergence=Divergence( + note=( + "The pinned client's contract is the opposite by design: it adopts a local " + "DiscoverResult with zero connect-time wire traffic (pinned by " + "lifecycle:mode:pin-never-handshakes), so the probe-first SHOULD cannot be " + "satisfied and a legacy peer fails non-deterministically." + ), + ), + deferred=( + "Not implemented in the SDK: the modern-only client (Client mode='') never sends server/discover -- Client.__aenter__ " + "(src/mcp/client/client.py) adopts prior_discover or a locally synthesized " + "DiscoverResult with zero connect-time wire traffic, and no public option makes a " + "pinned client probe first, so the probe-first deterministic-failure behaviour " + "against a legacy peer cannot be driven; the no-probe half is already pinned by " + "lifecycle:mode:pin-never-handshakes." + ), + ), "lifecycle:mode:prior-discover-zero-rtt": Requirement( source="sdk", behavior=( @@ -464,6 +727,23 @@ def __post_init__(self) -> None: ), added_in="2026-07-28", ), + "lifecycle:mode:prior-discover-stale-reprobe": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/versioning#backward-compatibility-with-initialization-based-versions", + behavior=( + "A client that persisted a prior DiscoverResult re-probes when the cached version " + "assumption later fails, instead of surfacing the stale -32022 failure to the caller." + ), + added_in="2026-07-28", + deferred=( + "Not implemented in the SDK: the client has no re-probe path when a cached prior " + "version assumption later fails. Client.__aenter__ with a version-pin mode adopts " + "prior_discover (or a synthesized DiscoverResult) with zero wire traffic " + "(src/mcp/client/client.py), and -32022 UNSUPPORTED_PROTOCOL_VERSION is handled " + "only at connect-time probe (src/mcp/client/_probe.py; " + "src/mcp/client/session.py) -- there is no handling on the regular send_request " + "path, so a stale prior surfaces as MCPError(-32022) to the caller with no re-probe." + ), + ), # ═══════════════════════════════════════════════════════════════════════════ # Protocol primitives: cancellation, timeout, progress, errors, _meta # ═══════════════════════════════════════════════════════════════════════════ @@ -481,6 +761,20 @@ def __post_init__(self) -> None: "to a request the client sent or a notification carrying no id." ), ), + "protocol:directionality:no-client-responses": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns", + behavior=( + "A 2026-07-28 wire trace contains no server-initiated JSON-RPC requests and no " + "client-sent JSON-RPC responses: every client-to-server frame is a request and every " + "server-to-client frame is a response, even across a multi-round-trip exchange that at " + "2025-11-25 was a server-initiated request answered by the client." + ), + added_in="2026-07-28", + note=( + "Asserted at the streamable HTTP wire seam: the in-memory 2026 transport dispatches " + "typed objects directly with no JSON-RPC framing, so it has no trace to inspect." + ), + ), "protocol:cancel:abort-signal": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#cancellation-flow", behavior=( @@ -492,13 +786,53 @@ def __post_init__(self) -> None: "request; cancellation requires hand-constructing the notification (which is how " "protocol:cancel:in-flight exercises the receiving side)." ), + note=( + "At 2026-07-28 the cancellation wire act splits by transport: stdio still sends " + "notifications/cancelled (a MUST), while streamable HTTP replaces it with closing the response " + "stream. A single superseded_by cannot encode the split; the 2026 faces are pinned by " + "protocol:cancel:stdio-sends-cancelled and protocol:cancel:http-stream-close, both landed " + "as deferred entries; they flip to pinning tests when the missing client-side cancel API " + "lands (stdio 2026-era serving, the other former prerequisite, exists now)." + ), ), "protocol:cancel:handler-abort-propagates": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements", behavior="On the receiving side, a cancellation notification stops the running request handler.", arm_exclusions=( ArmExclusion(reason="requires-session", transport="streamable-http-stateless"), - ArmExclusion(reason="requires-session", spec_version="2026-07-28"), + ArmExclusion( + reason="requires-session", + spec_version="2026-07-28", + note=( + "Client-initiated cancellation persists at 2026-07-28 but the SDK's modern path does not " + "handle notifications/cancelled yet. Re-admission target is the in-memory arm only: on " + "streamable HTTP the 2026 cancellation signal is closing the response stream, pinned " + "separately by hosting:http:modern:disconnect-cancels-handler." + ), + ), + ), + ), + "protocol:cancel:http-stream-close": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/cancellation#transport-specific-cancellation", + behavior=( + "On a 2026-07-28 streamable HTTP connection, cancelling an in-flight client request (caller " + "signal or timeout) closes that request's response stream as the cancellation signal; no " + "notifications/cancelled is sent on the wire and the local call fails." + ), + added_in="2026-07-28", + transports=("streamable-http",), + deferred=( + "Not implemented in the SDK: there is no public client-side API to cancel an in-flight " + "request (the standing gap recorded on protocol:cancel:abort-signal), and the streamable " + "HTTP client (src/mcp/client/streamable_http.py) has no deliberate cancel-closes-stream " + "path -- a request's response stream closes only as part of request teardown, which no " + "test can trigger on demand through the public API." + ), + note=( + "Only observable over streamable HTTP: the 2026 cancellation signal is closing the " + "per-request response stream. The server side of the same signal is pinned by " + "hosting:http:modern:disconnect-cancels-handler; the stdio face is the " + "notifications/cancelled MUST (see protocol:cancel:abort-signal's note)." ), ), "protocol:cancel:in-flight": Requirement( @@ -517,7 +851,16 @@ def __post_init__(self) -> None: ), arm_exclusions=( ArmExclusion(reason="requires-session", transport="streamable-http-stateless"), - ArmExclusion(reason="requires-session", spec_version="2026-07-28"), + ArmExclusion( + reason="requires-session", + spec_version="2026-07-28", + note=( + "Client-initiated cancellation persists at 2026-07-28 but the SDK's modern path does not " + "handle notifications/cancelled yet. Re-admission target is the in-memory arm only: on " + "streamable HTTP the 2026 cancellation signal is closing the response stream, pinned " + "separately by hosting:http:modern:disconnect-cancels-handler." + ), + ), ), ), "protocol:cancel:initialize-not-cancellable": Requirement( @@ -531,12 +874,98 @@ def __post_init__(self) -> None: "request stays failed and no error is raised." ), ), + "protocol:cancel:listen-teardown-cancelled": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/cancellation#behavior-requirements", + behavior=( + "When a server tears down a subscriptions/listen stream it sends notifications/cancelled " + "referencing that listen request's id." + ), + added_in="2026-07-28", + deferred=( + "Not implemented in the SDK: listen-stream teardown machinery now exists " + "(src/mcp/server/subscriptions.py -- close() and the buffered-event cap both end streams) " + "but it answers the listen request with the stamped empty result and never emits " + "notifications/cancelled; the only notifications/cancelled send machinery remains the " + "shared dispatcher's courtesy cancel, sent by whichever peer issued an outbound request " + "when it abandons that request " + "(src/mcp/shared/jsonrpc_dispatcher.py). Pinning the absence would take a side in the " + "contradiction described in the note, and observing a teardown through this suite's client " + "needs the subscriptions/listen client driver." + ), + note=( + "The spec is self-contradictory at this revision: the cancellation page says the server " + "MUST send notifications/cancelled on listen teardown, while the subscriptions page's " + "Graceful Closure section says the server SHOULD answer the listen request with an empty " + "result and close the stream. Tracked against the cancellation wording; revisit when the " + "spec editors reconcile the two." + ), + ), + "protocol:cancel:no-further-notifications": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/stdio#cancellation", + behavior=( + "After receiving a cancellation for an in-flight request the server sends no further " + "notifications for that request: a notification the handler attempts during its " + "cancellation unwind never reaches the wire." + ), + note=( + "The 2026-07-28 stdio page states the receiver-side rule as 'MUST NOT send any " + "further messages for it', strengthening the cancellation page's SHOULD-shaped " + "receiver bullets (both revisions); the response half of 'any further messages' is " + "the divergence recorded on protocol:cancel:in-flight (both seats answer a cancelled " + "request with a code-0 error response). This entry pins the notifications half: the " + "cancellation stops the handler, so a send attempted during its unwind is itself " + "cancelled before transmitting. Era-unbounded: the enforcement is the shared " + "handler-scope cancellation, observable on the arms where notifications/cancelled " + "can be driven." + ), + arm_exclusions=( + ArmExclusion(reason="requires-session", transport="streamable-http-stateless"), + ArmExclusion( + reason="requires-session", + spec_version="2026-07-28", + note=( + "Client-initiated cancellation persists at 2026-07-28 but the SDK's modern path does not " + "handle notifications/cancelled yet. Re-admission target is the in-memory arm only: on " + "streamable HTTP the 2026 cancellation signal is closing the response stream, pinned " + "separately by hosting:http:modern:disconnect-cancels-handler." + ), + ), + ), + ), + "protocol:cancel:server-listen-only": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/cancellation#behavior-requirements", + behavior=( + "At 2026-07-28 a server sends notifications/cancelled only to tear down a " + "subscriptions/listen stream, referencing that listen request's id; it never sends one for " + "any other purpose." + ), + added_in="2026-07-28", + deferred=( + "Not implemented in the SDK: the one permitted emission never occurs -- listen-stream " + "teardown exists (src/mcp/server/subscriptions.py) but deliberately answers with the " + "stamped empty result rather than notifications/cancelled -- and the only other emitter, " + "the courtesy cancel on abandoning a server-initiated request " + "(src/mcp/shared/jsonrpc_dispatcher.py), is unreachable at 2026-07-28 where no " + "server-initiated JSON-RPC requests exist, leaving the prohibition vacuously satisfied " + "with nothing to observe; observing a listen stream through this suite's client needs the " + "subscriptions/listen client driver." + ), + ), "protocol:cancel:server-survives": Requirement( source="sdk", behavior="The session continues to serve new requests after an earlier request was cancelled.", arm_exclusions=( ArmExclusion(reason="requires-session", transport="streamable-http-stateless"), - ArmExclusion(reason="requires-session", spec_version="2026-07-28"), + ArmExclusion( + reason="requires-session", + spec_version="2026-07-28", + note=( + "Client-initiated cancellation persists at 2026-07-28 but the SDK's modern path does not " + "handle notifications/cancelled yet. Re-admission target is the in-memory arm only: on " + "streamable HTTP the 2026 cancellation signal is closing the response stream, pinned " + "separately by hosting:http:modern:disconnect-cancels-handler." + ), + ), ), ), "protocol:cancel:server-to-client": Requirement( @@ -545,9 +974,36 @@ def __post_init__(self) -> None: "A server that abandons an in-flight server-initiated request (sampling, elicitation, roots) " "cancels it, and the client stops processing the cancelled request." ), - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2322/SEP-2575); with server-initiated requests retired there is " + "nothing in flight on the client for a server to cancel, and servers MUST NOT send " + "notifications/cancelled except to tear down a subscriptions/listen stream (pinned separately as " + "protocol:cancel:server-listen-only when the cancellation slice lands it). No replacement." + ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), + ), + "protocol:cancel:stdio-sends-cancelled": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/cancellation#transport-specific-cancellation", + behavior=( + "On a 2026-07-28 stdio connection, cancelling an in-flight client request sends " + "notifications/cancelled referencing the request id -- stdio has no per-request stream to " + "close, so the notification remains the cancellation signal." + ), + added_in="2026-07-28", + transports=("stdio",), + deferred=( + "Not implemented in the SDK: there is no public client-side API to cancel an in-flight " + "request (the standing gap recorded on protocol:cancel:abort-signal), so the sender-side " + "wire act cannot be driven. The former second blocker is gone: stream-pair 2026 serving " + "landed (serve_dual_era_loop, src/mcp/server/runner.py, serves 2026-era requests over " + "stdio and every other stream pair), so a 2026 stdio exchange now exists; only the " + "cancel trigger is missing." + ), + note=( + "Only observable over stdio: the streamable HTTP face of the same transport split is " + "closing the response stream, pinned by protocol:cancel:http-stream-close and " + "hosting:http:modern:disconnect-cancels-handler." ), ), "protocol:cancel:unknown-id-ignored": Requirement( @@ -568,10 +1024,72 @@ def __post_init__(self) -> None: "protocol:cancel:abort-signal), so the sender-side targeting rule has nothing to pin." ), ), + "custom-methods:client-handler:roundtrip": Requirement( + source="sdk", + behavior=( + "A client-side handler registered for a vendor-defined (non-spec) request method serves " + "requests sent by the server, with params and result validated against caller-supplied " + "schemas." + ), + removed_in="2026-07-28", + note=( + "removed in 2026-07-28: with server-initiated JSON-RPC requests retired " + "(protocol:directionality:no-client-responses) there is no server-to-client request for a " + "vendor method to ride on. No replacement." + ), + deferred=( + "Not implemented in the SDK: the client exposes no per-method request-handler registration " + "-- inbound server requests are parsed against the closed per-version method registry, and " + "an unknown method is answered with METHOD_NOT_FOUND before any callback " + "(src/mcp/client/session.py), so a vendor-method request can never reach typed handler code." + ), + ), + "errors:capability:sdkerror-capability-not-supported": Requirement( + source="sdk", + behavior=( + "Invoking an operation whose capability the remote side did not declare rejects locally " + "with a typed capability-not-supported error, without sending the request." + ), + deferred=( + "Not implemented in the SDK: neither seat checks the peer's declared capabilities before " + "sending -- there is no local pre-send capability gate and no typed capability error class; " + "the capability-check surfaces that do exist are receive-side (the server-side " + "ServerSession.check_client_capability boolean, src/mcp/server/session.py, and the " + "require_client_extension gate, src/mcp/server/mcpserver/server.py), and no send path " + "consults them." + ), + note=( + "The capability-gating gaps are also recorded on lifecycle:capability:client-not-declared " + "and lifecycle:capability:server-not-advertised; this entry tracks the cross-SDK local " + "error contract." + ), + ), + "protocol:custom-method:notification": Requirement( + source="sdk", + behavior=( + "A notification sent for a vendor-defined (non-spec) method is dispatched on the receiving " + "client to a handler registered for that method, with schema-validated params and no " + "capability error on either side." + ), + deferred=( + "Not implemented in the SDK: the client exposes no per-method notification handler " + "registration -- inbound notifications are parsed against the closed per-version method " + "registry, and an unknown method is dropped with a debug log before any callback " + "(src/mcp/client/session.py), so a vendor-method notification can never reach typed " + "handler code." + ), + ), "protocol:error:connection-closed": Requirement( source="sdk", behavior="Closing the transport fails all in-flight requests with a connection-closed error.", ), + "protocol:error:handler-error-passthrough": Requirement( + source="sdk", + behavior=( + "An MCPError raised by a request handler is returned to the caller as a JSON-RPC error " + "carrying the handler-chosen code and message verbatim." + ), + ), "protocol:error:internal-error": Requirement( source=f"{SPEC_BASE_URL}/basic#responses", behavior=( @@ -615,14 +1133,31 @@ def __post_init__(self) -> None: note=( "The dispatcher drops null-id error responses with a debug log; in v1, JSONRPCError.id was " "non-nullable, so a null-id error response failed transport validation and the resulting " - "ValidationError was surfaced to message_handler as an exception. A typed fault channel " - "restoring visibility is planned before v2 stable." + "ValidationError was surfaced to message_handler as an exception. The v2 fault channel " + "exists (message_handler receives stream exceptions), but response routing drops the " + "null-id error before anything reaches it." ), ), deferred=( "Not yet covered here: the current drop is pinned at the dispatcher level by " - "tests/shared/test_jsonrpc_dispatcher.py; an interaction-level test waits on the planned " - "fault channel." + "tests/shared/test_jsonrpc_dispatcher.py; an interaction-level test waits on the dispatcher " + "routing null-id errors into the existing fault channel." + ), + ), + "errors:wire:legacy-code-opaque": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic#error-codes", + behavior=( + "An error with a code from the legacy -32000..-32019 sub-range (other than -32002) " + "reaches the caller verbatim as a generic protocol error -- code, message, and data " + "unmodified, with no meaning assigned by the receiver." + ), + added_in="2026-07-28", + note=( + "The 2026-07-28 revision partitions the JSON-RPC implementation-defined range: " + "-32000..-32019 is legacy and opaque to receivers, -32020..-32099 is reserved for " + "the specification. The nearest sibling protocol:error:handler-error-passthrough " + "pins the era-independent pass-through mechanics; this entry pins the 2026 " + "receiver-side opacity rule on a code from the named sub-range." ), ), "protocol:meta:related-task": Requirement( @@ -635,12 +1170,23 @@ def __post_init__(self) -> None: "extension." ), ), - "meta:request-to-handler": Requirement( + "protocol:meta:request-to-handler": Requirement( source=f"{SPEC_BASE_URL}/basic#_meta", behavior="The _meta object the client attaches to a request is visible to the server handler.", - arm_exclusions=(ArmExclusion(reason="asserts-legacy-handshake", spec_version="2026-07-28"),), + arm_exclusions=( + ArmExclusion( + reason="legacy-only-vocabulary", + spec_version="2026-07-28", + note=( + "The pass-through itself holds at 2026, but the modern envelope merges the reserved " + "io.modelcontextprotocol/* keys into every request's _meta, so the test's " + "nothing-else-injected equality assertion only holds on the legacy wire; needs an " + "era-aware assertion before re-admission." + ), + ), + ), ), - "meta:result-to-client": Requirement( + "protocol:meta:result-to-client": Requirement( source=f"{SPEC_BASE_URL}/basic#_meta", behavior="The _meta object a handler attaches to its result is delivered to the client.", ), @@ -712,7 +1258,12 @@ def __post_init__(self) -> None: "protocol:progress:client-to-server": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow", behavior="A progress notification sent by the client is delivered to the server's progress handler.", - arm_exclusions=(ArmExclusion(reason="requires-session", spec_version="2026-07-28"),), + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2575); client-to-server progress is unrepresentable -- the only " + "client notification is notifications/cancelled, and there are no server-initiated requests to " + "report progress on." + ), ), "protocol:timeout:basic": Requirement( source=f"{SPEC_BASE_URL}/basic/lifecycle#timeouts", @@ -749,6 +1300,11 @@ def __post_init__(self) -> None: "When a request times out, the sender issues notifications/cancelled for that request before " "failing the local call." ), + note=( + "At 2026-07-28 on streamable HTTP, timeout cancellation is expressed by closing the response " + "stream rather than notifications/cancelled; the in-memory act this entry pins remains " + "spec-correct. Era-unbounded by design." + ), ), "protocol:timeout:session-survives": Requirement( source=f"{SPEC_BASE_URL}/basic/lifecycle#timeouts", @@ -798,10 +1354,13 @@ def __post_init__(self) -> None: "A tool handler that issues an elicitation receives the client's result and can embed it in " "the tool call result." ), - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + removed_in="2026-07-28", + superseded_by="mrtr:tools-call:write-once-roundtrip", + note=( + "removed in 2026-07-28 (SEP-2322); the in-tool elicitation round trip is now the MRTR " + "input_required/retry loop." ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "tools:call:is-error": Requirement( source=f"{SPEC_BASE_URL}/server/tools#error-handling", @@ -816,6 +1375,14 @@ def __post_init__(self) -> None: "Log notifications emitted by a tool handler during execution reach the client's logging " "callback before the tool result returns." ), + divergence=Divergence( + note=( + "At 2026-07-28 the spec forbids notifications/message for a request whose _meta lacks the " + "io.modelcontextprotocol/logLevel opt-in; the session's send_log_message never reads that " + "key and the tool handler's mid-call messages are delivered unconditionally, so a bound " + "test pins the un-gated delivery on the live 2026-07-28 cells." + ), + ), ), "tools:call:progress": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow", @@ -830,10 +1397,13 @@ def __post_init__(self) -> None: "A tool handler that issues a sampling request receives the client's completion and can embed " "it in the tool call result." ), - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + removed_in="2026-07-28", + superseded_by="sampling:mrtr:create:basic", + note=( + "removed in 2026-07-28 (SEP-2322); the in-tool sampling round trip is now the MRTR " + "input_required/retry loop." ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "tools:call:structured-content": Requirement( source=f"{SPEC_BASE_URL}/server/tools#structured-content", @@ -842,6 +1412,13 @@ def __post_init__(self) -> None: "tools:call:structured-content:text-mirror": Requirement( source=f"{SPEC_BASE_URL}/server/tools#structured-content", behavior="A tool returning structured content also returns the serialized JSON as a text content block.", + divergence=Divergence( + note=( + "Holds for object returns (the bound test pins the serialized-JSON mirror); a " + "list-returning tool yields one text block per element rather than the serialized JSON " + "of its structured value (pinned by the test on mcpserver:tool:output-schema:wrapped)." + ), + ), ), "tools:call:unknown-name": Requirement( source=f"{SPEC_BASE_URL}/server/tools#error-handling", @@ -850,8 +1427,13 @@ def __post_init__(self) -> None: "tools:capability:declared": Requirement( source=f"{SPEC_BASE_URL}/server/tools#capabilities", behavior="A server with a list_tools handler advertises the tools capability in its initialize result.", - arm_exclusions=(ArmExclusion(reason="legacy-only-vocabulary", spec_version="2026-07-28"),), - ), + removed_in="2026-07-28", + superseded_by="lifecycle:discover:capabilities:from-handlers", + note=( + "initialize handshake removed at 2026-07-28; server capability advertisement moved to the " + "server/discover result." + ), + ), "tools:input-schema:json-schema-2020-12": Requirement( source=f"{SPEC_BASE_URL}/server/tools#tool", behavior=( @@ -877,15 +1459,64 @@ def __post_init__(self) -> None: "When the tool set changes, the server sends notifications/tools/list_changed and it reaches " "the client's handler." ), - arm_exclusions=( - ArmExclusion(reason="requires-session", transport="streamable-http-stateless"), - ArmExclusion(reason="requires-session", spec_version="2026-07-28"), + removed_in="2026-07-28", + superseded_by="tools:listen:list-changed", + note=( + "removed in 2026-07-28 (SEP-2575); unsolicited server notifications retired -- list_changed is " + "delivered only on a subscriptions/listen stream." + ), + arm_exclusions=(ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),), + ), + "tools:listen:list-changed": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/tools#list-changed-notification", + behavior=( + "A notifications/tools/list_changed emitted while a client's subscriptions/listen stream " + "requested toolsListChanged is delivered on that stream and dispatched to the client's " + "registered notification handler." + ), + added_in="2026-07-28", + supersedes=("tools:list-changed",), + deferred=( + "Not yet covered here: the server listen runtime landed (src/mcp/server/subscriptions.py) " + "and a scripted in-memory ClientSession subscriptions/listen request already delivers the " + "stamped notification to the client's registered handler; the typed subscriptions/listen " + "client driver is still missing, so the test either drives that session seam now or waits " + "for the driver." ), ), "tools:list:basic": Requirement( source=f"{SPEC_BASE_URL}/server/tools#listing-tools", behavior="tools/list returns the registered tools with name, description, and inputSchema.", ), + "tools:list:connection-independent": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/tools#capabilities", + behavior=( + "The set of tools returned by tools/list does not vary per-connection and does not " + "change as a side effect of other requests on the connection: concurrent connections " + "to one server see the same list, before and after one of them calls a tool." + ), + added_in="2026-07-28", + note=( + "New normative text in the 2026-07-28 revision (the 2025-11-25 tools page has no " + "per-connection-invariance language). The spec's carve-out -- the set MAY vary by the " + "authorization presented on the request -- is per-request input, not connection state, " + "and is not exercised here. Sibling of resources:list:connection-invariant and " + "prompts:list:connection-invariant: the same paragraph instantiated per feature page." + ), + ), + "tools:list:deterministic-order": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/tools#capabilities", + behavior=( + "tools/list returns tools in a deterministic order: repeated requests against an " + "unchanged tool set return the same ordering." + ), + added_in="2026-07-28", + note=( + "New SHOULD in the 2026-07-28 revision, motivated by client-side list caching and " + "prompt-cache hit rates. MCPServer's deterministic order is registration order (the " + "registry is an insertion-ordered dict); the test pins that choice." + ), + ), "tools:list:metadata": Requirement( source=f"{SPEC_BASE_URL}/server/tools#tool", behavior=( @@ -903,6 +1534,122 @@ def __post_init__(self) -> None: # ═══════════════════════════════════════════════════════════════════════════ # Tools: SDK guarantees # ═══════════════════════════════════════════════════════════════════════════ + "client:jsonschema:2020-12:prefixItems": Requirement( + source=f"{SPEC_BASE_URL}/server/tools#output-schema", + behavior=( + "The client validator enforces JSON Schema 2020-12 vocabulary: structuredContent " + "violating a prefixItems per-index schema inside the tool's declared outputSchema is " + "rejected, and a conforming tuple is returned to the caller." + ), + note=( + "The schema under test declares $schema 2020-12 explicitly, separating vocabulary " + "enforcement under a declared dialect from the no-$schema default (the sibling " + "client:jsonschema:dialect:default-is-2020-12). Era-unbounded: the JSON Schema usage " + "rules date from 2025-11-25 and the schema/value pair is object-rooted, legal on " + "every era cell." + ), + ), + "client:jsonschema:dialect:default-is-2020-12": Requirement( + source=f"{SPEC_BASE_URL}/basic#json-schema-usage", + behavior=( + "An outputSchema without a $schema field is validated with the JSON Schema 2020-12 " + "dialect (a 2020-12-only keyword such as prefixItems is enforced); a schema that " + "declares a supported dialect is validated according to the declared dialect instead." + ), + note=( + "Both halves are the spec's own sentence ('validate schemas according to their " + "declared or default dialect'). The declared-dialect half is pinned with draft-07, " + "under which prefixItems is an unknown (ignored) keyword -- the same schema/value " + "pair flips outcome purely on the $schema field, proving the no-$schema enforcement " + "is genuinely the default and not a hardcoded engine. Era-unbounded, as the " + "prefixItems sibling." + ), + ), + "client:jsonschema:falsy-structured-content-validated": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/tools#structured-content", + behavior=( + "A falsy structuredContent value (0, false, '') is treated as present by the client: " + "it is validated against the declared outputSchema and a conforming value is returned " + "to the caller, never mistaken for missing structured content." + ), + added_in="2026-07-28", + note=( + "added_in is load-bearing, not decorative: the 2025-11-25 wire surface restricts " + "outputSchema to a type 'object' root at serialization (serialize_server_result " + "literal-errors the tools/list result), so the non-object schemas these arms need " + "are unconstructible on 2025 cells -- probe-verified at the pin." + ), + ), + "client:jsonschema:non-object-output": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/tools#output-schema", + behavior=( + "A tool whose outputSchema has a non-object root (e.g. type: array) is validated by " + "the client on a 2026-07-28 connection: conforming structuredContent resolves and is " + "returned as-is, and violating structuredContent is rejected." + ), + added_in="2026-07-28", + note=( + "added_in is load-bearing: structuredContent is restricted to a JSON object and " + "outputSchema to an object root through 2025-11-25 (the server's 2025 wire surface " + "refuses to even list an array-rooted schema -- probe-verified); 2026-07-28 widens " + "both to any JSON value." + ), + ), + "client:jsonschema:null-structured-content": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/tools#structured-content", + behavior=( + "A tool whose outputSchema is {type: 'null'} returning structuredContent null is " + "accepted by the client: null is a valid JSON value that conforms to the schema " + "(2026-07-28 allows any JSON value), and the call resolves with it." + ), + added_in="2026-07-28", + divergence=Divergence( + note=( + "The client rejects a wire structuredContent null as if it were missing: " + "CallToolResult.structured_content parses JSON null to None -- the same value as " + "field-absent, with no sentinel -- and the presence check in " + "ClientSession._validate_tool_result (src/mcp/client/session.py) reads is-None as " + "'did not return structured content' and raises RuntimeError, so a conforming " + "null never reaches the schema validator. A fix needs an absent-vs-null sentinel " + "on the model before the presence check can tell the cases apart." + ), + issue="L116", + ), + note=( + "The typed Server cannot author the wire null (structured_content None means absent " + "and exclude_none strips it at serialization), so the test plays the server by hand " + "over memory streams against a pinned-2026 ClientSession." + ), + ), + "client:jsonschema:ref-resolution:no-network-fetch": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic#ref-resolution", + behavior=( + "The client-side schema validator never dereferences a $ref that resolves to a network URI; " + "a schema that fails to validate because of an unresolved external $ref is rejected rather " + "than treated as permissive." + ), + added_in="2026-07-28", + deferred=( + "Untestable negative through the public API: proving the validator never performs a network " + "fetch is a universally-quantified negative this suite refuses -- the client hands the " + "advertised outputSchema to the jsonschema library with no custom resolver " + "(src/mcp/client/session.py), and no public knob configures network retrieval whose absence " + "a test could pin." + ), + ), + "client:jsonschema:unsupported-dialect-graceful": Requirement( + source=f"{SPEC_BASE_URL}/basic#json-schema-usage", + behavior=( + "A tool whose advertised outputSchema declares a $schema dialect the client validator does " + "not support is refused gracefully: the call fails with a clear unsupported-dialect error " + "instead of the underlying engine failing opaquely." + ), + deferred=( + "Not implemented in the SDK: nothing inspects the declared $schema dialect -- " + "_validate_tool_result (src/mcp/client/session.py) hands the advertised schema straight to " + "jsonschema.validate, so there is no SDK-authored unsupported-dialect rejection to pin." + ), + ), "client:output-schema:skip-on-error": Requirement( source="sdk", behavior="The client skips structured-content validation when the tool result has isError true.", @@ -932,6 +1679,228 @@ def __post_init__(self) -> None: ), ), ), + "client:x-mcp-header:invalid-definition-rejected": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#schema-extension", + behavior=( + "A tool definition whose x-mcp-header value violates the schema-extension " + "constraints is rejected by the modern client: the tool is excluded from the " + "tools/list result while valid sibling tools survive." + ), + added_in="2026-07-28", + note=( + "The spec scopes the rejection MUST to clients using the Streamable HTTP transport " + "(other transports MAY ignore the annotations); the SDK gates on the negotiated modern " + "version instead, so the exclusion also runs on the in-memory 2026 connection -- a " + "deliberate superset, pinned on both cells." + ), + ), + "client:x-mcp-header:invalid-definition-rejected:empty": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#schema-extension", + behavior=( + "A tool whose x-mcp-header annotation is the empty string is excluded from the " + "modern client's tools/list result." + ), + added_in="2026-07-28", + note=( + "The spec scopes the rejection MUST to clients using the Streamable HTTP transport " + "(other transports MAY ignore the annotations); the SDK gates on the negotiated modern " + "version instead, so the exclusion also runs on the in-memory 2026 connection -- a " + "deliberate superset, pinned on both cells." + ), + ), + "client:x-mcp-header:invalid-definition-rejected:non-tchar": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#schema-extension", + behavior=( + "A tool whose x-mcp-header annotation is not an RFC 9110 field-name token is " + "excluded from the modern client's tools/list result." + ), + added_in="2026-07-28", + note=( + "The spec scopes the rejection MUST to clients using the Streamable HTTP transport " + "(other transports MAY ignore the annotations); the SDK gates on the negotiated modern " + "version instead, so the exclusion also runs on the in-memory 2026 connection -- a " + "deliberate superset, pinned on both cells." + ), + ), + "client:x-mcp-header:invalid-definition-rejected:control-chars": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#schema-extension", + behavior=( + "A tool whose x-mcp-header annotation contains control characters (CR/LF) is " + "excluded from the modern client's tools/list result." + ), + added_in="2026-07-28", + note=( + "The spec scopes the rejection MUST to clients using the Streamable HTTP transport " + "(other transports MAY ignore the annotations); the SDK gates on the negotiated modern " + "version instead, so the exclusion also runs on the in-memory 2026 connection -- a " + "deliberate superset, pinned on both cells." + ), + ), + "client:x-mcp-header:invalid-definition-rejected:duplicate": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#schema-extension", + behavior=( + "A tool whose inputSchema carries two x-mcp-header values equal under " + "case-insensitive comparison is excluded from the modern client's tools/list result." + ), + added_in="2026-07-28", + note=( + "The spec scopes the rejection MUST to clients using the Streamable HTTP transport " + "(other transports MAY ignore the annotations); the SDK gates on the negotiated modern " + "version instead, so the exclusion also runs on the in-memory 2026 connection -- a " + "deliberate superset, pinned on both cells." + ), + ), + "client:x-mcp-header:invalid-definition-rejected:non-primitive": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#schema-extension", + behavior=( + "An x-mcp-header annotation on a non-primitive property (e.g. type number, which " + "the spec explicitly forbids) makes the tool definition invalid and the modern " + "client excludes it from tools/list." + ), + added_in="2026-07-28", + note=( + "The spec scopes the rejection MUST to clients using the Streamable HTTP transport " + "(other transports MAY ignore the annotations); the SDK gates on the negotiated modern " + "version instead, so the exclusion also runs on the in-memory 2026 connection -- a " + "deliberate superset, pinned on both cells." + ), + ), + "client:x-mcp-header:invalid-definition-rejected:not-statically-reachable": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#schema-extension", + behavior=( + "An x-mcp-header annotation on a property not reachable from the schema root via a " + "pure properties chain (e.g. under items) invalidates the tool and the modern client " + "excludes it from tools/list; an annotation on a nested pure-properties chain stays valid." + ), + added_in="2026-07-28", + note=( + "The spec scopes the rejection MUST to clients using the Streamable HTTP transport " + "(other transports MAY ignore the annotations); the SDK gates on the negotiated modern " + "version instead, so the exclusion also runs on the in-memory 2026 connection -- a " + "deliberate superset, pinned on both cells." + ), + ), + "client:x-mcp-header:invalid-tool-excluded:logs-warning": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/tools#x-mcp-header", + behavior=( + "When the modern client rejects a tool definition over an invalid x-mcp-header, " + "it logs a warning naming the tool and the reason for rejection." + ), + added_in="2026-07-28", + note="A SHOULD; the same text also appears on the streamable-http transport page.", + ), + "2025:jsonschema:non-object-output-wrapped": Requirement( + source="sdk", + behavior=( + "On a 2025-era listing, a tool registered with a non-object-root outputSchema advertises it " + "wrapped as {type: 'object', properties: {result: }, required: ['result']} (the " + "SEP-2106 legacy interop envelope), keeping the schema valid 2025 wire data." + ), + removed_in="2026-07-28", + deferred=( + "Not implemented in the SDK: there is no era-conditional SEP-2106 projection -- MCPServer " + "derives output schemas only from return annotations, wrapping non-object roots in " + "{'result': ...} at registration time on every era " + "(src/mcp/server/mcpserver/utilities/func_metadata.py; pinned by " + "mcpserver:tool:output-schema:wrapped), and no raw-outputSchema registration path exists, " + "so a natural non-object schema for the 2025 era to wrap cannot be constructed." + ), + note=( + "Era-bound, like its five 2025:jsonschema: siblings: the wrap exists only on 2025-era " + "exchanges; at 2026-07-28 non-object roots are legal wire data and pass through naturally " + "(the server:jsonschema: entries)." + ), + ), + "2025:jsonschema:non-object-structured-content-wrapped": Requirement( + source="sdk", + behavior=( + "On a 2025-era tools/call, non-object structuredContent (array, primitive, or null) is " + "delivered wrapped as {result: } with the auto text fallback injected, satisfying " + "both the 2025 object-only wire shape and the wrapped advertised outputSchema." + ), + removed_in="2026-07-28", + note=( + "Era-bound: 2025-era-only legacy interop projection (see 2025:jsonschema:non-object-output-wrapped's note)." + ), + deferred=( + "Not implemented in the SDK: there is no era-aware projection on the result path -- " + "convert_result (src/mcp/server/mcpserver/utilities/func_metadata.py) wraps " + "annotation-derived values identically on every era and passes a handler-built " + "CallToolResult through untouched, so a 2025-specific wrap on the wire cannot be observed." + ), + ), + "2025:jsonschema:ref-rewrite-on-wrap": Requirement( + source="sdk", + behavior=( + "On a 2025-era listing, same-document $ref pointers in a non-object outputSchema wrapped " + "under #/properties/result are rewritten so they keep resolving: the wrapped schema " + "compiles on the client and validates the wrapped {result: ...} structuredContent." + ), + removed_in="2026-07-28", + note=( + "Era-bound: 2025-era-only legacy interop projection (see 2025:jsonschema:non-object-output-wrapped's note)." + ), + deferred=( + "Not implemented in the SDK: no $ref rewriting exists anywhere in src/mcp/server/ -- " + "schemas are generated whole from pydantic models with $defs at the document root " + "(src/mcp/server/mcpserver/utilities/func_metadata.py), and the SEP-2106 wrap-then-rewrite " + "projection that would create dangling pointers does not exist." + ), + ), + "2025:jsonschema:ref-rewrite-scope": Requirement( + source="sdk", + behavior=( + "The legacy-wrap $ref rewrite is position-aware ($ref and $dynamicRef in subschema " + "positions only, never keyword-position literal data) and $id-scoped (a subtree carrying " + "$id keeps its same-document refs unrewritten, resolving against the embedded base)." + ), + removed_in="2026-07-28", + note=( + "Era-bound: 2025-era-only legacy interop projection (see 2025:jsonschema:non-object-output-wrapped's note)." + ), + deferred=( + "Not implemented in the SDK: the rewrite whose scoping this entry refines does not exist " + "(see 2025:jsonschema:ref-rewrite-on-wrap); no code in src/mcp/server/ walks or rewrites " + "schema documents." + ), + ), + "2025:jsonschema:schemaless-non-object-sc-wrapped": Requirement( + source="sdk", + behavior=( + "On a 2025-era tools/call, a tool with no advertised outputSchema whose handler returns " + "non-object structuredContent has the value wrapped as {result: } on value shape " + "alone, because the 2025 wire shape requires structuredContent to be an object." + ), + removed_in="2026-07-28", + note=( + "Era-bound: 2025-era-only legacy interop projection (see 2025:jsonschema:non-object-output-wrapped's note)." + ), + deferred=( + "Not implemented in the SDK: a handler-built CallToolResult with no output schema is passed " + "through untouched by convert_result " + "(src/mcp/server/mcpserver/utilities/func_metadata.py) on every era, so non-object " + "structuredContent reaches the 2025 wire unwrapped rather than projected." + ), + ), + "2025:jsonschema:wrap-follows-schema-not-value": Requirement( + source="sdk", + behavior=( + "On the 2025 era, a tool whose outputSchema has a non-object root wraps every " + "structuredContent value as {result: } -- including object-valued results -- so the " + "result always satisfies the wrapped schema advertised in tools/list: the wrap predicate " + "follows the per-tool schema decision, not the runtime value shape." + ), + removed_in="2026-07-28", + note=( + "Era-bound: 2025-era-only legacy interop projection (see 2025:jsonschema:non-object-output-wrapped's note)." + ), + deferred=( + "Not implemented in the SDK: the wrap decision the entry constrains is registration-time " + "and era-independent (wrap_output in " + "src/mcp/server/mcpserver/utilities/func_metadata.py), not the per-era projection predicate " + "SEP-2106 describes; a natural non-object root cannot be advertised in the first place." + ), + ), "mcpserver:output-schema:missing-structured": Requirement( source=f"{SPEC_BASE_URL}/server/tools#output-schema", behavior="A tool with an output schema whose function returns no structured content produces a server error.", @@ -983,6 +1952,21 @@ def __post_init__(self) -> None: "with the validation failure described in content) without invoking the function." ), ), + "mcpserver:tool:input-validation:dialect-default-2020-12": Requirement( + source=f"{SPEC_BASE_URL}/server/tools#tool", + behavior=( + "Tool-call arguments are validated against the advertised inputSchema under the JSON Schema " + "2020-12 dialect when the schema declares no $schema field." + ), + deferred=( + "Not implemented in the SDK: MCPServer never runs a JSON-Schema engine over tool-call " + "arguments -- Tool.run validates via the pydantic arg_model built from the function " + "signature (src/mcp/server/mcpserver/tools/base.py), the advertised inputSchema is that " + "model's generated schema, and there is no raw-inputSchema registration path, so no " + "$schema/dialect selection exists on the input side (the SDK's only JSON-Schema engine is " + "the client-side output validator in src/mcp/client/session.py)." + ), + ), "mcpserver:tool:naming-validation": Requirement( source="sdk", behavior=( @@ -1029,20 +2013,74 @@ def __post_init__(self) -> None: "error -32042 with the elicitation parameters intact." ), removed_in="2026-07-28", + superseded_by="mrtr:url-elicitation:no-32042-on-2026", note=( "removed in 2026-07-28 (SEP-2322); error -32042 retired, replaced by an MRTR input_required result " "carrying inputRequests." ), ), + "server:jsonschema:array-structured-content-textfallback": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/tools#structured-content", + behavior=( + "A tool whose handler returns array-typed structuredContent and no text content has a " + "serialized-JSON text block auto-appended (the backwards-compatibility SHOULD); an " + "author-supplied text block suppresses the auto-append." + ), + added_in="2026-07-28", + deferred=( + "Not implemented in the SDK: MCPServer never emits array-typed structuredContent -- " + "annotation-derived list returns are wrapped in {'result': ...} on every era " + "(src/mcp/server/mcpserver/utilities/func_metadata.py), so the 2026 natural-array result " + "the fallback would decorate cannot be produced; the wrapped-object fallback that exists " + "today is pinned by tools:call:structured-content:text-mirror." + ), + ), + "server:jsonschema:primitive-structured-content": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/tools#structured-content", + behavior=( + "A tool whose handler returns primitive (string, number, boolean, or null) " + "structuredContent round-trips on the 2026-07-28 era: the value reaches the client " + "unwrapped and the auto text fallback carries its JSON serialisation." + ), + added_in="2026-07-28", + deferred=( + "Not implemented in the SDK: primitive returns are wrapped in {'result': ...} on every era " + "(src/mcp/server/mcpserver/utilities/func_metadata.py; pinned by " + "mcpserver:tool:output-schema:wrapped), so an unwrapped primitive structuredContent never " + "reaches the wire." + ), + ), + "server:jsonschema:union-output-natural": Requirement( + source="sdk", + behavior=( + "On the 2026-07-28 era, a tool whose output type is a union of object and non-object arms " + "advertises the natural typeless {anyOf: [...]} root and returns structuredContent " + "unwrapped on both arms, with the auto text fallback still firing for the non-object arm." + ), + added_in="2026-07-28", + deferred=( + "Not implemented in the SDK: a union return annotation is wrapped in {'result': ...} like " + "any other non-object root (src/mcp/server/mcpserver/utilities/func_metadata.py), and no " + "registration path advertises a natural {anyOf: [...]} root, so neither arm can be " + "observed unwrapped." + ), + ), # ═══════════════════════════════════════════════════════════════════════════ # MCPServer: Context helpers (SDK) # ═══════════════════════════════════════════════════════════════════════════ - "mcpserver:context:logging": Requirement( + "mcpserver:context:log-from-handler": Requirement( source="sdk", behavior=( "The Context logging helpers (debug/info/warning/error) send log message notifications at the " "corresponding severity." ), + divergence=Divergence( + note=( + "At 2026-07-28 the spec forbids notifications/message for a request whose _meta lacks the " + "io.modelcontextprotocol/logLevel opt-in; the Context helpers never read that key and emit " + "unconditionally, so a bound test pins the un-gated delivery on the live 2026-07-28 cells." + ), + ), ), "mcpserver:context:progress": Requirement( source="sdk", @@ -1050,15 +2088,38 @@ def __post_init__(self) -> None: "Context.report_progress sends a progress notification against the requesting client's progress token." ), ), - "mcpserver:context:elicit": Requirement( + "mcpserver:context:elicit-from-handler": Requirement( source="sdk", behavior=( "Context.elicit sends a form elicitation built from a typed schema and returns a typed " "accepted/declined/cancelled result." ), - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + removed_in="2026-07-28", + superseded_by="mrtr:tools-call:write-once-roundtrip", + note=( + "removed in 2026-07-28 (SEP-2322); in-tool elicitation now returns an input_required result from " + "the tool; the push Context API's 2026 failure mode is pinned separately by " + "mrtr:push-api:loud-fail-2026." + ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), + ), + "mcpserver:context:sampling-from-handler": Requirement( + source="sdk", + behavior=( + "A Context sampling helper sends sampling/createMessage to the client from inside a tool " + "handler and resolves with the client's CreateMessageResult." + ), + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2322); in-tool sampling at 2026 is the input_required " + "embedding (sampling:mrtr:create:basic), so the push shape never gained a Context helper. " + "No replacement entry." + ), + deferred=( + "Not implemented in the SDK: Context (src/mcp/server/mcpserver/context.py) exposes " + "elicitation, logging, progress, and resource-read helpers but no sampling helper; the " + "only route is the ctx.session escape hatch onto the lowlevel session, which is not an " + "MCPServer idiom." ), ), "mcpserver:context:read-resource": Requirement( @@ -1084,7 +2145,12 @@ def __post_init__(self) -> None: "A server with resource handlers advertises the resources capability, including the subscribe " "sub-flag when a subscribe handler is registered." ), - arm_exclusions=(ArmExclusion(reason="legacy-only-vocabulary", spec_version="2026-07-28"),), + removed_in="2026-07-28", + superseded_by="lifecycle:discover:capabilities:from-handlers", + note=( + "initialize handshake removed at 2026-07-28; server capability advertisement moved to the " + "server/discover result." + ), ), "resources:list-changed": Requirement( source=f"{SPEC_BASE_URL}/server/resources#list-changed-notification", @@ -1092,9 +2158,29 @@ def __post_init__(self) -> None: "When the resource set changes, the server sends notifications/resources/list_changed and it " "reaches the client's handler." ), - arm_exclusions=( - ArmExclusion(reason="requires-session", transport="streamable-http-stateless"), - ArmExclusion(reason="requires-session", spec_version="2026-07-28"), + removed_in="2026-07-28", + superseded_by="resources:listen:list-changed", + note=( + "removed in 2026-07-28 (SEP-2575); unsolicited server notifications retired -- list_changed is " + "delivered only on a subscriptions/listen stream." + ), + arm_exclusions=(ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),), + ), + "resources:listen:list-changed": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/resources#list-changed-notification", + behavior=( + "A notifications/resources/list_changed emitted while a client's subscriptions/listen stream " + "requested resourcesListChanged is delivered on that stream and dispatched to the client's " + "registered notification handler." + ), + added_in="2026-07-28", + supersedes=("resources:list-changed",), + deferred=( + "Not yet covered here: the server listen runtime landed (src/mcp/server/subscriptions.py) " + "and a scripted in-memory ClientSession subscriptions/listen request already delivers the " + "stamped notification to the client's registered handler; the typed subscriptions/listen " + "client driver is still missing, so the test either drives that session seam now or waits " + "for the driver." ), ), "resources:list:basic": Requirement( @@ -1104,14 +2190,71 @@ def __post_init__(self) -> None: "fields supplied by the server." ), ), + "resources:list:connection-invariant": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/resources#capabilities", + behavior=( + "The set of resources returned by resources/list does not vary per-connection and " + "does not change as a side effect of other requests on the connection: concurrent " + "connections to one server see the same list, before and after one of them reads a " + "resource." + ), + added_in="2026-07-28", + note=( + "New normative text in the 2026-07-28 revision; sibling of " + "tools:list:connection-independent and prompts:list:connection-invariant (the same " + "paragraph per feature page). The authorization carve-out (the set MAY vary by " + "per-request credentials) is not exercised here." + ), + ), "resources:list:pagination": Requirement( source=f"{SPEC_BASE_URL}/server/utilities/pagination#operations-supporting-pagination", behavior="resources/list supports cursor pagination.", ), + "resources:mrtr:read:basic": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#supported-requests", + behavior=( + "A resources/read may be answered with an input_required result; the client fulfils the " + "embedded request and the retried resources/read completes with the resource contents." + ), + added_in="2026-07-28", + note=( + "Driven on the low-level Server; MCPServer now passes InputRequiredResult through its " + "resource pipeline as well, so an mcpserver mirror is possible and not yet covered here." + ), + ), "resources:read:blob": Requirement( source=f"{SPEC_BASE_URL}/server/resources#reading-resources", behavior="resources/read returns binary contents base64-encoded in blob.", ), + "resources:read:multiple-contents": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/resources#reading-resources", + behavior=( + "A resources/read result may carry several contents entries (e.g. a directory read " + "returning multiple files); all of them reach the client with order, URIs, and " + "text/blob payloads intact." + ), + note=( + "The licensing sentence is new in the 2026-07-28 revision, but the contents array " + "is the wire shape of every revision and the SDK passes it through era-independently " + "-- not era-gated, matching the text/blob content-shape siblings." + ), + ), + "resources:read:path-traversal-rejected": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/resources#security-considerations", + behavior=( + "resources/read against a file:// resource template with a traversal-bearing path " + "parameter is rejected with a JSON-RPC error and the resource function is never " + "invoked." + ), + added_in="2026-07-28", + note=( + "New security MUST in the 2026-07-28 revision. MCPServer's default ResourceSecurity " + "policy rejects traversal, absolute-path, and null-byte parameter values at template " + "match time; the rejection is deliberately surfaced as the same -32602 'Unknown " + "resource' error as a non-match, so the wire gives no probing oracle. The era gate " + "follows the obligation; the SDK applies the same policy on 2025-11-25 connections." + ), + ), "resources:read:template-vars": Requirement( source="sdk", behavior="Variables extracted from a templated resource URI reach the resource function as typed arguments.", @@ -1121,13 +2264,17 @@ def __post_init__(self) -> None: behavior="resources/read returns text contents carrying uri, mimeType, and the text.", ), "resources:read:unknown-uri": Requirement( - source=f"{SPEC_BASE_URL}/server/resources#error-handling", - behavior="resources/read for an unknown URI returns JSON-RPC error -32002 (resource not found).", + source=f"{SPEC_2026_BASE_URL}/server/resources#error-handling", + behavior=( + "resources/read for a URI matching no registered resource returns JSON-RPC error -32602 " + "(invalid params) with the requested URI in error.data, per SEP-2164." + ), ), "resources:subscribe": Requirement( source=f"{SPEC_BASE_URL}/server/resources#subscriptions", behavior="resources/subscribe delivers the URI to the server's subscribe handler and returns an empty result.", removed_in="2026-07-28", + superseded_by="subscriptions:listen:ack-first-stamped", note="removed in 2026-07-28 (SEP-2575); resources/subscribe replaced by subscriptions/listen.", ), "resources:subscribe:capability-required": Requirement( @@ -1136,6 +2283,7 @@ def __post_init__(self) -> None: "resources/subscribe to a server that did not advertise the subscribe capability is rejected with an error." ), removed_in="2026-07-28", + superseded_by="subscriptions:listen:honored-filter-narrows-to-advertised", note=( "removed in 2026-07-28 (SEP-2575); the resources/subscribe RPC is gone. The resources.subscribe " "capability flag is retained but reinterpreted as opt-in for the resourceSubscriptions filter on " @@ -1151,8 +2299,166 @@ def __post_init__(self) -> None: "separately by resources:subscribe and resources:updated-notification." ), removed_in="2026-07-28", + superseded_by="resources:listen:updated", note="removed in 2026-07-28 (SEP-2575); resources/subscribe replaced by subscriptions/listen.", ), + "subscriptions:listen:ack-first-stamped": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/subscriptions#acknowledgment", + behavior=( + "notifications/subscriptions/acknowledged is the first message on a subscriptions/listen stream " + "and carries the listen request's JSON-RPC id verbatim under the io.modelcontextprotocol/subscriptionId " + "_meta key, plus the honored subset of the requested filter." + ), + added_in="2026-07-28", + supersedes=("resources:subscribe",), + deferred=( + "Not yet covered here: the server listen runtime landed (src/mcp/server/subscriptions.py " + "-- ListenHandler acks first, stamps io.modelcontextprotocol/subscriptionId, filters per " + "stream; MCPServer registers it by default) and this behaviour is drivable server-side; " + "pending the typed subscriptions/listen client driver the test drives the wire directly " + "(raw modern HTTP or the in-memory session seam)." + ), + ), + "subscriptions:listen:capacity-guard": Requirement( + source="sdk", + behavior=( + "A subscriptions/listen request beyond the server's configured subscription limit is " + "refused with an in-band JSON-RPC error before any acknowledgment, leaving existing " + "streams intact." + ), + added_in="2026-07-28", + deferred=( + "Not yet covered here: the server listen runtime landed (src/mcp/server/subscriptions.py " + "-- ListenHandler acks first, stamps io.modelcontextprotocol/subscriptionId, filters per " + "stream; MCPServer registers it by default) and this behaviour is drivable server-side; " + "pending the typed subscriptions/listen client driver the test drives the wire directly " + "(raw modern HTTP or the in-memory session seam). The capacity knobs exist on " + "ListenHandler (max_subscriptions, over-limit listens refused with INTERNAL_ERROR before " + "any ack; max_buffered_events) but are lowlevel-only -- MCPServer exposes no way to set " + "them." + ), + ), + "subscriptions:listen:concurrent-demux": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/subscriptions#multiple-concurrent-subscriptions", + behavior=( + "A client may hold multiple subscriptions/listen streams concurrently; every notification " + "carries its own stream's listen request id under io.modelcontextprotocol/subscriptionId, " + "and the client demultiplexes by that id." + ), + added_in="2026-07-28", + deferred=( + "Not implemented in the SDK: the server runtime stamps io.modelcontextprotocol/subscriptionId " + "per stream (src/mcp/server/subscriptions.py), but there is no subscriptions/listen client " + "driver -- src/mcp/client contains no listen method, no ack consumption, and nothing that " + "reads the subscriptionId key to demultiplex concurrent streams." + ), + ), + "subscriptions:listen:demux-by-subscription-id": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/subscriptions#receiving-notifications", + behavior=( + "On stdio, where every message shares one channel, the client correlates each delivered " + "notification with its originating subscription via the " + "io.modelcontextprotocol/subscriptionId _meta key." + ), + added_in="2026-07-28", + transports=("stdio",), + note=( + "Only observable over stdio: on streamable HTTP each subscriptions/listen request has its " + "own response stream, so transport framing already correlates notifications." + ), + deferred=( + "Not implemented in the SDK: there is no subscriptions/listen client driver " + "(src/mcp/client contains no listen surface and nothing reads the " + "io.modelcontextprotocol/subscriptionId key), and stream-pair listen serving is refused -- " + "src/mcp/server/runner.py answers subscriptions/listen with METHOD_NOT_FOUND ('not served " + "over this transport'), so on this entry's stdio scope even the server half cannot run." + ), + ), + "subscriptions:listen:graceful-close": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/subscriptions#graceful-closure", + behavior=( + "A server ending a subscription on its own initiative answers the original " + "subscriptions/listen request with an empty result (stamped with the subscriptionId) before " + "closing the stream, so the client can distinguish a graceful close from a transport drop." + ), + added_in="2026-07-28", + deferred=( + "Not yet covered here: the server listen runtime landed (src/mcp/server/subscriptions.py " + "-- ListenHandler acks first, stamps io.modelcontextprotocol/subscriptionId, filters per " + "stream; MCPServer registers it by default) and this behaviour is drivable server-side; " + "pending the typed subscriptions/listen client driver the test drives the wire directly " + "(raw modern HTTP or the in-memory session seam). ListenHandler.close() flushes each " + "stream and ends it with the stamped empty result; close is reachable only on a lowlevel " + "Server (MCPServer exposes no teardown)." + ), + ), + "subscriptions:listen:honored-filter-narrows-to-advertised": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/subscriptions#acknowledgment", + behavior=( + "The acknowledged filter on a subscriptions/listen stream is the requested set narrowed to what " + "the server supports -- a requested notification type the server does not advertise is omitted " + "from the honored filter and never delivered." + ), + added_in="2026-07-28", + supersedes=("resources:subscribe:capability-required",), + deferred=( + "Not yet covered here: the ack machinery landed (src/mcp/server/subscriptions.py) but " + "deliberately honors every requested kind -- there is no narrowing hook, so a test today " + "would pin honor-everything against this entry's narrowed-subset behaviour; coverage needs " + "an owner ruling first (record a divergence or rewrite the behaviour). The typed " + "subscriptions/listen client driver is also still missing." + ), + ), + "subscriptions:listen:notification-stamped": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/subscriptions#receiving-notifications", + behavior=( + "Every notification delivered on a subscriptions/listen stream carries " + "io.modelcontextprotocol/subscriptionId in _meta, whose value is the JSON-RPC id of the " + "listen request that opened the stream." + ), + added_in="2026-07-28", + deferred=( + "Not yet covered here: the server listen runtime landed (src/mcp/server/subscriptions.py " + "-- ListenHandler acks first, stamps io.modelcontextprotocol/subscriptionId, filters per " + "stream; MCPServer registers it by default) and this behaviour is drivable server-side; " + "pending the typed subscriptions/listen client driver the test drives the wire directly " + "(raw modern HTTP or the in-memory session seam). The server stamps every delivered " + "frame; no client code reads the key." + ), + ), + "subscriptions:listen:per-stream-filter": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/subscriptions#notification-filter", + behavior=( + "A subscriptions/listen stream delivers only the notification types its filter requested; " + "a type the filter did not request is never delivered on that stream." + ), + added_in="2026-07-28", + deferred=( + "Not yet covered here: the server listen runtime landed (src/mcp/server/subscriptions.py " + "-- ListenHandler acks first, stamps io.modelcontextprotocol/subscriptionId, filters per " + "stream; MCPServer registers it by default) and this behaviour is drivable server-side; " + "pending the typed subscriptions/listen client driver the test drives the wire directly " + "(raw modern HTTP or the in-memory session seam)." + ), + ), + "subscriptions:listen:stdio-resubscribe-after-reconnect": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/subscriptions#graceful-closure", + behavior=( + "On stdio, after the connection is terminated and re-established, the client re-sends " + "subscriptions/listen to re-establish its subscriptions -- the server holds no subscription " + "state across reconnections." + ), + added_in="2026-07-28", + transports=("stdio",), + note="Only observable over stdio: the re-send obligation is tied to stdio connection re-establishment.", + deferred=( + "Not implemented in the SDK: there is no subscriptions/listen client driver to re-send the " + "listen request, and stream-pair listen serving is refused -- src/mcp/server/runner.py " + "answers subscriptions/listen with METHOD_NOT_FOUND on the stdio loop. The no-state half " + "of the premise is implemented truth: delivery is fire-and-forget with no replay " + "(src/mcp/server/subscriptions.py), so no subscription survives a reconnect." + ), + ), "resources:templates:list": Requirement( source=f"{SPEC_BASE_URL}/server/resources#resource-templates", behavior=( @@ -1188,9 +2494,31 @@ def __post_init__(self) -> None: "A resources/updated notification sent by the server reaches the client carrying the URI of " "the changed resource." ), - arm_exclusions=( - ArmExclusion(reason="requires-session", transport="streamable-http-stateless"), - ArmExclusion(reason="requires-session", spec_version="2026-07-28"), + removed_in="2026-07-28", + superseded_by="resources:listen:updated", + note=( + "removed in 2026-07-28 (SEP-2575); resources/updated is delivered only on a subscriptions/listen " + "stream whose resourceSubscriptions filter names the URI." + ), + arm_exclusions=(ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),), + ), + "resources:listen:updated": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/subscriptions#notification-filter", + behavior=( + "A notifications/resources/updated for a URI named in a subscriptions/listen request's " + "resourceSubscriptions filter is delivered on that stream, carrying the changed URI and the " + "io.modelcontextprotocol/subscriptionId stamp." + ), + added_in="2026-07-28", + supersedes=("resources:subscribe:updated", "resources:updated-notification"), + deferred=( + "Not yet covered here: the server listen runtime landed (src/mcp/server/subscriptions.py " + "-- ListenHandler acks first, stamps io.modelcontextprotocol/subscriptionId, filters per " + "stream; MCPServer registers it by default) and this behaviour is drivable server-side; " + "pending the typed subscriptions/listen client driver the test drives the wire directly " + "(raw modern HTTP or the in-memory session seam). Context.notify_resource_updated " + "publishes to the bus (src/mcp/server/mcpserver/context.py) and the resourceSubscriptions " + "filter matches by exact URI string." ), ), # ═══════════════════════════════════════════════════════════════════════════ @@ -1207,6 +2535,19 @@ def __post_init__(self) -> None: ), ), ), + "mcpserver:resource:handle-update-remove": Requirement( + source="sdk", + behavior=( + "Registering a resource returns a handle that can update or remove the registration, " + "emitting notifications/resources/list_changed on each mutation." + ), + deferred=( + "Not implemented in the SDK: resource registration returns the Resource model itself, not " + "a handle -- ResourceManager " + "(src/mcp/server/mcpserver/resources/resource_manager.py) exposes no update or remove " + "operation and MCPServer emits no list_changed on registration mutation." + ), + ), "mcpserver:resource:read-throws-surfaced": Requirement( source="sdk", behavior=( @@ -1228,20 +2569,18 @@ def __post_init__(self) -> None: "by resources/read, receiving the parameters extracted from the requested URI." ), ), - "mcpserver:resource:unknown-uri": Requirement( - source=f"{SPEC_BASE_URL}/server/resources#error-handling", - behavior=( - "resources/read for a URI matching no registered resource returns JSON-RPC error -32602 " - "(invalid params) with the requested URI in error.data, per SEP-2164." - ), - ), # ═══════════════════════════════════════════════════════════════════════════ # Prompts # ═══════════════════════════════════════════════════════════════════════════ "prompts:capability:declared": Requirement( source=f"{SPEC_BASE_URL}/server/prompts#capabilities", behavior="A server with a list_prompts handler advertises the prompts capability in its initialize result.", - arm_exclusions=(ArmExclusion(reason="legacy-only-vocabulary", spec_version="2026-07-28"),), + removed_in="2026-07-28", + superseded_by="lifecycle:discover:capabilities:from-handlers", + note=( + "initialize handshake removed at 2026-07-28; server capability advertisement moved to the " + "server/discover result." + ), ), "prompts:get:content:audio": Requirement( source=f"{SPEC_BASE_URL}/server/prompts#audio-content", @@ -1255,6 +2594,18 @@ def __post_init__(self) -> None: source=f"{SPEC_BASE_URL}/server/prompts#image-content", behavior="Prompt messages may contain image content.", ), + "prompts:get:content:resource-link": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/prompts#resource-links", + behavior=( + "A prompt message can carry resource_link content -- a URI reference with descriptive " + "fields, without embedding the resource contents -- and it reaches the client intact." + ), + note=( + "The Resource Links section is new on the 2026-07-28 prompts page, but PromptMessage " + "content admitted ResourceLink in the 2025-11-25 schema already -- not era-gated, " + "matching the image/audio/embedded-resource siblings." + ), + ), "prompts:get:missing-required-args": Requirement( source=f"{SPEC_BASE_URL}/server/prompts#error-handling", behavior="prompts/get omitting a required argument returns JSON-RPC error -32602 (Invalid params).", @@ -1264,7 +2615,16 @@ def __post_init__(self) -> None: "which the low-level server converts to error code 0 with the exception text as the message." ), ), - arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),), + arm_exclusions=( + ArmExclusion( + reason="modern-error-surface", + spec_version="2026-07-28", + note=( + "prompts/get persists at 2026-07-28; only the error surface differs. The test pins the " + "legacy code-0 error shape and needs an era-aware assertion before re-admission." + ), + ), + ), ), "prompts:get:multi-message": Requirement( source=f"{SPEC_BASE_URL}/server/prompts#getting-a-prompt", @@ -1288,26 +2648,83 @@ def __post_init__(self) -> None: "When the prompt set changes, the server sends notifications/prompts/list_changed and it " "reaches the client's handler." ), - arm_exclusions=( - ArmExclusion(reason="requires-session", transport="streamable-http-stateless"), - ArmExclusion(reason="requires-session", spec_version="2026-07-28"), + removed_in="2026-07-28", + superseded_by="prompts:listen:list-changed", + note=( + "removed in 2026-07-28 (SEP-2575); unsolicited server notifications retired -- list_changed is " + "delivered only on a subscriptions/listen stream." + ), + arm_exclusions=(ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),), + ), + "prompts:listen:list-changed": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/prompts#list-changed-notification", + behavior=( + "A notifications/prompts/list_changed emitted while a client's subscriptions/listen stream " + "requested promptsListChanged is delivered on that stream and dispatched to the client's " + "registered notification handler." + ), + added_in="2026-07-28", + supersedes=("prompts:list-changed",), + deferred=( + "Not yet covered here: the server listen runtime landed (src/mcp/server/subscriptions.py) " + "and a scripted in-memory ClientSession subscriptions/listen request already delivers the " + "stamped notification to the client's registered handler; the typed subscriptions/listen " + "client driver is still missing, so the test either drives that session seam now or waits " + "for the driver." ), ), "prompts:list:basic": Requirement( source=f"{SPEC_BASE_URL}/server/prompts#listing-prompts", behavior="prompts/list returns the registered prompts with name, description, and argument declarations.", ), + "prompts:list:connection-invariant": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/prompts#capabilities", + behavior=( + "The set of prompts returned by prompts/list does not vary per-connection and does " + "not change as a side effect of other requests on the connection: concurrent " + "connections to one server see the same list, before and after one of them gets a " + "prompt." + ), + added_in="2026-07-28", + note=( + "New normative text in the 2026-07-28 revision; sibling of " + "tools:list:connection-independent and resources:list:connection-invariant (the same " + "paragraph per feature page). The authorization carve-out (the set MAY vary by " + "per-request credentials) is not exercised here." + ), + ), "prompts:list:pagination": Requirement( source=f"{SPEC_BASE_URL}/server/utilities/pagination#operations-supporting-pagination", behavior="prompts/list supports cursor pagination.", ), + "prompts:mrtr:get:basic": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#supported-requests", + behavior=( + "A prompts/get may be answered with an input_required result; the client fulfils the " + "embedded request and the retried prompts/get completes with the prompt messages." + ), + added_in="2026-07-28", + note=( + "Driven on the low-level Server; MCPServer now passes InputRequiredResult through its " + "prompt pipeline as well, so an mcpserver mirror is possible and not yet covered here." + ), + ), # ═══════════════════════════════════════════════════════════════════════════ # Prompts: SDK guarantees # ═══════════════════════════════════════════════════════════════════════════ "mcpserver:prompt:args-validation": Requirement( source=f"{SPEC_BASE_URL}/server/prompts#implementation-considerations", behavior="prompts/get arguments that fail the prompt's argument schema are rejected before the function runs.", - arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),), + arm_exclusions=( + ArmExclusion( + reason="modern-error-surface", + spec_version="2026-07-28", + note=( + "prompts/get persists at 2026-07-28; only the error surface differs. The test pins the " + "legacy code-0 error shape and needs an era-aware assertion before re-admission." + ), + ), + ), ), "mcpserver:prompt:decorated": Requirement( source="sdk", @@ -1326,6 +2743,19 @@ def __post_init__(self) -> None: ), ), ), + "mcpserver:prompt:handle-update-remove": Requirement( + source="sdk", + behavior=( + "Registering a prompt returns a handle that can update or remove the registration, " + "emitting notifications/prompts/list_changed on each mutation." + ), + deferred=( + "Not implemented in the SDK: prompt registration returns the Prompt model itself, not a " + "handle -- removal is now public (MCPServer.remove_prompt, delegating to " + "PromptManager.remove_prompt, src/mcp/server/mcpserver/prompts/manager.py) but there is " + "no update operation and MCPServer emits no list_changed on registration mutation." + ), + ), "mcpserver:prompt:optional-args": Requirement( source="sdk", behavior="A prompt with optional arguments can be fetched without supplying them.", @@ -1335,11 +2765,21 @@ def __post_init__(self) -> None: behavior="prompts/get for a name that was never registered returns JSON-RPC error -32602 (Invalid params).", divergence=Divergence( note=( - "The spec's example uses -32602 Invalid params for unknown prompts; MCPServer raises " + "The spec SHOULD-lists -32602 Invalid params for an invalid prompt name; MCPServer raises " "ValueError, which the low-level server converts to error code 0." ), ), - arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),), + arm_exclusions=( + ArmExclusion( + reason="modern-error-surface", + spec_version="2026-07-28", + note=( + "prompts/get persists at 2026-07-28; only the error surface differs (legacy code 0 vs " + "-32602). The test pins the legacy shape and needs an era-aware assertion before " + "re-admission." + ), + ), + ), ), # ═══════════════════════════════════════════════════════════════════════════ # Completion @@ -1347,7 +2787,12 @@ def __post_init__(self) -> None: "completion:capability:declared": Requirement( source=f"{SPEC_BASE_URL}/server/utilities/completion#capabilities", behavior="A server with a completion handler advertises the completions capability in its initialize result.", - arm_exclusions=(ArmExclusion(reason="legacy-only-vocabulary", spec_version="2026-07-28"),), + removed_in="2026-07-28", + superseded_by="lifecycle:discover:capabilities:from-handlers", + note=( + "initialize handshake removed at 2026-07-28; server capability advertisement moved to the " + "server/discover result." + ), ), "completion:complete:not-supported": Requirement( source=f"{SPEC_BASE_URL}/server/utilities/completion#capabilities", @@ -1385,7 +2830,12 @@ def __post_init__(self) -> None: "MCPServer advertises the completions capability when at least one completion source is " "registered, and omits it otherwise." ), - arm_exclusions=(ArmExclusion(reason="asserts-legacy-handshake", spec_version="2026-07-28"),), + removed_in="2026-07-28", + superseded_by="lifecycle:discover:capabilities:from-handlers", + note=( + "initialize handshake removed at 2026-07-28; server capability advertisement moved to the " + "server/discover result." + ), ), # ═══════════════════════════════════════════════════════════════════════════ # Logging @@ -1401,11 +2851,24 @@ def __post_init__(self) -> None: "even though the Context helpers send log message notifications." ), ), - arm_exclusions=(ArmExclusion(reason="legacy-only-vocabulary", spec_version="2026-07-28"),), + removed_in="2026-07-28", + superseded_by="lifecycle:discover:capabilities:from-handlers", + note=( + "initialize handshake removed at 2026-07-28; server capability advertisement moved to the " + "server/discover result." + ), ), "logging:message:all-levels": Requirement( source=f"{SPEC_BASE_URL}/server/utilities/logging#log-levels", behavior="All eight RFC 5424 severity levels are deliverable as log message notifications.", + divergence=Divergence( + note=( + "At 2026-07-28 the spec forbids notifications/message for a request whose _meta lacks the " + "io.modelcontextprotocol/logLevel opt-in; the session's send_log_message never reads that " + "key and all eight severity levels are delivered unconditionally, so a bound test pins the " + "un-gated delivery on the live 2026-07-28 cells." + ), + ), ), "logging:message:fields": Requirement( source=f"{SPEC_BASE_URL}/server/utilities/logging#log-message-notifications", @@ -1413,6 +2876,14 @@ def __post_init__(self) -> None: "A log message sent by a server handler is delivered to the client's logging callback with its " "severity level, logger name, and data." ), + divergence=Divergence( + note=( + "At 2026-07-28 the spec forbids notifications/message for a request whose _meta lacks the " + "io.modelcontextprotocol/logLevel opt-in; the session's send_log_message never reads that " + "key and the handler's messages are delivered unconditionally, so a bound test pins the " + "un-gated delivery on the live 2026-07-28 cells." + ), + ), ), "logging:message:filtered": Requirement( source=f"{SPEC_BASE_URL}/server/utilities/logging#setting-log-level", @@ -1425,6 +2896,7 @@ def __post_init__(self) -> None: ), ), removed_in="2026-07-28", + superseded_by="logging:per-request-level:opt-in", note=( "removed in 2026-07-28 (SEP-2575); logging/setLevel removed, replaced by per-request " "io.modelcontextprotocol/logLevel in _meta." @@ -1434,6 +2906,7 @@ def __post_init__(self) -> None: source=f"{SPEC_BASE_URL}/server/utilities/logging#setting-log-level", behavior="logging/setLevel delivers the requested level to the server's handler and returns an empty result.", removed_in="2026-07-28", + superseded_by="logging:per-request-level:opt-in", note=( "removed in 2026-07-28 (SEP-2575); logging/setLevel removed, replaced by per-request " "io.modelcontextprotocol/logLevel in _meta." @@ -1443,11 +2916,39 @@ def __post_init__(self) -> None: source=f"{SPEC_BASE_URL}/server/utilities/logging#error-handling", behavior="logging/setLevel with an invalid level value returns JSON-RPC error -32602 (Invalid params).", removed_in="2026-07-28", + superseded_by="logging:per-request-level:invalid-level", note=( "removed in 2026-07-28 (SEP-2575); logging/setLevel removed, replaced by per-request " "io.modelcontextprotocol/logLevel in _meta." ), ), + "logging:per-request-level:opt-in": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/utilities/logging#per-request-log-level", + behavior=( + "A request whose _meta carries io.modelcontextprotocol/logLevel receives notifications/message " + "at or above that level on its own response stream, before the final response." + ), + added_in="2026-07-28", + supersedes=("logging:set-level", "logging:message:filtered"), + deferred=( + "Not implemented in the SDK: the server never reads io.modelcontextprotocol/logLevel from a " + "request's _meta -- the log helpers emit notifications/message unconditionally, with no " + "suppression when the key is absent and no at-or-above-level filter on the request's own stream." + ), + ), + "logging:per-request-level:invalid-level": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/utilities/logging#error-handling", + behavior=( + "A request carrying an unrecognized io.modelcontextprotocol/logLevel value is rejected with " + "-32602 Invalid params." + ), + added_in="2026-07-28", + supersedes=("logging:set-level:invalid-level",), + deferred=( + "Not implemented in the SDK: an unrecognized io.modelcontextprotocol/logLevel value is accepted " + "rather than rejected with -32602; nothing validates the key on the inbound path." + ), + ), # ═══════════════════════════════════════════════════════════════════════════ # Sampling (server → client) # ═══════════════════════════════════════════════════════════════════════════ @@ -1456,10 +2957,13 @@ def __post_init__(self) -> None: behavior=( "A client that handles sampling requests advertises the sampling capability in its initialize request." ), - arm_exclusions=( - ArmExclusion(reason="requires-session", transport="streamable-http-stateless"), - ArmExclusion(reason="asserts-legacy-handshake", spec_version="2026-07-28"), + removed_in="2026-07-28", + superseded_by="lifecycle:envelope:stamped-on-every-request", + note=( + "initialize handshake removed at 2026-07-28; client capabilities are stamped per-request in the " + "_meta envelope." ), + arm_exclusions=(ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),), ), "sampling:create:basic": Requirement( source=f"{SPEC_BASE_URL}/client/sampling#creating-messages", @@ -1467,18 +2971,24 @@ def __post_init__(self) -> None: "A sampling/createMessage request from a server handler is answered by the client's sampling " "callback, and the callback's result (role, content, model, stopReason) is returned to the handler." ), - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + removed_in="2026-07-28", + superseded_by="sampling:mrtr:create:basic", + note=( + "removed in 2026-07-28 (SEP-2322); server-initiated sampling/createMessage retired -- the " + "request now rides MRTR inputRequests." ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "sampling:create:include-context": Requirement( source=f"{SPEC_BASE_URL}/client/sampling#capabilities", behavior="The includeContext value supplied by the server reaches the client callback intact.", - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + removed_in="2026-07-28", + superseded_by="sampling:mrtr:create:include-context", + note=( + "removed in 2026-07-28 (SEP-2322); server-initiated sampling/createMessage retired -- the " + "request now rides MRTR inputRequests." ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "sampling:context:server-gated-by-capability": Requirement( source=f"{SPEC_BASE_URL}/client/sampling#capabilities", @@ -1492,10 +3002,28 @@ def __post_init__(self) -> None: "capability; the server-side validator only checks tools/tool_choice." ), ), - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + removed_in="2026-07-28", + superseded_by="sampling:mrtr:capability:not-declared", + note=( + "removed in 2026-07-28 (SEP-2322); the push vehicle is retired -- the capability gate persists " + "as the server-side embed gate on MRTR inputRequests." ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), + ), + "sampling:create:messages-not-retained": Requirement( + source=f"{SPEC_2026_BASE_URL}/client/sampling#messages", + behavior=( + "Each sampling request delivers exactly its own messages list to the client's " + "sampling callback: nothing from an earlier request in the same session is retained " + "or merged into a later one." + ), + note=( + "The SHOULD NOT is new in the 2026-07-28 revision, but it governs the client's " + "handling of every sampling request shape: era-unbounded, with the 2025-11-25 push " + "face and the 2026-07-28 MRTR-embedded face each pinned by its own test (the tests " + "stack the era-appropriate sampling entry to select their cells)." + ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "sampling:create:model-preferences": Requirement( source=f"{SPEC_BASE_URL}/client/sampling#model-preferences", @@ -1503,18 +3031,24 @@ def __post_init__(self) -> None: "The model preferences supplied by the server (hints and the cost, speed, and intelligence " "priorities) reach the client callback intact." ), - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + removed_in="2026-07-28", + superseded_by="sampling:mrtr:create:model-preferences", + note=( + "removed in 2026-07-28 (SEP-2322); server-initiated sampling/createMessage retired -- the " + "request now rides MRTR inputRequests." ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "sampling:create:system-prompt": Requirement( source=f"{SPEC_BASE_URL}/client/sampling#creating-messages", behavior="The system prompt supplied by the server reaches the client callback intact.", - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + removed_in="2026-07-28", + superseded_by="sampling:mrtr:create:system-prompt", + note=( + "removed in 2026-07-28 (SEP-2322); server-initiated sampling/createMessage retired -- the " + "request now rides MRTR inputRequests." ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "sampling:create:tools": Requirement( source=f"{SPEC_BASE_URL}/client/sampling#tools-in-sampling", @@ -1528,32 +3062,42 @@ def __post_init__(self) -> None: "rejects every tool-enabled request before it is sent." ), ), - "sampling:create-message:audio-content": Requirement( + "sampling:create:audio-content": Requirement( source=f"{SPEC_BASE_URL}/client/sampling#audio-content", behavior="Sampling messages can carry audio content: base64 data with a mimeType.", - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + removed_in="2026-07-28", + superseded_by="sampling:mrtr:create:audio-content", + note=( + "removed in 2026-07-28 (SEP-2322); server-initiated sampling/createMessage retired -- the " + "request now rides MRTR inputRequests." ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), - "sampling:create-message:image-content": Requirement( + "sampling:create:image-content": Requirement( source=f"{SPEC_BASE_URL}/client/sampling#image-content", behavior="Sampling messages can carry image content: base64 data with a mimeType.", - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + removed_in="2026-07-28", + superseded_by="sampling:mrtr:create:image-content", + note=( + "removed in 2026-07-28 (SEP-2322); server-initiated sampling/createMessage retired -- the " + "request now rides MRTR inputRequests." ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), - "sampling:create-message:not-supported": Requirement( + "sampling:create:not-supported": Requirement( source=f"{SPEC_BASE_URL}/client/sampling#capabilities", behavior=( "A sampling request to a client that did not declare the sampling capability fails with an " "error rather than hanging or being silently dropped; the spec names no error code for this case." ), - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + removed_in="2026-07-28", + superseded_by="sampling:mrtr:capability:not-declared", + note=( + "removed in 2026-07-28 (SEP-2322); the client no longer answers server requests -- the surviving " + "protection is the server-side embed gate (and -32021 MissingRequiredClientCapability on the " + "originating client request)." ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "sampling:error:user-rejected": Requirement( source=f"{SPEC_BASE_URL}/client/sampling#error-handling", @@ -1561,18 +3105,24 @@ def __post_init__(self) -> None: "A sampling request the user rejects is answered with a JSON-RPC error (the spec's code for " "this case is -1, 'User rejected sampling request'), surfaced to the requesting handler as an MCPError." ), - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2322); there is no error answer to a sampling request under MRTR -- " + "the client simply does not retry and the server is not waiting. The -1 code dies with the " + "answer plane. No replacement." ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "sampling:message:content-cardinality": Requirement( source=f"{SPEC_BASE_URL}/client/sampling", behavior="A sampling message's content may be a single block or an array of blocks.", - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + removed_in="2026-07-28", + superseded_by="sampling:mrtr:message:content-cardinality", + note=( + "removed in 2026-07-28 (SEP-2322); server-initiated sampling/createMessage retired -- the " + "request now rides MRTR inputRequests." ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "sampling:result:no-tools-single-content": Requirement( source="sdk", @@ -1587,10 +3137,13 @@ def __post_init__(self) -> None: "pydantic.ValidationError from the server's response parsing (send_request) instead." ), ), - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2322); the push answer this guarantee validated no longer exists. " + "Whether the MRTR client driver enforces the same shape on fulfilment results is undesigned SDK " + "surface -- re-pin as a new entry when it is." ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "sampling:result:with-tools-array-content": Requirement( source="sdk", @@ -1609,10 +3162,13 @@ def __post_init__(self) -> None: "A user sampling message that carries tool_result content contains only tool_result blocks; " "mixing tool_result with text, image, or audio content is rejected as invalid." ), - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + removed_in="2026-07-28", + superseded_by="sampling:mrtr:tool-result:no-mixed-content", + note=( + "removed in 2026-07-28 (SEP-2322); server-initiated sampling/createMessage retired -- the " + "request now rides MRTR inputRequests." ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "sampling:tool-use:result-balance": Requirement( source=f"{SPEC_BASE_URL}/client/sampling#tool-use-and-result-balance", @@ -1639,10 +3195,13 @@ def __post_init__(self) -> None: "The server validates tool_use/tool_result balance before sending a sampling/createMessage " "request; an unmatched tool_use raises ValueError and the request never reaches the wire." ), - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2322); the push send this preflight guarded is retired. The " + "tool-use/result balance MUST itself survives; an embedded-request preflight is undesigned SDK " + "surface." ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "sampling:tools:server-gated-by-capability": Requirement( source=f"{SPEC_BASE_URL}/client/sampling#tools-in-sampling", @@ -1650,9 +3209,141 @@ def __post_init__(self) -> None: "A tool-enabled sampling request to a client that did not declare sampling.tools is rejected " "by the server before anything reaches the wire (the SDK surfaces this as an Invalid params error)." ), - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + removed_in="2026-07-28", + superseded_by="sampling:mrtr:capability:not-declared", + note=( + "removed in 2026-07-28 (SEP-2322); the push vehicle is retired -- the capability gate persists " + "as the server-side embed gate on MRTR inputRequests." + ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), + ), + "sampling:mrtr:create:basic": Requirement( + source=f"{SPEC_2026_BASE_URL}/client/sampling#creating-messages", + behavior=( + "An embedded sampling/createMessage request returned in an input_required result from a tool " + "handler is fulfilled by the client's sampling callback, and the callback's result (role, " + "content, model, stopReason) reaches the retried tool handler in inputResponses." + ), + added_in="2026-07-28", + supersedes=("sampling:create:basic", "tools:call:sampling-roundtrip"), + ), + "sampling:mrtr:create:include-context": Requirement( + source=f"{SPEC_2026_BASE_URL}/client/sampling#context-inclusion", + behavior=( + "The includeContext value supplied in an embedded sampling/createMessage request reaches the " + "client sampling callback intact." + ), + added_in="2026-07-28", + supersedes=("sampling:create:include-context",), + ), + "sampling:mrtr:create:max-tokens": Requirement( + source=f"{SPEC_2026_BASE_URL}/client/sampling#sampling-parameters", + behavior=( + "The maxTokens parameter of a sampling request reaches the client's sampling " + "integration unchanged (the delivery half of the client MUST respect maxTokens; " + "enforcement of the cap belongs to the consumer's sampler)." + ), + added_in="2026-07-28", + note=( + "Bound to the embedded-MRTR params test, whose full-params equality includes " + "max_tokens; the 2025 push-API sibling test delivers the same field incidentally " + "(lowlevel/test_sampling.py, test_create_message_params_reach_callback)." + ), + ), + "sampling:mrtr:create:model-preferences": Requirement( + source=f"{SPEC_2026_BASE_URL}/client/sampling#model-preferences", + behavior=( + "The model preferences supplied in an embedded sampling/createMessage request (hints and the " + "cost, speed, and intelligence priorities) reach the client sampling callback intact." + ), + added_in="2026-07-28", + supersedes=("sampling:create:model-preferences",), + ), + "sampling:mrtr:create:system-prompt": Requirement( + source=f"{SPEC_2026_BASE_URL}/client/sampling#system-prompt", + behavior=( + "The system prompt supplied in an embedded sampling/createMessage request reaches the client " + "sampling callback intact." + ), + added_in="2026-07-28", + supersedes=("sampling:create:system-prompt",), + ), + "sampling:mrtr:create:audio-content": Requirement( + source=f"{SPEC_2026_BASE_URL}/client/sampling#audio-content", + behavior=( + "Messages in an embedded sampling/createMessage request can carry audio content (base64 data " + "with a mimeType), reaching the client callback intact." + ), + added_in="2026-07-28", + supersedes=("sampling:create:audio-content",), + deferred=( + "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " + "expected drivable by analogy with its triaged MRTR siblings." + ), + ), + "sampling:mrtr:create:image-content": Requirement( + source=f"{SPEC_2026_BASE_URL}/client/sampling#image-content", + behavior=( + "Messages in an embedded sampling/createMessage request can carry image content (base64 data " + "with a mimeType), reaching the client callback intact." + ), + added_in="2026-07-28", + supersedes=("sampling:create:image-content",), + deferred=( + "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " + "expected drivable by analogy with its triaged MRTR siblings." + ), + ), + "sampling:mrtr:message:content-cardinality": Requirement( + source=f"{SPEC_2026_BASE_URL}/client/sampling#messages", + behavior=( + "A message in an embedded sampling/createMessage request may carry a single content block or an " + "array of blocks." + ), + added_in="2026-07-28", + supersedes=("sampling:message:content-cardinality",), + deferred=( + "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " + "expected drivable by analogy with its triaged MRTR siblings." + ), + ), + "sampling:mrtr:tool-result:no-mixed-content": Requirement( + source=f"{SPEC_2026_BASE_URL}/client/sampling#tool-result-messages", + behavior=( + "A user message carrying tool_result content in an embedded sampling request contains only " + "tool_result blocks; mixing tool_result with text, image, or audio content is rejected as invalid." + ), + added_in="2026-07-28", + supersedes=("sampling:tool-result:no-mixed-content",), + deferred=( + "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " + "expected drivable by analogy with its triaged MRTR siblings." + ), + ), + "sampling:mrtr:capability:not-declared": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#server-requirements-basic-workflow", + behavior=( + "The server does not place a sampling/createMessage request in an input_required result's " + "inputRequests for a client whose declared capabilities do not support it (tool-enabled " + "requests require sampling.tools; thisServer/allServers context -- itself deprecated -- should " + "not be used without sampling.context)." + ), + divergence=Divergence( + note=( + "The embed gate is not implemented: an input_required result carrying a " + "sampling/createMessage request for a client that declared no sampling capability is " + "transmitted as-is, and the violation surfaces as the client driver's refusal " + "(INVALID_REQUEST, 'Sampling not supported') aborting the call. The sub-capability legs " + "(sampling.tools, sampling.context) are equally ungated and covered by this divergence " + "without separate pins." + ), + issue="L109", + ), + added_in="2026-07-28", + supersedes=( + "sampling:create:not-supported", + "sampling:tools:server-gated-by-capability", + "sampling:context:server-gated-by-capability", ), ), # ═══════════════════════════════════════════════════════════════════════════ @@ -1690,10 +3381,13 @@ def __post_init__(self) -> None: "elicitation/create; the spec's MUST NOT is not enforced." ), ), - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + removed_in="2026-07-28", + superseded_by="elicitation:mrtr:capability:not-declared", + note=( + "removed in 2026-07-28 (SEP-2322); the push vehicle is retired -- the mode-level gate persists " + "as the server-side embed gate on MRTR inputRequests." ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "elicitation:form:action:accept": Requirement( source=f"{SPEC_BASE_URL}/client/elicitation#response-actions", @@ -1701,26 +3395,26 @@ def __post_init__(self) -> None: "A form-mode elicitation answered with action 'accept' returns the user's content to the " "requesting handler." ), - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), - ), + removed_in="2026-07-28", + superseded_by="elicitation:mrtr:form:basic", + note="removed in 2026-07-28 (SEP-2322); elicitation/create now rides MRTR inputRequests.", + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "elicitation:form:action:cancel": Requirement( source=f"{SPEC_BASE_URL}/client/elicitation#response-actions", behavior="A form-mode elicitation answered with action 'cancel' returns no content to the handler.", - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), - ), + removed_in="2026-07-28", + superseded_by="elicitation:mrtr:form:action:cancel", + note="removed in 2026-07-28 (SEP-2322); elicitation/create now rides MRTR inputRequests.", + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "elicitation:form:action:decline": Requirement( source=f"{SPEC_BASE_URL}/client/elicitation#response-actions", behavior="A form-mode elicitation answered with action 'decline' returns no content to the handler.", - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), - ), + removed_in="2026-07-28", + superseded_by="elicitation:mrtr:form:action:decline", + note="removed in 2026-07-28 (SEP-2322); elicitation/create now rides MRTR inputRequests.", + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "elicitation:form:basic": Requirement( source=f"{SPEC_BASE_URL}/client/elicitation#form-mode-elicitation-requests", @@ -1728,10 +3422,10 @@ def __post_init__(self) -> None: "A form-mode elicitation delivers the message and requested schema to the client callback " "exactly as the server sent them." ), - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), - ), + removed_in="2026-07-28", + superseded_by="elicitation:mrtr:form:basic", + note="removed in 2026-07-28 (SEP-2322); elicitation/create now rides MRTR inputRequests.", + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "elicitation:form:defaults": Requirement( source=f"{SPEC_BASE_URL}/client/elicitation#requested-schema", @@ -1757,10 +3451,13 @@ def __post_init__(self) -> None: divergence=Divergence( note="The client's default callback answers with -32600 Invalid request instead of -32602.", ), - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + removed_in="2026-07-28", + superseded_by="elicitation:mrtr:capability:not-declared", + note=( + "removed in 2026-07-28 (SEP-2322); the client no longer answers elicitation requests (the " + "-32602 answer plane is gone) -- the surviving protection is the server-side embed gate." ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "elicitation:form:schema:enum-variants": Requirement( source=f"{SPEC_BASE_URL}/client/elicitation#requested-schema", @@ -1768,18 +3465,18 @@ def __post_init__(self) -> None: "Requested-schema enum fields (including titled and multi-select variants) reach the client " "callback as sent." ), - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), - ), + removed_in="2026-07-28", + superseded_by="elicitation:mrtr:form:schema:enum-variants", + note="removed in 2026-07-28 (SEP-2322); elicitation/create now rides MRTR inputRequests.", + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "elicitation:form:schema:primitives": Requirement( source=f"{SPEC_BASE_URL}/client/elicitation#requested-schema", behavior="Requested-schema fields may be string (with format), number or integer, or boolean.", - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), - ), + removed_in="2026-07-28", + superseded_by="elicitation:mrtr:form:schema:primitives", + note="removed in 2026-07-28 (SEP-2322); elicitation/create now rides MRTR inputRequests.", + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "elicitation:form:schema:restricted-subset": Requirement( source=f"{SPEC_BASE_URL}/client/elicitation#requested-schema", @@ -1797,10 +3494,10 @@ def __post_init__(self) -> None: "the elicitation callback." ), ), - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), - ), + removed_in="2026-07-28", + superseded_by="elicitation:mrtr:form:schema:restricted-subset", + note="removed in 2026-07-28 (SEP-2322); elicitation/create now rides MRTR inputRequests.", + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "elicitation:form:response-validation": Requirement( source=f"{SPEC_BASE_URL}/client/elicitation#form-mode-security", @@ -1815,10 +3512,14 @@ def __post_init__(self) -> None: "validates server-side, but the low-level session API does not)." ), ), - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + removed_in="2026-07-28", + superseded_by="mrtr:input-responses:invalid-rejected", + note=( + "removed in 2026-07-28 (SEP-2322); the server-side validation half re-homes to the MRTR " + "inputResponses contract; the client-side validate-before-sending half folds into the MRTR " + "client driver's contract -- covered when that is pinned." ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "elicitation:url:action:accept-no-content": Requirement( source=f"{SPEC_BASE_URL}/client/elicitation#response-actions", @@ -1827,10 +3528,26 @@ def __post_init__(self) -> None: "response carries no content (accept means the user agreed to visit the URL, not that the " "interaction completed)." ), - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), - ), + removed_in="2026-07-28", + superseded_by="elicitation:mrtr:url:action-no-content", + note="removed in 2026-07-28 (SEP-2322); URL-mode elicitation/create now rides MRTR inputRequests.", + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), + ), + "elicitation:url:action:cancel": Requirement( + source=f"{SPEC_BASE_URL}/client/elicitation#response-actions", + behavior="A URL-mode elicitation answered with cancel returns the action with no content.", + removed_in="2026-07-28", + superseded_by="elicitation:mrtr:url:action-no-content", + note="removed in 2026-07-28 (SEP-2322); URL-mode elicitation/create now rides MRTR inputRequests.", + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), + ), + "elicitation:url:action:decline": Requirement( + source=f"{SPEC_BASE_URL}/client/elicitation#response-actions", + behavior="A URL-mode elicitation answered with decline returns the action with no content.", + removed_in="2026-07-28", + superseded_by="elicitation:mrtr:url:action-no-content", + note="removed in 2026-07-28 (SEP-2322); URL-mode elicitation/create now rides MRTR inputRequests.", + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "elicitation:url:basic": Requirement( source=f"{SPEC_BASE_URL}/client/elicitation#url-mode-elicitation-requests", @@ -1838,71 +3555,433 @@ def __post_init__(self) -> None: "A url-mode elicitation delivers the elicitation id and URL to the client callback exactly as " "the server sent them." ), - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + removed_in="2026-07-28", + superseded_by="mrtr:url-elicitation:no-32042-on-2026", + note="removed in 2026-07-28 (SEP-2322); URL-mode elicitation/create now rides MRTR inputRequests.", + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), + ), + "elicitation:url:complete-notification": Requirement( + source=f"{SPEC_BASE_URL}/client/elicitation#completion-notifications-for-url-mode-elicitation", + behavior=( + "An elicitation/complete notification sent by the server after an out-of-band elicitation " + "finishes reaches the client carrying the elicitationId." + ), + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (no-SEP spec fix-up following SEP-2322's MRTR model); " + "notifications/elicitation/complete and elicitationId removed, no replacement (under MRTR the " + "client learns completion by retrying)." ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), - "elicitation:url:cancel": Requirement( - source=f"{SPEC_BASE_URL}/client/elicitation#response-actions", - behavior="A URL-mode elicitation answered with cancel returns the action with no content.", - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + "elicitation:url:complete-unknown-ignored": Requirement( + source=f"{SPEC_BASE_URL}/client/elicitation#completion-notifications-for-url-mode-elicitation", + behavior=( + "The client ignores an elicitation/complete notification referencing an unknown or " + "already-completed elicitationId without error." + ), + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (no-SEP spec fix-up following SEP-2322's MRTR model); " + "notifications/elicitation/complete removed, no replacement." + ), + ), + "elicitation:url:not-supported": Requirement( + source=f"{SPEC_BASE_URL}/client/elicitation#error-handling", + behavior=( + "A URL-mode elicitation to a client that declared only form-mode support is rejected with an " + "Invalid params error." + ), + deferred=( + "Not implemented in the SDK: a Client with an elicitation callback always declares both the " + "form and url sub-capabilities, so a form-only client cannot be constructed." + ), + ), + "elicitation:url:required-error": Requirement( + source=f"{SPEC_BASE_URL}/client/elicitation#url-elicitation-required-error", + behavior=( + "A handler that cannot proceed without a URL elicitation rejects the request with error " + "-32042, carrying the pending elicitations in the error data." + ), + removed_in="2026-07-28", + superseded_by="mrtr:url-elicitation:no-32042-on-2026", + note=( + "removed in 2026-07-28 (SEP-2322); error -32042 retired, replaced by an MRTR input_required result " + "carrying inputRequests." + ), + ), + "elicitation:url:valid-url": Requirement( + source=f"{SPEC_BASE_URL}/client/elicitation#url-mode-elicitation-requests", + behavior="The url parameter of a url-mode elicitation contains a valid URL.", + deferred=( + "Not implemented in the SDK: the url is a plain str at every layer -- " + "ElicitRequestURLParams.url (src/mcp-types/mcp_types/_types.py) and the elicit_url helpers " + "(src/mcp/server/elicitation.py) forward the caller's string verbatim with no URL " + "validation anywhere on the path, so the producer-side MUST has no enforcement point to " + "pin (pass-through is covered by elicitation:url:basic)." + ), + ), + "elicitation:mrtr:form:basic": Requirement( + source=f"{SPEC_2026_BASE_URL}/client/elicitation#form-mode-elicitation-requests", + behavior=( + "An embedded form-mode elicitation/create request in an input_required result delivers the " + "message and requested schema to the client's elicitation callback exactly as sent, and an " + "accept response carrying the user's content reaches the retried handler in inputResponses." + ), + added_in="2026-07-28", + supersedes=( + "elicitation:form:basic", + "elicitation:form:action:accept", + "transport:streamable-http:server-to-client", + ), + ), + "elicitation:mrtr:form:action:cancel": Requirement( + source=f"{SPEC_2026_BASE_URL}/client/elicitation#response-actions", + behavior=( + "An embedded form-mode elicitation answered with action 'cancel' reaches the retried handler " + "in inputResponses with no content." + ), + added_in="2026-07-28", + supersedes=("elicitation:form:action:cancel",), + ), + "elicitation:mrtr:form:action:decline": Requirement( + source=f"{SPEC_2026_BASE_URL}/client/elicitation#response-actions", + behavior=( + "An embedded form-mode elicitation answered with action 'decline' reaches the retried handler " + "in inputResponses with no content." + ), + added_in="2026-07-28", + supersedes=("elicitation:form:action:decline",), + ), + "elicitation:mrtr:form:schema:primitives": Requirement( + source=f"{SPEC_2026_BASE_URL}/client/elicitation#requested-schema", + behavior=( + "Requested-schema fields on an embedded form-mode elicitation may be string (with format), " + "number or integer, or boolean; they reach the client callback intact." + ), + added_in="2026-07-28", + supersedes=("elicitation:form:schema:primitives",), + ), + "elicitation:mrtr:form:schema:enum-variants": Requirement( + source=f"{SPEC_2026_BASE_URL}/client/elicitation#requested-schema", + behavior=( + "Requested-schema enum fields (including titled and multi-select variants) on an embedded " + "form-mode elicitation reach the client callback as sent." + ), + added_in="2026-07-28", + supersedes=("elicitation:form:schema:enum-variants",), + deferred=( + "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " + "expected drivable by analogy with its triaged MRTR siblings." + ), + ), + "elicitation:mrtr:form:schema:restricted-subset": Requirement( + source=f"{SPEC_2026_BASE_URL}/client/elicitation#requested-schema", + behavior=( + "Form-mode requested schemas on embedded elicitations are flat objects with primitive-typed " + "properties only; nested structures and arrays of objects are not used." + ), + added_in="2026-07-28", + supersedes=("elicitation:form:schema:restricted-subset",), + deferred=( + "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " + "expected drivable by analogy with its triaged MRTR siblings." + ), + ), + "elicitation:mrtr:capability:not-declared": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#server-requirements-basic-workflow", + behavior=( + "The server does not place an elicitation/create request in an input_required result's " + "inputRequests for a client whose declared capabilities do not support it (including " + "mode-level support: form vs url)." + ), + divergence=Divergence( + note=( + "The server does not gate input_required input requests against the client's declared " + "capabilities: an elicitation/create is embedded and sent as-is to a client whose request " + "envelope declared no elicitation capability. The mode-level half of the same MUST NOT " + "(form vs url) is equally ungated and additionally unpinned -- a configured elicitation " + "callback always declares both modes, so a form-only client is unproducible through the " + "public API." + ), + issue="L109", + ), + added_in="2026-07-28", + supersedes=("elicitation:form:not-supported", "elicitation:capability:server-respects-mode"), + ), + "elicitation:mrtr:url:action-no-content": Requirement( + source=f"{SPEC_2026_BASE_URL}/client/elicitation#response-actions", + behavior=( + "ElicitResult actions for an embedded URL-mode elicitation carry no content: accept means the " + "user agreed to visit the URL, and cancel/decline reach the retried handler with the action " + "and no content." + ), + added_in="2026-07-28", + supersedes=( + "elicitation:url:action:accept-no-content", + "elicitation:url:action:cancel", + "elicitation:url:action:decline", + ), + deferred=( + "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " + "expected drivable by analogy with its triaged MRTR siblings." + ), + ), + # ═══════════════════════════════════════════════════════════════════════════ + # MRTR (multi-round-trip requests, 2026-07-28) + # ═══════════════════════════════════════════════════════════════════════════ + "mrtr:input-required-result:at-least-one-of": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#server-requirements-basic-workflow", + behavior=( + "An InputRequiredResult carries at least one of inputRequests or requestState; a " + "handler-built violation fails at construction and surfaces to the client as a JSON-RPC " + "error, never as a malformed interim result." + ), + added_in="2026-07-28", + note=( + "The at-least-one-of MUST is enforced by construction (mcp_types model validator). Both " + "2026 dispatchers map the handler's ValidationError to the shared " + "ErrorData(INVALID_PARAMS, 'Invalid request parameters', data='') shape " + "(handler_exception_to_error_data); INTERNAL_ERROR is arguably the more apt code for a " + "server-side construction bug, but the spec mandates no code for this failure." + ), + ), + "mrtr:input-required-result:result-type-serialized": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/index#result-responses", + behavior=( + "The serialized interim frame carries resultType input_required explicitly; the " + "discriminator is never elided on the wire." + ), + added_in="2026-07-28", + ), + "protocol:result-type:absent-is-complete": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic#resulttype", + behavior=( + "A result body with no resultType field is treated as resultType 'complete': the " + "result parses and surfaces as the normal terminal result (backward compatibility " + "with servers implementing earlier protocol versions)." + ), + added_in="2026-07-28", + note=( + "Exercised on a legacy-era session because that is the clause's own scenario (an " + "earlier-protocol server cannot be on a 2026 session). On a 2026 session the SDK " + "follows the 2026 schema, where resultType is a required field, and refuses a " + "body that omits it at result validation -- the spec's prose and schema disagree " + "here (schema.ts marks the field required while this clause demands absence " + "tolerance); the SDK reads the schema as the wire contract." + ), + ), + "protocol:result-type:input-required-not-masked": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic#resulttype", + behavior=( + "An input_required result body never surfaces as an empty-content success through " + "the client request path: a caller that has not opted into the manual loop " + "receives a typed local error naming the situation." + ), + added_in="2026-07-28", + note=( + "The error shape is SDK-defined and era-split: on a modern session the session " + "surface raises the allow_input_required guidance error -- the leg the test pins. " + "On a legacy session the 2025 result surface does not admit the interim shape at " + "all, so the body is refused at result validation with a pydantic ValidationError " + "naming the missing content field (the typescript-sdk surfaces the same boundary " + "as an UNSUPPORTED_RESULT_TYPE error); that leg is probe-verified but carries no " + "test here -- a real Server's own serializer refuses to emit the interim on a " + "2025 connection, so only a scripted peer could drive it." + ), + ), + "protocol:result-type:unrecognized-invalid": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic#resulttype", + behavior=( + "A resultType value the client does not recognize is treated as invalid rather than " + "surfaced as a normal result." + ), + added_in="2026-07-28", + divergence=Divergence( + note=( + "The client accepts an unrecognized resultType whenever the body also parses " + "as a complete core result: ResultType is a deliberately open Literal-or-str " + "union (src/mcp-types/mcp_types/_types.py), and the client's discriminated " + "claim adapter (src/mcp/client/session.py) routes unknown tags to the core " + "arm, so such a value is surfaced on the returned result unchanged. A body " + "that does not parse as a core result fails result validation -- that reject " + "arm is pinned by extensions:client:claimed-result-undeclared-invalid. The " + "in-code TODO in src/mcp/server/runner.py records the missing rejection." + ), + issue="L117", + ), + ), + "mrtr:input-responses:invalid-rejected": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#error-handling", + behavior=( + "The server validates that a retry's inputResponses parse as a valid InputResponses object; " + "a structurally malformed map is rejected with a JSON-RPC error before the handler runs." + ), + added_in="2026-07-28", + supersedes=("elicitation:form:response-validation",), + note=( + "Elicited content is handed to the handler without requestedSchema re-validation; servers " + "validate semantic constraints themselves (spec asks only for structural validation)." + ), + ), + "mrtr:input-responses:key-correspondence": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#inputresponses", + behavior=( + "A retry's inputResponses map is keyed by the originating inputRequests keys, each value " + "the client's typed result for that key's request (e.g. ElicitResult, ListRootsResult)." + ), + added_in="2026-07-28", + ), + "mrtr:input-responses:missing-reprompted": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#error-handling", + behavior=( + "When a retry omits information requested in a previous inputRequests, the server " + "responds with a new InputRequiredResult requesting the missing information again " + "rather than returning an error: the partial inputResponses map passes validation " + "and is delivered to the handler unmodified, and the re-prompt interim round-trips " + "as a normal input_required round." + ), + added_in="2026-07-28", + ), + "mrtr:input-responses:unknown-ignored": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#error-handling", + behavior=( + "Additional, unexpected entries in a retry's inputResponses are ignored rather " + "than rejected: a structurally valid response under a key the server never " + "requested passes validation and the call completes using only the recognized keys." + ), + added_in="2026-07-28", + note=( + "The SDK forwards the unrecognized entry to the handler unfiltered (pinned); " + "ignoring is exercised at the handler, which is where the spec's 'does not " + "recognize or need' judgement lives. An SDK that instead dropped unknown keys " + "before dispatch would equally satisfy the SHOULD -- the handler-visibility " + "assertion pins current behaviour so that change is conscious, not silent." + ), + ), + "mrtr:url-elicitation:no-32042-on-2026": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr", + behavior=( + "URL-mode elicitation rides the multi-round-trip flow at 2026-07-28: a handler embeds a " + "URL-mode elicitation/create in an input_required result, the registered elicitation callback " + "fulfils it, the retried call completes, and error -32042 never appears on the wire." + ), + added_in="2026-07-28", + supersedes=( + "elicitation:url:basic", + "elicitation:url:required-error", + "mcpserver:tool:url-elicitation-error", + "flow:elicitation:url-required-then-retry", + ), + ), + "mrtr:tools-call:write-once-roundtrip": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#basic-workflow", + behavior=( + "A tool that returns an input_required result on a 2026-07-28 connection is fulfilled by the " + "client driver: the registered callback answers the embedded request, and the original call is " + "retried with a fresh request id, a byte-exact requestState echo, and the collected " + "inputResponses, completing as a plain CallToolResult." ), + added_in="2026-07-28", + supersedes=("tools:call:elicitation-roundtrip", "mcpserver:context:elicit-from-handler"), ), - "elicitation:url:complete-notification": Requirement( - source=f"{SPEC_BASE_URL}/client/elicitation#completion-notifications-for-url-mode-elicitation", + "mrtr:request-state-only:retry": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#client-requirements-basic-workflow", behavior=( - "An elicitation/complete notification sent by the server after an out-of-band elicitation " - "finishes reaches the client carrying the elicitationId." + "An InputRequiredResult carrying only requestState (no inputRequests) is retried by the " + "client driver with no inputResponses and the requestState echoed verbatim." ), - removed_in="2026-07-28", + added_in="2026-07-28", note=( - "removed in 2026-07-28 (spec PR #2891); notifications/elicitation/complete and elicitationId removed, no " - "replacement (under MRTR the client learns completion by retrying)." + "The spec's 'MAY retry the original request immediately' is permission; the SDK paces " + "state-only retries with an internal 50 ms exponential backoff as its chosen pacing." ), - arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), - "elicitation:url:complete-unknown-ignored": Requirement( - source=f"{SPEC_BASE_URL}/client/elicitation#completion-notifications-for-url-mode-elicitation", + "mrtr:request-state:omitted-when-absent": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#client-requirements-basic-workflow", behavior=( - "The client ignores an elicitation/complete notification referencing an unknown or " - "already-completed elicitationId without error." + "When an InputRequiredResult carries no requestState field, the client does not include " + "a requestState key in the serialized retry." ), - removed_in="2026-07-28", - note="removed in 2026-07-28 (spec PR #2891); notifications/elicitation/complete removed, no replacement.", + added_in="2026-07-28", ), - "elicitation:url:decline": Requirement( - source=f"{SPEC_BASE_URL}/client/elicitation#response-actions", - behavior="A URL-mode elicitation answered with decline returns the action with no content.", - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + "mrtr:request-state:reject-tampered": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#server-requirements-basic-workflow", + behavior=( + "A server whose requestState influences authorization, resource access, or business logic " + "protects its integrity (e.g. HMAC or AEAD) and rejects state that fails verification." + ), + added_in="2026-07-28", + deferred=( + "Not yet covered here: requestState integrity protection landed " + "(src/mcp/server/request_state.py) -- RequestStateBoundary seals outbound requestState " + "and verifies inbound echoes, installed by default on MCPServer (opt-in middleware for " + "the lowlevel Server), rejecting a tampered echo with the frozen -32602 'Invalid or " + "expired requestState' error. The test tampers with the sealed token on retry through " + "the public client API against an MCPServer and asserts that rejection." ), ), - "elicitation:url:not-supported": Requirement( - source=f"{SPEC_BASE_URL}/client/elicitation#error-handling", + "mrtr:request-state:replay-binding": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#server-requirements-basic-workflow", behavior=( - "A URL-mode elicitation to a client that declared only form-mode support is rejected with an " - "Invalid params error." + "To prevent replay, a server includes the authenticated principal, a short expiry, and an " + "originating-request identifier inside the integrity-protected requestState payload and " + "verifies each on receipt, rejecting state presented by a different principal, after the " + "expiry lapses, or on a request that does not match." ), + added_in="2026-07-28", deferred=( - "Not implemented in the SDK: a Client with an elicitation callback always declares both the " - "form and url sub-capabilities, so a form-only client cannot be constructed." + "Not yet covered here: the requestState integrity envelope landed " + "(src/mcp/server/request_state.py) and binds the spec's full list -- iat/exp expiry, " + "method plus target plus arguments digest for the originating request, and a salted " + "principal claim (inert on unauthenticated transports) -- all verified fail-closed on " + "receipt with the same frozen -32602 rejection. The test replays a sealed state against " + "different arguments and asserts the rejection; the expiry arm needs a TTL design call " + "(this suite refuses real-time waits) and the principal arm needs two authenticated " + "identities through the in-process OAuth rig." ), ), - "elicitation:url:required-error": Requirement( - source=f"{SPEC_BASE_URL}/client/elicitation#url-elicitation-required-error", + "mrtr:request-state:scoped-to-originating-request": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#client-requirements-basic-workflow", behavior=( - "A handler that cannot proceed without a URL elicitation rejects the request with error " - "-32042, carrying the pending elicitations in the error data." + "inputRequests and requestState affect only the client's retry of the originating " + "request; they never appear on any other request the client sends in parallel." + ), + added_in="2026-07-28", + ), + "mrtr:multi-round:complete": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#server-requirements-basic-workflow", + behavior=( + "A server may answer the same request with input_required on multiple successive attempts; " + "after two or more productive rounds the retried request completes normally." + ), + added_in="2026-07-28", + supersedes=("flow:elicitation:multi-step-form",), + ), + "mrtr:rounds-cap": Requirement( + source="sdk", + behavior=( + "Client.call_tool / get_prompt / read_resource bound the input_required retry loop at the " + "configurable input_required_max_rounds; a server that keeps answering input_required past " + "the cap raises InputRequiredRoundsExceededError carrying the configured cap." + ), + added_in="2026-07-28", + ), + "mrtr:push-api:loud-fail-2026": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr", + behavior=( + "The push-style server-to-client request APIs (ServerSession.elicit_form / elicit_url / " + "create_message / list_roots) on a 2026-07-28 connection fail with a typed local error " + "(NoBackChannelError, INVALID_REQUEST) before any request reaches the client; a handler " + "can catch it and fall back, and the originating call still completes." ), - removed_in="2026-07-28", note=( - "removed in 2026-07-28 (SEP-2322); error -32042 retired, replaced by an MRTR input_required result " - "carrying inputRequests." + "Era-routed by construction: every modern dispatch path hands handlers a request-scoped " + "channel that refuses server-initiated requests and a connection with no standalone " + "back-channel, so the refusal is local on both legs of every 2026 transport, while legacy " + "connections keep their live back-channel." ), + added_in="2026-07-28", ), # ═══════════════════════════════════════════════════════════════════════════ # Roots (server → client) @@ -1937,26 +4016,29 @@ def __post_init__(self) -> None: "A roots/list request from a server handler is answered by the client's roots callback, and " "the returned roots (uri, name) reach the handler." ), - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), - ), + removed_in="2026-07-28", + superseded_by="roots:mrtr:list:basic", + note="removed in 2026-07-28 (SEP-2322); roots/list now rides MRTR inputRequests.", + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "roots:list:client-error": Requirement( source=f"{SPEC_BASE_URL}/client/roots#error-handling", behavior="A roots callback that answers with an error surfaces to the requesting handler as an MCPError.", - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2322); there is no error answer to a roots request under MRTR -- " + "the client does not replay the call with an error message, as the server is not waiting. No " + "replacement." ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "roots:list:empty": Requirement( source=f"{SPEC_BASE_URL}/client/roots#listing-roots", behavior="An empty roots list is a valid response and reaches the handler as such.", - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), - ), + removed_in="2026-07-28", + superseded_by="roots:mrtr:list:empty", + note="removed in 2026-07-28 (SEP-2322); roots/list now rides MRTR inputRequests.", + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "roots:list:not-supported": Requirement( source=f"{SPEC_BASE_URL}/client/roots#error-handling", @@ -1967,10 +4049,49 @@ def __post_init__(self) -> None: divergence=Divergence( note="The client's default callback answers with -32600 Invalid request instead of -32601.", ), - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + removed_in="2026-07-28", + superseded_by="roots:mrtr:capability:not-declared", + note=( + "removed in 2026-07-28 (SEP-2322); the client no longer answers roots requests (the -32601 " + "answer plane is gone) -- the surviving protection is the server-side embed gate." + ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), + ), + "roots:mrtr:list:basic": Requirement( + source=f"{SPEC_2026_BASE_URL}/client/roots#listing-roots", + behavior=( + "An embedded roots/list request in an input_required result is fulfilled by the client's roots " + "callback, and the returned roots (uri, name) reach the retried handler in inputResponses." + ), + added_in="2026-07-28", + supersedes=("roots:list:basic",), + ), + "roots:mrtr:list:empty": Requirement( + source=f"{SPEC_2026_BASE_URL}/client/roots#listing-roots", + behavior=( + "An empty roots list returned by the client roots callback for an embedded roots/list request " + "reaches the retried handler as such." + ), + added_in="2026-07-28", + supersedes=("roots:list:empty",), + ), + "roots:mrtr:capability:not-declared": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#server-requirements-basic-workflow", + behavior=( + "The server does not place a roots/list request in an input_required result's inputRequests " + "for a client that did not declare the roots capability." + ), + divergence=Divergence( + note=( + "The embed gate is not implemented: an input_required result carrying a roots/list " + "request for a client that did not declare the roots capability is transmitted as-is, " + "and the violation surfaces as the client driver's refusal (INVALID_REQUEST, 'List " + "roots not supported') aborting the call." + ), + issue="L109", ), + added_in="2026-07-28", + supersedes=("roots:list:not-supported",), ), "roots:uri:file-scheme": Requirement( source=f"{SPEC_BASE_URL}/client/roots#root", @@ -1994,6 +4115,40 @@ def __post_init__(self) -> None: "Not implemented in the SDK: the client has no list-changed auto-refresh mechanism; " "notifications are only delivered to the message handler." ), + removed_in="2026-07-28", + superseded_by="client:listen:auto-refresh", + note=( + "removed in 2026-07-28 (SEP-2575); unsolicited list_changed notifications retired -- the modern " + "auto-refresh reacts to changes published on a subscriptions/listen stream." + ), + ), + "client:listen:auto-refresh": Requirement( + source="sdk", + behavior=( + "A client configured with listChanged auto-refresh, on a modern connection, opens a " + "subscriptions/listen stream and on each published change re-fetches the corresponding list " + "and delivers the fresh result to its callback." + ), + added_in="2026-07-28", + supersedes=("client:list-changed:auto-refresh",), + deferred=( + "Not implemented in the SDK: the client has no subscriptions/listen client driver and no " + "list-changed auto-refresh mechanism." + ), + ), + "client:listen:signal-only": Requirement( + source="sdk", + behavior=( + "A client configured for signal-only list-changed handling on a modern connection is " + "notified of changes published on its subscriptions/listen stream without auto-refreshing " + "the corresponding list." + ), + added_in="2026-07-28", + deferred=( + "Not implemented in the SDK: the client has no subscriptions/listen client driver and no " + "client-side list-changed handling to configure." + ), + note="The 2025-era push-notification sibling is client:list-changed:signal-only.", ), "client:list-changed:capability-gated": Requirement( source="sdk", @@ -2008,6 +4163,21 @@ def __post_init__(self) -> None: behavior="A client configured for signal-only list-changed handling is notified without auto-refreshing.", deferred="Not implemented in the SDK: no client-side list-changed handling exists.", ), + "mcpserver:handle:enable-disable": Requirement( + source="sdk", + behavior=( + "A registration handle can disable a registered item -- removing it from list results and " + "erroring calls or reads -- and re-enable it later, with each transition published as a " + "list change." + ), + deferred=( + "Not implemented in the SDK: MCPServer registration returns the registered model, not a " + "handle -- there is no disable/enable lifecycle and no list-change publication on mutation " + "(see mcpserver:register:post-connect); the registration mutation surfaces are " + "MCPServer.remove_tool and MCPServer.remove_prompt (src/mcp/server/mcpserver/server.py), " + "neither a disable/enable pair." + ), + ), "mcpserver:list-changed:debounce": Requirement( source="sdk", behavior=( @@ -2019,17 +4189,47 @@ def __post_init__(self) -> None: "debounce." ), ), + "mcpserver:onerror:reach-through": Requirement( + source="sdk", + behavior=( + "An error callback on the underlying low-level server receives transport-level and " + "protocol-level errors (uncaught notification-handler exceptions, failed sends, unknown " + "message ids) raised outside request handlers." + ), + deferred=( + "Not implemented in the SDK: no error-callback surface exists at any layer -- neither the " + "lowlevel Server (src/mcp/server/lowlevel/server.py) nor the dispatcher " + "(src/mcp/shared/jsonrpc_dispatcher.py) accepts an error handler; out-of-request failures " + "go to the module logger." + ), + ), + "mcpserver:reach-through:set-request-handler": Requirement( + source="sdk", + behavior=( + "The low-level server under an MCPServer is publicly reachable, so a raw request handler " + "can be installed for a method the high-level API has not wired, alongside high-level " + "registrations." + ), + deferred=( + "Not implemented in the SDK: MCPServer keeps its low-level Server private (the " + "_lowlevel_server attribute, src/mcp/server/mcpserver/server.py) and exposes no public " + "reach-through; the lowlevel add_request_handler surface exists but is not reachable from " + "MCPServer." + ), + ), "mcpserver:register:post-connect": Requirement( source="sdk", behavior=( - "A tool, resource, or prompt registered or removed after the client connected appears in (or " - "disappears from) the corresponding list results, and the change is announced with a " - "list_changed notification." + "A tool registered or removed after the client connected appears in (or disappears from) " + "tools/list results, and the change is announced with a list_changed notification." ), divergence=Divergence( note=( - "MCPServer never sends list_changed notifications on registration changes, so a connected " - "client cannot learn that the set changed without polling." + "MCPServer never publishes anything on registration changes -- add/remove only mutate " + "the registry. At 2026-07-28 a handler can announce a change itself via " + "ctx.notify_tools_changed() to subscriptions/listen subscribers " + "(src/mcp/server/mcpserver/context.py); absent such an app-driven notification, a " + "connected client can only learn that the set changed by polling." ), ), ), @@ -2050,8 +4250,311 @@ def __post_init__(self) -> None: "pagination:client:cursor-handling": Requirement( source=f"{SPEC_BASE_URL}/server/utilities/pagination#implementation-guidelines", behavior=( - "The client treats cursors as opaque tokens — it does not parse, modify, or persist them — " - "and does not assume a fixed page size." + "The client treats cursors as opaque tokens — it does not parse or modify them — and " + "does not assume a fixed page size." + ), + note=( + "The 2026-07-28 revision rewrote the page's third client-MUST bullet: 'Don't persist " + "cursors across sessions' (2025-11-25 only) is gone, replaced by the empty-string rule " + "pinned by protocol:pagination:empty-cursor-valid. The dropped persist clause was also " + "never pinnable here (no cross-session observable in the bound test), so the behavior " + "keeps to the cross-era core the test actually drives." + ), + ), + "protocol:pagination:empty-cursor-valid": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/utilities/pagination#implementation-guidelines", + behavior=( + "An empty-string nextCursor in a list result is a valid cursor, not end-of-results: " + "the client passes it back verbatim and continues paging." + ), + added_in="2026-07-28", + note=( + "The 2026-07-28 revision rewrote the third client-MUST bullet of the pagination " + "page's Implementation Guidelines to make the empty-string rule explicit; the rewrite " + "has no changelog entry, so changelog-driven era sweeps miss it. The 2025-11-25 page " + "was silent on empty cursors (the SDK behaves identically there, unobligated). The " + "SDK's share of the MUST is preserving the empty-string/absent distinction on both " + "legs -- surfacing nextCursor='' as '' and sending cursor='' verbatim; whether to " + "stop paging is the caller's decision, which only stays correct because the " + "distinction survives." + ), + ), + # ═══════════════════════════════════════════════════════════════════════════ + # Caching (SEP-2549, 2026-07-28) + # ═══════════════════════════════════════════════════════════════════════════ + "caching:hints:prompts-list": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/utilities/caching#cacheable-results", + behavior=( + "prompts/list results at 2026-07-28 carry the caching hints -- ttlMs >= 0 and " + "cacheScope -- alongside resultType complete; handler-authored hint values reach the " + "client unmodified." + ), + added_in="2026-07-28", + note=( + "Completes the spec's six-operation MUST together with " + "hosting:http:modern:cacheable-stamping (tools/list, resources/list, resources/read) " + "and caching:hints:server-discover (server/discover). The server-side " + "'ttlMs >= 0' MUST is by construction: CacheableResult.ttl_ms is Field(ge=0), so a " + "violating result is unconstructible through the typed API." + ), + ), + "caching:hints:resources-templates-list": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/utilities/caching#cacheable-results", + behavior=( + "resources/templates/list results at 2026-07-28 carry the caching hints -- " + "ttlMs >= 0 and cacheScope -- alongside resultType complete; handler-authored hint " + "values reach the client unmodified." + ), + added_in="2026-07-28", + note=( + "The sixth operation of the spec's cacheable-results MUST; see " + "caching:hints:prompts-list for the family map." + ), + ), + "caching:hints:server-discover": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/utilities/caching#cacheable-results", + behavior=( + "server/discover results at 2026-07-28 carry the caching hints -- ttlMs >= 0 and " + "cacheScope -- alongside resultType complete." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "Only observable over streamable HTTP: server/discover is served only by the modern " + "HTTP entry (the in-memory 2026 connection synthesizes its DiscoverResult client-side " + "and never sends the request). The pinned 0/private values are the SDK's " + "CacheableResult defaults -- no handler authors discover hints -- so the test pins " + "the stamping mechanism, not authored pass-through. Completes the six-operation map " + "with caching:hints:prompts-list (family index) and hosting:http:modern:cacheable-stamping." + ), + ), + "caching:pagination:same-scope-all-pages": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/utilities/caching#interaction-with-pagination", + behavior=( + "Every page of one paginated list request carries the same cacheScope: the scope of " + "the first page applies to all subsequent pages of that request." + ), + added_in="2026-07-28", + divergence=Divergence( + note=( + "The SDK applies no cross-page cacheScope consistency: each page's scope is " + "whatever that handler invocation returned, and a handler authoring mismatched " + "scopes across one cursor walk is forwarded unmodified with no error. The " + "stateless 2026 entry cannot correlate pages of 'a given list request' without " + "encoding state in the (server-minted, opaque) cursor, so enforcement is a real " + "SDK design question; today the spec MUST is delegated entirely to the handler " + "author. The SDK's own defaults are trivially cross-page consistent." + ), + issue="L111", + ), + ), + "caching:ttl:absent-defaults-zero": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/utilities/caching#time-to-live-ttl-field", + behavior=( + "When a result arrives with no ttlMs (a pre-2026 server), the client surfaces the " + "default 0 -- immediately stale -- rather than failing or inventing freshness." + ), + removed_in="2026-07-28", + note=( + "Era-bound for constructibility, matching the spec's own scoping ('this should only " + "occur in older server versions'): the 2026-07-28 wire surface makes ttlMs/cacheScope " + "required, so absence at 2026 is a validation error, not a defaulting case. The " + "companion cacheScope private default the test also pins is the SDK's chosen safe " + "default -- the spec sentence covers only ttlMs." + ), + ), + "caching:ttl:zero-immediately-stale": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/utilities/caching#time-to-live-ttl-field", + behavior=( + "A result stamped ttlMs 0 is immediately stale: the client re-fetches on every " + "access instead of serving the previous response." + ), + added_in="2026-07-28", + note=( + "Load-bearing against the live client response cache: a ttlMs-0 result is never " + "stored, so both calls reach the handler, while the same seam with a positive ttl_ms " + "serves the second access from cache. The fresh window itself is the sibling " + "caching:ttl:positive-fresh-window." + ), + ), + "caching:input-required:no-hints": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/utilities/caching#cacheable-results", + behavior=( + "An interim resultType input_required result carries no caching hints on the wire, " + "while the terminal complete result of the very same exchange carries both ttlMs and " + "cacheScope." + ), + added_in="2026-07-28", + note=( + "The no-hints half is by construction (InputRequiredResult does not extend " + "CacheableResult and rejects extras); the wire pin proves the serialized frame, where " + "typed models hide absent-vs-default. The sentence's 'are not cacheable' consumer " + "half is now observable -- the client response cache never stores input_required or " + "driver-round results (src/mcp/client/client.py) -- and is the deferred sibling " + "caching:key:mrtr-retry-not-cached." + ), + ), + "caching:ttl:negative-treated-as-zero": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/utilities/caching#time-to-live-ttl-field", + behavior="A negative ttlMs on an inbound result is ignored and treated as 0.", + added_in="2026-07-28", + note=( + "The leniency is receive-side only: the client clamps a negative inbound ttlMs to 0 " + "before validation, while emission keeps ge=0 on the shared type -- a conformant " + "server can never author a negative ttlMs through the typed API." + ), + ), + "caching:ttl:positive-fresh-window": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/utilities/caching#time-to-live-ttl-field", + behavior=( + "A positive ttlMs opens a fresh window: a caching client considers the result fresh " + "for that many milliseconds after receipt and need not re-fetch on access within it." + ), + added_in="2026-07-28", + deferred=( + "Not yet covered here: the SEP-2549 response cache landed (src/mcp/client/caching.py, " + "default-on for Client) and a positive ttlMs now opens a real fresh window. The test " + "drives Client against a handler stamping a positive ttl_ms and asserts the second " + "access is served without a second fetch; note the SDK caps the window at 24 hours " + "(MAX_TTL_MS), which the spec does not mention." + ), + ), + "caching:freshness:stale-refetch-on-access": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/utilities/caching#freshness-calculation", + behavior=("Once a cached response's TTL expires it is stale, and the client re-fetches on the next access."), + added_in="2026-07-28", + deferred=( + "Not yet covered here: the SEP-2549 response cache landed and checks expiry on access " + "against an injectable epoch source (CacheConfig clock, src/mcp/client/caching.py), so " + "the transition is constructible without real time: the test serves within the window, " + "advances the injected clock past expiry, and asserts the next access re-fetches." + ), + ), + "caching:freshness:no-background-polling": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/utilities/caching#freshness-calculation", + behavior=( + "The client does not treat TTL as a polling interval: expiry alone triggers no " + "automatic background re-fetch; freshness is checked on access." + ), + added_in="2026-07-28", + deferred=( + "Not yet covered here: the SEP-2549 response cache landed and its TTL machinery checks " + "freshness only inside the access path -- src/mcp/client/caching.py spawns no tasks -- " + "so the negative is no longer vacuous: the test advances the injected clock past expiry " + "with no access, asserts the handler saw no fetch, then accesses once and sees exactly " + "one." + ), + ), + "caching:key:no-cross-key-serve": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/utilities/caching#cache-key", + behavior=( + "A cached response is keyed by method plus result-affecting parameters; it is never " + "served for a request whose method or parameters differ." + ), + added_in="2026-07-28", + deferred=( + "Not yet covered here: the SEP-2549 response cache landed and serves hits keyed by " + "method plus the uri for resources/read (src/mcp/client/caching.py), giving cross-key " + "discipline its positive half: the test reads two hinted URIs and asserts each serves " + "only its own entry. For the list verbs the cursor never enters the key -- cursored " + "calls skip the cache entirely (src/mcp/client/client.py) -- so the MUST NOT is " + "satisfied by non-participation, not by a cursor-bearing key." + ), + ), + "caching:key:mrtr-retry-not-cached": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/utilities/caching#cache-key", + behavior=( + "Results produced by an MRTR retry -- a request carrying inputResponses or " + "requestState -- are never cached: they depend on inputs outside the cache key." + ), + added_in="2026-07-28", + deferred=( + "Not yet covered here: the SEP-2549 response cache landed and implements the MUST " + "directly -- seeded reads skip the cache, an interim input_required result is never " + "stored, and a terminal result reached through driver rounds is never stored " + "(src/mcp/client/client.py) -- so the refusal is now distinguishable from " + "never-caching: the test pairs a plain hinted read (one fetch, then served) with an " + "input_required exchange whose full round-trip re-runs on every access." + ), + ), + "caching:notification:invalidates-fresh-cache": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/utilities/caching#interaction-with-notifications", + behavior=( + "A relevant notification received while a cached response is still fresh invalidates " + "it: the entry becomes immediately stale regardless of remaining TTL." + ), + added_in="2026-07-28", + deferred=( + "Not implemented in the SDK: the eviction half exists -- the SEP-2549 response cache " + "maps list_changed and resources/updated notifications to evictions via a " + "message-handler wrap (src/mcp/client/caching.py, src/mcp/client/client.py) -- but at " + "2026-07-28 no delivery vehicle can feed it: unsolicited list_changed is retired " + "(SEP-2575), delivery rides subscriptions/listen, and nothing in src/mcp/client/ " + "issues a subscriptions/listen request -- the typed listen client driver is still " + "missing." + ), + ), + "caching:pagination:per-page-independent": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/utilities/caching#interaction-with-pagination", + behavior=( + "Each page of a paginated list is an independently cacheable response: each carries " + "its own ttlMs, and each page's freshness clock starts at its own receipt time." + ), + added_in="2026-07-28", + deferred=( + "Not implemented in the SDK: the SEP-2549 response cache deliberately caches only the " + "cursor-less first page -- continuation pages skip it entirely " + "(src/mcp/client/client.py) -- so there are no per-page receipt times or independent " + "expiries to observe. The carriage half (each page carries its own ttlMs, set per " + "handler invocation) is expressible today and can be split out if wanted." + ), + ), + "caching:pagination:expired-page-refetch-by-cursor": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/utilities/caching#interaction-with-pagination", + behavior=( + "When one cached page expires, the client re-fetches that page by its cursor; fresh " + "sibling pages are not re-fetched." + ), + added_in="2026-07-28", + deferred=( + "Not implemented in the SDK: the SEP-2549 response cache keeps no per-page ttlMs " + "bookkeeping (pages after the first never enter it, src/mcp/client/client.py) and runs " + "no autonomous re-fetch loop -- freshness is checked only on access -- so the spec " + "scenario of one expired page re-fetched by cursor while fresh siblings stand remains " + "unconstructible." + ), + ), + "caching:pagination:invalid-cursor-discards-all": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/utilities/caching#interaction-with-pagination", + behavior=( + "When a previously valid cursor starts erroring, the client discards all cached " + "pages and re-fetches the list from the beginning." + ), + added_in="2026-07-28", + deferred=( + "Not yet covered here: the SEP-2549 response cache landed and implements the reaction " + "-- an INVALID_PARAMS rejection of a cursored list call evicts the method's cached " + "entry (src/mcp/client/client.py) -- so the test primes the cursor-less entry, has a " + "cursored call rejected with -32602 (the surfacing itself stays pinned by " + "pagination:invalid-cursor), and asserts the next cursor-less access re-fetches. Only " + "the cursor-less first page is ever cached, so 'all cached pages' is that single " + "entry, and the re-fetch happens on next access, not autonomously." + ), + ), + "caching:scope:private-not-shared-across-auth-contexts": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/utilities/caching#cache-scope-field", + behavior=( + "A private-scoped cache is never shared across authorization contexts: a different " + "access token requires a different cache." + ), + added_in="2026-07-28", + deferred=( + "Not yet covered here: the SEP-2549 response cache landed with per-authorization-" + "context partitions (CacheConfig partition, src/mcp/client/caching.py; private entries " + "are always partition-scoped), so the test shares one custom store between two Clients " + "with different partitions and asserts a private-scoped entry served to one is never " + "served to the other. The partition is caller-supplied -- the SDK does not derive it " + "from the access token." ), ), # ═══════════════════════════════════════════════════════════════════════════ @@ -2404,9 +4907,9 @@ def __post_init__(self) -> None: ), added_in="2026-07-28", note=( - "Known leniency: the monolith result surface still accepts an unknown tag when the payload " - "also parses as a complete core result (open result_type, extras ignored). Rejecting tags " - "outside core plus active claims is a tracked follow-up ruling." + "The lenient accept arm -- an unknown tag whose body still parses as a complete core result " + "surfaces unchanged -- is a recorded divergence owned by " + "protocol:result-type:unrecognized-invalid; this entry pins only the reject arm." ), ), "extensions:client:capability-ad:gates-server-behaviour": Requirement( @@ -2441,9 +4944,11 @@ def __post_init__(self) -> None: ), added_in="2026-07-28", deferred=( - "Covered at session tier by tests/client/test_session_notification_bindings.py: no public " - "server-side surface emits vendor-method notifications (ServerNotification is a closed union), " - "and HTTP-modern arrival additionally needs the subscriptions/listen client runtime." + "Not implemented in the SDK: no public server-side surface emits vendor-method " + "notifications (ServerNotification is a closed union, src/mcp-types/mcp_types/_types.py), " + "and HTTP-modern arrival additionally needs the subscriptions/listen client runtime; the " + "client-side binding half is covered at session tier by " + "tests/client/test_session_notification_bindings.py." ), ), # ═══════════════════════════════════════════════════════════════════════════ @@ -2476,8 +4981,7 @@ def __post_init__(self) -> None: "transport:streamable-http:notifications": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#streamable-http", behavior=( - "Notifications emitted during a request are delivered on that request's SSE stream and reach " - "the client's callbacks, in order, before the response." + "Notifications emitted during a request reach the client's callbacks over the streamable HTTP framing." ), transports=("streamable-http",), note="Only observable over streamable HTTP: per-request SSE streams are HTTP-specific.", @@ -2508,7 +5012,12 @@ def __post_init__(self) -> None: "A server-initiated request nested inside an in-flight call round-trips over stateful streamable HTTP." ), transports=("streamable-http",), - note="Only observable over streamable HTTP: exercises stateful HTTP session callbacks.", + removed_in="2026-07-28", + superseded_by="elicitation:mrtr:form:basic", + note=( + "removed in 2026-07-28 (SEP-2322); server-initiated requests are forbidden on streamable HTTP, " + "replaced by MRTR input requests embedded in InputRequiredResult." + ), ), "transport:streamable-http:resumability": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#streamable-http", @@ -2517,12 +5026,6 @@ def __post_init__(self) -> None: removed_in="2026-07-28", note="removed in 2026-07-28 (SEP-2575); Last-Event-ID resumability/redelivery dropped, no replacement.", ), - "transport:streamable-http:origin-validation": Requirement( - source=f"{SPEC_BASE_URL}/basic/transports#security-warning", - behavior="Requests with an invalid Origin header are rejected with 403 before reaching the session.", - transports=("streamable-http",), - note="Only observable over streamable HTTP: Origin is an HTTP header.", - ), "transport:sse": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#backwards-compatibility", behavior=( @@ -2664,15 +5167,55 @@ def __post_init__(self) -> None: transports=("streamable-http",), note="Stateless mode is a streamable-HTTP hosting option.", ), - "hosting:stateless:no-session-id": Requirement( - source="sdk", - behavior="In stateless mode no Mcp-Session-Id is emitted and no session validation is performed.", + "hosting:stateless:no-session-id": Requirement( + source="sdk", + behavior="In stateless mode no Mcp-Session-Id is emitted and no session validation is performed.", + transports=("streamable-http",), + note="Stateless mode is a streamable-HTTP hosting option; Mcp-Session-Id is an HTTP header.", + ), + # ═══════════════════════════════════════════════════════════════════════════ + # Hosting: auth + # ═══════════════════════════════════════════════════════════════════════════ + "hosting:auth:401-scope-hint": Requirement( + source=f"{SPEC_BASE_URL}/basic/authorization#protected-resource-metadata-discovery-requirements", + behavior=( + "The 401 WWW-Authenticate challenge includes a scope parameter (RFC 6750 Section 3) naming " + "the scopes required for the resource, giving clients scope guidance before authorization." + ), + transports=("streamable-http",), + note=( + "Auth is enforced at the HTTP layer; WWW-Authenticate is an HTTP header. The scope-less " + "401 the SDK emits today is pinned by hosting:auth:missing-401, whose Divergence records " + "this same SHOULD." + ), + deferred=( + "Not implemented in the SDK: the 401 challenge builder " + "(src/mcp/server/auth/middleware/bearer_auth.py) serializes only error, error_description, " + "and resource_metadata -- the middleware holds required_scopes but never emits a scope " + "parameter, and no public configuration can make it do so." + ), + ), + "hosting:auth:as-iss-emission": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/authorization#authorization-response-validation", + behavior=( + "The bundled authorization server appends the RFC 9207 iss parameter to every " + "authorization redirect -- success and error -- and advertises " + "authorization_response_iss_parameter_supported in its metadata." + ), + added_in="2026-07-28", transports=("streamable-http",), - note="Stateless mode is a streamable-HTTP hosting option; Mcp-Session-Id is an HTTP header.", + note=( + "Auth is enforced at the HTTP layer; the bundled AS is an ASGI app. The suite's " + "client-side iss tests run against the in-process test provider, which stamps iss itself " + "precisely because the SDK handler does not." + ), + deferred=( + "Not implemented in the SDK: the bundled authorize handler " + "(src/mcp/server/auth/handlers/authorize.py) builds success and error redirects without an " + "iss parameter, and build_metadata (src/mcp/server/auth/routes.py) never sets " + "authorization_response_iss_parameter_supported." + ), ), - # ═══════════════════════════════════════════════════════════════════════════ - # Hosting: auth - # ═══════════════════════════════════════════════════════════════════════════ "hosting:auth:as-router": Requirement( source="sdk", behavior=( @@ -2718,6 +5261,22 @@ def __post_init__(self) -> None: note="The challenge carries no `scope` parameter; see the note on hosting:auth:missing-401.", ), ), + "hosting:auth:malformed-request-400": Requirement( + source=f"{SPEC_BASE_URL}/basic/authorization#error-handling", + behavior="A malformed authorization request to the protected resource is answered with HTTP 400.", + transports=("streamable-http",), + note=( + "Auth is enforced at the HTTP layer; 400 is an HTTP status code. The 401 collapse the SDK " + "performs instead is pinned by hosting:auth:invalid-401 and hosting:auth:query-token-ignored." + ), + deferred=( + "Not implemented in the SDK: the protected-resource gate has no 400 path -- " + "BearerAuthBackend.authenticate (src/mcp/server/auth/middleware/bearer_auth.py) returns " + "None for every malformed Authorization presentation and the middleware maps that to a 401 " + "invalid_token challenge; the only statuses the gate can emit are 401 and 403, so the " + "RFC 6750 invalid_request 400 case cannot be produced." + ), + ), "hosting:auth:metadata-endpoints": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-location", behavior=( @@ -2779,6 +5338,46 @@ def __post_init__(self) -> None: ), ), ), + "hosting:auth:scope-403:all-scopes": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/authorization#runtime-insufficient-scope-errors", + behavior=( + "When a token is missing more than one required scope, the single 403 challenge " + "names all scopes required for the operation, so the client can step up in one " + "authorization round instead of being challenged incrementally." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note="Auth is enforced at the HTTP layer; 403 is an HTTP status code.", + divergence=Divergence( + note=( + "The bearer middleware checks required scopes in order and 403s on the first " + "missing one (server/auth/middleware/bearer_auth.py), naming only that scope " + "in error_description and emitting no scope parameter at all (the sibling " + "hosting:auth:scope-403 divergence) -- a client missing several scopes is " + "challenged one scope per round trip." + ), + issue="L118", + ), + ), + "hosting:auth:scope:no-offline-access": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/authorization#refresh-tokens", + behavior=( + "The protected resource does not include offline_access in its WWW-Authenticate scope or " + "in Protected Resource Metadata scopes_supported -- refresh tokens are not a resource " + "requirement." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note="Auth is enforced at the HTTP layer; both surfaces are HTTP documents.", + deferred=( + "Not yet covered here: a server-deployment configuration rule with no SDK decision point " + "-- PRM scopes_supported is the integrator's AuthSettings.required_scopes passed through " + "verbatim (src/mcp/server/auth/routes.py) and the SDK never emits a WWW-Authenticate scope " + "parameter at all (src/mcp/server/auth/middleware/bearer_auth.py), so an in-suite " + "assertion would either restate the test's own configuration or assert an unobservable " + "negative." + ), + ), "hosting:auth:as:authorize-requires-pkce": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#authorization-code-protection", behavior=( @@ -2845,6 +5444,35 @@ def __post_init__(self) -> None: transports=("streamable-http",), note="Auth is enforced at the HTTP layer; Cache-Control is an HTTP header.", ), + "hosting:auth:as:register-echo-application-type": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/authorization/client-registration#dynamic-client-registration", + behavior=( + "The bundled registration endpoint echoes the registered application_type back in " + "the RFC 7591 registration response (the response contains all registered " + "metadata about the client)." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "Auth is enforced at the HTTP layer; the bundled AS is an ASGI app. RFC 7591 " + "section 3.2.1 is incorporated via the spec's Dynamic Client Registration " + "section. The SDK OAuth client adopts the echo into its persisted client_info, " + "so the dropped field also corrupts client-side storage (a web client reads back " + "native) -- which is why the client-side app-type-override test deliberately " + "does not assert the echo today; when the fix lands, add the echo assertion " + "there and re-pin here." + ), + divergence=Divergence( + note=( + "The registration handler's passthrough copies the metadata field-by-field " + "(server/auth/handlers/register.py) and omits application_type, so the model " + "default fills the echo: a client registering application_type='web' is told " + "'native'. RFC 7591 section 3.2.1 requires the response to reflect the " + "registered metadata." + ), + issue="L114", + ), + ), "hosting:auth:as:register-error-response": Requirement( source="sdk", behavior=( @@ -2854,6 +5482,96 @@ def __post_init__(self) -> None: transports=("streamable-http",), note="Auth is enforced at the HTTP layer; the bundled AS is an ASGI app.", ), + "hosting:auth:as:cimd-client-id": Requirement( + source=f"{SPEC_BASE_URL}/basic/authorization#client-id-metadata-documents", + behavior=( + "The bundled authorization server supports clients using Client ID Metadata Documents: a " + "URL-formatted client_id is accepted end-to-end through the authorize flow by resolving " + "the metadata document instead of requiring registration." + ), + transports=("streamable-http",), + note=( + "Auth is enforced at the HTTP layer; the bundled AS is an ASGI app. The client half is " + "pinned by client-auth:cimd; the suite's CIMD tests shim the AS metadata and pre-seed the " + "provider because the bundled AS has no CIMD-aware client lookup of its own." + ), + deferred=( + "Not implemented in the SDK: the bundled authorization server has no Client ID Metadata " + "Document support -- no handler resolves a URL-formatted client_id (no document fetch, " + "validation, or client-info synthesis); client lookup is exclusively provider.get_client() " + "against the registration store (src/mcp/server/auth/middleware/client_auth.py)." + ), + ), + "hosting:auth:as:cimd-supported-flag": Requirement( + source=f"{SPEC_BASE_URL}/basic/authorization#discovery", + behavior=( + "The bundled authorization server advertises CIMD support by setting " + "client_id_metadata_document_supported in its RFC 8414 metadata." + ), + transports=("streamable-http",), + note="Auth is enforced at the HTTP layer; the metadata document is served over HTTP.", + deferred=( + "Not implemented in the SDK: build_metadata (src/mcp/server/auth/routes.py) never sets " + "client_id_metadata_document_supported -- the field exists only on the shared model " + "(src/mcp/shared/auth.py) for the client's parsing of a remote AS, and the suite shims the " + "flag into the in-process AS metadata for the client-side CIMD tests." + ), + ), + "hosting:auth:as:cimd:cache-respects-headers": Requirement( + source=f"{SPEC_BASE_URL}/basic/authorization#implementation-requirements", + behavior=("The authorization server caches fetched client metadata documents respecting HTTP cache headers."), + transports=("streamable-http",), + note="Auth is enforced at the HTTP layer; caching is an HTTP-header behaviour.", + deferred=( + "Not implemented in the SDK: there is no CIMD fetch to cache -- the bundled authorization " + "server never retrieves client metadata documents (see hosting:auth:as:cimd-client-id)." + ), + ), + "hosting:auth:as:cimd:client-id-matches-url": Requirement( + source=f"{SPEC_BASE_URL}/basic/authorization#implementation-requirements", + behavior=( + "The authorization server validates that a fetched metadata document's client_id matches " + "the document URL exactly, rejecting the authorization request otherwise." + ), + transports=("streamable-http",), + note="Auth is enforced at the HTTP layer; the bundled AS is an ASGI app.", + deferred=( + "Not implemented in the SDK: there is no CIMD fetch whose result could be validated -- the " + "bundled authorization server never retrieves client metadata documents (see " + "hosting:auth:as:cimd-client-id)." + ), + ), + "hosting:auth:as:cimd:document-validation": Requirement( + source=f"{SPEC_BASE_URL}/basic/authorization#implementation-requirements", + behavior=( + "The authorization server validates that a fetched client metadata document is valid JSON " + "and contains the required fields before accepting it." + ), + transports=("streamable-http",), + note=( + "Auth is enforced at the HTTP layer; the bundled AS is an ASGI app. Distinct from " + "hosting:auth:as:register-error-response, which is the DCR registration POST." + ), + deferred=( + "Not implemented in the SDK: there is no CIMD fetch whose result could be validated -- the " + "bundled authorization server never retrieves client metadata documents (see " + "hosting:auth:as:cimd-client-id)." + ), + ), + "hosting:auth:as:cimd:fetch-on-url-client-id": Requirement( + source=f"{SPEC_BASE_URL}/basic/authorization#implementation-requirements", + behavior=( + "The authorization server fetches the client metadata document when it encounters a " + "URL-formatted client_id." + ), + transports=("streamable-http",), + note="Auth is enforced at the HTTP layer; the bundled AS is an ASGI app.", + deferred=( + "Not implemented in the SDK: no handler detects a URL-shaped client_id or fetches " + "anything -- client lookup is exclusively provider.get_client() against the registration " + "store (src/mcp/server/auth/middleware/client_auth.py; see hosting:auth:as:cimd-client-id)." + ), + ), # ═══════════════════════════════════════════════════════════════════════════ # Hosting: resumability # ═══════════════════════════════════════════════════════════════════════════ @@ -2966,6 +5684,7 @@ def __post_init__(self) -> None: ), transports=("streamable-http",), removed_in="2026-07-28", + superseded_by="hosting:http:modern:disconnect-cancels-handler", note=( "removed in 2026-07-28 (SEP-2575); resumability dropped and the rule is inverted (closing the response " "stream is now the HTTP cancellation signal), no replacement." @@ -3092,8 +5811,9 @@ def __post_init__(self) -> None: "hosting:http:protocol-version-rejection-literal": Requirement( source="sdk", behavior=( - "The legacy streamable-HTTP transport's version-rejection body contains the literal substring " - "'Unsupported protocol version', which other-SDK clients substring-match during negotiation." + "The streamable-HTTP version-rejection body contains the literal substring 'Unsupported " + "protocol version', which other-SDK clients substring-match during negotiation; the modern " + "request classifier is its only emission site." ), transports=("streamable-http",), note=( @@ -3135,7 +5855,7 @@ def __post_init__(self) -> None: ), "hosting:http:modern:initialize-removed": Requirement( source=f"{SPEC_2026_BASE_URL}/basic/index", - behavior="A 2026-07-28 initialize request is answered with METHOD_NOT_FOUND.", + behavior="A 2026-07-28 initialize request is answered with METHOD_NOT_FOUND at HTTP 404.", added_in="2026-07-28", transports=("streamable-http",), note=("Only observable over streamable HTTP: the modern entry's method registry omits initialize."), @@ -3143,9 +5863,10 @@ def __post_init__(self) -> None: "hosting:http:modern:legacy-fallthrough": Requirement( source=f"{SPEC_2026_BASE_URL}/basic/versioning", behavior=( - "Non-2026-07-28 traffic on the same /mcp endpoint reaches the legacy transport " - "byte-unchanged: a 2025-era initialize handshake still completes, and an unrecognised " - "MCP-Protocol-Version header still produces the legacy 400 'Unsupported protocol version' literal." + "Initialize-handshake-era traffic on the same /mcp endpoint reaches the legacy transport " + "byte-unchanged: a 2025-era initialize handshake still completes. Any other " + "MCP-Protocol-Version header routes to the modern entry, whose validation ladder rejects " + "the envelope-less request with 400 INVALID_PARAMS." ), added_in="2026-07-28", transports=("streamable-http",), @@ -3208,6 +5929,356 @@ def __post_init__(self) -> None: transports=("streamable-http",), note="Only observable over streamable HTTP: the modern entry's JSONRPCError-to-HTTP-status mapping.", ), + "hosting:http:modern:disconnect-cancels-handler": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/cancellation#transport-specific-cancellation", + behavior=( + "On a 2026-07-28 streamable HTTP request, the client closing the SSE response stream is " + "treated by the server as cancellation: the running handler is stopped and no JSON-RPC " + "response is written." + ), + added_in="2026-07-28", + transports=("streamable-http",), + supersedes=("hosting:http:disconnect-not-cancel",), + note="Only observable over streamable HTTP: stream closure is the transport-level cancellation signal.", + ), + "hosting:http:modern:cacheable-stamping": Requirement( + source=f"{SPEC_2026_BASE_URL}/server/utilities/caching#cacheable-results", + behavior=( + "A 2026-07-28 cacheable result (tools/list, resources/list, resources/read, ...) reaches " + "the wire as resultType complete plus the required ttlMs and cacheScope hints: " + "handler-authored values pass through unchanged, and a result whose handler set neither " + "is stamped with the defaults ttlMs 0 / cacheScope private." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "Only observable over streamable HTTP: typed client models default-fill ttl_ms/cache_scope, " + "so absent-vs-stamped is a wire fact. The spec mandates the hints' presence and ttlMs >= 0; " + "the 0/private default fill is the SDK's choice (CacheableResult defaults). Python has no " + "operation-level cache-hint configuration (the TS createMcpHandler cacheHints precedence " + "ladder); hints are authored per-result by the handler." + ), + ), + "hosting:http:modern:json-response-mode": Requirement( + source="sdk", + behavior=( + "With JSON response mode enabled, a 2026-07-28 request is answered with a single " + "application/json body carrying only the terminal JSON-RPC response; request-scoped " + "notifications emitted mid-call are dropped, not buffered." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "Only observable over streamable HTTP: response Content-Type and body framing are " + "HTTP-specific. 2025-era sibling: hosting:http:json-response-mode. TS twin " + "(typescript:hosting:entry:modern-response-mode) also has a forced-SSE response mode " + "python does not implement: there is no responseMode equivalent, the SDK knob is the " + "boolean json_response." + ), + ), + "hosting:http:modern:lazy-sse-upgrade": Requirement( + source="sdk", + behavior=( + "On the default response mode, a 2026-07-28 exchange is answered as a single " + "application/json body when the handler emits nothing before its result, and upgrades to " + "text/event-stream when the handler emits request-scoped notifications mid-call: the " + "frames carry the notifications in emission order with the terminal response as the last " + "frame." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "Only observable over streamable HTTP: the Content-Type commit is the assertion. The " + "deferral window before a silent handler commits SSE anyway (_SSE_PING_INTERVAL) is not " + "pinned: asserting it would need a real-time wait the suite refuses." + ), + ), + "hosting:http:modern:response-stream-request-scoped": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#receiving-messages", + behavior=( + "Notifications on a 2026-07-28 SSE response stream relate to the originating client " + "request: a notification emitted while serving request A travels only on A's response " + "stream and never appears on another in-flight request's response." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "Only observable over streamable HTTP: which stream a message travels on is the assertion. " + "Request-scoping is by construction on the modern entry (per-request sink); the test pins " + "the observable consequence." + ), + ), + "hosting:http:sse-x-accel-buffering": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#receiving-messages", + behavior=( + "When a 2026-07-28 response commits to an SSE stream, the response carries " + "X-Accel-Buffering: no so reverse proxies deliver events unbuffered." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "Only observable over streamable HTTP: a response header is the assertion. Scoped to the " + "modern entry (the SHOULD is new on the draft transport page); the legacy 2025-era " + "SSE/streamable-http transports carry no such header and are not bound by this entry. The " + "other 2026 SSE-initiation point, subscriptions/listen, is not constructible at this pin." + ), + ), + "hosting:http:modern:header-name-case-insensitive": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#case-sensitivity", + behavior=( + "Standard request header names are matched case-insensitively: a 2026-07-28 POST whose " + "MCP-Protocol-Version / Mcp-Method / Mcp-Name headers arrive under any casing is served, " + "not rejected as missing a required header." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "Only observable over streamable HTTP. The in-process ASGI bridge lowercases header names " + "into the scope (as every conformant ASGI server must), so the discriminating claim pinned " + "end-to-end is that the server's lookups key on the lowercase canonical names " + "(shared/inbound.py constants) rather than any cased spelling." + ), + ), + "hosting:http:modern:missing-standard-header-rejected": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#server-validation", + behavior=( + "A 2026-07-28 request missing a required standard header -- Mcp-Method, or Mcp-Name on a " + "name-bearing method -- is rejected with HTTP 400 and JSON-RPC error -32020 HeaderMismatch." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "Only observable over streamable HTTP: the HTTP status is half the assertion. Narrowed to " + "the Mcp-Method / Mcp-Name arms: the MCP-Protocol-Version-missing arm belongs to the " + "deferred hosting:http:modern:missing-protocol-version-header-rejected (a header-less " + "request routes to the legacy transport; the rejecting modern-only posture is not " + "implemented). The SDK reaches the rejection through its mismatch rung (absent header != " + "body value), so the error message says 'does not match' rather than 'missing'." + ), + ), + "hosting:http:modern:missing-protocol-version-header-rejected": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#protocol-version-header", + behavior=( + "A server that does not support clients predating the MCP-Protocol-Version header " + "(pre-2025-06-18) rejects a request that omits the header with HTTP 400 and JSON-RPC " + "error -32020 HeaderMismatch." + ), + added_in="2026-07-28", + transports=("streamable-http",), + deferred=( + "Not implemented in the SDK: there is no modern-only server posture -- " + "StreamableHTTPSessionManager.handle_request (src/mcp/server/streamable_http_manager.py) " + "unconditionally routes a request without an MCP-Protocol-Version header to the legacy " + "2025 transport (seeded with DEFAULT_NEGOTIATED_VERSION) instead of rejecting it, and the " + "manager exposes no option to declare pre-2025-06-18 clients unsupported, so the " + "rejecting arm is unconstructible." + ), + note=( + "Only observable over streamable HTTP: MCP-Protocol-Version is an HTTP header. The " + "implemented MAY arm (a header-less request is served as 2025-era traffic) is pinned by " + "hosting:http:protocol-version-default and hosting:http:modern:legacy-fallthrough." + ), + ), + "hosting:http:modern:protocol-version-meta-mismatch-400": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#protocol-version-header", + behavior=( + "A request whose MCP-Protocol-Version header and _meta protocolVersion envelope value are " + "both individually valid but disagree is rejected with HTTP 400 and JSON-RPC error -32020 " + "HeaderMismatch, before any supported-version check." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "Only observable over streamable HTTP: the SDK client derives header and envelope from one " + "value (_make_modern_stamp) and can never produce the mismatch, so only a raw POST drives it." + ), + ), + "hosting:http:modern:std-header-mismatch-400": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#server-validation", + behavior=( + "A 2026-07-28 request whose Mcp-Method or Mcp-Name header disagrees with the " + "corresponding request-body value is rejected with HTTP 400 and a HeaderMismatch " + "(-32020) JSON-RPC error." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "TS id: sep-2243:std-header:mismatch-rejected. Scope boundary: present-but-" + "disagreeing Mcp-Method/Mcp-Name only -- the MCP-Protocol-Version mismatch is " + "hosting:http:modern:protocol-version-meta-mismatch-400 and the missing-header " + "conditions are hosting:http:modern:missing-standard-header-rejected." + ), + ), + "hosting:http:modern:sentinel-decoded-before-validation": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#value-encoding", + behavior=( + "A base64-sentinel-encoded Mcp-Name header value is decoded before server validation " + "compares it to the request body value, so an encoded-but-decode-matching value is served " + "rather than rejected with HeaderMismatch." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "Only observable over streamable HTTP: header encoding never surfaces through the client " + "API. Both members of the spec's 'Mcp-Name or Mcp-Param-{Name}' pair are now " + "server-validated: the Mcp-Param decode leg runs live inside validate_mcp_param_headers " + "(src/mcp/shared/inbound.py), with strictly canonical base64 on both rungs; its reject " + "arm is tracked by hosting:http:modern:invalid-header-chars-rejected. The tests here pin " + "the Mcp-Name rung." + ), + ), + "hosting:http:modern:mcp-param-null-absent-not-required": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#server-behavior-for-custom-headers", + behavior=( + "A 2026-07-28 tools/call whose annotated arguments are null or absent carries no " + "Mcp-Param-* header for them, and the server accepts the request without expecting one." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "Only observable over streamable HTTP. The acceptance arm is a real validation pass: " + "validate_mcp_param_headers (src/mcp/shared/inbound.py) resolves the tool's advertised " + "schema and verifies that null or absent annotated arguments carry no header (an orphan " + "header for such an argument is actively rejected) before the call is served." + ), + ), + "hosting:http:modern:mcp-param-mismatch-400": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#server-behavior-for-custom-headers", + behavior=( + "A 2026-07-28 tools/call whose decoded Mcp-Param-{Name} header value does not match " + "the corresponding body argument is rejected with HTTP 400 and JSON-RPC -32020 " + "(HeaderMismatch)." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "TS implements this (createMcpHandler) with no requirement id of its own. When the " + "server registers no tools/list handler the validation deliberately fails open and " + "the call is served." + ), + ), + "hosting:http:modern:invalid-header-chars-rejected": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#server-behavior-for-custom-headers", + behavior=( + "A 2026-07-28 request carrying a recognized Mcp-Param-{Name} header that contains " + "invalid characters is rejected with HTTP 400 and JSON-RPC error -32020 HeaderMismatch." + ), + added_in="2026-07-28", + transports=("streamable-http",), + deferred=( + "Not yet covered here: server-side Mcp-Param validation landed -- " + "validate_mcp_param_headers (src/mcp/shared/inbound.py) recognizes headers via the " + "tool's advertised x-mcp-header annotations and rejects with -32020. The SDK has no " + "literal character-class check on raw header values (HTTP itself constrains what is " + "transmittable): 'invalid characters' surfaces as the malformed-base64-sentinel " + "rejection or a plain value mismatch. The test sends a recognized Mcp-Param header " + "carrying a malformed sentinel over raw modern HTTP and asserts the 400/-32020 " + "rejection." + ), + note=("Only observable over streamable HTTP: Mcp-Param-* are HTTP request headers."), + ), + "hosting:http:modern:numeric-header-comparison": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#server-validation", + behavior=( + "When validating integer parameter values against Mcp-Param-{Name} headers, the server " + "compares the header value and the body value numerically rather than as strings " + "(42.0 and 42 are considered equal)." + ), + added_in="2026-07-28", + transports=("streamable-http",), + deferred=( + "Not yet covered here: server-side Mcp-Param validation landed and implements the " + "numeric SHOULD -- _mcp_param_value_matches (src/mcp/shared/inbound.py) compares " + "integer-typed declarations numerically for canonical-decimal headers (42.0 matches " + "42; the non-canonical 1e2 deliberately does not match 100). The test drives an " + "integer-annotated tool over raw modern HTTP, pinning the 42.0-vs-42 accept and a " + "mismatch reject." + ), + note=( + "Only observable over streamable HTTP: the comparison's input is an HTTP request header. " + "The SHOULD is the lenient arm of the Mcp-Param header-vs-body comparison pinned by " + "hosting:http:modern:mcp-param-mismatch-400." + ), + ), + "hosting:http:request-headers-in-handler": Requirement( + source="sdk", + behavior=( + "A custom HTTP header sent by the client reaches the request handler through the " + "per-request HTTP request context (ctx.request), on both the legacy session path and the " + "2026-07-28 single-exchange path." + ), + transports=("streamable-http",), + note=( + "Only observable over streamable HTTP: stdio has no HTTP request context. No added_in: the " + "behaviour exists on both eras. Carries phase-4 FINDING F3: the un-minted twin proposal " + "hosting:context:web-request-headers describes the same observable; this python-neutral id " + "is the recommended survivor of that merge." + ), + ), + "hosting:http:modern:get-delete-405": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#earlier-streamable-http-revisions", + behavior=( + "A server that supports only 2026-07-28 answers GET or DELETE to the MCP endpoint with 405 " + "Method Not Allowed, ignoring Mcp-Session-Id and Last-Event-ID." + ), + added_in="2026-07-28", + transports=("streamable-http",), + deferred=( + "Not implemented in the SDK: the modern-only posture the SHOULD is conditioned on does not " + "exist -- StreamableHTTPSessionManager.handle_request " + "(src/mcp/server/streamable_http_manager.py) unconditionally serves both eras at one " + "endpoint with no option to refuse legacy traffic, so GET and DELETE are always handled by " + "the legacy session machinery." + ), + note=( + "Same missing posture as hosting:http:modern-only:initialize-rejection-names-versions. " + "Distinct from the 2025-era unofficial-stateless 405 behaviour (a separate pre-existing " + "proposal, not yet a manifest entry)." + ), + ), + "hosting:http:modern:notification-post": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#sending-messages", + behavior=( + "A POST to the modern entry whose body is a notification (no id) is acknowledged without a " + "JSON-RPC response: 202 Accepted with an empty body, or the explicit cannot-accept " + "rejection -- the transport's two sanctioned responses." + ), + added_in="2026-07-28", + transports=("streamable-http",), + deferred=( + "Not yet covered here: which sanctioned response the modern entry gives is an explicitly " + "unmade design choice -- the cannot-accept branch it takes today carries the in-code TODO " + "recording strict-vs-lenient as open (src/mcp/server/_streamable_http_modern.py) -- so " + "pinning the current rejection would manufacture churn when the choice lands." + ), + note=( + "Only observable over streamable HTTP. The legacy path's 202 for notification POSTs is " + "pinned by hosting:http:notifications-202." + ), + ), + "hosting:http:modern-only:initialize-rejection-names-versions": Requirement( + source="sdk", + behavior=( + "A server configured to serve only modern protocol revisions rejects a 2025-shaped " + "initialize with the unsupported-protocol-version error naming its supported modern " + "revisions in error.data.supported, instead of silently serving the 2025 era." + ), + added_in="2026-07-28", + transports=("streamable-http",), + deferred=( + "Not implemented in the SDK: there is no strict/modern-only hosting posture -- " + "StreamableHTTPSessionManager.handle_request unconditionally routes " + "initialize-handshake-era traffic (and any request without an MCP-Protocol-Version " + "header) to the legacy transport, and the manager exposes no option to refuse it, so the " + "strict rejection is unconstructible." + ), + note=( + "TS twin: typescript:hosting:entry:strict-rejects-legacy (createMcpHandler legacy: " + "'reject'). The adjacent implemented behaviour -- an envelope whose protocolVersion is " + "unsupported gets UNSUPPORTED_PROTOCOL_VERSION with data.supported -- is the classifier's " + "rung 3 and is owned by the discover-versioning family, not this entry." + ), + ), # ═══════════════════════════════════════════════════════════════════════════ # Client transport: streamable HTTP # ═══════════════════════════════════════════════════════════════════════════ @@ -3264,10 +6335,7 @@ def __post_init__(self) -> None: ), "client-transport:http:custom-client": Requirement( source="sdk", - behavior=( - "A caller-supplied HTTP client (and its event hooks and headers) is used for all MCP traffic, " - "including auth flows." - ), + behavior="A caller-supplied HTTP client (and its event hooks and headers) is used for all MCP traffic.", transports=("streamable-http",), note="Only observable over HTTP: the httpx client is HTTP-specific.", ), @@ -3304,14 +6372,6 @@ def __post_init__(self) -> None: transports=("streamable-http",), note="Only observable over HTTP: MCP-Protocol-Version is an HTTP header.", ), - "client-transport:http:protocol-version-stored": Requirement( - source="sdk", - behavior=( - "The client transport stores the negotiated protocol version and sends it on every subsequent request." - ), - transports=("streamable-http",), - note="Only observable over HTTP: MCP-Protocol-Version is an HTTP header.", - ), "client-transport:http:reconnect-get": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#resumability-and-redelivery", behavior=( @@ -3374,6 +6434,22 @@ def __post_init__(self) -> None: "POST." ), ), + "client-transport:http:sse-comment-line-ignored": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#receiving-messages", + behavior=( + "SSE comment lines (lines beginning with a colon, e.g. ': keep-alive') interleaved " + "into a response stream carry no event data and are ignored: the requests on that " + "stream complete normally." + ), + transports=("streamable-http",), + note=( + "Stated as a Note on the 2026-07-28 streamable-http page (normative by incorporation " + "of the WHATWG SSE specification, which has always required comment tolerance of any " + "SSE consumer -- not era-gated); the page pairs it with telling servers to emit " + "':' keep-alives on long-lived streams, so intolerance would break against conformant " + "servers. Only observable over streamable HTTP: the property is SSE framing." + ), + ), "client-transport:http:terminate-405-ok": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#session-management", behavior="Session termination succeeds without error if the server answers 405 (termination unsupported).", @@ -3382,7 +6458,7 @@ def __post_init__(self) -> None: note="removed in 2026-07-28 (SEP-2567); session DELETE removed with Mcp-Session-Id, no replacement.", ), "client-transport:http:body-derived-headers": Requirement( - source=f"{SPEC_2026_BASE_URL}/basic/transports#stateless-request-headers", + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#standard-request-headers", behavior=( "An envelope-bearing request body yields MCP-Protocol-Version, Mcp-Method, and (for tools/call) " "Mcp-Name headers on the outgoing HTTP request; a body without the envelope yields none." @@ -3391,6 +6467,17 @@ def __post_init__(self) -> None: transports=("streamable-http",), note="Only observable over streamable HTTP: headers are derived from the body envelope at the transport seam.", ), + "client-transport:http:mcp-name-base64-sentinel": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#standard-request-headers", + behavior=( + "A tools/call for a tool whose name is not header-safe carries the Mcp-Name header " + "in the =?base64?...?= sentinel form while the body keeps the literal name, and the " + "round trip completes." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note="Only observable over streamable HTTP: the header is derived at the transport seam.", + ), "client-transport:http:custom-param-headers": Requirement( source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#custom-headers-from-tool-parameters", behavior=( @@ -3404,6 +6491,36 @@ def __post_init__(self) -> None: transports=("streamable-http",), note="Only observable over streamable HTTP: headers are derived from the cached tool schema at the seam.", ), + "client-transport:http:custom-param-headers:sentinel-collision-escaped": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#value-encoding", + behavior=( + "A plain-ASCII argument value that itself matches the =?base64?...?= sentinel " + "pattern is base64-wrapped when mirrored into its Mcp-Param-* header, while the " + "body keeps the literal value." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note="Only observable over streamable HTTP: headers are derived from the cached tool schema at the seam.", + ), + "client-transport:http:custom-param-headers:refresh-and-retry-on-reject": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#client-behavior", + behavior=( + "When a server rejects a tools/call because required custom Mcp-Param-* headers " + "are missing, the client refetches tools/list to obtain the current inputSchema " + "and retries the original request with the appropriate headers." + ), + added_in="2026-07-28", + transports=("streamable-http",), + deferred=( + "Not implemented in the SDK: the client has no recovery path for a header-rejection " + "error -- call_tool issues a single request and raises the JSON-RPC error to the " + "caller; no handler refetches tools/list and retries with the appropriate headers." + ), + note=( + "Only observable over streamable HTTP: the trigger is an HTTP-layer HeaderMismatch " + "rejection and the retried request's Mcp-Param-* headers are wire artifacts." + ), + ), "client-transport:http:vendor-name-param-header": Requirement( source="sdk", behavior=( @@ -3418,7 +6535,7 @@ def __post_init__(self) -> None: ), ), "client-transport:http:stateless-ignores-session-id": Requirement( - source=f"{SPEC_2026_BASE_URL}/basic/transports#stateless-request-headers", + source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#standard-request-headers", behavior=( "A pinned client never echoes a server-issued Mcp-Session-Id and never opens the standalone " "GET stream or the closing DELETE: the recorded wire is POST-only." @@ -3428,6 +6545,70 @@ def __post_init__(self) -> None: note="Only observable over streamable HTTP: session-id, GET stream and DELETE are streamable-HTTP mechanics.", deferred="defensive against a misbehaving peer; covered by a tests/client/ unit test", ), + "client-transport:http:body-stream-error-preserved": Requirement( + source="sdk", + behavior=( + "When the SSE response body stream errors mid-read, the failure surfaces to the caller " + "preserving the original exception (as the instance or its cause), not a " + "string-interpolated wrapper that discards its type." + ), + transports=("streamable-http",), + note="Only observable over streamable HTTP: the SSE response body stream is an HTTP mechanism.", + deferred=( + "Not implemented in the SDK: the client transport has no error callback and no " + "error-preservation contract -- read failures inside the SSE loops of " + "src/mcp/client/streamable_http.py are logged or trigger reconnection, with nothing " + "delivering the original exception to caller code." + ), + ), + "client-transport:http:error-status-code": Requirement( + source="sdk", + behavior=( + "An error produced by a non-OK HTTP response carries the originating HTTP status code so " + "callers can branch on 401/403/404." + ), + transports=("streamable-http",), + deferred=( + "Not implemented in the SDK: a non-2xx response without a JSON-RPC body is surfaced as a " + "synthesized INTERNAL_ERROR ('Server returned an error response') that carries no status " + "attribute (src/mcp/client/streamable_http.py), so no typed status-bearing error exists " + "to pin." + ), + note=( + "The testable weak sibling -- a non-2xx surfaces as an error at all rather than hanging -- " + "is a separate pre-existing proposal (client-transport:http:non-2xx-surfaces), not this " + "entry." + ), + ), + "client-transport:http:reconnect-failure-onerror": Requirement( + source="sdk", + behavior=( + "When the standalone SSE stream drops and automatic reconnection ultimately fails, the " + "failure is delivered to an error callback rather than thrown from an unrelated request or " + "silently swallowed." + ), + transports=("streamable-http",), + note="Only observable over streamable HTTP: SSE reconnection is an HTTP transport mechanism.", + deferred=( + "Not implemented in the SDK: there is no transport error callback -- exhausting " + "MAX_RECONNECTION_ATTEMPTS on the GET stream ends with a debug log inside " + "src/mcp/client/streamable_http.py and nothing is delivered to caller code." + ), + ), + "client-transport:http:session-id-preconfigured": Requirement( + source="sdk", + behavior=( + "A session id supplied at transport construction is sent as Mcp-Session-Id from the first " + "request onwards, letting a client resume a known session." + ), + transports=("streamable-http",), + note="Only observable over streamable HTTP: Mcp-Session-Id is an HTTP header mechanism.", + deferred=( + "Not implemented in the SDK: StreamableHTTPTransport.__init__ takes only the url -- " + "session_id starts as None and is only ever adopted from a server response header " + "(src/mcp/client/streamable_http.py), so a pre-existing session id cannot be supplied." + ), + ), # ═══════════════════════════════════════════════════════════════════════════ # Client auth # ═══════════════════════════════════════════════════════════════════════════ @@ -3447,13 +6628,11 @@ def __post_init__(self) -> None: ), "client-auth:403-scope-upgrade": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#step-up-authorization-flow", - behavior=( - "A 403 with WWW-Authenticate triggers a scope-upgrade authorization attempt; repeated 403s do not loop." - ), + behavior="A 403 with WWW-Authenticate triggers a scope-upgrade authorization attempt.", transports=("streamable-http",), note="OAuth is HTTP-only.", ), - "client-auth:403-scope-union": Requirement( + "client-auth:stepup:scope-union": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#step-up-authorization-flow", behavior=( "On a 403 insufficient_scope step-up, the re-authorization request carries the union of the " @@ -3462,6 +6641,90 @@ def __post_init__(self) -> None: transports=("streamable-http",), note="OAuth is HTTP-only.", ), + "client-auth:stepup:retry-cap": Requirement( + source=f"{SPEC_BASE_URL}/basic/authorization#step-up-authorization-flow", + behavior=( + "Step-up re-authorization is bounded per request send: one re-authorization and one " + "retry, after which a further insufficient_scope 403 on the retried request " + "surfaces to the caller as an error without another authorization attempt." + ), + transports=("streamable-http",), + note=( + "OAuth is HTTP-only. The bound is structural -- the auth flow performs at most one " + "step-up before its generator ends -- not a configurable retry count; the surfaced " + "error is the transport's INTERNAL_ERROR stand-in for a non-2xx final response. " + "Cross-request attempt tracking is the separate deferred " + "client-auth:stepup:attempt-tracking." + ), + ), + "client-auth:stepup:get-stream-403": Requirement( + source=f"{SPEC_BASE_URL}/basic/authorization#step-up-authorization-flow", + behavior=( + "A 403 insufficient_scope challenge on the standalone GET stream open receives the " + "same step-up handling as the POST path: the scope union is re-authorized once and " + "the stream is established on the retried GET with the upgraded token." + ), + transports=("streamable-http",), + removed_in="2026-07-28", + note=( + "OAuth is HTTP-only. The standalone GET stream is a 2025-11-25 transport mechanism " + "removed at 2026-07-28; the auth suite's legacy-mode connect is its natural home. " + "The uniformity is structural (the OAuth provider wraps every request the transport " + "issues), but the GET leg's choreography is pinned because a failed step-up there " + "would otherwise vanish into the stream's silent reconnect loop." + ), + ), + "client-auth:stepup:attempt-tracking": Requirement( + source=f"{SPEC_BASE_URL}/basic/authorization#step-up-authorization-flow", + behavior=( + "The client tracks scope-upgrade attempts across request sends to avoid repeated " + "failures for the same resource and operation combination." + ), + transports=("streamable-http",), + note="OAuth is HTTP-only. The per-send bound is client-auth:stepup:retry-cap.", + deferred=( + "Not implemented in the SDK: the client OAuth provider keeps no cross-request memory " + "of scope-upgrade attempts. The 403 insufficient_scope branch " + "(src/mcp/client/auth/oauth2.py:704-734) performs one inline step-up per send with no " + "attempt counter and no (resource, operation) key, and OAuthContext (oauth2.py:98) " + "carries no field recording prior step-up failures, so a second send for the same " + "resource and operation re-attempts the upgrade unconditionally. The per-send " + '"repeated 403s do not loop" half of this spec line is client-auth:403-scope-upgrade.' + ), + ), + "client-auth:stepup:refresh-bypass-on-superset": Requirement( + source=f"{SPEC_BASE_URL}/basic/authorization#step-up-authorization-flow", + behavior=( + "On a 403 insufficient_scope step-up, when the scope union strictly exceeds the current " + "token's grant the client bypasses the refresh-token branch and forces a fresh " + "authorization so the widened scope reaches the authorization server; when the token " + "already covers the union, refresh is used." + ), + transports=("streamable-http",), + note="OAuth is HTTP-only.", + deferred=( + "Not implemented in the SDK: the 403 insufficient_scope branch " + "(src/mcp/client/auth/oauth2.py) performs one unconditional re-authorization -- there is " + "no granted-scope comparison choosing between refresh and fresh authorization, and no " + "force-reauthorization knob." + ), + ), + "client-auth:stepup:throw-mode": Requirement( + source="sdk", + behavior=( + "A throw-mode step-up option surfaces a 403 insufficient_scope challenge to the caller as " + "a typed error carrying the required scope, resource metadata URL, and error description, " + "without re-authorizing." + ), + transports=("streamable-http",), + note="OAuth is HTTP-only.", + deferred=( + "Not implemented in the SDK: the step-up flow has no behaviour knob -- the 403 " + "insufficient_scope branch (src/mcp/client/auth/oauth2.py) always attempts the inline " + "re-authorization and there is no option to surface the challenge as a typed error " + "instead." + ), + ), "client-auth:as-metadata-discovery:priority-order": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-metadata-discovery", behavior=( @@ -3496,7 +6759,7 @@ def __post_init__(self) -> None: ), ), ), - "client-auth:authorize:offline-access-consent": Requirement( + "client-auth:scope:offline-access-gate": Requirement( source="sdk", behavior=( "When the authorization server's metadata advertises offline_access in scopes_supported and " @@ -3530,7 +6793,7 @@ def __post_init__(self) -> None: transports=("streamable-http",), note="OAuth is HTTP-only.", ), - "client-auth:dcr:registration-error-surfaces": Requirement( + "client-auth:dcr:registration-rejected-error": Requirement( source="sdk", behavior=( "A 400 from the registration endpoint surfaces to the caller as an OAuthRegistrationError " @@ -3548,15 +6811,184 @@ def __post_init__(self) -> None: transports=("streamable-http",), note="OAuth is HTTP-only.", ), - "client-auth:as-binding": Requirement( - source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-binding", + "client-auth:dcr:app-type-heuristic": Requirement( + source=( + f"{SPEC_2026_BASE_URL}" + "/basic/authorization/client-registration#application-type-and-redirect-uri-constraints" + ), + behavior=( + "When the client metadata does not set application_type, dynamic client " + "registration derives it from the redirect URIs: a loopback host or custom URI " + "scheme yields 'native', otherwise 'web' (SEP-837)." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "OAuth is HTTP-only. The spec MUST (always send an application_type) IS satisfied " + "at this pin: OAuthClientMetadata defaults the field to 'native' and every " + "registration body carries it, pinned incidentally by the " + "client-auth:dcr:grant-types-default body snapshot. Only the derive-from-redirect-" + "URIs strategy for the 'web' SHOULD is unimplemented; a web-app consumer sets " + "application_type='web' explicitly and it is transmitted verbatim; the consumer-set " + "half is pinned by client-auth:dcr:app-type-override." + ), + deferred=( + "Not implemented in the SDK: application_type is a static model default ('native') " + "on OAuthClientMetadata (src/mcp/shared/auth.py); no code path inspects the " + "redirect URIs to choose between 'native' and 'web'." + ), + ), + "client-auth:dcr:app-type-override": Requirement( + source=( + f"{SPEC_2026_BASE_URL}" + "/basic/authorization/client-registration#application-type-and-redirect-uri-constraints" + ), + behavior=( + "A consumer-set application_type is sent verbatim in the dynamic-registration " + "request; the SDK never rewrites it (SEP-837)." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "OAuth is HTTP-only. At this pin nothing could rewrite it -- python has no " + "redirect-URI derivation strategy (client-auth:dcr:app-type-heuristic, deferred) " + "-- so this entry pins the pass-through: a future heuristic may only fill the " + "omitted case, never overwrite an explicit choice." + ), + ), + "client-auth:dcr:grant-types-default": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/authorization#refresh-tokens", + behavior=( + "When the client metadata does not set grant_types, the dynamic-registration " + "request carries ['authorization_code', 'refresh_token'] so the authorization " + "server may issue refresh tokens (SEP-2207); a consumer-set grant_types is sent " + "verbatim, never rewritten." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "OAuth is HTTP-only. A SHOULD. Python implements the default on the " + "OAuthClientMetadata model (a field default), not in registration code, so it is " + "present from construction -- wire-observably identical to injecting it at " + "registration time, which is what the registration body pins." + ), + ), + "client-auth:as-binding:reregister": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/authorization/client-registration#authorization-server-binding", + behavior=( + "Stored client credentials are bound to the issuer that registered them; when the " + "authorization server changes, the client discards them and re-registers with the " + "new authorization server (SEP-2352)." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note="OAuth is HTTP-only.", + ), + "client-auth:as-binding:no-cred-reuse": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/authorization/client-registration#authorization-server-binding", behavior=( - "Stored client credentials are bound to the issuer that registered them; when the authorization " - "server changes, the client discards them and re-registers rather than reusing them (SEP-2352)." + "When the authorization server changes, the client never reuses credentials from " + "the previous authorization server: the stale client_id reaches neither the " + "authorize nor the token endpoint (SEP-2352)." ), + added_in="2026-07-28", transports=("streamable-http",), note="OAuth is HTTP-only.", ), + "client-auth:as-binding:prereg-mismatch-error": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/authorization/client-registration#authorization-server-binding", + behavior=( + "Pre-registered credentials are specific to one authorization server: when the " + "authorization server indicated by protected resource metadata no longer matches " + "the issuer recorded with the credentials, the client surfaces an error rather " + "than silently attempting to use them (SEP-2352)." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "OAuth is HTTP-only. The divergence covers only credentials stored with an issuer. " + "A pre-registered credential stored without one carries no binding to compare: " + "credentials_match_issuer (src/mcp/client/auth/utils.py) leaves it as-is and the " + "flow silently presents it to whatever authorization server discovery finds -- a " + "documented limitation rather than a divergence, because the SHOULD's trigger " + "('no longer matches the one the credentials were registered with') presupposes a " + "recorded binding." + ), + divergence=Divergence( + note=( + "The SDK has no pre-registered marker: an issuer-stamped credential whose " + "issuer mismatches the discovered authorization server takes the same path as " + "a DCR-persisted one -- silently discarded and re-registered, the path the " + "spec blesses only for DCR-persisted credentials -- and no error is surfaced." + ), + ), + ), + "client-auth:as-binding:no-token-reuse": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/authorization/client-registration#authorization-server-binding", + behavior=( + "When the authorization server changes, tokens obtained from the previous " + "authorization server are discarded along with the bound credentials: the stale " + "refresh token is never presented to any endpoint of the new authorization " + "server, and re-authorization mints fresh tokens (SEP-2352)." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "OAuth is HTTP-only. At the pin this holds through two cooperating facts: the " + "post-401 binding check discards tokens together with the credentials " + "(oauth2.py, the SEP-2352 branch), and the pre-discovery refresh branch never " + "engages for storage-reloaded tokens because reload loses the expiry clock (the " + "storage-reload expiry gap, tracked in the cleanup ledger). A fix that makes " + "reloaded tokens expire MUST keep the discard ahead of any refresh attempt: with " + "no AS metadata yet discovered, _refresh_token falls back to the CURRENT server " + "origin's /token -- which after a migration IS the new authorization server. " + "This test is the regression net for that ordering." + ), + ), + "client-auth:as-binding:cimd-portable": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/authorization/client-registration#authorization-server-binding", + behavior=( + "A URL-based client ID (CIMD) is portable across authorization servers: when the " + "authorization server changes, the client keeps using the same metadata-document " + "URL as its client_id with no dynamic registration (SEP-2352)." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "OAuth is HTTP-only. Portability is implemented as a binding-check bypass: " + "credentials_match_issuer (src/mcp/client/auth/utils.py) treats a client_id equal " + "to the configured client_metadata_url as always matching, so any recorded issuer " + "stamp on CIMD credentials is informational and is deliberately NOT updated on " + "migration (the typescript-sdk re-saves the record instead; same observable " + "either way: no re-registration, same client_id presented)." + ), + ), + "client-auth:as-binding:m2m-no-cred-reuse": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/authorization/client-registration#authorization-server-binding", + behavior=( + "Statically-credentialed machine-to-machine clients (the client_credentials and JWT-bearer " + "grants) treat their credentials as bound to the authorization server that issued them: " + "when discovery resolves a different authorization server, the flow refuses to present the " + "credential there and fails before any token request (SEP-2352)." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "OAuth is HTTP-only. This is the machine-to-machine face of the binding pinned by the " + "sibling as-binding entries; the cross-SDK manifests carry this row as as-migration " + "m2m-expected-issuer (an expected-issuer constructor knob), but the obligation tracked " + "here is the spec's, independent of any one surface." + ), + deferred=( + "Not implemented in the SDK: the machine-to-machine providers " + "(src/mcp/client/auth/extensions/client_credentials.py) record no issuer binding for their " + "static credentials and expose no expected-issuer surface -- every issuer reference in " + "that module is RFC 7523 assertion plumbing (the assertion's iss claim and its audience, " + "oauth_metadata.issuer); none stamps, stores, or compares the authorization server a " + "credential belongs to, so the credential is presented to whatever authorization server " + "discovery finds." + ), + ), "client-auth:invalid-client-clears-all": Requirement( source="sdk", behavior=( @@ -3648,6 +7080,29 @@ def __post_init__(self) -> None: transports=("streamable-http",), note="OAuth is HTTP-only.", ), + "client-auth:refresh:rotation-handling": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/authorization#refresh-tokens", + behavior=( + "On a refresh-token exchange, a new refresh_token in the response replaces the " + "stored one, and a response that omits refresh_token leaves the stored one in " + "place -- the client never assumes a refresh token will be issued " + "(RFC 6749 section 6 / SEP-2207)." + ), + transports=("streamable-http",), + note=( + "OAuth is HTTP-only. No added_in: the replace/preserve mechanics are RFC 6749 " + "section 6 client behaviour that predates the 2026 Refresh Tokens section restating " + "them (the add plan classifies this entry era PRE-EXISTING), and the auth tests " + "bypass the connect fixture so era fields drive no cells. The follow-on claim -- " + "the NEXT refresh presents the rotated token -- is real-time-bound at this pin: a " + "token that is already expired when its refresh response arrives is not refreshed " + "again on the same request; the request goes out unauthenticated and 401s into a " + "full re-authorization (oauth2.py sends at most one refresh per request and only " + "attaches a bearer it considers valid), so a second same-connection refresh cannot " + "be driven without wall-clock waits. The tests therefore pin replacement and " + "preservation at the storage/wire seam of a single refresh." + ), + ), "client-auth:refresh:transparent": Requirement( source="sdk", behavior=( @@ -3692,22 +7147,176 @@ def __post_init__(self) -> None: transports=("streamable-http",), note="OAuth is HTTP-only.", ), - "client-auth:authorization-response:iss-verify": Requirement( - source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-metadata-discovery", + "client-auth:iss:mismatch-reject": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/authorization#authorization-response-validation", behavior=( "The client validates the RFC 9207 iss authorization-response parameter against the " - "authorization server issuer (simple string comparison) and rejects a mismatch, or a " - "missing iss when the server advertises support (SEP-2468)." + "authorization server issuer (simple string comparison) and rejects a mismatch (SEP-2468)." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note="OAuth is HTTP-only.", + ), + "client-auth:iss:match": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/authorization#authorization-response-validation", + behavior=( + "When the authorization server's metadata advertises " + "authorization_response_iss_parameter_supported and the callback's iss equals the " + "recorded metadata issuer, the client proceeds to redeem the authorization code " + "(RFC 9207 validation table row 1)." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note="OAuth is HTTP-only.", + ), + "client-auth:iss:no-normalize": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/authorization#authorization-response-validation", + behavior=( + "The iss comparison is simple string comparison (RFC 3986 section 6.2.1): a value " + "differing from the recorded issuer only by a trailing slash is rejected as a " + "mismatch -- no scheme or host case folding, default-port elision, trailing-slash, " + "or percent-encoding normalization is applied before comparison." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "OAuth is HTTP-only. The comparison is a single string inequality; the test pins the " + "trailing-slash arm as the representative normalization class." + ), + ), + "client-auth:iss:supported-missing-reject": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/authorization#authorization-response-validation", + behavior=( + "When the authorization server's metadata advertises " + "authorization_response_iss_parameter_supported: true and the callback carries no " + "iss, the client rejects the authorization response before redeeming the code " + "(RFC 9207 validation table row 2)." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note="OAuth is HTTP-only.", + ), + "client-auth:iss:unadvertised-proceed": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/authorization#authorization-response-validation", + behavior=( + "When the authorization server's metadata does not advertise " + "authorization_response_iss_parameter_supported and the callback carries no iss, " + "the client proceeds with the code exchange (RFC 9207 validation table row 4)." ), + added_in="2026-07-28", transports=("streamable-http",), note="OAuth is HTTP-only.", ), + "client-auth:iss:unadvertised-present-validated": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/authorization#authorization-response-validation", + behavior=( + "A present iss is validated against the recorded issuer regardless of metadata " + "advertisement (RFC 9207 validation table row 3, where this specification " + "deliberately exceeds RFC 9207's local-policy provision): a matching iss proceeds " + "and a mismatching iss is rejected." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "OAuth is HTTP-only. Covered by two tests: the match half directly, and the " + "mismatch half by the client-auth:iss:mismatch-reject test, which drives a " + "mismatched iss against the suite's unadvertising authorization server." + ), + ), + "client-auth:iss:error-response-validated": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/authorization#authorization-response-validation", + behavior=( + "iss validation applies equally to error responses: a mismatched iss on an error " + "callback is rejected before the flow acts on the response, and on mismatch the " + "client must not act on or display error, error_description, or error_uri." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "OAuth is HTTP-only. The non-surfacing half holds by construction: the callback " + "contract (AuthorizationCodeResult) carries no error fields, so those values never " + "enter the SDK; the test pins the observable half -- the iss mismatch is raised in " + "preference to the missing-authorization-code failure." + ), + ), + "client-auth:finishauth:urlsearchparams-sanitizes": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/authorization#authorization-response-validation", + behavior=( + "A raw authorization-callback entry point accepting the redirect's query parameters " + "extracts code and iss, validates iss before the code is used, and on mismatch surfaces " + "none of the callback's error, error_description, or error_uri values; the authorization " + "code never reaches a token endpoint." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "OAuth is HTTP-only. The typed path's ordering and non-surfacing guarantees are pinned by " + "the client-auth:iss table (see client-auth:iss:error-response-validated)." + ), + deferred=( + "Not implemented in the SDK: there is no raw-callback entry point -- the " + "integrator-supplied callback_handler (src/mcp/client/auth/oauth2.py) parses the redirect " + "itself and returns a typed AuthorizationCodeResult, so the callback's raw query string " + "(and any error fields in it) never enters the SDK." + ), + ), "client-auth:token-endpoint-auth-method": Requirement( source="sdk", behavior="The client authenticates to the token endpoint using the auth method established at registration.", transports=("streamable-http",), note="OAuth is HTTP-only.", ), + "client-auth:token-endpoint:https-guard": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/authorization#refresh-tokens", + behavior=( + "Token-exchange and refresh requests are sent only to an https token endpoint (loopback " + "exempt); a non-https endpoint is refused before client credentials or refresh tokens are " + "transmitted." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note="OAuth is HTTP-only.", + deferred=( + "Not implemented in the SDK: the token-exchange and refresh paths " + "(src/mcp/client/auth/oauth2.py) take the discovered token_endpoint verbatim with no " + "scheme check -- the only https validation in the client auth stack is the CIMD " + "client_metadata_url shape check, so credentials are sent to whatever endpoint metadata " + "names." + ), + ), + "client-auth:token-error:machine-readable-code": Requirement( + source="sdk", + behavior=( + "An RFC 6749 error response from the token endpoint (e.g. invalid_grant, " + "invalid_client, on either the authorization-code exchange or a refresh) surfaces " + "to the caller as a typed OAuth error carrying the wire error code as a " + "machine-readable field, not only embedded in the message text." + ), + transports=("streamable-http",), + note="OAuth is HTTP-only. The weak testable sibling is client-auth:token:error-surfaces.", + deferred=( + "Not implemented in the SDK: OAuthTokenError (src/mcp/client/auth/exceptions.py) " + "carries only a message string; the token-response handler embeds the RFC 6749 " + "error body in an f-string and the refresh-response handler clears tokens without " + "reading the body (src/mcp/client/auth/oauth2.py), so there is no machine-readable " + "error code for a caller to branch on." + ), + ), + "client-auth:token:error-surfaces": Requirement( + source="sdk", + behavior=( + "A non-2xx response from the token endpoint on the authorization-code exchange " + "aborts the flow and surfaces to the caller as an error naming the HTTP status; " + "the flow does not loop, and no request is ever sent with a bearer token." + ), + transports=("streamable-http",), + note=( + "OAuth is HTTP-only. Completes the endpoint error-surfaces family alongside " + "client-auth:authorize:error-surfaces and " + "client-auth:dcr:registration-rejected-error; the machine-readable half is " + "client-auth:token-error:machine-readable-code (deferred)." + ), + ), "client-auth:token-provenance": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#token-handling", behavior=( @@ -3825,12 +7434,58 @@ def __post_init__(self) -> None: "excluded from this suite. Covered by tests/client/test_stdio.py." ), ), + "transport:stdio:restart-after-crash": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports/stdio#unexpected-termination", + behavior=( + "If the server process exits unexpectedly, the client restarts it; in-flight requests are " + "lost and may be retried against the fresh process." + ), + added_in="2026-07-28", + transports=("stdio",), + note="Only observable over stdio: child-process lifecycle is stdio-specific.", + deferred=( + "Not implemented in the SDK: stdio_client (src/mcp/client/stdio.py) spawns the server " + "process exactly once and has no restart or respawn path -- on unexpected exit the stdout " + "loop ends and the read stream closes, surfacing connection closure; the only " + "process-lifecycle code is teardown." + ), + ), "transport:stdio:stderr-passthrough": Requirement( source="sdk", behavior="Server stderr is available to the client and is not consumed by the transport.", transports=("stdio",), note="Only observable over stdio: stderr is a child-process stream.", ), + "transport:stdio:dual-era-serving": Requirement( + source="sdk", + behavior=( + "A stdio server serves a plain legacy client via initialize and an " + "auto-negotiating client at 2026-07-28 via server/discover, each on its own " + "connection against the same factory, over a real child-process pipe." + ), + added_in="2026-07-28", + transports=("stdio",), + deferred=( + "Not yet covered here: stream-pair 2026 serving landed -- serve_dual_era_loop " + "(src/mcp/server/runner.py) locks each stream-pair connection's era on the first " + "era-distinctive frame to succeed (a request that classifies as modern locks modern " + "only after it is served to a client-visible success; a successful initialize locks " + "legacy; no failure of any kind locks -- rejected classification, malformed envelope " + "content, unknown method, handler error -- and a success the peer cancelled away from " + "does not lock either; the lock settles once, so a straggling other-era success " + "cannot move it), routing server/discover and envelope-bearing requests to a " + "per-request Connection.from_envelope, and Server.run drives it for stdio, so the " + "suite's subprocess server (tests/interaction/transports/_stdio_server.py) already " + "serves both eras unchanged. The test connects a mode='auto' client that negotiates " + "2026-07-28 via server/discover alongside the existing legacy-mode connection against " + "the same factory, over a real child-process pipe." + ), + note=( + "stdio-only by definition: the dual-era HTTP analogue is the session manager's " + "header routing, pinned by hosting:http:modern:legacy-fallthrough and " + "lifecycle:version:dual-era-precedence." + ), + ), # ═══════════════════════════════════════════════════════════════════════════ # Composite end-to-end flows # ═══════════════════════════════════════════════════════════════════════════ @@ -3870,10 +7525,13 @@ def __post_init__(self) -> None: "A single tool handler issues sequential elicitations; an accept on one step feeds the next, " "and a decline or cancel at any step short-circuits to a final result." ), - arm_exclusions=( - ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), - ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + removed_in="2026-07-28", + superseded_by="mrtr:multi-round:complete", + note=( + "removed in 2026-07-28 (SEP-2322); sequential elicitation steps become multiple MRTR " + "input_required rounds before completion." ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "flow:elicitation:url-at-session-init": Requirement( source="sdk", @@ -3902,6 +7560,7 @@ def __post_init__(self) -> None: "after the client completes the URL flow and the server announces completion." ), removed_in="2026-07-28", + superseded_by="mrtr:url-elicitation:no-32042-on-2026", note=( "removed in 2026-07-28 (SEP-2322); the -32042 + elicitation/complete flow is replaced by the MRTR " "input_required/retry loop." diff --git a/tests/interaction/auth/_harness.py b/tests/interaction/auth/_harness.py index 4fd1110c9..c54e783ea 100644 --- a/tests/interaction/auth/_harness.py +++ b/tests/interaction/auth/_harness.py @@ -16,9 +16,10 @@ from typing import Any from urllib.parse import parse_qs, parse_qsl, urlsplit +import anyio import httpx from pydantic import AnyHttpUrl, AnyUrl, BaseModel -from starlette.types import ASGIApp, Receive, Scope, Send +from starlette.types import ASGIApp, Message, Receive, Scope, Send from mcp.client.auth import OAuthClientProvider from mcp.client.client import Client @@ -132,25 +133,38 @@ class HeadlessOAuth: `redirect_handler` GETs the authorize URL on the bound client (with `auth=None` so the request does not re-enter the locked auth flow), parses `code` and `state` from the 302 `Location`, and stashes them; `callback_handler` returns the stashed pair. Tests inspect - `authorize_url` to assert what the SDK put on the authorize request. + `authorize_url` to assert what the SDK put on the authorize request, and `iss`/`error` to + assert what the redirect carried. `state_override`: when set, `callback_handler` returns this value as the state instead of the one parsed from the redirect, so tests can drive the state-mismatch path. `iss_override`: when set, `callback_handler` returns this value as the RFC 9207 issuer instead of the one parsed from the redirect, so tests can drive the iss-mismatch path. + + `code_override`: when set, returned as the code instead of the parsed one (token-endpoint rejection path). + `omit_iss`: when set, no iss is returned, overriding everything (`iss_override` cannot express absence). """ - def __init__(self, *, state_override: str | None = None, iss_override: str | None = None) -> None: + def __init__( + self, + *, + state_override: str | None = None, + iss_override: str | None = None, + code_override: str | None = None, + omit_iss: bool = False, + ) -> None: self.authorize_url: str | None = None self.authorize_urls: list[str] = [] self.error: str | None = None + self.iss: str | None = None self._state_override = state_override self._iss_override = iss_override + self._code_override = code_override + self._omit_iss = omit_iss self._http: httpx.AsyncClient | None = None self._code: str = "" self._state: str | None = None - self._iss: str | None = None def bind(self, http_client: httpx.AsyncClient) -> None: self._http = http_client @@ -166,14 +180,15 @@ async def redirect_handler(self, authorization_url: str) -> None: params = parse_qs(urlsplit(response.headers["location"]).query) self._code = params.get("code", [""])[0] self._state = params.get("state", [None])[0] - self._iss = params.get("iss", [None])[0] + self.iss = params.get("iss", [None])[0] self.error = params.get("error", [None])[0] async def callback_handler(self) -> AuthorizationCodeResult: + iss = self._iss_override if self._iss_override is not None else self.iss return AuthorizationCodeResult( - code=self._code, + code=self._code_override if self._code_override is not None else self._code, state=self._state_override if self._state_override is not None else self._state, - iss=self._iss_override if self._iss_override is not None else self._iss, + iss=None if self._omit_iss else iss, ) @@ -308,7 +323,7 @@ def first_challenge_shim(www_authenticate: str, *, path: str = "/mcp") -> Callab return lambda app: _FirstChallenge(app, path, www_authenticate) -def step_up_shim(www_authenticate: str, *, on_nth_authenticated_post: int = 2) -> AppShim: +def step_up_shim(www_authenticate: str, *, on_nth_authenticated_post: int = 2, persist: bool = False) -> AppShim: """Build an `app_shim` that 403s the Nth authenticated POST to `/mcp` with the given challenge. Subsequent requests pass through. Used to drive the client's `insufficient_scope` step-up @@ -320,6 +335,8 @@ def step_up_shim(www_authenticate: str, *, on_nth_authenticated_post: int = 2) - first authenticated POST is the auth flow's retry of the original initialize request (yielded after the 401 branch, where the generator ends without inspecting the response), so a 403 there would not reach the step-up handler. + + `persist`: when set, 403s every authenticated POST from the Nth onward, re-challenging the step-up retry. """ seen = 0 fired = False @@ -328,7 +345,7 @@ def factory(app: ASGIApp) -> ASGIApp: async def wrapped(scope: Scope, receive: Receive, send: Send) -> None: nonlocal seen, fired if ( - not fired + (persist or not fired) and scope["type"] == "http" and scope["path"] == "/mcp" and scope["method"] == "POST" @@ -355,6 +372,55 @@ async def wrapped(scope: Scope, receive: Receive, send: Send) -> None: return factory +def get_stream_step_up_shim(www_authenticate: str) -> tuple[list[int], anyio.Event, AppShim]: + """Build an `app_shim` that 403s the first authenticated GET to `/mcp` with the given challenge. + + Returns: + The statuses of every authenticated GET response (live-updated), an event set when one + of those responses starts with status 200 (the reopened stream), and the shim factory. + """ + statuses: list[int] = [] + reopened = anyio.Event() + fired = False + + def factory(app: ASGIApp) -> ASGIApp: + async def wrapped(scope: Scope, receive: Receive, send: Send) -> None: + nonlocal fired + if not ( + scope["type"] == "http" + and scope["path"] == "/mcp" + and scope["method"] == "GET" + and b"authorization" in dict(scope["headers"]) + ): + await app(scope, receive, send) + return + + async def recording_send(message: Message) -> None: + if message["type"] == "http.response.start": + statuses.append(message["status"]) + if message["status"] == 200: + reopened.set() + await send(message) + + if not fired: + fired = True + await recording_send( + { + "type": "http.response.start", + "status": 403, + "headers": [(b"www-authenticate", www_authenticate.encode())], + } + ) + await recording_send({"type": "http.response.body", "body": b""}) + return + # The reopened SSE stream stays open until the test's exit cancels it; nothing may follow this await. + await app(scope, receive, recording_send) + + return wrapped + + return statuses, reopened, factory + + def m2m_token_shim(provider: InMemoryAuthorizationServerProvider, *, scopes: list[str]) -> AppShim: """Build an `app_shim` that handles `grant_type=client_credentials` at `/token`. diff --git a/tests/interaction/auth/_provider.py b/tests/interaction/auth/_provider.py index 0c54d4fd3..43c9ab259 100644 --- a/tests/interaction/auth/_provider.py +++ b/tests/interaction/auth/_provider.py @@ -49,6 +49,8 @@ class InMemoryAuthorizationServerProvider( `fail_next_refresh`: the next refresh-token exchange raises `invalid_grant` once. `reject_all_tokens`: `load_access_token` returns None for every token, so the bearer middleware 401s every authenticated request. + `rotate_refresh_tokens`: when False, the refresh response carries no `refresh_token` and + the presented one stays valid (an RFC 6749 §6 non-rotating server). """ def __init__( @@ -59,6 +61,7 @@ def __init__( issue_expired_first: bool = False, fail_next_refresh: bool = False, reject_all_tokens: bool = False, + rotate_refresh_tokens: bool = True, issuer: str | None = None, ) -> None: self._default_scopes = list(default_scopes) if default_scopes is not None else ["mcp"] @@ -71,6 +74,7 @@ def __init__( self._issue_expired_first = issue_expired_first self._fail_next_refresh = fail_next_refresh self._reject_all_tokens = reject_all_tokens + self._rotate_refresh_tokens = rotate_refresh_tokens self._tokens_issued = 0 self.clients: dict[str, OAuthClientInformationFull] = {} self.codes: dict[str, AuthorizationCode] = {} @@ -178,11 +182,19 @@ async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_t async def exchange_refresh_token( self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str] ) -> OAuthToken: - """Mint a new access token and rotate the refresh token, consuming the old one.""" + """Mint a new access token, and rotate the refresh token unless rotation is disabled.""" assert client.client_id is not None if self._fail_next_refresh: self._fail_next_refresh = False raise TokenError(error="invalid_grant", error_description="refresh denied by harness") + if not self._rotate_refresh_tokens: + access = self.mint_access_token(client_id=client.client_id, scopes=scopes) + return OAuthToken( + access_token=access, + token_type="Bearer", + expires_in=self._next_expires_in(), + scope=" ".join(scopes), + ) access = self.mint_access_token(client_id=client.client_id, scopes=scopes) new_refresh = f"refresh_{secrets.token_hex(16)}" self.refresh_tokens[new_refresh] = RefreshToken(token=new_refresh, client_id=client.client_id, scopes=scopes) diff --git a/tests/interaction/auth/test_as_handlers.py b/tests/interaction/auth/test_as_handlers.py index 5cb4e92d8..a7dddb217 100644 --- a/tests/interaction/auth/test_as_handlers.py +++ b/tests/interaction/auth/test_as_handlers.py @@ -16,10 +16,11 @@ import httpx import pytest from inline_snapshot import snapshot +from pydantic import AnyUrl from mcp.server import Server from mcp.server.auth.provider import ProviderTokenVerifier -from mcp.shared.auth import OAuthClientInformationFull +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata from tests.interaction._connect import mounted_app from tests.interaction._requirements import requirement from tests.interaction.auth._harness import REDIRECT_URI, auth_settings, oauth_client_metadata @@ -298,3 +299,25 @@ async def test_a_non_loopback_http_redirect_uri_is_accepted_at_registration( info = OAuthClientInformationFull.model_validate_json(response.content) assert [str(u) for u in (info.redirect_uris or [])] == ["http://evil.example/callback"] assert info.client_id in provider.clients + + +@requirement("hosting:auth:as:register-echo-application-type") +async def test_register_echoes_native_for_a_client_that_registered_application_type_web( + as_app: tuple[httpx.AsyncClient, InMemoryAuthorizationServerProvider], +) -> None: + """A client registering `application_type: "web"` is told `"native"` in the registration echo. + + When the passthrough fix lands: re-pin the echo to `"web"` and delete the Divergence. + """ + http, _ = as_app + metadata = OAuthClientMetadata( + client_name="interaction-suite", redirect_uris=[AnyUrl(REDIRECT_URI)], application_type="web" + ) + + response = await http.post("/register", content=metadata.model_dump_json()) + + assert response.status_code == 201 + body = response.json() + assert body["application_type"] == "native" + # The omission is specific to application_type, not a generally lossy echo. + assert body["client_name"] == "interaction-suite" diff --git a/tests/interaction/auth/test_authorize_token.py b/tests/interaction/auth/test_authorize_token.py index d4eb591b5..0ef27c1f7 100644 --- a/tests/interaction/auth/test_authorize_token.py +++ b/tests/interaction/auth/test_authorize_token.py @@ -26,7 +26,7 @@ from mcp_types import ListToolsResult, Tool from pydantic import AnyHttpUrl, AnyUrl -from mcp.client.auth import OAuthFlowError +from mcp.client.auth import OAuthFlowError, OAuthTokenError from mcp.server import Server, ServerRequestContext from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata from tests.interaction._connect import BASE_URL @@ -113,7 +113,7 @@ async def recorded_oauth_flow() -> AsyncIterator[RecordedFlow]: @requirement("client-auth:pkce:s256") @requirement("client-auth:resource-parameter") -@requirement("client-auth:authorize:offline-access-consent") +@requirement("client-auth:scope:offline-access-gate") async def test_the_authorize_url_carries_s256_pkce_and_the_resource_indicator( recorded_oauth_flow: RecordedFlow, ) -> None: @@ -187,13 +187,16 @@ async def test_a_mismatched_state_on_the_callback_aborts_the_flow() -> None: await connect_with_oauth(server, provider=provider, headless=headless).__aenter__() -@requirement("client-auth:authorization-response:iss-verify") +@requirement("client-auth:iss:mismatch-reject") +@requirement("client-auth:iss:unadvertised-present-validated") async def test_a_mismatched_iss_on_the_callback_aborts_the_flow() -> None: """A callback whose RFC 9207 iss does not match the authorization server issuer aborts the flow. `iss_override` makes the headless callback return an issuer the AS never advertised; the SDK compares it to `oauth_metadata.issuer` and raises `OAuthFlowError` before the token exchange. + Also the row-3 mismatch arm: a present iss is validated even though the AS never advertises iss support. """ + recorded, on_request = record_requests() provider = InMemoryAuthorizationServerProvider() server = Server("guarded", on_list_tools=list_tools) headless = HeadlessOAuth(iss_override="https://attacker.example.com") @@ -202,7 +205,11 @@ async def test_a_mismatched_iss_on_the_callback_aborts_the_flow() -> None: with pytest.RaisesGroup( pytest.RaisesExc(OAuthFlowError, match="^Authorization response iss mismatch:"), flatten_subgroups=True ): - await connect_with_oauth(server, provider=provider, headless=headless).__aenter__() + await connect_with_oauth(server, provider=provider, headless=headless, on_request=on_request).__aenter__() + + # The recorded unauthenticated trigger POST guards the negative below against an unwired hook. + assert find(recorded, "POST", "/mcp") != [] + assert find(recorded, "POST", "/token") == [] @requirement("client-auth:resource-parameter") @@ -415,3 +422,199 @@ async def test_an_authorize_error_on_the_callback_aborts_the_flow_before_the_tok assert headless.error == "access_denied" assert find(recorded, "POST", "/token") == [] + + +@requirement("client-auth:token:error-surfaces") +async def test_a_token_endpoint_error_response_aborts_the_flow_without_a_bearer_request() -> None: + """A token-endpoint error response aborts the flow as `OAuthTokenError`, and no bearer is ever sent. + + SDK-defined behaviour. The match pins only the SDK-authored status prefix; the missing + machine-readable error code is the deferred `client-auth:token-error:machine-readable-code`. + """ + recorded, on_request = record_requests() + provider = InMemoryAuthorizationServerProvider() + server = Server("guarded", on_list_tools=list_tools) + headless = HeadlessOAuth(code_override="forged-code") + + with anyio.fail_after(5): + with pytest.RaisesGroup( + pytest.RaisesExc(OAuthTokenError, match=r"^Token exchange failed \(400\): "), flatten_subgroups=True + ): + await connect_with_oauth(server, provider=provider, headless=headless, on_request=on_request).__aenter__() + + # Guards that the failure happened at the token step, not earlier in the flow. + assert find(recorded, "GET", "/authorize") != [] + assert len(find(recorded, "POST", "/token")) == 1 + assert all("authorization" not in r.headers for r in find(recorded, "POST", "/mcp")) + + +def canned_asm(*, iss_advertised: bool | None) -> dict[str, bytes]: + """Build a `serve=` override: canned AS metadata pinning the iss-advertisement arm. + + Needed because the SDK server's `build_metadata` never advertises iss support; `None` omits the field. + """ + override = OAuthMetadata( + issuer=AnyHttpUrl(f"{BASE_URL}/"), + authorization_endpoint=AnyHttpUrl(f"{BASE_URL}/authorize"), + token_endpoint=AnyHttpUrl(f"{BASE_URL}/token"), + registration_endpoint=AnyHttpUrl(f"{BASE_URL}/register"), + scopes_supported=["mcp"], + grant_types_supported=["authorization_code", "refresh_token"], + code_challenge_methods_supported=["S256"], + authorization_response_iss_parameter_supported=iss_advertised, + ) + return {ASM_PATH: override.model_dump_json(exclude_none=True).encode()} + + +@requirement("client-auth:iss:match") +async def test_a_matching_iss_lets_the_flow_redeem_the_code_when_the_as_advertises_iss_support() -> None: + """A callback iss equal to the recorded metadata issuer proceeds to redeem the code (RFC 9207 table row 1). + + Spec-mandated. `headless.iss` proves the callback really carried the issuer; whether the SDK + consulted the advertisement flag is only observable on the absent-iss arms, so it is not asserted. + """ + recorded, on_request = record_requests() + provider = InMemoryAuthorizationServerProvider() + server = Server("guarded", on_list_tools=list_tools) + storage = InMemoryTokenStorage() + + with anyio.fail_after(5): + async with connect_with_oauth( + server, + provider=provider, + storage=storage, + app_shim=lambda app: shimmed_app(app, serve=canned_asm(iss_advertised=True)), + on_request=on_request, + ) as (client, headless): + result = await client.list_tools() + + assert result.tools[0].name == "echo" + assert storage.tokens is not None + assert headless.iss == f"{BASE_URL}/" + assert len(find(recorded, "GET", "/authorize")) == 1 + assert len(find(recorded, "POST", "/token")) == 1 + + +@requirement("client-auth:iss:no-normalize") +async def test_an_iss_differing_only_by_a_trailing_slash_is_rejected_without_normalization() -> None: + """An iss equal to the recorded issuer up to a trailing slash is a mismatch: nothing is normalized away. + + Spec-mandated: RFC 9207 simple string comparison with no normalization; the trailing-slash + arm is pinned as the representative class (the SDK's comparison is a single string inequality). + """ + recorded, on_request = record_requests() + provider = InMemoryAuthorizationServerProvider(issuer=BASE_URL) + server = Server("guarded", on_list_tools=list_tools) + mismatch = re.escape(f"Authorization response iss mismatch: {BASE_URL} != {BASE_URL}/") + + with anyio.fail_after(5): + with pytest.RaisesGroup(pytest.RaisesExc(OAuthFlowError, match=f"^{mismatch}$"), flatten_subgroups=True): + await connect_with_oauth(server, provider=provider, on_request=on_request).__aenter__() + + # The recorded unauthenticated trigger POST guards the negative below against an unwired hook. + assert find(recorded, "POST", "/mcp") != [] + assert find(recorded, "POST", "/token") == [] + + +@requirement("client-auth:iss:supported-missing-reject") +async def test_a_missing_iss_is_rejected_when_the_as_advertises_iss_support() -> None: + """A callback without iss is rejected before the code is redeemed when the AS advertises iss support (row 2). + + Spec-mandated. The callback is the SDK's whole authorization-response input, so `omit_iss` removes iss there. + """ + recorded, on_request = record_requests() + provider = InMemoryAuthorizationServerProvider() + server = Server("guarded", on_list_tools=list_tools) + headless = HeadlessOAuth(omit_iss=True) + + with anyio.fail_after(5): + with pytest.RaisesGroup( + pytest.RaisesExc( + OAuthFlowError, + match="^Authorization response missing iss parameter advertised by the authorization server$", + ), + flatten_subgroups=True, + ): + await connect_with_oauth( + server, + provider=provider, + headless=headless, + app_shim=lambda app: shimmed_app(app, serve=canned_asm(iss_advertised=True)), + on_request=on_request, + ).__aenter__() + + assert find(recorded, "POST", "/mcp") != [] + assert find(recorded, "POST", "/token") == [] + + +@requirement("client-auth:iss:unadvertised-proceed") +async def test_a_missing_iss_is_tolerated_when_the_as_does_not_advertise_iss_support() -> None: + """A callback without iss proceeds with the code exchange when the AS does not advertise iss support (row 4). + + Spec-mandated. Natural metadata is already the unadvertising arm; if the SDK server ever + started advertising, absent iss would hit the row-2 reject, so the precondition cannot rot. + """ + recorded, on_request = record_requests() + provider = InMemoryAuthorizationServerProvider() + server = Server("guarded", on_list_tools=list_tools) + storage = InMemoryTokenStorage() + headless = HeadlessOAuth(omit_iss=True) + + with anyio.fail_after(5): + async with connect_with_oauth( + server, provider=provider, storage=storage, headless=headless, on_request=on_request + ) as (client, _): + result = await client.list_tools() + + assert result.tools[0].name == "echo" + assert storage.tokens is not None + assert len(find(recorded, "POST", "/token")) == 1 + + +@requirement("client-auth:iss:unadvertised-present-validated") +async def test_a_present_iss_is_validated_and_accepted_even_when_the_as_does_not_advertise_support() -> None: + """A present iss is compared against the recorded issuer even without metadata advertisement (row 3, match arm). + + Spec-mandated; MCP exceeds RFC 9207's local-policy provision here. The mismatch arm is pinned + by `test_a_mismatched_iss_on_the_callback_aborts_the_flow`. + """ + recorded, on_request = record_requests() + provider = InMemoryAuthorizationServerProvider() + server = Server("guarded", on_list_tools=list_tools) + storage = InMemoryTokenStorage() + + with anyio.fail_after(5): + async with connect_with_oauth(server, provider=provider, storage=storage, on_request=on_request) as ( + client, + headless, + ): + result = await client.list_tools() + + assert result.tools[0].name == "echo" + assert storage.tokens is not None + assert headless.iss == f"{BASE_URL}/" + assert len(find(recorded, "POST", "/token")) == 1 + + +@requirement("client-auth:iss:error-response-validated") +async def test_an_error_redirect_with_a_mismatched_iss_is_rejected_on_iss_before_the_missing_code_error() -> None: + """iss validation applies equally to error responses: the mismatch is raised before the missing-code error. + + Spec-mandated at 2026-07-28 (SEP-2468). The mismatch pre-empting the missing-code error proves + validation ran; the MUST-NOT-act-on half is vacuous (no error fields in the callback contract). + """ + recorded, on_request = record_requests() + provider = InMemoryAuthorizationServerProvider(deny_authorize=True) + server = Server("guarded", on_list_tools=list_tools) + headless = HeadlessOAuth(iss_override="https://attacker.example.com") + + with anyio.fail_after(5): + with pytest.RaisesGroup( + pytest.RaisesExc(OAuthFlowError, match="^Authorization response iss mismatch:"), flatten_subgroups=True + ): + await connect_with_oauth(server, provider=provider, headless=headless, on_request=on_request).__aenter__() + + assert headless.error == "access_denied" + # The recorded unauthenticated trigger POST guards the negative below against an unwired hook. + assert find(recorded, "POST", "/mcp") != [] + assert find(recorded, "POST", "/token") == [] diff --git a/tests/interaction/auth/test_bearer.py b/tests/interaction/auth/test_bearer.py index 55029c9f4..2b6c9dbd2 100644 --- a/tests/interaction/auth/test_bearer.py +++ b/tests/interaction/auth/test_bearer.py @@ -156,6 +156,30 @@ async def test_a_token_missing_a_required_scope_is_answered_403_insufficient_sco assert "scope" not in parsed +@requirement("hosting:auth:scope-403:all-scopes") +async def test_a_token_missing_two_required_scopes_is_challenged_with_only_the_first() -> None: + """A token missing both required scopes is challenged for only the first missing scope. + + Pins the recorded divergence: the middleware checks required scopes in order and names + only the first missing one, where the spec wants all of them in a single challenge. + When the middleware aggregates: re-pin to a challenge naming both scopes and delete the Divergence. + """ + settings = auth_settings(required_scopes=["mcp:read", "mcp:write"]) + verifier = StaticTokenVerifier( + {"tok-zeroscope": AccessToken(token="tok-zeroscope", client_id="c", scopes=["other:thing"], expires_at=_FUTURE)} + ) + + async with mounted_app(Server("rs"), auth=settings, token_verifier=verifier) as (http, _): + response = await post_mcp(http, bearer="tok-zeroscope") + + assert response.status_code == 403 + assert parse_www_authenticate(response.headers["www-authenticate"]) == { + "error": "insufficient_scope", + "error_description": "Required scope: mcp:read", + "resource_metadata": RESOURCE_METADATA_URL, + } + + @requirement("hosting:auth:aud-validation") async def test_a_token_with_a_mismatched_audience_is_accepted(protected: httpx.AsyncClient) -> None: """A token whose `resource` does not match the server's resource identifier is accepted. diff --git a/tests/interaction/auth/test_discovery.py b/tests/interaction/auth/test_discovery.py index 1317fd19d..b45606a95 100644 --- a/tests/interaction/auth/test_discovery.py +++ b/tests/interaction/auth/test_discovery.py @@ -150,7 +150,7 @@ async def test_when_every_prm_probe_fails_the_client_discovers_as_metadata_at_th assert result.tools[0].name == "probe" -@requirement("client-auth:dcr:registration-error-surfaces") +@requirement("client-auth:dcr:registration-rejected-error") async def test_a_400_from_the_registration_endpoint_surfaces_as_a_registration_error() -> None: """A 400 from `/register` surfaces as `OAuthRegistrationError` carrying the server's body. diff --git a/tests/interaction/auth/test_flow.py b/tests/interaction/auth/test_flow.py index e98735abf..2e9d8d13e 100644 --- a/tests/interaction/auth/test_flow.py +++ b/tests/interaction/auth/test_flow.py @@ -22,7 +22,7 @@ from mcp.server import Server, ServerRequestContext from mcp.server.auth.middleware.auth_context import get_access_token -from mcp.shared.auth import OAuthClientInformationFull +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata from tests.interaction._connect import BASE_URL from tests.interaction._requirements import requirement from tests.interaction.auth._harness import ( @@ -215,6 +215,93 @@ async def test_the_dcr_request_carries_the_client_metadata() -> None: assert list(provider.clients) == [storage.client_info.client_id] +@requirement("client-auth:dcr:grant-types-default") +async def test_dcr_defaults_grant_types_to_authorization_code_and_refresh_token_when_omitted() -> None: + """Registration metadata without `grant_types` sends `["authorization_code", "refresh_token"]`. + + The metadata is built directly rather than via `oauth_client_metadata()`, which sets `grant_types`. + """ + requests: list[httpx.Request] = [] + provider = InMemoryAuthorizationServerProvider() + server = Server("guarded", on_list_tools=list_tools) + client_metadata = OAuthClientMetadata(client_name="interaction-suite", redirect_uris=[AnyUrl(REDIRECT_URI)]) + + with anyio.fail_after(5): + async with connect_with_oauth( + server, provider=provider, client_metadata=client_metadata, on_request=requests.append + ) as (client, _): + result = await client.list_tools() + + assert result.tools[0].name == "whoami" + + register = next(r for r in requests if r.url.path == "/register") + assert json.loads(register.content) == snapshot( + { + "redirect_uris": ["http://127.0.0.1:8000/oauth/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "scope": "mcp", + "application_type": "native", + "client_name": "interaction-suite", + } + ) + + +@requirement("client-auth:dcr:grant-types-default") +async def test_dcr_sends_consumer_set_grant_types_verbatim() -> None: + """A consumer-set `grant_types` is sent on the registration request verbatim, never rewritten. + + The value deliberately differs from the default pair so pass-through is distinguishable from defaulting. + """ + requests: list[httpx.Request] = [] + provider = InMemoryAuthorizationServerProvider() + server = Server("guarded", on_list_tools=list_tools) + client_metadata = OAuthClientMetadata( + client_name="interaction-suite", + redirect_uris=[AnyUrl(REDIRECT_URI)], + grant_types=["authorization_code"], + ) + + with anyio.fail_after(5): + async with connect_with_oauth( + server, provider=provider, client_metadata=client_metadata, on_request=requests.append + ) as (client, _): + result = await client.list_tools() + + assert result.tools[0].name == "whoami" + + register = next(r for r in requests if r.url.path == "/register") + assert json.loads(register.content)["grant_types"] == ["authorization_code"] + + +@requirement("client-auth:dcr:app-type-override") +async def test_dcr_sends_a_consumer_set_application_type_verbatim() -> None: + """A consumer-set `application_type` is sent on the registration request verbatim, never rewritten. + + `"web"` against a loopback redirect URI is deliberately not what redirect-URI derivation + would produce, so pass-through stays distinguishable from any future derivation strategy. + """ + requests: list[httpx.Request] = [] + provider = InMemoryAuthorizationServerProvider() + server = Server("guarded", on_list_tools=list_tools) + client_metadata = OAuthClientMetadata( + client_name="interaction-suite", + redirect_uris=[AnyUrl(REDIRECT_URI)], + application_type="web", + ) + + with anyio.fail_after(5): + async with connect_with_oauth( + server, provider=provider, client_metadata=client_metadata, on_request=requests.append + ) as (client, _): + result = await client.list_tools() + + assert result.tools[0].name == "whoami" + + register = next(r for r in requests if r.url.path == "/register") + assert json.loads(register.content)["application_type"] == "web" + + async def test_shimmed_app_serves_overrides_404s_and_otherwise_forwards_to_the_wrapped_app() -> None: """Harness self-test: `shimmed_app` serves canned bodies, 404s, and forwards everything else. diff --git a/tests/interaction/auth/test_lifecycle.py b/tests/interaction/auth/test_lifecycle.py index c810f8c44..f2d5395c8 100644 --- a/tests/interaction/auth/test_lifecycle.py +++ b/tests/interaction/auth/test_lifecycle.py @@ -14,13 +14,13 @@ import mcp_types as types import pytest from inline_snapshot import snapshot -from mcp_types import INTERNAL_ERROR, ListToolsResult, Tool +from mcp_types import INTERNAL_ERROR, ErrorData, ListToolsResult, Tool from pydantic import AnyHttpUrl, AnyUrl from mcp import MCPError from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider from mcp.server import Server, ServerRequestContext -from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata +from mcp.shared.auth import OAuthClientInformationFull, OAuthMetadata, OAuthToken from tests.interaction._connect import BASE_URL from tests.interaction._requirements import requirement from tests.interaction.auth._harness import ( @@ -29,6 +29,7 @@ RecordedRequest, auth_settings, connect_with_oauth, + get_stream_step_up_shim, m2m_token_shim, metadata_body, record_requests, @@ -135,6 +136,61 @@ async def test_an_expired_access_token_is_transparently_refreshed_before_the_nex assert storage.tokens.expires_in == 3600 +@requirement("client-auth:refresh:rotation-handling") +async def test_the_rotated_refresh_token_from_a_refresh_response_replaces_the_stored_one() -> None: + """A new refresh token in a refresh response replaces the stored one (RFC 6749 §6 rotation).""" + recorded, on_request = record_requests() + provider = InMemoryAuthorizationServerProvider(issue_expired_first=True) + storage = InMemoryTokenStorage() + server = Server("guarded", on_list_tools=list_tools) + + with anyio.fail_after(5): + async with connect_with_oauth(server, provider=provider, storage=storage, on_request=on_request) as (client, _): + await client.list_tools() + + token_posts = find(recorded, "POST", "/token") + assert [form_body(r)["grant_type"] for r in token_posts] == snapshot(["authorization_code", "refresh_token"]) + + presented = form_body(token_posts[1])["refresh_token"] + assert storage.tokens is not None + assert storage.tokens.refresh_token != presented + # The stored token is the one the AS minted; the AS consumed the presented one. + assert storage.tokens.refresh_token in provider.refresh_tokens + assert presented not in provider.refresh_tokens + + +@requirement("client-auth:refresh:rotation-handling") +async def test_a_refresh_response_without_a_refresh_token_preserves_the_stored_one() -> None: + """A refresh response that omits `refresh_token` leaves the stored one in place. + + RFC 6749 §6 lets the authorization server omit `refresh_token` from a refresh response; + `rotate_refresh_tokens=False` models that non-rotating AS. + """ + recorded, on_request = record_requests() + provider = InMemoryAuthorizationServerProvider(issue_expired_first=True, rotate_refresh_tokens=False) + storage = InMemoryTokenStorage() + server = Server("guarded", on_list_tools=list_tools) + + with anyio.fail_after(5): + async with connect_with_oauth(server, provider=provider, storage=storage, on_request=on_request) as (client, _): + result = await client.list_tools() + + assert result.tools[0].name == "echo" + + token_posts = find(recorded, "POST", "/token") + assert [form_body(r)["grant_type"] for r in token_posts] == snapshot(["authorization_code", "refresh_token"]) + + assert storage.tokens is not None + assert storage.tokens.refresh_token == form_body(token_posts[1])["refresh_token"] + # expires_in flipping from -3600 proves the refresh response was adopted, not dropped. + assert storage.tokens.expires_in == 3600 + + # The omission triggered no re-authorization or re-registration. + counts = path_counts(recorded) + assert counts[("GET", "/authorize")] == 1 + assert counts[("POST", "/register")] == 1 + + @requirement("client-auth:403-scope-upgrade") async def test_a_403_insufficient_scope_triggers_one_reauthorize_with_the_challenged_scope() -> None: """A 403 `insufficient_scope` challenge is answered by one re-authorize with the challenge's scope. @@ -145,7 +201,7 @@ async def test_a_403_insufficient_scope_triggers_one_reauthorize_with_the_challe wider scope; step-up reuses cached metadata and the existing client registration, re-authorizes with the new scope, and the connect completes. The client is pre-registered with both scopes so the server's authorize handler accepts the wider second request. One - re-authorize, one retry; the spec's SHOULD-retry-limit ("a few") is not enforced. + re-authorize, one retry; the per-send bound is pinned by `client-auth:stepup:retry-cap`. """ recorded, on_request = record_requests() provider = InMemoryAuthorizationServerProvider() @@ -179,7 +235,7 @@ async def test_a_403_insufficient_scope_triggers_one_reauthorize_with_the_challe assert counts[("POST", "/token")] == 2 -@requirement("client-auth:403-scope-union") +@requirement("client-auth:stepup:scope-union") async def test_a_403_step_up_re_authorizes_with_the_union_of_prior_and_challenged_scopes() -> None: """The step-up re-authorize requests the union of the previously requested and challenged scopes. @@ -208,7 +264,8 @@ async def test_a_403_step_up_re_authorizes_with_the_union_of_prior_and_challenge assert authorize_params(headless.authorize_urls[1])["scope"] == "mcp write" -@requirement("client-auth:as-binding") +@requirement("client-auth:as-binding:reregister") +@requirement("client-auth:as-binding:no-cred-reuse") async def test_credentials_bound_to_a_different_issuer_are_discarded_and_the_client_re_registers() -> None: """Credentials bound to a stale issuer are dropped and re-registered against the current AS. @@ -242,6 +299,134 @@ async def test_credentials_bound_to_a_different_issuer_are_discarded_and_the_cli assert storage.client_info.issuer == f"{BASE_URL}/" +@requirement("client-auth:as-binding:no-token-reuse") +async def test_tokens_from_the_previous_authorization_server_are_never_replayed_after_migration() -> None: + """Tokens from the previous authorization server are discarded with its credentials, never replayed. + + Storage carries an old-issuer registration plus that server's tokens (SEP-2352); the discard + must drop both, so the stale refresh token reaches no endpoint of the new authorization server. + """ + recorded, on_request = record_requests() + provider = InMemoryAuthorizationServerProvider() + # Not via `seeded_client`: the old AS's client must not be registered with the current provider. + stale = OAuthClientInformationFull.model_validate( + { + "client_id": "stale-as-client", + "token_endpoint_auth_method": "none", + "redirect_uris": [AnyUrl(REDIRECT_URI)], + "grant_types": ["authorization_code", "refresh_token"], + "scope": "mcp", + "issuer": "https://old-as.example.com", + } + ) + storage = InMemoryTokenStorage(client_info=stale) + storage.tokens = OAuthToken( + access_token="stale-access-token", + token_type="Bearer", + expires_in=3600, + scope="mcp", + refresh_token="stale-refresh-token", + ) + server = Server("guarded", on_list_tools=list_tools) + + with anyio.fail_after(5): + async with connect_with_oauth(server, provider=provider, storage=storage, on_request=on_request) as (client, _): + result = await client.list_tools() + + token_posts = find(recorded, "POST", "/token") + assert [form_body(r)["grant_type"] for r in token_posts] == snapshot(["authorization_code"]) + + for r in recorded: + assert "stale-refresh-token" not in r.content.decode() + assert "stale-refresh-token" not in r.url.query.decode() + + # Non-vacuity: the stale access token was actually presented, and refused. + stale_bearer_paths = [r.path for r in recorded if r.headers.get("authorization") == "Bearer stale-access-token"] + assert stale_bearer_paths == ["/mcp"] + + assert path_counts(recorded)[("POST", "/register")] == 1 + + assert result.tools[0].name == "echo" + assert storage.tokens is not None + assert storage.tokens.refresh_token != "stale-refresh-token" + + +@requirement("client-auth:as-binding:cimd-portable") +async def test_a_cimd_client_id_survives_an_authorization_server_change_without_reregistration() -> None: + """A CIMD client_id keeps working across an authorization-server change with no re-registration. + + CIMD client IDs are URLs the authorization server resolves on demand; pre-seeding the + provider stands in for that resolution (the SDK server has no CIMD-aware client lookup). + """ + recorded, on_request = record_requests() + provider = InMemoryAuthorizationServerProvider() + info = seeded_client(provider, client_id=CIMD_URL, issuer="https://old-as.example.com") + storage = InMemoryTokenStorage(client_info=info) + server = Server("guarded", on_list_tools=list_tools) + + with anyio.fail_after(5): + async with connect_with_oauth( + server, + provider=provider, + storage=storage, + client_metadata_url=CIMD_URL, + on_request=on_request, + ) as (client, headless): + result = await client.list_tools() + + # The spec sentence itself: "no re-registration is needed when the authorization server changes". + assert find(recorded, "POST", "/register") == [] + + assert headless.authorize_url is not None + assert authorize_params(headless.authorize_url)["client_id"] == CIMD_URL + + assert result.tools[0].name == "echo" + assert [form_body(r)["grant_type"] for r in find(recorded, "POST", "/token")] == snapshot(["authorization_code"]) + + # The issuer stamp is deliberately not re-stamped on CIMD credentials; a re-stamp fails here consciously. + assert storage.client_info is not None + assert storage.client_info.client_id == CIMD_URL + assert storage.client_info.issuer == "https://old-as.example.com" + + +@requirement("client-auth:as-binding:prereg-mismatch-error") +async def test_preregistered_credentials_bound_to_a_different_issuer_are_silently_replaced_without_an_error() -> None: + """Pre-registered credentials with a mismatched issuer are silently replaced rather than erroring. + + The spec's SHOULD-surface-an-error is missed: the SDK cannot tell pre-registered from + DCR-persisted credentials, so the mismatch takes the discard-and-re-register path -- a recorded divergence. + """ + recorded, on_request = record_requests() + provider = InMemoryAuthorizationServerProvider() + prereg = seeded_client( + provider, + client_id="prereg-old-as", + client_secret="prereg-secret", + token_endpoint_auth_method="client_secret_post", + issuer="https://old-as.example.com", + ) + storage = InMemoryTokenStorage(client_info=prereg) + server = Server("guarded", on_list_tools=list_tools) + + with anyio.fail_after(5): + async with connect_with_oauth(server, provider=provider, storage=storage, on_request=on_request) as (client, _): + result = await client.list_tools() + + assert result.tools[0].name == "echo" + + assert path_counts(recorded)[("POST", "/register")] == 1 + + # Only the error half of the SHOULD is missed: the mismatched credential is never presented. + for r in recorded: + assert "prereg-old-as" not in r.url.query.decode() + assert "prereg-old-as" not in r.content.decode() + assert "prereg-secret" not in r.content.decode() + + assert storage.client_info is not None + assert storage.client_info.client_id != "prereg-old-as" + assert storage.client_info.issuer == f"{BASE_URL}/" + + @requirement("client-auth:401-after-auth-throws") async def test_a_second_401_after_a_completed_oauth_flow_surfaces_without_looping() -> None: """A 401 on the post-auth retry surfaces as an error rather than re-entering discovery. @@ -507,3 +692,89 @@ async def test_registration_priority_prefers_preregistered_then_cimd_then_dcr( else: assert find(recorded, "POST", "/register") == [] assert chosen_client_id == expected_client_id + + +@requirement("client-auth:stepup:retry-cap") +async def test_a_second_insufficient_scope_403_after_a_step_up_surfaces_without_another_authorize() -> None: + """A persistent 403 gets one step-up and one retry, then the retried request's 403 surfaces as an error. + + The bound is structural, not a counter: the auth flow re-authorizes once, yields one retry, + and its generator ends, so the second 403 surfaces as the legacy transport's INTERNAL_ERROR. + The shim 403s from the third authenticated POST (the `list_tools` request) because the + client silently drops a non-2xx response to a notification POST. + """ + recorded, on_request = record_requests() + provider = InMemoryAuthorizationServerProvider() + storage = InMemoryTokenStorage(client_info=seeded_client(provider, scope="mcp write")) + server = Server("guarded", on_list_tools=list_tools) + settings = auth_settings(required_scopes=["mcp"], valid_scopes=["mcp", "write"]) + challenge = 'Bearer error="insufficient_scope", scope="mcp write"' + + with anyio.fail_after(5): + async with connect_with_oauth( + server, + provider=provider, + storage=storage, + settings=settings, + app_shim=step_up_shim(challenge, on_nth_authenticated_post=3, persist=True), + on_request=on_request, + ) as (client, headless): + # A sync `with` beside an `async with` mis-traces its body arc under branch coverage. + with pytest.raises(MCPError) as exc_info: # pragma: no branch + await client.list_tools() + + assert exc_info.value.error == snapshot(ErrorData(code=INTERNAL_ERROR, message="Server returned an error response")) + + assert len(headless.authorize_urls) == 2 + assert authorize_params(headless.authorize_urls[0])["scope"] == "mcp" + assert authorize_params(headless.authorize_urls[1])["scope"] == "mcp write" + + # init-retry, initialized, challenged list_tools, retried list_tools -- and no fifth. + authenticated_posts = [r for r in find(recorded, "POST", "/mcp") if "authorization" in r.headers] + assert len(authenticated_posts) == 4 + counts = path_counts(recorded) + assert counts[("POST", "/mcp")] == 5 + assert counts[("POST", "/token")] == 2 + + +@requirement("client-auth:stepup:get-stream-403") +async def test_a_403_on_the_get_stream_open_steps_up_and_reopens_the_stream_with_the_upgraded_token() -> None: + """A 403 `insufficient_scope` on the standalone GET stream open steps up and reopens the stream. + + The standalone GET (a 2025-11-25 mechanism, removed at 2026-07-28) is opened by the SDK in the + background and is invisible to `Client`, so the harness shim records each authenticated + GET's response status and the test waits on the reopened stream's 200 before acting. The + failure arm stays unpinned: the transport swallows GET failures into a timed reconnect loop + this suite cannot observe without sleeps. + """ + recorded, on_request = record_requests() + provider = InMemoryAuthorizationServerProvider() + storage = InMemoryTokenStorage(client_info=seeded_client(provider, scope="mcp write")) + server = Server("guarded", on_list_tools=list_tools) + settings = auth_settings(required_scopes=["mcp"], valid_scopes=["mcp", "write"]) + statuses, reopened, app_shim = get_stream_step_up_shim('Bearer error="insufficient_scope", scope="mcp write"') + + with anyio.fail_after(5): + async with connect_with_oauth( + server, + provider=provider, + storage=storage, + settings=settings, + app_shim=app_shim, + on_request=on_request, + ) as (client, headless): + await reopened.wait() + result = await client.list_tools() + + assert result.tools[0].name == "echo" + + assert statuses == [403, 200] + + assert len(headless.authorize_urls) == 2 + assert authorize_params(headless.authorize_urls[0])["scope"] == "mcp" + assert authorize_params(headless.authorize_urls[1])["scope"] == "mcp write" + + first_get, second_get = find(recorded, "GET", "/mcp") + assert storage.tokens is not None + assert second_get.headers["authorization"] == f"Bearer {storage.tokens.access_token}" + assert first_get.headers["authorization"] != second_get.headers["authorization"] diff --git a/tests/interaction/lowlevel/test_caching.py b/tests/interaction/lowlevel/test_caching.py new file mode 100644 index 000000000..fbd4e6ffc --- /dev/null +++ b/tests/interaction/lowlevel/test_caching.py @@ -0,0 +1,304 @@ +"""SEP-2549 caching hints: producer-side stamping and client-facing TTL/scope semantics. + +One test pins 2026 wire frames (typed models hide absent-vs-default keys); one scripts a +non-conformant server (the typed Server cannot author the malformed value). The client response +cache is live but its serve/evict behaviours are not yet pinned here (see the caching:* deferrals). +""" + +import anyio +import mcp_types as types +import pytest +from mcp_types import ( + DiscoverResult, + ElicitRequest, + ElicitRequestFormParams, + ElicitResult, + Implementation, + InputRequiredResult, + JSONRPCRequest, + JSONRPCResponse, + ListPromptsResult, + ListResourceTemplatesResult, + ListToolsResult, + Prompt, + ReadResourceResult, + ResourceTemplate, + ServerCapabilities, + TextResourceContents, + Tool, +) +from mcp_types.version import LATEST_MODERN_VERSION + +from mcp.client import ClientRequestContext +from mcp.client.client import Client +from mcp.client.session import ClientSession +from mcp.client.streamable_http import streamable_http_client +from mcp.server import Server, ServerRequestContext +from mcp.shared.memory import create_client_server_memory_streams +from mcp.shared.message import SessionMessage +from tests.interaction._connect import BASE_URL, Connect, mounted_app +from tests.interaction._helpers import RecordingTransport +from tests.interaction._requirements import requirement + +pytestmark = pytest.mark.anyio + +# Non-default values (the defaults are 0/"private") prove the authored hints travelled. +PROMPTS_TTL_MS = 60_000 +TEMPLATES_TTL_MS = 120_000 + +_NAME_SCHEMA: dict[str, object] = { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], +} + + +@requirement("caching:hints:prompts-list") +async def test_prompts_list_result_carries_the_handler_authored_ttl_and_scope_hints(connect: Connect) -> None: + """Handler-authored ttlMs/cacheScope on a prompts/list result reach the client unmodified. Spec-mandated.""" + + async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListPromptsResult: + assert params is not None + return ListPromptsResult(prompts=[Prompt(name="greet")], ttl_ms=PROMPTS_TTL_MS, cache_scope="public") + + server = Server("cached", on_list_prompts=list_prompts) + + async with connect(server) as client: + result = await client.list_prompts() + + assert result.ttl_ms == PROMPTS_TTL_MS + assert result.cache_scope == "public" + assert result.result_type == "complete" + assert result.prompts == [Prompt(name="greet")] + + +@requirement("caching:hints:resources-templates-list") +async def test_resource_templates_list_result_carries_the_handler_authored_ttl_and_scope_hints( + connect: Connect, +) -> None: + """Handler-authored hints on a resources/templates/list result reach the client unmodified. Spec-mandated.""" + + async def list_resource_templates( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> ListResourceTemplatesResult: + assert params is not None + return ListResourceTemplatesResult( + resource_templates=[ResourceTemplate(name="file", uri_template="file:///{name}")], + ttl_ms=TEMPLATES_TTL_MS, + cache_scope="public", + ) + + server = Server("cached", on_list_resource_templates=list_resource_templates) + + async with connect(server) as client: + result = await client.list_resource_templates() + + assert result.ttl_ms == TEMPLATES_TTL_MS + assert result.cache_scope == "public" + assert result.result_type == "complete" + assert result.resource_templates == [ResourceTemplate(name="file", uri_template="file:///{name}")] + + +@requirement("caching:pagination:same-scope-all-pages") +async def test_mismatched_per_page_cache_scopes_are_forwarded_unmodified_across_a_cursor_walk( + connect: Connect, +) -> None: + """Mismatched per-page cacheScopes in one cursor walk reach the client unmodified (pinned Divergence). + + When enforcement lands: re-pin to `page2.cache_scope == page1.cache_scope` and delete the Divergence. + """ + seen_cursors: list[str | None] = [] + + async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + assert params is not None + seen_cursors.append(params.cursor) + if params.cursor is None: + return ListToolsResult( + tools=[Tool(name="a", input_schema={"type": "object"})], + next_cursor="page-2", + cache_scope="public", + ) + assert params.cursor == "page-2" + # Deliberately mismatched with page 1's "public": the forwarded mismatch is the pinned gap. + return ListToolsResult(tools=[Tool(name="b", input_schema={"type": "object"})], cache_scope="private") + + server = Server("cached", on_list_tools=list_tools) + + async with connect(server) as client: + page1 = await client.list_tools() + page2 = await client.list_tools(cursor=page1.next_cursor) + + assert page1.cache_scope == "public" + assert page2.cache_scope == "private" + assert seen_cursors == [None, "page-2"] + + +@requirement("caching:ttl:absent-defaults-zero") +async def test_a_result_without_ttl_from_a_2025_server_surfaces_the_immediately_stale_defaults( + connect: Connect, +) -> None: + """A hint-less 2025-era result surfaces ttl_ms 0 (immediately stale) and cache_scope private. + + The ttl half is the spec SHOULD for older servers; the private half is SDK-defined behaviour. + """ + + async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + assert params is not None + # Neither hint authored: the spec's "older server versions" scenario. + return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})]) + + server = Server("cached", on_list_tools=list_tools) + + async with connect(server) as client: + result = await client.list_tools() + + assert result.ttl_ms == 0 + assert result.cache_scope == "private" + assert [tool.name for tool in result.tools] == ["t"] + + +@requirement("caching:ttl:zero-immediately-stale") +async def test_ttl_zero_results_are_refetched_on_every_access(connect: Connect) -> None: + """Two consecutive list_tools calls against a ttlMs-0 server both reach the handler. + + Load-bearing against the live response cache: a ttlMs-0 result is never stored, so every + access re-fetches, while the same seam with a positive ttl_ms serves the second access from + cache (one fetch). + """ + fetches: list[int] = [] + + async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + assert params is not None + fetches.append(1) + # Explicit ttl_ms=0: the value under test, not the default's accident. + return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})], ttl_ms=0, cache_scope="public") + + server = Server("cached", on_list_tools=list_tools) + + async with connect(server) as client: + first = await client.list_tools() + second = await client.list_tools() + + assert len(fetches) == 2 + assert first.ttl_ms == 0 + assert second.ttl_ms == 0 + + +# --- wire-level: the modern HTTP entry is the only 2026 framing seam --- + + +@requirement("caching:input-required:no-hints") +@requirement("mrtr:input-required-result:result-type-serialized") +async def test_the_interim_input_required_frame_carries_no_caching_hints_while_the_complete_frame_does() -> None: + """The serialized interim input_required frame carries no caching hints; the terminal complete frame does. + + Asserted at the transport seam because typed models hide absent-vs-default; the key-set pin + also proves resultType is serialized explicitly (the stacked mrtr entry). Spec-mandated. + """ + + async def read_resource( + ctx: ServerRequestContext, params: types.ReadResourceRequestParams + ) -> ReadResourceResult | InputRequiredResult: + assert str(params.uri) == "res://profile" + if params.input_responses is None: + return InputRequiredResult( + input_requests={ + "who": ElicitRequest(params=ElicitRequestFormParams(message="Who?", requested_schema=_NAME_SCHEMA)) + } + ) + answer = params.input_responses["who"] + assert isinstance(answer, ElicitResult) + assert answer.content is not None + # Non-default hints: the contrast frame is provably handler-driven, not default fill. + return ReadResourceResult( + contents=[TextResourceContents(uri="res://profile", text=f"hi {answer.content['name']}")], + ttl_ms=60_000, + cache_scope="public", + ) + + server = Server("cached", on_read_resource=read_resource) + + async def answer_who(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult: + assert isinstance(params, ElicitRequestFormParams) + return ElicitResult(action="accept", content={"name": "ada"}) + + with anyio.fail_after(5): + # Combined async-with (recorder via :=): a nested `async with` mis-traces exit arcs under 3.11+ branch coverage. + async with ( + mounted_app(server) as (http, _), + Client( + recording := RecordingTransport(streamable_http_client(f"{BASE_URL}/mcp", http_client=http)), + mode=LATEST_MODERN_VERSION, + elicitation_callback=answer_who, + ) as client, + ): + result = await client.read_resource("res://profile") + + # resources/read generates no implicit sibling traffic, so the whole request log is asserted. + reads = [message.message for message in recording.sent if isinstance(message.message, JSONRPCRequest)] + assert [read.method for read in reads] == ["resources/read", "resources/read"] + responses = { + message.message.id: message.message.result + for message in recording.received + if isinstance(message, SessionMessage) and isinstance(message.message, JSONRPCResponse) + } + interim = responses[reads[0].id] + complete = responses[reads[1].id] + # Exact key vocabulary, stronger than `not in` checks: any field added to interim frames fails loudly. + assert sorted(interim) == ["inputRequests", "resultType"] + assert interim["resultType"] == "input_required" + assert complete["ttlMs"] == 60_000 + assert complete["cacheScope"] == "public" + assert complete["resultType"] == "complete" + assert result.contents == [TextResourceContents(uri="res://profile", text="hi ada")] + assert result.ttl_ms == 60_000 + + +# --- scripted peer: a malformed inbound value the typed Server cannot author --- + + +@requirement("caching:ttl:negative-treated-as-zero") +async def test_a_negative_ttl_from_a_nonconformant_server_is_clamped_to_zero() -> None: + """An inbound ttlMs of -1 surfaces as ttl_ms 0 instead of failing validation. Spec-mandated (SHOULD). + + Scripted peer: the typed Server cannot author a negative ttlMs (emission keeps ge=0). + """ + async with create_client_server_memory_streams() as (client_streams, server_streams): + client_read, client_write = client_streams + server_read, server_write = server_streams + + async def scripted_server() -> None: + with anyio.fail_after(5): + incoming = await server_read.receive() + assert isinstance(incoming, SessionMessage) + assert isinstance(incoming.message, JSONRPCRequest) + assert incoming.message.method == "tools/list" + await server_write.send( + SessionMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=incoming.message.id, + result={"tools": [], "resultType": "complete", "ttlMs": -1, "cacheScope": "public"}, + ) + ) + ) + + # Combined async-with: a nested `async with` mis-traces exit arcs under 3.11+ branch coverage. + async with ( + anyio.create_task_group() as task_group, + ClientSession(client_read, client_write, client_info=Implementation(name="cli", version="0")) as session, + ): + task_group.start_soon(scripted_server) + session.adopt( + DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + server_info=Implementation(name="srv", version="0"), + ) + ) + with anyio.fail_after(5): + result = await session.list_tools() + + assert result.ttl_ms == 0 + assert result.cache_scope == "public" + assert result.tools == [] diff --git a/tests/interaction/lowlevel/test_cancellation.py b/tests/interaction/lowlevel/test_cancellation.py index 247e1135a..b14e9f687 100644 --- a/tests/interaction/lowlevel/test_cancellation.py +++ b/tests/interaction/lowlevel/test_cancellation.py @@ -87,6 +87,63 @@ async def call_and_capture_error() -> None: assert errors == snapshot([ErrorData(code=0, message="Request cancelled")]) +@requirement("protocol:cancel:no-further-notifications") +async def test_no_notifications_for_a_request_arrive_after_its_cancellation(connect: Connect) -> None: + """After a request is cancelled, no further notifications for it reach the wire (spec-mandated).""" + started = anyio.Event() + handler_cancelled = anyio.Event() + request_ids: list[types.RequestId] = [] + attempted: list[str] = [] + progress_updates: list[tuple[float, float | None, str | None]] = [] + + async def collect(progress: float, total: float | None, message: str | None) -> None: + progress_updates.append((progress, total, message)) + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + assert params.name == "block" + assert ctx.request_id is not None + request_ids.append(ctx.request_id) + # Proves the progress channel is live before the cancellation arrives. + await ctx.session.report_progress(1.0, total=2.0, message="started") + started.set() + try: + await anyio.Event().wait() # blocks until cancelled + except anyio.get_cancelled_exc_class(): + handler_cancelled.set() + try: + # The MUST NOT under test: attempting a send during the unwind proves the negative is enforced. + await ctx.session.report_progress(2.0, total=2.0, message="too late") + except anyio.get_cancelled_exc_class(): + attempted.append("send-cancelled") + raise + raise NotImplementedError # unreachable + raise NotImplementedError # unreachable + + server = Server("blocker", on_call_tool=call_tool) + + async with connect(server) as client: + with anyio.fail_after(5): + async with anyio.create_task_group() as task_group: + + async def call_and_swallow_cancellation_error() -> None: + with pytest.raises(MCPError): + await client.call_tool("block", {}, progress_callback=collect) + + task_group.start_soon(call_and_swallow_cancellation_error) + await started.wait() + await client.session.send_notification( + types.CancelledNotification( + params=types.CancelledNotificationParams(request_id=request_ids[0], reason="user aborted") + ) + ) + + await handler_cancelled.wait() + + # Progress shares the ordered stream with the error response: a sent "too late" would already be here. + assert progress_updates == [(1.0, 2.0, "started")] + assert attempted == ["send-cancelled"] + + @requirement("protocol:cancel:server-survives") async def test_session_serves_requests_after_cancellation(connect: Connect) -> None: """A request cancelled mid-flight does not poison the session: the next request succeeds.""" diff --git a/tests/interaction/lowlevel/test_client_connect.py b/tests/interaction/lowlevel/test_client_connect.py index 69fd5c4e8..33a63dc1b 100644 --- a/tests/interaction/lowlevel/test_client_connect.py +++ b/tests/interaction/lowlevel/test_client_connect.py @@ -19,6 +19,7 @@ import httpx import mcp_types as types import pytest +from inline_snapshot import snapshot from mcp_types import ( CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, @@ -26,6 +27,7 @@ METHOD_NOT_FOUND, PROTOCOL_VERSION_META_KEY, UNSUPPORTED_PROTOCOL_VERSION, + CompletionsCapability, DiscoverResult, Implementation, InitializeResult, @@ -33,6 +35,7 @@ JSONRPCNotification, JSONRPCRequest, JSONRPCResponse, + PromptsCapability, ServerCapabilities, ToolsCapability, ) @@ -154,7 +157,7 @@ async def test_prior_discover_populates_state_with_zero_connect_time_traffic() - async def test_auto_mode_probes_server_discover_and_adopts_the_result() -> None: """`Client(..., mode='auto')` sends `server/discover` first and adopts the returned version and server_info. - Requirement `lifecycle:discover:basic` (spec basic/lifecycle#discover): the probe is a + Requirement `lifecycle:discover:basic` (spec server/discover): the probe is a single `server/discover` request whose result carries supported versions, capabilities, server_info and the cache-hint fields, after which the session is modern-negotiated. """ @@ -179,7 +182,7 @@ async def test_auto_mode_probes_server_discover_and_adopts_the_result() -> None: async def test_auto_mode_retries_discover_once_on_unsupported_protocol_version() -> None: """A -32022 from `server/discover` triggers exactly one retry at the highest mutual modern version. - Requirement `lifecycle:discover:retry-on-32022` (spec basic/lifecycle#version-errors): the + Requirement `lifecycle:discover:retry-on-32022` (spec basic/versioning#protocol-version-negotiation): the client intersects `error.data.supported` with its own modern versions and re-probes once; the second success is adopted. The server's `server/discover` handler is overridden to fail the first call and succeed on the second. @@ -349,7 +352,7 @@ async def list_tools( async def test_http_protocol_version_header_matches_meta_protocol_version_on_every_post() -> None: """On streamable-HTTP, the `MCP-Protocol-Version` header on each POST equals `_meta.protocolVersion` in its body. - Requirement `lifecycle:envelope:header-matches-meta` (spec streamable-http#headers): the + Requirement `lifecycle:envelope:header-matches-meta` (spec streamable-http#protocol-version-header): the body-derived header and the envelope's protocol version are kept in lockstep so the server's header-based routing and body-based validation never disagree. """ @@ -368,3 +371,142 @@ async def test_http_protocol_version_header_matches_meta_protocol_version_on_eve body = json.loads(request.content) assert request.headers["mcp-protocol-version"] == body["params"]["_meta"][PROTOCOL_VERSION_META_KEY] assert request.headers["mcp-protocol-version"] == LATEST_MODERN_VERSION + + +@requirement("lifecycle:discover:instructions") +async def test_discover_carries_server_instructions_and_omits_them_when_undeclared() -> None: + """A server's instructions string arrives through the `server/discover` result; an undeclared one reads None. + + Auto mode is the only public path doing a real probe; the version asserts rule out an initialize fallback. + """ + with anyio.fail_after(5): + async with Client(Server("guided", instructions="Call the add tool.")) as client: + assert client.protocol_version == LATEST_MODERN_VERSION + assert client.instructions == snapshot("Call the add tool.") + + with anyio.fail_after(5): + async with Client(Server("unguided")) as client: + assert client.protocol_version == LATEST_MODERN_VERSION + assert client.instructions is None + + +@requirement("lifecycle:discover:capabilities:from-handlers") +async def test_discover_capabilities_reflect_registered_handlers() -> None: + """The discover result advertises a capability per registered handler area and omits the rest. + + Only era-clean areas are registered. The subscription-delivered bits are era-honest (at 2026 + they derive from whether subscriptions/listen is served, so a legacy subscribe handler + advertises nothing), but logging derivation is still era-agnostic: a setLevel handler would + advertise the era-deprecated logging capability -- a quirk deliberately left unpinned here. + """ + + # The handlers exist only so their capability is advertised; none is ever called. + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + raise NotImplementedError + + async def list_prompts( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListPromptsResult: + raise NotImplementedError + + async def completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> types.CompleteResult: + raise NotImplementedError + + server = Server("capable", on_list_tools=list_tools, on_list_prompts=list_prompts, on_completion=completion) + + with anyio.fail_after(5): + async with Client(server) as client: + assert client.protocol_version == LATEST_MODERN_VERSION + assert client.server_capabilities == snapshot( + ServerCapabilities( + prompts=PromptsCapability(list_changed=False), + completions=CompletionsCapability(), + tools=ToolsCapability(list_changed=False), + ) + ) + + with anyio.fail_after(5): + async with Client(Server("bare")) as client: + assert client.server_capabilities == ServerCapabilities() + + +@requirement("lifecycle:mode:auto-probes-first") +async def test_auto_mode_sends_discover_before_any_other_request_at_its_preferred_modern_version() -> None: + """An auto-negotiating client's first wire request is `server/discover`, stamped with its preferred modern version. + + The spec sentence lives on the stdio page but binds transport-independent ordering, so the HTTP seam suffices. + """ + requests, on_request = _request_recorder() + + with anyio.fail_after(5): + async with ( + mounted_app(_tools_server(), on_request=on_request) as (http, _), + Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto") as client, + ): + await client.list_tools() + + bodies = [json.loads(r.content) for r in requests] + assert [b["method"] for b in bodies] == ["server/discover", "tools/list"] + assert bodies[0]["params"]["_meta"][PROTOCOL_VERSION_META_KEY] == LATEST_MODERN_VERSION + + +@requirement("lifecycle:discover:era-cached") +async def test_auto_mode_probes_discover_once_and_reuses_it_for_the_connection_lifetime() -> None: + """One `server/discover` probe serves the whole connection; an explicit `discover()` re-fetches nothing. + + `ClientSession` is reached directly because `Client` exposes no re-fetch surface. + """ + requests, on_request = _request_recorder() + + with anyio.fail_after(5): + async with ( + mounted_app(_tools_server(), on_request=on_request) as (http, _), + Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto") as client, + ): + adopted = client.session.discover_result + await client.list_tools() + await client.list_tools() + await client.list_tools() + again = await client.session.discover() + + assert [json.loads(r.content)["method"] for r in requests] == [ + "server/discover", + "tools/list", + "tools/list", + "tools/list", + ] + assert again is adopted + + +@requirement("lifecycle:discover:retry-on-32022") +async def test_auto_mode_raises_when_discover_rejects_with_a_disjoint_supported_list() -> None: + """A -32022 whose `supported` list shares no version with the client raises -- no retry, no initialize. + + The fully-disjoint "1999-12-31" isolates the raise: a modern member would trigger the one-shot + retry and a handshake member the initialize fallback. + """ + + async def discover(ctx: ServerRequestContext, params: types.RequestParams | None) -> DiscoverResult: + proposed = ctx.meta.get(PROTOCOL_VERSION_META_KEY) if ctx.meta else None + raise MCPError( + code=UNSUPPORTED_PROTOCOL_VERSION, + message="unsupported protocol version", + data={"supported": ["1999-12-31"], "requested": proposed}, + ) + + server = _tools_server("disjoint") + server.add_request_handler("server/discover", types.RequestParams, discover) + requests, on_request = _request_recorder() + + with anyio.fail_after(5): + async with mounted_app(server, on_request=on_request) as (http, _): + with pytest.RaisesGroup( + pytest.RaisesExc(MCPError, check=lambda e: e.error.code == UNSUPPORTED_PROTOCOL_VERSION), + flatten_subgroups=True, + ): + async with Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto"): + raise NotImplementedError("entering the Client should have raised") # pragma: no cover + + assert [json.loads(r.content)["method"] for r in requests] == ["server/discover"] diff --git a/tests/interaction/lowlevel/test_elicitation.py b/tests/interaction/lowlevel/test_elicitation.py index b8393dd31..07ea4984b 100644 --- a/tests/interaction/lowlevel/test_elicitation.py +++ b/tests/interaction/lowlevel/test_elicitation.py @@ -9,9 +9,11 @@ import pytest from inline_snapshot import snapshot from mcp_types import ( + URL_ELICITATION_REQUIRED, CallToolResult, ElicitCompleteNotification, ElicitCompleteNotificationParams, + ElicitRequest, ElicitRequestedSchema, ElicitRequestFormParams, ElicitRequestURLParams, @@ -19,6 +21,7 @@ ErrorData, Implementation, InitializeResult, + InputRequiredResult, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, @@ -26,14 +29,17 @@ ServerCapabilities, TextContent, ) +from mcp_types.version import LATEST_MODERN_VERSION from mcp import MCPError, UrlElicitationRequiredError from mcp.client import ClientRequestContext, ClientSession +from mcp.client.client import Client +from mcp.client.streamable_http import streamable_http_client from mcp.server import Server, ServerRequestContext from mcp.shared.memory import MessageStream, create_client_server_memory_streams from mcp.shared.message import SessionMessage -from tests.interaction._connect import Connect -from tests.interaction._helpers import IncomingMessage +from tests.interaction._connect import BASE_URL, Connect, mounted_app +from tests.interaction._helpers import IncomingMessage, RecordingTransport from tests.interaction._requirements import requirement pytestmark = pytest.mark.anyio @@ -240,7 +246,7 @@ async def answer_url(context: ClientRequestContext, params: types.ElicitRequestP assert result == snapshot(CallToolResult(content=[TextContent(text="accept content=None")])) -@requirement("elicitation:url:decline") +@requirement("elicitation:url:action:decline") async def test_elicit_url_decline_returns_no_content(connect: Connect) -> None: """A declined URL elicitation returns the decline action to the handler with no content.""" @@ -269,7 +275,7 @@ async def answer_url(context: ClientRequestContext, params: types.ElicitRequestP assert result == snapshot(CallToolResult(content=[TextContent(text="decline content=None")])) -@requirement("elicitation:url:cancel") +@requirement("elicitation:url:action:cancel") async def test_elicit_url_cancel_returns_no_content(connect: Connect) -> None: """A cancelled URL elicitation returns the cancel action to the handler with no content.""" @@ -663,3 +669,334 @@ async def scripted_server(streams: MessageStream) -> None: assert len(server_received) == 1 assert isinstance(server_received[0], JSONRPCResponse) assert server_received[0].id == 2 + + +@requirement("elicitation:mrtr:form:basic") +async def test_embedded_form_elicitation_accepted_content_returns_to_retried_handler(connect: Connect) -> None: + """An embedded form elicitation reaches the callback as sent and its accepted content reaches the handler. + + Spec-mandated: at 2026-07-28 elicitation/create rides the MRTR flow, not a server-initiated request. + """ + received: list[types.ElicitRequestParams] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[types.Tool(name="signup", description="Register the user.", input_schema={"type": "object"})] + ) + + async def call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams + ) -> CallToolResult | InputRequiredResult: + assert params.name == "signup" + if not params.input_responses: + return InputRequiredResult( + input_requests={ + "signup": ElicitRequest( + params=ElicitRequestFormParams(message="Choose a username.", requested_schema=REQUESTED_SCHEMA) + ) + } + ) + answer = params.input_responses["signup"] + assert isinstance(answer, ElicitResult) + return CallToolResult(content=[TextContent(text=answer.action)], structured_content=answer.content) + + server = Server("registrar", on_list_tools=list_tools, on_call_tool=call_tool) + + async def answer_form(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult: + received.append(params) + return ElicitResult(action="accept", content={"username": "ada", "newsletter": True}) + + async with connect(server, elicitation_callback=answer_form) as client: + result = await client.call_tool("signup", {}) + + assert received == snapshot( + [ + ElicitRequestFormParams( + message="Choose a username.", + requested_schema={ + "properties": {"username": {"type": "string"}, "newsletter": {"type": "boolean"}}, + "required": ["username"], + "type": "object", + }, + ) + ] + ) + assert result == snapshot( + CallToolResult(content=[TextContent(text="accept")], structured_content={"username": "ada", "newsletter": True}) + ) + + +@requirement("elicitation:mrtr:form:action:decline") +async def test_embedded_form_elicitation_decline_reaches_retried_handler_with_no_content(connect: Connect) -> None: + """An embedded form elicitation declined by the callback reaches the retried handler with no content.""" + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[types.Tool(name="confirm", description="Ask for confirmation.", input_schema={"type": "object"})] + ) + + async def call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams + ) -> CallToolResult | InputRequiredResult: + assert params.name == "confirm" + if not params.input_responses: + return InputRequiredResult( + input_requests={ + "confirm": ElicitRequest( + params=ElicitRequestFormParams( + message="Proceed?", requested_schema={"type": "object", "properties": {}} + ) + ) + } + ) + answer = params.input_responses["confirm"] + assert isinstance(answer, ElicitResult) + return CallToolResult(content=[TextContent(text=f"{answer.action} content={answer.content}")]) + + server = Server("confirmer", on_list_tools=list_tools, on_call_tool=call_tool) + + async def answer_form(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult: + return ElicitResult(action="decline") + + async with connect(server, elicitation_callback=answer_form) as client: + result = await client.call_tool("confirm", {}) + + assert result == snapshot(CallToolResult(content=[TextContent(text="decline content=None")])) + + +@requirement("elicitation:mrtr:form:action:cancel") +async def test_embedded_form_elicitation_cancel_reaches_retried_handler_with_no_content(connect: Connect) -> None: + """An embedded form elicitation cancelled by the callback reaches the retried handler with no content.""" + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[types.Tool(name="confirm", description="Ask for confirmation.", input_schema={"type": "object"})] + ) + + async def call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams + ) -> CallToolResult | InputRequiredResult: + assert params.name == "confirm" + if not params.input_responses: + return InputRequiredResult( + input_requests={ + "confirm": ElicitRequest( + params=ElicitRequestFormParams( + message="Proceed?", requested_schema={"type": "object", "properties": {}} + ) + ) + } + ) + answer = params.input_responses["confirm"] + assert isinstance(answer, ElicitResult) + return CallToolResult(content=[TextContent(text=f"{answer.action} content={answer.content}")]) + + server = Server("confirmer", on_list_tools=list_tools, on_call_tool=call_tool) + + async def answer_form(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult: + return ElicitResult(action="cancel") + + async with connect(server, elicitation_callback=answer_form) as client: + result = await client.call_tool("confirm", {}) + + assert result == snapshot(CallToolResult(content=[TextContent(text="cancel content=None")])) + + +@requirement("elicitation:mrtr:form:schema:primitives") +async def test_embedded_form_elicitation_schema_primitives_reach_the_callback_as_sent(connect: Connect) -> None: + """Primitive requested-schema fields on an embedded form elicitation reach the callback intact. + + Spec-mandated. One representative constraint per type; the exhaustive sweep lives with the 2025 push-path sibling. + """ + schema: ElicitRequestedSchema = { + "type": "object", + "properties": { + "email": {"type": "string", "format": "email", "title": "Email"}, + "age": {"type": "integer", "minimum": 0}, + "score": {"type": "number"}, + "subscribed": {"type": "boolean", "default": False}, + }, + "required": ["email"], + } + received: list[types.ElicitRequestParams] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[types.Tool(name="profile", description="Collect a profile.", input_schema={"type": "object"})] + ) + + async def call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams + ) -> CallToolResult | InputRequiredResult: + assert params.name == "profile" + if not params.input_responses: + return InputRequiredResult( + input_requests={ + "profile": ElicitRequest( + params=ElicitRequestFormParams(message="Complete your profile.", requested_schema=schema) + ) + } + ) + answer = params.input_responses["profile"] + assert isinstance(answer, ElicitResult) + return CallToolResult(content=[TextContent(text=answer.action)], structured_content=answer.content) + + server = Server("profiler", on_list_tools=list_tools, on_call_tool=call_tool) + + async def answer_form(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult: + received.append(params) + return ElicitResult( + action="accept", content={"email": "ada@example.com", "age": 36, "score": 9.5, "subscribed": True} + ) + + async with connect(server, elicitation_callback=answer_form) as client: + result = await client.call_tool("profile", {}) + + assert received == snapshot( + [ + ElicitRequestFormParams( + message="Complete your profile.", + requested_schema={ + "properties": { + "email": {"type": "string", "format": "email", "title": "Email"}, + "age": {"type": "integer", "minimum": 0}, + "score": {"type": "number"}, + "subscribed": {"type": "boolean", "default": False}, + }, + "required": ["email"], + "type": "object", + }, + ) + ] + ) + assert result == snapshot( + CallToolResult( + content=[TextContent(text="accept")], + structured_content={"email": "ada@example.com", "age": 36, "score": 9.5, "subscribed": True}, + ) + ) + + +@requirement("elicitation:mrtr:capability:not-declared") +async def test_server_embeds_elicitation_for_a_client_that_declared_no_elicitation_capability( + connect: Connect, +) -> None: + """Pins a known gap: the SDK embeds an elicitation for a client that declared no elicitation capability. + + The manual loop (allow_input_required=True) surfaces the server-side embed the auto loop would mask. + When the embed gate lands: re-pin to the gated behaviour and delete the Divergence. + """ + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> InputRequiredResult: + assert params.name == "ask" + # In-band precondition: the request envelope declared no elicitation capability. + assert ctx.session.client_params is not None + assert ctx.session.client_params.capabilities.elicitation is None + return InputRequiredResult( + input_requests={ + "ask": ElicitRequest( + params=ElicitRequestFormParams( + message="Anyone there?", requested_schema={"type": "object", "properties": {}} + ) + ) + } + ) + + server = Server("asker", on_call_tool=call_tool) + + async with connect(server) as client: + raw = await client.session.call_tool("ask", {}, allow_input_required=True) + + assert isinstance(raw, InputRequiredResult) + assert raw == snapshot( + InputRequiredResult( + input_requests={ + "ask": ElicitRequest( + params=ElicitRequestFormParams( + message="Anyone there?", requested_schema={"properties": {}, "type": "object"} + ) + ) + } + ) + ) + + +@requirement("mrtr:url-elicitation:no-32042-on-2026") +async def test_url_elicitation_rides_mrtr_and_no_32042_error_crosses_the_wire() -> None: + """At 2026-07-28 URL elicitation rides the MRTR loop and the retired -32042 code crosses no frame. + + Spec-mandated (-32042 is reserved-never-reused). Asserted at the client transport seam over + streamable HTTP; the exchange is POST-only, so every frame is captured before call_tool returns. + """ + received: list[types.ElicitRequestParams] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + # Live handler: the client's output-schema cache refresh calls tools/list after the first tools/call. + return types.ListToolsResult( + tools=[types.Tool(name="protected", description="Needs a sign-in.", input_schema={"type": "object"})] + ) + + async def call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams + ) -> CallToolResult | InputRequiredResult: + assert params.name == "protected" + if not params.input_responses: + return InputRequiredResult( + input_requests={ + "link": ElicitRequest( + params=ElicitRequestURLParams(message="Sign in to continue.", url="https://example.com/auth") + ) + } + ) + answer = params.input_responses["link"] + assert isinstance(answer, ElicitResult) + return CallToolResult(content=[TextContent(text=f"{answer.action} content={answer.content}")]) + + server = Server("guard", on_list_tools=list_tools, on_call_tool=call_tool) + + async def answer_url(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult: + received.append(params) + return ElicitResult(action="accept") + + with anyio.fail_after(5): + # Combined async-with (recorder bound via :=): a nested async-with mis-traces exit arcs under branch coverage. + async with ( + mounted_app(server) as (http, _), + Client( + recording := RecordingTransport(streamable_http_client(f"{BASE_URL}/mcp", http_client=http)), + mode=LATEST_MODERN_VERSION, + elicitation_callback=answer_url, + ) as client, + ): + result = await client.call_tool("protected", {}) + + assert received == snapshot( + [ElicitRequestURLParams(message="Sign in to continue.", url="https://example.com/auth")] + ) + assert result == snapshot(CallToolResult(content=[TextContent(text="accept content=None")])) + # Positive control: the interim input_required leg was captured, so the scan below is not vacuous. + interim = [ + message.message + for message in recording.received + if isinstance(message, SessionMessage) + and isinstance(message.message, JSONRPCResponse) + and message.message.result.get("resultType") == "input_required" + ] + assert len(interim) == 1 + # Substring scan catches the code inside a result body; test payloads leave no legitimate "32042". + frames = [ + message.message.model_dump_json(by_alias=True, exclude_none=True) + for message in [*recording.sent, *recording.received] + if isinstance(message, SessionMessage) + ] + assert all(str(URL_ELICITATION_REQUIRED) not in frame for frame in frames) diff --git a/tests/interaction/lowlevel/test_meta.py b/tests/interaction/lowlevel/test_meta.py index 27cf25e30..121819d73 100644 --- a/tests/interaction/lowlevel/test_meta.py +++ b/tests/interaction/lowlevel/test_meta.py @@ -16,7 +16,7 @@ pytestmark = pytest.mark.anyio -@requirement("meta:request-to-handler") +@requirement("protocol:meta:request-to-handler") async def test_request_meta_reaches_handler(connect: Connect) -> None: """The _meta object the client attaches to a request arrives at the tool handler unchanged.""" request_meta: RequestParamsMeta = {"example.com/trace": "abc-123"} @@ -41,7 +41,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara assert observed_metas == [dict(request_meta)] -@requirement("meta:result-to-client") +@requirement("protocol:meta:result-to-client") async def test_result_meta_reaches_client(connect: Connect) -> None: """The _meta object a handler attaches to its result is delivered to the client unchanged.""" result_meta = {"example.com/cost": 3} diff --git a/tests/interaction/lowlevel/test_mrtr.py b/tests/interaction/lowlevel/test_mrtr.py new file mode 100644 index 000000000..1d9626d40 --- /dev/null +++ b/tests/interaction/lowlevel/test_mrtr.py @@ -0,0 +1,1064 @@ +"""The 2026-07-28 multi-round-trip request (MRTR) pattern over tools/call. + +Fixture-driven tests pin the client driver's contract on both 2026 matrix cells; wire-level tests +record JSON-RPC frames over the modern HTTP entry, the only transport with 2026 framing; raw-dialect +and scripted-peer tests cover params and result bodies the typed API cannot produce. +""" + +from typing import Any + +import anyio +import mcp_types as types +import pytest +from inline_snapshot import snapshot +from mcp_types import ( + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + INVALID_PARAMS, + INVALID_REQUEST, + PROTOCOL_VERSION_META_KEY, + CallToolResult, + ClientCapabilities, + CreateMessageRequest, + CreateMessageRequestParams, + ElicitRequest, + ElicitRequestFormParams, + ElicitResult, + ErrorData, + Implementation, + InitializeResult, + InputRequiredResult, + JSONRPCError, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, + ListRootsRequest, + ListRootsResult, + Root, + RootsCapability, + SamplingCapability, + SamplingMessage, + ServerCapabilities, + TextContent, +) +from mcp_types.version import LATEST_MODERN_VERSION +from pydantic import FileUrl + +from mcp import InputRequiredRoundsExceededError, MCPError +from mcp.client import ClientRequestContext, ClientSession +from mcp.client.client import Client +from mcp.client.streamable_http import streamable_http_client +from mcp.server import MCPServer, Server, ServerRequestContext +from mcp.server.context import CallNext, HandlerResult +from mcp.server.extension import Extension +from mcp.shared.exceptions import NoBackChannelError +from mcp.shared.memory import MessageStream, create_client_server_memory_streams +from mcp.shared.message import SessionMessage +from tests.interaction._connect import BASE_URL, Connect, base_headers, mounted_app +from tests.interaction._helpers import RecordingTransport +from tests.interaction._requirements import requirement + +pytestmark = pytest.mark.anyio + +# Not parseable as JSON or base64: a client that inspected request_state instead of echoing it fails below. +OPAQUE_STATE = 'state!{"not-json' + +_NAME_SCHEMA: dict[str, object] = { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], +} + + +def _form_request(message: str) -> ElicitRequest: + """A form-mode elicitation request embeddable in input_requests.""" + return ElicitRequest(params=ElicitRequestFormParams(message=message, requested_schema=_NAME_SCHEMA)) + + +def _login_server(request_states: list[str | None]) -> Server: + """Two-round login server shared by the roundtrip pair; appends each round's request_state.""" + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + # Live: the client's output-schema cache refresh calls tools/list after the tools/call result. + return types.ListToolsResult(tools=[types.Tool(name="login", input_schema={"type": "object"})]) + + async def call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams + ) -> CallToolResult | InputRequiredResult: + assert params.name == "login" + request_states.append(params.request_state) + if params.input_responses is None: + assert params.request_state is None + return InputRequiredResult( + input_requests={"github_login": _form_request("Provide your GitHub username")}, + request_state=OPAQUE_STATE, + ) + assert params.request_state == OPAQUE_STATE + answer = params.input_responses["github_login"] + assert isinstance(answer, ElicitResult) + assert answer.action == "accept" + assert answer.content is not None + return CallToolResult(content=[TextContent(text=f"hello {answer.content['name']}")]) + + return Server("mrtr", on_list_tools=list_tools, on_call_tool=call_tool) + + +@requirement("mrtr:tools-call:write-once-roundtrip") +async def test_input_required_tool_call_is_auto_fulfilled_and_retried_to_completion(connect: Connect) -> None: + """An input_required tools/call is auto-fulfilled by the client driver and retried to completion. + + The byte-exact requestState echo (spec MUST) is the only observable proxy for the MUST NOT + inspect/parse/modify rule. + """ + request_states: list[str | None] = [] + server = _login_server(request_states) + + prompts: list[str] = [] + + async def answer_login(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult: + assert isinstance(params, ElicitRequestFormParams) + prompts.append(params.message) + return ElicitResult(action="accept", content={"name": "octocat"}) + + async with connect(server, elicitation_callback=answer_login) as client: + result = await client.call_tool("login", {}) + + assert result == snapshot(CallToolResult(content=[TextContent(text="hello octocat")])) + assert prompts == ["Provide your GitHub username"] + assert request_states == [None, OPAQUE_STATE] + + +@requirement("mrtr:request-state-only:retry") +async def test_state_only_input_required_is_retried_with_no_responses_and_echoed_state(connect: Connect) -> None: + """A state-only input_required result is retried with no inputResponses and the state echoed. + + No callbacks are registered: a driver that wrongly dispatched here would error the call. + """ + resume_token = "resume-token-1" + request_states: list[str | None] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="resume", input_schema={"type": "object"})]) + + async def call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams + ) -> CallToolResult | InputRequiredResult: + assert params.name == "resume" + request_states.append(params.request_state) + # Both rounds carry input_responses=None here, so the rounds are told apart by the state. + if params.request_state is None: + return InputRequiredResult(request_state=resume_token) + assert params.request_state == resume_token + assert params.input_responses is None + return CallToolResult(content=[TextContent(text="done")]) + + server = Server("resumer", on_list_tools=list_tools, on_call_tool=call_tool) + + async with connect(server) as client: + result = await client.call_tool("resume", {}) + + assert result == snapshot(CallToolResult(content=[TextContent(text="done")])) + assert request_states == [None, resume_token] + + +@requirement("mrtr:multi-round:complete") +async def test_server_reprompts_across_two_productive_rounds_then_completes(connect: Connect) -> None: + """A server re-prompting with input_required across two productive rounds completes normally. + + Round 1's answer rides forward inside request_state (the spec's stateless-server pattern). Each + retry carrying only the latest round's responses is SDK-defined (spec silent on accumulate-vs-replace). + """ + request_states: list[str | None] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="enroll", input_schema={"type": "object"})]) + + async def call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams + ) -> CallToolResult | InputRequiredResult: + assert params.name == "enroll" + request_states.append(params.request_state) + if params.input_responses is None: + return InputRequiredResult(input_requests={"first": _form_request("first question")}, request_state="s1") + if "first" in params.input_responses: + assert params.request_state == "s1" + first = params.input_responses["first"] + assert isinstance(first, ElicitResult) + assert first.content is not None + return InputRequiredResult( + input_requests={"second": _form_request("second question")}, + request_state=f"s2:{first.content['name']}", + ) + assert set(params.input_responses) == {"second"} + assert params.request_state is not None and params.request_state.startswith("s2:") + first_answer = params.request_state.removeprefix("s2:") + second = params.input_responses["second"] + assert isinstance(second, ElicitResult) + assert second.content is not None + return CallToolResult(content=[TextContent(text=f"{first_answer}+{second.content['name']}")]) + + server = Server("reprompter", on_list_tools=list_tools, on_call_tool=call_tool) + + answers = {"first question": "one", "second question": "two"} + prompts: list[str] = [] + + async def answer_by_prompt(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult: + assert isinstance(params, ElicitRequestFormParams) + prompts.append(params.message) + return ElicitResult(action="accept", content={"name": answers[params.message]}) + + async with connect(server, elicitation_callback=answer_by_prompt) as client: + result = await client.call_tool("enroll", {}) + + assert result == snapshot(CallToolResult(content=[TextContent(text="one+two")])) + assert prompts == ["first question", "second question"] + assert request_states == [None, "s1", "s2:one"] + + +@requirement("mrtr:rounds-cap") +async def test_auto_loop_raises_rounds_exceeded_when_the_server_never_completes() -> None: + """Exceeding input_required_max_rounds raises InputRequiredRoundsExceededError with the cap. + + SDK-defined behaviour (the spec places no bound). Direct in-memory Client because the connect + factories do not forward input_required_max_rounds; the driver is transport-independent. + """ + seen_responses: list[set[str] | None] = [] + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> InputRequiredResult: + assert params.name == "never-done" + seen_responses.append(None if params.input_responses is None else set(params.input_responses)) + return InputRequiredResult(input_requests={"q": _form_request("again")}) + + server = Server("bottomless", on_call_tool=call_tool) + + prompts: list[str] = [] + + async def answer_again(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult: + assert isinstance(params, ElicitRequestFormParams) + prompts.append(params.message) + return ElicitResult(action="accept", content={"name": "x"}) + + async with Client( + server, mode=LATEST_MODERN_VERSION, elicitation_callback=answer_again, input_required_max_rounds=2 + ) as client: + # Raised inside the block: Client.__aexit__ would wrap the error in an ExceptionGroup. + with pytest.raises(InputRequiredRoundsExceededError) as exc_info: + await client.call_tool("never-done", {}) + + assert exc_info.value.max_rounds == 2 + assert str(exc_info.value) == snapshot( + "Server returned InputRequiredResult for more than 2 rounds; raise input_required_max_rounds " + "on the Client, or use client.session.(..., allow_input_required=True) to drive the loop manually." + ) + # The initial call plus two retries reach the handler; the tripping round's requests are never dispatched. + assert seen_responses == [None, {"q"}, {"q"}] + assert prompts == ["again", "again"] + + +@requirement("protocol:result-type:input-required-not-masked") +async def test_unopted_session_call_with_an_input_required_result_raises_instead_of_returning_it() -> None: + """A session tools/call without allow_input_required raises instead of returning the interim. + + The interim never surfaces as an empty-content success; the error shape is SDK-defined. + """ + calls: list[str] = [] + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> InputRequiredResult: + assert params.name == "ask" + calls.append(params.name) + return InputRequiredResult(input_requests={"q": _form_request("Need a name")}, request_state="s") + + server = Server("interim-only", on_call_tool=call_tool) + + async with Client(server, mode=LATEST_MODERN_VERSION) as client: + # Raised inside the block: Client.__aexit__ would wrap the error in an ExceptionGroup. + with pytest.raises(RuntimeError) as exc_info: + await client.session.call_tool("ask", {}) + + assert str(exc_info.value) == snapshot( + "Server returned InputRequiredResult; pass allow_input_required=True to receive it " + "and retry call_tool(..., input_responses=..., request_state=result.request_state)." + ) + # The handler ran exactly once: no hidden retry preceded the raise. + assert calls == ["ask"] + + +@requirement("mrtr:input-required-result:at-least-one-of") +async def test_input_required_result_with_neither_field_cannot_reach_the_client(connect: Connect) -> None: + """An InputRequiredResult with neither inputRequests nor requestState cannot reach the client. + + The model validator enforces the at-least-one-of MUST; both 2026 dispatchers map the handler's + ValidationError to the same SDK-defined invalid-params error, so one snapshot serves both cells. + """ + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> InputRequiredResult: + assert params.name == "bare" + # Statically legal (both fields default None); raises pydantic's ValidationError here. + return InputRequiredResult() + + server = Server("malformed-interim", on_call_tool=call_tool) + + async with connect(server) as client: + with pytest.raises(MCPError) as exc_info: + await client.call_tool("bare", {}) + + assert exc_info.value.error == snapshot( + ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="") + ) + + +@requirement("mrtr:input-responses:key-correspondence") +async def test_multi_request_input_responses_are_keyed_by_the_input_request_keys(connect: Connect) -> None: + """inputResponses on the retry are keyed by the inputRequests keys, each value that key's typed result. + + ElicitResult and ListRootsResult prove the map contract; sampling fidelity belongs to the sampling entries. + """ + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="profile", input_schema={"type": "object"})]) + + async def call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams + ) -> CallToolResult | InputRequiredResult: + assert params.name == "profile" + if params.input_responses is None: + # Constructing ListRootsRequest raises no deprecation warning; only push-API calls do. + return InputRequiredResult( + input_requests={"github_login": _form_request("Need a name"), "workspace_roots": ListRootsRequest()} + ) + assert set(params.input_responses) == {"github_login", "workspace_roots"} + login = params.input_responses["github_login"] + roots = params.input_responses["workspace_roots"] + assert isinstance(login, ElicitResult) + assert isinstance(roots, ListRootsResult) + assert login.content is not None + return CallToolResult(content=[TextContent(text=f"{login.content['name']}@{roots.roots[0].uri}")]) + + server = Server("profiled", on_list_tools=list_tools, on_call_tool=call_tool) + + async def answer_login(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult: + assert isinstance(params, ElicitRequestFormParams) + return ElicitResult(action="accept", content={"name": "octocat"}) + + async def answer_roots(context: ClientRequestContext) -> ListRootsResult: + return ListRootsResult(roots=[Root(uri=FileUrl("file:///workspace"))]) + + async with connect(server, elicitation_callback=answer_login, list_roots_callback=answer_roots) as client: + result = await client.call_tool("profile", {}) + + assert result == snapshot(CallToolResult(content=[TextContent(text="octocat@file:///workspace")])) + + +@requirement("mrtr:input-responses:missing-reprompted") +async def test_retry_missing_a_requested_key_is_reprompted_not_errored(connect: Connect) -> None: + """A retry omitting a requested inputResponses key is re-prompted, not errored (spec SHOULD). + + The re-prompt decision belongs to the test's handler; the SDK obligation pinned is that the partial + map reaches the handler unmodified. Manual loop: the auto driver answers every requested key. + """ + seen: list[set[str] | None] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="enroll", input_schema={"type": "object"})]) + + async def call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams + ) -> CallToolResult | InputRequiredResult: + assert params.name == "enroll" + seen.append(None if params.input_responses is None else set(params.input_responses)) + if params.input_responses is None: + return InputRequiredResult( + input_requests={"first": _form_request("first question"), "second": _form_request("second question")}, + request_state="r1", + ) + if "second" not in params.input_responses: + first = params.input_responses["first"] + assert isinstance(first, ElicitResult) + assert first.content is not None + # Re-prompt for the missing key, threading round 1's answer through the state. + return InputRequiredResult( + input_requests={"second": _form_request("second question")}, + request_state=f"r2:{first.content['name']}", + ) + assert params.request_state is not None and params.request_state.startswith("r2:") + second = params.input_responses["second"] + assert isinstance(second, ElicitResult) + assert second.content is not None + return CallToolResult( + content=[TextContent(text=f"{params.request_state.removeprefix('r2:')}+{second.content['name']}")] + ) + + server = Server("reprompting", on_list_tools=list_tools, on_call_tool=call_tool) + + async with connect(server) as client: + round1 = await client.session.call_tool("enroll", {}, allow_input_required=True) + assert isinstance(round1, InputRequiredResult) + assert round1.input_requests is not None + assert set(round1.input_requests) == {"first", "second"} + round2 = await client.session.call_tool( + "enroll", + {}, + input_responses={"first": ElicitResult(action="accept", content={"name": "one"})}, + request_state=round1.request_state, + allow_input_required=True, + ) + assert isinstance(round2, InputRequiredResult) + assert round2.input_requests is not None + assert set(round2.input_requests) == {"second"} + result = await client.session.call_tool( + "enroll", + {}, + input_responses={"second": ElicitResult(action="accept", content={"name": "two"})}, + request_state=round2.request_state, + allow_input_required=True, + ) + + assert result == snapshot(CallToolResult(content=[TextContent(text="one+two")])) + # The partial map reached the handler as sent, not filtered or rejected. + assert seen == [None, {"first"}, {"second"}] + + +@requirement("mrtr:input-responses:unknown-ignored") +async def test_retry_with_an_unrequested_extra_key_is_tolerated_and_the_call_completes(connect: Connect) -> None: + """A retry carrying an unrequested inputResponses key completes normally (spec SHOULD: ignore). + + The ignoring happens in the test's handler; the SDK half pinned is that the stray entry is + delivered unfiltered. Manual loop: the auto driver only answers the server's own keys. + """ + seen: list[set[str] | None] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="greet", input_schema={"type": "object"})]) + + async def call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams + ) -> CallToolResult | InputRequiredResult: + assert params.name == "greet" + seen.append(None if params.input_responses is None else set(params.input_responses)) + if params.input_responses is None: + return InputRequiredResult(input_requests={"name": _form_request("Need a name")}, request_state="s1") + # Completes from the requested key alone; the stray entry is deliberately never read. + answer = params.input_responses["name"] + assert isinstance(answer, ElicitResult) + assert answer.content is not None + return CallToolResult(content=[TextContent(text=f"hello {answer.content['name']}")]) + + server = Server("tolerant", on_list_tools=list_tools, on_call_tool=call_tool) + + async with connect(server) as client: + round1 = await client.session.call_tool("greet", {}, allow_input_required=True) + assert isinstance(round1, InputRequiredResult) + result = await client.session.call_tool( + "greet", + {}, + # Structurally valid value: only the key is unknown, keeping this disjoint from invalid-rejected below. + input_responses={ + "name": ElicitResult(action="accept", content={"name": "ada"}), + "stray": ElicitResult(action="accept", content={"name": "noise"}), + }, + request_state=round1.request_state, + allow_input_required=True, + ) + + assert result == snapshot(CallToolResult(content=[TextContent(text="hello ada")])) + assert seen == [None, {"name", "stray"}] + + +@requirement("mrtr:push-api:loud-fail-2026") +async def test_push_elicit_on_2026_raises_typed_local_error_and_call_still_completes(connect: Connect) -> None: + """A push API call on a 2026 connection raises a typed local error and the call still completes. + + Spec-mandated outcome, era-routed enforcement: every modern dispatch path installs a + channel-less context by construction, so the gate is "no back-channel", never a send-time + era check. One push API stands for all four: they share ServerSession.send_request's + channel selection. + """ + caught: list[NoBackChannelError] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="ask", input_schema={"type": "object"})]) + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + assert params.name == "ask" + try: + await ctx.session.elicit_form("Need a name", _NAME_SCHEMA) + except NoBackChannelError as exc: + caught.append(exc) + return CallToolResult(content=[TextContent(text="fallback")]) + + server = Server("push", on_list_tools=list_tools, on_call_tool=call_tool) + + # Declares the elicitation capability, isolating the failure to the missing back-channel; never delivered. + async def never_deliverable(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult: + raise NotImplementedError + + async with connect(server, elicitation_callback=never_deliverable) as client: + result = await client.call_tool("ask", {}) + + # The failed push did not poison the request: the call completes with the handler's fallback. + assert result == snapshot(CallToolResult(content=[TextContent(text="fallback")])) + assert len(caught) == 1 + assert caught[0].method == "elicitation/create" + assert caught[0].error == snapshot( + ErrorData( + code=INVALID_REQUEST, + message=( + "Cannot send 'elicitation/create': this transport context has no back-channel " + "for server-initiated requests." + ), + ) + ) + + +@requirement("mrtr:push-api:loud-fail-2026") +async def test_request_scoped_push_elicit_on_in_memory_2026_loud_fails_locally_and_the_call_still_completes() -> None: + """A request-scoped push elicit on in-memory 2026 loud-fails locally and the call still completes. + + The related id routes the send onto the per-request dispatch channel -- the one leg whose + channel is otherwise live in-memory -- so this pin proves local provenance: the typed + NoBackChannelError (never a peer answer) and a callback that never fires. A delivered frame + would raise NotImplementedError in the callback, surface as a non-NoBackChannelError error, + escape the narrowed except, and fail the test loudly. + """ + caught: list[NoBackChannelError] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="ask", input_schema={"type": "object"})]) + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + assert params.name == "ask" + assert ctx.request_id is not None + try: + # The related id routes the send onto the per-request dispatch channel. + await ctx.session.elicit_form("Need a name", _NAME_SCHEMA, related_request_id=ctx.request_id) + except NoBackChannelError as exc: + # Narrow on purpose: a peer-answered MCPError would propagate and fail the test. + caught.append(exc) + return CallToolResult(content=[TextContent(text="fallback")]) + + server = Server("scoped-push", on_list_tools=list_tools, on_call_tool=call_tool) + + # Registering the callback declares the elicitation capability; it must never fire. + async def never_deliverable(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult: + raise NotImplementedError + + async with Client(server, mode=LATEST_MODERN_VERSION, elicitation_callback=never_deliverable) as client: + result = await client.call_tool("ask", {}) + + # The failed push did not poison the request: the call completes with the handler's fallback. + assert result == snapshot(CallToolResult(content=[TextContent(text="fallback")])) + assert len(caught) == 1 + assert caught[0].method == "elicitation/create" + assert caught[0].error == snapshot( + ErrorData( + code=INVALID_REQUEST, + message=( + "Cannot send 'elicitation/create': this transport context has no back-channel " + "for server-initiated requests." + ), + ) + ) + + +@requirement("sampling:mrtr:capability:not-declared") +async def test_sampling_request_embedded_for_a_non_sampling_client_is_sent_and_refused_client_side( + connect: Connect, +) -> None: + """PINS A KNOWN GAP: an embedded sampling request an undeclared client cannot support is sent anyway. + + The SDK has no embed gate (spec MUST NOT), so the violation surfaces as the client driver's refusal + aborting the call. When the server-side gate lands: re-pin to the gated outcome and delete the Divergence. + """ + calls: list[str] = [] + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> InputRequiredResult: + assert params.name == "gated" + calls.append(params.name) + # Precondition: this connection's envelope declared no sampling capability. + assert not ctx.session.check_client_capability(ClientCapabilities(sampling=SamplingCapability())) + return InputRequiredResult( + input_requests={ + "ask-model": CreateMessageRequest( + params=CreateMessageRequestParams( + messages=[SamplingMessage(role="user", content=TextContent(text="hi"))], max_tokens=8 + ) + ) + } + ) + + server = Server("ungated-sampling", on_call_tool=call_tool) + + async with connect(server) as client: + # Raised inside the block: Client.__aexit__ would wrap the error in an ExceptionGroup. + with pytest.raises(MCPError) as exc_info: + await client.call_tool("gated", {}) + + # The refusal comes from the client driver's default sampling callback -- proof the embed was transmitted. + assert exc_info.value.error == snapshot(ErrorData(code=INVALID_REQUEST, message="Sampling not supported")) + # The handler ran exactly once: the driver aborts on the refusal, no retry. + assert calls == ["gated"] + + +@requirement("roots:mrtr:capability:not-declared") +async def test_roots_request_embedded_for_a_rootless_client_is_sent_and_refused_client_side( + connect: Connect, +) -> None: + """PINS A KNOWN GAP: an embedded roots request a rootless client cannot support is sent anyway. + + The SDK has no embed gate (spec MUST NOT), so the violation surfaces as the client driver's refusal + aborting the call. When the server-side gate lands: re-pin to the gated outcome and delete the Divergence. + """ + calls: list[str] = [] + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> InputRequiredResult: + assert params.name == "gated" + calls.append(params.name) + # Precondition: this connection's envelope declared no roots capability. + assert not ctx.session.check_client_capability(ClientCapabilities(roots=RootsCapability())) + return InputRequiredResult(input_requests={"workspace-roots": ListRootsRequest()}) + + server = Server("ungated-roots", on_call_tool=call_tool) + + async with connect(server) as client: + # Raised inside the block: Client.__aexit__ would wrap the error in an ExceptionGroup. + with pytest.raises(MCPError) as exc_info: + await client.call_tool("gated", {}) + + # The refusal comes from the client driver's default roots callback -- proof the embed was transmitted. + assert exc_info.value.error == snapshot(ErrorData(code=INVALID_REQUEST, message="List roots not supported")) + # The handler ran exactly once: the driver aborts on the refusal, no retry. + assert calls == ["gated"] + + +# --- wire-level: the modern HTTP entry is the only 2026 framing seam --- + + +@requirement("mrtr:tools-call:write-once-roundtrip") +async def test_mrtr_retry_frame_carries_fresh_id_and_byte_exact_request_state() -> None: + """The MRTR retry frame carries a fresh JSON-RPC id and the requestState key serialized byte-exact. + + Asserted at the client transport seam: the retry's id (spec MUST: the retry is an independent + request) and the serialized key presence are invisible to API callers. + """ + server = _login_server([]) + + async def answer_login(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult: + assert isinstance(params, ElicitRequestFormParams) + return ElicitResult(action="accept", content={"name": "octocat"}) + + with anyio.fail_after(5): + # One combined async-with, recorder bound via :=: nested async-with mis-traces exit arcs on 3.11+. + async with ( + mounted_app(server) as (http, _), + Client( + recording := RecordingTransport(streamable_http_client(f"{BASE_URL}/mcp", http_client=http)), + mode=LATEST_MODERN_VERSION, + elicitation_callback=answer_login, + ) as client, + ): + await client.call_tool("login", {}) + + # Filtered to tools/call: the client's schema-cache refresh also puts a tools/list on the wire. + calls = [ + message.message + for message in recording.sent + if isinstance(message.message, JSONRPCRequest) and message.message.method == "tools/call" + ] + assert len(calls) == 2 + first, retry = calls + # Inequality, not pinned values: the id sequence belongs to protocol:request-id:unique. + assert first.id is not None + assert retry.id is not None + assert retry.id != first.id + assert first.params is not None + assert "requestState" not in first.params + assert "inputResponses" not in first.params + assert retry.params is not None + assert retry.params["requestState"] == OPAQUE_STATE + assert retry.params["inputResponses"]["github_login"]["action"] == "accept" + # The interim travelled as a *result*, matched to the initial request by its id. + interim = next( + message.message + for message in recording.received + if isinstance(message, SessionMessage) + and isinstance(message.message, JSONRPCResponse) + and message.message.id == first.id + ) + assert interim.result["resultType"] == "input_required" + assert "requestState" in interim.result + + +@requirement("mrtr:request-state:omitted-when-absent") +async def test_retry_omits_the_request_state_key_when_the_server_sent_none() -> None: + """When the server's input_required carried no requestState, the retry omits the key entirely. + + Wire-pinned (spec MUST NOT): typed None and key-absence are indistinguishable in-memory. The + fresh-id test above proves the same serializer emits the key when present, guarding against vacuity. + """ + request_states: list[str | None] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="ask", input_schema={"type": "object"})]) + + async def call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams + ) -> CallToolResult | InputRequiredResult: + assert params.name == "ask" + request_states.append(params.request_state) + if params.input_responses is None: + return InputRequiredResult(input_requests={"q": _form_request("Need a name")}) + assert params.request_state is None + return CallToolResult(content=[TextContent(text="ok")]) + + server = Server("stateless-asker", on_list_tools=list_tools, on_call_tool=call_tool) + + async def answer(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult: + assert isinstance(params, ElicitRequestFormParams) + return ElicitResult(action="accept", content={"name": "ada"}) + + with anyio.fail_after(5): + async with ( + mounted_app(server) as (http, _), + Client( + recording := RecordingTransport(streamable_http_client(f"{BASE_URL}/mcp", http_client=http)), + mode=LATEST_MODERN_VERSION, + elicitation_callback=answer, + ) as client, + ): + await client.call_tool("ask", {}) + + calls = [ + message.message + for message in recording.sent + if isinstance(message.message, JSONRPCRequest) and message.message.method == "tools/call" + ] + assert len(calls) == 2 + retry = calls[1] + assert retry.params is not None + # The absence is specific: no requestState key on an otherwise-loaded retry frame. + assert "requestState" not in retry.params + assert "inputResponses" in retry.params + assert request_states == [None, None] + + +@requirement("mrtr:request-state:scoped-to-originating-request") +async def test_parallel_mrtr_calls_keep_request_state_and_responses_isolated() -> None: + """Parallel MRTR calls keep requestState and inputResponses scoped to their originating request. + + A symmetric rendezvous in the elicitation callback forces both loops mid-flight before either + retry leaves (spec MUST NOT). Handler capture suffices: every tools/call the client sends is + delivered to the handler, so the captured rounds are 1:1 with the sent frames. + """ + rounds: list[tuple[str, str | None, set[str] | None]] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool(name="alpha", input_schema={"type": "object"}), + types.Tool(name="beta", input_schema={"type": "object"}), + ] + ) + + async def call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams + ) -> CallToolResult | InputRequiredResult: + assert params.name in ("alpha", "beta") + name = params.name + rounds.append( + (name, params.request_state, None if params.input_responses is None else set(params.input_responses)) + ) + if params.input_responses is None: + return InputRequiredResult( + input_requests={f"q-{name}": _form_request(f"for {name}")}, + request_state=f"state-{name}", + ) + return CallToolResult(content=[TextContent(text=name)]) + + server = Server("parallel", on_list_tools=list_tools, on_call_tool=call_tool) + + round1_seen = {"alpha": anyio.Event(), "beta": anyio.Event()} + + async def answer(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult: + assert isinstance(params, ElicitRequestFormParams) + name = params.message.removeprefix("for ") + assert name in round1_seen + # Set own round-1 event before waiting on the other's: deadlock-free, both loops provably mid-flight. + round1_seen[name].set() + other = "beta" if name == "alpha" else "alpha" + with anyio.fail_after(5): + await round1_seen[other].wait() + return ElicitResult(action="accept", content={"name": name}) + + results: dict[str, CallToolResult] = {} + + with anyio.fail_after(5): + async with ( + Client(server, mode=LATEST_MODERN_VERSION, elicitation_callback=answer) as client, + # Last item so it exits first: both calls complete while the client is still open. + anyio.create_task_group() as task_group, + ): + + async def call(name: str) -> None: + results[name] = await client.call_tool(name, {}) + + task_group.start_soon(call, "alpha") + task_group.start_soon(call, "beta") + + # The rendezvous guarantees both initial rounds land before either retry; order within a phase is free. + assert sorted(rounds[:2]) == [("alpha", None, None), ("beta", None, None)] + # Each retry carries exactly its own call's state and response key -- nothing crossed over. + assert sorted(rounds[2:]) == [("alpha", "state-alpha", {"q-alpha"}), ("beta", "state-beta", {"q-beta"})] + assert results == { + "alpha": CallToolResult(content=[TextContent(text="alpha")]), + "beta": CallToolResult(content=[TextContent(text="beta")]), + } + + +@requirement("protocol:directionality:no-client-responses") +async def test_2026_trace_is_client_requests_and_server_responses_only() -> None: + """A completed 2026 exchange's trace is client-sent requests and server-sent responses only. + + At 2025-11-25 this same elicitation was a server-initiated request answered by a client response + -- the maximal legitimate occasion for the forbidden frames (spec MUST NOT, both halves) -- yet + the trace contains neither. The full trace is snapshotted so a frame reorder fails consciously. + """ + elicited: list[str] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="ask", input_schema={"type": "object"})]) + + async def call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams + ) -> CallToolResult | InputRequiredResult: + assert params.name == "ask" + if params.input_responses is None: + return InputRequiredResult(input_requests={"q": _form_request("Need a name")}, request_state="s1") + answer = params.input_responses["q"] + assert isinstance(answer, ElicitResult) + assert answer.content is not None + return CallToolResult(content=[TextContent(text=f"done:{answer.content['name']}:{params.request_state}")]) + + server = Server("one-round", on_list_tools=list_tools, on_call_tool=call_tool) + + async def answer(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult: + assert isinstance(params, ElicitRequestFormParams) + elicited.append(params.message) + return ElicitResult(action="accept", content={"name": "Berlin"}) + + with anyio.fail_after(5): + async with ( + mounted_app(server) as (http, _), + Client( + recording := RecordingTransport(streamable_http_client(f"{BASE_URL}/mcp", http_client=http)), + mode=LATEST_MODERN_VERSION, + elicitation_callback=answer, + ) as client, + ): + result = await client.call_tool("ask", {}) + + # Non-vacuity: the elicitation genuinely happened and the round trip completed through it. + assert result == snapshot(CallToolResult(content=[TextContent(text="done:Berlin:s1")])) + assert elicited == ["Need a name"] + # Prove the received log holds only messages before narrowing: a filtered-out transport exception would fake it. + received_messages = [message for message in recording.received if isinstance(message, SessionMessage)] + assert received_messages == recording.received + # The client half of the clause: every client-to-server frame is a request. + assert [ + (type(message.message).__name__, getattr(message.message, "method", None)) for message in recording.sent + ] == snapshot( + [("JSONRPCRequest", "tools/call"), ("JSONRPCRequest", "tools/call"), ("JSONRPCRequest", "tools/list")] + ) + # The server half of the same sentence: every server-to-client frame is a response. + assert [type(message.message).__name__ for message in received_messages] == snapshot( + ["JSONRPCResponse", "JSONRPCResponse", "JSONRPCResponse"] + ) + # Response ids pair the sent request ids in order; the snapshots above prove these filters drop nothing. + requests = [message.message for message in recording.sent if isinstance(message.message, JSONRPCRequest)] + responses = [message.message for message in received_messages if isinstance(message.message, JSONRPCResponse)] + assert [response.id for response in responses] == [request.id for request in requests] + + +# --- raw 2026 dialect: malformed params can only originate from a scripted client --- + + +def _modern_headers(*, method: str, name: str) -> dict[str, str]: + """Headers for a raw 2026-07-28 tools/call POST: baseline plus the modern routing/advisory headers.""" + return base_headers() | {"mcp-protocol-version": LATEST_MODERN_VERSION, "mcp-method": method, "mcp-name": name} + + +def _meta_envelope() -> dict[str, object]: + """The three-key per-request ``_meta`` envelope a 2026-07-28 client stamps on every request.""" + return { + PROTOCOL_VERSION_META_KEY: LATEST_MODERN_VERSION, + CLIENT_INFO_META_KEY: {"name": "raw", "version": "0.0.0"}, + CLIENT_CAPABILITIES_META_KEY: {}, + } + + +@requirement("mrtr:input-responses:invalid-rejected") +async def test_retry_with_malformed_input_responses_is_rejected_with_invalid_params() -> None: + """A retry whose inputResponses do not parse is rejected with invalid params before dispatch (spec SHOULD). + + Raw httpx against the mounted modern entry: the typed API rejects garbage inputResponses at + construction, so the violation is unproducible above this seam. + """ + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + raise NotImplementedError + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + # Params validation precedes dispatch, so the malformed retry must never reach this body. + raise NotImplementedError + + server = Server("never-dispatches", on_list_tools=list_tools, on_call_tool=call_tool) + + with anyio.fail_after(5): + async with mounted_app(server) as (http, _): + response = await http.post( + f"{BASE_URL}/mcp", + headers=_modern_headers(method="tools/call", name="never-runs"), + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "never-runs", + "inputResponses": {"k": {"not": "a result"}}, + "_meta": _meta_envelope(), + }, + }, + ) + + error = JSONRPCError.model_validate(response.json()) + assert error.error == snapshot(ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="")) + + +# --- scripted server peer: byte-controlled absence of the resultType key --- + + +@requirement("protocol:result-type:absent-is-complete") +async def test_result_body_without_result_type_parses_as_a_complete_result() -> None: + """A tools/call result body with no resultType key parses as the normal terminal result. + + Spec MUST: clients treat an absent resultType as "complete" (backward compatibility). The server + is played by hand over memory streams so the key's absence is byte-controlled, not a serializer artifact. + """ + + async def scripted_server(streams: MessageStream) -> None: + server_read, server_write = streams + + def respond(request_id: types.RequestId, result: dict[str, object]) -> SessionMessage: + return SessionMessage(JSONRPCResponse(jsonrpc="2.0", id=request_id, result=result)) + + init = await server_read.receive() + assert isinstance(init, SessionMessage) + assert isinstance(init.message, JSONRPCRequest) + assert init.message.method == "initialize" + await server_write.send( + respond( + init.message.id, + InitializeResult( + protocol_version="2025-11-25", + capabilities=ServerCapabilities(), + server_info=Implementation(name="scripted", version="0.0.1"), + ).model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + + initialized = await server_read.receive() + assert isinstance(initialized, SessionMessage) + assert isinstance(initialized.message, JSONRPCNotification) + assert initialized.message.method == "notifications/initialized" + + call = await server_read.receive() + assert isinstance(call, SessionMessage) + assert isinstance(call.message, JSONRPCRequest) + assert call.message.method == "tools/call" + # Deliberately no "resultType" key: the absence is the clause under test. + await server_write.send(respond(call.message.id, {"content": [{"type": "text", "text": "plain"}]})) + + # The client's output-schema cache refresh follows the call result; stopping here hangs the test. + refresh = await server_read.receive() + assert isinstance(refresh, SessionMessage) + assert isinstance(refresh.message, JSONRPCRequest) + assert refresh.message.method == "tools/list" + await server_write.send( + respond(refresh.message.id, {"tools": [{"name": "x", "inputSchema": {"type": "object"}}]}) + ) + + async with ( + create_client_server_memory_streams() as ((client_read, client_write), server_streams), + anyio.create_task_group() as task_group, + ClientSession(client_read, client_write) as session, + ): + task_group.start_soon(scripted_server, server_streams) + with anyio.fail_after(5): + await session.initialize() + result = await session.call_tool("x", {}) + + # The parse default filling "complete" IS the MUST under test. + assert result.result_type == "complete" + assert result == snapshot(CallToolResult(content=[TextContent(text="plain")])) + + +# --- unrecognized resultType: a server extension puts an arbitrary tag on the wire --- + + +@requirement("protocol:result-type:unrecognized-invalid") +async def test_an_unrecognized_result_type_value_is_surfaced_unchanged_instead_of_treated_as_invalid( + connect: Connect, +) -> None: + """PINS A KNOWN GAP: an unrecognized resultType round-trips instead of being treated as invalid (spec MUST). + + The leniency is narrow: the unknown tag survives only because the body also parses as a + complete core result. When the client starts rejecting unrecognized resultType values: + re-pin to the typed rejection and delete the Divergence. + """ + + class BogusIssuer(Extension): + identifier = "com.example/bogus" + + async def intercept_tool_call( + self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext + ) -> HandlerResult: + assert params.name == "probe" + # "bogus" is in no core or extension vocabulary -- exactly the value the MUST addresses. + return {"resultType": "bogus", "content": [{"type": "text", "text": "still here"}]} + + server = MCPServer("bogus-issuer", extensions=[BogusIssuer()]) + + @server.tool() + def probe() -> CallToolResult: + """Probe the unrecognized-tag path.""" + raise NotImplementedError # the server extension answers before the tool runs + + async with connect(server) as client: + result = await client.call_tool("probe", {}) + + # The divergent observable: the unrecognized discriminator survives unchanged, never a rejection. + assert result.result_type == "bogus" + assert result == snapshot(CallToolResult(content=[TextContent(text="still here")], result_type="bogus")) diff --git a/tests/interaction/lowlevel/test_pagination.py b/tests/interaction/lowlevel/test_pagination.py index 01bc0a99b..c753fe86f 100644 --- a/tests/interaction/lowlevel/test_pagination.py +++ b/tests/interaction/lowlevel/test_pagination.py @@ -58,6 +58,32 @@ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestPa assert second_page == snapshot(ListToolsResult(tools=[Tool(name="beta", input_schema={"type": "object"})])) +@requirement("protocol:pagination:empty-cursor-valid") +async def test_an_empty_string_next_cursor_round_trips_as_a_cursor_not_end_of_results(connect: Connect) -> None: + """An empty-string next_cursor round-trips verbatim, distinct from absent. + + Spec-mandated: an empty string is a valid cursor and MUST NOT be treated as the end of results. + """ + seen_cursors: list[str | None] = [] + + async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + assert params is not None + seen_cursors.append(params.cursor) + if params.cursor is None: + return ListToolsResult(tools=[Tool(name="alpha", input_schema={"type": "object"})], next_cursor="") + return ListToolsResult(tools=[Tool(name="beta", input_schema={"type": "object"})]) + + server = Server("paginated", on_list_tools=list_tools) + + async with connect(server) as client: + first_page = await client.list_tools() + second_page = await client.list_tools(cursor=first_page.next_cursor) + + assert first_page.next_cursor == "" + assert seen_cursors == [None, ""] + assert second_page == snapshot(ListToolsResult(tools=[Tool(name="beta", input_schema={"type": "object"})])) + + @requirement("pagination:exhaustion") @requirement("tools:list:pagination") async def test_paginating_until_next_cursor_is_absent_yields_every_page(connect: Connect) -> None: diff --git a/tests/interaction/lowlevel/test_prompts.py b/tests/interaction/lowlevel/test_prompts.py index eb19d4d60..7c070a254 100644 --- a/tests/interaction/lowlevel/test_prompts.py +++ b/tests/interaction/lowlevel/test_prompts.py @@ -6,20 +6,27 @@ from mcp_types import ( INVALID_PARAMS, AudioContent, + ElicitRequest, + ElicitRequestFormParams, + ElicitResult, EmbeddedResource, ErrorData, GetPromptResult, Icon, ImageContent, + InputRequiredResult, + InputResponses, ListPromptsResult, Prompt, PromptArgument, PromptMessage, + ResourceLink, TextContent, TextResourceContents, ) from mcp import MCPError +from mcp.client import ClientRequestContext from mcp.server import Server, ServerRequestContext from tests.interaction._connect import Connect from tests.interaction._requirements import requirement @@ -191,6 +198,48 @@ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestPa ) +@requirement("prompts:get:content:resource-link") +async def test_get_prompt_resource_link_content_round_trips(connect: Connect) -> None: + """A resource_link prompt message reaches the client with URI and descriptive fields intact. Spec-mandated.""" + + async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> GetPromptResult: + assert params.name == "entry_point" + return GetPromptResult( + messages=[ + PromptMessage( + role="user", + content=ResourceLink( + uri="file:///project/src/main.rs", + name="main.rs", + description="Primary application entry point", + mime_type="text/x-rust", + ), + ) + ] + ) + + server = Server("prompter", on_get_prompt=get_prompt) + + async with connect(server) as client: + result = await client.get_prompt("entry_point") + + assert result == snapshot( + GetPromptResult( + messages=[ + PromptMessage( + role="user", + content=ResourceLink( + name="main.rs", + uri="file:///project/src/main.rs", + description="Primary application entry point", + mime_type="text/x-rust", + ), + ) + ] + ) + ) + + @requirement("prompts:get:unknown-name") async def test_get_prompt_unknown_name_is_protocol_error(connect: Connect) -> None: """A handler that rejects an unrecognised prompt name with MCPError produces a JSON-RPC error. @@ -208,3 +257,49 @@ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestPa await client.get_prompt("nope") assert exc_info.value.error == snapshot(ErrorData(code=INVALID_PARAMS, message="Unknown prompt: nope")) + + +@requirement("prompts:mrtr:get:basic") +async def test_get_prompt_input_required_is_fulfilled_and_the_retry_returns_the_messages(connect: Connect) -> None: + """A prompts/get answered with input_required is fulfilled by the elicitation callback and retried. + + Spec-mandated: prompts/get is an MRTR-supported request (basic/patterns/mrtr, Supported Requests). + """ + sent = ElicitRequestFormParams( + message="Who is reading?", + requested_schema={"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}, + ) + answer = ElicitResult(action="accept", content={"name": "alice"}) + state = "state-1" + rounds: list[tuple[InputResponses | None, str | None]] = [] + callback_received: list[ElicitRequestFormParams] = [] + + async def get_prompt( + ctx: ServerRequestContext, params: types.GetPromptRequestParams + ) -> GetPromptResult | InputRequiredResult: + assert params.name == "greet" + rounds.append((params.input_responses, params.request_state)) + if params.input_responses is None: + return InputRequiredResult(input_requests={"who": ElicitRequest(params=sent)}, request_state=state) + response = params.input_responses["who"] + assert isinstance(response, ElicitResult) + assert response.content is not None + return GetPromptResult( + messages=[PromptMessage(role="user", content=TextContent(text=f"Hello, {response.content['name']}!"))] + ) + + server = Server("prompter", on_get_prompt=get_prompt) + + async def elicit(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult: + assert isinstance(params, ElicitRequestFormParams) + callback_received.append(params) + return answer + + async with connect(server, elicitation_callback=elicit) as client: + result = await client.get_prompt("greet") + + assert result == snapshot( + GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="Hello, alice!"))]) + ) + assert callback_received == [sent] + assert rounds == [(None, None), ({"who": answer}, state)] diff --git a/tests/interaction/lowlevel/test_resources.py b/tests/interaction/lowlevel/test_resources.py index 44ab33e64..c7c9ab381 100644 --- a/tests/interaction/lowlevel/test_resources.py +++ b/tests/interaction/lowlevel/test_resources.py @@ -11,9 +11,14 @@ Annotations, BlobResourceContents, CallToolResult, + ElicitRequest, + ElicitRequestFormParams, + ElicitResult, EmptyResult, ErrorData, Icon, + InputRequiredResult, + InputResponses, ListResourcesResult, ListResourceTemplatesResult, ReadResourceResult, @@ -26,6 +31,7 @@ ) from mcp import MCPError +from mcp.client import ClientRequestContext from mcp.server import Server, ServerRequestContext from tests.interaction._connect import Connect from tests.interaction._helpers import IncomingMessage @@ -140,12 +146,43 @@ async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceReq ) -@requirement("resources:read:unknown-uri") -async def test_read_resource_unknown_uri_is_protocol_error(connect: Connect) -> None: - """A handler that rejects an unrecognised URI with MCPError produces a JSON-RPC error. +@requirement("resources:read:multiple-contents") +async def test_read_resource_returns_multiple_contents_in_order(connect: Connect) -> None: + """A multi-entry resources/read result reaches the client intact and in order. Spec-mandated.""" - The spec reserves -32002 for resource-not-found; the code is the handler's choice and reaches - the client verbatim. + async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult: + assert params.uri == "file:///project/" + return ReadResourceResult( + contents=[ + TextResourceContents(uri="file:///project/a.txt", mime_type="text/plain", text="alpha"), + TextResourceContents(uri="file:///project/b.txt", mime_type="text/plain", text="beta"), + BlobResourceContents(uri="file:///project/logo.png", mime_type="image/png", blob="aW1n"), + ] + ) + + server = Server("library", on_read_resource=read_resource) + + async with connect(server) as client: + result = await client.read_resource("file:///project/") + + assert result == snapshot( + ReadResourceResult( + contents=[ + TextResourceContents(uri="file:///project/a.txt", mime_type="text/plain", text="alpha"), + TextResourceContents(uri="file:///project/b.txt", mime_type="text/plain", text="beta"), + BlobResourceContents(uri="file:///project/logo.png", mime_type="image/png", blob="aW1n"), + ] + ) + ) + + +@requirement("protocol:error:handler-error-passthrough") +async def test_handler_raised_mcperror_code_and_message_reach_the_client_verbatim(connect: Connect) -> None: + """A handler-raised MCPError's code and message reach the client verbatim. + + The -32002 here is only this handler's choice (the pre-2026 resource-not-found code; the 2026 + spec reserves -32602 for an unknown URI). The real unknown-URI posture lives in the resource + registry and is pinned in mcpserver/test_resources.py; this test asserts the generic passthrough. """ async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceRequestParams) -> ReadResourceResult: @@ -219,6 +256,30 @@ async def subscribe_resource(ctx: ServerRequestContext, params: types.SubscribeR assert result == snapshot(EmptyResult()) +@requirement("lifecycle:version:era-method-gate") +async def test_resources_subscribe_on_a_2026_connection_is_method_not_found_despite_a_registered_handler( + connect: Connect, +) -> None: + """On a 2026-07-28 connection, `resources/subscribe` is METHOD_NOT_FOUND even with a handler registered. + + resources/subscribe is removed from the 2026-07-28 surface; the registry rejects it before handler lookup. + """ + + async def subscribe_resource(ctx: ServerRequestContext, params: types.SubscribeRequestParams) -> EmptyResult: + """Registered so the rejection provably comes from the era gate, not a missing handler.""" + raise NotImplementedError + + server = Server("library", on_subscribe_resource=subscribe_resource) + + async with connect(server) as client: + with pytest.raises(MCPError) as exc_info: + await client.subscribe_resource("file:///watched.txt") + + assert exc_info.value.error == snapshot( + ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="resources/subscribe") + ) + + @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. @@ -312,3 +373,49 @@ async def subscribe_resource(ctx: ServerRequestContext, params: types.SubscribeR assert received == snapshot( [ResourceUpdatedNotification(params=ResourceUpdatedNotificationParams(uri="file:///watched.txt"))] ) + + +@requirement("resources:mrtr:read:basic") +async def test_read_resource_input_required_is_fulfilled_and_the_retry_returns_the_contents(connect: Connect) -> None: + """A resources/read answered with input_required is fulfilled by the elicitation callback and retried. + + Spec-mandated: resources/read is an MRTR-supported request (basic/patterns/mrtr, Supported Requests). + """ + sent = ElicitRequestFormParams( + message="Who is reading?", + requested_schema={"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}, + ) + answer = ElicitResult(action="accept", content={"name": "alice"}) + state = "state-1" + rounds: list[tuple[InputResponses | None, str | None]] = [] + callback_received: list[ElicitRequestFormParams] = [] + + async def read_resource( + ctx: ServerRequestContext, params: types.ReadResourceRequestParams + ) -> ReadResourceResult | InputRequiredResult: + assert params.uri == "file:///profile.txt" + rounds.append((params.input_responses, params.request_state)) + if params.input_responses is None: + return InputRequiredResult(input_requests={"who": ElicitRequest(params=sent)}, request_state=state) + response = params.input_responses["who"] + assert isinstance(response, ElicitResult) + assert response.content is not None + return ReadResourceResult( + contents=[TextResourceContents(uri=params.uri, text=f"hello {response.content['name']}")] + ) + + server = Server("library", on_read_resource=read_resource) + + async def elicit(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult: + assert isinstance(params, ElicitRequestFormParams) + callback_received.append(params) + return answer + + async with connect(server, elicitation_callback=elicit) as client: + result = await client.read_resource("file:///profile.txt") + + assert result == snapshot( + ReadResourceResult(contents=[TextResourceContents(uri="file:///profile.txt", text="hello alice")]) + ) + assert callback_received == [sent] + assert rounds == [(None, None), ({"who": answer}, state)] diff --git a/tests/interaction/lowlevel/test_roots.py b/tests/interaction/lowlevel/test_roots.py index bfd6cc90a..5e8d24c9a 100644 --- a/tests/interaction/lowlevel/test_roots.py +++ b/tests/interaction/lowlevel/test_roots.py @@ -4,7 +4,17 @@ import mcp_types as types import pytest from inline_snapshot import snapshot -from mcp_types import INTERNAL_ERROR, CallToolResult, ErrorData, ListRootsResult, Root, TextContent +from mcp_types import ( + INTERNAL_ERROR, + CallToolResult, + ErrorData, + InputRequiredResult, + InputResponses, + ListRootsRequest, + ListRootsResult, + Root, + TextContent, +) from pydantic import FileUrl from mcp import MCPError @@ -165,3 +175,88 @@ async def list_roots(context: ClientRequestContext) -> ListRootsResult: await delivered.wait() assert received == snapshot([types.NotificationParams()]) + + +@requirement("roots:mrtr:list:basic") +async def test_embedded_roots_list_is_fulfilled_and_the_roots_reach_the_retried_handler(connect: Connect) -> None: + """The roots callback answers an embedded roots/list and its roots reach the retried handler. Spec-mandated.""" + ROOTS = ListRootsResult( + roots=[ + Root(uri=FileUrl("file:///home/alice/project"), name="project"), + Root(uri=FileUrl("file:///home/alice/scratch")), + ] + ) + handler_received: list[InputResponses] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="show_roots", input_schema={"type": "object"})]) + + async def call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams + ) -> CallToolResult | InputRequiredResult: + assert params.name == "show_roots" + if params.input_responses is None: + return InputRequiredResult(input_requests={"roots": ListRootsRequest()}) + handler_received.append(params.input_responses) + answer = params.input_responses["roots"] + assert isinstance(answer, ListRootsResult) + lines = [f"{root.uri} name={root.name}" for root in answer.roots] + return CallToolResult(content=[TextContent(text="\n".join(lines))]) + + server = Server("rooted", on_list_tools=list_tools, on_call_tool=call_tool) + + async def list_roots(context: ClientRequestContext) -> ListRootsResult: + return ROOTS + + async with connect(server, list_roots_callback=list_roots) as client: + result = await client.call_tool("show_roots", {}) + + assert result == snapshot( + CallToolResult( + content=[ + TextContent( + text="""\ +file:///home/alice/project name=project +file:///home/alice/scratch name=None\ +""" + ) + ] + ) + ) + assert handler_received == [{"roots": ROOTS}] + + +@requirement("roots:mrtr:list:empty") +async def test_an_empty_embedded_roots_list_reaches_the_retried_handler_as_such(connect: Connect) -> None: + """An empty embedded roots list reaches the retried handler as an empty list, not an error. Spec-mandated.""" + EMPTY = ListRootsResult(roots=[]) + handler_received: list[InputResponses] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="count_roots", input_schema={"type": "object"})]) + + async def call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams + ) -> CallToolResult | InputRequiredResult: + assert params.name == "count_roots" + if params.input_responses is None: + return InputRequiredResult(input_requests={"roots": ListRootsRequest()}) + handler_received.append(params.input_responses) + answer = params.input_responses["roots"] + assert isinstance(answer, ListRootsResult) + return CallToolResult(content=[TextContent(text=str(len(answer.roots)))]) + + server = Server("rooted", on_list_tools=list_tools, on_call_tool=call_tool) + + async def list_roots(context: ClientRequestContext) -> ListRootsResult: + return EMPTY + + async with connect(server, list_roots_callback=list_roots) as client: + result = await client.call_tool("count_roots", {}) + + assert result == snapshot(CallToolResult(content=[TextContent(text="0")])) + assert handler_received == [{"roots": EMPTY}] diff --git a/tests/interaction/lowlevel/test_sampling.py b/tests/interaction/lowlevel/test_sampling.py index 4d2e888c4..ad2cca464 100644 --- a/tests/interaction/lowlevel/test_sampling.py +++ b/tests/interaction/lowlevel/test_sampling.py @@ -3,6 +3,8 @@ Each test nests a sampling/createMessage request inside a tool call: the tool handler calls ctx.session.create_message(), the client's sampling callback answers it, and the handler round-trips what it received back to the test through its tool result. + +The 2026 MRTR tests embed the request in an input_required result instead; the client fulfils it and retries. """ import mcp_types as types @@ -12,11 +14,14 @@ from mcp_types import ( AudioContent, CallToolResult, + CreateMessageRequest, CreateMessageRequestParams, CreateMessageResult, CreateMessageResultWithTools, ErrorData, ImageContent, + InputRequiredResult, + InputResponses, ModelHint, ModelPreferences, SamplingCapability, @@ -155,7 +160,7 @@ async def sampling_callback( ) -@requirement("sampling:create-message:image-content") +@requirement("sampling:create:image-content") async def test_create_message_request_with_image_content_reaches_callback(connect: Connect) -> None: """A sampling request message carrying image content arrives at the client callback intact. @@ -207,7 +212,7 @@ async def sampling_callback( ) -@requirement("sampling:create-message:image-content") +@requirement("sampling:create:image-content") async def test_create_message_result_with_image_content_returns_to_handler(connect: Connect) -> None: """A sampling result whose content is an image is returned to the requesting handler intact. @@ -281,7 +286,7 @@ async def sampling_callback(context: ClientRequestContext, params: CreateMessage assert result == snapshot(CallToolResult(content=[TextContent(text="-1: User rejected sampling request")])) -@requirement("sampling:create-message:not-supported") +@requirement("sampling:create:not-supported") async def test_create_message_without_callback_is_error(connect: Connect) -> None: """A sampling request to a client with no sampling callback fails with the SDK's default error.""" @@ -437,7 +442,7 @@ async def sampling_callback( assert captured == snapshot([SamplingCapability()]) -@requirement("sampling:create-message:audio-content") +@requirement("sampling:create:audio-content") async def test_create_message_request_with_audio_content_reaches_callback(connect: Connect) -> None: """A sampling request message carrying audio content arrives at the client callback intact. @@ -489,7 +494,7 @@ async def sampling_callback( ) -@requirement("sampling:create-message:audio-content") +@requirement("sampling:create:audio-content") async def test_create_message_result_with_audio_content_returns_to_handler(connect: Connect) -> None: """A sampling result whose content is audio is returned to the requesting handler intact. @@ -686,3 +691,230 @@ async def sampling_callback( result = await client.call_tool("ask_model", {}) assert result == snapshot(CallToolResult(content=[TextContent(text="ValidationError")])) + + +@requirement("sampling:mrtr:create:basic") +async def test_embedded_sampling_request_is_fulfilled_and_its_result_reaches_the_retried_handler( + connect: Connect, +) -> None: + """An embedded sampling request is fulfilled by the client callback and its result reaches the retried handler. + + Spec-mandated. + """ + SENT = CreateMessageRequestParams( + messages=[SamplingMessage(role="user", content=TextContent(text="Say hello."))], + max_tokens=100, + ) + RESULT = CreateMessageResult( + role="assistant", + content=TextContent(text="Hello to you too."), + model="mock-llm-1", + stop_reason="endTurn", + ) + callback_received: list[CreateMessageRequestParams] = [] + handler_received: list[InputResponses] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="ask_model", input_schema={"type": "object"})]) + + async def call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams + ) -> CallToolResult | InputRequiredResult: + assert params.name == "ask_model" + if params.input_responses is None: + return InputRequiredResult(input_requests={"ask": CreateMessageRequest(params=SENT)}) + handler_received.append(params.input_responses) + answer = params.input_responses["ask"] + assert isinstance(answer, CreateMessageResult) + assert isinstance(answer.content, TextContent) + return CallToolResult(content=[TextContent(text=f"{answer.model}/{answer.stop_reason}: {answer.content.text}")]) + + server = Server("sampler", on_list_tools=list_tools, on_call_tool=call_tool) + + async def sampling_callback( + context: ClientRequestContext, params: CreateMessageRequestParams + ) -> CreateMessageResult: + callback_received.append(params) + return RESULT + + async with connect(server, sampling_callback=sampling_callback) as client: + result = await client.call_tool("ask_model", {}) + + assert result == snapshot(CallToolResult(content=[TextContent(text="mock-llm-1/endTurn: Hello to you too.")])) + assert callback_received == [SENT] + assert handler_received == [{"ask": RESULT}] + + +@requirement("sampling:mrtr:create:include-context") +@requirement("sampling:mrtr:create:max-tokens") +@requirement("sampling:mrtr:create:model-preferences") +@requirement("sampling:mrtr:create:system-prompt") +async def test_embedded_sampling_params_reach_the_callback_intact(connect: Connect) -> None: + """Every parameter supplied in an embedded sampling request reaches the client callback unchanged. + + Spec-mandated. + """ + SENT = CreateMessageRequestParams( + messages=[SamplingMessage(role="user", content=TextContent(text="Pick a model."))], + model_preferences=ModelPreferences( + hints=[ModelHint(name="claude"), ModelHint(name="gpt")], + cost_priority=0.2, + speed_priority=0.3, + intelligence_priority=0.9, + ), + system_prompt="You are terse.", + # The other include_context values are deprecated at 2026-07-28 (SEP-2596) and capability-gated. + include_context="none", + temperature=0.7, + max_tokens=50, + stop_sequences=["\n\n", "END"], + ) + callback_received: list[CreateMessageRequestParams] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="ask_model", input_schema={"type": "object"})]) + + async def call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams + ) -> CallToolResult | InputRequiredResult: + assert params.name == "ask_model" + if params.input_responses is None: + return InputRequiredResult(input_requests={"ask": CreateMessageRequest(params=SENT)}) + answer = params.input_responses["ask"] + assert isinstance(answer, CreateMessageResult) + assert isinstance(answer.content, TextContent) + return CallToolResult(content=[TextContent(text=answer.content.text)]) + + server = Server("sampler", on_list_tools=list_tools, on_call_tool=call_tool) + + async def sampling_callback( + context: ClientRequestContext, params: CreateMessageRequestParams + ) -> CreateMessageResult: + callback_received.append(params) + return CreateMessageResult(role="assistant", content=TextContent(text="ok"), model="mock-llm-1") + + async with connect(server, sampling_callback=sampling_callback) as client: + result = await client.call_tool("ask_model", {}) + + assert callback_received == [SENT] + assert result == snapshot(CallToolResult(content=[TextContent(text="ok")])) + + +@requirement("sampling:create:messages-not-retained") +@requirement("sampling:mrtr:create:basic") +async def test_each_embedded_sampling_round_delivers_only_its_own_messages(connect: Connect) -> None: + """Each embedded sampling round delivers exactly its own messages list to the client callback. + + A retaining client would show round one's message inside round two's list. + """ + SENT1 = CreateMessageRequestParams( + messages=[SamplingMessage(role="user", content=TextContent(text="round one"))], + max_tokens=50, + ) + SENT2 = CreateMessageRequestParams( + messages=[SamplingMessage(role="user", content=TextContent(text="round two"))], + max_tokens=60, + ) + callback_received: list[CreateMessageRequestParams] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="ask_model", input_schema={"type": "object"})]) + + async def call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams + ) -> CallToolResult | InputRequiredResult: + assert params.name == "ask_model" + if params.input_responses is None: + return InputRequiredResult(input_requests={"first": CreateMessageRequest(params=SENT1)}) + if "first" in params.input_responses: + first = params.input_responses["first"] + assert isinstance(first, CreateMessageResult) + assert first.role == "assistant" + assert isinstance(first.content, TextContent) + assert first.content.text == "reply 1" + return InputRequiredResult( + input_requests={"second": CreateMessageRequest(params=SENT2)}, request_state="round-2" + ) + assert set(params.input_responses) == {"second"} + assert params.request_state == "round-2" + second = params.input_responses["second"] + assert isinstance(second, CreateMessageResult) + assert isinstance(second.content, TextContent) + return CallToolResult(content=[TextContent(text=f"{second.model}/{second.stop_reason}: {second.content.text}")]) + + server = Server("sampler", on_list_tools=list_tools, on_call_tool=call_tool) + + async def sampling_callback( + context: ClientRequestContext, params: CreateMessageRequestParams + ) -> CreateMessageResult: + callback_received.append(params) + return CreateMessageResult( + role="assistant", + content=TextContent(text=f"reply {len(callback_received)}"), + model="mock-llm-1", + stop_reason="endTurn", + ) + + async with connect(server, sampling_callback=sampling_callback) as client: + result = await client.call_tool("ask_model", {}) + + assert result == snapshot(CallToolResult(content=[TextContent(text="mock-llm-1/endTurn: reply 2")])) + assert callback_received == [SENT1, SENT2] + + +@requirement("sampling:create:messages-not-retained") +@requirement("sampling:create:basic") +async def test_each_push_sampling_request_delivers_only_its_own_messages(connect: Connect) -> None: + """Each push sampling request delivers exactly its own messages list to the client callback.""" + first_messages = [SamplingMessage(role="user", content=TextContent(text="round one"))] + second_messages = [SamplingMessage(role="user", content=TextContent(text="round two"))] + callback_received: list[CreateMessageRequestParams] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + return types.ListToolsResult(tools=[types.Tool(name="ask_twice", input_schema={"type": "object"})]) + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + assert params.name == "ask_twice" + first = await ctx.session.create_message(messages=first_messages, max_tokens=50) # pyright: ignore[reportDeprecated] + second = await ctx.session.create_message(messages=second_messages, max_tokens=60) # pyright: ignore[reportDeprecated] + assert first.role == "assistant" + assert second.role == "assistant" + assert isinstance(first.content, TextContent) + assert isinstance(second.content, TextContent) + return CallToolResult( + content=[ + TextContent( + text=f"{first.model}/{first.stop_reason}: {first.content.text} | " + f"{second.model}/{second.stop_reason}: {second.content.text}" + ) + ] + ) + + server = Server("sampler", on_list_tools=list_tools, on_call_tool=call_tool) + + async def sampling_callback( + context: ClientRequestContext, params: CreateMessageRequestParams + ) -> CreateMessageResult: + callback_received.append(params) + return CreateMessageResult( + role="assistant", + content=TextContent(text=f"reply {len(callback_received)}"), + model="mock-llm-1", + stop_reason="endTurn", + ) + + async with connect(server, sampling_callback=sampling_callback) as client: + result = await client.call_tool("ask_twice", {}) + + assert result == snapshot( + CallToolResult(content=[TextContent(text="mock-llm-1/endTurn: reply 1 | mock-llm-1/endTurn: reply 2")]) + ) + assert [p.messages for p in callback_received] == [first_messages, second_messages] diff --git a/tests/interaction/lowlevel/test_tools.py b/tests/interaction/lowlevel/test_tools.py index 861dd75e4..902c95a15 100644 --- a/tests/interaction/lowlevel/test_tools.py +++ b/tests/interaction/lowlevel/test_tools.py @@ -8,25 +8,45 @@ INVALID_PARAMS, AudioContent, CallToolResult, + DiscoverResult, EmbeddedResource, ErrorData, Icon, ImageContent, + Implementation, + JSONRPCRequest, + JSONRPCResponse, ListToolsResult, ResourceLink, + ServerCapabilities, TextContent, TextResourceContents, Tool, ToolAnnotations, ) +from mcp_types.version import LATEST_MODERN_VERSION from mcp import MCPError +from mcp.client.session import ClientSession from mcp.server import Server, ServerRequestContext +from mcp.shared.memory import create_client_server_memory_streams +from mcp.shared.message import SessionMessage from tests.interaction._connect import Connect from tests.interaction._requirements import requirement pytestmark = pytest.mark.anyio +# Shared by the client:jsonschema:* tests. prefixItems is enforced under JSON Schema 2020-12 but +# ignored under draft-07, so one schema/value pair reveals which engine validated it. +_PREFIX_ITEMS_SCHEMA: dict[str, object] = { + "type": "object", + "properties": {"point": {"type": "array", "prefixItems": [{"type": "number"}, {"type": "number"}]}}, + "required": ["point"], +} +_CONFORMING_POINT = {"point": [1.5, 2.5]} +_VIOLATING_POINT = {"point": [1, "x"]} # index 1 violates the second prefixItems schema +_INTS_SCHEMA: dict[str, object] = {"type": "array", "items": {"type": "integer"}} + @requirement("tools:call:content:text") async def test_call_tool_returns_text_content(connect: Connect) -> None: @@ -116,6 +136,28 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara assert exc_info.value.error == snapshot(ErrorData(code=0, message="boom")) +@requirement("errors:wire:legacy-code-opaque") +async def test_a_legacy_range_error_code_reaches_the_caller_verbatim_without_interpretation( + connect: Connect, +) -> None: + """An error code from the legacy -32000..-32019 sub-range reaches the caller verbatim with no meaning assigned.""" + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + assert params.name == "vendor" + # -32011: an in-band legacy-range code with no defined meaning (deliberately not -32002). + raise MCPError(code=-32011, message="vendor-specific failure", data={"hint": "opaque"}) + + server = Server("errors", on_call_tool=call_tool) + + async with connect(server) as client: + with pytest.raises(MCPError) as exc_info: + await client.call_tool("vendor", {}) + + assert exc_info.value.error == snapshot( + ErrorData(code=-32011, message="vendor-specific failure", data={"hint": "opaque"}) + ) + + @requirement("tools:list:basic") async def test_list_tools_returns_registered_tools(connect: Connect) -> None: """The tools advertised by the server's list handler arrive at the client unchanged.""" @@ -504,6 +546,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara server = Server("weather", on_list_tools=list_tools, on_call_tool=call_tool) async with connect(server) as client: + # The {} args matter: on http-2026 a non-empty call adds the server's internal Mcp-Param validation listing. first = await client.call_tool("forecast", {}) assert list_calls == ["called"] second = await client.call_tool("forecast", {}) @@ -511,3 +554,234 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara assert list_calls == ["called"] assert first == snapshot(CallToolResult(content=[TextContent(text="21 C")], structured_content={"temperature": 21})) assert second == first + + +@requirement("client:jsonschema:2020-12:prefixItems") +async def test_prefix_items_in_the_output_schema_are_enforced_per_index_on_structured_content( + connect: Connect, +) -> None: + """A structuredContent tuple violating a prefixItems per-index schema is rejected; a conforming one returns. + + Spec-mandated (2025-11-25 onward): clients MUST support 2020-12 and SHOULD validate structured results. + """ + schema = {**_PREFIX_ITEMS_SCHEMA, "$schema": "https://json-schema.org/draft/2020-12/schema"} + + async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult( + tools=[ + Tool(name="coords_ok", input_schema={"type": "object"}, output_schema=schema), + Tool(name="coords_bad", input_schema={"type": "object"}, output_schema=schema), + ] + ) + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + assert params.name in ("coords_ok", "coords_bad") + point = _CONFORMING_POINT if params.name == "coords_ok" else _VIOLATING_POINT + return CallToolResult(content=[TextContent(text="point")], structured_content=point) + + server = Server("coords", on_list_tools=list_tools, on_call_tool=call_tool) + + async with connect(server) as client: + await client.list_tools() + ok = await client.call_tool("coords_ok", {}) + with pytest.raises(RuntimeError) as exc_info: + await client.call_tool("coords_bad", {}) + + assert ok.structured_content == _CONFORMING_POINT + # The message embeds the jsonschema validation error, so only the SDK-authored prefix is pinned. + assert str(exc_info.value).startswith("Invalid structured content returned by tool coords_bad") + + +@requirement("client:jsonschema:dialect:default-is-2020-12") +async def test_schema_dialect_defaults_to_2020_12_and_a_declared_draft_07_dialect_is_honored( + connect: Connect, +) -> None: + """An outputSchema without $schema is validated as 2020-12; a declared draft-07 dialect is honored. + + Spec-mandated: schemas are validated per their declared or default dialect (2025-11-25 basic). + """ + schema_d7 = {**_PREFIX_ITEMS_SCHEMA, "$schema": "http://json-schema.org/draft-07/schema#"} + + async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult( + tools=[ + Tool(name="untagged", input_schema={"type": "object"}, output_schema=_PREFIX_ITEMS_SCHEMA), + Tool(name="tagged_draft7", input_schema={"type": "object"}, output_schema=schema_d7), + Tool(name="d7_type_bad", input_schema={"type": "object"}, output_schema=schema_d7), + ] + ) + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + assert params.name in ("untagged", "tagged_draft7", "d7_type_bad") + if params.name == "d7_type_bad": + # type IS enforced under draft-07, so this rejection proves validation ran under the declared dialect. + return CallToolResult(content=[TextContent(text="point")], structured_content={"point": "xx"}) + return CallToolResult(content=[TextContent(text="point")], structured_content=_VIOLATING_POINT) + + server = Server("dialects", on_list_tools=list_tools, on_call_tool=call_tool) + + async with connect(server) as client: + await client.list_tools() + with pytest.raises(RuntimeError) as untagged_exc: + await client.call_tool("untagged", {}) + tagged = await client.call_tool("tagged_draft7", {}) + with pytest.raises(RuntimeError) as d7_exc: + await client.call_tool("d7_type_bad", {}) + + assert str(untagged_exc.value).startswith("Invalid structured content returned by tool untagged") + assert tagged.structured_content == _VIOLATING_POINT + assert str(d7_exc.value).startswith("Invalid structured content returned by tool d7_type_bad") + + +@requirement("client:jsonschema:falsy-structured-content-validated") +async def test_falsy_structured_content_is_validated_not_mistaken_for_missing(connect: Connect) -> None: + """Falsy structuredContent values are validated as present, not mistaken for missing. + + A falsy presence check would route all three calls to the missing-structured-content error. + 2026-only: earlier revisions restrict structuredContent to objects. + """ + + async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult( + tools=[ + Tool(name="zero", input_schema={"type": "object"}, output_schema={"type": "integer"}), + Tool(name="empty", input_schema={"type": "object"}, output_schema={"type": "string"}), + Tool(name="flag", input_schema={"type": "object"}, output_schema={"type": "integer"}), + ] + ) + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + assert params.name in ("zero", "empty", "flag") + # flag deliberately mismatches its integer schema: JSON Schema excludes booleans from integer. + values: dict[str, object] = {"zero": 0, "empty": "", "flag": False} + return CallToolResult(content=[TextContent(text=params.name)], structured_content=values[params.name]) + + server = Server("falsy", on_list_tools=list_tools, on_call_tool=call_tool) + + async with connect(server) as client: + await client.list_tools() + zero = await client.call_tool("zero", {}) + empty = await client.call_tool("empty", {}) + with pytest.raises(RuntimeError) as exc_info: + await client.call_tool("flag", {}) + + assert zero.structured_content == 0 + # False == 0 and bool subclasses int, so pin the type as well. + assert type(zero.structured_content) is int + assert empty.structured_content == "" + assert str(exc_info.value).startswith("Invalid structured content returned by tool flag") + + +@requirement("client:jsonschema:non-object-output") +async def test_a_non_object_output_schema_root_is_validated_and_its_structured_content_returned( + connect: Connect, +) -> None: + """An array-rooted outputSchema is validated and its conforming structuredContent returned. + + 2026-only: through 2025-11-25 both the schema root and structuredContent are restricted to objects. + """ + + async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult( + tools=[ + Tool(name="ints", input_schema={"type": "object"}, output_schema=_INTS_SCHEMA), + Tool(name="ints_bad", input_schema={"type": "object"}, output_schema=_INTS_SCHEMA), + ] + ) + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + assert params.name in ("ints", "ints_bad") + values: dict[str, object] = {"ints": [1, 2, 3], "ints_bad": [1, "x"]} + return CallToolResult(content=[TextContent(text=params.name)], structured_content=values[params.name]) + + server = Server("arrays", on_list_tools=list_tools, on_call_tool=call_tool) + + async with connect(server) as client: + await client.list_tools() + result = await client.call_tool("ints", {}) + with pytest.raises(RuntimeError) as exc_info: + await client.call_tool("ints_bad", {}) + + assert result.structured_content == [1, 2, 3] + # The rejection proves validation ran for the non-object root rather than being skipped. + assert str(exc_info.value).startswith("Invalid structured content returned by tool ints_bad") + + +@requirement("client:jsonschema:null-structured-content") +async def test_a_wire_null_structured_content_is_rejected_as_missing_by_the_client() -> None: + """A wire structuredContent null is rejected as missing rather than validated against {type: 'null'}. + + Scripted over raw streams: the typed Server cannot author a wire null, and Client cannot drive raw streams. + When the SDK gains an absent-vs-null distinction: re-pin to the resolved null result and delete the Divergence. + """ + async with create_client_server_memory_streams() as (client_streams, server_streams): + client_read, client_write = client_streams + server_read, server_write = server_streams + + async def scripted_server() -> None: + with anyio.fail_after(5): + listing = await server_read.receive() + assert isinstance(listing, SessionMessage) + assert isinstance(listing.message, JSONRPCRequest) + assert listing.message.method == "tools/list" + await server_write.send( + SessionMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=listing.message.id, + # ttlMs/cacheScope/resultType are required v2026 scaffolding; the caching tests own them. + result={ + "tools": [ + {"name": "nil", "inputSchema": {"type": "object"}, "outputSchema": {"type": "null"}} + ], + "resultType": "complete", + "ttlMs": 0, + "cacheScope": "private", + }, + ) + ) + ) + with anyio.fail_after(5): + call = await server_read.receive() + assert isinstance(call, SessionMessage) + assert isinstance(call.message, JSONRPCRequest) + assert call.message.method == "tools/call" + assert call.message.params is not None + assert call.message.params["name"] == "nil" + await server_write.send( + SessionMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=call.message.id, + # None here IS the JSON null under test -- these raw dicts are the wire. + result={ + "content": [{"type": "text", "text": "null"}], + "resultType": "complete", + "structuredContent": None, + }, + ) + ) + ) + + # Combined async-with: a nested `async with` mis-traces its exit arcs under branch coverage on 3.11+. + async with ( + anyio.create_task_group() as task_group, + ClientSession(client_read, client_write, client_info=Implementation(name="cli", version="0")) as session, + ): + task_group.start_soon(scripted_server) + session.adopt( + DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + server_info=Implementation(name="srv", version="0"), + ) + ) + with anyio.fail_after(5): + listed = await session.list_tools() + assert [(tool.name, tool.output_schema) for tool in listed.tools] == [("nil", {"type": "null"})] + with pytest.raises(RuntimeError) as exc_info: + with anyio.fail_after(5): + await session.call_tool("nil", {}) + assert str(exc_info.value) == snapshot( + "Tool nil has an output schema but did not return structured content" + ) diff --git a/tests/interaction/lowlevel/test_x_mcp_header.py b/tests/interaction/lowlevel/test_x_mcp_header.py new file mode 100644 index 000000000..97e5b1915 --- /dev/null +++ b/tests/interaction/lowlevel/test_x_mcp_header.py @@ -0,0 +1,184 @@ +"""Client-side rejection of tools whose ``x-mcp-header`` annotation violates the 2026-07-28 spec. + +The SDK gates the check on the negotiated version rather than the transport (a deliberate +superset of the spec's Streamable-HTTP scoping), so both 2026 matrix cells pin the eviction. +""" + +import logging + +import pytest +from inline_snapshot import snapshot +from mcp_types import ListToolsResult, PaginatedRequestParams, Tool + +from mcp.server import Server, ServerRequestContext +from tests.interaction._connect import Connect +from tests.interaction._requirements import requirement + +pytestmark = pytest.mark.anyio + + +def _listing_server(*tools: Tool) -> Server: + """A server whose only job is to list the given tools.""" + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=list(tools)) + + return Server("x-mcp-header", on_list_tools=list_tools) + + +# Carries a valid annotation (not a plain schema) so survival proves the validator passes valid +# annotations; every test lists the broken tool first, so a client aborting the whole listing fails. +_VALID_TOOL = Tool( + name="ok", + input_schema={"type": "object", "properties": {"region": {"type": "string", "x-mcp-header": "Region"}}}, +) + + +@requirement("client:x-mcp-header:invalid-definition-rejected:empty") +@requirement("client:x-mcp-header:invalid-definition-rejected") +async def test_tool_with_empty_x_mcp_header_is_excluded_from_list_tools(connect: Connect) -> None: + """A tool with an empty x-mcp-header is excluded from tools/list while the valid sibling survives. + + Same SDK token check as the non-tchar case below, but the spec states the two MUSTs separately. + """ + broken = Tool( + name="broken", + input_schema={"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": ""}}}, + ) + + async with connect(_listing_server(broken, _VALID_TOOL)) as client: + listed = await client.list_tools() + + assert [tool.name for tool in listed.tools] == ["ok"] + + +@requirement("client:x-mcp-header:invalid-definition-rejected:non-tchar") +async def test_tool_with_non_token_x_mcp_header_is_excluded_from_list_tools(connect: Connect) -> None: + """A tool whose x-mcp-header is not an RFC 9110 token (``1*tchar``) is excluded from tools/list.""" + broken = Tool( + name="broken", + input_schema={"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "bad name"}}}, + ) + + async with connect(_listing_server(broken, _VALID_TOOL)) as client: + listed = await client.list_tools() + + assert [tool.name for tool in listed.tools] == ["ok"] + + +@requirement("client:x-mcp-header:invalid-definition-rejected:control-chars") +async def test_tool_with_crlf_in_x_mcp_header_is_excluded_from_list_tools(connect: Connect) -> None: + """A tool whose x-mcp-header contains CR/LF is excluded from tools/list. + + The control-character MUST NOT is its own spec sentence; the input is the header-injection shape. + """ + broken = Tool( + name="broken", + input_schema={ + "type": "object", + "properties": {"a": {"type": "string", "x-mcp-header": "X-Region\r\nEvil: 1"}}, + }, + ) + + async with connect(_listing_server(broken, _VALID_TOOL)) as client: + listed = await client.list_tools() + + assert [tool.name for tool in listed.tools] == ["ok"] + + +@requirement("client:x-mcp-header:invalid-definition-rejected:duplicate") +async def test_tool_with_case_insensitively_duplicate_x_mcp_headers_is_excluded_from_list_tools( + connect: Connect, +) -> None: + """A tool with two x-mcp-header values equal only case-insensitively is excluded from tools/list. + + ``Region``/``region`` defeats a validator that compares duplicates as exact strings. + """ + broken = Tool( + name="broken", + input_schema={ + "type": "object", + "properties": { + "a": {"type": "string", "x-mcp-header": "Region"}, + "b": {"type": "string", "x-mcp-header": "region"}, + }, + }, + ) + + async with connect(_listing_server(broken, _VALID_TOOL)) as client: + listed = await client.list_tools() + + assert [tool.name for tool in listed.tools] == ["ok"] + + +@requirement("client:x-mcp-header:invalid-definition-rejected:non-primitive") +async def test_tool_with_x_mcp_header_on_a_number_property_is_excluded_from_list_tools(connect: Connect) -> None: + """A tool annotating a ``number`` property with x-mcp-header is excluded from tools/list. + + ``number`` is the one JSON primitive the spec forbids, defeating an "any JSON primitive" check. + """ + broken = Tool( + name="broken", + input_schema={"type": "object", "properties": {"amount": {"type": "number", "x-mcp-header": "Amount"}}}, + ) + + async with connect(_listing_server(broken, _VALID_TOOL)) as client: + listed = await client.list_tools() + + assert [tool.name for tool in listed.tools] == ["ok"] + + +@requirement("client:x-mcp-header:invalid-definition-rejected:not-statically-reachable") +async def test_x_mcp_header_under_items_invalidates_the_tool_while_a_nested_properties_chain_stays_valid( + connect: Connect, +) -> None: + """An x-mcp-header under ``items`` invalidates its tool; one nested via ``properties`` keys stays valid. + + The nested sibling covers both arms of the spec sentence, so the flat ``_VALID_TOOL`` is unused. + """ + via_items = Tool( + name="via-items", + input_schema={ + "type": "object", + "properties": {"a": {"type": "array", "items": {"type": "string", "x-mcp-header": "Region"}}}, + }, + ) + nested_ok = Tool( + name="nested-ok", + input_schema={ + "type": "object", + "properties": { + "cfg": {"type": "object", "properties": {"region": {"type": "string", "x-mcp-header": "Region"}}} + }, + }, + ) + + async with connect(_listing_server(via_items, nested_ok)) as client: + listed = await client.list_tools() + + assert [tool.name for tool in listed.tools] == ["nested-ok"] + + +@requirement("client:x-mcp-header:invalid-tool-excluded:logs-warning") +async def test_rejecting_an_invalid_tool_logs_a_warning_naming_the_tool_and_reason( + connect: Connect, caplog: pytest.LogCaptureFixture +) -> None: + """Rejecting a tool over an invalid x-mcp-header logs a warning naming the tool and the reason. + + A single deterministic SDK-authored record, so the whole message is snapshot-pinned. + """ + broken = Tool( + name="broken", + input_schema={"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "bad name"}}}, + ) + + async with connect(_listing_server(broken, _VALID_TOOL)) as client: + with caplog.at_level(logging.WARNING, logger="client"): + listed = await client.list_tools() + + assert [tool.name for tool in listed.tools] == ["ok"] + records = [record for record in caplog.records if record.name == "client"] + assert len(records) == 1 + assert records[0].getMessage() == snapshot( + "dropping tool 'broken': invalid x-mcp-header (property 'a': x-mcp-header 'bad name' is not an RFC 9110 token)" + ) diff --git a/tests/interaction/mcpserver/test_context.py b/tests/interaction/mcpserver/test_context.py index 27c0c70cc..cc8637f24 100644 --- a/tests/interaction/mcpserver/test_context.py +++ b/tests/interaction/mcpserver/test_context.py @@ -27,7 +27,7 @@ pytestmark = pytest.mark.anyio -@requirement("mcpserver:context:logging") +@requirement("mcpserver:context:log-from-handler") @requirement("logging:capability:declared") async def test_context_logging_helpers_send_log_notifications(connect: Connect) -> None: """Each Context logging helper sends a log message notification at the matching severity. @@ -121,7 +121,7 @@ async def whoami(ctx: Context) -> str: assert request_id -@requirement("mcpserver:context:logging") +@requirement("mcpserver:context:log-from-handler") @requirement("protocol:progress:no-token") async def test_report_progress_without_a_progress_token_sends_nothing(connect: Connect) -> None: """When the caller supplied no progress callback, Context.report_progress is a silent no-op. @@ -153,7 +153,7 @@ async def collect(message: IncomingMessage) -> None: ) -@requirement("mcpserver:context:elicit") +@requirement("mcpserver:context:elicit-from-handler") @requirement("tools:call:elicitation-roundtrip") async def test_context_elicit_returns_typed_result(connect: Connect) -> None: """Context.elicit sends a form elicitation built from a pydantic schema and returns a typed result. diff --git a/tests/interaction/mcpserver/test_extensions.py b/tests/interaction/mcpserver/test_extensions.py index 205a7fd6e..ca1f3124d 100644 --- a/tests/interaction/mcpserver/test_extensions.py +++ b/tests/interaction/mcpserver/test_extensions.py @@ -102,8 +102,10 @@ async def test_claimed_shape_fails_validation_for_a_client_without_the_extension """Spec-mandated: an unrecognized `resultType` is invalid, so a client without the owning extension fails to parse the claimed shape.""" async with connect(_receipt_shop(_ReceiptIssuer())) as client: - with pytest.raises(ValidationError): + with pytest.raises(ValidationError) as exc_info: await client.call_tool("buy", {"item": "lamp"}) + # Structured error fields, not message text: pydantic's rendering changes across versions. + assert ("literal_error", "receipt") in [(error["type"], error["input"]) for error in exc_info.value.errors()] class _SettingsEchoIssuer(Extension): diff --git a/tests/interaction/mcpserver/test_prompts.py b/tests/interaction/mcpserver/test_prompts.py index 58c8b48c7..431fbd2e3 100644 --- a/tests/interaction/mcpserver/test_prompts.py +++ b/tests/interaction/mcpserver/test_prompts.py @@ -193,3 +193,38 @@ def greet_second() -> str: messages=[PromptMessage(role="user", content=TextContent(text="first"))], ) ) + + +@requirement("prompts:list:connection-invariant") +async def test_prompt_list_is_identical_across_connections_and_unchanged_by_other_requests( + connect: Connect, +) -> None: + """Spec-mandated: concurrent connections see the same prompt list, unchanged by a prompts/get on one.""" + mcp = MCPServer("prompter") + + @mcp.prompt() + def greet() -> str: + """A fixed greeting.""" + return "Say hello." + + @mcp.prompt() + def farewell() -> str: + """Listed on both connections; never rendered.""" + raise NotImplementedError + + async with connect(mcp) as first_client, connect(mcp) as second_client: + first_list = await first_client.list_prompts() + second_list = await second_client.list_prompts() + assert second_list == first_list + # The snapshot at the end proves this request ran; the list asserts prove it changed nothing. + result = await first_client.get_prompt("greet") + assert await first_client.list_prompts() == first_list + assert await second_client.list_prompts() == first_list + + assert result == snapshot( + GetPromptResult( + description="A fixed greeting.", + messages=[PromptMessage(role="user", content=TextContent(text="Say hello."))], + ) + ) + assert [prompt.name for prompt in first_list.prompts] == snapshot(["greet", "farewell"]) diff --git a/tests/interaction/mcpserver/test_resources.py b/tests/interaction/mcpserver/test_resources.py index d7fd99605..2a7bbf6fc 100644 --- a/tests/interaction/mcpserver/test_resources.py +++ b/tests/interaction/mcpserver/test_resources.py @@ -41,6 +41,7 @@ def app_config() -> str: @requirement("mcpserver:resource:static") +@requirement("mcpserver:resource:template") async def test_list_static_and_templated_resources(connect: Connect) -> None: """Statically-registered resources appear in resources/list; templated ones only in templates/list. @@ -110,7 +111,7 @@ def user_profile(user_id: str) -> str: ) -@requirement("mcpserver:resource:unknown-uri") +@requirement("resources:read:unknown-uri") async def test_read_unknown_uri_is_error(connect: Connect) -> None: """Reading a URI that matches no registered resource fails with -32602 and the URI in data (SEP-2164).""" mcp = MCPServer("library") @@ -181,3 +182,79 @@ def config_second() -> str: assert result == snapshot( ReadResourceResult(contents=[TextResourceContents(uri="config://app", mime_type="text/plain", text="first")]) ) + + +@requirement("resources:list:connection-invariant") +async def test_resource_list_is_identical_across_connections_and_unchanged_by_other_requests( + connect: Connect, +) -> None: + """Concurrent connections see the same resource list before and after one reads (spec-mandated, 2026-07-28).""" + mcp = MCPServer("library") + + @mcp.resource("config://app") + def app_config() -> str: + """The application configuration.""" + return "theme = dark" + + @mcp.resource("memo://notes") + def notes() -> str: + """Listed on both connections; never read.""" + raise NotImplementedError + + async with connect(mcp) as first_client, connect(mcp) as second_client: + first_list = await first_client.list_resources() + second_list = await second_client.list_resources() + assert second_list == first_list + # The read must succeed and leave both lists unchanged. + result = await first_client.read_resource("config://app") + assert await first_client.list_resources() == first_list + assert await second_client.list_resources() == first_list + + assert result == snapshot( + ReadResourceResult( + contents=[TextResourceContents(uri="config://app", mime_type="text/plain", text="theme = dark")] + ) + ) + assert [resource.name for resource in first_list.resources] == snapshot(["app_config", "notes"]) + + +@requirement("resources:read:path-traversal-rejected") +async def test_read_with_a_traversal_path_is_rejected_without_invoking_the_resource_function( + connect: Connect, +) -> None: + """A traversal in the extracted path parameter is rejected before the resource function runs. + + Spec-mandated security MUST (2026-07-28). {+path} admits /-bearing values, so the URI matches the + template and the -32602 (deliberately identical to a non-match) comes from the security policy. + """ + mcp = MCPServer("files") + invoked: list[str] = [] + + @mcp.resource("file:///files/{+path}") + def serve_file(path: str) -> str: + invoked.append(path) + return f"contents of {path}" + + async with connect(mcp) as client: + # Control: prove the template serves safe paths. + control = await client.read_resource("file:///files/notes.txt") + with pytest.raises(MCPError) as exc_info: + await client.read_resource("file:///files/../../etc/passwd") + + assert control == snapshot( + ReadResourceResult( + contents=[ + TextResourceContents( + uri="file:///files/notes.txt", mime_type="text/plain", text="contents of notes.txt" + ) + ] + ) + ) + assert exc_info.value.error == snapshot( + ErrorData( + code=-32602, + message="Unknown resource: file:///files/../../etc/passwd", + data={"uri": "file:///files/../../etc/passwd"}, + ) + ) + assert invoked == ["notes.txt"] diff --git a/tests/interaction/mcpserver/test_tools.py b/tests/interaction/mcpserver/test_tools.py index a7791d7cd..2a3540bd5 100644 --- a/tests/interaction/mcpserver/test_tools.py +++ b/tests/interaction/mcpserver/test_tools.py @@ -394,9 +394,8 @@ async def test_adding_and_removing_tools_does_not_notify_connected_clients(conne add_tool and remove_tool only update the registry: a connected client that listed the tools before the mutation has no way to learn it should list them again. The spec provides - notifications/tools/list_changed for exactly this; MCPServer never sends it. The tool emits - one log message as a sentinel so the test proves notifications do reach the collector -- the - log message arrives, a list_changed does not. + notifications/tools/list_changed for exactly this; MCPServer never sends it. The sentinel's + log message proves notifications do reach the collector -- it arrives, a list_changed does not. """ received: list[IncomingMessage] = [] mcp = MCPServer("mutable") @@ -411,22 +410,87 @@ def doomed() -> str: raise NotImplementedError @mcp.tool() - async def grow(ctx: Context) -> str: - mcp.add_tool(extra, name="extra") - mcp.remove_tool("doomed") - await ctx.info("tool set changed") # pyright: ignore[reportDeprecated] - return "mutated" + async def sentinel(ctx: Context) -> str: + await ctx.info("after the mutation") # pyright: ignore[reportDeprecated] + return "sentinel ran" async def collect(message: IncomingMessage) -> None: received.append(message) async with connect(mcp, message_handler=collect) as client: before = await client.list_tools() - await client.call_tool("grow", {}) + # Mutate between requests: the spec forbids varying the set as a side effect of another request. + mcp.add_tool(extra, name="extra") + mcp.remove_tool("doomed") + await client.call_tool("sentinel", {}) after = await client.list_tools() - assert [tool.name for tool in before.tools] == ["doomed", "grow"] - assert [tool.name for tool in after.tools] == ["grow", "extra"] + assert [tool.name for tool in before.tools] == ["doomed", "sentinel"] + assert [tool.name for tool in after.tools] == ["sentinel", "extra"] assert received == snapshot( - [LoggingMessageNotification(params=LoggingMessageNotificationParams(level="info", data="tool set changed"))] + [LoggingMessageNotification(params=LoggingMessageNotificationParams(level="info", data="after the mutation"))] ) + + +@requirement("tools:list:connection-independent") +async def test_tool_list_is_identical_across_connections_and_unchanged_by_other_requests( + connect: Connect, +) -> None: + """Concurrent connections to one server see the same tool list, before and after one of them calls a tool. + + Spec-mandated (2026-07-28): the set MUST NOT vary per-connection or as a side effect of other requests. + """ + mcp = MCPServer("registry") + + @mcp.tool() + def cherry() -> str: + raise NotImplementedError + + @mcp.tool() + def apple() -> str: + return "ate" + + @mcp.tool() + def banana() -> str: + raise NotImplementedError + + async with connect(mcp) as first_client, connect(mcp) as second_client: + first_list = await first_client.list_tools() + second_list = await second_client.list_tools() + assert second_list == first_list + # An unrelated request on the first connection: proves it ran AND changed nothing. + result = await first_client.call_tool("apple", {}) + assert await first_client.list_tools() == first_list + assert await second_client.list_tools() == first_list + + assert result == snapshot(CallToolResult(content=[TextContent(text="ate")], structured_content={"result": "ate"})) + assert [tool.name for tool in first_list.tools] == snapshot(["cherry", "apple", "banana"]) + + +@requirement("tools:list:deterministic-order") +async def test_tool_list_order_is_stable_across_repeated_requests(connect: Connect) -> None: + """tools/list returns the same ordering on repeated requests against an unchanged tool set. + + Spec-mandated SHOULD (2026-07-28), requiring only *some* stable order; the snapshot pins the SDK's + registration order. Tools are registered non-alphabetically to expose any accidental re-sort. + """ + mcp = MCPServer("registry") + + @mcp.tool() + def cherry() -> str: + raise NotImplementedError + + @mcp.tool() + def apple() -> str: + raise NotImplementedError + + @mcp.tool() + def banana() -> str: + raise NotImplementedError + + async with connect(mcp) as client: + first = await client.list_tools() + second = await client.list_tools() + + assert [tool.name for tool in first.tools] == snapshot(["cherry", "apple", "banana"]) + assert second == first diff --git a/tests/interaction/transports/test_client_transport_http.py b/tests/interaction/transports/test_client_transport_http.py index 5508d3e8f..8f2fb6070 100644 --- a/tests/interaction/transports/test_client_transport_http.py +++ b/tests/interaction/transports/test_client_transport_http.py @@ -14,7 +14,7 @@ import pytest from inline_snapshot import snapshot from mcp_types import INVALID_REQUEST, CallToolResult, ErrorData, ListToolsResult, TextContent, Tool -from starlette.types import Receive, Scope, Send +from starlette.types import Message, Receive, Scope, Send from mcp import MCPError from mcp.client.client import Client @@ -95,7 +95,6 @@ async def test_every_request_after_initialize_carries_the_issued_session_id(reco assert session_id -@requirement("client-transport:http:protocol-version-stored") @requirement("client-transport:http:protocol-version-header") async def test_every_request_after_initialize_carries_the_negotiated_protocol_version( recorded: list[httpx.Request], @@ -246,3 +245,42 @@ async def first_post_then_404(scope: Scope, receive: Receive, send: Send) -> Non await client.list_tools() assert exc_info.value.error == snapshot(ErrorData(code=INVALID_REQUEST, message="Session terminated")) + + +@requirement("client-transport:http:sse-comment-line-ignored") +async def test_sse_comment_lines_in_the_response_stream_are_ignored_by_the_client() -> None: + """SSE comment lines interleaved into response streams are ignored by the client. Spec-mandated.""" + server = _tooled_server() + real_app = server.streamable_http_app(transport_security=NO_DNS_REBINDING_PROTECTION) + injected: list[bytes] = [] + + async def inject_sse_comments(scope: Scope, receive: Receive, send: Send) -> None: + # The bridge only delivers http scopes, so no scope-type guard is needed. + sse_response = False + + async def send_with_comments(message: Message) -> None: + nonlocal sse_response + if message["type"] == "http.response.start": + headers = {key.lower(): value for key, value in message.get("headers", [])} + sse_response = headers.get(b"content-type", b"").startswith(b"text/event-stream") + elif message["type"] == "http.response.body" and sse_response and message.get("body"): + message = {**message, "body": b": keep-alive\r\n\r\n" + message["body"]} + injected.append(message["body"]) + await send(message) + + await real_app(scope, receive, send_with_comments) + + async with ( + server.session_manager.run(), + httpx.AsyncClient(transport=StreamingASGITransport(inject_sse_comments), base_url=BASE_URL) as http_client, + ): + transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client) + with anyio.fail_after(5): # pragma: no branch + async with Client(transport, mode="legacy") as client: # pragma: no branch + tools = await client.list_tools() + result = await client.call_tool("echo", {"text": "hi"}) + + assert [tool.name for tool in tools.tools] == ["echo"] + assert result == snapshot(CallToolResult(content=[TextContent(text="hi")])) + # Non-vacuity: the initialize, tools/list, and tools/call responses each had a comment injected. + assert len(injected) >= 3 diff --git a/tests/interaction/transports/test_hosting_http.py b/tests/interaction/transports/test_hosting_http.py index 6331c2dae..57e3d0779 100644 --- a/tests/interaction/transports/test_hosting_http.py +++ b/tests/interaction/transports/test_hosting_http.py @@ -188,6 +188,7 @@ async def test_protocol_version_header_is_validated() -> None: @requirement("hosting:http:protocol-version-rejection-literal") +@requirement("lifecycle:version:unsupported-32022") async def test_unsupported_protocol_version_rejection_body_contains_the_sniffed_literal() -> None: """The 400 body for an unsupported MCP-Protocol-Version contains the substring peer SDKs sniff. @@ -195,6 +196,7 @@ async def test_unsupported_protocol_version_rejection_body_contains_the_sniffed_ version`` in the response body, so the literal must survive any rewording of the surrounding message. The unsupported value must appear in both the header and the envelope so the classifier reaches its version-supported rung rather than reporting a header mismatch first. + Also pins the -32022 negotiation error: a spec MUST, produced server-side here without the client retry path. """ bad = "1991-01-01" meta = { @@ -345,7 +347,6 @@ async def read_standalone_stream() -> None: @requirement("hosting:http:dns-rebinding") -@requirement("transport:streamable-http:origin-validation") async def test_origin_validation_rejects_disallowed_origins_when_enabled() -> None: """A disallowed Origin returns 403 (and Host 421) with protection enabled; disabled lets both through. diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py index 7ba905306..255041334 100644 --- a/tests/interaction/transports/test_hosting_http_modern.py +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -4,7 +4,7 @@ asserting the wire contract for a 2026-07-28 POST -- one self-contained request, no initialize handshake, no ``Mcp-Session-Id``, JSON response body -- and that 2025-era traffic on the same endpoint is byte-unchanged. The SDK client never exposes the response headers or the raw -result-envelope shape, so every assertion here is necessarily wire-level. +result-envelope shape, so every assertion here is necessarily wire-level. A few tests drive the SDK client instead. """ import json @@ -14,38 +14,64 @@ import anyio import httpx import pytest +from httpx_sse import aconnect_sse from inline_snapshot import snapshot from mcp_types import ( CLIENT_CAPABILITIES_META_KEY, HEADER_MISMATCH, INTERNAL_ERROR, INVALID_PARAMS, + INVALID_REQUEST, METHOD_NOT_FOUND, MISSING_REQUIRED_CLIENT_CAPABILITY, + PROTOCOL_VERSION_META_KEY, CallToolRequestParams, CallToolResult, DiscoverResult, + ElicitRequestParams, + ElicitResult, EmptyResult, + ErrorData, + GetPromptRequestParams, + GetPromptResult, Implementation, JSONRPCError, + JSONRPCMessage, JSONRPCResponse, + ListResourcesResult, ListToolsResult, PaginatedRequestParams, + ProgressNotification, + ProgressNotificationParams, + PromptMessage, + ReadResourceRequestParams, + ReadResourceResult, Request, RequestParams, Result, ServerCapabilities, TextContent, + TextResourceContents, Tool, ) -from mcp_types.version import LATEST_MODERN_VERSION +from mcp_types.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION +from starlette.requests import Request as StarletteRequest from mcp import MCPError +from mcp.client import ClientRequestContext from mcp.client.client import Client from mcp.client.session import ClientSession from mcp.client.streamable_http import streamable_http_client from mcp.server import Server, ServerRequestContext -from tests.interaction._connect import BASE_URL, base_headers, initialize_via_http, mounted_app +from mcp.shared.exceptions import NoBackChannelError +from tests.interaction._connect import ( + BASE_URL, + base_headers, + client_via_http, + initialize_via_http, + mounted_app, + parse_sse_messages, +) from tests.interaction._requirements import requirement pytestmark = pytest.mark.anyio @@ -95,13 +121,15 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> @requirement("hosting:http:modern:tools-call-stateless") +@requirement("hosting:http:modern:lazy-sse-upgrade") async def test_modern_tools_call_returns_result_type_complete_without_initialize() -> None: """A 2026-07-28 tools/call is served without an initialize handshake and returns resultType: complete. Spec-mandated under the draft transport: the per-request ``_meta`` envelope replaces initialize, and ``resultType`` is the 2026 result-envelope discriminator (``complete`` for the monolith result). Asserted at the wire because the SDK client never surfaces ``resultType`` and because - the absence of any prior request on the connection is the assertion. + the absence of any prior request on the connection is the assertion. The ``application/json`` + Content-Type also pins the lazy-upgrade JSON arm: a silent handler never commits SSE. """ body = { "jsonrpc": "2.0", @@ -143,6 +171,7 @@ async def test_modern_response_carries_no_session_id_header() -> None: @requirement("hosting:http:modern:initialize-removed") +@requirement("lifecycle:version:dual-era-precedence") async def test_modern_initialize_is_method_not_found() -> None: """A 2026-07-28 initialize request that carries a valid envelope is answered METHOD_NOT_FOUND at HTTP 404. @@ -151,7 +180,8 @@ async def test_modern_initialize_is_method_not_found() -> None: ``_meta`` envelope so the classifier ladder admits it as far as kernel dispatch -- without the envelope the request is INVALID_PARAMS at rung 1, never METHOD_NOT_FOUND. Asserted at the wire because the SDK client at 2026-07-28 never sends initialize, so only a raw POST can drive the - negative. + negative. Also pins dual-era precedence: this frame is simultaneously a valid modern envelope + and the legacy handshake opener, and the rejection proves the modern classification won. """ body = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"_meta": _meta_envelope()}} async with mounted_app(_server()) as (http, _): @@ -212,12 +242,14 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> @requirement("hosting:http:modern:discover-response-shape") +@requirement("caching:hints:server-discover") async def test_modern_server_discover_returns_capabilities_and_supported_versions() -> None: """A 2026-07-28 server/discover POST returns capabilities, serverInfo, and supportedVersions. Spec-mandated under the draft: server/discover is the 2026 advertisement method that replaces the initialize-response payload, and ``supportedVersions`` is the field a client picks its - per-request envelope version from. Asserted at the wire because the SDK client never exposes + per-request envelope version from. Also pins the default ``ttlMs 0`` / ``cacheScope private`` + hints stamped on the result. Asserted at the wire because the SDK client never exposes the raw result body. """ body = {"jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {"_meta": _meta_envelope()}} @@ -229,6 +261,9 @@ async def test_modern_server_discover_returns_capabilities_and_supported_version assert result["supportedVersions"] == snapshot(["2026-07-28"]) assert result["serverInfo"]["name"] == "modern" assert "capabilities" in result + assert result["resultType"] == "complete" + assert result["ttlMs"] == 0 + assert result["cacheScope"] == "private" @requirement("hosting:http:modern:removed-method-status-404") @@ -513,7 +548,9 @@ async def test_modern_client_stops_mirroring_after_a_re_list_drops_the_tool() -> The tool is first listed with a valid annotation (so a call mirrors `Mcp-Param-Region`), then re-listed with an invalid annotation -- the modern client drops it and evicts the cached map, so a later `tools/call` - by name carries no `Mcp-Param-*` header. Asserted at the wire, where the eviction is observable. + by name carries no `Mcp-Param-*` header. The server serves that header-less call only because the same + invalid schema disables its own validation (the shared validator skips schemas it rejects); a valid + annotated schema would reject the missing header. Asserted at the wire, where the eviction is observable. """ schema = {"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "Region"}}} bad_schema = {"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "bad name"}}} @@ -614,3 +651,831 @@ async def on_request(request: httpx.Request) -> None: [wire_request] = requests assert wire_request.headers["mcp-name"] == "job-7" assert json.loads(wire_request.content)["params"]["jobId"] == "job-7" + + +@requirement("client-transport:http:mcp-name-base64-sentinel") +async def test_non_header_safe_tool_name_is_carried_as_base64_sentinel_mcp_name() -> None: + """A tools/call for a non-header-safe tool name carries ``Mcp-Name`` in the base64 sentinel form. + + Spec-mandated. No prior ``list_tools``, so the header is derived from the request body, not a + cached schema; the round trip completing proves the server decoded the sentinel. + """ + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + # Live: the client's implicit output-schema refresh calls tools/list. + return ListToolsResult(tools=[Tool(name="hëllo", input_schema={"type": "object"})]) + + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + assert params.name == "hëllo" + return CallToolResult(content=[TextContent(text="ok")]) + + server = Server("sentinel-name", on_list_tools=list_tools, on_call_tool=call_tool) + + requests: list[httpx.Request] = [] + + async def on_request(request: httpx.Request) -> None: + requests.append(request) + + discover = DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + server_info=Implementation(name="srv", version="0"), + ) + with anyio.fail_after(5): + async with ( + mounted_app(server, on_request=on_request) as (http, _), + Client( + streamable_http_client(f"{BASE_URL}/mcp", http_client=http), + mode=LATEST_MODERN_VERSION, + prior_discover=discover, + ) as client, + ): + result = await client.call_tool("hëllo", {}) + + assert result == snapshot(CallToolResult(content=[TextContent(text="ok")])) + call = next(r for r in requests if json.loads(r.content)["method"] == "tools/call") + assert call.headers["mcp-name"] == snapshot("=?base64?aMOrbGxv?=") + assert json.loads(call.content)["params"]["name"] == "hëllo" + + +@requirement("client-transport:http:custom-param-headers:sentinel-collision-escaped") +async def test_sentinel_lookalike_argument_value_is_base64_wrapped_in_its_param_header() -> None: + """An argument value that itself matches ``=?base64?...?=`` is base64-wrapped in its param header. + + Spec-mandated by the sentinel-collision rule, the only encoding trigger: the value is otherwise header-safe ASCII. + """ + requests: list[httpx.Request] = [] + + async def on_request(request: httpx.Request) -> None: + requests.append(request) + + discover = DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + server_info=Implementation(name="srv", version="0"), + ) + with anyio.fail_after(5): + async with ( + mounted_app(_custom_header_server(), on_request=on_request) as (http, _), + Client( + streamable_http_client(f"{BASE_URL}/mcp", http_client=http), + mode=LATEST_MODERN_VERSION, + prior_discover=discover, + ) as client, + ): + # Param mirroring requires the cached schema map, so list first. + await client.list_tools() + await client.call_tool("run", {"region": "=?base64?literal?="}) + + call = next(r for r in requests if json.loads(r.content)["method"] == "tools/call") + assert {k: v for k, v in call.headers.items() if k.startswith("mcp-param-")} == snapshot( + {"mcp-param-region": "=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?="} + ) + assert json.loads(call.content)["params"]["arguments"] == {"region": "=?base64?literal?="} + + +@requirement("hosting:http:modern:mcp-param-null-absent-not-required") +@requirement("client-transport:http:custom-param-headers") +async def test_null_and_absent_annotated_arguments_emit_no_param_headers_and_the_server_accepts() -> None: + """Null and absent annotated arguments emit no ``Mcp-Param-*`` headers and the server accepts the call. + + Spec-mandated by the behaviour matrix's null and absent rows. The fixture advertises the + annotated schema, so this acceptance is a validated accept: the server checks each annotated + argument against its `Mcp-Param-*` header and would reject an orphan header for the null or + absent argument (a header matching no annotation is ignored). + """ + requests: list[httpx.Request] = [] + + async def on_request(request: httpx.Request) -> None: + requests.append(request) + + discover = DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + server_info=Implementation(name="srv", version="0"), + ) + with anyio.fail_after(5): + async with ( + mounted_app(_custom_header_server(), on_request=on_request) as (http, _), + Client( + streamable_http_client(f"{BASE_URL}/mcp", http_client=http), + mode=LATEST_MODERN_VERSION, + prior_discover=discover, + ) as client, + ): + # Param mirroring requires the cached schema map, so list first. + await client.list_tools() + result = await client.call_tool("run", {"region": "us-west1", "note": None}) + + assert result == snapshot(CallToolResult(content=[TextContent(text="ok")])) + call = next(r for r in requests if json.loads(r.content)["method"] == "tools/call") + assert {k: v for k, v in call.headers.items() if k.startswith("mcp-param-")} == snapshot( + {"mcp-param-region": "us-west1"} + ) + assert json.loads(call.content)["params"]["arguments"] == {"region": "us-west1", "note": None} + + +@requirement("hosting:http:modern:std-header-mismatch-400") +async def test_modern_mcp_method_header_disagreeing_with_body_method_is_rejected_400_header_mismatch() -> None: + """A ``Mcp-Method`` header disagreeing with the body's method is rejected with HTTP 400 and HeaderMismatch. + + Spec-mandated; everything else on the request is valid, so the rejection provably comes from the Mcp-Method rung. + """ + body = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "add", "arguments": {"a": 2, "b": 3}, "_meta": _meta_envelope()}, + } + with anyio.fail_after(5): + async with mounted_app(_server()) as (http, _): + response = await http.post("/mcp", json=body, headers=_modern_headers(method="tools/list", name="add")) + + assert response.status_code == 400 + assert JSONRPCError.model_validate(response.json()).error == snapshot( + ErrorData(code=HEADER_MISMATCH, message="mcp-method header does not match the request body's method") + ) + + +@requirement("hosting:http:modern:std-header-mismatch-400") +async def test_modern_mcp_name_header_disagreeing_with_body_name_is_rejected_400_header_mismatch() -> None: + """A ``Mcp-Name`` header disagreeing with the body's name parameter is rejected with HTTP 400 and HeaderMismatch. + + Spec-mandated: the Mcp-Name arm of the same MUST as the test above, a distinct rung with its own message. + """ + body = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "add", "arguments": {"a": 2, "b": 3}, "_meta": _meta_envelope()}, + } + with anyio.fail_after(5): + async with mounted_app(_server()) as (http, _): + response = await http.post("/mcp", json=body, headers=_modern_headers(method="tools/call", name="subtract")) + + assert response.status_code == 400 + assert JSONRPCError.model_validate(response.json()).error == snapshot( + ErrorData(code=HEADER_MISMATCH, message="mcp-name header does not match the request body's 'name' parameter") + ) + + +@requirement("hosting:http:modern:cacheable-stamping") +async def test_modern_cacheable_results_carry_ttl_and_scope_with_defaults_filled() -> None: + """A 2026-07-28 cacheable result reaches the wire as resultType complete plus the ttlMs/cacheScope hints. + + Spec-mandated for the hints' presence; SDK-defined for the fill: authored values pass through + (tools/list), unauthored gets the defaults (resources/list), partial fills only the missing + hint (resources/read). The typed client default-fills, so the stamp is only visible at the wire. + """ + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult( + tools=[Tool(name="add", input_schema={"type": "object"})], ttl_ms=60_000, cache_scope="public" + ) + + async def list_resources(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListResourcesResult: + # Neither hint set: the wire values are the SDK's default fill. + return ListResourcesResult(resources=[]) + + async def read_resource(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult: + assert params.uri == "res://x" + return ReadResourceResult(contents=[TextResourceContents(uri="res://x", text="hi")], ttl_ms=5_000) + + server = Server( + "cacheable", on_list_tools=list_tools, on_list_resources=list_resources, on_read_resource=read_resource + ) + + with anyio.fail_after(5): + async with mounted_app(server) as (http, _): + listed_tools = await http.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": _meta_envelope()}}, + headers=_modern_headers(method="tools/list"), + ) + listed_resources = await http.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 2, "method": "resources/list", "params": {"_meta": _meta_envelope()}}, + headers=_modern_headers(method="resources/list"), + ) + # resources/read is name-bearing on its uri param: without Mcp-Name the ladder 400s. + read = await http.post( + "/mcp", + json={ + "jsonrpc": "2.0", + "id": 3, + "method": "resources/read", + "params": {"uri": "res://x", "_meta": _meta_envelope()}, + }, + headers=_modern_headers(method="resources/read", name="res://x"), + ) + + assert listed_tools.status_code == 200 + assert JSONRPCResponse.model_validate(listed_tools.json()).result == snapshot( + { + "cacheScope": "public", + "resultType": "complete", + "tools": [{"inputSchema": {"type": "object"}, "name": "add"}], + "ttlMs": 60000, + } + ) + assert listed_resources.status_code == 200 + assert JSONRPCResponse.model_validate(listed_resources.json()).result == snapshot( + {"cacheScope": "private", "resources": [], "resultType": "complete", "ttlMs": 0} + ) + assert read.status_code == 200 + assert JSONRPCResponse.model_validate(read.json()).result == snapshot( + { + "cacheScope": "private", + "contents": [{"text": "hi", "uri": "res://x"}], + "resultType": "complete", + "ttlMs": 5000, + } + ) + + +@requirement("hosting:http:modern:json-response-mode") +async def test_modern_json_response_mode_returns_single_json_body_and_drops_mid_call_notifications() -> None: + """In JSON response mode a 2026-07-28 request gets one application/json body; mid-call emits are dropped. + + SDK-defined. The full-body snapshot is both proofs: the one body is the only place a buffered + notification could surface. The emit passes ``related_request_id`` so the drop pinned is the + json-mode drop, not the no-channel drop the connection's outbound would apply anyway. + """ + + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + assert params.name == "noisy" + await ctx.session.send_notification( + ProgressNotification(params=ProgressNotificationParams(progress_token="t", progress=1)), + related_request_id=ctx.request_id, + ) + return CallToolResult(content=[TextContent(text="done")]) + + body = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "noisy", "arguments": {}, "_meta": _meta_envelope()}, + } + with anyio.fail_after(5): + async with mounted_app(Server("modern", on_call_tool=call_tool), json_response=True) as (http, _): + response = await http.post("/mcp", json=body, headers=_modern_headers(method="tools/call", name="noisy")) + + assert response.status_code == 200 + assert response.headers["content-type"].split(";", 1)[0] == "application/json" + assert response.json() == snapshot( + { + "jsonrpc": "2.0", + "id": 1, + "result": {"content": [{"text": "done", "type": "text"}], "isError": False, "resultType": "complete"}, + } + ) + + +@requirement("hosting:http:modern:lazy-sse-upgrade") +async def test_modern_response_upgrades_to_sse_when_the_handler_emits_and_ends_with_the_result() -> None: + """On the default mode, mid-call emits upgrade the response to SSE with the result as the last frame. + + SDK-defined framing; the snapshot's length is the nothing-after-the-result proof, and the + silent-handler JSON arm is pinned by the stateless tools/call test above. The deferral window + before a silent handler commits SSE is deliberately unpinned (needs a real-time wait). + """ + + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + assert params.name == "noisy" + for progress in (1, 2): + await ctx.session.send_notification( + ProgressNotification(params=ProgressNotificationParams(progress_token="t", progress=progress)), + related_request_id=ctx.request_id, + ) + return CallToolResult(content=[TextContent(text="done")]) + + body = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "noisy", "arguments": {}, "_meta": _meta_envelope()}, + } + with anyio.fail_after(5): + async with ( + mounted_app(Server("modern", on_call_tool=call_tool)) as (http, _), + aconnect_sse( + http, "POST", "/mcp", json=body, headers=_modern_headers(method="tools/call", name="noisy") + ) as source, + ): + events = [event async for event in source.aiter_sse()] + + assert source.response.status_code == 200 + assert source.response.headers["content-type"].split(";", 1)[0] == "text/event-stream" + assert [ + m.model_dump(by_alias=True, mode="json", exclude_none=True) for m in parse_sse_messages(events) + ] == snapshot( + [ + {"jsonrpc": "2.0", "method": "notifications/progress", "params": {"progressToken": "t", "progress": 1.0}}, + {"jsonrpc": "2.0", "method": "notifications/progress", "params": {"progressToken": "t", "progress": 2.0}}, + { + "jsonrpc": "2.0", + "id": 1, + "result": {"content": [{"text": "done", "type": "text"}], "isError": False, "resultType": "complete"}, + }, + ] + ) + + +@requirement("hosting:http:modern:response-stream-request-scoped") +async def test_modern_notifications_land_only_on_the_originating_requests_response_stream() -> None: + """A notification emitted while serving one request travels only on that request's response stream. + + Spec-mandated. The interleaving is structural: "quiet" parks mid-handler, "emit" sends its + notification while quiet is provably in flight, then releases it; a broadcast or misroute + would have committed quiet's still-uncommitted response to SSE or added a frame. + """ + quiet_started = anyio.Event() + release_quiet = anyio.Event() + + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + if params.name == "emit": + with anyio.fail_after(5): + await quiet_started.wait() + await ctx.session.send_notification( + ProgressNotification(params=ProgressNotificationParams(progress_token="t", progress=1)), + related_request_id=ctx.request_id, + ) + release_quiet.set() + return CallToolResult(content=[TextContent(text="emitted")]) + assert params.name == "quiet" + quiet_started.set() + with anyio.fail_after(5): + await release_quiet.wait() + return CallToolResult(content=[TextContent(text="quiet-done")]) + + server = Server("scoped", on_call_tool=call_tool) + + emit_responses: list[httpx.Response] = [] + emit_frames: list[JSONRPCMessage] = [] + quiet_responses: list[httpx.Response] = [] + + async def post_emit(http: httpx.AsyncClient) -> None: + body = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "emit", "arguments": {}, "_meta": _meta_envelope()}, + } + async with aconnect_sse( + http, "POST", "/mcp", json=body, headers=_modern_headers(method="tools/call", name="emit") + ) as source: + events = [event async for event in source.aiter_sse()] + emit_responses.append(source.response) + emit_frames.extend(parse_sse_messages(events)) + + async def post_quiet(http: httpx.AsyncClient) -> None: + body = { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": {"name": "quiet", "arguments": {}, "_meta": _meta_envelope()}, + } + quiet_responses.append( + await http.post("/mcp", json=body, headers=_modern_headers(method="tools/call", name="quiet")) + ) + + with anyio.fail_after(5): + async with ( + mounted_app(server) as (http, _), + anyio.create_task_group() as tg, + ): + tg.start_soon(post_emit, http) + tg.start_soon(post_quiet, http) + + [sse_response] = emit_responses + assert sse_response.headers["content-type"].split(";", 1)[0] == "text/event-stream" + assert [m.model_dump(by_alias=True, mode="json", exclude_none=True) for m in emit_frames] == snapshot( + [ + {"jsonrpc": "2.0", "method": "notifications/progress", "params": {"progressToken": "t", "progress": 1.0}}, + { + "jsonrpc": "2.0", + "id": 1, + "result": { + "content": [{"text": "emitted", "type": "text"}], + "isError": False, + "resultType": "complete", + }, + }, + ] + ) + [json_response] = quiet_responses + assert json_response.headers["content-type"].split(";", 1)[0] == "application/json" + assert json_response.json() == snapshot( + { + "jsonrpc": "2.0", + "id": 2, + "result": {"content": [{"text": "quiet-done", "type": "text"}], "isError": False, "resultType": "complete"}, + } + ) + + +@requirement("hosting:http:sse-x-accel-buffering") +async def test_modern_sse_response_carries_x_accel_buffering_no() -> None: + """A 2026-07-28 response that commits to an SSE stream carries ``X-Accel-Buffering: no``. + + Spec-recommended so proxies deliver events unbuffered; the Content-Type assert guards a vacuous pass. + """ + + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + assert params.name == "noisy" + await ctx.session.send_notification( + ProgressNotification(params=ProgressNotificationParams(progress_token="t", progress=1)), + related_request_id=ctx.request_id, + ) + return CallToolResult(content=[TextContent(text="done")]) + + body = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "noisy", "arguments": {}, "_meta": _meta_envelope()}, + } + with anyio.fail_after(5): + async with ( + mounted_app(Server("modern", on_call_tool=call_tool)) as (http, _), + aconnect_sse( + http, "POST", "/mcp", json=body, headers=_modern_headers(method="tools/call", name="noisy") + ) as source, + ): + # Drained only so teardown is clean. + async for _ in source.aiter_sse(): + pass + + assert source.response.headers["x-accel-buffering"] == "no" + assert source.response.headers["content-type"].split(";", 1)[0] == "text/event-stream" + + +@requirement("hosting:http:modern:header-name-case-insensitive") +async def test_modern_standard_headers_are_matched_case_insensitively() -> None: + """Standard request headers sent under any casing are served, not rejected as missing. + + Spec-mandated. The bridge lowercases header names into the ASGI scope, so the pinned claim is + that the server's lookups key on the lowercase canonical names, not on any cased spelling. + """ + body = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "add", "arguments": {"a": 2, "b": 3}, "_meta": _meta_envelope()}, + } + # Hand-built: a union with base_headers() would keep its lowercase mcp-protocol-version key + # alongside the cased spelling, breaking the no-lowercase-spelling-anywhere premise. + headers = { + "accept": "application/json, text/event-stream", + "content-type": "application/json", + "MCP-PROTOCOL-VERSION": LATEST_MODERN_VERSION, + "MCP-METHOD": "tools/call", + "McP-NaMe": "add", + } + with anyio.fail_after(5): + async with mounted_app(_server()) as (http, _): + response = await http.post("/mcp", json=body, headers=headers) + + assert response.status_code == 200 + parsed = JSONRPCResponse.model_validate(response.json()) + assert parsed.id == 1 + assert parsed.result == snapshot( + {"content": [{"text": "5", "type": "text"}], "isError": False, "resultType": "complete"} + ) + + +@requirement("hosting:http:modern:missing-standard-header-rejected") +async def test_modern_request_missing_mcp_method_header_is_header_mismatch_at_http_400() -> None: + """A 2026-07-28 request missing the ``Mcp-Method`` header is rejected with HTTP 400 and HeaderMismatch. + + Spec-mandated. The rejection comes through the mismatch rung (absent header != body method), + so the message says "does not match" rather than "missing" -- covered by the spec, not a divergence. + """ + # tools/list is non-name-bearing, so the omitted Mcp-Method is the only missing header. + body = {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": _meta_envelope()}} + headers = base_headers() | {"mcp-protocol-version": LATEST_MODERN_VERSION} + with anyio.fail_after(5): + async with mounted_app(_server()) as (http, _): + response = await http.post("/mcp", json=body, headers=headers) + + assert response.status_code == 400 + error = JSONRPCError.model_validate(response.json()).error + assert error.code == HEADER_MISMATCH + assert error.message == snapshot("mcp-method header does not match the request body's method") + + +@requirement("hosting:http:modern:missing-standard-header-rejected") +async def test_modern_name_bearing_request_missing_mcp_name_header_is_header_mismatch_at_http_400() -> None: + """A name-bearing request missing the ``Mcp-Name`` header is rejected with HTTP 400 and HeaderMismatch. + + Spec-mandated. The body's ``name`` is present while the header is absent (a name-less body is the spec's lenience). + """ + body = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "add", "arguments": {"a": 2, "b": 3}, "_meta": _meta_envelope()}, + } + with anyio.fail_after(5): + async with mounted_app(_server()) as (http, _): + # _modern_headers omits Mcp-Name when no name is given: valid except the one header. + response = await http.post("/mcp", json=body, headers=_modern_headers(method="tools/call")) + + assert response.status_code == 400 + error = JSONRPCError.model_validate(response.json()).error + assert error.code == HEADER_MISMATCH + assert error.message == snapshot("mcp-name header does not match the request body's 'name' parameter") + + +@requirement("hosting:http:modern:protocol-version-meta-mismatch-400") +async def test_modern_protocol_version_header_envelope_disagreement_is_header_mismatch_at_http_400() -> None: + """Individually valid but disagreeing header and envelope protocol versions are rejected 400 HeaderMismatch. + + Spec-mandated, and the mismatch rung runs before the supported-version check: the envelope + value is deliberately unsupported, so the snapshot pins the rung order for free. + """ + envelope = _meta_envelope() + envelope[PROTOCOL_VERSION_META_KEY] = LATEST_HANDSHAKE_VERSION + body = {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": envelope}} + with anyio.fail_after(5): + async with mounted_app(_server()) as (http, _): + response = await http.post("/mcp", json=body, headers=_modern_headers(method="tools/list")) + + assert response.status_code == 400 + error = JSONRPCError.model_validate(response.json()).error + assert error.code == HEADER_MISMATCH + assert error.message == snapshot( + "mcp-protocol-version header does not match the request envelope's protocol version" + ) + + +@requirement("hosting:http:modern:sentinel-decoded-before-validation") +async def test_modern_encoded_mcp_name_matching_the_body_after_decode_is_served() -> None: + """A sentinel-encoded ``Mcp-Name`` whose decoded value matches the body is served, not rejected. + + Spec-mandated: the server decodes the header before validation -- a plain string comparison + would have answered 400 HeaderMismatch. The typed client sends ASCII bare, hence raw httpx. + """ + body = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "add", "arguments": {"a": 2, "b": 3}, "_meta": _meta_envelope()}, + } + headers = _modern_headers(method="tools/call") | {"mcp-name": "=?base64?YWRk?="} + with anyio.fail_after(5): + async with mounted_app(_server()) as (http, _): + response = await http.post("/mcp", json=body, headers=headers) + + assert response.status_code == 200 + parsed = JSONRPCResponse.model_validate(response.json()) + assert parsed.id == 1 + assert parsed.result == snapshot( + {"content": [{"text": "5", "type": "text"}], "isError": False, "resultType": "complete"} + ) + + +@requirement("hosting:http:modern:sentinel-decoded-before-validation") +async def test_modern_client_non_ascii_prompt_name_round_trips_via_sentinel_encoded_header() -> None: + """A non-ASCII prompt name round-trips end to end, travelling sentinel-encoded on the Mcp-Name header. + + Spec-mandated. The recorded request proves the header on the wire really was the sentinel + form; ``prompts/get`` is name-bearing with no implicit follow-up traffic, so exactly one POST. + """ + + async def get_prompt(ctx: ServerRequestContext, params: GetPromptRequestParams) -> GetPromptResult: + assert params.name == "héllo" + return GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="bonjour"))]) + + server = Server("sentinel-prompt", on_get_prompt=get_prompt) + + requests: list[httpx.Request] = [] + + async def on_request(request: httpx.Request) -> None: + requests.append(request) + + discover = DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + server_info=Implementation(name="srv", version="0"), + ) + with anyio.fail_after(5): + async with ( + mounted_app(server, on_request=on_request) as (http, _), + Client( + streamable_http_client(f"{BASE_URL}/mcp", http_client=http), + mode=LATEST_MODERN_VERSION, + prior_discover=discover, + ) as client, + ): + result = await client.get_prompt("héllo") + + assert result == snapshot( + GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="bonjour"))]) + ) + [call] = requests + assert json.loads(call.content)["method"] == "prompts/get" + assert call.headers["mcp-name"] == snapshot("=?base64?aMOpbGxv?=") + assert json.loads(call.content)["params"]["name"] == "héllo" + + +@requirement("hosting:http:modern:disconnect-cancels-handler") +async def test_modern_client_disconnect_mid_request_cancels_the_running_handler() -> None: + """Closing the SSE response stream mid-request cancels the running handler. + + Spec-mandated: the disconnect is the transport-level cancellation signal. The handler emits + one notification first so a committed stream exists to close; the "no response is written" + clause holds by construction (a cancelled handler never produces a result). + """ + handler_cancelled = anyio.Event() + + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + assert params.name == "park" + await ctx.session.send_notification( + ProgressNotification(params=ProgressNotificationParams(progress_token="t", progress=1)), + related_request_id=ctx.request_id, + ) + try: + # Parked with no normal exit: transport cancellation is the only way out. + while True: + await anyio.sleep_forever() + except anyio.get_cancelled_exc_class(): + handler_cancelled.set() + raise + + body = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "park", "arguments": {}, "_meta": _meta_envelope()}, + } + with anyio.fail_after(5): + async with mounted_app(Server("modern", on_call_tool=call_tool)) as (http, _): + async with aconnect_sse( + http, "POST", "/mcp", json=body, headers=_modern_headers(method="tools/call", name="park") + ) as source: + # Advanced once only: a full `async for` would wait for the close that is ours to perform. + events = source.aiter_sse() + first = await anext(events) + # Awaited while the app is still mounted: after mounted_app exits, the bridge's + # teardown cancellation would make this pass vacuously. + await handler_cancelled.wait() + + [first_frame] = parse_sse_messages([first]) + assert first_frame.model_dump(by_alias=True, mode="json", exclude_none=True) == snapshot( + {"jsonrpc": "2.0", "method": "notifications/progress", "params": {"progressToken": "t", "progress": 1.0}} + ) + + +@requirement("mrtr:push-api:loud-fail-2026") +async def test_modern_request_scoped_push_elicit_loud_fails_locally_and_the_call_still_completes() -> None: + """A request-scoped push elicit over the modern HTTP entry loud-fails locally and the call still completes. + + Spec-mandated outcome: the modern HTTP entry builds its per-request channel with no + back-channel, so the refusal is local by construction. The in-memory twin of this leg is + pinned in lowlevel/test_mrtr.py; this pin keeps the HTTP entry's own gate regression-covered. + """ + caught: list[NoBackChannelError] = [] + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + # Live: the client's implicit output-schema refresh calls tools/list. + return ListToolsResult(tools=[Tool(name="ask", input_schema={"type": "object"})]) + + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + assert params.name == "ask" + assert ctx.request_id is not None + try: + # The related id selects the per-request dispatch channel. + await ctx.session.elicit_form( + "Need a name", + {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}, + related_request_id=ctx.request_id, + ) + except NoBackChannelError as exc: + caught.append(exc) + return CallToolResult(content=[TextContent(text="fallback")]) + + server = Server("scoped-push", on_list_tools=list_tools, on_call_tool=call_tool) + + # Declares the elicitation capability, isolating the failure to the missing back-channel. + async def never_deliverable(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult: + raise NotImplementedError + + discover = DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + server_info=Implementation(name="srv", version="0"), + ) + with anyio.fail_after(5): + async with ( + mounted_app(server) as (http, _), + Client( + streamable_http_client(f"{BASE_URL}/mcp", http_client=http), + mode=LATEST_MODERN_VERSION, + prior_discover=discover, + elicitation_callback=never_deliverable, + ) as client, + ): + result = await client.call_tool("ask", {}) + + assert result == snapshot(CallToolResult(content=[TextContent(text="fallback")])) + assert len(caught) == 1 + assert caught[0].method == "elicitation/create" + assert caught[0].error == snapshot( + ErrorData( + code=INVALID_REQUEST, + message=( + "Cannot send 'elicitation/create': this transport context has no back-channel " + "for server-initiated requests." + ), + ) + ) + + +@requirement("hosting:http:request-headers-in-handler") +async def test_custom_request_header_reaches_the_handler_request_context_on_both_serving_paths() -> None: + """A custom HTTP header sent by the client reaches the handler's ctx.request on both serving paths. + + SDK-defined. The per-leg values are distinct so a failure names the broken path; each leg + builds a fresh server because a session manager only runs once. + """ + + def probe_server() -> Server: + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + # Live: call_tool's implicit output-schema fetch lists. + return ListToolsResult(tools=[Tool(name="probe", input_schema={"type": "object"})]) + + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + assert params.name == "probe" + assert isinstance(ctx.request, StarletteRequest) + return CallToolResult(content=[TextContent(text=ctx.request.headers.get("x-probe", ""))]) + + return Server("header-probe", on_list_tools=list_tools, on_call_tool=call_tool) + + discover = DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + server_info=Implementation(name="srv", version="0"), + ) + with anyio.fail_after(5): + async with ( + mounted_app(probe_server(), headers={"x-probe": "modern-value"}) as (http, _), + Client( + streamable_http_client(f"{BASE_URL}/mcp", http_client=http), + mode=LATEST_MODERN_VERSION, + prior_discover=discover, + ) as client, + ): + modern_result = await client.call_tool("probe", {}) + + with anyio.fail_after(5): + async with ( + mounted_app(probe_server(), headers={"x-probe": "legacy-value"}) as (http, _), + client_via_http(http) as client, + ): + legacy_result = await client.call_tool("probe", {}) + + assert modern_result == snapshot(CallToolResult(content=[TextContent(text="modern-value")])) + assert legacy_result == snapshot(CallToolResult(content=[TextContent(text="legacy-value")])) + + +@requirement("hosting:http:modern:mcp-param-mismatch-400") +async def test_modern_mcp_param_header_disagreeing_with_body_argument_is_rejected_400_header_mismatch() -> None: + """A ``Mcp-Param-*`` header disagreeing with its body argument is rejected with HTTP 400 and HeaderMismatch. + + Spec-mandated: the server resolves the ``x-mcp-header`` annotation from the tool's advertised + ``inputSchema`` via its own tools/list handler and rejects the decoded-header/body disagreement + before dispatch. Raw httpx because the HTTP status is a wire-only observable and the typed + client cannot emit a mismatching header by construction. + """ + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + tool = Tool( + name="run", + input_schema={"type": "object", "properties": {"region": {"type": "string", "x-mcp-header": "Region"}}}, + ) + return ListToolsResult(tools=[tool]) + + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + raise NotImplementedError # The mismatch is rejected before dispatch reaches the handler. + + body = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "run", "arguments": {"region": "us-west1"}, "_meta": _meta_envelope()}, + } + headers = _modern_headers(method="tools/call", name="run") | {"mcp-param-region": "eu-central1"} + with anyio.fail_after(5): + async with mounted_app(Server("param-mismatch", on_list_tools=list_tools, on_call_tool=call_tool)) as ( + http, + _, + ): + response = await http.post("/mcp", json=body, headers=headers) + + assert response.status_code == 400 + assert JSONRPCError.model_validate(response.json()).error == snapshot( + ErrorData( + code=HEADER_MISMATCH, message="Mcp-Param-Region header does not match the request body's 'region' argument" + ) + )