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.
server:
# --- No stateless server path on main yet (carried-forward 2025-era scenarios) ---
# mcp-everything-server only runs in 2025 stateful mode. With
# --spec-version 2026-07-28 the harness sends stateless requests
# (MCP-Protocol-Version: 2026-07-28, _meta envelope, no initialize), which
# 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
# --- Carried-forward 2025-era scenarios still failing on the 2026 wire ---
# The stateless 2026 path now reaches handlers for plain request/response
# scenarios; tools-call-with-progress still fails because the stateless
# server has no channel for server→client progress notifications.
- 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
# 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
@ -130,12 +108,10 @@ server:
- input-required-result-result-type
- input-required-result-tampered-state
- input-required-result-capability-check
# SEP-2549 (caching): no ttlMs/cacheScope support.
- caching
- input-required-result-validate-input
# SEP-2243 (HTTP header standardization): -32020 HeaderMismatch handling and
# case-insensitive/whitespace-trimmed header validation not implemented.
- http-header-validation
- http-custom-header-server-validation
# --- WARNING-only entries ---
# These scenarios emit no FAILURE checks, only SHOULD-level WARNINGs, but
@ -143,7 +119,5 @@ server:
# as the draft suite in expected-failures.yml.
# SEP-2164: server returns -32600 (not -32602) and omits error.data.uri.
- sep-2164-resource-not-found
# SEP-2322 SHOULD-level behaviours (re-request missing inputResponses,
# ignore unrecognized inputResponses keys).
# SEP-2322 SHOULD-level behaviour (re-request missing inputResponses).
- 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-tampered-state
- 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
# case-insensitive/whitespace-trimmed header validation not implemented.
- http-header-validation
- http-custom-header-server-validation
# WARNING-only entries: these scenarios emit no FAILURE checks, only SHOULD-level
# WARNINGs, but the expected-failures evaluator counts WARNINGs as failures.
# SEP-2164: server returns -32600 (not -32602) and omits error.data.uri.
- sep-2164-resource-not-found
# SEP-2322 SHOULD-level behaviours (re-request missing inputResponses, ignore
# unrecognized inputResponses keys).
# SEP-2322 SHOULD-level behaviour (re-request missing inputResponses).
- input-required-result-missing-input-response
- input-required-result-ignore-extra-params
# Intentionally NOT baselined (2 of 19 draft scenarios): the SEP-2322
# negative-case scenarios input-required-result-unsupported-methods and
# input-required-result-validate-input pass today only because the stateful
# server's -32600 "Missing session ID" satisfies their assertions. They will
# start failing for real once stateless mode lands; add them then.
# SEP-2322 negative-case scenarios: input-required-result-validate-input is
# now baselined (added when the stateless path landed — the stateless server
# reaches the handler, so the previous accidental pass via -32600 "Missing
# session ID" no longer applies). input-required-result-unsupported-methods
# is intentionally NOT baselined: it still passes for now; add it once it
# 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
`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

View file

@ -92,6 +92,15 @@ class Client:
client_info: Implementation | None = None
"""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
"""Callback for handling elicitation requests."""
@ -103,7 +112,7 @@ class Client:
if isinstance(self.server, Server | MCPServer):
self._transport = InMemoryTransport(self.server, raise_exceptions=self.raise_exceptions)
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:
self._transport = self.server
@ -126,6 +135,7 @@ class Client:
message_handler=self.message_handler,
client_info=self.client_info,
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.session import RequestResponder
from mcp.shared.transport_context import TransportContext
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
from mcp.types import INTERNAL_ERROR, METHOD_NOT_FOUND, RequestId, RequestParamsMeta
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS, SUPPORTED_PROTOCOL_VERSIONS
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
DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0")
@ -141,11 +149,14 @@ class ClientSession:
message_handler: MessageHandlerFnT | None = None,
client_info: types.Implementation | None = None,
*,
protocol_version: str | None = None,
sampling_capabilities: types.SamplingCapability | None = None,
dispatcher: Dispatcher[Any] | None = None,
) -> None:
self._session_read_timeout_seconds = read_timeout_seconds
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_capabilities = sampling_capabilities
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._message_handler = message_handler or _default_message_handler
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
if dispatcher 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)
method: str = data["method"]
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 = (
request_read_timeout_seconds
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)
await self._dispatcher.notify(data["method"], data.get("params"))
async def initialize(self) -> types.InitializeResult:
def _build_capabilities(self) -> types.ClientCapabilities:
sampling = (
(self._sampling_capabilities or types.SamplingCapability())
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
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(
types.InitializeRequest(
params=types.InitializeRequestParams(
protocol_version=types.LATEST_PROTOCOL_VERSION,
capabilities=types.ClientCapabilities(
sampling=sampling,
elicitation=elicitation,
experimental=None,
roots=roots,
),
protocol_version=self._pinned_version
if self._pinned_version is not None
else types.LATEST_PROTOCOL_VERSION,
capabilities=capabilities,
client_info=self._client_info,
),
),
@ -303,14 +341,24 @@ class ClientSession:
def initialize_result(self) -> types.InitializeResult | None:
"""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
@property
def protocol_version(self) -> str | None:
"""The negotiated protocol version. None until `initialize()` has completed."""
return self._initialize_result.protocol_version if self._initialize_result else None
"""Negotiated or pinned protocol version. None until initialize() unless pinned at construction.
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:
"""Send a ping request."""

View file

@ -2,8 +2,10 @@
from __future__ import annotations as _annotations
import base64
import contextlib
import logging
import re
from collections.abc import AsyncGenerator, Awaitable, Callable
from contextlib import asynccontextmanager
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._httpx_utils import create_mcp_http_client
from mcp.shared.message import ClientMessageMetadata, SessionMessage
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
from mcp.types import (
INTERNAL_ERROR,
INVALID_REQUEST,
@ -44,12 +47,24 @@ StreamReader = ContextReceiveStream[SessionMessage]
MCP_SESSION_ID = "mcp-session-id"
MCP_PROTOCOL_VERSION = "mcp-protocol-version"
MCP_METHOD = "mcp-method"
MCP_NAME = "mcp-name"
LAST_EVENT_ID = "last-event-id"
# Reconnection defaults
DEFAULT_RECONNECTION_DELAY_MS = 1000 # 1 second fallback when server doesn't provide retry
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):
"""Base exception for StreamableHTTP transport errors."""
@ -73,15 +88,40 @@ class RequestContext:
class StreamableHTTPTransport:
"""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.
Args:
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.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]:
"""Build MCP-specific request headers.
@ -117,6 +157,9 @@ class StreamableHTTPTransport:
def _maybe_extract_protocol_version_from_message(self, message: JSONRPCMessage) -> None:
"""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
try:
# Parse the result as InitializeResult for type safety
@ -256,6 +299,7 @@ class StreamableHTTPTransport:
"""Handle a POST request with response processing."""
headers = self._prepare_headers()
message = ctx.session_message.message
headers.update(self._per_message_headers(message))
is_initialization = self._is_initialization_request(message)
async with ctx.client.stream(
@ -522,6 +566,7 @@ async def streamable_http_client(
*,
http_client: httpx.AsyncClient | None = None,
terminate_on_close: bool = True,
protocol_version: str | None = None,
) -> AsyncGenerator[TransportStreams, None]:
"""Client transport for StreamableHTTP.
@ -531,6 +576,8 @@ async def streamable_http_client(
client with recommended MCP timeouts will be created. To configure headers,
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.
protocol_version: Pin the MCP-Protocol-Version header for stateless 2026-07-28 sessions.
Tracer-bullet duplication — also pass to `ClientSession(protocol_version=...)`.
Yields:
Tuple containing:
@ -548,7 +595,7 @@ async def streamable_http_client(
# Create default client with recommended MCP timeouts
client = create_mcp_http_client()
transport = StreamableHTTPTransport(url)
transport = StreamableHTTPTransport(url, protocol_version=protocol_version)
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.session import ServerSession
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.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.message import MessageMetadata, ServerMessageMetadata
from mcp.shared.transport_context import TransportContext
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
@ -175,7 +174,7 @@ class ServerRunner(Generic[LifespanT]):
"""Per-connection orchestrator. One instance per client connection."""
server: Server[LifespanT]
dispatcher: JSONRPCDispatcher[Any]
dispatcher: Dispatcher[Any]
lifespan_state: LifespanT
has_standalone_channel: bool
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`.
"""
from typing import Any, TypeVar, overload
from typing import Any, TypeVar, cast, overload
from pydantic import AnyUrl, BaseModel
from mcp import types
from mcp.server.connection import Connection
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.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.message import ServerMessageMetadata
from mcp.types import methods as _methods
@ -37,7 +36,7 @@ class ServerSession:
def __init__(
self,
dispatcher: JSONRPCDispatcher[Any],
dispatcher: Dispatcher[Any],
connection: Connection,
*,
stateless: bool = False,
@ -92,8 +91,19 @@ class ServerSession:
# Fail fast instead of parking forever on a response that cannot
# arrive; matches `Connection.send_raw_request`.
raise NoBackChannelError(data["method"])
result = await self._dispatcher.send_raw_request(
data["method"], data.get("params"), opts or None, _related_request_id=related
# TODO: _related_request_id is not on the Dispatcher Protocol (and must not
# 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.
version = self.protocol_version or "2025-11-25"
@ -110,7 +120,7 @@ class ServerSession:
) -> None:
"""Send a typed server-to-client notification."""
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:
"""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.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.streamable_http import (
MCP_SESSION_ID_HEADER,
@ -22,6 +23,7 @@ from mcp.server.streamable_http import (
)
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared._compat import resync_tracer
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS
from mcp.types import INVALID_REQUEST, ErrorData, JSONRPCError
if TYPE_CHECKING:
@ -150,6 +152,13 @@ class StreamableHTTPSessionManager:
if self._task_group is None:
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
if self.stateless:
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."""
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]
"""Protocol revisions this SDK can negotiate."""

View file

@ -185,15 +185,17 @@ class PaginatedResult(Result):
class CacheableResult(Result):
"""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
default, so a handler answering at 2026-07-28 must set them explicitly.
Both fields are required on the 2026-07-28 wire. The SDK defaults to
`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
`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
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():
with patch("mcp.client.client.streamable_http_client") as mock:
_ = 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):

