From 8c71b1b93cf2eaf0af6b2b2e0e8756550f81575b Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:07:50 +0000 Subject: [PATCH 01/16] Fix dead 2026 spec source URLs in the interaction manifest Eight entries cited pages or anchors that do not exist in the 2026-07-28 specification tree (the basic/lifecycle page was split into basic/versioning and server/discover; two anchors were renamed). Repoint each to the verified live section. Also add the missing added_in="2026-07-28" on client-auth:authorization-response:iss-verify: RFC 9207 iss validation is SEP-2468, new at 2026-07-28, and the prior 2025 source page carries no such requirement. Cell generation is unchanged (830 before and after). --- tests/interaction/_requirements.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index d376f0b9f..59fa6dcfd 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -352,7 +352,7 @@ def __post_init__(self) -> None: 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", + source=f"{SPEC_2026_BASE_URL}/basic#_meta", behavior=( "At protocol_version 2026-07-28, every request carries io.modelcontextprotocol/protocolVersion, " "/clientInfo, and /clientCapabilities in params._meta; no initialize handshake occurs." @@ -369,7 +369,7 @@ def __post_init__(self) -> None: 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 " @@ -398,14 +398,14 @@ def __post_init__(self) -> None: supersedes=("lifecycle:initialize:client-info", "lifecycle:initialize:client-capabilities"), ), "lifecycle:envelope:header-matches-meta": Requirement( - source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#headers", + 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." @@ -413,7 +413,7 @@ def __post_init__(self) -> None: added_in="2026-07-28", ), "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." @@ -3319,7 +3319,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." @@ -3342,7 +3342,7 @@ def __post_init__(self) -> None: note="Only observable over streamable HTTP: headers are derived from the cached tool schema at the seam.", ), "client-transport:http:stateless-ignores-session-id": Requirement( - source=f"{SPEC_2026_BASE_URL}/basic/transports#stateless-request-headers", + 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." @@ -3473,7 +3473,7 @@ def __post_init__(self) -> None: note="OAuth is HTTP-only.", ), "client-auth:as-binding": Requirement( - source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-binding", + 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)." @@ -3617,12 +3617,13 @@ def __post_init__(self) -> None: note="OAuth is HTTP-only.", ), "client-auth:authorization-response:iss-verify": Requirement( - source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-metadata-discovery", + 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)." ), + added_in="2026-07-28", transports=("streamable-http",), note="OAuth is HTTP-only.", ), From dad6a2a424f426aa860d3fe4fa706bc91d53ecdf Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:22:12 +0000 Subject: [PATCH 02/16] Align interaction-manifest ids with the cross-SDK vocabulary Rename 14 requirement ids to the names shared with the typescript-sdk e2e suite (sampling:create:*, elicitation:url:action:*, protocol:meta:*, the client-auth stepup/iss/scope/dcr family, the mcpserver context helpers), updating every @requirement decorator to match. The resources:read:unknown-uri name previously sat on an entry whose test proves lowlevel handler-error passthrough, not the unknown-resource rule; that entry is re-described honestly as protocol:error:handler-error- passthrough (source 'sdk'), and the real unknown-resource entry (-32602 with the URI in error.data, SEP-2164) takes the vacated name with its source repointed to the 2026 page that mandates it. The protocol:meta:request-to-handler 2026 arm exclusion now carries the accurate reason (legacy-only-vocabulary) and a note explaining the envelope- key merge that breaks the equality assertion, so the re-admission checklist finds it. Three test docstrings quoting pre-rename spec anchors updated. Cell generation unchanged (830 before and after). --- tests/interaction/_requirements.py | 88 +++++++++++-------- .../interaction/auth/test_authorize_token.py | 4 +- tests/interaction/auth/test_discovery.py | 2 +- tests/interaction/auth/test_lifecycle.py | 2 +- .../lowlevel/test_client_connect.py | 6 +- .../interaction/lowlevel/test_elicitation.py | 4 +- tests/interaction/lowlevel/test_meta.py | 4 +- tests/interaction/lowlevel/test_resources.py | 2 +- tests/interaction/lowlevel/test_sampling.py | 10 +-- tests/interaction/mcpserver/test_context.py | 6 +- tests/interaction/mcpserver/test_resources.py | 2 +- 11 files changed, 72 insertions(+), 58 deletions(-) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 59fa6dcfd..9d6371e90 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -572,6 +572,13 @@ def __post_init__(self) -> None: 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=( @@ -635,12 +642,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.", ), @@ -1037,7 +1055,7 @@ def __post_init__(self) -> None: # ═══════════════════════════════════════════════════════════════════════════ # 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 " @@ -1050,7 +1068,7 @@ 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 " @@ -1121,8 +1139,11 @@ 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", @@ -1228,13 +1249,6 @@ 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 # ═══════════════════════════════════════════════════════════════════════════ @@ -1528,7 +1542,7 @@ 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=( @@ -1536,7 +1550,7 @@ def __post_init__(self) -> None: ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), ), ), - "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=( @@ -1544,7 +1558,7 @@ def __post_init__(self) -> None: ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), ), ), - "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 " @@ -1832,20 +1846,28 @@ def __post_init__(self) -> None: ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), ), ), - "elicitation:url:basic": Requirement( - source=f"{SPEC_BASE_URL}/client/elicitation#url-mode-elicitation-requests", - behavior=( - "A url-mode elicitation delivers the elicitation id and URL to the client callback exactly as " - "the server sent them." - ), + "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.", arm_exclusions=( ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), ), ), - "elicitation:url:cancel": Requirement( + "elicitation:url:action:decline": Requirement( source=f"{SPEC_BASE_URL}/client/elicitation#response-actions", - behavior="A URL-mode elicitation answered with cancel returns the action with no content.", + 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"), + ), + ), + "elicitation:url:basic": Requirement( + source=f"{SPEC_BASE_URL}/client/elicitation#url-mode-elicitation-requests", + behavior=( + "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"), @@ -1873,14 +1895,6 @@ def __post_init__(self) -> None: removed_in="2026-07-28", note="removed in 2026-07-28 (spec PR #2891); notifications/elicitation/complete removed, no replacement.", ), - "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"), - ), - ), "elicitation:url:not-supported": Requirement( source=f"{SPEC_BASE_URL}/client/elicitation#error-handling", behavior=( @@ -3377,7 +3391,7 @@ def __post_init__(self) -> None: 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 " @@ -3420,7 +3434,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 " @@ -3454,7 +3468,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 " @@ -3616,7 +3630,7 @@ def __post_init__(self) -> None: transports=("streamable-http",), note="OAuth is HTTP-only.", ), - "client-auth:authorization-response:iss-verify": Requirement( + "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 " diff --git a/tests/interaction/auth/test_authorize_token.py b/tests/interaction/auth/test_authorize_token.py index d4eb591b5..d8e61d47c 100644 --- a/tests/interaction/auth/test_authorize_token.py +++ b/tests/interaction/auth/test_authorize_token.py @@ -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,7 +187,7 @@ 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") 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. 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_lifecycle.py b/tests/interaction/auth/test_lifecycle.py index c810f8c44..72c079cfa 100644 --- a/tests/interaction/auth/test_lifecycle.py +++ b/tests/interaction/auth/test_lifecycle.py @@ -179,7 +179,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. diff --git a/tests/interaction/lowlevel/test_client_connect.py b/tests/interaction/lowlevel/test_client_connect.py index 69fd5c4e8..164eb0aab 100644 --- a/tests/interaction/lowlevel/test_client_connect.py +++ b/tests/interaction/lowlevel/test_client_connect.py @@ -154,7 +154,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 +179,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 +349,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. """ diff --git a/tests/interaction/lowlevel/test_elicitation.py b/tests/interaction/lowlevel/test_elicitation.py index b8393dd31..8c79fd061 100644 --- a/tests/interaction/lowlevel/test_elicitation.py +++ b/tests/interaction/lowlevel/test_elicitation.py @@ -240,7 +240,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 +269,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.""" 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_resources.py b/tests/interaction/lowlevel/test_resources.py index 44ab33e64..48b0c7ee2 100644 --- a/tests/interaction/lowlevel/test_resources.py +++ b/tests/interaction/lowlevel/test_resources.py @@ -140,7 +140,7 @@ async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceReq ) -@requirement("resources:read:unknown-uri") +@requirement("protocol:error:handler-error-passthrough") 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. diff --git a/tests/interaction/lowlevel/test_sampling.py b/tests/interaction/lowlevel/test_sampling.py index 4d2e888c4..faef014cf 100644 --- a/tests/interaction/lowlevel/test_sampling.py +++ b/tests/interaction/lowlevel/test_sampling.py @@ -155,7 +155,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 +207,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 +281,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 +437,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 +489,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. 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_resources.py b/tests/interaction/mcpserver/test_resources.py index d7fd99605..3a236af85 100644 --- a/tests/interaction/mcpserver/test_resources.py +++ b/tests/interaction/mcpserver/test_resources.py @@ -110,7 +110,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") From bf6c2776074ed5664d0ab6fa97fc8e6350b67c0e Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:33:22 +0000 Subject: [PATCH 03/16] Retire three redundant interaction-manifest entries; era-bound a fourth client-transport:http:protocol-version-stored and transport:streamable-http:origin-validation were second labels on assertions their tests already pin under client-transport:http:protocol-version-header and hosting:http:dns-rebinding respectively (decorators removed, tests kept). lifecycle:stateless:no-initialize described a pin API that no longer exists, deferred against coverage that does not exist, and bound no tests. transport:streamable-http:server-to-client is NOT deleted: the underlying behaviour is real 2025-era wire truth that 2026 forbids (SEP-2322), so it gets removed_in="2026-07-28" with the supersession note; the MRTR successor link lands with the era pass. Cells unchanged (830 before and after). --- tests/interaction/_requirements.py | 29 ++++--------------- .../transports/test_client_transport_http.py | 1 - .../transports/test_hosting_http.py | 1 - 3 files changed, 5 insertions(+), 26 deletions(-) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 9d6371e90..1c5b8274d 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -359,15 +359,6 @@ def __post_init__(self) -> None: ), added_in="2026-07-28", ), - "lifecycle:stateless:no-initialize": Requirement( - source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#stateless-operation", - behavior=( - "A ClientSession pinned to 2026-07-28 is born initialized: initialize() is idempotent " - "and returns the synthesized result without any frame sent." - ), - 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#_meta", behavior=( @@ -2459,7 +2450,11 @@ 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", + 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", @@ -2468,12 +2463,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=( @@ -3255,14 +3244,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=( diff --git a/tests/interaction/transports/test_client_transport_http.py b/tests/interaction/transports/test_client_transport_http.py index 5508d3e8f..5225f089b 100644 --- a/tests/interaction/transports/test_client_transport_http.py +++ b/tests/interaction/transports/test_client_transport_http.py @@ -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], diff --git a/tests/interaction/transports/test_hosting_http.py b/tests/interaction/transports/test_hosting_http.py index 6331c2dae..4fc62bc9e 100644 --- a/tests/interaction/transports/test_hosting_http.py +++ b/tests/interaction/transports/test_hosting_http.py @@ -345,7 +345,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. From 380b7e2caf9fbcacf093191faf20cd9af9708fbf Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 28 Jun 2026 11:51:27 +0000 Subject: [PATCH 04/16] Make interaction-manifest behaviour strings match what their tests prove Sixteen fixes on sixteen entries, each evidenced against the bound test body or the spec text: narrow over-claiming strings to the assertions that exist (403-scope-upgrade's unproven no-loop clause, iss mismatch-only coverage, tools-only registration, arrival-only HTTP notification delivery, the custom-client auth clause), correct two entries that attributed the modern classifier's version rejection to the legacy transport, record the 2026 per-request logLevel gate divergence on the Context logging helper and the list-vs-object structured-content gap on text-mirror, re-ground the null-id deferral on the now-existing fault channel, and fix the discover result field name (supportedVersions) plus the 404 status the spec mandates for initialize at the modern entry. Cells unchanged (830 before and after). --- tests/interaction/_requirements.py | 85 +++++++++++++++++++----------- 1 file changed, 53 insertions(+), 32 deletions(-) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 1c5b8274d..4212f4511 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -383,10 +383,15 @@ 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"), + 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#protocol-version-header", @@ -399,7 +404,7 @@ def __post_init__(self) -> None: 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, serverInfo and the cache hint fields." ), added_in="2026-07-28", ), @@ -414,18 +419,25 @@ def __post_init__(self) -> None: "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." + ), ), "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", @@ -613,14 +625,15 @@ 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." ), ), "protocol:meta:related-task": Requirement( @@ -851,6 +864,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", @@ -1052,6 +1072,13 @@ def __post_init__(self) -> None: "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", @@ -1340,7 +1367,7 @@ 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." ), ), @@ -2027,9 +2054,8 @@ def __post_init__(self) -> None: "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=( @@ -2418,8 +2444,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.", @@ -3032,8 +3057,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=( @@ -3075,7 +3101,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."), @@ -3083,9 +3109,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",), @@ -3204,10 +3231,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.", ), @@ -3366,9 +3390,7 @@ 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.", ), @@ -3615,8 +3637,7 @@ def __post_init__(self) -> None: 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",), From 1ad839e3a48658556798d1f1655c7401433cd183 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:08:47 +0000 Subject: [PATCH 05/16] Model the 2025-11-25 to 2026-07-28 transition as first-class manifest data Execute the era/supersession pass: 37 new successor entries (the MRTR family that replaces server-initiated requests, the per-request log-level pair, the subscriptions/listen family, discover-side successors), all registered deferred ahead of their tests; 82 existing entries edited - version-wide 2026 arm exclusions on era-retired behaviours become removed_in with a superseded_by link and an explanatory note (transport-shaped exclusions stay), no-heir removals get tombstone notes, and the per-request logLevel divergence is recorded on the three logging entries whose tests pin un-gated delivery on live 2026 cells. 62 supersession pairs, all bidirectional and versioned, enforced by the coverage gate at import. The twelve surviving version-wide exclusions are exactly the documented re-admission checklist. Cells unchanged (830 before and after). --- tests/interaction/_requirements.py | 1105 ++++++++++++++++++++++++---- 1 file changed, 948 insertions(+), 157 deletions(-) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 4212f4511..4507371f9 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -216,13 +216,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 +233,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 +332,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 +351,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,6 +361,7 @@ 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( @@ -386,7 +399,11 @@ def __post_init__(self) -> None: "_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 " @@ -407,6 +424,7 @@ def __post_init__(self) -> None: "carrying supportedVersions, capabilities, serverInfo and the cache hint fields." ), added_in="2026-07-28", + supersedes=("lifecycle:initialize:server-info",), ), "lifecycle:discover:retry-on-32022": Requirement( source=f"{SPEC_2026_BASE_URL}/basic/versioning#protocol-version-negotiation", @@ -415,6 +433,55 @@ def __post_init__(self) -> None: "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",), + deferred=( + "Not yet covered here: lifecycle:discover:basic exercises the discover round trip but no test " + "asserts the instructions field." + ), + ), + "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", + ), + deferred=( + "Not yet covered here: lifecycle:discover:basic pins the result shape but no test asserts the " + "per-area handler-to-capability derivation." + ), + ), + "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",), + deferred=( + "Not yet covered here: the server-side -32022 production is exercised only indirectly through " + "the client retry path." + ), ), "lifecycle:discover:fallback-method-not-found": Requirement( source=f"{SPEC_2026_BASE_URL}/basic/transports/stdio#backward-compatibility", @@ -495,13 +562,29 @@ 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 when the " + "cancellation add-batch lands them." + ), ), "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:in-flight": Requirement( @@ -520,7 +603,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( @@ -539,7 +631,16 @@ def __post_init__(self) -> None: 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( @@ -548,10 +649,14 @@ 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:unknown-id-ignored": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#error-handling", @@ -734,7 +839,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", @@ -771,6 +881,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", @@ -820,10 +935,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", @@ -838,6 +956,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", @@ -852,10 +978,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", @@ -879,7 +1008,12 @@ 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", @@ -906,9 +1040,27 @@ 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 implemented in the SDK: subscriptions/listen has wire types and a lowlevel handler hook, " + "but no runtime -- there is no client-side listen API, and no server machinery emits the " + "acknowledgment, narrows the filter, or routes notifications onto a listen stream." ), ), "tools:list:basic": Requirement( @@ -1058,6 +1210,7 @@ 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." @@ -1092,10 +1245,14 @@ def __post_init__(self) -> None: "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 when the mrtr add-batch lands it." ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "mcpserver:context:read-resource": Requirement( source="sdk", @@ -1120,7 +1277,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", @@ -1128,9 +1290,27 @@ 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 implemented in the SDK: subscriptions/listen has wire types and a lowlevel handler hook, " + "but no runtime -- there is no client-side listen API, and no server machinery emits the " + "acknowledgment, narrows the filter, or routes notifications onto a listen stream." ), ), "resources:list:basic": Requirement( @@ -1167,6 +1347,7 @@ def __post_init__(self) -> None: 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( @@ -1175,6 +1356,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 " @@ -1190,8 +1372,39 @@ 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 implemented in the SDK: subscriptions/listen has wire types and a lowlevel handler hook, " + "but no runtime -- there is no client-side listen API, and no server machinery emits the " + "acknowledgment, narrows the filter, or routes notifications onto a listen stream." + ), + ), + "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 implemented in the SDK: subscriptions/listen has wire types and a lowlevel handler hook, " + "but no runtime -- there is no client-side listen API, and no server machinery emits the " + "acknowledgment, narrows the filter, or routes notifications onto a listen stream." + ), + ), "resources:templates:list": Requirement( source=f"{SPEC_BASE_URL}/server/resources#resource-templates", behavior=( @@ -1227,9 +1440,27 @@ 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 implemented in the SDK: subscriptions/listen has wire types and a lowlevel handler hook, " + "but no runtime -- there is no client-side listen API, and no server machinery emits the " + "acknowledgment, narrows the filter, or routes notifications onto a listen stream." ), ), # ═══════════════════════════════════════════════════════════════════════════ @@ -1273,7 +1504,12 @@ def __post_init__(self) -> None: "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", @@ -1296,7 +1532,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", @@ -1320,9 +1565,27 @@ 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 implemented in the SDK: subscriptions/listen has wire types and a lowlevel handler hook, " + "but no runtime -- there is no client-side listen API, and no server machinery emits the " + "acknowledgment, narrows the filter, or routes notifications onto a listen stream." ), ), "prompts:list:basic": Requirement( @@ -1339,7 +1602,16 @@ def __post_init__(self) -> None: "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", @@ -1371,7 +1643,17 @@ def __post_init__(self) -> None: "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 @@ -1379,7 +1661,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", @@ -1417,7 +1704,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 @@ -1433,11 +1725,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", @@ -1445,6 +1750,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", @@ -1457,6 +1770,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." @@ -1466,6 +1780,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." @@ -1475,11 +1790,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) # ═══════════════════════════════════════════════════════════════════════════ @@ -1488,10 +1831,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", @@ -1499,18 +1845,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", @@ -1524,10 +1876,13 @@ 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:model-preferences": Requirement( source=f"{SPEC_BASE_URL}/client/sampling#model-preferences", @@ -1535,18 +1890,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", @@ -1563,18 +1924,24 @@ def __post_init__(self) -> None: "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: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:not-supported": Requirement( source=f"{SPEC_BASE_URL}/client/sampling#capabilities", @@ -1582,10 +1949,14 @@ def __post_init__(self) -> None: "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", @@ -1593,18 +1964,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", @@ -1619,10 +1996,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", @@ -1641,10 +2021,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", @@ -1671,10 +2054,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", @@ -1682,9 +2068,137 @@ 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"), + deferred=( + "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " + "the behaviour is implemented and drivable (phase-4 verdict)." + ), + ), + "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",), + deferred=( + "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " + "the behaviour is implemented and drivable (phase-4 verdict)." + ), + ), + "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",), + deferred=( + "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " + "the behaviour is implemented and drivable (phase-4 verdict)." + ), + ), + "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",), + deferred=( + "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " + "the behaviour is implemented and drivable (phase-4 verdict)." + ), + ), + "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)." + ), + added_in="2026-07-28", + supersedes=( + "sampling:create:not-supported", + "sampling:tools:server-gated-by-capability", + "sampling:context:server-gated-by-capability", + ), + deferred=( + "Not implemented in the SDK: the server does not gate input_required input requests against the " + "client's declared capabilities -- a handler can embed a sampling/createMessage request for a " + "client that never declared the matching capability and it is sent as-is." ), ), # ═══════════════════════════════════════════════════════════════════════════ @@ -1722,10 +2236,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", @@ -1733,26 +2250,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", @@ -1760,10 +2277,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", @@ -1789,10 +2306,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", @@ -1800,18 +2320,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", @@ -1829,10 +2349,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", @@ -1847,10 +2367,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", @@ -1859,26 +2383,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.", - 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: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"), - ), + 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", @@ -1886,10 +2410,10 @@ 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", @@ -1931,11 +2455,191 @@ def __post_init__(self) -> None: "-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: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", + ), + deferred=( + "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " + "the behaviour is implemented and drivable (phase-4 verdict)." + ), + ), + "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",), + deferred=( + "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " + "the behaviour is implemented and drivable (phase-4 verdict)." + ), + ), + "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",), + deferred=( + "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " + "the behaviour is implemented and drivable (phase-4 verdict)." + ), + ), + "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",), + deferred=( + "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " + "the behaviour is implemented and drivable (phase-4 verdict)." + ), + ), + "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)." + ), + added_in="2026-07-28", + supersedes=("elicitation:form:not-supported", "elicitation:capability:server-respects-mode"), + deferred=( + "Not implemented in the SDK: the server does not gate input_required input requests against the " + "client's declared capabilities -- a handler can embed an elicitation/create request for a " + "client that never declared the matching capability or mode and it is sent as-is." + ), + ), + "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-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 valid results for the requests it " + "issued; content that violates the requested schema is rejected rather than silently accepted." + ), + added_in="2026-07-28", + supersedes=("elicitation:form:response-validation",), + deferred=( + "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " + "the behaviour is implemented and drivable (phase-4 verdict)." + ), + ), + "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", + ), + deferred=( + "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " + "the behaviour is implemented and drivable (phase-4 verdict)." + ), + ), + "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"), + deferred=( + "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " + "the behaviour is implemented and drivable (phase-4 verdict)." + ), + ), + "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",), + deferred=( + "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " + "the behaviour is implemented and drivable (phase-4 verdict)." + ), + ), # ═══════════════════════════════════════════════════════════════════════════ # Roots (server → client) # ═══════════════════════════════════════════════════════════════════════════ @@ -1969,26 +2673,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", @@ -1999,9 +2706,52 @@ 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",), + deferred=( + "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " + "the behaviour is implemented and drivable (phase-4 verdict)." + ), + ), + "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",), + deferred=( + "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " + "the behaviour is implemented and drivable (phase-4 verdict)." + ), + ), + "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." + ), + added_in="2026-07-28", + supersedes=("roots:list:not-supported",), + deferred=( + "Not implemented in the SDK: the server does not gate input_required input requests against the " + "client's declared capabilities -- a handler can embed a roots/list request for a client that " + "never declared the roots capability and it is sent as-is." ), ), "roots:uri:file-scheme": Requirement( @@ -2026,6 +2776,26 @@ 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 API and no list-changed " + "auto-refresh mechanism." + ), ), "client:list-changed:capability-gated": Requirement( source="sdk", @@ -2476,6 +3246,7 @@ def __post_init__(self) -> None: ), transports=("streamable-http",), 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." @@ -2931,6 +3702,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." @@ -3175,6 +3947,21 @@ 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.", + deferred=( + "Not yet covered here: planned transport-conformance test for the modern entry's disconnect handling." + ), + ), # ═══════════════════════════════════════════════════════════════════════════ # Client transport: streamable HTTP # ═══════════════════════════════════════════════════════════════════════════ @@ -3811,10 +4598,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", @@ -3843,6 +4633,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." From 8e442aee579f6e6f408118b08a60949141fc52fb Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:28:40 +0000 Subject: [PATCH 06/16] Add the first MRTR interaction tests: 16 tests across six files The multi-round-trip request pattern (SEP-2322, the 2026-07-28 replacement for server-initiated requests) gets its first end-to-end coverage: - lowlevel/test_mrtr.py (new): the write-once roundtrip (byte-exact requestState echo, opacity via a non-parseable state, fresh JSON-RPC id on retry), state-only retry, omit-when-absent, and parallel-call isolation via a symmetric rendezvous that provably holds both loops mid-flight. - test_elicitation.py: form-mode elicitation over MRTR (basic, decline, cancel, schema primitives) and the capability gate, which pins the current un-gated embed behaviour with a recorded divergence. - test_resources.py / test_prompts.py: the resources/read and prompts/get MRTR origins (lowlevel-only; MCPServer admits InputRequiredResult on the tools path only). - test_sampling.py / test_roots.py: sampling/createMessage and roots/list embedded as MRTR input requests, with model preferences, system prompt, and context-inclusion pass-through. Seventeen requirement entries flip from deferred to tested; five entries are minted (the request-state client obligations and the two non-tools origins). 830 -> 859 collected cells, every new cell accounted; suite green three consecutive runs; 100% line and branch on the new file with no coverage pragmas. --- tests/interaction/_requirements.py | 109 ++--- .../interaction/lowlevel/test_elicitation.py | 280 +++++++++++++ tests/interaction/lowlevel/test_mrtr.py | 390 ++++++++++++++++++ tests/interaction/lowlevel/test_prompts.py | 54 +++ tests/interaction/lowlevel/test_resources.py | 54 +++ tests/interaction/lowlevel/test_roots.py | 104 ++++- tests/interaction/lowlevel/test_sampling.py | 119 ++++++ 7 files changed, 1061 insertions(+), 49 deletions(-) create mode 100644 tests/interaction/lowlevel/test_mrtr.py diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 4507371f9..512f59286 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -1324,6 +1324,18 @@ def __post_init__(self) -> None: 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=( + "Low-level Server only: MCPServer returns InputRequiredResult from tools alone, so the " + "resources/read MRTR leg has no mcpserver mirror." + ), + ), "resources:read:blob": Requirement( source=f"{SPEC_BASE_URL}/server/resources#reading-resources", behavior="resources/read returns binary contents base64-encoded in blob.", @@ -1596,6 +1608,18 @@ def __post_init__(self) -> None: 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=( + "Low-level Server only: MCPServer returns InputRequiredResult from tools alone, so the " + "prompts/get MRTR leg has no mcpserver mirror." + ), + ), # ═══════════════════════════════════════════════════════════════════════════ # Prompts: SDK guarantees # ═══════════════════════════════════════════════════════════════════════════ @@ -2085,10 +2109,6 @@ def __post_init__(self) -> None: ), added_in="2026-07-28", supersedes=("sampling:create:basic", "tools:call:sampling-roundtrip"), - deferred=( - "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " - "the behaviour is implemented and drivable (phase-4 verdict)." - ), ), "sampling:mrtr:create:include-context": Requirement( source=f"{SPEC_2026_BASE_URL}/client/sampling#context-inclusion", @@ -2098,10 +2118,6 @@ def __post_init__(self) -> None: ), added_in="2026-07-28", supersedes=("sampling:create:include-context",), - deferred=( - "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " - "the behaviour is implemented and drivable (phase-4 verdict)." - ), ), "sampling:mrtr:create:model-preferences": Requirement( source=f"{SPEC_2026_BASE_URL}/client/sampling#model-preferences", @@ -2111,10 +2127,6 @@ def __post_init__(self) -> None: ), added_in="2026-07-28", supersedes=("sampling:create:model-preferences",), - deferred=( - "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " - "the behaviour is implemented and drivable (phase-4 verdict)." - ), ), "sampling:mrtr:create:system-prompt": Requirement( source=f"{SPEC_2026_BASE_URL}/client/sampling#system-prompt", @@ -2124,10 +2136,6 @@ def __post_init__(self) -> None: ), added_in="2026-07-28", supersedes=("sampling:create:system-prompt",), - deferred=( - "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " - "the behaviour is implemented and drivable (phase-4 verdict)." - ), ), "sampling:mrtr:create:audio-content": Requirement( source=f"{SPEC_2026_BASE_URL}/client/sampling#audio-content", @@ -2474,10 +2482,6 @@ def __post_init__(self) -> None: "elicitation:form:action:accept", "transport:streamable-http:server-to-client", ), - deferred=( - "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " - "the behaviour is implemented and drivable (phase-4 verdict)." - ), ), "elicitation:mrtr:form:action:cancel": Requirement( source=f"{SPEC_2026_BASE_URL}/client/elicitation#response-actions", @@ -2487,10 +2491,6 @@ def __post_init__(self) -> None: ), added_in="2026-07-28", supersedes=("elicitation:form:action:cancel",), - deferred=( - "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " - "the behaviour is implemented and drivable (phase-4 verdict)." - ), ), "elicitation:mrtr:form:action:decline": Requirement( source=f"{SPEC_2026_BASE_URL}/client/elicitation#response-actions", @@ -2500,10 +2500,6 @@ def __post_init__(self) -> None: ), added_in="2026-07-28", supersedes=("elicitation:form:action:decline",), - deferred=( - "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " - "the behaviour is implemented and drivable (phase-4 verdict)." - ), ), "elicitation:mrtr:form:schema:primitives": Requirement( source=f"{SPEC_2026_BASE_URL}/client/elicitation#requested-schema", @@ -2513,10 +2509,6 @@ def __post_init__(self) -> None: ), added_in="2026-07-28", supersedes=("elicitation:form:schema:primitives",), - deferred=( - "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " - "the behaviour is implemented and drivable (phase-4 verdict)." - ), ), "elicitation:mrtr:form:schema:enum-variants": Requirement( source=f"{SPEC_2026_BASE_URL}/client/elicitation#requested-schema", @@ -2551,13 +2543,18 @@ def __post_init__(self) -> None: "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." + ), + ), added_in="2026-07-28", supersedes=("elicitation:form:not-supported", "elicitation:capability:server-respects-mode"), - deferred=( - "Not implemented in the SDK: the server does not gate input_required input requests against the " - "client's declared capabilities -- a handler can embed an elicitation/create request for a " - "client that never declared the matching capability or mode and it is sent as-is." - ), ), "elicitation:mrtr:url:action-no-content": Requirement( source=f"{SPEC_2026_BASE_URL}/client/elicitation#response-actions", @@ -2622,10 +2619,34 @@ def __post_init__(self) -> None: ), added_in="2026-07-28", supersedes=("tools:call:elicitation-roundtrip", "mcpserver:context:elicit-from-handler"), - deferred=( - "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " - "the behaviour is implemented and drivable (phase-4 verdict)." + ), + "mrtr:request-state-only:retry": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#client-requirements-basic-workflow", + behavior=( + "An InputRequiredResult carrying only requestState (no inputRequests) is retried by the " + "client driver with no inputResponses and the requestState echoed verbatim." ), + added_in="2026-07-28", + note=( + "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." + ), + ), + "mrtr:request-state:omitted-when-absent": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#client-requirements-basic-workflow", + behavior=( + "When an InputRequiredResult carries no requestState field, the client does not include " + "a requestState key in the serialized retry." + ), + added_in="2026-07-28", + ), + "mrtr:request-state:scoped-to-originating-request": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#client-requirements-basic-workflow", + behavior=( + "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", @@ -2722,10 +2743,6 @@ def __post_init__(self) -> None: ), added_in="2026-07-28", supersedes=("roots:list:basic",), - deferred=( - "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " - "the behaviour is implemented and drivable (phase-4 verdict)." - ), ), "roots:mrtr:list:empty": Requirement( source=f"{SPEC_2026_BASE_URL}/client/roots#listing-roots", @@ -2735,10 +2752,6 @@ def __post_init__(self) -> None: ), added_in="2026-07-28", supersedes=("roots:list:empty",), - deferred=( - "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " - "the behaviour is implemented and drivable (phase-4 verdict)." - ), ), "roots:mrtr:capability:not-declared": Requirement( source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#server-requirements-basic-workflow", diff --git a/tests/interaction/lowlevel/test_elicitation.py b/tests/interaction/lowlevel/test_elicitation.py index 8c79fd061..7a324ae41 100644 --- a/tests/interaction/lowlevel/test_elicitation.py +++ b/tests/interaction/lowlevel/test_elicitation.py @@ -12,6 +12,7 @@ CallToolResult, ElicitCompleteNotification, ElicitCompleteNotificationParams, + ElicitRequest, ElicitRequestedSchema, ElicitRequestFormParams, ElicitRequestURLParams, @@ -19,6 +20,7 @@ ErrorData, Implementation, InitializeResult, + InputRequiredResult, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, @@ -663,3 +665,281 @@ 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 delivers its message and requested schema to the client's + elicitation callback as sent, and the accepted content reaches the retried handler in + inputResponses. + + Spec-mandated: at 2026-07-28 elicitation/create rides the multi-round-trip flow (an + input_required result the client fulfils and retries) instead of 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 answered with action 'decline' reaches the retried handler in + inputResponses with no content. + + The spec says decline content is typically omitted; the pinned shape is the SDK passing the + callback's contentless ElicitResult through unmodified. + """ + + 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 answered with action 'cancel' reaches the retried handler in + inputResponses with no content. + + The spec says cancel content is typically omitted; the pinned shape is the SDK passing the + callback's contentless ElicitResult through unmodified. + """ + + 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: + """String (with format), integer, number, and boolean requested-schema fields on an embedded + form elicitation reach the client callback intact. + + Spec-mandated: the requested schema's primitive property types survive the embed and delivery + unchanged. One representative constraint per type; the exhaustive keyword sweep stays 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 spec forbids embedding an elicitation/create for a client that has not + declared the elicitation capability, but the SDK has no embed gate; the request is sent as-is. + + The handler proves the precondition in-band (this connection's envelope declared no elicitation + capability) and the client receives the forbidden embed through the documented manual-loop + escape hatch `client.session.call_tool(..., allow_input_required=True)`; the auto loop would + surface only the client's local refusal, the wrong actor for this server-side obligation. See + the divergence on the requirement. + """ + + 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"} + ) + ) + } + ) + ) diff --git a/tests/interaction/lowlevel/test_mrtr.py b/tests/interaction/lowlevel/test_mrtr.py new file mode 100644 index 000000000..b1c2249d7 --- /dev/null +++ b/tests/interaction/lowlevel/test_mrtr.py @@ -0,0 +1,390 @@ +"""The 2026-07-28 multi-round-trip request (MRTR) core pattern over tools/call. + +A tool that needs more input answers with an ``input_required`` result; the client driver fulfils +the embedded requests through its registered callbacks and retries the original call carrying the +collected ``inputResponses`` and the echoed opaque ``requestState``. The fixture-driven tests pin +the driver's user-facing contract on both 2026 matrix cells; the wire-level tests record JSON-RPC +frames at the client transport seam over the modern streamable HTTP entry -- the only transport +serving 2026 JSON-RPC frames -- because retry ids and serialized key presence are protocol facts +invisible to API callers (the in-memory 2026 path has no JSON-RPC framing at all). +""" + +from typing import Any + +import anyio +import mcp_types as types +import pytest +from inline_snapshot import snapshot +from mcp_types import ( + CallToolResult, + ElicitRequest, + ElicitRequestFormParams, + ElicitResult, + InputRequiredResult, + JSONRPCRequest, + JSONRPCResponse, + TextContent, +) +from mcp_types.version import LATEST_MODERN_VERSION + +from mcp.client import ClientRequestContext +from mcp.client.client import Client +from mcp.client.streamable_http import streamable_http_client +from mcp.server import Server, ServerRequestContext +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 + +# Deliberately not parseable as JSON or base64: a client that inspected or normalized +# request_state instead of echoing it byte-exact would fail the equality assertions 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 an InputRequiredResult's input_requests.""" + return ElicitRequest(params=ElicitRequestFormParams(message=message, requested_schema=_NAME_SCHEMA)) + + +def _login_server(request_states: list[str | None]) -> Server: + """The two-round write-once server the roundtrip pair shares. + + Round 1 answers with one embedded form request plus the opaque state; round 2 asserts the + byte-exact echo and completes with the elicited name. Every round's request_state is appended + to `request_states`. + """ + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + # Live (not NotImplementedError): the client's output-schema cache refresh invokes + # tools/list right after the first 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 fulfilled by the client driver and retried to completion. + + The registered callback answers the embedded form request and the retried call completes as a + plain CallToolResult (spec basic workflow). The byte-exact requestState echo (spec MUST) is + pinned by equality against a deliberately non-parseable literal -- the only observable proxy + for the MUST NOT inspect/parse/modify rule; the retry's frame-level obligations are pinned by + the wire test below. + """ + 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"] + # Exactly the initial round and one retry, the retry echoing the module constant byte-exact. + 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. + + There is nothing to dispatch, so no callbacks are registered -- a driver that wrongly + dispatched on this branch would error the call instead of completing it. The spec's "MAY + retry the original request immediately" is permission: the SDK paces the retry with an + internal 50 ms backoff, its own pacing choice rather than a divergence, and the test neither + adds sleeps nor asserts elapsed time. + """ + resume_token = "resume-token-1" + request_states: list[str | None] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + # Live (not NotImplementedError): the client's output-schema cache refresh invokes + # tools/list right after the first tools/call result. + 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")])) + # Exactly one retry, echoing the token the server minted. + assert request_states == [None, resume_token] + + +# --- 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 because the retry's id (spec MUST: initial request and + retry are independent requests) and the serialized requestState key are invisible to API + callers; the modern HTTP entry is the only transport serving 2026 JSON-RPC frames at this pin + (the in-memory 2026 path has no framing). The recorded round-1 response also exhibits one + compliant input_required interim -- enforcement of the at-least-one-of MUST is owned by its + own entry -- and this test is the emission complement of the key-omission test below. + """ + 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, the recorder bound via := -- a separately nested `async with` + # line mis-traces its exit arcs under branch coverage 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 + # Fresh id on the retry, asserted as inequality: the id *sequence* belongs to + # protocol:request-id:unique, and pinned values would couple to the refresh-frame count. + 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 (the received + # log also carries the retry's response and the tools/list response). + 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. + + The spec MUST NOT include one in the retry is wire-pinned: typed request_state None and + key-absence are indistinguishable in-memory, so only the serialized retry frame can prove the + omission. The fresh-id test above proves the same serializer emits the key when the server + sent one, guarding this absence assertion against vacuity. + """ + request_states: list[str | None] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + # Live (not NotImplementedError): the client's output-schema cache refresh invokes + # tools/list right after the first tools/call result. + 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 MUST NOT itself: no requestState key on the serialized retry... + assert "requestState" not in retry.params + # ...on a frame that is otherwise loaded -- the absence is specific, not a bare frame. + assert "inputResponses" in retry.params + # Handler-side corroboration of what the frame shows. + 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 (each call sets its own round-1 event, then + waits on the other's) forces both loops to be simultaneously mid-flight -- interim results + received, neither retry sent -- before either retry leaves, so the spec scenario ("any other + request that the client may be sending in parallel") provably occurs; the exhaustive scan over + every recorded tools/call frame is the MUST NOT's proof that neither call's fields leak into + the other's. + """ + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + # Live (not NotImplementedError): the client's output-schema cache refresh invokes + # tools/list right after the first tools/call result. + 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 + if params.input_responses is None: + return InputRequiredResult( + input_requests={f"q-{name}": _form_request(f"for {name}")}, + request_state=f"state-{name}", + ) + # Each retry carries its own call's state -- the handler-side half of the isolation claim. + assert params.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 + # Symmetric rendezvous: set own round-1 event before waiting on the other's -- deadlock-free + # by set-before-wait, and both loops are provably mid-flight before either retry leaves. + 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 ( + 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, + # 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") + + frames = [ + message.message + for message in recording.sent + if isinstance(message.message, JSONRPCRequest) and message.message.method == "tools/call" + ] + by_name: dict[str, list[dict[str, Any]]] = {"alpha": [], "beta": []} + for frame in frames: + assert frame.params is not None + by_name[frame.params["name"]].append(frame.params) + for name, sent_params in by_name.items(): + assert len(sent_params) == 2 + initial, retry = sent_params + assert "requestState" not in initial + assert "inputResponses" not in initial + assert retry["requestState"] == f"state-{name}" + assert set(retry["inputResponses"]) == {f"q-{name}"} + # The exhaustive negative (spec MUST NOT): no recorded tools/call frame anywhere carries the + # other call's state or responses. + for params in (frame.params for frame in frames): + assert params is not None + other = "beta" if params["name"] == "alpha" else "alpha" + assert params.get("requestState") in (None, f"state-{params['name']}") + assert f"q-{other}" not in params.get("inputResponses", {}) + assert results == { + "alpha": CallToolResult(content=[TextContent(text="alpha")]), + "beta": CallToolResult(content=[TextContent(text="beta")]), + } diff --git a/tests/interaction/lowlevel/test_prompts.py b/tests/interaction/lowlevel/test_prompts.py index eb19d4d60..c8bb43262 100644 --- a/tests/interaction/lowlevel/test_prompts.py +++ b/tests/interaction/lowlevel/test_prompts.py @@ -6,11 +6,16 @@ from mcp_types import ( INVALID_PARAMS, AudioContent, + ElicitRequest, + ElicitRequestFormParams, + ElicitResult, EmbeddedResource, ErrorData, GetPromptResult, Icon, ImageContent, + InputRequiredResult, + InputResponses, ListPromptsResult, Prompt, PromptArgument, @@ -20,6 +25,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._requirements import requirement @@ -208,3 +214,51 @@ 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. + + The retry carries the callback's responses and the echoed request_state, and returns the prompt + messages. Spec-mandated: prompts/get is an MRTR-supported request (basic/patterns/mrtr, Supported + Requests). Low-level Server only — MCPServer cannot return InputRequiredResult from prompts. + """ + 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 48b0c7ee2..566856e5c 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 @@ -312,3 +318,51 @@ 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. + + The retry carries the callback's responses and the echoed request_state, and returns the resource + contents. Spec-mandated: resources/read is an MRTR-supported request (basic/patterns/mrtr, Supported + Requests). Low-level Server only — MCPServer cannot return InputRequiredResult from resources. + """ + 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..4c48cd3e2 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,95 @@ 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: + """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 tool handler in + inputResponses. Spec-mandated (client/roots, Listing Roots -- the 2026 MRTR successor of the + retired push round trip). + """ + 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 roots list returned by the client's roots callback for an embedded roots/list + request reaches the retried tool handler as an empty list -- not an error, not an absent + response. Spec-mandated (client/roots, Listing Roots). + """ + 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 faef014cf..512b04923 100644 --- a/tests/interaction/lowlevel/test_sampling.py +++ b/tests/interaction/lowlevel/test_sampling.py @@ -3,6 +3,9 @@ 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-07-28 MRTR successors embed the request in an input_required result instead of calling +ctx.session.create_message(): the client fulfils it and retries the tool call. """ import mcp_types as types @@ -12,11 +15,14 @@ from mcp_types import ( AudioContent, CallToolResult, + CreateMessageRequest, CreateMessageRequestParams, CreateMessageResult, CreateMessageResultWithTools, ErrorData, ImageContent, + InputRequiredResult, + InputResponses, ModelHint, ModelPreferences, SamplingCapability, @@ -686,3 +692,116 @@ 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/createMessage request in an input_required result is fulfilled by the + client's sampling callback, and the callback's result (role, content, model, stop reason) + reaches the retried tool handler in inputResponses. Spec-mandated (client/sampling, Creating + Messages -- the 2026 MRTR successor of the retired push round trip). + """ + 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:model-preferences") +@requirement("sampling:mrtr:create:system-prompt") +async def test_embedded_sampling_params_reach_the_callback_intact(connect: Connect) -> None: + """Model preferences (hints and the cost/speed/intelligence priorities), the system prompt, + and the includeContext value supplied in an embedded sampling/createMessage request all reach + the client sampling callback unchanged. Spec-mandated (client/sampling #model-preferences, + #system-prompt, #context-inclusion). + """ + 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.", + # "none", not the 2025 sibling's "thisServer": the other values are deprecated at + # 2026-07-28 (SEP-2596) and gated behind the sampling.context capability. + 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")])) From d282e48c41ffdc087228250cc76ff9983669352c Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 28 Jun 2026 12:52:59 +0000 Subject: [PATCH 07/16] Complete the MRTR core coverage: multi-round, bounds, and the 2026 directionality pins Ten more tests: the multi-round completion loop, the rounds cap, the at-least-one-of construction-site rejection, the inputResponses structural validation and key correspondence, the -32042 emission-ban wire scan, and the 2026 directionality edges - the push-API loud-fail split (the standalone leg pins NoBackChannelError green on both 2026 cells; a dedicated in-memory test pins the request-scoped leg still transmitting the forbidden frame, recorded as a per-transport, per-leg divergence so the eventual era-gate fix re-pins mechanically), a wire-trace proof that a 2026 exchange contains no server-initiated requests and no client-sent responses, and the sampling and roots embed capability gates (both pinned un-gated with recorded divergences, completing the embed-gate family). Five entries flip from deferred, five origin-new entries are minted with their tests. 859 -> 876 collected cells, every node accounted; suite green three consecutive runs. --- tests/interaction/_requirements.py | 119 +++- .../interaction/lowlevel/test_elicitation.py | 93 ++- tests/interaction/lowlevel/test_mrtr.py | 552 +++++++++++++++++- 3 files changed, 737 insertions(+), 27 deletions(-) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 512f59286..76c4bc9ff 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -551,6 +551,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=( @@ -1250,7 +1264,7 @@ def __post_init__(self) -> None: 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 when the mrtr add-batch lands it." + "mrtr:push-api:loud-fail-2026." ), arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), @@ -2197,17 +2211,22 @@ def __post_init__(self) -> None: "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." + ), + ), added_in="2026-07-28", supersedes=( "sampling:create:not-supported", "sampling:tools:server-gated-by-capability", "sampling:context:server-gated-by-capability", ), - deferred=( - "Not implemented in the SDK: the server does not gate input_required input requests against the " - "client's declared capabilities -- a handler can embed a sampling/createMessage request for a " - "client that never declared the matching capability and it is sent as-is." - ), ), # ═══════════════════════════════════════════════════════════════════════════ # Elicitation (server → client) @@ -2577,18 +2596,42 @@ def __post_init__(self) -> None: # ═══════════════════════════════════════════════════════════════════════════ # 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-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 valid results for the requests it " - "issued; content that violates the requested schema is rejected rather than silently accepted." + "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",), - deferred=( - "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " - "the behaviour is implemented and drivable (phase-4 verdict)." + 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:url-elicitation:no-32042-on-2026": Requirement( source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr", @@ -2604,10 +2647,6 @@ def __post_init__(self) -> None: "mcpserver:tool:url-elicitation-error", "flow:elicitation:url-required-then-retry", ), - deferred=( - "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " - "the behaviour is implemented and drivable (phase-4 verdict)." - ), ), "mrtr:tools-call:write-once-roundtrip": Requirement( source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#basic-workflow", @@ -2656,10 +2695,41 @@ def __post_init__(self) -> None: ), added_in="2026-07-28", supersedes=("flow:elicitation:multi-step-form",), - deferred=( - "Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; " - "the behaviour is implemented and drivable (phase-4 verdict)." + ), + "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." + ), + divergence=Divergence( + note=( + "The prohibition is enforced by each transport's missing back-channel, not by an " + "era gate on the send path, and the enforcement splits per transport and per leg. " + "Standalone sends (no related_request_id) raise NoBackChannelError locally on both " + "2026 transports because the per-request Connection has no outbound channel. " + "Request-scoped sends (related_request_id=...) ride the per-request dispatch " + "context, whose can_send_request the modern HTTP entry hard-codes to False but the " + "in-memory direct-dispatcher pair leaves at its True default -- so in-memory the " + "forbidden elicitation/create frame IS transmitted, and the failure comes back from " + "the client's 2026 version gate (METHOD_NOT_FOUND) instead of arising locally. An " + "era-aware gate on the send path would loud-fail both legs on every transport; when " + "it lands, re-pin the request-scoped in-memory test to the local NoBackChannelError " + "and delete this divergence." + ), + ), + added_in="2026-07-28", ), # ═══════════════════════════════════════════════════════════════════════════ # Roots (server → client) @@ -2759,13 +2829,16 @@ def __post_init__(self) -> None: "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." + ), + ), added_in="2026-07-28", supersedes=("roots:list:not-supported",), - deferred=( - "Not implemented in the SDK: the server does not gate input_required input requests against the " - "client's declared capabilities -- a handler can embed a roots/list request for a client that " - "never declared the roots capability and it is sent as-is." - ), ), "roots:uri:file-scheme": Requirement( source=f"{SPEC_BASE_URL}/client/roots#root", diff --git a/tests/interaction/lowlevel/test_elicitation.py b/tests/interaction/lowlevel/test_elicitation.py index 7a324ae41..a3f738b2e 100644 --- a/tests/interaction/lowlevel/test_elicitation.py +++ b/tests/interaction/lowlevel/test_elicitation.py @@ -9,6 +9,7 @@ import pytest from inline_snapshot import snapshot from mcp_types import ( + URL_ELICITATION_REQUIRED, CallToolResult, ElicitCompleteNotification, ElicitCompleteNotificationParams, @@ -28,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 @@ -943,3 +947,88 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara } ) ) + + +@requirement("mrtr:url-elicitation:no-32042-on-2026") +async def test_url_elicitation_rides_mrtr_and_no_32042_error_crosses_the_wire() -> None: + """URL-mode elicitation rides the MRTR loop at 2026-07-28: the embedded URL request reaches the + callback, accepting it fulfils the loop, the retried call completes, and the retired -32042 + urlElicitationRequired code never appears in any frame of the exchange. + + Spec-mandated (-32042 is reserved-never-reused at 2026). Asserted at the client transport seam + over the modern streamable HTTP entry, the only transport serving 2026 JSON-RPC frames; the + post-exchange scan needs no waiting because the exchange is POST request/response pairs only, + each response fully consumed before its await returns. + """ + received: list[types.ElicitRequestParams] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + # Live (not NotImplementedError): the client's output-schema cache refresh invokes + # tools/list right after the first successful tools/call result. + 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) + # Accept means the user agreed to visit the URL; there is never content to carry. + return ElicitResult(action="accept") + + with anyio.fail_after(5): + # One combined async-with, the recorder bound via := -- a separately nested `async with` + # line mis-traces its exit arcs under branch coverage 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_url, + ) as client, + ): + result = await client.call_tool("protected", {}) + + # The URL params crossed the real wire intact; elicitation_id rides its None default at 2026. + 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 recording demonstrably captured the MRTR interim leg, so the scan + # below covers a conversation that contained the input_required round, not an empty log. + 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 + # The negative: no serialized frame in either direction carries the retired code. A substring + # scan also catches the code smuggled inside a result body -- the exact shape the 2025 era + # surfaced it in. Test-controlled payloads and single-digit request ids leave no legitimate + # occurrence of the digits. + 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_mrtr.py b/tests/interaction/lowlevel/test_mrtr.py index b1c2249d7..26e2850da 100644 --- a/tests/interaction/lowlevel/test_mrtr.py +++ b/tests/interaction/lowlevel/test_mrtr.py @@ -6,7 +6,12 @@ the driver's user-facing contract on both 2026 matrix cells; the wire-level tests record JSON-RPC frames at the client transport seam over the modern streamable HTTP entry -- the only transport serving 2026 JSON-RPC frames -- because retry ids and serialized key presence are protocol facts -invisible to API callers (the in-memory 2026 path has no JSON-RPC framing at all). +invisible to API callers (the in-memory 2026 path has no JSON-RPC framing at all). One test +speaks the raw 2026 dialect against the mounted modern entry, the only seam where malformed +params can originate. The directionality-edge tests pin the 2026 boundary itself: the retired +push APIs fail loudly (except the in-memory request-scoped leg, a pinned divergence), embedded +input requests cross un-gated to the refusing client driver, and a completed exchange's trace +carries client requests and server responses only. """ from typing import Any @@ -16,23 +21,43 @@ import pytest from inline_snapshot import snapshot from mcp_types import ( + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + INVALID_PARAMS, + INVALID_REQUEST, + METHOD_NOT_FOUND, + PROTOCOL_VERSION_META_KEY, CallToolResult, + ClientCapabilities, + CreateMessageRequest, + CreateMessageRequestParams, ElicitRequest, ElicitRequestFormParams, ElicitResult, + ErrorData, InputRequiredResult, + JSONRPCError, JSONRPCRequest, JSONRPCResponse, + ListRootsRequest, + ListRootsResult, + Root, + RootsCapability, + SamplingCapability, + SamplingMessage, TextContent, ) from mcp_types.version import LATEST_MODERN_VERSION +from pydantic import FileUrl +from mcp import InputRequiredRoundsExceededError, MCPError from mcp.client import ClientRequestContext from mcp.client.client import Client from mcp.client.streamable_http import streamable_http_client from mcp.server import Server, ServerRequestContext +from mcp.shared.exceptions import NoBackChannelError from mcp.shared.message import SessionMessage -from tests.interaction._connect import BASE_URL, Connect, mounted_app +from tests.interaction._connect import BASE_URL, Connect, base_headers, mounted_app from tests.interaction._helpers import RecordingTransport from tests.interaction._requirements import requirement @@ -161,6 +186,392 @@ async def call_tool( 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 may answer the same call with input_required on successive attempts (spec MAY); + after two productive rounds the retried call completes normally. + + Round 1's answer is threaded through ``request_state`` -- the spec's own stateless-server + pattern -- so the terminal snapshot proves both rounds' data reached the server. Echoing the + *latest* round's state on each retry is spec-mandated (the retry echoes the exact value of the + result being retried); round 3 carrying only round 3's answers is SDK-defined -- the driver + rebuilds responses per round, and the spec is silent on accumulate-vs-replace. + """ + request_states: list[str | None] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + # Live (not NotImplementedError): the client's output-schema cache refresh invokes + # tools/list right after the first tools/call result. + 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 + # The stateless-server pattern: round 1's answer rides forward inside the new state. + return InputRequiredResult( + input_requests={"second": _form_request("second question")}, + request_state=f"s2:{first.content['name']}", + ) + # Only the current round's answers ride the retry (SDK-defined; see docstring). + 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"] + # Three rounds, each retry echoing the latest round's state (spec) -- round 1's answer + # visible inside round 2's threaded state. + 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: + """The driver's retry loop is bounded: a server that keeps answering input_required past the + configured ``input_required_max_rounds`` raises ``InputRequiredRoundsExceededError`` carrying + the configured cap. SDK-defined contract (the spec places no bound; servers MUST NOT assume + clients retry at all). + + Constructed directly on the in-memory 2026 cell rather than via the connect fixture because + the factories do not forward ``input_required_max_rounds``; the driver is a + transport-independent pure function, so the knob's effect is fully observable here. Every + round carries ``input_requests``, so the state-only backoff branch never runs. + """ + 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: + # Inside the connect block: unwinding through Client.__aexit__ would wrap the error in + # ExceptionGroups (task-group teardown), and pytest.raises would miss the bare type. + with pytest.raises(InputRequiredRoundsExceededError) as exc_info: + await client.call_tool("never-done", {}) + + # The configured cap comes back on the error; the message is the SDK's own guidance text. + 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." + ) + # SDK-defined loop accounting: the initial call plus two retries reach the handler (round 3's + # input_required trips the cap), and the tripping round's requests are never dispatched. + assert seen_responses == [None, {"q"}, {"q"}] + assert prompts == ["again", "again"] + + +@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: + """A handler-built InputRequiredResult with neither inputRequests nor requestState cannot + cross to the client: construction fails (the spec's at-least-one-of MUST, enforced by the + model validator) and the call surfaces a JSON-RPC error, never a malformed interim result. + + The error shape is SDK-defined -- both 2026 dispatchers map the handler's ValidationError to + the same invalid-params ErrorData, so one snapshot serves both cells; the spec mandates no + code for a server-side construction bug (its 'appropriate error code' SHOULD addresses + client-sent malformed data). + """ + + 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 the client's + typed result for that key's request (spec: keys correspond; values are per-request results). + + Two of the three response types (ElicitResult, ListRootsResult) prove the map contract; + sampling-value fidelity belongs to the sampling MRTR entries. Both payloads travel back + through the protocol into one terminal result. + """ + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + # Live (not NotImplementedError): the client's output-schema cache refresh invokes + # tools/list right after the first tools/call result. + 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"] + # Per-key type routing: each key's value is the result type its request asked for. + 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:push-api:loud-fail-2026") +async def test_push_elicit_on_2026_raises_typed_local_error_and_call_still_completes(connect: Connect) -> None: + """A handler calling the retired push API on a 2026 connection gets a typed, catchable local + error before anything reaches the client, and the originating call still completes normally. + + The outcome is spec-mandated (the previous server-initiated request pattern is no longer + supported) but the enforcement at this pin is incidental -- the gate is "this transport context + has no back-channel", not "wrong era"; the request-scoped half of that gap is pinned by the + divergence test below. One push API stands for all four: elicit_form / elicit_url / + create_message / list_roots share the single channel-selection point in + ServerSession.send_request, and the deprecated siblings would add warning scaffolding only to + re-prove the same gate. + """ + caught: list[NoBackChannelError] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + # Live (not NotImplementedError): the client's output-schema cache refresh invokes + # tools/list right after the first tools/call result. + 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) + + # Registered so the client declares the elicitation capability in the per-request envelope, + # isolating the failure to the missing back-channel rather than to capability gating; the + # body is itself the never-delivered assertion. + 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_transmits_the_forbidden_frame() -> None: + """PINS A KNOWN GAP: the 2026 prohibition on server-initiated requests is enforced + per-transport by the missing back-channel, and the in-memory pair's request-scoped channel + still has one -- a push elicit carrying related_request_id transmits the forbidden + elicitation/create, and the failure comes back from the client's 2026 version gate instead of + arising locally. See the requirement's divergence; when an era-aware send gate lands, re-pin + this to the local NoBackChannelError the test above observes and delete the divergence. + + Direct in-memory client, no fixture: the behaviour is transport-split -- over the modern HTTP + entry this same leg loud-fails locally -- so one fixture body cannot pin both cells. + """ + caught: list[MCPError] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + # Live (not NotImplementedError): the client's output-schema cache refresh invokes + # tools/list right after the first tools/call result. + 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 MCPError as exc: + # MCPError, not NoBackChannelError: nothing is raised locally on this path -- the + # failure is the peer's typed answer. Post-fix, the local NoBackChannelError (an + # MCPError subclass) lands here too and fails on the snapshot, a clean re-pin signal. + caught.append(exc) + return CallToolResult(content=[TextContent(text="fallback")]) + + server = Server("scoped-push", on_list_tools=list_tools, on_call_tool=call_tool) + + # Registered so the client declares the elicitation capability (mirroring the test above); + # the body is itself the never-delivered assertion. + 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 connection survives the rejected frame. + assert result == snapshot(CallToolResult(content=[TextContent(text="fallback")])) + assert len(caught) == 1 + # The transmission proof, byte-exact: only the client version gate's KeyError branch + # (SERVER_REQUESTS has zero 2026-07-28 entries) answers with data= -- it runs strictly + # before callback dispatch, so this snapshot also proves the callback layer was never reached + # (a delivered-then-failing callback surfaces a different error shape). + assert caught[0].error == snapshot( + ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="elicitation/create") + ) + + +@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: the spec forbids embedding an inputRequests entry the client's declared + capabilities do not support, but the SDK has no embed gate -- the sampling/createMessage is + transmitted as-is and the violation surfaces as the client driver's refusal aborting the call. + See the requirement's divergence; when the server-side gate lands (expected: the originating + request fails with -32021 MissingRequiredClientCapability or the embed is rejected at + construction), re-pin. + """ + calls: list[str] = [] + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> InputRequiredResult: + assert params.name == "gated" + calls.append(params.name) + # The precondition, through the same public surface a conformant gate would use: 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) + + # Default client, no callbacks: precisely the client that declared no capabilities. + async with connect(server) as client: + # Inside the connect block: unwinding through Client.__aexit__ would wrap the error in + # ExceptionGroups (task-group teardown), and pytest.raises would miss the bare type. + with pytest.raises(MCPError) as exc_info: + await client.call_tool("gated", {}) + + # The SDK-authored refusal originates in the client driver's default sampling callback -- + # possible only because the server transmitted the embed a conformant server MUST NOT send. + 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: the spec forbids embedding an inputRequests entry the client's declared + capabilities do not support, but the SDK has no embed gate -- the roots/list is transmitted + as-is and the violation surfaces as the client driver's refusal aborting the call. See the + requirement's divergence; when the server-side gate lands (expected: the originating request + fails with -32021 MissingRequiredClientCapability or the embed is rejected at construction), + re-pin. + """ + calls: list[str] = [] + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> InputRequiredResult: + assert params.name == "gated" + calls.append(params.name) + # The precondition, through the same public surface a conformant gate would use: 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) + + # Default client, no callbacks: precisely the client that declared no capabilities. + async with connect(server) as client: + # Inside the connect block: unwinding through Client.__aexit__ would wrap the error in + # ExceptionGroups (task-group teardown), and pytest.raises would miss the bare type. + with pytest.raises(MCPError) as exc_info: + await client.call_tool("gated", {}) + + # The SDK-authored refusal originates in the client driver's default roots callback -- + # possible only because the server transmitted the embed a conformant server MUST NOT send. + 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 --- @@ -388,3 +799,140 @@ async def call(name: str) -> None: "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 wire trace is client-sent requests and server-sent responses + only -- zero server-initiated requests, zero client-sent responses (spec MUST NOT, both halves). + + The scenario is the maximal legitimate occasion for the forbidden frames: at 2025-11-25 this + same elicitation was a server-initiated request answered by a client JSON-RPC response; here it + rides the MRTR loop to completion and the trace still contains neither. The full trace shape is + snapshotted (the trailing tools/list is the client's implicit output-schema refresh) so any + future frame reorder fails consciously rather than silently narrowing the claim. + """ + elicited: list[str] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + # Live (not NotImplementedError): the client's output-schema cache refresh invokes + # tools/list right after the first tools/call result. + 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"] + # Load-bearing, not decoration: a transport exception silently filtered out below would fake + # the pass, so prove the received log holds messages only before narrowing to them. + 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"] + ) + # Every server frame ANSWERS a client request: response ids pair the sent request ids in + # order. The shape snapshots above prove these two isinstance 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: the Accept/Content-Type baseline plus the + routing and advisory headers a modern client always sends (the test_hosting_http_modern.py + dialect, minus the optional-name branch this file's one caller never takes).""" + 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 as a valid InputResponses object is rejected + with invalid params before the handler runs (spec SHOULD: validate; the structural arm only -- + no requestedSchema re-validation happens on this path, and the spec asks for none). + + Raw httpx against the mounted modern entry because the violation is unproducible above this + seam: the typed API rejects garbage inputResponses at construction, and the memory-streams + scripted-peer pattern cannot serve 2026 requests (the stream loop's init gate rejects + envelope-bearing requests). The cold retry is licensed by the spec's own framing -- the + initial request and the retry are completely independent. + """ + + 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="")) From 004534205a126cdb04aa391a764ffc5cbb43f1d7 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:35:10 +0000 Subject: [PATCH 08/16] Cover the x-mcp-header and modern HTTP entry families: 19 tests The SEP-2243 header derivation pipeline gets full coverage: static definition validation with per-tool eviction and the logged warning (RFC 9110 token rule, control characters, case-insensitive duplicates, the number type the spec now forbids, items/nested-properties reachability), the base64 sentinel encoding both ways including the collision-escape row from the spec's own table, null/absent argument omission, and the Mcp-Method/Mcp-Name mismatch rejections (400, -32020). The known server-side gap - Mcp-Param-* values are not validated against the body - is pinned as a divergence carrying issue=L110 with the recognized-header judgement call recorded so the fix re-pins under either shape. The modern entry itself: response modes, lazy SSE upgrade, cacheable stamping, disconnect cancellation, header validation arms, and the initialize-removed rejections. 28 entries minted (23 tested, 5 deferred), one flip, and the ledger riders: issue=L109 on the three embed-gate divergences, issue=L107 on the push-API pin. 876 -> 909 collected cells, all accounted; suite green three runs. --- tests/interaction/_requirements.py | 462 ++++++++- .../interaction/lowlevel/test_x_mcp_header.py | 222 +++++ .../transports/test_hosting_http_modern.py | 906 +++++++++++++++++- 3 files changed, 1586 insertions(+), 4 deletions(-) create mode 100644 tests/interaction/lowlevel/test_x_mcp_header.py diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 76c4bc9ff..c42a1b727 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -1127,6 +1127,116 @@ 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.", + ), "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.", @@ -2220,6 +2330,7 @@ def __post_init__(self) -> None: "(sampling.tools, sampling.context) are equally ungated and covered by this divergence " "without separate pins." ), + issue="L109", ), added_in="2026-07-28", supersedes=( @@ -2571,6 +2682,7 @@ def __post_init__(self) -> None: "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"), @@ -2728,6 +2840,7 @@ def __post_init__(self) -> None: "it lands, re-pin the request-scoped in-memory test to the local NoBackChannelError " "and delete this divergence." ), + issue="L107", ), added_in="2026-07-28", ), @@ -2836,6 +2949,7 @@ def __post_init__(self) -> None: "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",), @@ -4044,8 +4158,313 @@ def __post_init__(self) -> None: 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 yet covered here: planned transport-conformance test for the modern entry's disconnect handling." + "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. Only the Mcp-Name member of the spec's 'Mcp-Name or Mcp-Param-{Name}' pair is " + "server-validated; the SDK performs no Mcp-Param-* header-to-body comparison at all (the " + "recorded gap on hosting:http:modern:mcp-param-mismatch-400), so the Mcp-Param decode leg " + "is vacuous until that validation lands." + ), + ), + "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 currently holds by " + "construction (the server validates no Mcp-Param-* headers at all -- see " + "hosting:http:modern:mcp-param-mismatch-400); the pin is the regression bar for " + "when that validation lands." + ), + ), + "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",), + divergence=Divergence( + note=( + "The server performs no Mcp-Param-* header validation: the inbound ladder " + "compares only MCP-Protocol-Version, Mcp-Method and Mcp-Name, so a request " + "whose decoded Mcp-Param header disagrees with the body argument is accepted " + "and the handler runs on the body value; the same gap covers the spec's " + "'client omits header but value is in body' reject row. The SDK has no notion " + "of a 'recognized' param header (the inbound ladder never sees a tool schema); " + "the pinned accept uses a header that name-matches a body argument -- the " + "strongest candidate for any future validation -- and the unknown-header arm " + "(a header with no corresponding body argument) is deliberately not pinned: " + "its reject-vs-ignore consequence must be decided when validation lands." + ), + issue="L110", + ), + note="TS implements this (createMcpHandler) with no requirement id of its own.", + ), + "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 implemented in the SDK: the server never validates Mcp-Param-{Name} headers -- " + "classify_inbound_request (src/mcp/shared/inbound.py) checks only " + "MCP-Protocol-Version/Mcp-Method/Mcp-Name, and MCP_PARAM_HEADER_PREFIX / the " + "x-mcp-header schema map have client-emit-only consumers (src/mcp/client/session.py), " + "so there is no server-side notion of a 'recognized' param header, no " + "invalid-character check, and no rejection to assert." + ), + note=( + "Only observable over streamable HTTP: Mcp-Param-* are HTTP request headers. Sibling of " + "the gap recorded on hosting:http:modern:mcp-param-mismatch-400 (issue L110): both await " + "the same server-side Mcp-Param validation." + ), + ), + "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 implemented in the SDK: the server performs no Mcp-Param-{Name} header-vs-body " + "validation (classify_inbound_request in src/mcp/shared/inbound.py checks only " + "MCP-Protocol-Version/Mcp-Method/Mcp-Name; MCP_PARAM_HEADER_PREFIX has no server-side " + "consumer), so there is no integer comparison -- numeric or string -- to observe; a " + "42.0-vs-42 request is accepted only because nothing is checked." + ), + 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 whose absence " + "is recorded on hosting:http:modern:mcp-param-mismatch-400 (issue L110)." + ), + ), + "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-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." ), ), # ═══════════════════════════════════════════════════════════════════════════ @@ -4220,6 +4639,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=( @@ -4233,6 +4663,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:stateless-ignores-session-id": Requirement( source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#standard-request-headers", behavior=( 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..23ec9722a --- /dev/null +++ b/tests/interaction/lowlevel/test_x_mcp_header.py @@ -0,0 +1,222 @@ +"""The 2026-07-28 ``x-mcp-header`` schema-extension constraints, enforced client-side. + +A tool definition whose ``x-mcp-header`` annotation violates the spec's constraints is rejected by +the modern client: the tool is excluded from the tools/list result while valid sibling tools +survive, so a single malformed definition never takes down the rest of the listing. 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 eviction +also runs on the in-memory 2026 connection -- a deliberate superset these fixture-driven tests pin +on both 2026 matrix cells. +""" + +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) + + +# A valid-annotated sibling, not a plain schema: its survival proves the validator passes valid +# annotations rather than evicting everything that mentions x-mcp-header. Every test lists the +# broken tool FIRST, so the eviction assertion also proves the spec's stated rationale -- "a single +# malformed tool definition does not prevent other valid tools from being used"; a client that +# aborted the listing at the first invalid tool would fail. +_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 whose x-mcp-header annotation is the empty string is excluded from the tools/list + result while the valid sibling survives (spec MUST: the value MUST NOT be empty; rejection + means exclusion from the tools/list result). + + The SDK funnels the empty string through the same RFC 9110 token check as the non-tchar case + below, but the spec states the two MUSTs separately, so each keeps its own test and input. + """ + 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() + + # Membership is the property: list equality, not a full-result snapshot, so this test does not + # re-pin tools-result semantics (caching fields, schema echo) owned by other entries. + 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 annotation is not an RFC 9110 field-name token is excluded from + the tools/list result while the valid sibling survives (spec MUST: the value MUST match + ``1*tchar``, RFC 9110 section 5.1) -- a space is not a tchar. + """ + 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 annotation contains CR/LF is excluded from the tools/list result + while the valid sibling survives (spec MUST NOT contain control characters, naming CR and LF + -- the header-injection shape). + + The SDK enforces this through the single RFC 9110 token regex (control characters are not + tchars); the test pins the spec observable -- exclusion -- not the code path. + """ + 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 whose inputSchema carries two x-mcp-header values equal only under case-insensitive + comparison is excluded from the tools/list result while the valid sibling survives (spec MUST: + values are case-insensitively unique among all x-mcp-header values in the inputSchema). + + ``Region``/``region`` differ as exact strings, so a validator doing an exact-string duplicate + check would keep the tool and fail this test. Which of the two properties the rejection names + is walk-order implementation detail, deliberately not asserted. + """ + 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``-typed property with x-mcp-header is excluded from the + tools/list result while the valid sibling survives (spec MUST: the annotation is only + permitted on integer/string/boolean properties; ``number`` is the one JSON-Schema primitive + the spec forbids by name, so a validator merely checking "is a JSON primitive" would fail here). + + The ``object``/``array``/missing-``type`` variants take the same code arm and are deliberately + not swept here -- one input per entry, and the entry's spec sentence names ``number``. + """ + 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 annotation reachable only through ``items`` invalidates its tool, while a + sibling annotated at the end of a nested pure-``properties`` chain stays listed (spec MUST: + the annotation applies only to properties statically reachable from the schema root via a + chain consisting solely of ``properties`` keys; nested object properties are explicitly + permitted). + + The valid sibling here is the nested one, doing double duty for both arms of the spec + sentence (the flat ``_VALID_TOOL`` is not used); ``items`` is the spec's first-named + forbidden keyword and stands for the rest -- the per-applicator sweep is unit-test + territory, not interaction. + """ + 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 definition over an invalid x-mcp-header logs a warning naming the tool + and the reason for rejection (a spec SHOULD, not MUST). The eviction itself is co-asserted + so the log claim is not proven in a vacuum. + + The warning is a single deterministic record of fully SDK-authored text, so the whole + message is snapshot-pinned -- unlike the multi-record registration warning in + ``mcpserver/test_tools.py``, where only a stable prefix is asserted. + """ + 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/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py index a8f1f53c7..6bbbbb2d9 100644 --- a/tests/interaction/transports/test_hosting_http_modern.py +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -14,35 +14,57 @@ 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, METHOD_NOT_FOUND, MISSING_REQUIRED_CLIENT_CAPABILITY, + PROTOCOL_VERSION_META_KEY, CallToolRequestParams, CallToolResult, DiscoverResult, EmptyResult, + ErrorData, + GetPromptRequestParams, + GetPromptResult, Implementation, JSONRPCError, + JSONRPCMessage, JSONRPCResponse, + ListResourcesResult, ListToolsResult, PaginatedRequestParams, + ProgressNotification, + ProgressNotificationParams, + PromptMessage, + ReadResourceRequestParams, + ReadResourceResult, RequestParams, 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 from mcp import MCPError from mcp.client.client import Client from mcp.client.session import ClientSession from mcp.client.streamable_http import streamable_http_client from mcp.server import Server, ServerRequestContext -from tests.interaction._connect import BASE_URL, base_headers, initialize_via_http, mounted_app +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 @@ -92,13 +114,16 @@ 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. Its ``application/json`` + Content-Type also pins the lazy-upgrade JSON arm: a handler that emits nothing before its + result never commits SSE. """ body = { "jsonrpc": "2.0", @@ -550,3 +575,878 @@ async def on_request(request: httpx.Request) -> None: before, after = tool_calls assert before.headers.get("mcp-param-region") == "x" assert not any(k.startswith("mcp-param-") for k in after.headers) + + +@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 under the draft transport's standard-request-headers rules: tool names are only + SHOULD-constrained to header-safe, so a name that cannot survive an HTTP field round-trip + travels base64-sentinel-wrapped while the body keeps the literal. The call is made with no + prior `list_tools`, proving the header is derived from the request body at the transport seam, + not from a cached schema; the round trip completing proves the server decoded the sentinel + before comparing it against the body (a non-decoding server would answer 400 HeaderMismatch). + Asserted at the wire because the client never surfaces outgoing headers. + """ + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + # Live (not NotImplementedError): the client's output-schema cache refresh invokes + # tools/list right after the tools/call result. + 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")])) + # Method-filtered, never positional: the implicit output-schema tools/list is also recorded. + call = next(r for r in requests if json.loads(r.content)["method"] == "tools/call") + assert call.headers["mcp-name"] == snapshot("=?base64?aMOrbGxv?=") + # Encoding is a header concern only: the body keeps the literal name. + 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 `Mcp-Param-*` header. + + Spec-mandated by the value-encoding sentinel-collision rule, and the wrapped header is + byte-equal to the spec's own encoding-table example row. The value is plain ASCII and + otherwise header-safe, so the collision rule is the ONLY encoding trigger here -- a distinct + branch from the non-ASCII trigger the mirroring test above pins. Asserted at the wire because + the client never surfaces outgoing headers. + """ + requests: list[httpx.Request] = [] + + async def on_request(request: httpx.Request) -> None: + requests.append(request) + + discover = DiscoverResult( + supported_versions=[LATEST_MODERN_VERSION], + capabilities=ServerCapabilities(), + server_info=Implementation(name="srv", version="0"), + ) + with anyio.fail_after(5): + async with ( + mounted_app(_custom_header_server(), on_request=on_request) as (http, _), + Client( + streamable_http_client(f"{BASE_URL}/mcp", http_client=http), + mode=LATEST_MODERN_VERSION, + prior_discover=discover, + ) as client, + ): + # 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") + # Full-dict form so no stray param header can hide. + assert {k: v for k, v in call.headers.items() if k.startswith("mcp-param-")} == snapshot( + {"mcp-param-region": "=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?="} + ) + # Mirroring is additive: the body keeps the literal value. + 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 custom-header behaviour matrix, both columns of its null and absent rows: + with `note` null and `priority`/`verbose` absent, exactly one param header (the present + `region`) goes out, the null genuinely travels in the body, and the server must not reject the + request over the missing headers. The acceptance arm currently holds by construction -- the + server validates no `Mcp-Param-*` headers at all (the recorded gap on + `hosting:http:modern:mcp-param-mismatch-400`) -- so this pin is the regression bar: when that + validation lands, null/absent must not start being rejected. + """ + 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}) + + # The server did not expect the missing headers: no 400/HeaderMismatch, a normal result. + assert result == snapshot(CallToolResult(content=[TextContent(text="ok")])) + call = next(r for r in requests if json.loads(r.content)["method"] == "tools/call") + # Exactly one param header: the null and the two absent annotated arguments are omitted. + assert {k: v for k, v in call.headers.items() if k.startswith("mcp-param-")} == snapshot( + {"mcp-param-region": "us-west1"} + ) + # The null travelled in the body, so the server-side row is genuinely exercised. + 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 under the draft transport's server-validation rules: a server that processes the + body MUST reject a request whose standard header values do not match it, with 400 and JSON-RPC + -32020. Everything else on the request (protocol-version header, envelope) is valid, so the + rejection provably comes from the Mcp-Method rung, before any handler dispatch. Driven by raw + httpx because the typed client stamps matching headers by construction; asserted at the wire + because the HTTP status is part of the assertion. + """ + 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 under the draft transport's server-validation rules, the Mcp-Name arm of the + same MUST as the test above (one obligation, two distinct validation rungs with distinct + SDK-authored messages): the header is present and decodable but names a different tool than + the body. Driven by raw httpx because the typed client stamps matching headers by + construction; asserted at the wire because the HTTP status is part of the assertion. + """ + 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 on cacheable results; SDK-defined for the fill: + handler-authored values pass through unchanged (tools/list), a result whose handler set + neither is stamped with the ``CacheableResult`` defaults ``ttlMs 0`` / ``cacheScope private`` + (resources/list), and a partly-authored result fills only the missing hint (resources/read). + Asserted at the wire because the typed client models default-fill ``ttl_ms``/``cache_scope``, + so absent-vs-stamped is invisible above it. Three of the six MUST-listed operations pin the + mechanism: ``prompts/list`` / ``resources/templates/list`` are the caching family's own + proposed entries, and ``server/discover`` is already snapshot-pinned under its own entry. + """ + + 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: with ``json_response=True`` the modern entry answers with only the terminal + JSON-RPC response, and a request-scoped notification emitted mid-call is dropped, not + buffered. The full-body snapshot is simultaneously the single-body proof and the drop proof: + the one body is the only place a buffered notification could surface, and the snapshot + excludes it. The emit deliberately passes ``related_request_id`` so the drop pinned is the + json-mode no-sink drop, not the silent no-channel drop the connection's outbound would apply + anyway. (The TS twin's forced-SSE response mode has no python equivalent; the SDK knob is + the boolean ``json_response``.) + """ + + 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 of the 2026-07-28 entry: a handler that emits request-scoped + notifications before returning commits ``text/event-stream``, the frames carry the + notifications in emission order, and the terminal JSON-RPC response is the final frame + (the snapshot's length is the nothing-after-it proof). The JSON arm -- a silent handler is + answered ``application/json`` -- is pinned by the stateless tools/call test above. The + deferral window before a silent handler commits SSE anyway is deliberately unpinned: + asserting it would need a real-time wait the suite refuses. + """ + + 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 under the draft transport: notifications on a 2026-07-28 SSE response stream + relate to the originating client request. The interleaving is structural, not + scheduler-lucky. Steps: + + 1. the "quiet" request arrives, sets ``quiet_started``, and parks mid-handler; + 2. the "emit" request waits for that, emits its progress notification while "quiet" is + provably in flight, then releases it; + 3. emit's stream carries exactly its own notification plus its result, while quiet -- whose + response was still uncommitted at the emit -- stays a pure ``application/json`` body. A + broadcast or misroute would have committed quiet's response to SSE or added a frame. + + Request-scoping is by construction on the modern entry (per-request sink); this pins the + observable consequence. + """ + 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 (SHOULD, new on the draft transport page) so reverse proxies deliver events + unbuffered; scoped to the modern entry, the only 2026 SSE initiator at this pin. The + Content-Type assertion guards against a vacuous pass on a non-SSE response if the commit + logic ever changes. + """ + + 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: the framing is the lazy-upgrade test's subject. + 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 under the draft transport's case-sensitivity rules: header *names* are + case-insensitive. The in-process bridge lowercases header names into the ASGI scope, as every + conformant ASGI server must, so the claim pinned end-to-end is that the server's lookups key + on the lowercase canonical names rather than any cased spelling -- a routing match on cased + bytes would miss the all-uppercase ``MCP-PROTOCOL-VERSION``, fall through to the legacy path, + and fail there (no session, no handshake) instead of returning the modern result. Driven by + raw httpx because the typed client always emits the canonical lowercase spellings. + """ + body = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "add", "arguments": {"a": 2, "b": 3}, "_meta": _meta_envelope()}, + } + # Hand-built rather than base_headers()-derived: a dict union would keep base_headers()'s + # lowercase mcp-protocol-version key alongside the cased spelling (distinct dict keys), + # 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 under the draft transport's server-validation rules: a request missing a + required standard header fails validation with 400 and JSON-RPC -32020. The SDK reaches the + rejection through its mismatch rung (absent header != body method), so its SDK-authored + message says "does not match" rather than "missing" -- an implementation route the spec's + missing/malformed description covers, not a divergence. The missing-``MCP-Protocol-Version`` + arm is a different entry: a header-less request routes to the legacy transport, and the + rejecting modern-only posture is not implemented. Driven by raw httpx because the typed + client always sends the header. + """ + # tools/list is deliberately non-name-bearing, so the omitted Mcp-Method is the one and only + # missing required 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 Mcp-Name arm of the same missing-required-header obligation as the test + above -- a distinct validation rung with its own SDK-authored message, hence its own test. + The body's ``name`` parameter is present while the header is absent, which is exactly the + shape the mismatch rung rejects (a body with no ``name`` at all is the spec's own + server-MUST-NOT-expect-the-header lenience, not this entry). Driven by raw httpx because the + typed client always derives the header from the body. + """ + 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 under the draft transport's protocol-version-header rules, and the mismatch + rung runs before the supported-version check. The envelope value is deliberately a version + outside the supported modern revisions, so an UNSUPPORTED_PROTOCOL_VERSION response here + would mean the rung order regressed -- the HeaderMismatch snapshot pins the ordering for + free. Driven by raw httpx because the SDK client derives header and envelope from one value + and can never produce the disagreement. + """ + 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 under the draft transport's value-encoding rules: the server decodes a + sentinel-carrying header value before validation compares it to the body, so the + decode-matching request reaches the handler -- a plain string comparison would have answered + 400 HeaderMismatch. Driven by raw httpx because the typed client only sentinel-wraps values + that need encoding (plain-ASCII ``add`` goes bare), so an encoded-ASCII header is an input + the typed API cannot produce. The reject direction for a non-matching decoded value is the + std-header-mismatch entry's subject, pinned above. + """ + body = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "add", "arguments": {"a": 2, "b": 3}, "_meta": _meta_envelope()}, + } + # The sentinel form of "add"; encode_header_value would send plain ASCII bare. + 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 under the draft transport's value-encoding rules, the composed contract the raw + accept test above isolates server-side: the client sentinel-encodes the non-header-safe name, + the server decodes it before validation, and the typed result comes back. The recorded-request + hook supplies the one fact the client cannot observe -- that the header on the wire really was + the sentinel form, without which a never-decoding server would still pass this round trip. + ``prompts/get`` is the vehicle because it is name-bearing with no implicit follow-up traffic, + so exactly one POST is recorded. + """ + + 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"))]) + ) + # The single-element unpack is the no-implicit-traffic proof; the header value is byte-equal + # to the codec pin for the identical bytes on mcp-param-note in the mirroring test above. + [call] = requests + assert json.loads(call.content)["method"] == "prompts/get" + assert call.headers["mcp-name"] == snapshot("=?base64?aMOpbGxv?=") + # Encoding is a header concern only: the body keeps the literal name. + 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 under the draft cancellation rules: closing the SSE response stream is the + transport-level cancellation signal, and the server MUST treat the disconnect as cancellation + of that request. The entry's "no JSON-RPC response is written" clause is unobservable from the + closed stream and holds by construction -- the cancelled handler never produces a result -- so + the cancellation observation carries it. Steps: + + 1. POST the park tools/call over SSE; the handler emits one progress notification (committing + SSE, so a response stream exists to close) and parks where only cancellation can free it; + 2. read exactly the first SSE event -- the handler is provably running and the stream carried + only request-related traffic before the close; + 3. exit the SSE context: httpx closes the response and the bridge delivers http.disconnect; + 4. await the handler's cancellation while the app is still mounted -- the bridge teardown also + cancels application tasks, so only this placement proves the disconnect watcher did it. + """ + 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 (the loop + # spells out what sleep_forever's annotation does not promise). + 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: + # Held as an iterator and advanced exactly once: a full `async for` would wait for + # the stream to close, and the close under test is ours to perform. + events = source.aiter_sse() + first = await anext(events) + # The aconnect_sse exit above closed the response. The app is still mounted here, so + # the only possible canceller is the modern entry's disconnect watcher; waiting after + # mounted_app exits would pass vacuously off the bridge's teardown cancellation. + 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("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-request HTTP request context carries the real transport request on the + legacy session path and the 2026-07-28 single-exchange path alike. Both legs drive the SDK + client end to end -- the observation travels back through the protocol (the tool returns the + header it saw) and ``mounted_app(headers=...)`` injects the header, so no raw HTTP is needed. + 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 (not NotImplementedError): 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" + # The narrow is the proof the transport attached the real HTTP request object. + assert isinstance(ctx.request, Request) + 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_entry_accepts_a_mismatching_mcp_param_header_without_validation() -> None: + """A tools/call whose Mcp-Param header disagrees with the body argument is accepted, pinning the gap. + + Pins a known divergence (recorded on the entry, issue L110): the spec mandates that any server + processing the message body validate decoded header values against the corresponding body + values and reject with 400/-32020 HeaderMismatch on any failure, but the inbound ladder + compares only MCP-Protocol-Version, Mcp-Method and Mcp-Name -- the disagreeing + ``Mcp-Param-Region`` is ignored and the handler runs on the body value (its arguments assert + is the body-wins proof; the header value must not leak). The SDK has no notion of a + "recognized" param header -- the ladder never sees a tool schema, so this server deliberately + registers no on_list_tools -- and the pinned accept uses a header that name-matches a body + argument, the strongest candidate for any future validation; the unknown-header arm (a header + with no corresponding body argument) is deliberately not pinned and must be decided when + validation lands. When it does, this test flips to 400 and re-pins, while the null-and-absent + acceptance test above must stay green. Driven by raw httpx because the typed client mirrors + param headers correctly by construction. + """ + + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + assert params.name == "run" + assert params.arguments == {"region": "us-west1"} + return CallToolResult(content=[TextContent(text="ok")]) + + 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_call_tool=call_tool)) as (http, _): + response = await http.post("/mcp", json=body, headers=headers) + + # 200, not the spec-mandated 400: the request is served despite the mismatching header. + assert response.status_code == 200 + parsed = JSONRPCResponse.model_validate(response.json()) + assert parsed.id == 1 + assert parsed.result == snapshot( + {"content": [{"text": "ok", "type": "text"}], "isError": False, "resultType": "complete"} + ) From 7a9a33d74dab49ff6a2918225d6be6b0aa528254 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:47:55 +0000 Subject: [PATCH 09/16] Backlog hardening: complete the push-API divergence matrix and sharpen three tests Add the HTTP request-scoped loud-fail test, completing all four legs of the push-API divergence record (both transports x both legs). Add the missing templates/list decorator on the static-and-templated listing test. Redesign the post-connect registration fixture to mutate the tool set between requests rather than from inside a handler, so the fixture itself no longer violates the 2026 list-stability requirement on live cells. Assert that an iss-mismatch rejection never exchanges the authorization code (with a liveness guard on the recorded /token calls). 909 -> 910 cells. --- .../interaction/auth/test_authorize_token.py | 10 ++- tests/interaction/mcpserver/test_resources.py | 1 + tests/interaction/mcpserver/test_tools.py | 27 +++--- .../transports/test_hosting_http_modern.py | 84 ++++++++++++++++++- 4 files changed, 107 insertions(+), 15 deletions(-) diff --git a/tests/interaction/auth/test_authorize_token.py b/tests/interaction/auth/test_authorize_token.py index d8e61d47c..77593d577 100644 --- a/tests/interaction/auth/test_authorize_token.py +++ b/tests/interaction/auth/test_authorize_token.py @@ -192,8 +192,10 @@ 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. + compares it to `oauth_metadata.issuer` and raises `OAuthFlowError` before the token exchange -- + the recorded traffic shows no /token POST, so the tainted authorization code is never exchanged. """ + 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 +204,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") diff --git a/tests/interaction/mcpserver/test_resources.py b/tests/interaction/mcpserver/test_resources.py index 3a236af85..6497507ab 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. diff --git a/tests/interaction/mcpserver/test_tools.py b/tests/interaction/mcpserver/test_tools.py index a7791d7cd..cb66a3485 100644 --- a/tests/interaction/mcpserver/test_tools.py +++ b/tests/interaction/mcpserver/test_tools.py @@ -394,9 +394,11 @@ 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 mutation + happens out-of-band, between requests -- the spec's "MAY change over time" allowance; varying + the set as a side effect of another request on the connection is forbidden at 2026-07-28. The + sentinel tool logs one message after the mutation so the test proves notifications do reach + the collector -- the log message arrives, a list_changed does not. """ received: list[IncomingMessage] = [] mcp = MCPServer("mutable") @@ -411,22 +413,23 @@ 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", {}) + # Out-of-band: no request is in flight while the set changes. + 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"))] ) diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py index 6bbbbb2d9..94767f577 100644 --- a/tests/interaction/transports/test_hosting_http_modern.py +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -4,7 +4,9 @@ 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 handful of tests +instead drive the SDK client against the mounted entry, for serving behaviour that is specific +to this transport but observable through the public API. """ import json @@ -21,12 +23,15 @@ 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, @@ -53,10 +58,12 @@ from starlette.requests import Request 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 mcp.shared.exceptions import NoBackChannelError from tests.interaction._connect import ( BASE_URL, base_headers, @@ -1356,6 +1363,81 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> ) +@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 raises the typed local error and the + call still completes -- the related_request_id leg that the in-memory pair transmits (the + divergence pinned in lowlevel/test_mrtr.py) loud-fails here, byte-identical to the standalone + legs, because this entry's per-request dispatch context hard-codes its back-channel away. + + The outcome is spec-mandated (the previous server-initiated request pattern is no longer + supported) but the enforcement at this pin is incidental -- no back-channel, not an era gate. + Transport-pinned: the modern entry's gate is the only enforcement of the 2026 push prohibition + that holds on this leg, so it gets its own regression pin against the requirement's divergence + matrix. + """ + caught: list[NoBackChannelError] = [] + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + # Live (not NotImplementedError): the client's output-schema cache refresh invokes + # tools/list right after the tools/call result. + 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 -- the leg whose in-memory + # twin still transmits the forbidden frame. + 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) + + # Registered so the client declares the elicitation capability in the per-request envelope, + # isolating the failure to the missing back-channel rather than to capability gating; the + # body is itself the never-delivered assertion. + 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", {}) + + # 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("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. From 29c05ed0ee7e5dc2a454f1566ff4f5d59171f3da Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:11:05 +0000 Subject: [PATCH 10/16] Cover the caching and discover-versioning families: 17 tests SEP-2549 server-side caching: cache hints pass through unmodified on prompts, resource-template, and discover results (the discover hints were previously pinned nowhere); ttlMs zero means immediately stale; absent hints default per the 2025 rules on the legacy cells; the interim input_required frame carries no hints while the same exchange's complete result does. Two recorded divergences: cross-page cacheScope consistency is delegated to handler authors (the spec MUST binds the server), and a negative ttlMs raises a validation error where the spec says clients should ignore-as-zero - the divergence note records that emission-side strictness is correct and only the inbound parse should clamp. Discover and versioning: instructions and derived capabilities ride DiscoverResult (with vacuity guards against silent legacy fallback), auto mode probes before negotiating, era-cached results are reused identically, the -32022 supported-list retry, dual-era precedence, and the era method gate - a 2025 method on a 2026 connection is method-not-found before any handler lookup, proven by a registered handler that never runs. 27 entries minted (13 tested, 14 deferred with greppable re-open tokens), 3 flips. 910 -> 931 cells, all accounted; suite green three runs. --- tests/interaction/_requirements.py | 454 +++++++++++++++++- tests/interaction/lowlevel/test_caching.py | 347 +++++++++++++ .../lowlevel/test_client_connect.py | 169 +++++++ tests/interaction/lowlevel/test_resources.py | 31 ++ .../transports/test_hosting_http.py | 4 + .../transports/test_hosting_http_modern.py | 17 +- 6 files changed, 1006 insertions(+), 16 deletions(-) create mode 100644 tests/interaction/lowlevel/test_caching.py diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index c42a1b727..d20acf5f9 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -421,7 +421,7 @@ def __post_init__(self) -> None: source=f"{SPEC_2026_BASE_URL}/server/discover", behavior=( "Calling discover() sends server/discover with no params and returns a typed DiscoverResult " - "carrying supportedVersions, capabilities, serverInfo and the cache hint fields." + "carrying supportedVersions, capabilities and serverInfo." ), added_in="2026-07-28", supersedes=("lifecycle:initialize:server-info",), @@ -443,10 +443,6 @@ def __post_init__(self) -> None: ), added_in="2026-07-28", supersedes=("lifecycle:initialize:instructions",), - deferred=( - "Not yet covered here: lifecycle:discover:basic exercises the discover round trip but no test " - "asserts the instructions field." - ), ), "lifecycle:discover:capabilities:from-handlers": Requirement( source=f"{SPEC_2026_BASE_URL}/server/discover#response", @@ -465,9 +461,21 @@ def __post_init__(self) -> None: "logging:capability:declared", "mcpserver:completion:capability-auto", ), - deferred=( - "Not yet covered here: lifecycle:discover:basic pins the result shape but no test asserts the " - "per-area handler-to-capability derivation." + ), + "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( @@ -478,9 +486,57 @@ def __post_init__(self) -> None: ), added_in="2026-07-28", supersedes=("lifecycle:version:server-fallback-latest",), - deferred=( - "Not yet covered here: the server-side -32022 production is exercised only indirectly through " - "the client retry path." + 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 stripping is not implemented in " + "python -- the era-agnostic derivation can advertise capabilities for " + "era-removed methods on a 2026 discover result (probed: logging, " + "resources.subscribe), ruled era-agnostic and conformant (schema.ts keeps " + "logging deprecated-but-valid and subscribe era-unqualified) and deliberately " + "unpinned as capability-API-redesign territory (the runtime advertises " + "resources.subscribe while the listen runtime does not exist) -- 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( @@ -496,7 +552,30 @@ def __post_init__(self) -> None: 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." + "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( @@ -510,6 +589,19 @@ def __post_init__(self) -> None: 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=( @@ -526,6 +618,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=( @@ -534,6 +652,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 # ═══════════════════════════════════════════════════════════════════════════ @@ -2724,6 +2859,14 @@ def __post_init__(self) -> None: "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", + ), "mrtr:input-responses:invalid-rejected": Requirement( source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#error-handling", behavior=( @@ -3056,6 +3199,271 @@ def __post_init__(self) -> None: ), ), # ═══════════════════════════════════════════════════════════════════════════ + # 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=( + "Satisfied by construction at this pin -- the client has no response cache, so every " + "call re-fetches regardless of ttlMs (the positive-ttl fresh window is the deferred " + "sibling caching:ttl:positive-fresh-window). The pin is the regression bar for a " + "future cache: one that wrongly served a ttlMs 0 entry would fail it." + ), + ), + "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 unobservable: the client has no response cache (see the caching:key:* and " + "caching:freshness:* deferrals)." + ), + ), + "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", + divergence=Divergence( + note=( + "The client rejects a negative ttlMs with a pydantic ValidationError out of the " + "request call instead of ignoring it and treating it as 0: Field(ge=0) on the " + "2026-07-28 wire surface (and on the monolith CacheableResult) raises before any " + "coerce-to-zero leniency could run, and there is no response cache for 'treat as " + "0' to act on. The gap is asymmetric: ge=0 on server-authored EMISSION is correct " + "by-construction strictness (a conformant server can never author a negative " + "ttlMs through the typed API); the gap is ONLY the client's inbound parse, which " + "validates before any clamp-to-0 could apply. The remedy is receive-side leniency " + "-- clamp a negative inbound ttlMs to 0 before validation -- NOT loosening the " + "shared type, which would silently bless negative emission server-side." + ), + issue="L112", + ), + ), + "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 implemented in the SDK: the client has no SEP-2549 response cache -- nothing in " + "src/mcp/client/ consults ttl_ms after deserialization, and every list call " + "unconditionally re-issues the request -- so a positive ttlMs never produces a fresh " + "window in which a re-fetch is suppressed." + ), + ), + "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 implemented in the SDK: the client has no SEP-2549 response cache, so the " + "fresh-serve half of the staleness transition does not exist -- every access is " + "already a fetch -- and the transition is unconstructible." + ), + ), + "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 implemented in the SDK: the client has no SEP-2549 response cache and never " + "reads a result's ttl_ms, so there is no TTL machinery whose non-polling could be " + "observed -- the test would assert that absent code did not run." + ), + ), + "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 implemented in the SDK: the client never serves a cached response at all (no " + "SEP-2549 response cache exists under src/mcp/client/), so cache-key discipline has " + "no positive half to exercise and a two-requests-both-reach-the-server test would " + "pass vacuously." + ), + ), + "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 implemented in the SDK: the client has no SEP-2549 response cache, so there is " + "no cacher whose refusal to store an MRTR-retry result could be driven or observed; " + "the negative is indistinguishable from 'the client never caches anything'." + ), + ), + "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: there is no SEP-2549 response cache with a freshness " + "clock for a notification to invalidate, and the client has no server-to-client " + "list_changed pipeline that could feed one (the only list_changed code in " + "src/mcp/client/ is the outbound roots/list_changed sender)." + ), + ), + "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 per-receipt freshness clock and independent expiry " + "need a client response cache that records per-page receipt times; none exists. 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 client has no SEP-2549 response cache, no per-page " + "ttlMs bookkeeping, and no autonomous re-fetch loop -- the list methods are one-shot " + "and caller-cursored -- so there is no expired cached page to selectively re-fetch." + ), + ), + "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 implemented in the SDK: the client retains no list pages (no SEP-2549 response " + "cache), so there are no cached pages to discard and no re-fetch-from-the-beginning " + "reaction to observe; the -32602 surfacing itself is pinned by pagination:invalid-cursor." + ), + ), + "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 implemented in the SDK: the client has no SEP-2549 response cache and therefore " + "no per-authorization-context cache keying -- there is no stored entry that could be " + "served across an access-token change, on either side of the connection." + ), + ), + # ═══════════════════════════════════════════════════════════════════════════ # Tasks (experimental) # ═══════════════════════════════════════════════════════════════════════════ "tasks:auth:context-isolation": Requirement( @@ -5105,6 +5513,28 @@ def __post_init__(self) -> None: 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 implemented in the SDK: the stdio stream-loop server cannot serve 2026-era " + "requests -- the legacy loop's init gate (src/mcp/server/runner.py) rejects " + "envelope-bearing requests with INVALID_PARAMS because a pinned-2026 client never " + "sends initialize, and nothing on the stdio path wires Connection.from_envelope, " + "so a dual-era stdio factory is unconstructible." + ), + 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 # ═══════════════════════════════════════════════════════════════════════════ diff --git a/tests/interaction/lowlevel/test_caching.py b/tests/interaction/lowlevel/test_caching.py new file mode 100644 index 000000000..f7a6a05c7 --- /dev/null +++ b/tests/interaction/lowlevel/test_caching.py @@ -0,0 +1,347 @@ +"""SEP-2549 caching hints: producer-side stamping and the client-facing TTL/scope semantics. + +The fixture-driven tests pin what the public Client surfaces on the era matrix; one test records +2026 JSON-RPC frames over the modern HTTP entry because absent-vs-default hint keys are invisible +to typed models; and one plays a non-conformant server by hand over memory streams because the +typed Server cannot author the malformed value under test. The client-side response-cache +behaviours (fresh windows, invalidation, cache keys) are deliberately absent: the SDK has no +response cache, and the manifest tracks each as a deferred `caching:*` entry that re-opens when +one lands. +""" + +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 pydantic import ValidationError + +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 on purpose (the defaults are 0/"private"): a result the server failed to stamp +# would surface the defaults, so only non-default values 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, on + a resultType complete result. Spec-mandated (draft server/utilities/caching, the six-operation + MUST); the non-default values prove the hints travelled -- a result the server failed to stamp + would surface the 0/private defaults. On the streamable-http cell the 2026 wire surface makes + both hints required, so the client's own validation co-proves wire presence. + """ + + async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListPromptsResult: + assert params is not None # the client always sends params, even without a cursor + 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 ttlMs/cacheScope on a resources/templates/list result reach the client + unmodified, on a resultType complete result. Spec-mandated (draft server/utilities/caching -- + the sixth operation of the six-operation MUST); the non-default values prove the hints + travelled, and on the streamable-http cell the required 2026 wire aliases co-prove wire + presence. + """ + + async def list_resource_templates( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> ListResourceTemplatesResult: + assert params is not None # the client always sends params, even without a cursor + 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: + """A handler that authors cacheScope public on page 1 and private on page 2 of one cursor walk + reaches the client unmodified on both pages: the SDK applies no cross-page cacheScope + consistency, so the spec's same-scope-all-pages MUST rests entirely on the handler author. + Pins the known gap recorded on the requirement (divergence); a future enforcing SDK fails this + test -- 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" + # One request's page sequence, not two independent walks. + 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 2025-era exchange carries no ttlMs/cacheScope on the wire (the + hosting:http:legacy-no-modern-vocabulary entry pins the absence over HTTP); the client + surfaces ttl_ms 0 -- immediately stale -- and the SDK's safe cacheScope default private. The + ttl half is the spec SHOULD for older servers; the private half is SDK-defined (the spec + sentence covers only ttlMs). + """ + + 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, not laziness -- + # authored hints would be dropped on the 2025 wire anyway. + 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: nothing + is served from a cache. Honest provenance: this passes by construction -- the client has no + response cache at all, so the identical observable would occur for any ttl (the positive-ttl + fresh window is the deferred not-implemented sibling). The pin is the regression bar for a + future cache: one that wrongly served a ttlMs-0 entry fails it. + """ + fetches: list[int] = [] + + async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult: + assert params is not None + fetches.append(1) + # ttl_ms=0 authored explicitly: 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 + # The stamped value really was the one under test on both fetches. + 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: + """On one resources/read MRTR exchange, the serialized interim frame's result holds exactly + inputRequests plus resultType input_required -- no ttlMs, no cacheScope -- while the terminal + complete frame of the same method carries both. Asserted at the client transport seam over the + modern HTTP entry because typed models hide absent-vs-default (the monolith would default-fill + the hints on read-back); the in-test contrast frame guards the absence assertion against + vacuity. Spec-mandated (draft server/utilities/caching: interim results are not cacheable and + carry no caching hints), and the same key-set pin proves the resultType discriminator is + serialized explicitly (the stacked mrtr entry). The unobservable consumer half ('are not + cacheable') is recorded on the entry, not here. + """ + + 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 + # Both hints authored non-default (the defaults are 0/"private"): 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): + # One combined async-with, the recorder bound via := -- a separately nested `async with` + # line mis-traces its exit arcs under branch coverage 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_who, + ) as client, + ): + result = await client.read_resource("res://profile") + + # The whole sent log, not a filter: resources/read generates no implicit sibling traffic. + 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: a stronger absence claim than two `not in` checks -- any field added to + # interim frames fails loudly -- and the explicit resultType value is the stacked entry's pin. + assert sorted(interim) == ["inputRequests", "resultType"] + assert interim["resultType"] == "input_required" + # The same-exchange contrast frame: the terminal complete result of the same method carries both. + assert complete["ttlMs"] == 60_000 + assert complete["cacheScope"] == "public" + assert complete["resultType"] == "complete" + # The typed surface agrees with the terminal frame. + 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_rejected_not_coerced_to_zero() -> None: + """A tools/list answer carrying ttlMs -1 raises a pydantic ValidationError out of the awaiting + call instead of being ignored and treated as 0 -- the spec SHOULD is not implemented (known + gap recorded on the requirement: Field(ge=0) rejects before any leniency could run). The test + plays the server by hand over memory streams because the typed Server cannot author a negative + ttlMs (the same ge=0 constraint, at construction), and uses the bare pinned-2026 ClientSession + because Client has no public connect path over raw scripted streams. When coerce-to-zero + leniency lands, this test fails: re-pin to ttl_ms == 0 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): + 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"}, + ) + ) + ) + # Returns naturally: the task group needs no cancel after the session context exits. + + # One combined async-with: a separately nested `async with` line 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 pytest.raises(ValidationError) as excinfo: + with anyio.fail_after(5): + await session.list_tools() + + errors = excinfo.value.errors() + assert len(errors) == 1 + assert errors[0]["loc"] == ("ttlMs",) + # Stable pydantic-core identifier; the message text is third-party and + # deliberately unpinned. + assert errors[0]["type"] == "greater_than_equal" diff --git a/tests/interaction/lowlevel/test_client_connect.py b/tests/interaction/lowlevel/test_client_connect.py index 164eb0aab..2cbef47fb 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, ) @@ -368,3 +371,169 @@ 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. + + Requirement `lifecycle:discover:instructions` (spec server/discover#discoverresult): the field + rides the discover result, so the client connects in its default auto mode -- the only public + vehicle that performs a real `server/discover` round trip (the fixture's pinned 2026 cells adopt + a synthesized empty DiscoverResult and never observe server-side discover content). Asserting + the modern protocol version on both arms proves the carrier was discover, not an initialize + fallback, which would also expose instructions. + """ + 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. + + Requirement `lifecycle:discover:capabilities:from-handlers` (spec server/discover#response): + capabilities derive from the registered handlers; the full-object snapshot proves the + unregistered areas stay None, and the bare server advertises nothing at all. `list_changed=False` + comes from the default NotificationOptions, as in the 2025 initialize sibling. Only era-clean + areas (tools/prompts/completions) are registered on purpose: the derivation is era-agnostic, so + a subscribe or logging handler would advertise a capability whose method is era-removed at + 2026-07-28 -- a quirk deliberately left unpinned here. + """ + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + """Registered only so the tools capability is advertised; never called.""" + raise NotImplementedError + + async def list_prompts( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListPromptsResult: + """Registered only so the prompts capability is advertised; never called.""" + raise NotImplementedError + + async def completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> types.CompleteResult: + """Registered only so the completions capability is advertised; never called.""" + 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. + + Requirement `lifecycle:mode:auto-probes-first` (spec stdio#backward-compatibility, a SHOULD): a + dual-era client sends the probe before any other request, carrying its preferred modern version + in `_meta.protocolVersion`. The complete recorded method sequence is the before-any-other-request + clause -- nothing preceded the probe and nothing rode alongside it. The spec sentence lives on + the stdio page but binds connect-time ordering in transport-independent client code, asserted + here at the in-process streamable-HTTP seam like the sibling backward-compatibility entries. + """ + 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. + + Requirement `lifecycle:discover:era-cached` (spec basic/versioning#backward-compatibility-with- + initialization-based-versions, a SHOULD): the era determination is cached for the connection. + The complete recorded method list proves exactly one probe preceded three feature calls and + that the explicit `discover()` call added no POST. `ClientSession` is reached directly because + `Client` exposes no re-fetch surface; `discover()` / `discover_result` are its documented + cache API. The `is` assert proves the same adopted object is returned, not an equal copy. + """ + 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. + + Requirement `lifecycle:discover:retry-on-32022` (spec basic/versioning#protocol-version-negotiation): + the empty-intersection clause. The overridden `server/discover` handler advertises only + "1999-12-31": a modern member would trigger the one-shot retry and a handshake member the + initialize fallback, so the fully-disjoint list isolates the raise. The wire record asserted + after the app context is the equally load-bearing negative -- exactly one probe, no second + probe, no `initialize` (spec stdio#backward-compatibility: a recognized modern error must not + fall back to the handshake). The error surfaces through the streamable-http task-group + teardown as nested ExceptionGroups, so `RaisesGroup` flattens before matching; only the code + is checked because the message and data are this test's own scripted values. + """ + + 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_resources.py b/tests/interaction/lowlevel/test_resources.py index 566856e5c..fa31fa94b 100644 --- a/tests/interaction/lowlevel/test_resources.py +++ b/tests/interaction/lowlevel/test_resources.py @@ -225,6 +225,37 @@ 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. + + Requirement `lifecycle:version:era-method-gate`: the method exists at 2025-11-25 but is removed + from the 2026-07-28 method surface, so the per-version registry rejects the request before any + handler lookup. The registered handler is the load-bearing discriminator -- without it the + byte-identical error would come from the no-handler branch, which the 2025-only sibling + `test_subscribe_without_a_subscribe_handler_is_method_not_found` already pins. The adjacent + `test_subscribe_resource_delivers_uri_to_handler` is the 2025 control: the same server shape + and the same call succeed on every 2025 cell. SDK-authored error, snapshot-pinned, identical + on both 2026 cells. + """ + + 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. diff --git a/tests/interaction/transports/test_hosting_http.py b/tests/interaction/transports/test_hosting_http.py index 4fc62bc9e..53c07fad3 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,9 @@ 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 ``lifecycle:version:unsupported-32022`` (spec basic/versioning#protocol-version-negotiation, + a MUST): the -32022 code and the ``data.supported`` list asserted here are the server-side + production of the negotiation error, exercised directly rather than through the client retry path. """ bad = "1991-01-01" meta = { diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py index 94767f577..a8031314c 100644 --- a/tests/interaction/transports/test_hosting_http_modern.py +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -172,6 +172,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. @@ -180,7 +181,9 @@ 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 ``lifecycle:version:dual-era-precedence``: this frame is simultaneously a + valid modern envelope and the legacy handshake opener, and the rejection proves the modern + classification won -- a legacy classification would have answered the handshake. """ body = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"_meta": _meta_envelope()}} async with mounted_app(_server()) as (http, _): @@ -241,12 +244,15 @@ 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 SEP-2549 caching hints the entry stamps on + the discover result -- the SDK defaults ``ttlMs 0`` / ``cacheScope private`` -- under + ``caching:hints:server-discover``. 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()}} @@ -258,6 +264,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") @@ -789,8 +798,8 @@ async def test_modern_cacheable_results_carry_ttl_and_scope_with_defaults_filled (resources/list), and a partly-authored result fills only the missing hint (resources/read). Asserted at the wire because the typed client models default-fill ``ttl_ms``/``cache_scope``, so absent-vs-stamped is invisible above it. Three of the six MUST-listed operations pin the - mechanism: ``prompts/list`` / ``resources/templates/list`` are the caching family's own - proposed entries, and ``server/discover`` is already snapshot-pinned under its own entry. + mechanism: ``prompts/list`` / ``resources/templates/list`` are pinned under the caching + family's own entries, and ``server/discover``'s hints under ``caching:hints:server-discover``. """ async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: From 0c386159d29a3da8793a5bf6bfe445e7ab5a30cf Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:55:54 +0000 Subject: [PATCH 11/16] Cover the auth families: the RFC 9207 iss table, step-up bounds, DCR and refresh The full iss validation table from the 2026 authorization-response rules: match accepted, trailing-slash difference rejected without normalization (both comparison strings pinned as harness literals so server-side issuer serialization changes cannot invert the test), missing-iss rejected when advertised and tolerated when not, an unadvertised-but-present iss still validated, and an error redirect with a mismatched iss rejected on iss before the missing-code error - the ordering that proves validation applies equally to error responses. Step-up bounds: a second insufficient-scope 403 after one step-up surfaces as an error without another authorize round trip, and a 403 on the GET stream open steps up and reopens with the upgraded token (era-bound: the GET stream is removed at 2026-07-28). DCR defaults (grant_types omitted and passed through verbatim), refresh-token rotation handling at the single-refresh seam (replacement stored, preservation honoured when the server does not rotate), and a non-2xx token response surfacing typed. The as-binding entry splits into its two spec obligations (re-register and no-credential-reuse), both decorating the existing test unchanged. Harness: three small review-approved knobs (iss visibility, code override, persistent step-up shim, non-rotating provider). 16 entries minted (13 tested, 3 deferred), 931 -> 944 cells exact; suite green three runs. --- tests/interaction/_requirements.py | 251 +++++++++++++++++- tests/interaction/auth/_harness.py | 40 ++- tests/interaction/auth/_provider.py | 19 +- .../interaction/auth/test_authorize_token.py | 247 ++++++++++++++++- tests/interaction/auth/test_flow.py | 70 ++++- tests/interaction/auth/test_lifecycle.py | 241 ++++++++++++++++- 6 files changed, 851 insertions(+), 17 deletions(-) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index d20acf5f9..9a44f035c 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -5144,6 +5144,57 @@ 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:as-metadata-discovery:priority-order": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-metadata-discovery", behavior=( @@ -5230,12 +5281,68 @@ def __post_init__(self) -> None: transports=("streamable-http",), note="OAuth is HTTP-only.", ), - "client-auth:as-binding": Requirement( + "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." + ), + 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: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.", ), @@ -5330,6 +5437,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=( @@ -5384,12 +5514,127 @@ def __post_init__(self) -> None: 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: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-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=( diff --git a/tests/interaction/auth/_harness.py b/tests/interaction/auth/_harness.py index 4fd1110c9..8731cb11d 100644 --- a/tests/interaction/auth/_harness.py +++ b/tests/interaction/auth/_harness.py @@ -132,25 +132,44 @@ 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 — both record the redirect regardless of the + callback-boundary levers below. `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, `callback_handler` returns this value as the authorization code + instead of the one parsed from the redirect, so tests can drive the token-endpoint + rejection path. + + `omit_iss`: when set, `callback_handler` returns no iss regardless of what the redirect + carried, so tests can drive the missing-iss paths (the `iss_override` sentinel 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 +185,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 +328,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 +340,10 @@ 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, every authenticated POST from the Nth onward receives the 403 challenge + instead of only the Nth, so tests can drive a further `insufficient_scope` challenge on the + request retried after a step-up. """ seen = 0 fired = False @@ -328,7 +352,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" diff --git a/tests/interaction/auth/_provider.py b/tests/interaction/auth/_provider.py index 0c54d4fd3..d01c9e004 100644 --- a/tests/interaction/auth/_provider.py +++ b/tests/interaction/auth/_provider.py @@ -49,6 +49,9 @@ 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 exchange issues only a new access + token (the response carries no `refresh_token` and the presented one stays valid), + modelling an RFC 6749 §6 non-rotating authorization server. """ def __init__( @@ -59,6 +62,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 +75,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 +183,23 @@ 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, consuming the old one. + + Unless `rotate_refresh_tokens` is off: then only a new access token is minted, the + response carries no `refresh_token`, and the presented one stays valid. + """ 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_authorize_token.py b/tests/interaction/auth/test_authorize_token.py index 77593d577..ae22bc2e0 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 @@ -188,12 +188,16 @@ async def test_a_mismatched_state_on_the_callback_aborts_the_flow() -> None: @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 -- the recorded traffic shows no /token POST, so the tainted authorization code is never exchanged. + Also pins the mismatch arm of `client-auth:iss:unadvertised-present-validated`: the served AS + metadata never advertises `authorization_response_iss_parameter_supported`, so this rejection is + RFC 9207 validation table row 3 -- a present iss is validated regardless of advertisement. """ recorded, on_request = record_requests() provider = InMemoryAuthorizationServerProvider() @@ -421,3 +425,244 @@ 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 surfacing (no spec mandate governs client-side token-error handling): + `code_override` forges the authorization code at the callback boundary, so the SDK's own + token handler produces a genuine RFC 6749 `invalid_grant` 400 -- no shim anywhere. The + matched message pins only the SDK-authored prefix naming the HTTP status; the tail is the + server handler's JSON, and the absent machine-readable code is the deferred + `client-auth:token-error:machine-readable-code`. The recorded traffic proves the failed + exchange was not retried and no request ever carried a bearer token. + """ + 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__() + + # The flow genuinely reached the token step via a real authorize round trip... + assert find(recorded, "GET", "/authorize") != [] + # ...the failed exchange was not retried (no loop)... + assert len(find(recorded, "POST", "/token")) == 1 + # ...and no request ever carried a bearer: the failure pre-empted token use entirely. + 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 with a slash-explicit issuer literal. + + The SDK server's `build_metadata` never sets `authorization_response_iss_parameter_supported`, + so the advertising arms of the RFC 9207 validation table need this override; `None` omits the + field (`exclude_none`), keeping the document an unadvertising AS whose issuer bytes are still + harness-pinned. + """ + 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: advertised support + present matching iss -> compare and proceed. The shim + serves AS metadata advertising `authorization_response_iss_parameter_supported`; the suite's + provider stamps the matching iss on the success redirect. `headless.iss` proves the callback + really carried the recorded issuer (completion alone could also mean the value was never + looked at -- the rejection arms of the family are the discriminators). Whether the SDK + consulted the advertisement flag is not asserted: rows 1 and 3 share one unconditional + comparison branch, so the flag's effect is only observable on the absent-iss arms. + """ + 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}/" + # One authorize and one token exchange: the flow proceeded directly, no rejection/retry loop. + 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: the comparison is RFC 9207 simple string comparison, and the client MUST NOT + apply scheme or host case folding, default-port elision, trailing-slash, or percent-encoding + normalization before comparing; the trailing-slash arm is pinned as the representative class + (the SDK's comparison is a single string inequality). Both spellings are harness literals: + the canned metadata carries the slash-explicit issuer and the provider stamps the slash-less + redirect iss. Natural metadata would instead source the slash from the SDK server's issuer + serialization -- the load-bearing, churn-prone arm of that difference -- whereas the client's + metadata parse preserves the served bytes and contributes nothing to it. + """ + 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, + app_shim=lambda app: shimmed_app(app, serve=canned_asm(iss_advertised=None)), + 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: advertised support + absent iss -> reject. The SDK's whole authorization-response + input is the callback's `AuthorizationCodeResult`, so the absence is constructed at that boundary + with `omit_iss` (the suite's AS emits iss on every success redirect; the redirect itself is + unchanged). No /token POST is recorded -- the authorization code is never exchanged. + """ + 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: no advertisement + absent iss -> proceed. The SDK server's `build_metadata` + never sets `authorization_response_iss_parameter_supported`, so the natural metadata route IS + the unadvertising AS; if it ever started advertising, this test would fail loudly (absent iss + + advertised -> the row-2 reject), so the precondition cannot silently rot. The absent iss is + constructed at the callback boundary with `omit_iss`. + """ + 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 + # Exactly one token exchange: the flow proceeded directly, no rejection/retry detour. + 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, and the point where the MCP spec deliberately exceeds RFC 9207's local-policy + provision: a present iss is validated regardless of advertisement. The natural AS metadata is + the unadvertising AS and the provider stamps the matching iss; `headless.iss` proves it was + present on the callback. The match arm alone cannot prove the comparison ran -- the same + entry's mismatch arm is pinned by `test_a_mismatched_iss_on_the_callback_aborts_the_flow`, + which drives a mismatched iss against the same unadvertising AS. + """ + 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 AS denies the authorize request, producing a real + RFC 6749 error redirect (`error=access_denied`, no code, no iss), and `iss_override` supplies + the mismatching issuer at the callback boundary -- the SDK never parses a callback URL, so its + whole authorization-response input is the callback's `AuthorizationCodeResult`. Raising the + iss mismatch in preference to "No authorization code received" (the arm the error-surfaces + test above pins) is the observable proving the validation ran on an error response. The + MUST-NOT-act-on-or-display half is not asserted: the callback contract carries no error + fields, so the negative would be vacuously true by construction (the manifest note records it). + """ + 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__() + + # The callback genuinely was an error response, and the tainted exchange never started. + 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_flow.py b/tests/interaction/auth/test_flow.py index e98735abf..c70732291 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,74 @@ 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 2026 Refresh Tokens section (SEP-2207) says clients that desire refresh tokens SHOULD + include `refresh_token` in their `grant_types` client metadata; the SDK satisfies the + SHOULD when the consumer says nothing. The metadata here is constructed without + `grant_types` -- deliberately not via `oauth_client_metadata()`, which sets it explicitly -- + and the recorded `/register` body is snapshotted in full so a field the default machinery + adds or drops fails. The completed flow proves the real AS accepted the registration. + """ + 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 other half of the SEP-2207 grant-types default: the metadata sets + `["authorization_code"]` -- deliberately not the default pair, so pass-through is + distinguishable from defaulting. Plain `==` against the consumer's own value (snapshotting + a value the test itself supplied would prove nothing); the completed flow proves the real + AS accepted the narrower grant set end to end. + """ + 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"] + + 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 72c079cfa..acce91090 100644 --- a/tests/interaction/auth/test_lifecycle.py +++ b/tests/interaction/auth/test_lifecycle.py @@ -14,8 +14,9 @@ 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 starlette.types import ASGIApp, Message, Receive, Scope, Send from mcp import MCPError from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider @@ -25,6 +26,7 @@ from tests.interaction._requirements import requirement from tests.interaction.auth._harness import ( REDIRECT_URI, + AppShim, InMemoryTokenStorage, RecordedRequest, auth_settings, @@ -135,6 +137,79 @@ 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). + + Choreography twin of `test_an_expired_access_token_is_transparently_refreshed_before_the_next_request`, + which pins the access-token/bearer side of the same single refresh; this test pins the + refresh-token side. The provider's refresh exchange rotates -- it mints a new refresh token + and consumes the presented one -- so after the flow, storage must hold a refresh token that + is not the one the client presented, is the one the AS actually minted, and is the only + live one: the client adopted the rotation instead of keeping the credential it sent. + """ + 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 + # The persisted refresh token is not the one the client sent: replacement happened... + assert storage.tokens.refresh_token != presented + # ...and it is the token the AS minted in the rotation, while the AS consumed the old one, + # so the stored credential is the only live 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, in + which case the client keeps the one it holds; the 2026 Refresh Tokens section (SEP-2207) + restates this as "MUST NOT assume refresh tokens will be issued". The provider models the + non-rotating AS: its refresh response carries only a new access token (`exclude_none` + serialization keeps the key genuinely absent from the wire) and the presented token stays + valid server-side. The preserved token alone could pass vacuously if the refresh response + were dropped entirely, so the adopted `expires_in` (the first token's was -3600) proves it + was not, and the single authorize/register pair proves the omission was treated as normal + rather than triggering a re-authorization. + """ + 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 + # Preserved through a response that omitted it: byte-identical to the one presented. + assert storage.tokens.refresh_token == form_body(token_posts[1])["refresh_token"] + # The refresh response WAS adopted (the first token's expires_in was -3600). + assert storage.tokens.expires_in == 3600 + + # The missing refresh_token triggered neither a re-authorization nor a 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 +220,8 @@ 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 SDK has no configurable retry counter -- the structural + per-send bound is pinned by `client-auth:stepup:retry-cap`. """ recorded, on_request = record_requests() provider = InMemoryAuthorizationServerProvider() @@ -208,7 +284,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. @@ -507,3 +584,161 @@ 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 spec's per-send retry limit is a SHOULD; the SDK's bound is structural, not a counter: + the auth flow performs one scope-union re-authorize, yields one retry, and its generator + ends, so the second `insufficient_scope` 403 is the final response and the legacy transport + surfaces it as the INTERNAL_ERROR stand-in. The shim 403s every authenticated `/mcp` POST + from the third onward -- the `list_tools` request -- because the legacy client silently + drops a non-2xx response to a notification POST, which would smear the property across two + sends (cross-send attempt memory is the separate deferred + `client-auth:stepup:attempt-tracking` entry). + """ + 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): + # The README-sanctioned non-collapsible shape: a sync `with` adjacent to 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")) + + # Exactly one step-up, carrying the SEP-2350 scope union -- the second 403 triggered no third. + 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 + + +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. + + Later authenticated GETs pass through the wrapped app. The status of every authenticated + GET's response (the shim's own 403 included) is appended to the returned list, and the + returned event is set when one of those responses starts with status 200 -- the reopened + stream -- so the test can wait on the SDK's background GET task without sleeping. + Response-status observation is unique to this one test, so the shim stays file-local. + """ + 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 + # Tail call: the reopened SSE stream stays open until the test's exit cancels it, + # so nothing may follow this await. + await app(scope, receive, recording_send) + + return wrapped + + return statuses, reopened, factory + + +@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 is a 2025-11-25 transport mechanism (removed at 2026-07-28) that this + suite's legacy-mode connect opens itself after `notifications/initialized`; `Client` cannot + observe it at all, which is why the file-local shim records each authenticated GET's + response status. Steps: + + 1. the SDK opens the GET stream and the shim 403s it with a wider-scope challenge; + 2. the auth flow re-authorizes with the SEP-2350 union and retries the GET inside the same + stream-open call -- the real bearer middleware, not the shim, accepts the upgraded token; + 3. the test waits for the reopened stream's 200 before acting, closing the only racy seam + (the GET task is started by the SDK, never awaited by the test); + 4. a post-reopen `list_tools` proves the session, auth-context lock, and upgraded token + remain usable by the foreground. + + The failure arm (a step-up that fails again on the GET leg) is deliberately 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" + + # The challenged open, then the server-accepted reopen. + assert statuses == [403, 200] + + # The same one-step-up bound and SEP-2350 union as the POST path. + 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" + + # The reopened stream carries the upgraded token, which is the one persisted to storage. + 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"] From 4b9bed0c2edd9e7af74c749ee2cee46b5a196698 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:11:00 +0000 Subject: [PATCH 12/16] Pin the pre-registered-credentials divergence and the DCR application_type pass-through Pre-registered credentials bound to a different issuer are silently discarded and re-registered - the path the spec blesses only for DCR-persisted credentials; for manually provisioned ones it says an error should surface. The new test pins the silent replacement (flow completes, no error, the seeded credential never presented, storage rebound to the current AS) under a recorded divergence scoped to the issuer-stamped arm; the unbound arm is a documented limitation in the entry note, since a mismatch cannot be detected for credentials that never recorded a binding. A consumer-set application_type of web on a loopback redirect - a value the derivation heuristic would never produce - reaches the /register body verbatim, distinguishing pass-through from any future heuristic. Also: the last caching deferral gains its greppable re-open token, the omit_iss precedence is documented in the harness, and the app-type heuristic note cross-references its tested override sibling. 944 -> 946 cells; suite green three runs. --- tests/interaction/_requirements.py | 51 +++++++++++++++++++++++- tests/interaction/auth/_harness.py | 4 +- tests/interaction/auth/test_flow.py | 33 +++++++++++++++ tests/interaction/auth/test_lifecycle.py | 47 ++++++++++++++++++++++ 4 files changed, 131 insertions(+), 4 deletions(-) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 9a44f035c..9a44481b0 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -3419,7 +3419,7 @@ def __post_init__(self) -> None: added_in="2026-07-28", deferred=( "Not implemented in the SDK: the per-receipt freshness clock and independent expiry " - "need a client response cache that records per-page receipt times; none exists. The " + "need a SEP-2549 response cache that records per-page receipt times; none exists. The " "carriage half (each page carries its own ttlMs, set per handler invocation) is " "expressible today and can be split out if wanted." ), @@ -5299,7 +5299,8 @@ def __post_init__(self) -> None: "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." + "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') " @@ -5307,6 +5308,24 @@ def __post_init__(self) -> None: "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=( @@ -5346,6 +5365,34 @@ def __post_init__(self) -> None: 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:invalid-client-clears-all": Requirement( source="sdk", behavior=( diff --git a/tests/interaction/auth/_harness.py b/tests/interaction/auth/_harness.py index 8731cb11d..4020edd15 100644 --- a/tests/interaction/auth/_harness.py +++ b/tests/interaction/auth/_harness.py @@ -147,8 +147,8 @@ class HeadlessOAuth: rejection path. `omit_iss`: when set, `callback_handler` returns no iss regardless of what the redirect - carried, so tests can drive the missing-iss paths (the `iss_override` sentinel cannot - express absence). + carried or what `iss_override` supplies (omission wins when both are set), so tests can + drive the missing-iss paths (the `iss_override` sentinel cannot express absence). """ def __init__( diff --git a/tests/interaction/auth/test_flow.py b/tests/interaction/auth/test_flow.py index c70732291..3e66baa27 100644 --- a/tests/interaction/auth/test_flow.py +++ b/tests/interaction/auth/test_flow.py @@ -283,6 +283,39 @@ async def test_dcr_sends_consumer_set_grant_types_verbatim() -> None: 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. + + The application-type section (SEP-837) says web applications SHOULD register + `application_type: 'web'`; the SDK transmits a consumer-set value verbatim. The metadata + sets `'web'` against the suite's loopback redirect URI -- deliberately the value + redirect-URI derivation would NOT produce, so verbatim pass-through stays distinguishable + from any future derivation strategy (which may only fill the omitted case, pinned as + deferred on `client-auth:dcr:app-type-heuristic`). + """ + 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() + + # The flow completed: the real AS accepted a registration carrying `application_type: "web"`. + 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 acce91090..b1d711ac9 100644 --- a/tests/interaction/auth/test_lifecycle.py +++ b/tests/interaction/auth/test_lifecycle.py @@ -319,6 +319,53 @@ 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: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 2026 binding section says a client holding pre-registered credentials SHOULD surface an + error when the authorization server no longer matches the issuer they were registered with, + rather than silently attempting to use them. The SDK cannot tell pre-registered from + DCR-persisted credentials, so the issuer-stamped credential takes the DCR + discard-and-re-register path and the flow completes with no error -- a known divergence, + recorded on the requirement; the mismatched credential is at least never presented. + """ + 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() + + # The flow completed: no error surfaced -- the divergent observable everything below qualifies. + assert result.tools[0].name == "echo" + + # The replacement happened via a silent dynamic registration. + assert path_counts(recorded)[("POST", "/register")] == 1 + + # The mismatched credential was never presented anywhere: the SDK does not "silently attempt + # to use" it -- only the error half of the SHOULD is missed. + 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() + + # The pre-registered identity is gone and its replacement is bound to the current AS: + # "silently replaced" made concrete rather than inferred from a changed client_id. + 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. From e189cebbfade3b59391c43d700dd4cb020551ab3 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:59:24 +0000 Subject: [PATCH 13/16] Track the full deferred surface: 64 entries registered ahead of their capability Every behaviour the analysis identified that the SDK cannot yet express now has a manifest entry with a deferral stating exactly what is missing at this commit: the subscriptions/listen runtime family (types vendored, runtime absent, all carrying the greppable re-open token), the requestState integrity obligations (application-owned, the SDK passes opaque state through), the extension declaration surface, the legacy 2025 jsonschema wrap family (era-bound to the cells where it applies), the hosting-side auth surfaces, stdio-2026 service, and the cross-AS credential obligations (m2m credentials re-spelled into the as-binding family, targeting the spec obligation rather than another SDK's knob). Deferral reasons are re-grounded at this commit - no stale premises, no PR numbers, no internal references; two stale source attributions upgraded to the spec URLs that carry the requirement verbatim. Cells unchanged (946): deferred entries register coverage debt without running anything. --- tests/interaction/_requirements.py | 1066 ++++++++++++++++++++++++++++ 1 file changed, 1066 insertions(+) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 9a44481b0..8486ed60e 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,24 @@ 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 implemented in the SDK: capabilities.extensions has wire types but no runtime -- " + "neither side can declare extensions through the public API (_build_capabilities in " + "src/mcp/client/session.py never sets the field; get_capabilities in " + "src/mcp/server/lowlevel/server.py takes no extensions argument), and nothing in src/mcp " + "reads it (the one planned consumer is the in-code TODO on the resultType gate in " + "src/mcp/server/runner.py), so a supporting party's revert-or-reject fallback cannot be " + "constructed or observed." + ), + ), "lifecycle:initialize:basic": Requirement( source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization", behavior=( @@ -364,6 +411,32 @@ def __post_init__(self) -> None: 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:custom-supported-versions": Requirement( + source="sdk", + behavior=( + "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." + ), + ), "lifecycle:stateless:request-envelope": Requirement( source=f"{SPEC_2026_BASE_URL}/basic#_meta", behavior=( @@ -736,6 +809,29 @@ def __post_init__(self) -> None: ), ), ), + "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( source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements", behavior=( @@ -775,6 +871,45 @@ 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: subscriptions/listen has wire types and a lowlevel handler " + "hook, but no runtime -- no server machinery tears down a listen stream, and the only " + "notifications/cancelled send machinery is the client-side courtesy cancel on abandoning a " + "server-initiated request (src/mcp/shared/jsonrpc_dispatcher.py), so the teardown emission " + "cannot be driven." + ), + 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: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: subscriptions/listen has wire types and a lowlevel handler " + "hook, but no runtime -- no server machinery tears down a listen stream, so the one " + "permitted emission cannot be driven; 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." + ), + ), "protocol:cancel:server-survives": Requirement( source="sdk", behavior="The session continues to serve new requests after an earlier request was cancelled.", @@ -807,6 +942,29 @@ def __post_init__(self) -> None: ), 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); and the stdio " + "stream-loop server cannot serve 2026-era requests at all -- the legacy loop's init gate " + "(src/mcp/server/runner.py) rejects envelope-bearing requests with INVALID_PARAMS, so no " + "2026 stdio exchange exists on which the wire act could be observed (the same gap recorded " + "on transport:stdio:dual-era-serving)." + ), + 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( source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#error-handling", behavior=( @@ -825,6 +983,59 @@ 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 only capability-check surface is the server-side ServerSession.check_client_capability " + "boolean (src/mcp/server/session.py), which no send path consults." + ), + 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.", @@ -1233,6 +1444,35 @@ def __post_init__(self) -> None: # ═══════════════════════════════════════════════════════════════════════════ # Tools: SDK guarantees # ═══════════════════════════════════════════════════════════════════════════ + "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.", @@ -1372,6 +1612,118 @@ def __post_init__(self) -> None: 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.", @@ -1423,6 +1775,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=( @@ -1475,6 +1842,52 @@ def __post_init__(self) -> None: "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) # ═══════════════════════════════════════════════════════════════════════════ @@ -1513,6 +1926,25 @@ def __post_init__(self) -> None: ), 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( source="sdk", behavior="Context.read_resource reads a resource registered on the same server from inside a tool.", @@ -1661,6 +2093,71 @@ def __post_init__(self) -> None: "acknowledgment, narrows the filter, or routes notifications onto a listen stream." ), ), + "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 implemented in the SDK: subscriptions/listen has wire types and a lowlevel handler hook, " + "but no runtime -- there is no client-side listen API, and no server machinery emits the " + "acknowledgment, narrows the filter, or routes notifications onto a listen stream. There is " + "no subscription-capacity knob either." + ), + ), + "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: subscriptions/listen has wire types and a lowlevel handler hook, " + "but no runtime -- there is no client-side listen API, and no server machinery emits the " + "acknowledgment, narrows the filter, or routes notifications onto a listen stream. No SDK " + "code stamps or reads the io.modelcontextprotocol/subscriptionId key." + ), + ), + "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: subscriptions/listen has wire types and a lowlevel handler hook, " + "but no runtime -- there is no client-side listen API, and no server machinery emits the " + "acknowledgment, narrows the filter, or routes notifications onto a listen stream. No SDK " + "code stamps or reads the io.modelcontextprotocol/subscriptionId key." + ), + ), + "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 implemented in the SDK: subscriptions/listen has wire types and a lowlevel handler hook, " + "but no runtime -- there is no client-side listen API, and no server machinery emits the " + "acknowledgment, narrows the filter, or routes notifications onto a listen stream. There is " + "no server-side teardown path that could emit the closing result." + ), + ), "subscriptions:listen:honored-filter-narrows-to-advertised": Requirement( source=f"{SPEC_2026_BASE_URL}/basic/patterns/subscriptions#acknowledgment", behavior=( @@ -1676,6 +2173,50 @@ def __post_init__(self) -> None: "acknowledgment, narrows the filter, or routes notifications onto a listen stream." ), ), + "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 implemented in the SDK: subscriptions/listen has wire types and a lowlevel handler hook, " + "but no runtime -- there is no client-side listen API, and no server machinery emits the " + "acknowledgment, narrows the filter, or routes notifications onto a listen stream. No SDK " + "code stamps or reads the io.modelcontextprotocol/subscriptionId 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 implemented in the SDK: subscriptions/listen has wire types and a lowlevel handler hook, " + "but no runtime -- there is no client-side listen API, and no server machinery emits the " + "acknowledgment, narrows the filter, or routes notifications onto a listen stream." + ), + ), + "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: subscriptions/listen has wire types and a lowlevel handler hook, " + "but no runtime -- there is no client-side listen API, and no server machinery emits the " + "acknowledgment, narrows the filter, or routes notifications onto a listen stream." + ), + ), "resources:templates:list": Requirement( source=f"{SPEC_BASE_URL}/server/resources#resource-templates", behavior=( @@ -1748,6 +2289,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=( @@ -1913,6 +2467,18 @@ 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 -- PromptManager (src/mcp/server/mcpserver/prompts/manager.py) exposes no update " + "or remove 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.", @@ -2734,6 +3300,17 @@ def __post_init__(self) -> None: "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=( @@ -2867,6 +3444,23 @@ def __post_init__(self) -> None: ), added_in="2026-07-28", ), + "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", + deferred=( + "Not implemented in the SDK: the client never rejects an unrecognized resultType -- " + "ResultType is a deliberately open Literal-or-str union (src/mcp-types/mcp_types/_types.py), " + "the 2026-07-28 wire surface types resultType as a bare str, and the client's only " + "result-kind dispatch is isinstance(result, InputRequiredResult) " + "(src/mcp/client/session.py), so an unrecognized value round-trips and is surfaced on the " + "returned result unchanged (the in-code TODO in src/mcp/server/runner.py records the " + "missing rejection)." + ), + ), "mrtr:input-responses:invalid-rejected": Requirement( source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#error-handling", behavior=( @@ -2934,6 +3528,38 @@ def __post_init__(self) -> None: ), added_in="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 implemented in the SDK: requestState integrity protection is left to the server " + "application -- the SDK never constructs an InputRequiredResult or mints request_state, and " + "delivers it to the handler as an opaque string (CallToolRequestParams.request_state; the " + "mcpserver Context.request_state property); the only integrity-comparison code in src/mcp/ " + "is the OAuth client-secret check (src/mcp/server/auth/middleware/client_auth.py), so a " + "test's verification logic would be fixture code pinning nothing of the SDK." + ), + ), + "mrtr:request-state:replay-binding": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#server-requirements-basic-workflow", + behavior=( + "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: there is no SDK-defined requestState payload -- the SDK never " + "mints request_state (its only appearances in src/mcp/client/ are the opaque echo on retry) " + "and has no signing or verification surface into which a principal, TTL, or request digest " + "could be bound; replay binding is the server application's encoding of its own opaque blob." + ), + ), "mrtr:request-state:scoped-to-originating-request": Requirement( source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#client-requirements-basic-workflow", behavior=( @@ -3140,6 +3766,20 @@ def __post_init__(self) -> None: "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 API 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", behavior=( @@ -3153,6 +3793,20 @@ 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 only registration mutation surface is " + "MCPServer.remove_tool (src/mcp/server/mcpserver/server.py)." + ), + ), "mcpserver:list-changed:debounce": Requirement( source="sdk", behavior=( @@ -3164,6 +3818,34 @@ 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=( @@ -4017,6 +4699,46 @@ def __post_init__(self) -> None: # ═══════════════════════════════════════════════════════════════════════════ # 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=( + "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:as-router": Requirement( source="sdk", behavior=( @@ -4062,6 +4784,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=( @@ -4123,6 +4861,25 @@ def __post_init__(self) -> None: ), ), ), + "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=( @@ -4198,6 +4955,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 # ═══════════════════════════════════════════════════════════════════════════ @@ -4852,6 +5699,47 @@ def __post_init__(self) -> None: "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=( @@ -5112,6 +6000,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 # ═══════════════════════════════════════════════════════════════════════════ @@ -5195,6 +6147,39 @@ def __post_init__(self) -> None: '"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=( @@ -5393,6 +6378,32 @@ def __post_init__(self) -> None: ), ), ), + "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=( @@ -5643,12 +6654,51 @@ def __post_init__(self) -> None: "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=( @@ -5799,6 +6849,22 @@ 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.", From fb7e31e25da627130715aae30dd3c816196f0ade Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:43:16 +0000 Subject: [PATCH 14/16] Complete the planned 2026-07-28 coverage: the final 27 tests JSON Schema handling: prefixItems vocabulary enforcement, the 2020-12 default dialect (with a declared-dialect violation arm proving validation follows the tag), falsy structured content reaching the validator, and non-object outputs - plus the null structured-content divergence: a tool legitimately returning JSON null is indistinguishable from one returning nothing (the model collapses both to None and the dump drops them), so a spec-legal value raises; pinned with the fix direction recorded (absent-vs-null at the model layer, not a looser client check). MRTR edges: a retry missing a requested key is re-prompted rather than errored, unknown response keys are ignored, the resultType seam (absent means complete, input_required is never masked, unrecognized values rejected - flipping the deferred entry to a pinned divergence), and the max-tokens pass-through. Auth: refresh tokens are not reused across an AS change, CIMD documents are portable, the all-scopes single challenge, and the bundled AS registration echo dropping application_type (pinned against its ledger row). The scatter: list results are connection-independent and deterministically ordered, an empty-string cursor is a valid cursor (a 2026 rule the changelog never mentioned), cancellation stops notification delivery, SSE comment lines are ignored, legacy error codes pass through opaquely, sampling messages are not retained across rounds, multi-content reads, path-traversal rejection, and resource links in prompt content. 26 entries minted, one flipped, four divergences recorded. 946 -> 1009 cells, every node accounted; suite green three consecutive runs. --- tests/interaction/_requirements.py | 486 +++++++++++++++++- tests/interaction/auth/test_as_handlers.py | 34 +- tests/interaction/auth/test_bearer.py | 31 ++ tests/interaction/auth/test_lifecycle.py | 113 +++- .../interaction/lowlevel/test_cancellation.py | 69 +++ tests/interaction/lowlevel/test_mrtr.py | 331 +++++++++++- tests/interaction/lowlevel/test_pagination.py | 31 ++ tests/interaction/lowlevel/test_prompts.py | 48 ++ tests/interaction/lowlevel/test_resources.py | 36 ++ tests/interaction/lowlevel/test_sampling.py | 140 ++++- tests/interaction/lowlevel/test_tools.py | 314 +++++++++++ tests/interaction/mcpserver/test_prompts.py | 41 ++ tests/interaction/mcpserver/test_resources.py | 87 ++++ tests/interaction/mcpserver/test_tools.py | 75 +++ .../transports/test_client_transport_http.py | 50 +- 15 files changed, 1866 insertions(+), 20 deletions(-) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 8486ed60e..4ed4145d1 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -788,8 +788,9 @@ def __post_init__(self) -> None: "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 when the " - "cancellation add-batch lands them." + "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 " + "(and, for stdio, 2026-era serving) exists." ), ), "protocol:cancel:handler-abort-propagates": Requirement( @@ -893,6 +894,38 @@ def __post_init__(self) -> None: "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=( @@ -1101,6 +1134,22 @@ def __post_init__(self) -> None: "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( source=f"{SPEC_BASE_URL}/basic/utilities/tasks#related-task-metadata", behavior="Messages may carry related-task _meta associating them with a task.", @@ -1427,6 +1476,35 @@ def __post_init__(self) -> None: 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=( @@ -1444,6 +1522,93 @@ 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=( @@ -2011,6 +2176,22 @@ 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.", @@ -2031,6 +2212,35 @@ def __post_init__(self) -> None: 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.", @@ -2348,6 +2558,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).", @@ -2417,6 +2639,22 @@ def __post_init__(self) -> None: 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.", @@ -2733,6 +2971,21 @@ def __post_init__(self) -> None: ), 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", behavior=( @@ -2944,6 +3197,20 @@ def __post_init__(self) -> None: 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=( @@ -3444,6 +3711,42 @@ def __post_init__(self) -> None: ), 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=( @@ -3451,14 +3754,16 @@ def __post_init__(self) -> None: "surfaced as a normal result." ), added_in="2026-07-28", - deferred=( - "Not implemented in the SDK: the client never rejects an unrecognized resultType -- " - "ResultType is a deliberately open Literal-or-str union (src/mcp-types/mcp_types/_types.py), " - "the 2026-07-28 wire surface types resultType as a bare str, and the client's only " - "result-kind dispatch is isinstance(result, InputRequiredResult) " - "(src/mcp/client/session.py), so an unrecognized value round-trips and is surfaced on the " - "returned result unchanged (the in-code TODO in src/mcp/server/runner.py records the " - "missing rejection)." + divergence=Divergence( + note=( + "The client never rejects an unrecognized resultType: ResultType is a " + "deliberately open Literal-or-str union (src/mcp-types/mcp_types/_types.py), " + "the 2026-07-28 wire surface types resultType as a bare str, and the client's " + "only result-kind dispatch is isinstance(result, InputRequiredResult) " + "(src/mcp/client/session.py), so an unrecognized value round-trips and is " + "surfaced on the returned result unchanged -- on both eras (the in-code TODO " + "in src/mcp/server/runner.py records the missing rejection)." + ), ), ), "mrtr:input-responses:invalid-rejected": Requirement( @@ -3482,6 +3787,33 @@ def __post_init__(self) -> None: ), 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=( @@ -3876,8 +4208,33 @@ 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." ), ), # ═══════════════════════════════════════════════════════════════════════════ @@ -4861,6 +5218,26 @@ 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." + ), + ), + ), "hosting:auth:scope:no-offline-access": Requirement( source=f"{SPEC_2026_BASE_URL}/basic/authorization#refresh-tokens", behavior=( @@ -4946,6 +5323,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=( @@ -5918,6 +6324,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).", @@ -6378,6 +6800,46 @@ def __post_init__(self) -> None: ), ), ), + "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=( diff --git a/tests/interaction/auth/test_as_handlers.py b/tests/interaction/auth/test_as_handlers.py index 5cb4e92d8..5cd48e4df 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,34 @@ 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. + + Pins the known gap recorded on the requirement (divergence): the registration handler's + field-by-field passthrough omits `application_type`, so the model default fills the echo + where RFC 7591 §3.2.1 requires the registered value -- and the SDK OAuth client adopts the + echo into persisted storage, so the corruption is client-visible end to end. When the + one-line passthrough fix lands this test fails: re-pin the echo to `"web"`, delete the + Divergence, and add the echo assertion to + `test_dcr_sends_a_consumer_set_application_type_verbatim` (test_flow.py) per the + requirement's note. + """ + 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()) + + # Registration itself succeeds: the divergence is in the echo, not in acceptance. + assert response.status_code == 201 + body = response.json() + # The request carried "web" (the metadata above); the echo says "native" -- the pinned gap. + 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_bearer.py b/tests/interaction/auth/test_bearer.py index 55029c9f4..277924b62 100644 --- a/tests/interaction/auth/test_bearer.py +++ b/tests/interaction/auth/test_bearer.py @@ -156,6 +156,37 @@ 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 one scope per round trip. + + The spec says servers SHOULD include all scopes required for the current operation in a + single challenge; the bearer middleware instead checks required scopes in order and 403s on + the first missing one, naming only that scope in `error_description` (and emitting no + `scope` parameter at all -- the sibling `hosting:auth:scope-403` divergence). Pins the known + gap recorded on the requirement (divergence); when the middleware aggregates, this test + fails -- re-pin to a challenge naming both scopes and delete the Divergence. The two-scope + app is built inline: the file's `protected` fixture is single-scope, and a two-scope deficit + is this entry's distinct observable. + """ + 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 + # Full-dict equality: only the FIRST missing scope (registration order) is named, and no + # `scope` key appears. + 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_lifecycle.py b/tests/interaction/auth/test_lifecycle.py index b1d711ac9..f17675064 100644 --- a/tests/interaction/auth/test_lifecycle.py +++ b/tests/interaction/auth/test_lifecycle.py @@ -21,7 +21,7 @@ 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 ( @@ -319,6 +319,117 @@ 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. + + Choreography twin of the as-binding discard test above, pinning the token half of the same + SEP-2352 branch: storage carries both an old-issuer client registration and that server's + tokens. The stale access token is presented once to the resource server (reload treats it + as live), the 401 triggers the binding check, and the discard drops tokens together with + the credentials -- so the stale refresh token reaches no endpoint of the new authorization + server and the only token exchange is the fresh authorization-code grant. The requirement's + note carries the refresh-ordering hazard this test is the regression net for. + """ + recorded, on_request = record_requests() + provider = InMemoryAuthorizationServerProvider() + # Built directly rather than via `seeded_client`: the previous authorization server no + # longer exists, so its 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() + + # The only token exchange is the fresh code grant: no refresh grant ever fired. + token_posts = find(recorded, "POST", "/token") + assert [form_body(r)["grant_type"] for r in token_posts] == snapshot(["authorization_code"]) + + # The MUST NOT, swept exhaustively: the stale refresh token reached no endpoint at all. + for r in recorded: + assert "stale-refresh-token" not in r.content.decode() + assert "stale-refresh-token" not in r.url.query.decode() + + # Scenario non-vacuity: the stale access token WAS presented and refused -- to the resource + # server only, never to an authorization-server endpoint. + stale_bearer_paths = [r.path for r in recorded if r.headers.get("authorization") == "Bearer stale-access-token"] + assert stale_bearer_paths == ["/mcp"] + + # The migration branch actually engaged: the discard forced one re-registration. + assert path_counts(recorded)[("POST", "/register")] == 1 + + # Fresh credentials and tokens are in place, and the flow completed on them. + 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. + + The spec's portability sentence: client IDs based on Client ID Metadata Documents are + self-hosted HTTPS URLs resolved by the authorization server on demand, so "no + re-registration is needed when the authorization server changes". Storage carries CIMD + credentials stamped with the OLD issuer -- the migration precondition -- and the provider is + pre-seeded with the URL client_id, the harness stand-in for on-demand resolution (the SDK + server has no CIMD-aware client lookup of its own). No AS-metadata shim: the + `client_id_metadata_document_supported` flag gates only the registration-path selection, + which pre-seeded credentials never reach. + """ + 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() + + # "No re-registration is needed when the authorization server changes" -- the sentence itself. + assert find(recorded, "POST", "/register") == [] + + # The SAME metadata-document URL is presented as client_id at the new authorization server. + assert headless.authorize_url is not None + assert authorize_params(headless.authorize_url)["client_id"] == CIMD_URL + + # Portability is end to end, not a stalled flow: one fresh code exchange completed it. + assert result.tools[0].name == "echo" + assert [form_body(r)["grant_type"] for r in find(recorded, "POST", "/token")] == snapshot(["authorization_code"]) + + # The credentials survived untouched. The stale issuer stamp is informational on CIMD + # credentials and deliberately not re-stamped (see the requirement's note); a future + # 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. diff --git a/tests/interaction/lowlevel/test_cancellation.py b/tests/interaction/lowlevel/test_cancellation.py index 247e1135a..510143b84 100644 --- a/tests/interaction/lowlevel/test_cancellation.py +++ b/tests/interaction/lowlevel/test_cancellation.py @@ -87,6 +87,75 @@ 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). + + The 2026-07-28 stdio page says the receiver of a cancellation MUST NOT send any further + messages for the cancelled request. The response half of "any further messages" is the + divergence pinned on protocol:cancel:in-flight (both seats answer with a code-0 error); this + test pins the notifications half. The handler attempts a progress send during its cancellation + unwind so the negative is proved enforced, not merely unexercised: the attempt itself is + cancelled before transmitting, and the caller's progress callback saw only the + pre-cancellation notification. + """ + 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; nothing ever sets this event + except anyio.get_cancelled_exc_class(): + handler_cancelled.set() + try: + # The MUST NOT under test: a send attempted during the cancellation unwind. + 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: the unwind cancels the send before it transmits + raise NotImplementedError # unreachable: the wait above never completes normally + + 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: + # The error shape (code 0, "Request cancelled") is protocol:cancel:in-flight's + # pinned divergence and is deliberately not re-asserted here. + 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() + + # Request-scoped progress rides the same ordered stream as the error response that unblocked + # the call, so a transmitted "too late" notification would already have been delivered. + 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_mrtr.py b/tests/interaction/lowlevel/test_mrtr.py index 26e2850da..4eef161ed 100644 --- a/tests/interaction/lowlevel/test_mrtr.py +++ b/tests/interaction/lowlevel/test_mrtr.py @@ -11,7 +11,11 @@ params can originate. The directionality-edge tests pin the 2026 boundary itself: the retired push APIs fail loudly (except the in-memory request-scoped leg, a pinned divergence), embedded input requests cross un-gated to the refusing client driver, and a completed exchange's trace -carries client requests and server responses only. +carries client requests and server responses only. The error-handling SHOULDs the auto driver +can never trigger (a missing or unrequested ``inputResponses`` key) are driven through the +manual ``allow_input_required`` loop, and a trailing scripted-peer section plays the server by +hand for result bodies a real ``Server`` cannot emit: an absent ``resultType`` (the +backward-compat default) and an unrecognized ``resultType`` value (a pinned divergence). """ from typing import Any @@ -31,12 +35,16 @@ ClientCapabilities, CreateMessageRequest, CreateMessageRequestParams, + DiscoverResult, ElicitRequest, ElicitRequestFormParams, ElicitResult, ErrorData, + Implementation, + InitializeResult, InputRequiredResult, JSONRPCError, + JSONRPCNotification, JSONRPCRequest, JSONRPCResponse, ListRootsRequest, @@ -45,17 +53,19 @@ 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 +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.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 @@ -300,6 +310,40 @@ async def answer_again(context: ClientRequestContext, params: types.ElicitReques 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-surface tools/call that has not opted into the manual loop raises the SDK's + guidance error when the server answers ``input_required`` -- the interim never surfaces as an + empty-content success (spec: clients use ``resultType`` to determine how to parse the + result; the error shape itself is SDK-defined). Constructed directly on the in-memory 2026 + cell, the rounds-cap shape above: the claim is body-level, and the requirement's legacy-axis + half is recorded on its note rather than pinned. + """ + 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: + # Inside the connect block: unwinding through Client.__aexit__ would wrap the error in + # ExceptionGroups (task-group teardown), and pytest.raises would miss the bare type. + with pytest.raises(RuntimeError) as exc_info: + await client.session.call_tool("ask", {}) + + # The SDK's own deliberate guidance text; the snapshot also disambiguates the bare + # RuntimeError from any other. + 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: """A handler-built InputRequiredResult with neither inputRequests nor requestState cannot @@ -378,6 +422,146 @@ async def answer_roots(context: ClientRequestContext) -> ListRootsResult: 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 that omits one of the requested ``inputResponses`` keys is answered with a new + InputRequiredResult naming the missing key, not an error (spec SHOULD). The re-prompt + decision itself belongs to the test's handler -- the spec binds the server application; the + SDK obligations pinned are that the partial map passes validation, reaches the handler + unmodified, and the re-prompt interim rides the loop as an ordinary input_required round. + + Driven through the manual ``client.session.call_tool(..., allow_input_required=True)`` loop: + the auto driver answers every requested key, so a missing key is unconstructible through it. + """ + seen: list[set[str] | None] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + # Live (not NotImplementedError): the client's output-schema cache refresh invokes + # tools/list right after the completing tools/call result. + 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 + # The spec's recovery path: re-prompt for the missing key rather than erroring, + # threading round 1's answer through the state (the stateless-server pattern). + 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"} + # The SHOULD's observable: the partial retry comes back as a fresh InputRequiredResult + # naming only the missing key -- not an exception, not an error result. + 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, + ) + + # Both the partial round and the re-prompt round were productive: round 1's answer and the + # re-prompted answer meet in the terminal result. + assert result == snapshot(CallToolResult(content=[TextContent(text="one+two")])) + # The partial map arrived as sent: a future SDK that filtered or rejected partial + # inputResponses maps fails here consciously. + 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 a response under a key the server never requested completes normally, + the server using only the recognized key (spec SHOULD: ignore information it does not + recognize or need). The ignoring itself happens in the test's handler, where the spec's + judgement lives; the SDK half pinned here is that the stray entry passes validation and is + delivered unfiltered, so a future filter-before-dispatch change fails consciously (the + requirement note records that filtering would equally satisfy the SHOULD). + + Manual loop again: the auto driver builds responses exclusively from the server's own + ``inputRequests`` keys, so an extra key is unconstructible through it. + """ + seen: list[set[str] | None] = [] + + async def list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None + ) -> types.ListToolsResult: + # Live (not NotImplementedError): the client's output-schema cache refresh invokes + # tools/list right after the completing tools/call result. + 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", + {}, + # The stray value is structurally VALID -- its unknown-ness lives in the key alone, + # keeping this disjoint from the malformed-value claim of 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")])) + # Delivered, not filtered: the stray key reached the handler intact. + 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 handler calling the retired push API on a 2026 connection gets a typed, catchable local @@ -936,3 +1120,146 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara error = JSONRPCError.model_validate(response.json()) assert error.error == snapshot(ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="")) + + +# --- scripted server peer: result bodies a real Server cannot emit --- + + +@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 and surfaces as the normal + terminal result, resultType "complete" (spec MUST: for backward compatibility with servers + implementing earlier protocol versions, clients treat an absent resultType as "complete"). + + The test plays the server by hand over memory streams on a scripted 2025 handshake: a real + legacy SDK server omits resultType only incidentally (its 2025 serializer drops the field), + so only a byte-controlled body makes the absence explicit and assertable rather than an + artifact of the current serializer. + """ + + 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 refreshes its output-schema cache right after a successful call result; a + # peer that stops at the call response 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; the full-object equality + # proves the body surfaced as an ordinary terminal result. + assert result.result_type == "complete" + assert result == snapshot(CallToolResult(content=[TextContent(text="plain")])) + + +@requirement("protocol:result-type:unrecognized-invalid") +async def test_an_unrecognized_result_type_value_is_surfaced_unchanged_instead_of_treated_as_invalid() -> None: + """PINS A KNOWN GAP: a resultType of any value unrecognized by the client MUST be considered + invalid (spec), but the client's open ResultType union accepts any string and its only + result-kind dispatch is the InputRequiredResult isinstance check, so the bogus value + round-trips and the body surfaces as a normal successful result. See the requirement's + divergence; when the client starts rejecting unrecognized resultType values, re-pin this to + the typed rejection and delete the Divergence. + + The test plays the server by hand over memory streams against a pinned-2026 ClientSession: + the typed Server's result models cannot author an arbitrary resultType, and Client has no + public connect path over raw scripted streams. + """ + + 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)) + + call = await server_read.receive() + assert isinstance(call, SessionMessage) + assert isinstance(call.message, JSONRPCRequest) + assert call.message.method == "tools/call" + # "bogus" is in no core or extension vocabulary -- exactly the unrecognized value the + # MUST addresses; the content rides along to prove the body surfaces as a normal result. + await server_write.send( + respond(call.message.id, {"resultType": "bogus", "content": [{"type": "text", "text": "still here"}]}) + ) + + # The post-call output-schema cache refresh (same choreography fact as the test above). + 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"}}], + "resultType": "complete", + "ttlMs": 0, + "cacheScope": "private", + }, + ) + ) + + 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, client_info=Implementation(name="cli", version="0")) as session, + ): + task_group.start_soon(scripted_server, server_streams) + 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.call_tool("x", {}) + + # The divergent observable, pinned: the unrecognized discriminator survives the round + # trip unchanged and the body is a plain successful CallToolResult, 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..835832998 100644 --- a/tests/interaction/lowlevel/test_pagination.py +++ b/tests/interaction/lowlevel/test_pagination.py @@ -58,6 +58,37 @@ 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 is surfaced as "" -- distinct from absent -- and passes back verbatim as a cursor. + + Spec-mandated (2026-07-28): an empty string is a valid cursor and MUST NOT be treated as the + end of results. The SDK's share of the MUST is preserving the empty-string/absent distinction + on both legs; whether to stop paging is the caller's decision, which only stays correct + because the distinction survives. + """ + 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 == "" + # Identity, not a snapshot: the handler received back exactly the "" it issued, proving the + # empty string survived serialization on both legs and was not coerced or dropped. + 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 c8bb43262..d1c366cc9 100644 --- a/tests/interaction/lowlevel/test_prompts.py +++ b/tests/interaction/lowlevel/test_prompts.py @@ -20,6 +20,7 @@ Prompt, PromptArgument, PromptMessage, + ResourceLink, TextContent, TextResourceContents, ) @@ -197,6 +198,53 @@ 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 prompt message can carry resource_link content; the URI and descriptive fields reach the client intact. + + Spec-mandated: prompt messages MAY include links to resources -- a URI the client can fetch, + without embedding the contents. The full-result snapshot pins the discriminator, the URI, and + every descriptive field. Fetching the linked URI is client-application behaviour, not the SDK's. + """ + + 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. diff --git a/tests/interaction/lowlevel/test_resources.py b/tests/interaction/lowlevel/test_resources.py index fa31fa94b..2000d7ec5 100644 --- a/tests/interaction/lowlevel/test_resources.py +++ b/tests/interaction/lowlevel/test_resources.py @@ -146,6 +146,42 @@ async def read_resource(ctx: ServerRequestContext, params: types.ReadResourceReq ) +@requirement("resources:read:multiple-contents") +async def test_read_resource_returns_multiple_contents_in_order(connect: Connect) -> None: + """A resources/read result carrying several contents entries reaches the client intact and in order. + + Spec-mandated: servers MAY return multiple resource contents for a single read (e.g. a + directory read returning multiple files); the SDK surfaces the list verbatim. The mixed + text/blob list proves heterogeneous contents coexist; the full-result snapshot pins order, + URIs, MIME types, and payloads ("aW1n" is b"img"). + """ + + 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_read_resource_unknown_uri_is_protocol_error(connect: Connect) -> None: """A handler that rejects an unrecognised URI with MCPError produces a JSON-RPC error. diff --git a/tests/interaction/lowlevel/test_sampling.py b/tests/interaction/lowlevel/test_sampling.py index 512b04923..90d7e2d3f 100644 --- a/tests/interaction/lowlevel/test_sampling.py +++ b/tests/interaction/lowlevel/test_sampling.py @@ -750,13 +750,15 @@ async def sampling_callback( @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: """Model preferences (hints and the cost/speed/intelligence priorities), the system prompt, - and the includeContext value supplied in an embedded sampling/createMessage request all reach - the client sampling callback unchanged. Spec-mandated (client/sampling #model-preferences, - #system-prompt, #context-inclusion). + the includeContext value, and the maxTokens cap supplied in an embedded + sampling/createMessage request all reach the client sampling callback unchanged. + Spec-mandated (client/sampling #model-preferences, #system-prompt, #context-inclusion, + #sampling-parameters). """ SENT = CreateMessageRequestParams( messages=[SamplingMessage(role="user", content=TextContent(text="Pick a model."))], @@ -805,3 +807,135 @@ async def sampling_callback( 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. + + The 2026-07-28 sampling page says the messages list SHOULD NOT be retained between separate + requests; this is the MRTR face of that rule (the 2025-11-25 push face has its own test + below). Two productive rounds embed different single-message requests: a retaining or merging + client would show round one's message inside round two's list. The second decorator records + the incidental re-proof of the embed round trip (the callback's result reaches the retried + handler) and selects the 2026 cells. + """ + 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")])) + # Identity per round (pass-through rule): round two delivered exactly its own params. + 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. + + The 2025-11-25 push face of the messages-not-retained rule (the 2026-07-28 MRTR face has its + own test above; the stacked era-bound entry selects the legacy cells). The handler makes two + back-to-back sampling/createMessage requests in one session with different single messages: a + retaining client would show the first request's message inside the second's list. The + deprecated create_message call uses the file's pyright-ignore idiom (the SEP-2577 runtime + warning is globally ignored in pyproject filterwarnings). + """ + 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", {}) + + # Both replies embedded in one result: role/content/model/stopReason reached the handler. + assert result == snapshot( + CallToolResult(content=[TextContent(text="mock-llm-1/endTurn: reply 1 | mock-llm-1/endTurn: reply 2")]) + ) + # Identity per request: the second request carried only its own message. + 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..3e92e9ffa 100644 --- a/tests/interaction/lowlevel/test_tools.py +++ b/tests/interaction/lowlevel/test_tools.py @@ -8,25 +8,47 @@ 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 at the end of the file. prefixItems is enforced by the +# JSON Schema 2020-12 dialect but is an unknown (ignored) keyword under draft-07, so one +# schema/value pair can reveal which engine validated it; each constant is declared once and used +# by both the handler and the assertions so every equality is identity against the authored value. +_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 +138,36 @@ 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 passes through with no meaning assigned. + + The 2026-07-28 revision partitions the JSON-RPC implementation-defined range and says + receivers MUST NOT assume any specific meaning for legacy-range codes (apart from -32002). + Code, message, and data reach the caller verbatim, and the raise type being plain MCPError is + itself the no-interpretation observable: no typed special-casing keyed on the code. + """ + + async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult: + assert params.name == "vendor" + # -32011 is the test datum, not a missing constant: a deliberate in-band unknown from the + # legacy sub-range with no defined meaning anywhere (and deliberately not -32002, the + # carved-out exception). + 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.""" @@ -511,3 +563,265 @@ 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: + """The client validates structuredContent against the tool's declared outputSchema with full JSON + Schema 2020-12 vocabulary: a tuple violating a prefixItems per-index schema is rejected, a + conforming tuple is returned. Spec-mandated (2025-11-25 onward: clients MUST support 2020-12 and + SHOULD validate structured results); a draft-07 engine would silently ignore prefixItems and + accept both. The schema declares $schema 2020-12 explicitly -- the no-$schema default is the + dialect sibling test. + """ + 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 with no $schema is validated with the 2020-12 engine -- prefixItems, a + 2020-12-only keyword, is enforced -- while the same schema/value pair declaring draft-07 is + validated per the declared dialect, under which prefixItems is an unknown (ignored) keyword and + the call resolves; a draft-07-enforced keyword (type) still rejects, proving validation ran under + the declared dialect rather than being skipped. Spec-mandated ('validate schemas according to + their declared or default dialect', 2025-11-25 basic). The outcome flips on the $schema field + alone, proving the no-$schema enforcement is genuinely a default. + """ + 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": + # Violates "type": "array" on the point property -- a keyword draft-07 DOES enforce -- so a + # rejection proves validation ran under the declared dialect rather than being skipped. + return CallToolResult(content=[TextContent(text="point")], structured_content={"point": "xx"}) + # Same value, same keywords for the other two tools -- only the declared dialect differs. + 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", {}) + + # The messages embed jsonschema validation errors, so only the SDK-authored prefixes are pinned. + 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 present values: 0 and '' validate against their schemas and + come back to the caller, and a falsy value that VIOLATES its schema (false against + {type: integer} -- JSON Schema excludes booleans from integer) is rejected with the validation + error, not the missing-structured-content error. The error identity is the discriminator: a falsy + presence check would route all three to 'did not return structured content' (the test on + client:output-schema:missing-structured pins that message). 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 + # bool is an int subclass and False == 0, so the type pin keeps a False-returning regression from + # masquerading as the correct 0. + assert type(zero.structured_content) is int + assert empty.structured_content == "" + # The validation message, not the missing-structured one: the falsy value reached the validator. + 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: + """A tool advertising an array-rooted outputSchema round-trips on a 2026-07-28 connection: + conforming array structuredContent validates and is returned as-is, and a violating member is + rejected by the same client-side validation. 2026-only: through 2025-11-25 both the schema root + and structuredContent are restricted to objects (the 2025 wire surface refuses to even list an + array-rooted schema). + """ + + 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 non-vacuity arm: without it the accept arm cannot distinguish 'validated and passed' from + # 'validation silently skipped for non-objects'. + assert str(exc_info.value).startswith("Invalid structured content returned by tool ints_bad") + + +# --- scripted peer: a wire null structuredContent the typed Server cannot author --- + + +@requirement("client:jsonschema:null-structured-content") +async def test_a_wire_null_structured_content_is_rejected_as_missing_by_the_client() -> None: + """A tools/call answer carrying structuredContent null for a tool whose outputSchema is + {type: 'null'} raises the missing-structured-content RuntimeError instead of validating the + conforming null (known gap recorded on the requirement: the model parses null to None -- + indistinguishable from absent, no sentinel -- and the presence check fires before the validator). + The test plays the server by hand over memory streams because the typed Server cannot author a + wire null (structured_content None means absent and is stripped at serialization), and uses the + bare pinned-2026 ClientSession because Client has no public connect path over raw scripted + streams. When the SDK gains an absent-vs-null distinction, this test fails: 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 scaffolding the v2026 result model requires; + # the caching:* family owns their semantics. + 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, + }, + ) + ) + ) + # Returns naturally: the task group needs no cancel after the session context exits. + + # One combined async-with: a separately nested `async with` line 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() + # The client accepted and cached the null-rooted schema -- the conformant half of the + # story, and the precondition for the presence check the call is about to hit. + 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/mcpserver/test_prompts.py b/tests/interaction/mcpserver/test_prompts.py index 58c8b48c7..f5636a98d 100644 --- a/tests/interaction/mcpserver/test_prompts.py +++ b/tests/interaction/mcpserver/test_prompts.py @@ -193,3 +193,44 @@ 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: + """Concurrent connections to one server see the same prompt list, before and after one of them gets a prompt. + + Spec-mandated (2026-07-28): the set MUST NOT vary per-connection or as a side effect of other + requests on the connection. MCPServer's registry is server-level state shared by construction; + the pin is that the serving path adds no per-connection variation and that serving a + prompts/get mutates the registry on neither connection. + """ + 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 + # An unrelated request on the first connection: proves it ran AND 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 6497507ab..f0b4a5c10 100644 --- a/tests/interaction/mcpserver/test_resources.py +++ b/tests/interaction/mcpserver/test_resources.py @@ -182,3 +182,90 @@ 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 to one server see the same resource list, before and after one of them reads. + + Spec-mandated (2026-07-28): the set MUST NOT vary per-connection or as a side effect of other + requests on the connection. MCPServer's registry is server-level state shared by construction; + the pin is that the serving path adds no per-connection variation and that serving a + resources/read mutates the registry on neither connection. + """ + 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 + # An unrelated request on the first connection: proves it ran AND changed nothing. + 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 read whose extracted path parameter carries traversal is rejected; the resource function never runs. + + Spec-mandated security MUST (2026-07-28): servers sanitize file paths when serving file:// + resources. MCPServer's default ResourceSecurity policy rejects the value at template match + time and deliberately surfaces the rejection as the same -32602 "Unknown resource" error as a + non-match, so the wire gives no probing oracle. The {+path} template (RFC 6570 reserved + expansion) admits /-bearing values, so the traversal URI does match the template and the + rejection provably comes from the security policy, not a failed single-segment match. + """ + mcp = MCPServer("files") + invoked: list[str] = [] + + @mcp.resource("file:///files/{+path}") + def serve_file(path: str) -> str: + """Serves any safe path under the template; a traversal value must never reach it.""" + invoked.append(path) + return f"contents of {path}" + + async with connect(mcp) as client: + # Control read first: the template is live and the function runs for 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 cb66a3485..74e17906f 100644 --- a/tests/interaction/mcpserver/test_tools.py +++ b/tests/interaction/mcpserver/test_tools.py @@ -433,3 +433,78 @@ async def collect(message: IncomingMessage) -> None: assert received == snapshot( [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 on the connection. MCPServer's registry is server-level state shared by construction; + the pin is that the serving path adds no per-connection variation and that serving a + tools/call mutates the registry on neither connection. + """ + mcp = MCPServer("registry") + + @mcp.tool() + def cherry() -> str: + """Listed on both connections; never called.""" + raise NotImplementedError + + @mcp.tool() + def apple() -> str: + """The one tool the test calls.""" + return "ate" + + @mcp.tool() + def banana() -> str: + """Listed on both connections; never called.""" + 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), demanding only *some* stable order; the snapshot pins the + SDK's chosen order -- registration order (the registry is an insertion-ordered dict) -- so an + accidental re-sort fails consciously. The tools are registered in deliberately + non-alphabetical order to expose any sorting. + """ + mcp = MCPServer("registry") + + @mcp.tool() + def cherry() -> str: + """Listed only; never called.""" + raise NotImplementedError + + @mcp.tool() + def apple() -> str: + """Listed only; never called.""" + raise NotImplementedError + + @mcp.tool() + def banana() -> str: + """Listed only; never called.""" + 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 5225f089b..69e825459 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 @@ -245,3 +245,51 @@ 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 do not disturb the requests on them. + + The streamable-http page tells servers to emit ':'-prefixed keep-alive comment lines on + long-lived streams, and clients to ignore them -- normative by incorporation of the WHATWG SSE + specification. The shim prepends a comment line to every SSE body chunk, so the whole session + (initialize, tools/list, tools/call) runs over comment-bearing streams. The surface pinned is + the SDK's observable client behaviour (today the httpx-sse parser): a transport rewrite must + preserve the tolerance. + """ + 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 anchor: at least the initialize, tools/list, and tools/call SSE responses each + # had a comment line prepended (exactly 3 observed; ">=" because chunking is bridge-internal). + assert len(injected) >= 3 From 553a64121217e7b1a2f82a82919b8f3745dbd0a0 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:57:30 +0000 Subject: [PATCH 15/16] Link the last two pinned divergences to their tracking entries The unrecognized-resultType and scope-aggregation divergences gained tracking entries after their pins landed; wire the issue fields so the fixer trail is complete for every pinned divergence in the manifest. --- tests/interaction/_requirements.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 4ed4145d1..d8b9e84a4 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -3764,6 +3764,7 @@ def __post_init__(self) -> None: "surfaced on the returned result unchanged -- on both eras (the in-code TODO " "in src/mcp/server/runner.py records the missing rejection)." ), + issue="L117", ), ), "mrtr:input-responses:invalid-rejected": Requirement( @@ -5236,6 +5237,7 @@ def __post_init__(self) -> None: "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( From 011bbd5493430daec8590f3cc7213d3578e2436b Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:01:42 +0000 Subject: [PATCH 16/16] Re-ground the MRTR origin notes after the MCPServer pass-through landed MCPServer now passes InputRequiredResult through its prompt and resource pipelines, so the two origin entries' notes and the matching test docstrings no longer claim it cannot; the mcpserver mirrors are recorded as possible and not yet covered. No behaviour or assertion changes - the full suite is green unchanged against current main. --- tests/interaction/_requirements.py | 8 ++++---- tests/interaction/lowlevel/test_prompts.py | 2 +- tests/interaction/lowlevel/test_resources.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index d8b9e84a4..79dbebef9 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -2204,8 +2204,8 @@ def __post_init__(self) -> None: ), added_in="2026-07-28", note=( - "Low-level Server only: MCPServer returns InputRequiredResult from tools alone, so the " - "resources/read MRTR leg has no mcpserver mirror." + "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( @@ -2667,8 +2667,8 @@ def __post_init__(self) -> None: ), added_in="2026-07-28", note=( - "Low-level Server only: MCPServer returns InputRequiredResult from tools alone, so the " - "prompts/get MRTR leg has no mcpserver mirror." + "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." ), ), # ═══════════════════════════════════════════════════════════════════════════ diff --git a/tests/interaction/lowlevel/test_prompts.py b/tests/interaction/lowlevel/test_prompts.py index d1c366cc9..8f0840efd 100644 --- a/tests/interaction/lowlevel/test_prompts.py +++ b/tests/interaction/lowlevel/test_prompts.py @@ -270,7 +270,7 @@ async def test_get_prompt_input_required_is_fulfilled_and_the_retry_returns_the_ The retry carries the callback's responses and the echoed request_state, and returns the prompt messages. Spec-mandated: prompts/get is an MRTR-supported request (basic/patterns/mrtr, Supported - Requests). Low-level Server only — MCPServer cannot return InputRequiredResult from prompts. + Requests). Driven on the low-level Server; MCPServer also passes InputRequiredResult through prompts. """ sent = ElicitRequestFormParams( message="Who is reading?", diff --git a/tests/interaction/lowlevel/test_resources.py b/tests/interaction/lowlevel/test_resources.py index 2000d7ec5..332501e2a 100644 --- a/tests/interaction/lowlevel/test_resources.py +++ b/tests/interaction/lowlevel/test_resources.py @@ -393,7 +393,7 @@ async def test_read_resource_input_required_is_fulfilled_and_the_retry_returns_t The retry carries the callback's responses and the echoed request_state, and returns the resource contents. Spec-mandated: resources/read is an MRTR-supported request (basic/patterns/mrtr, Supported - Requests). Low-level Server only — MCPServer cannot return InputRequiredResult from resources. + Requests). Driven on the low-level Server; MCPServer also passes InputRequiredResult through resources. """ sent = ElicitRequestFormParams( message="Who is reading?",