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
26 changed files with 1515 additions and 93 deletions

View file

@ -82,33 +82,11 @@ client:
# neither run nor evaluated on this leg. # neither run nor evaluated on this leg.
server: server:
# --- No stateless server path on main yet (carried-forward 2025-era scenarios) --- # --- Carried-forward 2025-era scenarios still failing on the 2026 wire ---
# mcp-everything-server only runs in 2025 stateful mode. With # The stateless 2026 path now reaches handlers for plain request/response
# --spec-version 2026-07-28 the harness sends stateless requests # scenarios; tools-call-with-progress still fails because the stateless
# (MCP-Protocol-Version: 2026-07-28, _meta envelope, no initialize), which # server has no channel for server→client progress notifications.
# the server rejects before the handler runs. These scenarios all pass on
# the 2025 legs; they unblock once mcp-everything-server routes 2026
# requests through a stateless path.
- completion-complete
- tools-list
- tools-call-simple-text
- tools-call-image
- tools-call-audio
- tools-call-embedded-resource
- tools-call-mixed-content
- tools-call-error
- tools-call-with-progress - tools-call-with-progress
- server-sse-multiple-streams
- resources-list
- resources-read-text
- resources-read-binary
- resources-templates-read
- prompts-list
- prompts-get-simple
- prompts-get-with-args
- prompts-get-embedded-resource
- prompts-get-with-image
- dns-rebinding-protection
# SEP-2106 (JSON Schema 2020-12 in tool inputSchema): the fixture tool's # SEP-2106 (JSON Schema 2020-12 in tool inputSchema): the fixture tool's
# schema has none of the 2020-12 keywords the scenario checks. The scenario # schema has none of the 2020-12 keywords the scenario checks. The scenario
# is in `--suite all` but not `--suite active`, so this is the only leg that # is in `--suite all` but not `--suite active`, so this is the only leg that
@ -130,12 +108,10 @@ server:
- input-required-result-result-type - input-required-result-result-type
- input-required-result-tampered-state - input-required-result-tampered-state
- input-required-result-capability-check - input-required-result-capability-check
# SEP-2549 (caching): no ttlMs/cacheScope support. - input-required-result-validate-input
- caching
# SEP-2243 (HTTP header standardization): -32020 HeaderMismatch handling and # SEP-2243 (HTTP header standardization): -32020 HeaderMismatch handling and
# case-insensitive/whitespace-trimmed header validation not implemented. # case-insensitive/whitespace-trimmed header validation not implemented.
- http-header-validation - http-header-validation
- http-custom-header-server-validation
# --- WARNING-only entries --- # --- WARNING-only entries ---
# These scenarios emit no FAILURE checks, only SHOULD-level WARNINGs, but # These scenarios emit no FAILURE checks, only SHOULD-level WARNINGs, but
@ -143,7 +119,5 @@ server:
# as the draft suite in expected-failures.yml. # as the draft suite in expected-failures.yml.
# SEP-2164: server returns -32600 (not -32602) and omits error.data.uri. # SEP-2164: server returns -32600 (not -32602) and omits error.data.uri.
- sep-2164-resource-not-found - sep-2164-resource-not-found
# SEP-2322 SHOULD-level behaviours (re-request missing inputResponses, # SEP-2322 SHOULD-level behaviour (re-request missing inputResponses).
# ignore unrecognized inputResponses keys).
- input-required-result-missing-input-response - input-required-result-missing-input-response
- input-required-result-ignore-extra-params

View file

@ -65,23 +65,19 @@ server:
- input-required-result-result-type - input-required-result-result-type
- input-required-result-tampered-state - input-required-result-tampered-state
- input-required-result-capability-check - input-required-result-capability-check
# SEP-2549 (caching): no ttlMs/cacheScope support; scenario also hits the
# stateful-mode "Missing session ID" error.
- caching
# SEP-2243 (HTTP header standardization): -32020 HeaderMismatch handling and # SEP-2243 (HTTP header standardization): -32020 HeaderMismatch handling and
# case-insensitive/whitespace-trimmed header validation not implemented. # case-insensitive/whitespace-trimmed header validation not implemented.
- http-header-validation - http-header-validation
- http-custom-header-server-validation
# WARNING-only entries: these scenarios emit no FAILURE checks, only SHOULD-level # WARNING-only entries: these scenarios emit no FAILURE checks, only SHOULD-level
# WARNINGs, but the expected-failures evaluator counts WARNINGs as failures. # WARNINGs, but the expected-failures evaluator counts WARNINGs as failures.
# SEP-2164: server returns -32600 (not -32602) and omits error.data.uri. # SEP-2164: server returns -32600 (not -32602) and omits error.data.uri.
- sep-2164-resource-not-found - sep-2164-resource-not-found
# SEP-2322 SHOULD-level behaviours (re-request missing inputResponses, ignore # SEP-2322 SHOULD-level behaviour (re-request missing inputResponses).
# unrecognized inputResponses keys).
- input-required-result-missing-input-response - input-required-result-missing-input-response
- input-required-result-ignore-extra-params # SEP-2322 negative-case scenarios: input-required-result-validate-input is
# Intentionally NOT baselined (2 of 19 draft scenarios): the SEP-2322 # now baselined (added when the stateless path landed — the stateless server
# negative-case scenarios input-required-result-unsupported-methods and # reaches the handler, so the previous accidental pass via -32600 "Missing
# input-required-result-validate-input pass today only because the stateful # session ID" no longer applies). input-required-result-unsupported-methods
# server's -32600 "Missing session ID" satisfies their assertions. They will # is intentionally NOT baselined: it still passes for now; add it once it
# start failing for real once stateless mode lands; add them then. # starts failing for real.
- input-required-result-validate-input

View file

@ -1241,7 +1241,7 @@ If you relied on extra fields round-tripping through MCP types, move that data i
### 2025-11-25 and 2026-07-28 protocol fields modeled ### 2025-11-25 and 2026-07-28 protocol fields modeled
`mcp.types` models the 2025-11-25 and 2026-07-28 protocol fields (e.g. `resultType`, `ttlMs`/`cacheScope` on cacheable results, `inputResponses`/`requestState` on retried requests), so inbound payloads carrying these keys parse into typed fields and round-trip. `ttlMs`/`cacheScope` default to `None`; `resultType` defaults to `"complete"` on concrete results (`None` on `EmptyResult`); the server strips all of them from the wire at pre-2026 versions. `mcp.types` models the 2025-11-25 and 2026-07-28 protocol fields (e.g. `resultType`, `ttlMs`/`cacheScope` on cacheable results, `inputResponses`/`requestState` on retried requests), so inbound payloads carrying these keys parse into typed fields and round-trip. `ttlMs`/`cacheScope` default to `0`/`"private"` (immediately stale, not shared-cacheable); `resultType` defaults to `"complete"` on concrete results (`None` on `EmptyResult`); the server strips all of them from the wire at pre-2026 versions.
### `streamable_http_app()` available on lowlevel Server ### `streamable_http_app()` available on lowlevel Server

View file

@ -92,6 +92,15 @@ class Client:
client_info: Implementation | None = None client_info: Implementation | None = None
"""Client implementation info to send to server.""" """Client implementation info to send to server."""
protocol_version: str | None = None
"""Pin the protocol version instead of negotiating it.
Pinning to ``2026-07-28`` or later selects the stateless transport era: no initialize
handshake is sent on the wire (the session synthesizes its `InitializeResult` locally),
and for HTTP the ``MCP-Protocol-Version`` header is set from the first request.
Leave as ``None`` to negotiate the version via the initialize handshake.
"""
elicitation_callback: ElicitationFnT | None = None elicitation_callback: ElicitationFnT | None = None
"""Callback for handling elicitation requests.""" """Callback for handling elicitation requests."""
@ -103,7 +112,7 @@ class Client:
if isinstance(self.server, Server | MCPServer): if isinstance(self.server, Server | MCPServer):
self._transport = InMemoryTransport(self.server, raise_exceptions=self.raise_exceptions) self._transport = InMemoryTransport(self.server, raise_exceptions=self.raise_exceptions)
elif isinstance(self.server, str): elif isinstance(self.server, str):
self._transport = streamable_http_client(self.server) self._transport = streamable_http_client(self.server, protocol_version=self.protocol_version)
else: else:
self._transport = self.server self._transport = self.server
@ -126,6 +135,7 @@ class Client:
message_handler=self.message_handler, message_handler=self.message_handler,
client_info=self.client_info, client_info=self.client_info,
elicitation_callback=self.elicitation_callback, elicitation_callback=self.elicitation_callback,
protocol_version=self.protocol_version,
) )
) )

