diff --git a/tests/interaction/README.md b/tests/interaction/README.md index 473e79c83..863be7d6c 100644 --- a/tests/interaction/README.md +++ b/tests/interaction/README.md @@ -56,10 +56,12 @@ test body — each directory pins its flavour's true output exactly. Transport-agnostic tests take the `connect` fixture instead of constructing `Client(server)` directly, and therefore run once per transport: over the in-memory transport, over the server's -real streamable HTTP app driven in-process through the streaming bridge, and over the legacy SSE -transport the same way. A test connects with `async with connect(server, ...) as client:` and -asserts the same output on every leg, because the transport is not supposed to change observable -behaviour. Tests that are tied to one transport do not use the fixture: the wire-recording tests +real streamable HTTP app driven in-process through the streaming bridge (in both stateful and +stateless configurations), and over the legacy SSE transport the same way. A test connects with +`async with connect(server, ...) as client:` and asserts the same output on every leg, because the +transport is not supposed to change observable behaviour. Requirements that need a server-to-client +back-channel or persisted session state are carved out of the stateless arm via `arm_exclusions`. +Tests that are tied to one transport do not use the fixture: the wire-recording tests (their seam is the in-memory stream pair), the bare-`ClientSession` lifecycle tests, the real-clock timeout tests (the timeout machinery is transport-independent and must not race transport latency), and everything under `transports/`, which pins behaviour only observable on @@ -95,6 +97,14 @@ clients can share one session manager. would require real-time waits the suite refuses. - **`transports`** names the transports a behaviour applies to; omitted means transport-independent. - **`issue`** carries the tracking link for a recorded gap once one is filed. +- **`note`** carries free-form context that does not fit `divergence` or `deferred`. +- **`added_in`** / **`removed_in`** bound the spec versions the behaviour exists in, as a half-open + `[added_in, removed_in)` window. +- **`supersedes`** / **`superseded_by`** link a retired entry to its replacement; the link is + bidirectional and both ends must be versioned. +- **`arm_exclusions`** carve specific `(transport, spec_version)` matrix cells out with a typed + `ArmExclusionReason`. +- **`known_failures`** mark specific `(transport, spec_version)` cells as strict xfail. Tests link themselves to the manifest with a decorator: @@ -126,15 +136,60 @@ This is also the triage key for any rewrite: a test that fails on the new code p divergence note (the rewrite accidentally fixed a known gap — decide whether to keep the fix) or it does not (the rewrite broke something that was correct — fix the rewrite). -### When a new spec revision is released - -1. Update `SPEC_REVISION` and walk the new revision's changelog. -2. For each changed interaction, find its requirements (the IDs use the wire method strings the - changelog speaks in), re-audit the tests against the new text, and update `source` links and - assertions where behaviour legitimately changed. -3. New interactions get new requirements and new tests; removed interactions get their - requirements deleted along with their tests. -4. A behaviour that is correct under both revisions needs no change beyond the `source` link. +### Spec versions and the era axis + +`SPEC_VERSIONS` in `_requirements.py` is the ordered tuple of protocol revisions the suite +exercises. `SPEC_BASE_URL` (and `SPEC_2026_BASE_URL`) are pinned literals — not derived from +`SPEC_VERSIONS` — so growing the active axis never repoints existing `source` links. The +`connect` fixture fans out over `CONNECTABLE_TRANSPORTS × SPEC_VERSIONS`, but the grid is +filtered per test: +`pytest_generate_tests` reads the test's stacked `@requirement` marks and calls `compute_cells()`, +which intersects the admissible cells across every cited requirement — a cell survives only if +**all** of the test's requirements admit it. + +`streamable-http-stateless` is the fourth connectable transport: the 2025-era unofficial stateless +mode where each request opens a fresh transport, no session id is issued, and there is no standalone +GET stream. Requirements that need a server→client back-channel or persisted session state are +excluded from that arm via `arm_exclusions` (reasons `server-initiated-request` and +`requires-session`). + +What admits or excludes a cell: + +- **`added_in` / `removed_in`** gate which spec versions a requirement exists in, as a half-open + `[added_in, removed_in)` window. A test runs only on versions inside every cited requirement's + window. +- **`arm_exclusions`** carve specific `(transport, spec_version)` cells out with a typed + `ArmExclusionReason`. The reason vocabulary doubles as a re-admission checklist: when the gap + closes, grep for the reason string to find every cell to re-admit. +- **`known_failures`** keep a cell in the grid but mark it as a strict xfail — the test runs and + must fail; an unexpected pass fails the suite. +- **`TRANSPORT_SPEC_VERSIONS`** era-locks a transport to a subset of spec versions (currently only + `sse` is locked to `2025-11-25`). A `(transport, version)` cell is dropped if the version is not + in the transport's entry; transports absent from the map serve every spec version. This is the + mechanism for cutting an entire transport off from a new revision (or admitting it). +- **`transports`** is descriptive metadata for the non-`connect` transport-specific suites under + `transports/` and does **not** drive cell generation. Only `arm_exclusions`, `added_in`, + `removed_in`, and `TRANSPORT_SPEC_VERSIONS` filter the grid. +- **`supersedes` / `superseded_by`** link a retired entry to its replacement. `test_coverage.py` + enforces that links are bidirectional and versioned: the retired entry carries `removed_in`, the + replacement carries `added_in`. + +Node IDs stay `[transport]` while `len(SPEC_VERSIONS) == 1`, so today's test IDs are +byte-identical to before the era axis existed. They become `[transport-version]` the moment a +second version is appended to `SPEC_VERSIONS`. + +When a new spec revision lands: + +1. Append the version string to `SPEC_VERSIONS` (and to the `SpecVersion` `Literal`). +2. Walk the new revision's changelog. +3. For each affected requirement: set `removed_in` on retired behaviour, add a new entry with + `added_in` for its replacement, and link the pair with `supersedes` / `superseded_by`. + Behaviour that survives unchanged needs nothing beyond a re-audit of its `source` URL. +4. For requirements that cannot run on the new era's path, add an `arm_exclusions` entry with the + appropriate `ArmExclusionReason`. +5. Review `TRANSPORT_SPEC_VERSIONS`: any era-locked transport will not produce cells on the new + version unless its entry is extended (or removed); add an entry for any transport the new + revision retires. ## Writing a test diff --git a/tests/interaction/_connect.py b/tests/interaction/_connect.py index 1faf4aa8d..db585a052 100644 --- a/tests/interaction/_connect.py +++ b/tests/interaction/_connect.py @@ -9,6 +9,7 @@ from collections.abc import AsyncIterator, Awaitable, Callable, Iterable from contextlib import AbstractAsyncContextManager, asynccontextmanager +from functools import partial from typing import Any, Protocol import httpx @@ -69,6 +70,7 @@ def __call__( message_handler: MessageHandlerFnT | None = None, client_info: Implementation | None = None, elicitation_callback: ElicitationFnT | None = None, + protocol_version: str = LATEST_PROTOCOL_VERSION, ) -> AbstractAsyncContextManager[Client]: ... @@ -83,6 +85,7 @@ async def connect_in_memory( message_handler: MessageHandlerFnT | None = None, client_info: Implementation | None = None, elicitation_callback: ElicitationFnT | None = None, + protocol_version: str = LATEST_PROTOCOL_VERSION, ) -> AsyncIterator[Client]: """Yield a Client connected to the server over the in-memory transport.""" async with Client( @@ -113,13 +116,15 @@ async def connect_over_streamable_http( message_handler: MessageHandlerFnT | None = None, client_info: Implementation | None = None, elicitation_callback: ElicitationFnT | None = None, + protocol_version: str = LATEST_PROTOCOL_VERSION, ) -> AsyncIterator[Client]: """Yield a Client connected to the server's streamable HTTP app, entirely in process. - With the defaults this is the matrix leg (stateful sessions, SSE responses); the - transport-specific tests pass `stateless_http` or `json_response` to select the other - server modes, and the resumability tests pass an `event_store` (with `retry_interval=0` so - the client's reconnection wait is a no-op). + With the defaults this is the matrix leg (stateful sessions, SSE responses); the stateless + matrix arm binds `stateless_http=True` (see `connect_over_streamable_http_stateless`); + transport-specific tests pass `json_response` to select the other server mode, and the + resumability tests pass an `event_store` (with `retry_interval=0` so the client's + reconnection wait is a no-op). """ app = server.streamable_http_app( stateless_http=stateless_http, @@ -145,6 +150,12 @@ async def connect_over_streamable_http( yield client +connect_over_streamable_http_stateless: Connect = partial(connect_over_streamable_http, stateless_http=True) +"""The streamable-http matrix arm with the server in stateless mode (fresh transport per request, +no session id, no standalone GET stream). The same shared Server instance backs every request -- +stateless mode does not require a server factory.""" + + @asynccontextmanager async def mounted_app( server: Server | MCPServer, @@ -326,6 +337,7 @@ async def connect_over_sse( message_handler: MessageHandlerFnT | None = None, client_info: Implementation | None = None, elicitation_callback: ElicitationFnT | None = None, + protocol_version: str = LATEST_PROTOCOL_VERSION, ) -> AsyncIterator[Client]: """Yield a Client connected to the server's legacy SSE transport, entirely in process.""" app, _ = build_sse_app(server) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index c0f62ae57..5fb2c91b1 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -33,16 +33,51 @@ """ import re -from collections.abc import Callable +from collections.abc import Callable, Sequence from dataclasses import dataclass -from typing import Literal, TypeVar +from typing import Any, Literal, TypeVar import pytest -SPEC_REVISION = "2025-11-25" -SPEC_BASE_URL = f"https://modelcontextprotocol.io/specification/{SPEC_REVISION}" +from mcp.shared.version import KNOWN_PROTOCOL_VERSIONS -Transport = Literal["in-memory", "stdio", "streamable-http", "sse"] +SpecVersion = Literal["2025-11-25", "2026-07-28"] +"""A protocol version the suite parametrizes over. Both values are typed even though only one is +on the active axis (SPEC_VERSIONS) until the 2026-07-28 implementation lands.""" + +SPEC_VERSIONS: tuple[SpecVersion, ...] = ("2025-11-25",) +"""The active spec-version matrix axis, ordered oldest to newest. Every entry must be in KNOWN_PROTOCOL_VERSIONS.""" + +SPEC_BASE_URL = "https://modelcontextprotocol.io/specification/2025-11-25" +"""Deep-link base for entries citing the 2025-11-25 revision (the bulk of the manifest). Pinned -- +not derived from SPEC_VERSIONS -- so adding a newer revision to the active axis does not silently +repoint existing source URLs.""" + +SPEC_2026_BASE_URL = "https://modelcontextprotocol.io/specification/2026-07-28" +"""Deep-link base for entries citing the 2026-07-28 revision.""" + +Transport = Literal["in-memory", "stdio", "streamable-http", "streamable-http-stateless", "sse"] + +CONNECTABLE_TRANSPORTS: tuple[Transport, ...] = ("in-memory", "sse", "streamable-http", "streamable-http-stateless") +"""Transports the connect fixture fans out over (the subset with a factory in conftest._FACTORIES).""" + +TRANSPORT_SPEC_VERSIONS: dict[Transport, tuple[SpecVersion, ...]] = { + "sse": ("2025-11-25",), +} +"""Transports that only serve a subset of SPEC_VERSIONS. Absent => serves all. Consulted by compute_cells().""" + +ArmExclusionReason = Literal[ + "asserts-legacy-handshake", + "method-not-in-modern-registry", + "legacy-only-vocabulary", + "modern-error-surface", + "requires-session", + "drives-transport-directly", + "server-initiated-request", +] +"""Machine-readable reasons a requirement is excluded from a (transport, spec_version) matrix cell. +The set doubles as a re-admission checklist: when a feature lands, grep for its reason to find the +cells to re-admit. Values are kept byte-identical to the typescript-sdk's EntryExclusionReason.""" _TestFn = TypeVar("_TestFn", bound=Callable[..., object]) @@ -63,6 +98,38 @@ class Divergence: issue: str | None = None +@dataclass(frozen=True, kw_only=True) +class ArmExclusion: + """Excludes a requirement from a (transport, spec_version) matrix cell, with a typed reason.""" + + reason: ArmExclusionReason + transport: Transport | None = None + spec_version: SpecVersion | None = None + note: str | None = None + + def __post_init__(self) -> None: + if self.spec_version is not None and self.spec_version not in KNOWN_PROTOCOL_VERSIONS: + raise ValueError(f"spec_version {self.spec_version!r} is not in KNOWN_PROTOCOL_VERSIONS") + + +@dataclass(frozen=True, kw_only=True) +class KnownFailure: + """A (transport, spec_version) cell where the requirement's test is expected to fail (strict xfail).""" + + note: str + transport: Transport | None = None + spec_version: SpecVersion | None = None + issue: str | None = None + + def __post_init__(self) -> None: + if not self.note.strip(): + raise ValueError("note must be non-empty") + if self.spec_version is not None and self.spec_version not in KNOWN_PROTOCOL_VERSIONS: + raise ValueError(f"spec_version {self.spec_version!r} is not in KNOWN_PROTOCOL_VERSIONS") + if self.issue is not None and not re.fullmatch(r"#\d+|https://github\.com/\S+", self.issue): + raise ValueError(f"issue must be '#' or a GitHub URL, got {self.issue!r}") + + @dataclass(frozen=True, kw_only=True) class Requirement: """A single testable behaviour and the provenance of why it must hold.""" @@ -73,10 +140,27 @@ class Requirement: divergence: Divergence | None = None deferred: str | None = None issue: str | None = None + note: str | None = None + added_in: SpecVersion | None = None + removed_in: SpecVersion | None = None + supersedes: tuple[str, ...] = () + superseded_by: str | None = None + arm_exclusions: tuple[ArmExclusion, ...] = () + known_failures: tuple[KnownFailure, ...] = () def __post_init__(self) -> None: if not _SOURCE_PATTERN.fullmatch(self.source): raise ValueError(f"source must be a specification URL, 'sdk', or 'issue:#n', got {self.source!r}") + if self.added_in is not None and self.added_in not in KNOWN_PROTOCOL_VERSIONS: + raise ValueError(f"added_in {self.added_in!r} is not in KNOWN_PROTOCOL_VERSIONS") + if self.removed_in is not None and self.removed_in not in KNOWN_PROTOCOL_VERSIONS: + raise ValueError(f"removed_in {self.removed_in!r} is not in KNOWN_PROTOCOL_VERSIONS") + if ( + self.added_in is not None + and self.removed_in is not None + and KNOWN_PROTOCOL_VERSIONS.index(self.added_in) >= KNOWN_PROTOCOL_VERSIONS.index(self.removed_in) + ): + raise ValueError(f"added_in {self.added_in!r} must be earlier than removed_in {self.removed_in!r}") REQUIREMENTS: dict[str, Requirement] = { @@ -144,6 +228,7 @@ def __post_init__(self) -> None: "lifecycle:initialize:client-info": Requirement( source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization", behavior="The client's name, version, and title are visible to server handlers after initialization.", + arm_exclusions=(ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),), ), "lifecycle:initialize:client-capabilities": Requirement( source=f"{SPEC_BASE_URL}/basic/lifecycle#capability-negotiation", @@ -151,6 +236,7 @@ def __post_init__(self) -> None: "The client capabilities visible to the server reflect which client callbacks are configured " "(sampling, elicitation, roots)." ), + arm_exclusions=(ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),), ), "lifecycle:initialized-notification": Requirement( source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization", @@ -162,14 +248,21 @@ def __post_init__(self) -> None: "lifecycle:ping": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/ping#behavior-requirements", behavior="ping in either direction returns an empty result.", + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2575); ping deleted from the schema, no replacement.", ), "ping:client-to-server": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/ping#behavior-requirements", behavior="A client-initiated ping receives an empty result from the server.", + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2575); ping deleted from the schema, no replacement.", ), "ping:server-to-client": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/ping#behavior-requirements", behavior="A server-initiated ping receives an empty result from the client.", + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2575); ping deleted from the schema, no replacement.", + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "lifecycle:requests-before-initialized": Requirement( source="sdk", @@ -258,6 +351,10 @@ def __post_init__(self) -> None: "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"), + ), ), "protocol:cancel:in-flight": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements", @@ -273,6 +370,10 @@ def __post_init__(self) -> None: "requests — which is what unblocks the sender's pending call." ), ), + arm_exclusions=( + ArmExclusion(reason="requires-session", transport="streamable-http-stateless"), + ArmExclusion(reason="requires-session", spec_version="2026-07-28"), + ), ), "protocol:cancel:initialize-not-cancellable": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements", @@ -288,6 +389,10 @@ def __post_init__(self) -> None: "protocol:cancel:server-survives": Requirement( source="sdk", behavior="The session continues to serve new requests after an earlier request was cancelled.", + arm_exclusions=( + ArmExclusion(reason="requires-session", transport="streamable-http-stateless"), + ArmExclusion(reason="requires-session", spec_version="2026-07-28"), + ), ), "protocol:cancel:server-to-client": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#behavior-requirements", @@ -295,6 +400,10 @@ 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"), + ), ), "protocol:cancel:unknown-id-ignored": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#error-handling", @@ -364,10 +473,16 @@ def __post_init__(self) -> None: source=f"{SPEC_BASE_URL}/basic/utilities/tasks#related-task-metadata", behavior="Messages may carry related-task _meta associating them with a task.", deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension." + ), ), "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"),), ), "meta:result-to-client": Requirement( source=f"{SPEC_BASE_URL}/basic#_meta", @@ -386,6 +501,7 @@ def __post_init__(self) -> None: "Supplying a progress callback attaches a progress token to the outgoing request, which the " "server-side handler can observe in its request metadata." ), + arm_exclusions=(ArmExclusion(reason="asserts-legacy-handshake", spec_version="2026-07-28"),), ), "protocol:progress:token-unique": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow", @@ -412,6 +528,10 @@ def __post_init__(self) -> None: "completed; the late notification is sent and reaches the client." ), ), + arm_exclusions=( + ArmExclusion(reason="requires-session", transport="streamable-http-stateless"), + ArmExclusion(reason="requires-session", spec_version="2026-07-28"), + ), ), "protocol:progress:late-dropped-by-client": Requirement( source="sdk", @@ -419,6 +539,10 @@ def __post_init__(self) -> None: "A progress notification that arrives after its request has completed is not delivered to the " "original progress callback." ), + arm_exclusions=( + ArmExclusion(reason="requires-session", transport="streamable-http-stateless"), + ArmExclusion(reason="requires-session", spec_version="2026-07-28"), + ), ), "protocol:progress:no-token": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow", @@ -512,6 +636,10 @@ 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"), + ), ), "tools:call:is-error": Requirement( source=f"{SPEC_BASE_URL}/server/tools#error-handling", @@ -540,6 +668,10 @@ 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"), + ), ), "tools:call:structured-content": Requirement( source=f"{SPEC_BASE_URL}/server/tools#structured-content", @@ -556,6 +688,7 @@ 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"),), ), "tools:input-schema:json-schema-2020-12": Requirement( source=f"{SPEC_BASE_URL}/server/tools#tool", @@ -582,6 +715,10 @@ 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"), + ), ), "tools:list:basic": Requirement( source=f"{SPEC_BASE_URL}/server/tools#listing-tools", @@ -665,6 +802,10 @@ def __post_init__(self) -> None: "Tool functions can access request metadata (request id, client params, session) through the " "Context parameter." ), + arm_exclusions=( + ArmExclusion(reason="requires-session", transport="streamable-http-stateless"), + ArmExclusion(reason="asserts-legacy-handshake", spec_version="2026-07-28"), + ), ), "mcpserver:tool:handler-throws": Requirement( source="sdk", @@ -725,6 +866,11 @@ def __post_init__(self) -> None: "A tool function that raises the URL-elicitation-required error surfaces to the caller as " "error -32042 with the elicitation parameters intact." ), + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2322); error -32042 retired, replaced by an MRTR input_required result " + "carrying inputRequests." + ), ), # ═══════════════════════════════════════════════════════════════════════════ # MCPServer: Context helpers (SDK) @@ -748,6 +894,10 @@ 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"), + ), ), "mcpserver:context:read-resource": Requirement( source="sdk", @@ -772,6 +922,7 @@ 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"),), ), "resources:list-changed": Requirement( source=f"{SPEC_BASE_URL}/server/resources#list-changed-notification", @@ -779,6 +930,10 @@ 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"), + ), ), "resources:list:basic": Requirement( source=f"{SPEC_BASE_URL}/server/resources#listing-resources", @@ -810,12 +965,20 @@ def __post_init__(self) -> None: "resources:subscribe": Requirement( source=f"{SPEC_BASE_URL}/server/resources#subscriptions", behavior="resources/subscribe delivers the URI to the server's subscribe handler and returns an empty result.", + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2575); resources/subscribe replaced by subscriptions/listen.", ), "resources:subscribe:capability-required": Requirement( source="sdk", behavior=( "resources/subscribe to a server that did not advertise the subscribe capability is rejected with an error." ), + removed_in="2026-07-28", + 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 " + "subscriptions/listen -- there is no separate subscriptions capability." + ), ), "resources:subscribe:updated": Requirement( source=f"{SPEC_BASE_URL}/server/resources#subscriptions", @@ -825,6 +988,8 @@ def __post_init__(self) -> None: "updated notifications; emitting updates is entirely handler code. The two halves are pinned " "separately by resources:subscribe and resources:updated-notification." ), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2575); resources/subscribe replaced by subscriptions/listen.", ), "resources:templates:list": Requirement( source=f"{SPEC_BASE_URL}/server/resources#resource-templates", @@ -841,6 +1006,8 @@ def __post_init__(self) -> None: behavior=( "resources/unsubscribe delivers the URI to the server's unsubscribe handler and returns an empty result." ), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2575); resources/unsubscribe replaced by subscriptions/listen.", ), "resources:unsubscribe:stops-updates": Requirement( source=f"{SPEC_BASE_URL}/server/resources#subscriptions", @@ -850,6 +1017,8 @@ def __post_init__(self) -> None: "notifications stop after unsubscribe is entirely handler code; there is no SDK behaviour to " "pin beyond the unsubscribe request reaching the handler (covered by resources:unsubscribe)." ), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2575); resources/unsubscribe replaced by subscriptions/listen.", ), "resources:updated-notification": Requirement( source=f"{SPEC_BASE_URL}/server/resources#subscriptions", @@ -857,6 +1026,10 @@ 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"), + ), ), # ═══════════════════════════════════════════════════════════════════════════ # Resources: SDK guarantees @@ -906,6 +1079,7 @@ 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"),), ), "prompts:get:content:audio": Requirement( source=f"{SPEC_BASE_URL}/server/prompts#audio-content", @@ -951,6 +1125,10 @@ 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"), + ), ), "prompts:list:basic": Requirement( source=f"{SPEC_BASE_URL}/server/prompts#listing-prompts", @@ -1004,6 +1182,7 @@ 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"),), ), "completion:complete:not-supported": Requirement( source=f"{SPEC_BASE_URL}/server/utilities/completion#capabilities", @@ -1041,6 +1220,7 @@ 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"),), ), # ═══════════════════════════════════════════════════════════════════════════ # Logging @@ -1056,6 +1236,7 @@ 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"),), ), "logging:message:all-levels": Requirement( source=f"{SPEC_BASE_URL}/server/utilities/logging#log-levels", @@ -1078,14 +1259,29 @@ def __post_init__(self) -> None: "filtering; messages are delivered at every severity regardless of the requested level." ), ), + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2575); logging/setLevel removed, replaced by per-request " + "io.modelcontextprotocol/logLevel in _meta." + ), ), "logging:set-level": Requirement( 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", + note=( + "removed in 2026-07-28 (SEP-2575); logging/setLevel removed, replaced by per-request " + "io.modelcontextprotocol/logLevel in _meta." + ), ), "logging:set-level:invalid-level": Requirement( 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", + note=( + "removed in 2026-07-28 (SEP-2575); logging/setLevel removed, replaced by per-request " + "io.modelcontextprotocol/logLevel in _meta." + ), ), # ═══════════════════════════════════════════════════════════════════════════ # Sampling (server → client) @@ -1095,6 +1291,10 @@ 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"), + ), ), "sampling:create:basic": Requirement( source=f"{SPEC_BASE_URL}/client/sampling#creating-messages", @@ -1102,10 +1302,18 @@ 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"), + ), ), "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"), + ), ), "sampling:context:server-gated-by-capability": Requirement( source=f"{SPEC_BASE_URL}/client/sampling#capabilities", @@ -1119,6 +1327,10 @@ 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"), + ), ), "sampling:create:model-preferences": Requirement( source=f"{SPEC_BASE_URL}/client/sampling#model-preferences", @@ -1126,10 +1338,18 @@ 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"), + ), ), "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"), + ), ), "sampling:create:tools": Requirement( source=f"{SPEC_BASE_URL}/client/sampling#tools-in-sampling", @@ -1146,10 +1366,18 @@ def __post_init__(self) -> None: "sampling:create-message: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"), + ), ), "sampling:create-message: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"), + ), ), "sampling:create-message:not-supported": Requirement( source=f"{SPEC_BASE_URL}/client/sampling#capabilities", @@ -1157,6 +1385,10 @@ 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"), + ), ), "sampling:error:user-rejected": Requirement( source=f"{SPEC_BASE_URL}/client/sampling#error-handling", @@ -1164,10 +1396,18 @@ 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"), + ), ), "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"), + ), ), "sampling:result:no-tools-single-content": Requirement( source="sdk", @@ -1182,6 +1422,10 @@ 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"), + ), ), "sampling:result:with-tools-array-content": Requirement( source="sdk", @@ -1200,6 +1444,10 @@ 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"), + ), ), "sampling:tool-use:result-balance": Requirement( source=f"{SPEC_BASE_URL}/client/sampling#tool-use-and-result-balance", @@ -1226,6 +1474,10 @@ 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"), + ), ), "sampling:tools:server-gated-by-capability": Requirement( source=f"{SPEC_BASE_URL}/client/sampling#tools-in-sampling", @@ -1233,6 +1485,10 @@ 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"), + ), ), # ═══════════════════════════════════════════════════════════════════════════ # Elicitation (server → client) @@ -1269,6 +1525,10 @@ 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"), + ), ), "elicitation:form:action:accept": Requirement( source=f"{SPEC_BASE_URL}/client/elicitation#response-actions", @@ -1276,14 +1536,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"), + ), ), "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"), + ), ), "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"), + ), ), "elicitation:form:basic": Requirement( source=f"{SPEC_BASE_URL}/client/elicitation#form-mode-elicitation-requests", @@ -1291,6 +1563,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"), + ), ), "elicitation:form:defaults": Requirement( source=f"{SPEC_BASE_URL}/client/elicitation#requested-schema", @@ -1316,6 +1592,10 @@ 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"), + ), ), "elicitation:form:schema:enum-variants": Requirement( source=f"{SPEC_BASE_URL}/client/elicitation#requested-schema", @@ -1323,10 +1603,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"), + ), ), "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"), + ), ), "elicitation:form:schema:restricted-subset": Requirement( source=f"{SPEC_BASE_URL}/client/elicitation#requested-schema", @@ -1344,6 +1632,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"), + ), ), "elicitation:form:response-validation": Requirement( source=f"{SPEC_BASE_URL}/client/elicitation#form-mode-security", @@ -1358,6 +1650,10 @@ 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"), + ), ), "elicitation:url:action:accept-no-content": Requirement( source=f"{SPEC_BASE_URL}/client/elicitation#response-actions", @@ -1366,6 +1662,10 @@ 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"), + ), ), "elicitation:url:basic": Requirement( source=f"{SPEC_BASE_URL}/client/elicitation#url-mode-elicitation-requests", @@ -1373,10 +1673,18 @@ 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"), + ), ), "elicitation:url:cancel": Requirement( source=f"{SPEC_BASE_URL}/client/elicitation#response-actions", behavior="A URL-mode elicitation answered with cancel returns the action with no content.", + arm_exclusions=( + ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"), + ArmExclusion(reason="server-initiated-request", spec_version="2026-07-28"), + ), ), "elicitation:url:complete-notification": Requirement( source=f"{SPEC_BASE_URL}/client/elicitation#completion-notifications-for-url-mode-elicitation", @@ -1384,6 +1692,12 @@ def __post_init__(self) -> None: "An elicitation/complete notification sent by the server after an out-of-band elicitation " "finishes reaches the client carrying the elicitationId." ), + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (spec PR #2891); notifications/elicitation/complete and elicitationId removed, no " + "replacement (under MRTR the client learns completion by retrying)." + ), + arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),), ), "elicitation:url:complete-unknown-ignored": Requirement( source=f"{SPEC_BASE_URL}/client/elicitation#completion-notifications-for-url-mode-elicitation", @@ -1391,10 +1705,16 @@ def __post_init__(self) -> None: "The client ignores an elicitation/complete notification referencing an unknown or " "already-completed elicitationId without error." ), + removed_in="2026-07-28", + note="removed in 2026-07-28 (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", @@ -1413,6 +1733,11 @@ def __post_init__(self) -> None: "A handler that cannot proceed without a URL elicitation rejects the request with error " "-32042, carrying the pending elicitations in the error data." ), + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2322); error -32042 retired, replaced by an MRTR input_required result " + "carrying inputRequests." + ), ), # ═══════════════════════════════════════════════════════════════════════════ # Roots (server → client) @@ -1420,6 +1745,11 @@ def __post_init__(self) -> None: "roots:list-changed": Requirement( source=f"{SPEC_BASE_URL}/client/roots#root-list-changes", behavior="A roots/list_changed notification sent by the client is delivered to the server's handler.", + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2575); notifications/roots/list_changed removed, no replacement (the stateless " + "model carries no client→server change notifications)." + ), ), "roots:list-changed:client-emits": Requirement( source=f"{SPEC_BASE_URL}/client/roots#root-list-changes", @@ -1433,6 +1763,8 @@ def __post_init__(self) -> None: "provides send_roots_list_changed() for the host to call when its roots change, and that " "emission path is covered by roots:list-changed." ), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2575); notifications/roots/list_changed removed, no replacement.", ), "roots:list:basic": Requirement( source=f"{SPEC_BASE_URL}/client/roots#listing-roots", @@ -1440,14 +1772,26 @@ 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"), + ), ), "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"), + ), ), "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"), + ), ), "roots:list:not-supported": Requirement( source=f"{SPEC_BASE_URL}/client/roots#error-handling", @@ -1458,6 +1802,10 @@ 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"), + ), ), "roots:uri:file-scheme": Requirement( source=f"{SPEC_BASE_URL}/client/roots#root", @@ -1553,11 +1901,21 @@ def __post_init__(self) -> None: ), transports=("streamable-http",), deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension." + ), ), "tasks:bidirectional": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/tasks#definitions", behavior="Task APIs are bidirectional: the server may create, get, list, and cancel tasks on the client.", deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension." + ), ), "tasks:cancel:no-handler-abort": Requirement( source="sdk", @@ -1566,6 +1924,11 @@ def __post_init__(self) -> None: "(the spec says receivers SHOULD attempt to stop execution)." ), deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension." + ), ), "tasks:cancel:remains-cancelled": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/tasks#task-cancellation", @@ -1574,16 +1937,31 @@ def __post_init__(self) -> None: "completes or fails." ), deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension." + ), ), "tasks:cancel:terminal-rejected": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/tasks#task-cancellation", behavior="tasks/cancel on a task already in a terminal state returns Invalid params (-32602).", deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension." + ), ), "tasks:cancel:working": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/tasks#task-cancellation", behavior="tasks/cancel on a working task transitions it to cancelled and returns the updated task.", deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension." + ), ), "tasks:create:ttl-honored": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/tasks#ttl-and-resource-management", @@ -1592,21 +1970,41 @@ def __post_init__(self) -> None: "the create-task result carries the same value." ), deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension." + ), ), "tasks:create:via-tool-call": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/tasks#creating-tasks", behavior="A task-augmented tools/call returns a create-task result instead of the tool result.", deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension." + ), ), "tasks:get": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/tasks#getting-tasks", behavior="tasks/get returns the task's current status, ttl, timestamps, and status message.", deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension." + ), ), "tasks:lifecycle:initial-working": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/tasks#task-status-lifecycle", behavior="A newly created task has status 'working'.", deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension." + ), ), "tasks:lifecycle:input-required": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/tasks#input-required-status", @@ -1615,16 +2013,31 @@ def __post_init__(self) -> None: "response arrives the task leaves input_required (typically returning to working)." ), deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension." + ), ), "tasks:list:invalid-cursor": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/tasks#protocol-errors", behavior="tasks/list with an invalid cursor returns Invalid params (-32602).", deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension (tasks/list dropped in the redesign)." + ), ), "tasks:list:pagination": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/tasks#listing-tasks", behavior="tasks/list returns created tasks and supports cursor pagination.", deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension (tasks/list dropped in the redesign)." + ), ), "tasks:no-capability:ignore-task-param": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/tasks#task-support-and-handling", @@ -1633,6 +2046,11 @@ def __post_init__(self) -> None: "normally and returns the ordinary result, ignoring the task augmentation." ), deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension." + ), ), "tasks:progress:after-create": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/tasks#task-progress-notifications", @@ -1641,36 +2059,71 @@ def __post_init__(self) -> None: "continue to reach the caller until the task is terminal." ), deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension." + ), ), "tasks:request-cancel:no-task-cancel": Requirement( source="sdk", behavior="A cancellation notification for the originating request does not auto-cancel the created task.", deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension." + ), ), "tasks:result:failed": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/tasks#task-execution-errors", behavior="tasks/result for a failed task returns the failure result (isError true).", deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension (tasks/result dropped in the redesign)." + ), ), "tasks:result:related-task-meta": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/tasks#related-task-metadata", behavior="The tasks/result response carries related-task _meta naming the requested task.", deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension (tasks/result dropped in the redesign)." + ), ), "tasks:result:terminal": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/tasks#result-retrieval", behavior="tasks/result for a completed task returns the stored result of the original request type.", deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension (tasks/result dropped in the redesign)." + ), ), "tasks:side-channel:drain-fifo": Requirement( source="sdk", behavior="tasks/result drains queued related-task messages in FIFO order before returning the final result.", deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension (tasks/result side-channel dropped in the redesign)." + ), ), "tasks:side-channel:drop-on-cancel": Requirement( source="sdk", behavior="When a task is cancelled before tasks/result, queued related-task messages are dropped.", deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension (tasks/result side-channel dropped in the redesign)." + ), ), "tasks:side-channel:elicitation": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/tasks#input-required-status", @@ -1679,6 +2132,11 @@ def __post_init__(self) -> None: "client's response routes back to the handler." ), deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension (tasks/result side-channel dropped in the redesign)." + ), ), "tasks:side-channel:queue": Requirement( source="sdk", @@ -1686,6 +2144,11 @@ def __post_init__(self) -> None: "Server-to-client requests with related-task metadata sent while no tasks/result is open are queued." ), deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension (tasks/result side-channel dropped in the redesign)." + ), ), "tasks:side-channel:sampling": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/tasks#input-required-status", @@ -1694,6 +2157,11 @@ def __post_init__(self) -> None: "the client's response routes back to the task." ), deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension (tasks/result side-channel dropped in the redesign)." + ), ), "tasks:side-channel:stream": Requirement( source="sdk", @@ -1702,11 +2170,21 @@ def __post_init__(self) -> None: "produced, then returns the result." ), deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension (tasks/result side-channel dropped in the redesign)." + ), ), "tasks:status-notification": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/tasks#task-status-notification", behavior="Task status notifications deliver status updates carrying the full task fields.", deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension." + ), ), "tasks:tool-level:forbidden-with-task-32601": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/tasks#tool-level-negotiation", @@ -1714,16 +2192,31 @@ def __post_init__(self) -> None: "A task-augmented tools/call on a tool that does not support tasks returns Method not found (-32601)." ), deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension." + ), ), "tasks:tool-level:required-no-task-32601": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/tasks#tool-level-negotiation", behavior=("A plain tools/call on a tool that requires task augmentation returns Method not found (-32601)."), deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension." + ), ), "tasks:unknown-id": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/tasks#protocol-errors", behavior="tasks/get, tasks/result, and tasks/cancel for an unknown task id return Invalid params (-32602).", deferred=_TASKS_DEFERRAL, + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2663); tasks moved out of core into the io.modelcontextprotocol/tasks " + "extension." + ), ), # ═══════════════════════════════════════════════════════════════════════════ # Transports (in-suite coverage) @@ -1735,11 +2228,13 @@ def __post_init__(self) -> None: "streamable HTTP framing in its default stateful SSE-response mode." ), transports=("streamable-http",), + note="Only observable over streamable HTTP: exercises the stateful HTTP framing end to end.", ), "transport:streamable-http:json-response": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#streamable-http", behavior="The interaction round trip works when the server answers with plain JSON instead of SSE.", transports=("streamable-http",), + note="Only observable over streamable HTTP: JSON-response mode is an HTTP framing option.", ), "transport:streamable-http:stateless": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#streamable-http", @@ -1748,6 +2243,7 @@ def __post_init__(self) -> None: "fresh transport with no session id." ), transports=("streamable-http",), + note="Only observable over streamable HTTP: stateless mode is an HTTP hosting option.", ), "transport:streamable-http:notifications": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#streamable-http", @@ -1756,6 +2252,7 @@ def __post_init__(self) -> None: "the client's callbacks, in order, before the response." ), transports=("streamable-http",), + note="Only observable over streamable HTTP: per-request SSE streams are HTTP-specific.", ), "transport:streamable-http:stateless-restrictions": Requirement( source="sdk", @@ -1764,6 +2261,7 @@ def __post_init__(self) -> None: "result, because there is no session to call back through." ), transports=("streamable-http",), + note="Only observable over streamable HTTP: stateless mode is an HTTP hosting option.", ), "transport:streamable-http:unrelated-messages": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#streamable-http", @@ -1773,6 +2271,8 @@ def __post_init__(self) -> None: "own stream." ), transports=("streamable-http",), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2575); the standalone GET stream is replaced by subscriptions/listen.", ), "transport:streamable-http:server-to-client": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#streamable-http", @@ -1780,16 +2280,20 @@ 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.", ), "transport:streamable-http:resumability": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#streamable-http", behavior="A client that reconnects with Last-Event-ID receives the events it missed.", transports=("streamable-http",), + 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", @@ -1798,11 +2302,13 @@ def __post_init__(self) -> None: "requests, with server messages delivered on the SSE stream." ), transports=("sse",), + note="Only observable over the legacy SSE transport.", ), "transport:sse:endpoint-event": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#backwards-compatibility", behavior="Opening the SSE stream delivers an `endpoint` event naming the message-POST URL as the first event.", transports=("sse",), + note="Only observable over the legacy SSE transport.", ), "transport:sse:post:session-routing": Requirement( source="sdk", @@ -1812,6 +2318,7 @@ def __post_init__(self) -> None: "no session id, a malformed session id, or an unknown session id is rejected (400/400/404)." ), transports=("sse",), + note="Only observable over the legacy SSE transport.", ), "transport:stdio": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#stdio", @@ -1820,6 +2327,7 @@ def __post_init__(self) -> None: "and receives notifications and results over the child process's stdin/stdout." ), transports=("stdio",), + note="Only observable over stdio: exercises the child-process framing end to end.", ), # ═══════════════════════════════════════════════════════════════════════════ # Hosting: session lifecycle @@ -1829,6 +2337,8 @@ def __post_init__(self) -> None: behavior="CORS configuration exposes the Mcp-Session-Id header so browser clients can read it.", transports=("streamable-http",), deferred="Not implemented in the SDK: CORS configuration is left to the hosting ASGI application.", + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2567); Mcp-Session-Id removed, no replacement.", ), "hosting:session:create": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#session-management", @@ -1837,26 +2347,42 @@ def __post_init__(self) -> None: "response headers." ), transports=("streamable-http",), + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2567); Mcp-Session-Id and protocol-level sessions removed, no replacement " + "(cross-call state moves to explicit server-minted handles)." + ), ), "hosting:session:delete": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#session-management", behavior="DELETE with a valid Mcp-Session-Id terminates the session.", transports=("streamable-http",), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2567); session DELETE removed with Mcp-Session-Id, no replacement.", ), "hosting:session:id-charset": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#session-management", behavior="Generated Mcp-Session-Id values contain only visible ASCII characters.", transports=("streamable-http",), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2567); Mcp-Session-Id removed, no replacement.", ), "hosting:session:isolation": Requirement( source="sdk", behavior="Each session gets its own server instance; closing one session does not affect others.", transports=("streamable-http",), + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2567); per-session server instances retired with Mcp-Session-Id, no " + "replacement." + ), ), "hosting:session:missing-id": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#session-management", behavior="A non-initialize POST without Mcp-Session-Id in stateful mode returns 400.", transports=("streamable-http",), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2567); Mcp-Session-Id validation removed, no replacement.", ), "hosting:session:post-termination-404": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#session-management", @@ -1865,6 +2391,8 @@ def __post_init__(self) -> None: "404 Not Found." ), transports=("streamable-http",), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2567); Mcp-Session-Id removed, no replacement.", ), "hosting:session:reinitialize": Requirement( source="sdk", @@ -1876,31 +2404,43 @@ def __post_init__(self) -> None: "server, which answers it as a fresh handshake; nothing rejects re-initialization." ), ), + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2567); per-session initialize guard retired with Mcp-Session-Id, no " + "replacement." + ), ), "hosting:session:reuse": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#session-management", behavior="A POST carrying a valid Mcp-Session-Id routes to that session's transport with state preserved.", transports=("streamable-http",), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2567); Mcp-Session-Id routing removed, no replacement.", ), "hosting:session:unknown-id": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#session-management", behavior="A POST, GET, or DELETE with an unknown Mcp-Session-Id returns 404.", transports=("streamable-http",), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2567); Mcp-Session-Id removed, no replacement.", ), "hosting:stateless:concurrent-clients": Requirement( source="sdk", behavior="Multiple independent clients can connect to a stateless server concurrently.", transports=("streamable-http",), + note="Stateless mode is a streamable-HTTP hosting option.", ), "hosting:stateless:no-reuse": Requirement( source="sdk", behavior="A stateless per-request transport cannot be reused for a second request.", transports=("streamable-http",), + note="Stateless mode is a streamable-HTTP hosting option.", ), "hosting:stateless:no-session-id": Requirement( source="sdk", behavior="In stateless mode no Mcp-Session-Id is emitted and no session validation is performed.", transports=("streamable-http",), + note="Stateless mode is a streamable-HTTP hosting option; Mcp-Session-Id is an HTTP header.", ), # ═══════════════════════════════════════════════════════════════════════════ # Hosting: auth @@ -1912,11 +2452,13 @@ def __post_init__(self) -> None: "(and revocation when supported)." ), transports=("streamable-http",), + note="Auth is enforced at the HTTP layer; the AS router is an ASGI app.", ), "hosting:auth:aud-validation": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#access-token-usage", behavior="The resource server validates that the token audience matches its resource identifier.", transports=("streamable-http",), + note="Auth is enforced at the HTTP layer.", divergence=Divergence( note=( "BearerAuthBackend never inspects AccessToken.resource; a token issued for a different " @@ -1928,11 +2470,13 @@ def __post_init__(self) -> None: source="sdk", behavior="A valid token's auth info is exposed to request handlers.", transports=("streamable-http",), + note="Auth is enforced at the HTTP layer.", ), "hosting:auth:expired-401": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#token-handling", behavior="An expired token returns 401 invalid_token.", transports=("streamable-http",), + note="Auth is enforced at the HTTP layer; 401 is an HTTP status code.", divergence=Divergence( note="The challenge carries no `scope` parameter; see the note on hosting:auth:missing-401.", ), @@ -1941,6 +2485,7 @@ def __post_init__(self) -> None: source=f"{SPEC_BASE_URL}/basic/authorization#token-handling", behavior="A malformed bearer token or token-verification failure returns 401 with WWW-Authenticate.", transports=("streamable-http",), + note="Auth is enforced at the HTTP layer; 401 is an HTTP status code.", divergence=Divergence( note="The challenge carries no `scope` parameter; see the note on hosting:auth:missing-401.", ), @@ -1953,6 +2498,7 @@ def __post_init__(self) -> None: "at its own." ), transports=("streamable-http",), + note="Auth is enforced at the HTTP layer; well-known endpoints are HTTP routes.", ), "hosting:auth:missing-401": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#protected-resource-metadata-discovery-requirements", @@ -1961,6 +2507,7 @@ def __post_init__(self) -> None: "carries resource_metadata (one of the spec's two permitted discovery mechanisms)." ), transports=("streamable-http",), + note="Auth is enforced at the HTTP layer; 401 is an HTTP status code.", divergence=Divergence( note=( "The SDK never emits a `scope` parameter in any WWW-Authenticate challenge — neither the " @@ -1977,6 +2524,7 @@ def __post_init__(self) -> None: "The protected-resource metadata document includes an authorization_servers array with at least one entry." ), transports=("streamable-http",), + note="Auth is enforced at the HTTP layer; PRM is served over HTTP.", ), "hosting:auth:query-token-ignored": Requirement( source="sdk", @@ -1985,6 +2533,7 @@ def __post_init__(self) -> None: "unauthenticated." ), transports=("streamable-http",), + note="Auth is enforced at the HTTP layer; query strings are URL-specific.", ), "hosting:auth:scope-403": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#runtime-insufficient-scope-errors", @@ -1993,6 +2542,7 @@ def __post_init__(self) -> None: "insufficient_scope, the required scope, and resource_metadata." ), transports=("streamable-http",), + note="Auth is enforced at the HTTP layer; 403 is an HTTP status code.", divergence=Divergence( note=( 'The SDK emits error="insufficient_scope" and error_description but never the `scope` ' @@ -2008,6 +2558,7 @@ def __post_init__(self) -> None: "`code_challenge` with `invalid_request`." ), transports=("streamable-http",), + note="Auth is enforced at the HTTP layer; the bundled AS is an ASGI app.", ), "hosting:auth:as:verifier-mismatch": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#authorization-code-protection", @@ -2016,6 +2567,7 @@ def __post_init__(self) -> None: "does not hash to the stored `code_challenge` with `invalid_grant`." ), transports=("streamable-http",), + note="Auth is enforced at the HTTP layer; the bundled AS is an ASGI app.", ), "hosting:auth:as:code-single-use": Requirement( source="sdk", @@ -2025,6 +2577,7 @@ def __post_init__(self) -> None: "the handler relies on `load_authorization_code` returning None." ), transports=("streamable-http",), + note="Auth is enforced at the HTTP layer; the bundled AS is an ASGI app.", ), "hosting:auth:as:redirect-uri-binding": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#open-redirection", @@ -2034,6 +2587,7 @@ def __post_init__(self) -> None: "`redirect_uri` not in the client's registered list without redirecting to it." ), transports=("streamable-http",), + note="Auth is enforced at the HTTP layer; the bundled AS is an ASGI app.", divergence=Divergence( note=( "RFC 6749 §5.2 assigns redirect_uri mismatch at the token endpoint to invalid_grant; " @@ -2048,6 +2602,7 @@ def __post_init__(self) -> None: "The bundled registration endpoint accepts only redirect URIs that use HTTPS or target a loopback host." ), transports=("streamable-http",), + note="Auth is enforced at the HTTP layer; the bundled AS is an ASGI app.", divergence=Divergence( note=( "Not enforced: the registration handler models redirect_uris as AnyUrl with no scheme or " @@ -2060,6 +2615,7 @@ def __post_init__(self) -> None: source="sdk", behavior=("Every token-endpoint response carries `Cache-Control: no-store` and `Pragma: no-cache`."), transports=("streamable-http",), + note="Auth is enforced at the HTTP layer; Cache-Control is an HTTP header.", ), "hosting:auth:as:register-error-response": Requirement( source="sdk", @@ -2068,6 +2624,7 @@ def __post_init__(self) -> None: "RFC 7591 error body." ), transports=("streamable-http",), + note="Auth is enforced at the HTTP layer; the bundled AS is an ASGI app.", ), # ═══════════════════════════════════════════════════════════════════════════ # Hosting: resumability @@ -2083,21 +2640,29 @@ def __post_init__(self) -> None: "events." ), ), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2575); Last-Event-ID resumability dropped, no replacement.", ), "hosting:resume:buffered-replay": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#resumability-and-redelivery", behavior="Notifications emitted while no client is connected are replayed in order on reconnect.", transports=("streamable-http",), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2575); SSE stream resumability/redelivery dropped, no replacement.", ), "hosting:resume:close-stream": Requirement( source="sdk", behavior="Handlers can close an SSE stream cleanly when an event store is configured.", transports=("streamable-http",), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2575); the event-store / resumability path is dropped, no replacement.", ), "hosting:resume:event-ids": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#resumability-and-redelivery", behavior="With an event store configured, every SSE event carries an id field.", transports=("streamable-http",), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2575); SSE event-id assignment for resumability dropped, no replacement.", ), "hosting:resume:priming": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#sending-messages-to-the-server", @@ -2114,16 +2679,25 @@ def __post_init__(self) -> None: "is configured and the negotiated protocol version is at least 2025-11-25." ), ), + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2575); the priming-event / retry-hint requirement is dropped with " + "resumability, no replacement." + ), ), "hosting:resume:replay": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#resumability-and-redelivery", behavior="GET with Last-Event-ID replays stored events for that stream after the given id.", transports=("streamable-http",), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2575); Last-Event-ID replay dropped, no replacement.", ), "hosting:resume:stream-scoped": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#resumability-and-redelivery", behavior="Replay via Last-Event-ID returns only messages from the stream that event id belongs to.", transports=("streamable-http",), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2575); Last-Event-ID replay dropped, no replacement.", ), # ═══════════════════════════════════════════════════════════════════════════ # Hosting: HTTP semantics @@ -2132,6 +2706,7 @@ def __post_init__(self) -> None: source="sdk", behavior="A request whose Accept header does not allow the response representation returns 406.", transports=("streamable-http",), + note="Only observable over HTTP: 406 is an HTTP status code.", ), "hosting:http:batch": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#sending-messages-to-the-server", @@ -2140,11 +2715,13 @@ def __post_init__(self) -> None: "that forbid them." ), transports=("streamable-http",), + note="Only observable over HTTP: POST-body framing is HTTP-specific.", ), "hosting:http:content-type-415": Requirement( source="sdk", behavior="A POST with a Content-Type other than application/json returns 415.", transports=("streamable-http",), + note="Only observable over HTTP: 415 is an HTTP status code.", divergence=Divergence( note=( "The transport-security middleware rejects a non-JSON Content-Type with 400 'Invalid " @@ -2160,6 +2737,11 @@ def __post_init__(self) -> None: "handler; the request continues and its result remains retrievable." ), transports=("streamable-http",), + removed_in="2026-07-28", + 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." + ), ), "hosting:http:dns-rebinding": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#security-warning", @@ -2168,6 +2750,7 @@ def __post_init__(self) -> None: "Origin is rejected with 403 Forbidden." ), transports=("streamable-http",), + note="Only observable over HTTP: Origin is an HTTP header.", divergence=Divergence( note=( "The spec's Origin validation is an unconditional MUST; the SDK enables it only when the " @@ -2181,11 +2764,13 @@ def __post_init__(self) -> None: source=f"{SPEC_BASE_URL}/basic/transports#sending-messages-to-the-server", behavior="With JSON response mode enabled, POST returns application/json instead of SSE.", transports=("streamable-http",), + note="Only observable over HTTP: response Content-Type is HTTP-specific.", ), "hosting:http:method-405": Requirement( source="sdk", behavior="An unsupported HTTP method on the MCP endpoint returns 405.", transports=("streamable-http",), + note="Only observable over HTTP: 405 is an HTTP status code.", ), "hosting:http:no-broadcast": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#multiple-connections", @@ -2194,16 +2779,23 @@ def __post_init__(self) -> None: "exactly one stream, never duplicated." ), transports=("streamable-http",), + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2567); the per-session multiple-connections section is removed with " + "Mcp-Session-Id, no replacement." + ), ), "hosting:http:notifications-202": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#sending-messages-to-the-server", behavior="A POST containing only notifications or responses returns 202 with no body.", transports=("streamable-http",), + note="Only observable over HTTP: 202 is an HTTP status code.", ), "hosting:http:onerror": Requirement( source="sdk", behavior="Transport-level rejections are reported through an error callback on the server transport.", transports=("streamable-http",), + note="Only observable over HTTP: these rejections happen at the HTTP framing layer.", deferred="Not implemented in the SDK: the server transport has no error callback; rejections are logged.", ), "hosting:http:parse-error-400": Requirement( @@ -2213,11 +2805,13 @@ def __post_init__(self) -> None: "the body may carry a JSON-RPC error response (the SDK sends a Parse error body)." ), transports=("streamable-http",), + note="Only observable over HTTP: 400 is an HTTP status code.", ), "hosting:http:protocol-version-400": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#protocol-version-header", behavior="An invalid or unsupported MCP-Protocol-Version header returns 400 Bad Request.", transports=("streamable-http",), + note="Only observable over HTTP: MCP-Protocol-Version is an HTTP header.", ), "hosting:http:protocol-version-default": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#protocol-version-header", @@ -2226,6 +2820,7 @@ def __post_init__(self) -> None: "way, the server assumes protocol version 2025-03-26." ), transports=("streamable-http",), + note="Only observable over HTTP: MCP-Protocol-Version is an HTTP header.", ), "hosting:http:response-same-connection": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#sending-messages-to-the-server", @@ -2234,21 +2829,27 @@ def __post_init__(self) -> None: "that stream's resumed continuation), not on an unrelated stream." ), transports=("streamable-http",), + note="Only observable over HTTP: SSE stream affinity is HTTP-specific.", ), "hosting:http:second-sse-rejected": Requirement( source="sdk", behavior="A second concurrent standalone GET SSE stream on the same session is rejected.", transports=("streamable-http",), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2575); the standalone GET stream is replaced by subscriptions/listen.", ), "hosting:http:sse-close-after-response": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#sending-messages-to-the-server", behavior="The server terminates a POST-initiated SSE stream after writing the JSON-RPC response.", transports=("streamable-http",), + note="Only observable over HTTP: SSE stream lifecycle is HTTP-specific.", ), "hosting:http:standalone-sse": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#listening-for-messages-from-the-server", behavior="GET opens a standalone SSE stream that receives server-initiated messages.", transports=("streamable-http",), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2575); the standalone GET endpoint is replaced by subscriptions/listen.", ), "hosting:http:standalone-sse-no-response": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#listening-for-messages-from-the-server", @@ -2257,6 +2858,8 @@ def __post_init__(self) -> None: "response, except when resuming a prior request stream." ), transports=("streamable-http",), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2575); the standalone GET endpoint is replaced by subscriptions/listen.", ), # ═══════════════════════════════════════════════════════════════════════════ # Client transport: streamable HTTP @@ -2265,6 +2868,7 @@ def __post_init__(self) -> None: source="sdk", behavior="A 404 (session expired) on a request surfaces as an error to the caller.", transports=("streamable-http",), + note="Only observable over HTTP: 404 is an HTTP status code.", ), "client-transport:http:session-404-reinitialize": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#session-management", @@ -2283,11 +2887,18 @@ def __post_init__(self) -> None: "Not implemented in the SDK: the client surfaces a Session terminated error instead of " "re-initializing (the surfaced error is pinned by client-transport:http:404-surfaces)." ), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2567); Mcp-Session-Id and protocol-level sessions removed, no replacement.", ), "client-transport:http:accept-header-get": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#listening-for-messages-from-the-server", behavior="The client GET to the MCP endpoint includes an Accept header listing text/event-stream.", transports=("streamable-http",), + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2575); the standalone GET endpoint is replaced by the subscriptions/listen " + "POST." + ), ), "client-transport:http:accept-header-post": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#sending-messages-to-the-server", @@ -2296,11 +2907,13 @@ def __post_init__(self) -> None: "and text/event-stream." ), transports=("streamable-http",), + note="Only observable over HTTP: Accept is an HTTP request header.", ), "client-transport:http:concurrent-streams": Requirement( source="sdk", behavior="Multiple concurrent POST-initiated SSE streams each deliver their response to the right caller.", transports=("streamable-http",), + note="Only observable over HTTP: per-request SSE streams are HTTP-specific.", ), "client-transport:http:custom-client": Requirement( source="sdk", @@ -2309,26 +2922,31 @@ def __post_init__(self) -> None: "including auth flows." ), transports=("streamable-http",), + note="Only observable over HTTP: the httpx client is HTTP-specific.", ), "client-transport:http:custom-headers": Requirement( source="sdk", behavior="Caller-supplied headers are sent on every POST, GET, and DELETE to the MCP endpoint.", transports=("streamable-http",), + note="Only observable over HTTP: headers are an HTTP concept.", ), "client-transport:http:json-response-parsed": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#sending-messages-to-the-server", behavior="A Content-Type application/json response is parsed as a single JSON-RPC message.", transports=("streamable-http",), + note="Only observable over HTTP: Content-Type is an HTTP response header.", ), "client-transport:http:no-reconnect-after-close": Requirement( source="sdk", behavior="After the transport is closed, no further reconnection attempts are scheduled.", transports=("streamable-http",), + note="Only observable over HTTP: stream reconnection is HTTP-specific.", ), "client-transport:http:no-reconnect-after-response": Requirement( source="sdk", behavior="A POST-initiated stream that already delivered its response is not reconnected when it closes.", transports=("streamable-http",), + note="Only observable over HTTP: stream reconnection is HTTP-specific.", ), "client-transport:http:protocol-version-header": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#protocol-version-header", @@ -2337,6 +2955,7 @@ def __post_init__(self) -> None: "subsequent HTTP request." ), transports=("streamable-http",), + note="Only observable over HTTP: MCP-Protocol-Version is an HTTP header.", ), "client-transport:http:protocol-version-stored": Requirement( source="sdk", @@ -2344,6 +2963,7 @@ def __post_init__(self) -> None: "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", @@ -2357,6 +2977,8 @@ def __post_init__(self) -> None: "would require accepting that real-time wait. The POST-stream reconnection path is covered " "by client-transport:http:reconnect-post-priming." ), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2575); SSE stream resumability/redelivery dropped, no replacement.", ), "client-transport:http:reconnect-post-priming": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#sending-messages-to-the-server", @@ -2365,11 +2987,15 @@ def __post_init__(self) -> None: "if a priming event (an event carrying an ID) was received on it." ), transports=("streamable-http",), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2575); SSE stream resumability/redelivery dropped, no replacement.", ), "client-transport:http:reconnect-retry-value": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#sending-messages-to-the-server", behavior="Reconnection delay honours the server-provided SSE retry value when one was sent.", transports=("streamable-http",), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2575); SSE stream resumability/redelivery dropped, no replacement.", ), "client-transport:http:resume-stream-api": Requirement( source="sdk", @@ -2378,6 +3004,8 @@ def __post_init__(self) -> None: "the notifications it missed." ), transports=("streamable-http",), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2575); SSE stream resumability/redelivery dropped, no replacement.", ), "client-transport:http:session-stored": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#session-management", @@ -2386,16 +3014,25 @@ def __post_init__(self) -> None: "every subsequent request." ), transports=("streamable-http",), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2567); Mcp-Session-Id and protocol-level sessions removed, no replacement.", ), "client-transport:http:sse-405-tolerated": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#listening-for-messages-from-the-server", behavior="Opening the standalone GET SSE stream tolerates a 405 response without failing the connection.", transports=("streamable-http",), + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2575); the standalone GET endpoint is replaced by the subscriptions/listen " + "POST." + ), ), "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).", transports=("streamable-http",), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2567); session DELETE removed with Mcp-Session-Id, no replacement.", ), # ═══════════════════════════════════════════════════════════════════════════ # Client auth @@ -2406,11 +3043,13 @@ def __post_init__(self) -> None: "If the server still returns 401 after a successful authorization, the client fails instead of looping." ), transports=("streamable-http",), + note="OAuth is HTTP-only.", ), "client-auth:401-triggers-flow": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#protected-resource-metadata-discovery-requirements", behavior="A 401 on a request triggers the OAuth authorization flow once.", transports=("streamable-http",), + note="OAuth is HTTP-only.", ), "client-auth:403-scope-upgrade": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#step-up-authorization-flow", @@ -2418,6 +3057,7 @@ def __post_init__(self) -> None: "A 403 with WWW-Authenticate triggers a scope-upgrade authorization attempt; repeated 403s do not loop." ), transports=("streamable-http",), + note="OAuth is HTTP-only.", ), "client-auth:as-metadata-discovery:priority-order": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-metadata-discovery", @@ -2427,6 +3067,7 @@ def __post_init__(self) -> None: "root-path forms when the issuer URL has no path)." ), transports=("streamable-http",), + note="OAuth is HTTP-only.", ), "client-auth:as-metadata-discovery:issuer-validation": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-metadata-discovery", @@ -2435,6 +3076,7 @@ def __post_init__(self) -> None: "metadata was retrieved from (RFC 8414 section 3.3)." ), transports=("streamable-http",), + note="OAuth is HTTP-only.", divergence=Divergence( note=( "The SDK parses authorization-server metadata without comparing issuer to the discovery " @@ -2449,6 +3091,7 @@ def __post_init__(self) -> None: "request is issued, surfacing as an error to the caller." ), transports=("streamable-http",), + note="OAuth is HTTP-only.", divergence=Divergence( note=( "The callback contract has no error form, so the client surfaces 'No authorization code " @@ -2464,6 +3107,7 @@ def __post_init__(self) -> None: "and prompt=consent is added to the authorize request." ), transports=("streamable-http",), + note="OAuth is HTTP-only.", ), "client-auth:bearer-header:every-request": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#token-requirements", @@ -2472,11 +3116,13 @@ def __post_init__(self) -> None: "request to the MCP server, never in the query string." ), transports=("streamable-http",), + note="OAuth is HTTP-only.", ), "client-auth:cimd": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#client-id-metadata-documents", behavior="The client can use a client-ID metadata document URL as its OAuth client_id instead of registration.", transports=("streamable-http",), + note="OAuth is HTTP-only.", ), "client-auth:client-credentials": Requirement( source="sdk", @@ -2485,6 +3131,7 @@ def __post_init__(self) -> None: "bearer token authorizes subsequent requests." ), transports=("streamable-http",), + note="OAuth is HTTP-only.", ), "client-auth:dcr:registration-error-surfaces": Requirement( source="sdk", @@ -2493,6 +3140,7 @@ def __post_init__(self) -> None: "carrying the status and the server's RFC 7591 error body." ), transports=("streamable-http",), + note="OAuth is HTTP-only.", ), "client-auth:dcr": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#dynamic-client-registration", @@ -2501,6 +3149,7 @@ def __post_init__(self) -> None: "client_id is preconfigured." ), transports=("streamable-http",), + note="OAuth is HTTP-only.", ), "client-auth:invalid-client-clears-all": Requirement( source="sdk", @@ -2508,6 +3157,7 @@ def __post_init__(self) -> None: "An invalid-client or unauthorized-client error during authorization invalidates all stored credentials." ), transports=("streamable-http",), + note="OAuth is HTTP-only.", divergence=Divergence( note=( "The token-response handlers do not parse the error body; an invalid_client or " @@ -2524,6 +3174,7 @@ def __post_init__(self) -> None: source="sdk", behavior="An invalid-grant error during authorization invalidates only the stored tokens.", transports=("streamable-http",), + note="OAuth is HTTP-only.", ), "client-auth:pkce:refuse-if-unsupported": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#authorization-code-protection", @@ -2532,6 +3183,7 @@ def __post_init__(self) -> None: "code_challenge_methods_supported, since PKCE support cannot be verified." ), transports=("streamable-http",), + note="OAuth is HTTP-only.", divergence=Divergence( note=( "The client never inspects code_challenge_methods_supported and proceeds with PKCE S256 " @@ -2546,6 +3198,7 @@ def __post_init__(self) -> None: "the matching verifier." ), transports=("streamable-http",), + note="OAuth is HTTP-only.", ), "client-auth:pre-registration": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#preregistration", @@ -2553,11 +3206,13 @@ def __post_init__(self) -> None: "A client with statically preconfigured credentials skips dynamic registration and uses them directly." ), transports=("streamable-http",), + note="OAuth is HTTP-only.", ), "client-auth:private-key-jwt": Requirement( source="sdk", behavior="The client can authenticate the client-credentials grant with a signed JWT assertion.", transports=("streamable-http",), + note="OAuth is HTTP-only.", ), "client-auth:prm-discovery:fallback-order": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#protected-resource-metadata-discovery-requirements", @@ -2566,6 +3221,7 @@ def __post_init__(self) -> None: "well-known protected-resource locations in the documented order." ), transports=("streamable-http",), + note="OAuth is HTTP-only.", ), "client-auth:prm-discovery:no-prm-fallback": Requirement( source="sdk", @@ -2575,6 +3231,7 @@ def __post_init__(self) -> None: "rather than aborting." ), transports=("streamable-http",), + note="OAuth is HTTP-only.", ), "client-auth:prm-resource-mismatch": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#authorization-server-location", @@ -2583,6 +3240,7 @@ def __post_init__(self) -> None: "match the server URL it is connecting to." ), transports=("streamable-http",), + note="OAuth is HTTP-only.", ), "client-auth:refresh:transparent": Requirement( source="sdk", @@ -2592,6 +3250,7 @@ def __post_init__(self) -> None: "and the new token is persisted." ), transports=("streamable-http",), + note="OAuth is HTTP-only.", ), "client-auth:resource-parameter": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#resource-parameter-implementation", @@ -2600,6 +3259,7 @@ def __post_init__(self) -> None: "authorization request and the token request." ), transports=("streamable-http",), + note="OAuth is HTTP-only.", ), "client-auth:scope-selection:priority": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#scope-selection-strategy", @@ -2608,6 +3268,7 @@ def __post_init__(self) -> None: "uses scopes_supported from the PRM document; otherwise omits scope." ), transports=("streamable-http",), + note="OAuth is HTTP-only.", divergence=Divergence( note=( "The SDK inserts an extra fallback step between PRM and omit: if the authorization " @@ -2623,11 +3284,13 @@ def __post_init__(self) -> None: "missing or mismatched state are discarded." ), transports=("streamable-http",), + note="OAuth is HTTP-only.", ), "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-provenance": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#token-handling", @@ -2636,6 +3299,7 @@ def __post_init__(self) -> None: "never tokens obtained elsewhere." ), transports=("streamable-http",), + note="OAuth is HTTP-only.", deferred=( "Untestable negative through the public API: there is no path to inject a token obtained " "elsewhere into the auth provider's state, so the absence cannot be observed end to end." @@ -2648,6 +3312,7 @@ def __post_init__(self) -> None: source=f"{SPEC_BASE_URL}/basic/lifecycle#shutdown", behavior="Closing the client transport closes the child process's stdin and the server exits cleanly.", transports=("stdio",), + note="Only observable over stdio: child-process lifecycle is stdio-specific.", ), "transport:stdio:stream-purity": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#stdio", @@ -2656,6 +3321,7 @@ def __post_init__(self) -> None: "is not a valid MCP message is written to its stdin." ), transports=("stdio",), + note="Only observable over stdio: stdin/stdout purity is stdio-specific.", divergence=Divergence( note=( "stdio_server's own writes satisfy this, but it does not redirect or guard sys.stdout: " @@ -2668,6 +3334,7 @@ def __post_init__(self) -> None: source=f"{SPEC_BASE_URL}/basic/transports#stdio", behavior="Serialized JSON-RPC messages on stdio contain no embedded newlines; one message per line.", transports=("stdio",), + note="Only observable over stdio: newline-delimited framing is stdio-specific.", ), "transport:stdio:shutdown-escalation": Requirement( source=f"{SPEC_BASE_URL}/basic/lifecycle#stdio", @@ -2676,6 +3343,7 @@ def __post_init__(self) -> None: "it (and kills it if still alive) after a grace period." ), transports=("stdio",), + note="Only observable over stdio: child-process lifecycle is stdio-specific.", deferred=( "A server that ignores stdin close takes the full PROCESS_TERMINATION_TIMEOUT (2.0 s) grace " "period plus up to a further 2.0 s for SIGTERM/SIGKILL escalation; testing that path is " @@ -2687,6 +3355,7 @@ def __post_init__(self) -> None: source="sdk", behavior="Server stderr is available to the client and is not consumed by the transport.", transports=("stdio",), + note="Only observable over stdio: stderr is a child-process stream.", ), # ═══════════════════════════════════════════════════════════════════════════ # Composite end-to-end flows @@ -2698,6 +3367,7 @@ def __post_init__(self) -> None: "concurrently; clients on either transport can call the same tools." ), transports=("streamable-http", "sse"), + note="Exercises both HTTP transports side by side; not applicable to stdio.", ), "flow:compat:streamable-then-sse-fallback": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#backwards-compatibility", @@ -2706,6 +3376,7 @@ def __post_init__(self) -> None: "SSE client transport against the same server connects successfully." ), transports=("streamable-http", "sse"), + note="Exercises the HTTP-to-SSE fallback path; not applicable to stdio.", divergence=Divergence( note=( "The SDK provides no automatic streamable-HTTP-to-SSE client fallback; the spec's " @@ -2725,6 +3396,10 @@ 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"), + ), ), "flow:elicitation:url-at-session-init": Requirement( source="sdk", @@ -2740,6 +3415,11 @@ def __post_init__(self) -> None: "client request' deterministically would also require knowing the standalone GET stream is " "established, which has no synchronization signal." ), + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2575); the standalone GET stream and session initialization are both gone, no " + "replacement." + ), ), "flow:elicitation:url-required-then-retry": Requirement( source=f"{SPEC_BASE_URL}/client/elicitation#url-elicitation-required-error", @@ -2747,6 +3427,12 @@ def __post_init__(self) -> None: "A tool call rejected with the URL-elicitation-required error can be retried successfully " "after the client completes the URL flow and the server announces completion." ), + removed_in="2026-07-28", + note=( + "removed in 2026-07-28 (SEP-2322); the -32042 + elicitation/complete flow is replaced by the MRTR " + "input_required/retry loop." + ), + arm_exclusions=(ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),), ), "flow:multi-client:stateful-isolation": Requirement( source="sdk", @@ -2755,6 +3441,8 @@ def __post_init__(self) -> None: "only the notifications produced by their own requests." ), transports=("streamable-http",), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2567); per-client Mcp-Session-Id sessions removed, no replacement.", ), "flow:oauth:authorization-code-roundtrip": Requirement( source=f"{SPEC_BASE_URL}/basic/authorization#authorization-flow-steps", @@ -2763,6 +3451,7 @@ def __post_init__(self) -> None: "attempt requires authorization, the code is exchanged, and a subsequent connection succeeds." ), transports=("streamable-http",), + note="OAuth is HTTP-only.", ), "flow:resume:tool-call-resumption-token": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#resumability-and-redelivery", @@ -2771,11 +3460,15 @@ def __post_init__(self) -> None: "the last-seen event id, delivering only the remaining notifications and the final result." ), transports=("streamable-http",), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2575); SSE stream resumability/redelivery dropped, no replacement.", ), "flow:session:terminate-then-reconnect": Requirement( source=f"{SPEC_BASE_URL}/basic/transports#session-management", behavior=("After terminating a session, a fresh connection obtains a new session id and operations succeed."), transports=("streamable-http",), + removed_in="2026-07-28", + note="removed in 2026-07-28 (SEP-2567); session DELETE removed with Mcp-Session-Id, no replacement.", ), "flow:tool-result:resource-link-follow": Requirement( source=f"{SPEC_BASE_URL}/server/tools#resource-links", @@ -2810,3 +3503,74 @@ def apply(test_fn: _TestFn) -> _TestFn: def covered_by(requirement_id: str) -> list[str]: """Return the (mutable) list of test names recorded as exercising `requirement_id`.""" return _COVERAGE.setdefault(requirement_id, []) + + +def cell_id(transport: Transport, version: SpecVersion, *, spec_versions: Sequence[SpecVersion] = SPEC_VERSIONS) -> str: + """Return the pytest node-id suffix for a (transport, spec_version) cell. + + While the active matrix has a single spec version, the suffix is just the transport name so + existing node ids stay byte-identical; once a second version is on the axis the suffix becomes + ``transport-version``. + """ + return transport if len(spec_versions) == 1 else f"{transport}-{version}" + + +def compute_cells( + requirements: Sequence[Requirement], + *, + spec_versions: Sequence[SpecVersion] = SPEC_VERSIONS, + transports: Sequence[Transport] = CONNECTABLE_TRANSPORTS, +) -> list[Any]: + """Compute the (transport, spec_version) parametrization cells for a test. + + Stacked ``@requirement`` decorators contribute multiple entries; the cells emitted are the + INTERSECTION across all of them: a cell is dropped if it falls outside any requirement's + ``[added_in, removed_in)`` window or matches any requirement's ``arm_exclusions``. An empty + ``requirements`` sequence yields the full transport x spec-version grid. + + ``Requirement.transports`` is intentionally NOT consulted -- it is descriptive metadata about + where a behaviour is observable, not a cell filter (only ``arm_exclusions`` / ``added_in`` / + ``removed_in`` drive cell generation). + + Returns a list of ``pytest.param((transport, version), id=..., marks=...)`` values for use as + ``metafunc.parametrize`` argvalues. + """ + cells: list[Any] = [] + for version in spec_versions: + version_ordinal = KNOWN_PROTOCOL_VERSIONS.index(version) + for transport in sorted(transports): + if transport in TRANSPORT_SPEC_VERSIONS and version not in TRANSPORT_SPEC_VERSIONS[transport]: + continue + # Requirement.transports is descriptive metadata only and does not filter cells. + if any( + (req.added_in is not None and version_ordinal < KNOWN_PROTOCOL_VERSIONS.index(req.added_in)) + or (req.removed_in is not None and version_ordinal >= KNOWN_PROTOCOL_VERSIONS.index(req.removed_in)) + for req in requirements + ): + continue + if any( + (ex.transport is None or ex.transport == transport) + and (ex.spec_version is None or ex.spec_version == version) + for req in requirements + for ex in req.arm_exclusions + ): + continue + matched_failure = next( + ( + kf + for req in requirements + for kf in req.known_failures + if (kf.transport is None or kf.transport == transport) + and (kf.spec_version is None or kf.spec_version == version) + ), + None, + ) + marks = [pytest.mark.xfail(reason=matched_failure.note, strict=True)] if matched_failure else () + cells.append( + pytest.param( + (transport, version), + id=cell_id(transport, version, spec_versions=spec_versions), + marks=marks, + ) + ) + return cells diff --git a/tests/interaction/conftest.py b/tests/interaction/conftest.py index c2ace4507..cc1ae5ee7 100644 --- a/tests/interaction/conftest.py +++ b/tests/interaction/conftest.py @@ -1,23 +1,48 @@ -"""Shared fixtures for the interaction suite.""" +"""Shared fixtures for the interaction suite. + +The ``connect`` fixture is parametrized per-test from the ``@requirement`` marks the test +carries: ``pytest_generate_tests`` looks up each cited requirement in the manifest and computes +the (transport, spec_version) cells via :func:`compute_cells`, applying arm exclusions, version +bounds, and known-failure xfails declaratively. +""" + +from functools import partial import pytest -from tests.interaction._connect import Connect, connect_in_memory, connect_over_sse, connect_over_streamable_http +from tests.interaction._connect import ( + Connect, + connect_in_memory, + connect_over_sse, + connect_over_streamable_http, + connect_over_streamable_http_stateless, +) +from tests.interaction._requirements import REQUIREMENTS, compute_cells _FACTORIES: dict[str, Connect] = { "in-memory": connect_in_memory, "streamable-http": connect_over_streamable_http, + "streamable-http-stateless": connect_over_streamable_http_stateless, "sse": connect_over_sse, } -@pytest.fixture(params=sorted(_FACTORIES)) +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """Parametrize ``connect`` from the test's stacked ``@requirement`` marks.""" + if "connect" not in metafunc.fixturenames: + return + requirements = [REQUIREMENTS[mark.args[0]] for mark in metafunc.definition.iter_markers("requirement")] + metafunc.parametrize("connect", compute_cells(requirements), indirect=True) + + +@pytest.fixture def connect(request: pytest.FixtureRequest) -> Connect: - """The transport-parametrized connection factory: a test using it runs once per transport. + """The transport-parametrized connection factory: a test using it runs once per matrix cell. Tests that are tied to one transport (the wire-recording tests, the bare-ClientSession tests, the transport-specific tests under transports/) do not use this fixture and connect directly. """ - transport_name = request.param - assert isinstance(transport_name, str) - return _FACTORIES[transport_name] + transport, spec_version = request.param + assert isinstance(transport, str) + assert isinstance(spec_version, str) + return partial(_FACTORIES[transport], protocol_version=spec_version) diff --git a/tests/interaction/test_coverage.py b/tests/interaction/test_coverage.py index 7821c1eed..02322f3b4 100644 --- a/tests/interaction/test_coverage.py +++ b/tests/interaction/test_coverage.py @@ -11,10 +11,29 @@ import re from pathlib import Path from types import ModuleType +from typing import cast import pytest -from tests.interaction._requirements import REQUIREMENTS, Requirement, covered_by, requirement +from mcp.shared.version import KNOWN_PROTOCOL_VERSIONS +from mcp.types import LATEST_PROTOCOL_VERSION +from tests.interaction._requirements import ( + CONNECTABLE_TRANSPORTS, + REQUIREMENTS, + SPEC_2026_BASE_URL, + SPEC_BASE_URL, + SPEC_VERSIONS, + ArmExclusion, + KnownFailure, + Requirement, + SpecVersion, + Transport, + cell_id, + compute_cells, + covered_by, + requirement, +) +from tests.interaction.conftest import _FACTORIES _SUITE_ROOT = Path(__file__).parent _REPO_ROOT = _SUITE_ROOT.parent.parent @@ -93,6 +112,87 @@ def test_deferral_reasons_cite_existing_paths() -> None: assert not missing, f"Deferral reasons citing paths that do not exist: {missing}" +def test_spec_versions_are_known_and_include_latest() -> None: + """Every active spec version is one the SDK knows about, and the SDK's latest is on the active axis.""" + assert set(SPEC_VERSIONS) <= set(KNOWN_PROTOCOL_VERSIONS) + assert LATEST_PROTOCOL_VERSION in SPEC_VERSIONS + + +def test_spec_base_urls_are_pinned_to_their_revision() -> None: + """SPEC_BASE_URL constants are pinned literals, so growing SPEC_VERSIONS cannot repoint existing source links.""" + assert SPEC_BASE_URL == "https://modelcontextprotocol.io/specification/2025-11-25" + assert SPEC_2026_BASE_URL == "https://modelcontextprotocol.io/specification/2026-07-28" + + +def test_connectable_transports_match_connect_factories() -> None: + """CONNECTABLE_TRANSPORTS and the conftest factory map name exactly the same transports.""" + assert set(CONNECTABLE_TRANSPORTS) == set(_FACTORIES) + + +def test_supersession_links_are_symmetric_and_versioned() -> None: + """``supersedes``/``superseded_by`` reference real entries, agree in both directions, and carry version bounds.""" + broken = [ + f"{req_id} -> {target}" + for req_id, req in REQUIREMENTS.items() + for target in req.supersedes + if target not in REQUIREMENTS or REQUIREMENTS[target].superseded_by != req_id or req.added_in is None + ] + [ + f"{req_id} <- {req.superseded_by}" + for req_id, req in REQUIREMENTS.items() + if req.superseded_by is not None + if req.superseded_by not in REQUIREMENTS + or req_id not in REQUIREMENTS[req.superseded_by].supersedes + or req.removed_in is None + ] + assert not broken, f"Broken supersession links (forward '->' or back '<-'): {broken}" + + +def test_removed_entry_has_disposition() -> None: + """Every retired requirement carries either a forward link or a prose note explaining the retirement.""" + undisposed = [ + req_id + for req_id, req in REQUIREMENTS.items() + if req.removed_in is not None and req.superseded_by is None and req.note is None + ] + assert not undisposed, f"Requirements with removed_in but no superseded_by or note: {undisposed}" + + +def test_transport_restriction_has_note() -> None: + """Every transport-restricted requirement carries a note explaining why it is transport-specific.""" + missing = [req_id for req_id, req in REQUIREMENTS.items() if req.transports is not None and req.note is None] + assert not missing, f"Requirements with transports= but no note: {missing}" + + +def test_every_arm_exclusion_targets_a_reachable_cell() -> None: + """Every arm exclusion names a connectable transport (or wildcards). + + spec_version is type-checked against the SpecVersion Literal and may reference a version not yet + on the active SPEC_VERSIONS axis, so pre-staged exclusions for an upcoming revision are permitted. + """ + unreachable = [ + f"{req_id}: {exclusion}" + for req_id, req in REQUIREMENTS.items() + for exclusion in req.arm_exclusions + if exclusion.transport is not None and exclusion.transport not in CONNECTABLE_TRANSPORTS + ] + assert not unreachable, f"Arm exclusions targeting unreachable cells: {unreachable}" + + +def test_every_known_failure_targets_a_reachable_cell() -> None: + """Every known failure names a connectable transport (or wildcards). + + spec_version is type-checked against the SpecVersion Literal and may reference a version not yet + on the active SPEC_VERSIONS axis, so pre-staged exclusions for an upcoming revision are permitted. + """ + unreachable = [ + f"{req_id}: {failure}" + for req_id, req in REQUIREMENTS.items() + for failure in req.known_failures + if failure.transport is not None and failure.transport not in CONNECTABLE_TRANSPORTS + ] + assert not unreachable, f"Known failures targeting unreachable cells: {unreachable}" + + def test_unknown_requirement_id_is_rejected() -> None: """Marking a test with an ID that is not in the manifest fails at decoration time.""" with pytest.raises(KeyError, match="Unknown requirement id 'tools:call:does-not-exist'"): @@ -103,3 +203,159 @@ def test_invalid_requirement_source_is_rejected() -> None: """A requirement whose source is not a spec URL, 'sdk', or an issue reference fails at construction.""" with pytest.raises(ValueError, match="source must be a specification URL"): Requirement(source="https://example.com/not-the-spec", behavior="Never constructed.") + + +def test_arm_exclusion_with_unknown_spec_version_is_rejected() -> None: + """An arm exclusion naming a spec version outside KNOWN_PROTOCOL_VERSIONS fails at construction.""" + with pytest.raises(ValueError, match="is not in KNOWN_PROTOCOL_VERSIONS"): + ArmExclusion(reason="requires-session", spec_version=cast("SpecVersion", "2099-01-01")) + + +def test_known_failure_with_empty_note_is_rejected() -> None: + """A known failure with a blank note fails at construction.""" + with pytest.raises(ValueError, match="note must be non-empty"): + KnownFailure(note=" ") + + +def test_known_failure_with_unknown_spec_version_is_rejected() -> None: + """A known failure naming a spec version outside KNOWN_PROTOCOL_VERSIONS fails at construction.""" + with pytest.raises(ValueError, match="is not in KNOWN_PROTOCOL_VERSIONS"): + KnownFailure(note="x", spec_version=cast("SpecVersion", "2099-01-01")) + + +def test_known_failure_with_malformed_issue_is_rejected() -> None: + """A known failure whose issue reference is neither '#' nor a GitHub URL fails at construction.""" + with pytest.raises(ValueError, match="must be '#' or a GitHub URL"): + KnownFailure(note="x", issue="not-a-link") + + +def test_requirement_with_unknown_added_in_is_rejected() -> None: + """A requirement whose added_in is outside KNOWN_PROTOCOL_VERSIONS fails at construction.""" + with pytest.raises(ValueError, match="added_in .* is not in KNOWN_PROTOCOL_VERSIONS"): + Requirement(source="sdk", behavior="x", added_in=cast("SpecVersion", "2099-01-01")) + + +def test_requirement_with_unknown_removed_in_is_rejected() -> None: + """A requirement whose removed_in is outside KNOWN_PROTOCOL_VERSIONS fails at construction.""" + with pytest.raises(ValueError, match="removed_in .* is not in KNOWN_PROTOCOL_VERSIONS"): + Requirement(source="sdk", behavior="x", removed_in=cast("SpecVersion", "2099-01-01")) + + +def test_requirement_with_empty_version_range_is_rejected() -> None: + """A requirement whose added_in is not strictly earlier than its removed_in fails at construction.""" + with pytest.raises(ValueError, match="must be earlier than"): + Requirement(source="sdk", behavior="x", added_in="2025-11-25", removed_in="2025-11-25") + + +def _req( + *, + added_in: SpecVersion | None = None, + removed_in: SpecVersion | None = None, + transports: tuple[Transport, ...] | None = None, + arm_exclusions: tuple[ArmExclusion, ...] = (), + known_failures: tuple[KnownFailure, ...] = (), +) -> Requirement: + """Build a synthetic Requirement for compute_cells() unit tests.""" + return Requirement( + source="sdk", + behavior="x", + added_in=added_in, + removed_in=removed_in, + transports=transports, + arm_exclusions=arm_exclusions, + known_failures=known_failures, + ) + + +def test_compute_cells_with_no_requirements_yields_full_grid() -> None: + """With a single-version axis, an empty requirement list yields one cell per connectable transport.""" + cells = compute_cells([], spec_versions=("2025-11-25",)) + assert [c.id for c in cells] == ["in-memory", "sse", "streamable-http", "streamable-http-stateless"] + assert [c.values for c in cells] == [ + (("in-memory", "2025-11-25"),), + (("sse", "2025-11-25"),), + (("streamable-http", "2025-11-25"),), + (("streamable-http-stateless", "2025-11-25"),), + ] + + +def test_compute_cells_intersects_stacked_version_ranges() -> None: + """Stacked requirements intersect their [added_in, removed_in) windows: a cell survives only if all admit it.""" + cells = compute_cells( + [_req(removed_in="2026-07-28"), _req(added_in="2025-11-25")], + spec_versions=("2025-11-25", "2026-07-28"), + ) + assert [c.id for c in cells] == [ + "in-memory-2025-11-25", + "sse-2025-11-25", + "streamable-http-2025-11-25", + "streamable-http-stateless-2025-11-25", + ] + + +def test_compute_cells_drops_era_locked_transport_outside_its_versions() -> None: + """A transport listed in TRANSPORT_SPEC_VERSIONS only appears for the spec versions it serves.""" + cells = compute_cells([], spec_versions=("2025-11-25", "2026-07-28")) + assert [c.id for c in cells] == [ + "in-memory-2025-11-25", + "sse-2025-11-25", + "streamable-http-2025-11-25", + "streamable-http-stateless-2025-11-25", + "in-memory-2026-07-28", + "streamable-http-2026-07-28", + "streamable-http-stateless-2026-07-28", + ] + + +def test_compute_cells_honours_arm_exclusion_from_any_stacked_requirement() -> None: + """An arm exclusion on any stacked requirement drops the matching cell even when other requirements have none.""" + cells = compute_cells( + [_req(), _req(arm_exclusions=(ArmExclusion(reason="requires-session", transport="sse"),))], + spec_versions=("2025-11-25",), + ) + assert [c.id for c in cells] == ["in-memory", "streamable-http", "streamable-http-stateless"] + + +def test_compute_cells_wildcard_arm_exclusion_drops_every_cell() -> None: + """An arm exclusion with both transport and spec_version unset matches every cell, leaving none.""" + cells = compute_cells([_req(arm_exclusions=(ArmExclusion(reason="requires-session"),))]) + assert cells == [] + + +def test_compute_cells_marks_known_failure_as_strict_xfail() -> None: + """A known failure attaches a strict xfail mark to exactly the matching cell and leaves others unmarked.""" + cells = compute_cells( + [_req(known_failures=(KnownFailure(note="broken on sse", transport="sse"),))], + spec_versions=("2025-11-25",), + ) + by_id = {c.id: c for c in cells} + assert set(by_id) == {"in-memory", "sse", "streamable-http", "streamable-http-stateless"} + assert by_id["sse"].marks[0].name == "xfail" + assert by_id["sse"].marks[0].kwargs == {"reason": "broken on sse", "strict": True} + assert by_id["in-memory"].marks == () + assert by_id["streamable-http"].marks == () + assert by_id["streamable-http-stateless"].marks == () + + +def test_compute_cells_wildcard_known_failure_marks_every_cell() -> None: + """A known failure with both transport and spec_version unset marks every emitted cell as strict xfail.""" + cells = compute_cells([_req(known_failures=(KnownFailure(note="all broken"),))], spec_versions=("2025-11-25",)) + assert len(cells) == 4 + assert all(c.marks[0].name == "xfail" for c in cells) + assert all(c.marks[0].kwargs == {"reason": "all broken", "strict": True} for c in cells) + + +def test_compute_cells_ignores_transports_field() -> None: + """Requirement.transports is descriptive metadata only and does not filter the cell grid.""" + cells = compute_cells([_req(transports=("stdio",))], spec_versions=("2025-11-25",)) + assert [c.id for c in cells] == list(CONNECTABLE_TRANSPORTS) + + +def test_cell_id_omits_version_when_single_spec_version() -> None: + """With a single-version axis the cell id is just the transport name.""" + assert cell_id("sse", "2025-11-25", spec_versions=("2025-11-25",)) == "sse" + + +def test_cell_id_appends_version_when_multiple_spec_versions() -> None: + """With more than one active spec version the cell id gains a - suffix.""" + assert cell_id("sse", "2025-11-25", spec_versions=("2025-11-25", "2026-07-28")) == "sse-2025-11-25"