First end-to-end 2026-07-28 stateless tools/call (experimental entry + ClientSession pin) (#2917)

This commit is contained in:
Max 2026-06-20 14:55:59 +01:00 • committed by GitHub
commit 84bf9bde05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -45,7 +45,7 @@ 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",)
SPEC_VERSIONS: tuple[SpecVersion, ...] = ("2025-11-25", "2026-07-28")
"""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"
@ -63,6 +63,13 @@ CONNECTABLE_TRANSPORTS: tuple[Transport, ...] = ("in-memory", "sse", "streamable
TRANSPORT_SPEC_VERSIONS: dict[Transport, tuple[SpecVersion, ...]] = {
"sse": ("2025-11-25",),
# Temporary lock: the in-memory transport has no modern entry point yet, so it cannot
# negotiate the newer revision. Remove once an in-memory factory for the modern path lands.
"in-memory": ("2025-11-25",),
# At the newer revision the protocol-version header check runs before the stateless branch is
# taken, so a stateless connection at that revision behaves identically to the stateful one.
# Locked to avoid a redundant matrix column; revisit if the header/stateless ordering changes.
"streamable-http-stateless": ("2025-11-25",),
}
"""Transports that only serve a subset of SPEC_VERSIONS. Absent => serves all. Consulted by compute_cells()."""
@ -89,6 +96,12 @@ _TASKS_DEFERRAL = (
"unimplemented."
)
_MODERN_NOTIFY_DROP = (
"SingleExchangeDispatcher.notify() no-ops on the modern streamable-http driver; handler-emitted "
"logging/progress notifications never reach the per-request SSE response. Passes once SSE "
"response mode lands."
)
@dataclass(frozen=True, kw_only=True)
class Divergence:
@ -205,14 +218,20 @@ REQUIREMENTS: dict[str, Requirement] = {
"Connecting sends initialize with the protocol version, client capabilities, and client "
"info; the server responds with its own and the connection is established."
),
removed_in="2026-07-28",
note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.",
),
"lifecycle:initialize:server-info": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization",
behavior="The initialize result identifies the server: name and version, plus title when declared.",
removed_in="2026-07-28",
note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.",
),
"lifecycle:initialize:instructions": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization",
behavior="A server may include an instructions string in the initialize result; the client exposes it.",
removed_in="2026-07-28",
note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.",
),
"lifecycle:initialize:capabilities:from-handlers": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#capability-negotiation",
@ -220,14 +239,20 @@ REQUIREMENTS: dict[str, Requirement] = {
"The server advertises a capability for each feature area it has a registered handler for, "
"and omits the capability for areas it does not."
),
removed_in="2026-07-28",
note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.",
),
"lifecycle:initialize:capabilities:minimal": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#capability-negotiation",
behavior="A server with no feature handlers advertises no feature capabilities.",
removed_in="2026-07-28",
note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.",
),
"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.",
removed_in="2026-07-28",
note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.",
arm_exclusions=(ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),),
),
"lifecycle:initialize:client-capabilities": Requirement(
@ -236,6 +261,8 @@ REQUIREMENTS: dict[str, Requirement] = {
"The client capabilities visible to the server reflect which client callbacks are configured "
"(sampling, elicitation, roots)."
),
removed_in="2026-07-28",
note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.",
arm_exclusions=(ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),),
),
"lifecycle:initialized-notification": Requirement(
@ -244,6 +271,8 @@ REQUIREMENTS: dict[str, Requirement] = {
"After successful initialization, the client sends exactly one initialized notification, "
"before any non-ping request."
),
removed_in="2026-07-28",
note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.",
),
"lifecycle:ping": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/ping#behavior-requirements",
@ -269,6 +298,8 @@ REQUIREMENTS: dict[str, Requirement] = {
behavior=(
"A request other than ping sent before the initialization handshake completes is rejected with an error."
),
removed_in="2026-07-28",
note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.",
),
"lifecycle:pre-initialization-ordering": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization",
@ -297,6 +328,8 @@ REQUIREMENTS: dict[str, Requirement] = {
"When the server returns an older supported protocol version, the client downgrades to it "
"and the connection succeeds at that version."
),
removed_in="2026-07-28",
note="initialize-time version negotiation removed at 2026-07-28; version carried per-request in _meta.",
),
"lifecycle:version:match": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#version-negotiation",
@ -304,6 +337,8 @@ REQUIREMENTS: dict[str, Requirement] = {
"When the server supports the requested protocol version it echoes that version in the "
"initialize result, and the connection proceeds at that version."
),
removed_in="2026-07-28",
note="initialize-time version negotiation removed at 2026-07-28; version carried per-request in _meta.",
),
"lifecycle:version:server-fallback-latest": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#version-negotiation",
@ -311,6 +346,8 @@ REQUIREMENTS: dict[str, Requirement] = {
"An initialize request carrying a protocol version the server does not support is answered "
"with another version the server supports — the latest one — rather than an error."
),
removed_in="2026-07-28",
note="initialize-time version negotiation removed at 2026-07-28; version carried per-request in _meta.",
),
"lifecycle:version:reject-unsupported": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#version-negotiation",
@ -318,6 +355,45 @@ REQUIREMENTS: dict[str, Requirement] = {
"A client that receives an initialize response carrying a protocol version it does not "
"support fails initialization with an error rather than proceeding with the session."
),
removed_in="2026-07-28",
note="initialize-time version negotiation removed at 2026-07-28; version carried per-request in _meta.",
),
"lifecycle:stateless:request-envelope": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#stateless-operation",
behavior=(
"At protocol_version 2026-07-28, every request carries io.modelcontextprotocol/protocolVersion, "
"/clientInfo, and /clientCapabilities in params._meta; no initialize handshake occurs."
),
added_in="2026-07-28",
),
"lifecycle:stateless:no-initialize": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#stateless-operation",
behavior=(
"A ClientSession pinned to 2026-07-28 is born initialized: initialize() is idempotent "
"and returns the synthesized result without any frame sent."
),
added_in="2026-07-28",
deferred="covered by a tests/client/ unit test; not observable as an interaction",
),
"lifecycle:stateless:caller-meta-preserved": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#stateless-operation",
behavior=(
"Caller-supplied _meta keys on a request survive the per-request envelope merge: the "
"three io.modelcontextprotocol/* envelope keys overwrite any caller-supplied values for "
"those keys; non-colliding caller keys are preserved."
),
added_in="2026-07-28",
),
"lifecycle:stateless:unpinned-legacy-wire": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/versioning",
behavior=(
"An unpinned session that negotiates an earlier protocol version emits no 2026-07-28 "
"vocabulary on any JSON-RPC frame in either direction."
),
deferred=(
"bare-ClientSession seam; the high-level Client + HTTP-seam scan in "
"hosting:http:legacy-no-modern-vocabulary covers the same vocabulary set"
),
),
# ═══════════════════════════════════════════════════════════════════════════
# Protocol primitives: cancellation, timeout, progress, errors, _meta
@ -439,6 +515,17 @@ REQUIREMENTS: dict[str, Requirement] = {
"leaks str(exc) as the error message."
),
),
arm_exclusions=(
ArmExclusion(
reason="modern-error-surface",
spec_version="2026-07-28",
note=(
"The modern entry maps Exception->INTERNAL_ERROR (-32603) with an opaque message, so the "
"2026 arm SATISFIES this requirement; the test pins the legacy code-0 divergence and "
"needs an era-aware assertion before re-admission."
),
),
),
),
"protocol:error:invalid-params": Requirement(
source=f"{SPEC_BASE_URL}/basic#responses",
@ -494,6 +581,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"Progress notifications emitted by a handler during a request are delivered to the caller's "
"progress callback, in order, with their progress, total, and message."
),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
),
"protocol:progress:token-injected": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
@ -506,6 +594,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"protocol:progress:token-unique": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
behavior=("Concurrent in-flight requests that each supply a progress callback carry distinct progress tokens."),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
),
"protocol:progress:monotonic": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
@ -518,6 +607,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"handler that emits non-increasing values has them forwarded to the callback unchanged."
),
),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
),
"protocol:progress:stops-after-completion": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/progress#behavior-requirements",
@ -551,6 +641,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"protocol:progress:client-to-server": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
behavior="A progress notification sent by the client is delivered to the server's progress handler.",
arm_exclusions=(ArmExclusion(reason="requires-session", spec_version="2026-07-28"),),
),
"protocol:timeout:basic": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#timeouts",
@ -654,6 +745,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"Log notifications emitted by a tool handler during execution reach the client's logging "
"callback before the tool result returns."
),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
),
"tools:call:progress": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
@ -661,6 +753,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"Progress notifications emitted by a tool handler reach the caller's progress callback before "
"the tool result returns."
),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
),
"tools:call:sampling-roundtrip": Requirement(
source=f"{SPEC_BASE_URL}/client/sampling#creating-messages",
@ -881,12 +974,14 @@ REQUIREMENTS: dict[str, Requirement] = {
"The Context logging helpers (debug/info/warning/error) send log message notifications at the "
"corresponding severity."
),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
),
"mcpserver:context:progress": Requirement(
source="sdk",
behavior=(
"Context.report_progress sends a progress notification against the requesting client's progress token."
),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
),
"mcpserver:context:elicit": Requirement(
source="sdk",
@ -1048,6 +1143,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"mcpserver:resource:read-throws-surfaced": Requirement(
source="sdk",
behavior="A resource function that raises is surfaced to the caller as a JSON-RPC error response.",
arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),),
),
"mcpserver:resource:static": Requirement(
source="sdk",
@ -1072,6 +1168,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"the low-level server converts to error code 0."
),
),
arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),),
),
# ═══════════════════════════════════════════════════════════════════════════
# Prompts
@ -1102,6 +1199,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"which the low-level server converts to error code 0 with the exception text as the message."
),
),
arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),),
),
"prompts:get:multi-message": Requirement(
source=f"{SPEC_BASE_URL}/server/prompts#getting-a-prompt",
@ -1144,6 +1242,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"mcpserver:prompt:args-validation": Requirement(
source=f"{SPEC_BASE_URL}/server/prompts#implementation-considerations",
behavior="prompts/get arguments that fail the prompt's argument schema are rejected before the function runs.",
arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),),
),
"mcpserver:prompt:decorated": Requirement(
source="sdk",
@ -1175,6 +1274,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"ValueError, which the low-level server converts to error code 0."
),
),
arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),),
),
# ═══════════════════════════════════════════════════════════════════════════
# Completion
@ -1241,6 +1341,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"logging:message:all-levels": Requirement(
source=f"{SPEC_BASE_URL}/server/utilities/logging#log-levels",
behavior="All eight RFC 5424 severity levels are deliverable as log message notifications.",
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
),
"logging:message:fields": Requirement(
source=f"{SPEC_BASE_URL}/server/utilities/logging#log-message-notifications",
@ -1248,6 +1349,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"A log message sent by a server handler is delivered to the client's logging callback with its "
"severity level, logger name, and data."
),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
),
"logging:message:filtered": Requirement(
source=f"{SPEC_BASE_URL}/server/utilities/logging#setting-log-level",
@ -1867,6 +1969,16 @@ REQUIREMENTS: dict[str, Requirement] = {
"client cannot learn that the set changed without polling."
),
),
known_failures=(
KnownFailure(
spec_version="2026-07-28",
note=(
"List-mutation assertions hold; only the sentinel ctx.info() never reaches the client. "
+ _MODERN_NOTIFY_DROP
),
issue=None,
),
),
),
# ═══════════════════════════════════════════════════════════════════════════
# Pagination
@ -2861,6 +2973,81 @@ REQUIREMENTS: dict[str, Requirement] = {
removed_in="2026-07-28",
note="removed in 2026-07-28 (SEP-2575); the standalone GET endpoint is replaced by subscriptions/listen.",
),
"hosting:http:protocol-version-rejection-literal": Requirement(
source="sdk",
behavior=(
"The legacy streamable-HTTP transport's version-rejection body contains the literal substring "
"'Unsupported protocol version', which other-SDK clients substring-match during negotiation."
),
transports=("streamable-http",),
note=(
"Only observable over streamable HTTP: cross-SDK clients sniff this exact substring in the rejection body."
),
),
"hosting:http:legacy-no-modern-vocabulary": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/versioning",
behavior=(
"A 2025-era streamable-HTTP exchange carries none of the 2026-07-28 wire vocabulary "
"(resultType, ttlMs, cacheScope, io.modelcontextprotocol/* _meta keys, the 2026-07-28 "
"version string, or Mcp-Method/Mcp-Name/Mcp-Param-* headers)."
),
transports=("streamable-http",),
note=(
"Only observable over streamable HTTP: the assertion records HTTP headers and SSE frames "
"at the transport seam."
),
),
"hosting:http:modern:tools-call-stateless": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http",
behavior=(
"A 2026-07-28 tools/call POST is served without an initialize handshake and returns a "
"result body carrying resultType: complete."
),
added_in="2026-07-28",
transports=("streamable-http",),
note=(
"Only observable over streamable HTTP: the modern entry handles a 2026-07-28 POST without "
"an initialize handshake."
),
),
"hosting:http:modern:no-session-id": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http",
behavior="A 2026-07-28 response never carries an Mcp-Session-Id header.",
added_in="2026-07-28",
transports=("streamable-http",),
note="Only observable over streamable HTTP: Mcp-Session-Id is a streamable-HTTP response header.",
),
"hosting:http:modern:initialize-removed": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/index",
behavior="A 2026-07-28 initialize request is answered with METHOD_NOT_FOUND.",
added_in="2026-07-28",
transports=("streamable-http",),
note=("Only observable over streamable HTTP: the modern entry's method registry omits initialize."),
),
"hosting:http:modern:legacy-fallthrough": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/versioning",
behavior=(
"Non-2026-07-28 traffic on the same /mcp endpoint reaches the legacy transport "
"byte-unchanged: a 2025-era initialize handshake still completes, and an unrecognised "
"MCP-Protocol-Version header still produces the legacy 400 'Unsupported protocol version' literal."
),
added_in="2026-07-28",
transports=("streamable-http",),
note=(
"Only observable over streamable HTTP: routing branches on the MCP-Protocol-Version "
"header at the same /mcp endpoint."
),
),
"hosting:http:modern:handler-exception-internal-error": Requirement(
source="sdk",
behavior=(
"An unhandled handler exception on the 2026-07-28 entry is returned as JSON-RPC error "
"-32603 with a generic message that does not echo str(exc)."
),
added_in="2026-07-28",
transports=("streamable-http",),
note="Only observable over streamable HTTP: the modern entry's exception-to-JSONRPCError boundary.",
),
# ═══════════════════════════════════════════════════════════════════════════
# Client transport: streamable HTTP
# ═══════════════════════════════════════════════════════════════════════════
@ -3034,6 +3221,27 @@ REQUIREMENTS: dict[str, Requirement] = {
removed_in="2026-07-28",
note="removed in 2026-07-28 (SEP-2567); session DELETE removed with Mcp-Session-Id, no replacement.",
),
"client-transport:http:body-derived-headers": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/transports#stateless-request-headers",
behavior=(
"An envelope-bearing request body yields MCP-Protocol-Version, Mcp-Method, and (for tools/call) "
"Mcp-Name headers on the outgoing HTTP request; a body without the envelope yields none."
),
added_in="2026-07-28",
transports=("streamable-http",),
note="Only observable over streamable HTTP: headers are derived from the body envelope at the transport seam.",
),
"client-transport:http:stateless-ignores-session-id": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/transports#stateless-request-headers",
behavior=(
"A pinned client never echoes a server-issued Mcp-Session-Id and never opens the standalone "
"GET stream or the closing DELETE: the recorded wire is POST-only."
),
added_in="2026-07-28",
transports=("streamable-http",),
note="Only observable over streamable HTTP: session-id, GET stream and DELETE are streamable-HTTP mechanics.",
deferred="defensive against a misbehaving peer; covered by a tests/client/ unit test",
),
# ═══════════════════════════════════════════════════════════════════════════
# Client auth
# ═══════════════════════════════════════════════════════════════════════════