View file

@ -21,8 +21,16 @@ from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.message import ClientMessageMetadata, SessionMessage from mcp.shared.message import ClientMessageMetadata, SessionMessage
from mcp.shared.session import RequestResponder from mcp.shared.session import RequestResponder
from mcp.shared.transport_context import TransportContext from mcp.shared.transport_context import TransportContext
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS from mcp.shared.version import MODERN_PROTOCOL_VERSIONS, SUPPORTED_PROTOCOL_VERSIONS
from mcp.types import INTERNAL_ERROR, METHOD_NOT_FOUND, RequestId, RequestParamsMeta from mcp.types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
INTERNAL_ERROR,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
RequestId,
RequestParamsMeta,
)
from mcp.types import methods as _methods from mcp.types import methods as _methods
DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0") DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0")
@ -141,11 +149,14 @@ class ClientSession:
message_handler: MessageHandlerFnT | None = None, message_handler: MessageHandlerFnT | None = None,
client_info: types.Implementation | None = None, client_info: types.Implementation | None = None,
*, *,
protocol_version: str | None = None,
sampling_capabilities: types.SamplingCapability | None = None, sampling_capabilities: types.SamplingCapability | None = None,
dispatcher: Dispatcher[Any] | None = None, dispatcher: Dispatcher[Any] | None = None,
) -> None: ) -> None:
self._session_read_timeout_seconds = read_timeout_seconds self._session_read_timeout_seconds = read_timeout_seconds
self._client_info = client_info or DEFAULT_CLIENT_INFO self._client_info = client_info or DEFAULT_CLIENT_INFO
self._pinned_version = protocol_version
self._stateless_pinned = protocol_version in MODERN_PROTOCOL_VERSIONS
self._sampling_callback = sampling_callback or _default_sampling_callback self._sampling_callback = sampling_callback or _default_sampling_callback
self._sampling_capabilities = sampling_capabilities self._sampling_capabilities = sampling_capabilities
self._elicitation_callback = elicitation_callback or _default_elicitation_callback self._elicitation_callback = elicitation_callback or _default_elicitation_callback
@ -153,7 +164,19 @@ class ClientSession:
self._logging_callback = logging_callback or _default_logging_callback self._logging_callback = logging_callback or _default_logging_callback
self._message_handler = message_handler or _default_message_handler self._message_handler = message_handler or _default_message_handler
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {} self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
self._initialize_result: types.InitializeResult | None = None self._initialize_result: types.InitializeResult | None
if self._stateless_pinned:
assert protocol_version is not None
# A stateless-pinned session is born initialized: there is no handshake
# at 2026-07-28+, so we synthesize the result locally. `server_info` is a
# placeholder until `server/discover` is implemented to populate it.
self._initialize_result = types.InitializeResult(
protocol_version=protocol_version,
capabilities=types.ServerCapabilities(),
server_info=types.Implementation(name="", version=""),
)
else:
self._initialize_result = None
self._task_group: anyio.abc.TaskGroup | None = None self._task_group: anyio.abc.TaskGroup | None = None
if dispatcher is not None: if dispatcher is not None:
if read_stream is not None or write_stream is not None: if read_stream is not None or write_stream is not None:
@ -219,6 +242,19 @@ class ClientSession:
data = request.model_dump(by_alias=True, mode="json", exclude_none=True) data = request.model_dump(by_alias=True, mode="json", exclude_none=True)
method: str = data["method"] method: str = data["method"]
opts: CallOptions = {} opts: CallOptions = {}
if self._stateless_pinned:
params = data.setdefault("params", {})
envelope_meta = params.setdefault("_meta", {})
envelope_meta[PROTOCOL_VERSION_META_KEY] = self._pinned_version
envelope_meta[CLIENT_INFO_META_KEY] = self._client_info.model_dump(
by_alias=True, mode="json", exclude_none=True
)
envelope_meta[CLIENT_CAPABILITIES_META_KEY] = self._build_capabilities().model_dump(
by_alias=True, mode="json", exclude_none=True
)
# Stateless pinned mode: disconnect-as-cancel is the spec mechanism, so the
# dispatcher must not emit notifications/cancelled when the caller abandons.
opts["cancel_on_abandon"] = False
timeout = ( timeout = (
request_read_timeout_seconds request_read_timeout_seconds
if request_read_timeout_seconds is not None if request_read_timeout_seconds is not None
@ -254,7 +290,7 @@ class ClientSession:
data = notification.model_dump(by_alias=True, mode="json", exclude_none=True) data = notification.model_dump(by_alias=True, mode="json", exclude_none=True)
await self._dispatcher.notify(data["method"], data.get("params")) await self._dispatcher.notify(data["method"], data.get("params"))
async def initialize(self) -> types.InitializeResult: def _build_capabilities(self) -> types.ClientCapabilities:
sampling = ( sampling = (
(self._sampling_capabilities or types.SamplingCapability()) (self._sampling_capabilities or types.SamplingCapability())
if self._sampling_callback is not _default_sampling_callback if self._sampling_callback is not _default_sampling_callback
@ -273,17 +309,19 @@ class ClientSession:
if self._list_roots_callback is not _default_list_roots_callback if self._list_roots_callback is not _default_list_roots_callback
else None else None
) )
return types.ClientCapabilities(sampling=sampling, elicitation=elicitation, experimental=None, roots=roots)
async def initialize(self) -> types.InitializeResult:
if self._initialize_result is not None:
return self._initialize_result
capabilities = self._build_capabilities()
result = await self.send_request( result = await self.send_request(
types.InitializeRequest( types.InitializeRequest(
params=types.InitializeRequestParams( params=types.InitializeRequestParams(
protocol_version=types.LATEST_PROTOCOL_VERSION, protocol_version=self._pinned_version
capabilities=types.ClientCapabilities( if self._pinned_version is not None
sampling=sampling, else types.LATEST_PROTOCOL_VERSION,
elicitation=elicitation, capabilities=capabilities,
experimental=None,
roots=roots,
),
client_info=self._client_info, client_info=self._client_info,
), ),
), ),
@ -303,14 +341,24 @@ class ClientSession:
def initialize_result(self) -> types.InitializeResult | None: def initialize_result(self) -> types.InitializeResult | None:
"""The server's InitializeResult. None until initialize() has been called. """The server's InitializeResult. None until initialize() has been called.
Contains server_info, capabilities, instructions, and the negotiated protocol_version. A stateless-pinned session (protocol_version >= 2026-07-28) is born
initialized: this property is populated at construction with a
synthesized result and `initialize()` returns it without touching the
wire. Contains server_info, capabilities, instructions, and the
negotiated protocol_version.
""" """
return self._initialize_result return self._initialize_result
@property @property
def protocol_version(self) -> str | None: def protocol_version(self) -> str | None:
"""The negotiated protocol version. None until `initialize()` has completed.""" """Negotiated or pinned protocol version. None until initialize() unless pinned at construction.
return self._initialize_result.protocol_version if self._initialize_result else None
Once `initialize()` has completed, this is the version the server actually
negotiated (which can differ from a stateful pin); before that, the pin.
"""
if self._initialize_result is not None:
return self._initialize_result.protocol_version
return self._pinned_version
async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult: async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult:
"""Send a ping request.""" """Send a ping request."""

View file

@ -2,8 +2,10 @@
from __future__ import annotations as _annotations from __future__ import annotations as _annotations
import base64
import contextlib import contextlib
import logging import logging
import re
from collections.abc import AsyncGenerator, Awaitable, Callable from collections.abc import AsyncGenerator, Awaitable, Callable
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from dataclasses import dataclass from dataclasses import dataclass
@ -19,6 +21,7 @@ from mcp.shared._compat import resync_tracer
from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams from mcp.shared._context_streams import ContextReceiveStream, ContextSendStream, create_context_streams
from mcp.shared._httpx_utils import create_mcp_http_client from mcp.shared._httpx_utils import create_mcp_http_client
from mcp.shared.message import ClientMessageMetadata, SessionMessage from mcp.shared.message import ClientMessageMetadata, SessionMessage
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
from mcp.types import ( from mcp.types import (
INTERNAL_ERROR, INTERNAL_ERROR,
INVALID_REQUEST, INVALID_REQUEST,
@ -44,12 +47,24 @@ StreamReader = ContextReceiveStream[SessionMessage]
MCP_SESSION_ID = "mcp-session-id" MCP_SESSION_ID = "mcp-session-id"
MCP_PROTOCOL_VERSION = "mcp-protocol-version" MCP_PROTOCOL_VERSION = "mcp-protocol-version"
MCP_METHOD = "mcp-method"
MCP_NAME = "mcp-name"
LAST_EVENT_ID = "last-event-id" LAST_EVENT_ID = "last-event-id"
# Reconnection defaults # Reconnection defaults
DEFAULT_RECONNECTION_DELAY_MS = 1000 # 1 second fallback when server doesn't provide retry DEFAULT_RECONNECTION_DELAY_MS = 1000 # 1 second fallback when server doesn't provide retry
MAX_RECONNECTION_ATTEMPTS = 2 # Max retry attempts before giving up MAX_RECONNECTION_ATTEMPTS = 2 # Max retry attempts before giving up
_B64_SENTINEL = re.compile(r"^=\?base64\?.*\?=$")
# RFC 7230 token chars minus DEL; visible ASCII 0x20-0x7E is the practical bound for a header value.
_HEADER_SAFE = re.compile(r"^[\x20-\x7E]*$")
def _encode_header_value(value: str) -> str:
if _HEADER_SAFE.fullmatch(value) and value == value.strip() and not _B64_SENTINEL.fullmatch(value):
return value
return f"=?base64?{base64.b64encode(value.encode('utf-8')).decode('ascii')}?="
class StreamableHTTPError(Exception): class StreamableHTTPError(Exception):
"""Base exception for StreamableHTTP transport errors.""" """Base exception for StreamableHTTP transport errors."""
@ -73,15 +88,40 @@ class RequestContext:
class StreamableHTTPTransport: class StreamableHTTPTransport:
"""StreamableHTTP client transport implementation.""" """StreamableHTTP client transport implementation."""
def __init__(self, url: str) -> None: def __init__(self, url: str, protocol_version: str | None = None) -> None:
"""Initialize the StreamableHTTP transport. """Initialize the StreamableHTTP transport.
Args: Args:
url: The endpoint URL. url: The endpoint URL.
protocol_version: Pin the MCP-Protocol-Version header from the first request
instead of waiting to snoop it from an InitializeResult. Required for
stateless 2026-07-28 sessions that never send initialize.
""" """
self.url = url self.url = url
self.session_id: str | None = None self.session_id: str | None = None
self.protocol_version: str | None = None self.protocol_version: str | None = protocol_version
def _per_message_headers(self, message: JSONRPCMessage) -> dict[str, str]:
"""Per-POST routing headers (Mcp-Method, Mcp-Name) for 2026-07-28+ pinned transports.
MCP-Protocol-Version is not emitted here — `_prepare_headers()` already adds it
from `self.protocol_version` for every request.
"""
if self.protocol_version not in MODERN_PROTOCOL_VERSIONS:
return {}
if not isinstance(message, JSONRPCRequest | JSONRPCNotification):
return {}
headers: dict[str, str] = {MCP_METHOD: message.method}
# TODO: Mcp-Name is also REQUIRED for prompts/get (params.name) and resources/read
# (params.uri); a method->param-key map replaces this gate when those land.
if (
isinstance(message, JSONRPCRequest)
and message.method == "tools/call"
and message.params
and isinstance(name := message.params.get("name"), str)
):
headers[MCP_NAME] = _encode_header_value(name)
return headers
def _prepare_headers(self) -> dict[str, str]: def _prepare_headers(self) -> dict[str, str]:
"""Build MCP-specific request headers. """Build MCP-specific request headers.
@ -117,6 +157,9 @@ class StreamableHTTPTransport:
def _maybe_extract_protocol_version_from_message(self, message: JSONRPCMessage) -> None: def _maybe_extract_protocol_version_from_message(self, message: JSONRPCMessage) -> None:
"""Extract protocol version from initialization response message.""" """Extract protocol version from initialization response message."""
if self.protocol_version is not None:
# Constructor pin wins over snooping the InitializeResult.
return
if isinstance(message, JSONRPCResponse) and message.result: # pragma: no branch if isinstance(message, JSONRPCResponse) and message.result: # pragma: no branch
try: try:
# Parse the result as InitializeResult for type safety # Parse the result as InitializeResult for type safety
@ -256,6 +299,7 @@ class StreamableHTTPTransport:
"""Handle a POST request with response processing.""" """Handle a POST request with response processing."""
headers = self._prepare_headers() headers = self._prepare_headers()
message = ctx.session_message.message message = ctx.session_message.message
headers.update(self._per_message_headers(message))
is_initialization = self._is_initialization_request(message) is_initialization = self._is_initialization_request(message)
async with ctx.client.stream( async with ctx.client.stream(
@ -522,6 +566,7 @@ async def streamable_http_client(
*, *,
http_client: httpx.AsyncClient | None = None, http_client: httpx.AsyncClient | None = None,
terminate_on_close: bool = True, terminate_on_close: bool = True,
protocol_version: str | None = None,
) -> AsyncGenerator[TransportStreams, None]: ) -> AsyncGenerator[TransportStreams, None]:
"""Client transport for StreamableHTTP. """Client transport for StreamableHTTP.
@ -531,6 +576,8 @@ async def streamable_http_client(
client with recommended MCP timeouts will be created. To configure headers, client with recommended MCP timeouts will be created. To configure headers,
authentication, or other HTTP settings, create an httpx.AsyncClient and pass it here. authentication, or other HTTP settings, create an httpx.AsyncClient and pass it here.
terminate_on_close: If True, send a DELETE request to terminate the session when the context exits. terminate_on_close: If True, send a DELETE request to terminate the session when the context exits.
protocol_version: Pin the MCP-Protocol-Version header for stateless 2026-07-28 sessions.
Tracer-bullet duplication — also pass to `ClientSession(protocol_version=...)`.
Yields: Yields:
Tuple containing: Tuple containing:
@ -548,7 +595,7 @@ async def streamable_http_client(
# Create default client with recommended MCP timeouts # Create default client with recommended MCP timeouts
client = create_mcp_http_client() client = create_mcp_http_client()
transport = StreamableHTTPTransport(url) transport = StreamableHTTPTransport(url, protocol_version=protocol_version)
logger.debug(f"Connecting to StreamableHTTP endpoint: {url}") logger.debug(f"Connecting to StreamableHTTP endpoint: {url}")

View file

@ -0,0 +1,231 @@
"""Single-exchange HTTP serving for protocol version 2026-07-28.
Private module — entry is via `StreamableHTTPSessionManager.handle_request`.
The legacy streamable-HTTP transport is untouched and remains the supported
path for earlier protocol revisions.
A 2026-07-28 request is a self-contained POST: no `initialize` handshake, no
`Mcp-Session-Id`, one JSON-RPC request in, one JSON-RPC response out. This
module handles such a request directly in the ASGI task - no memory streams,
no per-request task group, no `JSONRPCDispatcher`.
"""
from __future__ import annotations
import logging
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
import anyio
import anyio.abc
from pydantic import ValidationError
from starlette.requests import Request
from starlette.responses import Response
from starlette.types import Receive, Scope, Send
from mcp.server.runner import (
_EXIT_STACK_CLOSE_TIMEOUT, # type: ignore[reportPrivateUsage]
ServerRunner,
otel_middleware,
)
from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings
from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest
from mcp.shared.exceptions import MCPError, NoBackChannelError
from mcp.shared.message import MessageMetadata, ServerMessageMetadata
from mcp.shared.transport_context import TransportContext
from mcp.types import (
INTERNAL_ERROR,
INVALID_PARAMS,
PARSE_ERROR,
ErrorData,
JSONRPCError,
JSONRPCRequest,
JSONRPCResponse,
RequestId,
)
if TYPE_CHECKING:
from mcp.server.lowlevel.server import Server
logger = logging.getLogger(__name__)
@dataclass
class _SingleExchangeDispatchContext:
"""`DispatchContext` for one inbound HTTP request.
Structurally satisfies `mcp.shared.dispatcher.DispatchContext`. The
back-channel is closed by construction: a 2026-07-28 server cannot send
requests to the client.
"""
transport: TransportContext
request_id: RequestId
message_metadata: MessageMetadata
cancel_requested: anyio.Event = field(default_factory=anyio.Event)
can_send_request: bool = False
async def send_raw_request(
self,
method: str,
params: Mapping[str, Any] | None,
opts: CallOptions | None = None,
) -> dict[str, Any]:
raise NoBackChannelError(method)
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
return None
async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None:
# TODO: no progressToken plumbing yet.
return None
class SingleExchangeDispatcher:
"""Dispatcher for exactly one inbound JSON-RPC request over a single HTTP POST.
The exception->wire boundary lives here (mirrors `JSONRPCDispatcher`'s
role). Implements the `Dispatcher` Protocol so `ServerRunner` /
`Connection` / `ServerSession` accept it; `run()` is never driven.
"""
def __init__(self, request: Request) -> None:
self._request = request
self._tctx = TransportContext(
kind="streamable-http",
can_send_request=False,
headers=request.headers,
)
async def send_raw_request(
self,
method: str,
params: Mapping[str, Any] | None,
opts: CallOptions | None = None,
*,
_related_request_id: RequestId | None = None,
) -> dict[str, Any]:
raise NoBackChannelError(method)
async def notify(
self,
method: str,
params: Mapping[str, Any] | None,
*,
_related_request_id: RequestId | None = None,
) -> None:
# TODO: buffer and stream as SSE once the response-mode design lands.
return None
async def run(
self,
on_request: OnRequest,
on_notify: OnNotify,
*,
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
) -> None:
raise RuntimeError("SingleExchangeDispatcher.run() is never driven; use handle()")
async def handle(self, req: JSONRPCRequest, on_request: OnRequest) -> JSONRPCResponse | JSONRPCError:
"""Dispatch one request and map any exception to a `JSONRPCError`."""
dctx = _SingleExchangeDispatchContext(
transport=self._tctx,
request_id=req.id,
message_metadata=ServerMessageMetadata(request_context=self._request),
)
try:
result = await on_request(dctx, req.method, req.params)
return JSONRPCResponse(jsonrpc="2.0", id=req.id, result=result)
except MCPError as e:
return JSONRPCError(jsonrpc="2.0", id=req.id, error=e.error)
except ValidationError:
return JSONRPCError(
jsonrpc="2.0",
id=req.id,
error=ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data=""),
)
# TODO: consolidate the three exception->ErrorData copies once the
# code=0 compat pin in JSONRPCDispatcher is lifted.
except Exception:
logger.exception("handler for %r raised", req.method)
return JSONRPCError(
jsonrpc="2.0",
id=req.id,
error=ErrorData(code=INTERNAL_ERROR, message="Internal server error"),
)
async def handle_modern_request(
app: Server[Any],
security_settings: TransportSecuritySettings | None,
protocol_version: str,
scope: Scope,
receive: Receive,
send: Send,
) -> None:
"""ASGI handler for a single stateless-era POST.
Called from `StreamableHTTPSessionManager.handle_request` when the
`MCP-Protocol-Version` header is in `MODERN_PROTOCOL_VERSIONS`; the header
value is passed as `protocol_version`. Never sets `Mcp-Session-Id`.
"""
request = Request(scope, receive)
security = TransportSecurityMiddleware(security_settings)
err = await security.validate_request(request, is_post=(request.method == "POST"))
if err is not None:
await err(scope, receive, send)
return
# TODO: validate Accept header once the JSON-vs-SSE response-mode design is settled.
if request.method != "POST":
# TODO: GET/DELETE rejection (405 + -32601) lands with the validation ladder.
await Response(status_code=405, headers={"Allow": "POST"})(scope, receive, send)
return
body = await request.body()
try:
req = JSONRPCRequest.model_validate_json(body)
except ValidationError:
msg = JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error"))
await Response(
msg.model_dump_json(by_alias=True),
status_code=400,
media_type="application/json",
)(scope, receive, send)
return
dispatcher = SingleExchangeDispatcher(request)
# TODO: per-request lifespan re-entry matches stateless_http=True today; revisit in #2893.
async with app.lifespan(app) as lifespan_state:
runner = ServerRunner(
server=app,
dispatcher=dispatcher,
lifespan_state=lifespan_state,
has_standalone_channel=False,
stateless=True,
dispatch_middleware=[otel_middleware],
)
runner.connection.protocol_version = protocol_version
try:
msg = await dispatcher.handle(req, runner._compose_on_request()) # type: ignore[reportPrivateUsage]
finally:
with anyio.move_on_after(_EXIT_STACK_CLOSE_TIMEOUT, shield=True) as cancel_scope:
try:
await runner.connection.exit_stack.aclose()
except Exception:
logger.exception("connection exit_stack cleanup raised")
if cancel_scope.cancelled_caught:
logger.warning(
"connection exit_stack cleanup exceeded %s seconds; abandoning remaining callbacks",
_EXIT_STACK_CLOSE_TIMEOUT,
)
# TODO: error.code -> HTTP status mapping is a follow-up; 200 for all JSONRPCError bodies for now.
await Response(
msg.model_dump_json(by_alias=True, exclude_none=True),
status_code=200,
media_type="application/json",
)(scope, receive, send)

View file

@ -31,9 +31,8 @@ from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, Server
from mcp.server.models import InitializationOptions from mcp.server.models import InitializationOptions
from mcp.server.session import ServerSession from mcp.server.session import ServerSession
from mcp.shared._otel import extract_trace_context, otel_span from mcp.shared._otel import extract_trace_context, otel_span
from mcp.shared.dispatcher import DispatchContext, DispatchMiddleware, OnRequest from mcp.shared.dispatcher import DispatchContext, Dispatcher, DispatchMiddleware, OnRequest
from mcp.shared.exceptions import MCPError from mcp.shared.exceptions import MCPError
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.message import MessageMetadata, ServerMessageMetadata from mcp.shared.message import MessageMetadata, ServerMessageMetadata
from mcp.shared.transport_context import TransportContext from mcp.shared.transport_context import TransportContext
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
@ -175,7 +174,7 @@ class ServerRunner(Generic[LifespanT]):
"""Per-connection orchestrator. One instance per client connection.""" """Per-connection orchestrator. One instance per client connection."""
server: Server[LifespanT] server: Server[LifespanT]
dispatcher: JSONRPCDispatcher[Any] dispatcher: Dispatcher[Any]
lifespan_state: LifespanT lifespan_state: LifespanT
has_standalone_channel: bool has_standalone_channel: bool
init_options: InitializationOptions | None = None init_options: InitializationOptions | None = None

View file

@ -9,16 +9,15 @@ The receive-loop, initialize handling, and per-request task isolation that
used to live here are now owned by `JSONRPCDispatcher` and `ServerRunner`. used to live here are now owned by `JSONRPCDispatcher` and `ServerRunner`.
""" """
from typing import Any, TypeVar, overload from typing import Any, TypeVar, cast, overload
from pydantic import AnyUrl, BaseModel from pydantic import AnyUrl, BaseModel
from mcp import types from mcp import types
from mcp.server.connection import Connection from mcp.server.connection import Connection
from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages from mcp.server.validation import validate_sampling_tools, validate_tool_use_result_messages
from mcp.shared.dispatcher import CallOptions, ProgressFnT from mcp.shared.dispatcher import CallOptions, Dispatcher, ProgressFnT
from mcp.shared.exceptions import NoBackChannelError, StatelessModeNotSupported from mcp.shared.exceptions import NoBackChannelError, StatelessModeNotSupported
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.message import ServerMessageMetadata from mcp.shared.message import ServerMessageMetadata
from mcp.types import methods as _methods from mcp.types import methods as _methods
@ -37,7 +36,7 @@ class ServerSession:
def __init__( def __init__(
self, self,
dispatcher: JSONRPCDispatcher[Any], dispatcher: Dispatcher[Any],
connection: Connection, connection: Connection,
*, *,
stateless: bool = False, stateless: bool = False,
@ -92,8 +91,19 @@ class ServerSession:
# Fail fast instead of parking forever on a response that cannot # Fail fast instead of parking forever on a response that cannot
# arrive; matches `Connection.send_raw_request`. # arrive; matches `Connection.send_raw_request`.
raise NoBackChannelError(data["method"]) raise NoBackChannelError(data["method"])
result = await self._dispatcher.send_raw_request( # TODO: _related_request_id is not on the Dispatcher Protocol (and must not
data["method"], data.get("params"), opts or None, _related_request_id=related # be — it's transport-specific). The fix is to give `ctx.session` a per-request
# Outbound (the DispatchContext, which threads its own request_id) alongside
# the connection-level one, with `related_request_id` as the selector; that
# belongs with the ServerSession/Context rework, not here.
result = cast(
"dict[str, Any]",
await self._dispatcher.send_raw_request(
data["method"],
data.get("params"),
opts or None,
_related_request_id=related, # type: ignore[call-arg]
),
) )
# Literal fallback covers pre-handshake and stateless; matches runner.py. # Literal fallback covers pre-handshake and stateless; matches runner.py.
version = self.protocol_version or "2025-11-25" version = self.protocol_version or "2025-11-25"
@ -110,7 +120,7 @@ class ServerSession:
) -> None: ) -> None:
"""Send a typed server-to-client notification.""" """Send a typed server-to-client notification."""
data = notification.model_dump(by_alias=True, mode="json", exclude_none=True) data = notification.model_dump(by_alias=True, mode="json", exclude_none=True)
await self._dispatcher.notify(data["method"], data.get("params"), _related_request_id=related_request_id) await self._dispatcher.notify(data["method"], data.get("params"), _related_request_id=related_request_id) # type: ignore[call-arg]
def check_client_capability(self, capability: types.ClientCapabilities) -> bool: def check_client_capability(self, capability: types.ClientCapabilities) -> bool:
"""Check if the client supports a specific capability.""" """Check if the client supports a specific capability."""

View file

@ -14,6 +14,7 @@ from starlette.requests import Request
from starlette.responses import Response from starlette.responses import Response
from starlette.types import Receive, Scope, Send from starlette.types import Receive, Scope, Send
from mcp.server._streamable_http_modern import handle_modern_request
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context
from mcp.server.streamable_http import ( from mcp.server.streamable_http import (
MCP_SESSION_ID_HEADER, MCP_SESSION_ID_HEADER,
@ -22,6 +23,7 @@ from mcp.server.streamable_http import (
) )
from mcp.server.transport_security import TransportSecuritySettings from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared._compat import resync_tracer from mcp.shared._compat import resync_tracer
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
from mcp.types import INVALID_REQUEST, ErrorData, JSONRPCError from mcp.types import INVALID_REQUEST, ErrorData, JSONRPCError
if TYPE_CHECKING: if TYPE_CHECKING:
@ -150,6 +152,13 @@ class StreamableHTTPSessionManager:
if self._task_group is None: if self._task_group is None:
raise RuntimeError("Task group is not initialized. Make sure to use run().") raise RuntimeError("Task group is not initialized. Make sure to use run().")
# TODO: header-only routing for now; body-primary classification
# (per SEP-2575) is a follow-up. 2025 paths below remain unchanged.
pv = next((v.decode("latin-1") for k, v in scope["headers"] if k == b"mcp-protocol-version"), None)
if pv in MODERN_PROTOCOL_VERSIONS:
await handle_modern_request(self.app, self.security_settings, pv, scope, receive, send)
return
# Dispatch to the appropriate handler # Dispatch to the appropriate handler
if self.stateless: if self.stateless:
await self._handle_stateless_request(scope, receive, send) await self._handle_stateless_request(scope, receive, send)

View file

@ -20,6 +20,9 @@ KNOWN_PROTOCOL_VERSIONS: Final[tuple[str, ...]] = (
) )
"""Every released protocol revision, oldest to newest.""" """Every released protocol revision, oldest to newest."""
MODERN_PROTOCOL_VERSIONS: Final[tuple[str, ...]] = ("2026-07-28",)
"""Protocol revisions that use the stateless per-request envelope."""
SUPPORTED_PROTOCOL_VERSIONS: list[str] = ["2024-11-05", "2025-03-26", "2025-06-18", LATEST_PROTOCOL_VERSION] SUPPORTED_PROTOCOL_VERSIONS: list[str] = ["2024-11-05", "2025-03-26", "2025-06-18", LATEST_PROTOCOL_VERSION]
"""Protocol revisions this SDK can negotiate.""" """Protocol revisions this SDK can negotiate."""

View file

@ -185,15 +185,17 @@ class PaginatedResult(Result):
class CacheableResult(Result): class CacheableResult(Result):
"""Base class for results that carry client-side caching directives (2026-07-28). """Base class for results that carry client-side caching directives (2026-07-28).
Both fields are required on the 2026-07-28 wire; the SDK declares no Both fields are required on the 2026-07-28 wire. The SDK defaults to
default, so a handler answering at 2026-07-28 must set them explicitly. `ttl_ms=0` (immediately stale) and `cache_scope="private"` so a handler
that doesn't set them still produces a valid 2026-07-28 result without
accidentally enabling shared caching.
""" """
ttl_ms: Annotated[int, Field(ge=0)] | None = None ttl_ms: Annotated[int, Field(ge=0)] = 0
"""How long (ms) the client MAY cache this response, analogous to HTTP """How long (ms) the client MAY cache this response, analogous to HTTP
`Cache-Control: max-age`. 0 means immediately stale.""" `Cache-Control: max-age`. 0 means immediately stale."""
cache_scope: Literal["public", "private"] | None = None cache_scope: Literal["public", "private"] = "private"
"""Analogous to HTTP `Cache-Control: public` vs `private`: "public" allows """Analogous to HTTP `Cache-Control: public` vs `private`: "public" allows
shared caches to serve the response to any user; "private" forbids that.""" shared caches to serve the response to any user; "private" forbids that."""

View file

@ -316,7 +316,7 @@ async def test_complete_with_prompt_reference(simple_server: Server):
def test_client_with_url_initializes_streamable_http_transport(): def test_client_with_url_initializes_streamable_http_transport():
with patch("mcp.client.client.streamable_http_client") as mock: with patch("mcp.client.client.streamable_http_client") as mock:
_ = Client("http://localhost:8000/mcp") _ = Client("http://localhost:8000/mcp")
mock.assert_called_once_with("http://localhost:8000/mcp") mock.assert_called_once_with("http://localhost:8000/mcp", protocol_version=None)
async def test_client_uses_transport_directly(app: MCPServer): async def test_client_uses_transport_directly(app: MCPServer):

View file

@ -1400,6 +1400,66 @@ async def test_aenter_cancelled_while_dispatcher_starts_unwinds_cleanly():
assert session._task_group is None assert session._task_group is None
@pytest.mark.anyio
async def test_initialize_on_a_stateless_pinned_session_returns_the_synthesized_result_without_any_frame_sent():
"""A session pinned to the 2026-07-28 stateless protocol is born initialized.
The 2026-07-28 lifecycle replaces the initialize handshake with a per-request ``_meta``
envelope, so ``initialize()`` is idempotent and returns a locally-synthesized result
without ever touching the wire.
"""
async with raw_client_session(protocol_version="2026-07-28") as (session, _send, from_client):
result = await session.initialize()
assert result.protocol_version == "2026-07-28"
assert isinstance(result.capabilities, ServerCapabilities)
assert from_client.statistics().current_buffer_used == 0
assert (await session.initialize()) is result
@pytest.mark.anyio
async def test_initialize_on_a_stateful_pin_requests_the_pinned_version():
"""A session pinned to a pre-2026 stateful version still runs the handshake, but the
outgoing ``initialize`` frame requests the pinned version rather than ``LATEST``."""
async with raw_client_session(protocol_version="2025-06-18") as (session, to_client, from_client):
first: list[InitializeResult] = []
async def do_initialize() -> None:
first.append(await session.initialize())
async with anyio.create_task_group() as tg:
tg.start_soon(do_initialize)
out = await from_client.receive()
assert isinstance(out.message, JSONRPCRequest)
assert out.message.params is not None
assert out.message.params["protocolVersion"] == "2025-06-18"
assert session.protocol_version == "2025-06-18"
# Server negotiates a different (older) supported version than the pin requested.
result = InitializeResult(
protocol_version="2025-03-26",
capabilities=ServerCapabilities(),
server_info=Implementation(name="mock-server", version="0.1.0"),
)
await to_client.send(
SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=out.message.id,
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
# Drain the notifications/initialized frame so the buffer-used assertion below
# measures only what the second initialize() emits.
notif = await from_client.receive()
assert isinstance(notif.message, JSONRPCNotification)
# The property reports the negotiated version, not the pin, once the handshake is done.
assert session.protocol_version == "2025-03-26"
# A second call returns the cached result without a second handshake frame.
again = await session.initialize()
assert again is first[0]
assert from_client.statistics().current_buffer_used == 0
@pytest.mark.anyio @pytest.mark.anyio
async def test_send_notification_after_close_is_dropped_silently(): async def test_send_notification_after_close_is_dropped_silently():
"""Post-close `send_notification` is fire-and-forget: the notification is dropped, """Post-close `send_notification` is fire-and-forget: the notification is dropped,

View file

@ -0,0 +1,151 @@
"""Unit tests for the streamable-HTTP client transport.
The full client<->server round trip is pinned by the interaction suite under
tests/interaction/transports/; these tests cover the transport's per-message header
derivation directly because the headers are an HTTP-seam observation the public client
never exposes.
"""
import base64
import json
import anyio
import httpx
import pytest
from inline_snapshot import snapshot
from mcp.client import ClientSession
from mcp.client.streamable_http import StreamableHTTPTransport, _encode_header_value, streamable_http_client
from mcp.types import JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse
@pytest.mark.parametrize(
("message", "expected"),
[
(
JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={"name": "add", "arguments": {}}),
snapshot({"mcp-method": "tools/call", "mcp-name": "add"}),
),
(
JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list", params={}),
snapshot({"mcp-method": "tools/list"}),
),
(
JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled"),
snapshot({"mcp-method": "notifications/cancelled"}),
),
(
JSONRPCResponse(jsonrpc="2.0", id=3, result={}),
snapshot({}),
),
],
)
def test_per_message_headers_for_pinned_transport_carry_method_and_name(
message: JSONRPCMessage, expected: dict[str, str]
) -> None:
"""A 2026-07-28-pinned transport derives ``Mcp-Method`` (and ``Mcp-Name`` for tools/call) from the body.
``MCP-Protocol-Version`` is not in the per-message set: ``_prepare_headers()`` adds it from the
pin for every request, so only the method/name advisory headers vary per POST. Responses yield
nothing because the spec only defines the headers for requests and notifications.
"""
transport = StreamableHTTPTransport("http://test/mcp", protocol_version="2026-07-28")
assert transport._per_message_headers(message) == expected # pyright: ignore[reportPrivateUsage]
@pytest.mark.parametrize("protocol_version", [None, "2025-11-25"])
def test_per_message_headers_are_empty_for_legacy_or_unpinned_transport(protocol_version: str | None) -> None:
"""An unpinned or 2025-era transport emits no per-message headers, keeping the wire byte-identical to v1."""
transport = StreamableHTTPTransport("http://test/mcp", protocol_version=protocol_version)
message = JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={"name": "add", "arguments": {}})
assert transport._per_message_headers(message) == {} # pyright: ignore[reportPrivateUsage]
@pytest.mark.parametrize(
("raw", "expected", "wrapped"),
[
("add", snapshot("add"), False),
("", snapshot(""), False),
("tool with spaces", snapshot("tool with spaces"), False),
(" add", snapshot("=?base64?IGFkZA==?="), True),
("add ", snapshot("=?base64?YWRkIA==?="), True),
("résumé", snapshot("=?base64?csOpc3Vtw6k=?="), True),
("a\r\nb", snapshot("=?base64?YQ0KYg==?="), True),
("=?base64?Zm9v?=", snapshot("=?base64?PT9iYXNlNjQ/Wm05dj89?="), True),
],
)
def test_mcp_name_header_values_are_base64_wrapped_when_unsafe_for_an_http_field(
raw: str, expected: str, wrapped: bool
) -> None:
"""Printable-ASCII names pass verbatim; CR/LF, non-ASCII, edge-whitespace, and sentinel-shaped names are wrapped.
The ``=?base64?...?=`` sentinel is the spec's RFC 7230 safety gate for the ``Mcp-Name`` header.
Wrapped values round-trip through base64 so the server can recover the original name. A leading
or trailing space is wrapped because RFC 7230 forbids it in field-values (h11 rejects on real
transports); an empty value is allowed and passes verbatim.
"""
encoded = _encode_header_value(raw)
assert encoded == expected
if wrapped:
assert encoded.startswith("=?base64?") and encoded.endswith("?=")
assert base64.b64decode(encoded.removeprefix("=?base64?").removesuffix("?=")).decode() == raw
else:
assert encoded == raw
@pytest.mark.anyio
async def test_pinned_transport_ignores_returned_session_id_and_never_opens_get_or_delete() -> None:
"""A server-issued ``Mcp-Session-Id`` never reaches a pinned client's wire: only POSTs are sent.
The session-id capture, the standalone GET listening stream, and the DELETE-on-close are all
gated implicitly: a pinned ``ClientSession`` never sends ``initialize`` (no InitializeResult to
capture an id from) and never sends ``notifications/initialized`` (which is what triggers the
standalone GET), so even when a misbehaving peer volunteers a session id on every response the
recorded log stays POST-only and no request echoes the id back. The successful ``tools/call``
triggers the client's implicit ``tools/list`` output-schema fetch so there is a second POST
after the id was offered.
"""
recorded: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
recorded.append(request)
body = json.loads(request.content)
if body["method"] == "tools/list":
result: dict[str, object] = {
"tools": [{"name": "add", "inputSchema": {"type": "object"}}],
"resultType": "complete",
"ttlMs": 0,
"cacheScope": "public",
}
else:
result = {"content": [{"type": "text", "text": "5"}], "isError": False, "resultType": "complete"}
return httpx.Response(
200, json={"jsonrpc": "2.0", "id": body["id"], "result": result}, headers={"mcp-session-id": "srv-123"}
)
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http, protocol_version="2026-07-28") as (read, write),
ClientSession(read, write, protocol_version="2026-07-28") as session,
):
await session.call_tool("add", {"a": 2, "b": 3})
assert [r.method for r in recorded] == snapshot(["POST", "POST"])
assert all("mcp-session-id" not in r.headers for r in recorded)
def test_constructor_pin_is_not_overwritten_by_an_initialize_result() -> None:
"""A protocol_version passed at construction wins over the InitializeResult snoop."""
transport = StreamableHTTPTransport("http://test/mcp", protocol_version="2026-07-28")
init = JSONRPCResponse(
jsonrpc="2.0",
id=1,
result={
"protocolVersion": "2025-11-25",
"capabilities": {},
"serverInfo": {"name": "s", "version": "0"},
},
)
transport._maybe_extract_protocol_version_from_message(init) # pyright: ignore[reportPrivateUsage]
assert transport.protocol_version == "2026-07-28"

View file

@ -97,6 +97,7 @@ async def connect_in_memory(
message_handler=message_handler, message_handler=message_handler,
client_info=client_info, client_info=client_info,
elicitation_callback=elicitation_callback, elicitation_callback=elicitation_callback,
protocol_version=protocol_version,
) as client: ) as client:
yield client yield client
@ -137,7 +138,7 @@ async def connect_over_streamable_http(
server.session_manager.run(), server.session_manager.run(),
httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http_client, httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http_client,
Client( Client(
streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client), streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client, protocol_version=protocol_version),
read_timeout_seconds=read_timeout_seconds, read_timeout_seconds=read_timeout_seconds,
sampling_callback=sampling_callback, sampling_callback=sampling_callback,
list_roots_callback=list_roots_callback, list_roots_callback=list_roots_callback,
@ -145,6 +146,7 @@ async def connect_over_streamable_http(
message_handler=message_handler, message_handler=message_handler,
client_info=client_info, client_info=client_info,
elicitation_callback=elicitation_callback, elicitation_callback=elicitation_callback,
protocol_version=protocol_version,
) as client, ) as client,
): ):
yield client yield client
@ -166,6 +168,7 @@ async def mounted_app(
retry_interval: int | None = None, retry_interval: int | None = None,
transport_security: TransportSecuritySettings | None = NO_DNS_REBINDING_PROTECTION, transport_security: TransportSecuritySettings | None = NO_DNS_REBINDING_PROTECTION,
on_request: Callable[[httpx.Request], Awaitable[None]] | None = None, on_request: Callable[[httpx.Request], Awaitable[None]] | None = None,
on_response: Callable[[httpx.Response], Awaitable[None]] | None = None,
headers: dict[str, str] | None = None, headers: dict[str, str] | None = None,
auth: AuthSettings | None = None, auth: AuthSettings | None = None,
token_verifier: TokenVerifier | None = None, token_verifier: TokenVerifier | None = None,
@ -177,8 +180,9 @@ async def mounted_app(
use this in two ways: for raw-httpx assertions (status codes, headers, SSE bytes) the test use this in two ways: for raw-httpx assertions (status codes, headers, SSE bytes) the test
speaks HTTP through the yielded client directly; for client-driven assertions the test wraps speaks HTTP through the yielded client directly; for client-driven assertions the test wraps
that client in `client_via_http(http)`, which lets several `Client`s share the one mounted that client in `client_via_http(http)`, which lets several `Client`s share the one mounted
session manager. `on_request` records every outgoing HTTP request before it leaves the session manager. `on_request` observes every outgoing HTTP request before it leaves the
yielded client. yielded client; `on_response` observes every HTTP response as its headers arrive (response
bodies of SSE streams are not yet read at that point).
DNS-rebinding protection is disabled by default; pass explicit settings (or `None` for the DNS-rebinding protection is disabled by default; pass explicit settings (or `None` for the
localhost auto-enable behaviour) to test the protection itself. localhost auto-enable behaviour) to test the protection itself.
@ -194,7 +198,11 @@ async def mounted_app(
token_verifier=token_verifier, token_verifier=token_verifier,
auth_server_provider=auth_server_provider, auth_server_provider=auth_server_provider,
) )
event_hooks = {"request": [on_request]} if on_request is not None else None event_hooks: dict[str, list[Callable[..., Awaitable[None]]]] = {}
if on_request is not None:
event_hooks["request"] = [on_request]
if on_response is not None:
event_hooks["response"] = [on_response]
async with ( async with (
server.session_manager.run(), server.session_manager.run(),
httpx.AsyncClient( httpx.AsyncClient(

View file

@ -0,0 +1,79 @@
"""Guard against 2026-era protocol vocabulary leaking onto legacy (2025-era) exchanges.
The 2026-07-28 spec revision introduces wire vocabulary that did not exist before it --
result-envelope fields (`resultType`, `ttlMs`, `cacheScope`), namespaced
`io.modelcontextprotocol/*` `_meta` keys, the version literal itself, and the per-request HTTP
headers `Mcp-Method` / `Mcp-Name` / `Mcp-Param-*`. None of that may appear on a connection
negotiated at an earlier protocol version: a test that records a plain legacy round trip and
runs it through :func:`assert_no_modern_vocabulary` will start failing the moment a 2026 change
leaks onto the existing wire.
Tests construct a :class:`RecordedExchange` from whatever instrumentation they have to hand --
the `on_request` / `on_response` hooks on :func:`tests.interaction._connect.mounted_app` for the
HTTP seam, and :class:`tests.interaction._helpers.RecordingTransport` for the JSON-RPC frames --
and pass it to the assertion. The helper scans header names and serialised bodies; it makes no
assumptions about which side produced what.
"""
from dataclasses import dataclass
import httpx
from mcp.types import JSONRPCMessage, jsonrpc_message_adapter
#: Substrings that must not appear anywhere in a request body or JSON-RPC frame on a legacy
#: exchange. Matching is by raw substring against the by-alias JSON serialisation, so a leaked
#: field name, `_meta` key prefix, or version literal is caught regardless of where in the
#: payload it sits.
MODERN_BODY_TOKENS: frozenset[str] = frozenset(
{
"resultType",
"ttlMs",
"cacheScope",
"io.modelcontextprotocol/",
"2026-07-28",
}
)
#: Lower-cased HTTP header names introduced by the 2026-07-28 transport.
MODERN_HEADER_NAMES: frozenset[str] = frozenset({"mcp-method", "mcp-name"})
#: Lower-cased prefix for the 2026-07-28 per-parameter header family.
MODERN_HEADER_PREFIX = "mcp-param-"
@dataclass
class RecordedExchange:
"""Everything a test captured from one streamable-HTTP conversation, for vocabulary scanning.
`requests` and `responses` are inspected for header names and (for requests) body bytes;
`frames` are re-serialised to their wire JSON and scanned as body text. Response bodies are
not read here -- streamable-HTTP responses are SSE streams that are consumed elsewhere -- so
the server-to-client body content must be supplied via `frames`.
"""
requests: list[httpx.Request]
responses: list[httpx.Response]
frames: list[JSONRPCMessage]
def assert_no_modern_vocabulary(recorded: RecordedExchange) -> None:
"""Fail if any 2026-era header name or body token appears anywhere in `recorded`.
All findings are collected before asserting so a single failure reports every leak.
"""
header_names = [name.lower() for request in recorded.requests for name in request.headers]
header_names += [name.lower() for response in recorded.responses for name in response.headers]
leaked = [
f"header {name!r}"
for name in header_names
if name in MODERN_HEADER_NAMES or name.startswith(MODERN_HEADER_PREFIX)
]
corpus = b"".join(request.content for request in recorded.requests).decode()
corpus += "".join(
jsonrpc_message_adapter.dump_json(frame, by_alias=True, exclude_none=True).decode() for frame in recorded.frames
)
leaked.extend(f"body token {token!r}" for token in MODERN_BODY_TOKENS if token in corpus)
assert not leaked, f"Modern (2026-07-28) protocol vocabulary on a legacy exchange: {leaked}"

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 """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.""" 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.""" """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" 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, ...]] = { TRANSPORT_SPEC_VERSIONS: dict[Transport, tuple[SpecVersion, ...]] = {
"sse": ("2025-11-25",), "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().""" """Transports that only serve a subset of SPEC_VERSIONS. Absent => serves all. Consulted by compute_cells()."""
@ -89,6 +96,12 @@ _TASKS_DEFERRAL = (
"unimplemented." "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) @dataclass(frozen=True, kw_only=True)
class Divergence: class Divergence:
@ -205,14 +218,20 @@ REQUIREMENTS: dict[str, Requirement] = {
"Connecting sends initialize with the protocol version, client capabilities, and client " "Connecting sends initialize with the protocol version, client capabilities, and client "
"info; the server responds with its own and the connection is established." "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( "lifecycle:initialize:server-info": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization", source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization",
behavior="The initialize result identifies the server: name and version, plus title when declared.", 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( "lifecycle:initialize:instructions": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization", source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization",
behavior="A server may include an instructions string in the initialize result; the client exposes it.", 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( "lifecycle:initialize:capabilities:from-handlers": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#capability-negotiation", 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, " "The server advertises a capability for each feature area it has a registered handler for, "
"and omits the capability for areas it does not." "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( "lifecycle:initialize:capabilities:minimal": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#capability-negotiation", source=f"{SPEC_BASE_URL}/basic/lifecycle#capability-negotiation",
behavior="A server with no feature handlers advertises no feature capabilities.", 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( "lifecycle:initialize:client-info": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization", source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization",
behavior="The client's name, version, and title are visible to server handlers after 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"),), arm_exclusions=(ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),),
), ),
"lifecycle:initialize:client-capabilities": Requirement( "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 " "The client capabilities visible to the server reflect which client callbacks are configured "
"(sampling, elicitation, roots)." "(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"),), arm_exclusions=(ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),),
), ),
"lifecycle:initialized-notification": Requirement( "lifecycle:initialized-notification": Requirement(
@ -244,6 +271,8 @@ REQUIREMENTS: dict[str, Requirement] = {
"After successful initialization, the client sends exactly one initialized notification, " "After successful initialization, the client sends exactly one initialized notification, "
"before any non-ping request." "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( "lifecycle:ping": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/ping#behavior-requirements", source=f"{SPEC_BASE_URL}/basic/utilities/ping#behavior-requirements",
@ -269,6 +298,8 @@ REQUIREMENTS: dict[str, Requirement] = {
behavior=( behavior=(
"A request other than ping sent before the initialization handshake completes is rejected with an error." "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( "lifecycle:pre-initialization-ordering": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization", 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 " "When the server returns an older supported protocol version, the client downgrades to it "
"and the connection succeeds at that version." "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( "lifecycle:version:match": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#version-negotiation", 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 " "When the server supports the requested protocol version it echoes that version in the "
"initialize result, and the connection proceeds at that version." "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( "lifecycle:version:server-fallback-latest": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#version-negotiation", 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 " "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." "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( "lifecycle:version:reject-unsupported": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#version-negotiation", 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 " "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." "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 # Protocol primitives: cancellation, timeout, progress, errors, _meta
@ -439,6 +515,17 @@ REQUIREMENTS: dict[str, Requirement] = {
"leaks str(exc) as the error message." "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( "protocol:error:invalid-params": Requirement(
source=f"{SPEC_BASE_URL}/basic#responses", 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 notifications emitted by a handler during a request are delivered to the caller's "
"progress callback, in order, with their progress, total, and message." "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( "protocol:progress:token-injected": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow", source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
@ -506,6 +594,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"protocol:progress:token-unique": Requirement( "protocol:progress:token-unique": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow", 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."), 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( "protocol:progress:monotonic": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow", 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." "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( "protocol:progress:stops-after-completion": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/progress#behavior-requirements", source=f"{SPEC_BASE_URL}/basic/utilities/progress#behavior-requirements",
@ -551,6 +641,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"protocol:progress:client-to-server": Requirement( "protocol:progress:client-to-server": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow", 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.", 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( "protocol:timeout:basic": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#timeouts", 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 " "Log notifications emitted by a tool handler during execution reach the client's logging "
"callback before the tool result returns." "callback before the tool result returns."
), ),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
), ),
"tools:call:progress": Requirement( "tools:call:progress": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow", 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 " "Progress notifications emitted by a tool handler reach the caller's progress callback before "
"the tool result returns." "the tool result returns."
), ),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
), ),
"tools:call:sampling-roundtrip": Requirement( "tools:call:sampling-roundtrip": Requirement(
source=f"{SPEC_BASE_URL}/client/sampling#creating-messages", 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 " "The Context logging helpers (debug/info/warning/error) send log message notifications at the "
"corresponding severity." "corresponding severity."
), ),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
), ),
"mcpserver:context:progress": Requirement( "mcpserver:context:progress": Requirement(
source="sdk", source="sdk",
behavior=( behavior=(
"Context.report_progress sends a progress notification against the requesting client's progress token." "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( "mcpserver:context:elicit": Requirement(
source="sdk", source="sdk",
@ -1048,6 +1143,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"mcpserver:resource:read-throws-surfaced": Requirement( "mcpserver:resource:read-throws-surfaced": Requirement(
source="sdk", source="sdk",
behavior="A resource function that raises is surfaced to the caller as a JSON-RPC error response.", 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( "mcpserver:resource:static": Requirement(
source="sdk", source="sdk",
@ -1072,6 +1168,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"the low-level server converts to error code 0." "the low-level server converts to error code 0."
), ),
), ),
arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),),
), ),
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
# Prompts # 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." "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( "prompts:get:multi-message": Requirement(
source=f"{SPEC_BASE_URL}/server/prompts#getting-a-prompt", source=f"{SPEC_BASE_URL}/server/prompts#getting-a-prompt",
@ -1144,6 +1242,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"mcpserver:prompt:args-validation": Requirement( "mcpserver:prompt:args-validation": Requirement(
source=f"{SPEC_BASE_URL}/server/prompts#implementation-considerations", 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.", 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( "mcpserver:prompt:decorated": Requirement(
source="sdk", source="sdk",
@ -1175,6 +1274,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"ValueError, which the low-level server converts to error code 0." "ValueError, which the low-level server converts to error code 0."
), ),
), ),
arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),),
), ),
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
# Completion # Completion
@ -1241,6 +1341,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"logging:message:all-levels": Requirement( "logging:message:all-levels": Requirement(
source=f"{SPEC_BASE_URL}/server/utilities/logging#log-levels", source=f"{SPEC_BASE_URL}/server/utilities/logging#log-levels",
behavior="All eight RFC 5424 severity levels are deliverable as log message notifications.", 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( "logging:message:fields": Requirement(
source=f"{SPEC_BASE_URL}/server/utilities/logging#log-message-notifications", 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 " "A log message sent by a server handler is delivered to the client's logging callback with its "
"severity level, logger name, and data." "severity level, logger name, and data."
), ),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
), ),
"logging:message:filtered": Requirement( "logging:message:filtered": Requirement(
source=f"{SPEC_BASE_URL}/server/utilities/logging#setting-log-level", 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." "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 # Pagination
@ -2861,6 +2973,81 @@ REQUIREMENTS: dict[str, Requirement] = {
removed_in="2026-07-28", removed_in="2026-07-28",
note="removed in 2026-07-28 (SEP-2575); the standalone GET endpoint is replaced by subscriptions/listen.", 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 # Client transport: streamable HTTP
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
@ -3034,6 +3221,27 @@ REQUIREMENTS: dict[str, Requirement] = {
removed_in="2026-07-28", removed_in="2026-07-28",
note="removed in 2026-07-28 (SEP-2567); session DELETE removed with Mcp-Session-Id, no replacement.", 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 # Client auth
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════

View file

@ -121,6 +121,7 @@ async def test_context_exposes_request_id_and_client_info_to_a_tool(connect: Con
assert request_id assert request_id
@requirement("mcpserver:context:logging")
@requirement("protocol:progress:no-token") @requirement("protocol:progress:no-token")
async def test_report_progress_without_a_progress_token_sends_nothing(connect: Connect) -> None: async def test_report_progress_without_a_progress_token_sends_nothing(connect: Connect) -> None:
"""When the caller supplied no progress callback, Context.report_progress is a silent no-op. """When the caller supplied no progress callback, Context.report_progress is a silent no-op.

View file

@ -301,9 +301,7 @@ def test_compute_cells_drops_era_locked_transport_outside_its_versions() -> None
"sse-2025-11-25", "sse-2025-11-25",
"streamable-http-2025-11-25", "streamable-http-2025-11-25",
"streamable-http-stateless-2025-11-25", "streamable-http-stateless-2025-11-25",
"in-memory-2026-07-28",
"streamable-http-2026-07-28", "streamable-http-2026-07-28",
"streamable-http-stateless-2026-07-28",
] ]

Some files were not shown because too many files have changed in this diff Show more