View file

@ -1400,6 +1400,66 @@ async def test_aenter_cancelled_while_dispatcher_starts_unwinds_cleanly():
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
async def test_send_notification_after_close_is_dropped_silently():
"""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,
client_info=client_info,
elicitation_callback=elicitation_callback,
protocol_version=protocol_version,
) as client:
yield client
@ -137,7 +138,7 @@ async def connect_over_streamable_http(
server.session_manager.run(),
httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http_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,
sampling_callback=sampling_callback,
list_roots_callback=list_roots_callback,
@ -145,6 +146,7 @@ async def connect_over_streamable_http(
message_handler=message_handler,
client_info=client_info,
elicitation_callback=elicitation_callback,
protocol_version=protocol_version,
) as client,
):
yield client
@ -166,6 +168,7 @@ async def mounted_app(
retry_interval: int | None = None,
transport_security: TransportSecuritySettings | None = NO_DNS_REBINDING_PROTECTION,
on_request: Callable[[httpx.Request], Awaitable[None]] | None = None,
on_response: Callable[[httpx.Response], Awaitable[None]] | None = None,
headers: dict[str, str] | None = None,
auth: AuthSettings | 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
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
session manager. `on_request` records every outgoing HTTP request before it leaves the
yielded client.
session manager. `on_request` observes every outgoing HTTP request before it leaves the
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
localhost auto-enable behaviour) to test the protection itself.
@ -194,7 +198,11 @@ async def mounted_app(
token_verifier=token_verifier,
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 (
server.session_manager.run(),
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
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
# ═══════════════════════════════════════════════════════════════════════════

View file

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

View file

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

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