From c0397c075fd74fa19adae6a3d80d156a2b1f0ad7 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:18:55 +0000 Subject: [PATCH 01/21] Add experimental 2026-07-28 stateless HTTP serving entry Routes MCP-Protocol-Version: 2026-07-28 requests at the session-manager seam to a new direct-invocation handler in mcp.server._experimental, leaving the existing 2025-era paths (stateful and stateless_http) unchanged. The new handler builds a fresh per-request ServerRunner over a single-exchange Dispatcher implementation (no memory streams, no JSONRPCDispatcher), pre-commits the connection to 2026-07-28, runs the composed on_request directly in the request task, and writes a JSON response. Server-to-client requests raise NoBackChannelError; notifications no-op pending SSE streaming. Dispatcher annotations on ServerRunner/ServerSession widened from JSONRPCDispatcher to the Dispatcher Protocol. The module is experimental and not part of the public API. Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK --- src/mcp/server/_experimental/__init__.py | 1 + .../_experimental/streamable_http_modern.py | 219 ++++++++++++++++++ src/mcp/server/runner.py | 5 +- src/mcp/server/session.py | 21 +- src/mcp/server/streamable_http_manager.py | 8 + 5 files changed, 244 insertions(+), 10 deletions(-) create mode 100644 src/mcp/server/_experimental/__init__.py create mode 100644 src/mcp/server/_experimental/streamable_http_modern.py diff --git a/src/mcp/server/_experimental/__init__.py b/src/mcp/server/_experimental/__init__.py new file mode 100644 index 0000000000..669c31c28f --- /dev/null +++ b/src/mcp/server/_experimental/__init__.py @@ -0,0 +1 @@ +"""Experimental, unstable. No public API; may change or vanish without deprecation.""" diff --git a/src/mcp/server/_experimental/streamable_http_modern.py b/src/mcp/server/_experimental/streamable_http_modern.py new file mode 100644 index 0000000000..9e16033753 --- /dev/null +++ b/src/mcp/server/_experimental/streamable_http_modern.py @@ -0,0 +1,219 @@ +"""Experimental, unstable. Single-exchange HTTP serving for protocol version 2026-07-28. + +No public API; everything in this module may change or vanish without +deprecation. The legacy streamable-HTTP transport is untouched and remains the +supported entry point. + +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, Final + +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 ServerRunner, otel_middleware +from mcp.server.transport_security import TransportSecurityMiddleware +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.streamable_http_manager import StreamableHTTPSessionManager + +logger = logging.getLogger(__name__) + +MODERN_PROTOCOL_VERSION: Final[str] = "2026-07-28" +"""The protocol version this module serves. Kept local so it does not leak into +`SUPPORTED_PROTOCOL_VERSIONS` or the legacy handshake.""" + + +@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( + manager: StreamableHTTPSessionManager, + scope: Scope, + receive: Receive, + send: Send, +) -> None: + """ASGI handler for a single 2026-07-28 POST. + + Called from `StreamableHTTPSessionManager.handle_request` when the + `MCP-Protocol-Version` header is `2026-07-28`. Never sets `Mcp-Session-Id`. + """ + request = Request(scope, receive) + + security = TransportSecurityMiddleware(manager.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)(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, exclude_none=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 manager.app.lifespan(manager.app) as lifespan_state: + runner = ServerRunner( + server=manager.app, + dispatcher=dispatcher, + lifespan_state=lifespan_state, + has_standalone_channel=False, + stateless=True, + dispatch_middleware=[otel_middleware], + ) + runner.connection.protocol_version = MODERN_PROTOCOL_VERSION + try: + msg = await dispatcher.handle(req, runner._compose_on_request()) # type: ignore[reportPrivateUsage] + finally: + await runner.connection.exit_stack.aclose() + + # 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) diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index 8dd9a2fac0..fcdcc68ced 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -31,9 +31,8 @@ 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 diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index 6254a01ee5..583210ddb4 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -9,16 +9,15 @@ 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,16 @@ async def send_request( # 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; either + # add it there or refactor ServerSession once the legacy path is compat-only. + 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 +117,7 @@ async def send_notification( ) -> 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.""" diff --git a/src/mcp/server/streamable_http_manager.py b/src/mcp/server/streamable_http_manager.py index cec170082e..aa1e7a910a 100644 --- a/src/mcp/server/streamable_http_manager.py +++ b/src/mcp/server/streamable_http_manager.py @@ -14,6 +14,7 @@ from starlette.responses import Response from starlette.types import Receive, Scope, Send +from mcp.server._experimental.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, @@ -150,6 +151,13 @@ async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> No 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 == "2026-07-28": + await handle_modern_request(self, scope, receive, send) + return + # Dispatch to the appropriate handler if self.stateless: await self._handle_stateless_request(scope, receive, send) From 980d57a196ad9a161f222eb81318a6d2ee7b5491 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:19:06 +0000 Subject: [PATCH 02/21] Add protocol_version pin to ClientSession for stateless 2026-07-28 mode When ClientSession is constructed with protocol_version="2026-07-28", each outgoing request carries the io.modelcontextprotocol/* envelope (protocolVersion, clientInfo, clientCapabilities) in params._meta, and initialize() raises if called. Capabilities derivation is extracted to _build_capabilities() so both paths share it. The streamable-HTTP transport derives MCP-Protocol-Version, Mcp-Method and (for tools/call) Mcp-Name headers per POST from the body's envelope; non-header-safe values are Base64-sentinel-encoded per the spec. Envelope-less bodies get no derived headers, so unpinned behaviour is unchanged. Session-id capture, the standalone GET stream and DELETE on close are gated on traffic the pinned mode never produces. Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK --- src/mcp/client/session.py | 42 ++++++++++++++++++++++++------- src/mcp/client/streamable_http.py | 34 +++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index dda241035d..edbd790037 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -22,7 +22,15 @@ 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.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,13 @@ def __init__( 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._sampling_callback = sampling_callback or _default_sampling_callback self._sampling_capabilities = sampling_capabilities self._elicitation_callback = elicitation_callback or _default_elicitation_callback @@ -218,6 +228,18 @@ async def send_request( """ data = request.model_dump(by_alias=True, mode="json", exclude_none=True) method: str = data["method"] + if self._pinned_version is not None: + params = data.setdefault("params", {}) + envelope_meta = params.setdefault("_meta", {}) + envelope_meta.setdefault(PROTOCOL_VERSION_META_KEY, self._pinned_version) + envelope_meta.setdefault( + CLIENT_INFO_META_KEY, + self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + envelope_meta.setdefault( + CLIENT_CAPABILITIES_META_KEY, + self._build_capabilities().model_dump(by_alias=True, mode="json", exclude_none=True), + ) opts: CallOptions = {} timeout = ( request_read_timeout_seconds @@ -254,7 +276,7 @@ async def send_notification(self, notification: types.ClientNotification) -> Non 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 +295,17 @@ async def initialize(self) -> types.InitializeResult: 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._pinned_version is not None: + raise RuntimeError("initialize() must not be called on a session pinned to a stateless protocol version") + 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, - ), + capabilities=capabilities, client_info=self._client_info, ), ), @@ -309,7 +331,9 @@ def initialize_result(self) -> types.InitializeResult | None: @property 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.""" + if self._pinned_version is not None: + return self._pinned_version return self._initialize_result.protocol_version if self._initialize_result else None async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult: diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index b5950d3b5c..d6bc2393e3 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -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 @@ -23,6 +25,7 @@ INTERNAL_ERROR, INVALID_REQUEST, PARSE_ERROR, + PROTOCOL_VERSION_META_KEY, ErrorData, InitializeResult, JSONRPCError, @@ -44,12 +47,42 @@ 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 not _B64_SENTINEL.fullmatch(value): + return value + return f"=?base64?{base64.b64encode(value.encode('utf-8')).decode('ascii')}?=" + + +def _body_derived_headers(message: JSONRPCMessage) -> dict[str, str]: + """Derive 2026-era headers from an envelope-bearing request body. Empty dict for legacy bodies.""" + if not isinstance(message, JSONRPCRequest) or message.params is None: + return {} + meta = message.params.get("_meta") + if meta is None: + return {} + version = meta.get(PROTOCOL_VERSION_META_KEY) + if not isinstance(version, str): + return {} + headers: dict[str, str] = {MCP_PROTOCOL_VERSION: version, MCP_METHOD: message.method} + if message.method == "tools/call": + name = message.params.get("name") + if isinstance(name, str): + headers[MCP_NAME] = _encode_header_value(name) + return headers + class StreamableHTTPError(Exception): """Base exception for StreamableHTTP transport errors.""" @@ -256,6 +289,7 @@ async def _handle_post_request(self, ctx: RequestContext) -> None: """Handle a POST request with response processing.""" headers = self._prepare_headers() message = ctx.session_message.message + headers.update(_body_derived_headers(message)) is_initialization = self._is_initialization_request(message) async with ctx.client.stream( From 188dc83d6e1dcf88cc8eb04b1c283cd23ae4a8d9 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:21:26 +0000 Subject: [PATCH 03/21] Pass app and security_settings explicitly to handle_modern_request Drops the StreamableHTTPSessionManager dependency from the experimental module; the handler only needs the lowlevel Server and the TransportSecuritySettings. Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK --- .../server/_experimental/streamable_http_modern.py | 13 +++++++------ src/mcp/server/streamable_http_manager.py | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/mcp/server/_experimental/streamable_http_modern.py b/src/mcp/server/_experimental/streamable_http_modern.py index 9e16033753..0210c5cfa9 100644 --- a/src/mcp/server/_experimental/streamable_http_modern.py +++ b/src/mcp/server/_experimental/streamable_http_modern.py @@ -25,7 +25,7 @@ from starlette.types import Receive, Scope, Send from mcp.server.runner import ServerRunner, otel_middleware -from mcp.server.transport_security import TransportSecurityMiddleware +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 @@ -42,7 +42,7 @@ ) if TYPE_CHECKING: - from mcp.server.streamable_http_manager import StreamableHTTPSessionManager + from mcp.server.lowlevel.server import Server logger = logging.getLogger(__name__) @@ -157,7 +157,8 @@ async def handle(self, req: JSONRPCRequest, on_request: OnRequest) -> JSONRPCRes async def handle_modern_request( - manager: StreamableHTTPSessionManager, + app: Server[Any], + security_settings: TransportSecuritySettings | None, scope: Scope, receive: Receive, send: Send, @@ -169,7 +170,7 @@ async def handle_modern_request( """ request = Request(scope, receive) - security = TransportSecurityMiddleware(manager.security_settings) + 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) @@ -196,9 +197,9 @@ async def handle_modern_request( dispatcher = SingleExchangeDispatcher(request) # TODO: per-request lifespan re-entry matches stateless_http=True today; revisit in #2893. - async with manager.app.lifespan(manager.app) as lifespan_state: + async with app.lifespan(app) as lifespan_state: runner = ServerRunner( - server=manager.app, + server=app, dispatcher=dispatcher, lifespan_state=lifespan_state, has_standalone_channel=False, diff --git a/src/mcp/server/streamable_http_manager.py b/src/mcp/server/streamable_http_manager.py index aa1e7a910a..c312dd7833 100644 --- a/src/mcp/server/streamable_http_manager.py +++ b/src/mcp/server/streamable_http_manager.py @@ -155,7 +155,7 @@ async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> No # (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 == "2026-07-28": - await handle_modern_request(self, scope, receive, send) + await handle_modern_request(self.app, self.security_settings, scope, receive, send) return # Dispatch to the appropriate handler From 9aebd530490fd837901359704081ddf05ad4e7ca Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:29:42 +0000 Subject: [PATCH 04/21] Add interaction tests for the 2026-07-28 stateless lifecycle and HTTP path - assert_no_modern_vocabulary helper and on_response= hook on mounted_app - lowlevel/test_lifecycle_stateless.py: pinned ClientSession stamps the envelope on every request, initialize() is rejected, caller _meta survives the merge, unpinned sessions carry no 2026 vocabulary - transports/test_legacy_wire.py: a 2025-era exchange carries no 2026 vocabulary at the HTTP seam - transports/test_client_transport_http_modern.py: body-derived header table and the Mcp-Name Base64-sentinel encoding - transports/test_hosting_http_modern.py: stateless tools/call returns resultType complete, no Mcp-Session-Id, initialize is METHOD_NOT_FOUND - transports/test_hosting_http.py: the Unsupported-protocol-version rejection literal stays sniffable Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK --- tests/interaction/_connect.py | 12 +- tests/interaction/_modern_vocab.py | 79 ++++++ .../lowlevel/test_lifecycle_stateless.py | 238 ++++++++++++++++++ .../test_client_transport_http_modern.py | 89 +++++++ .../transports/test_hosting_http.py | 20 ++ .../transports/test_hosting_http_modern.py | 126 ++++++++++ .../transports/test_legacy_wire.py | 85 +++++++ 7 files changed, 646 insertions(+), 3 deletions(-) create mode 100644 tests/interaction/_modern_vocab.py create mode 100644 tests/interaction/lowlevel/test_lifecycle_stateless.py create mode 100644 tests/interaction/transports/test_client_transport_http_modern.py create mode 100644 tests/interaction/transports/test_hosting_http_modern.py create mode 100644 tests/interaction/transports/test_legacy_wire.py diff --git a/tests/interaction/_connect.py b/tests/interaction/_connect.py index db585a0520..b709181f30 100644 --- a/tests/interaction/_connect.py +++ b/tests/interaction/_connect.py @@ -166,6 +166,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 +178,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 +196,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( diff --git a/tests/interaction/_modern_vocab.py b/tests/interaction/_modern_vocab.py new file mode 100644 index 0000000000..7531724ee3 --- /dev/null +++ b/tests/interaction/_modern_vocab.py @@ -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}" diff --git a/tests/interaction/lowlevel/test_lifecycle_stateless.py b/tests/interaction/lowlevel/test_lifecycle_stateless.py new file mode 100644 index 0000000000..5c1a5a4b07 --- /dev/null +++ b/tests/interaction/lowlevel/test_lifecycle_stateless.py @@ -0,0 +1,238 @@ +"""Stateless lifecycle at protocol version 2026-07-28, driven through a bare ClientSession. + +Under the 2026-07-28 lifecycle the initialize handshake is replaced by a per-request envelope: +every request carries the protocol version, client info, and client capabilities under +``params._meta`` and the server never sees an ``initialize`` frame. These tests pin the session +to that version and observe the outgoing JSON-RPC frame directly, so they drop below the +``connect`` fixture to a bare ClientSession over in-process memory streams. No 2026-aware Server +exists yet, so the receiving side is a scripted peer that hand-builds the wire response — reserve +this pattern for behaviour no real server can be made to produce. +""" + +from contextlib import nullcontext + +import anyio +import pytest +from inline_snapshot import snapshot + +from mcp.client import ClientSession +from mcp.shared.memory import MessageStream, create_client_server_memory_streams +from mcp.shared.message import SessionMessage +from mcp.types import ( + CallToolResult, + Implementation, + InitializeResult, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, + ListToolsResult, + ServerCapabilities, +) +from tests.interaction._helpers import RecordingTransport +from tests.interaction._modern_vocab import MODERN_BODY_TOKENS +from tests.interaction._requirements import requirement + +pytestmark = pytest.mark.anyio + + +@requirement("lifecycle:stateless:request-envelope") +async def test_pinned_session_stamps_the_envelope_meta_on_every_request_and_never_initializes() -> None: + """A pinned session's first request is the feature request itself, carrying the three-key envelope. + + The scripted peer asserts the only frame on the wire is ``tools/list`` (no ``initialize``, no + ``notifications/initialized``) and answers with a hand-built 2026-07-28 result; the test then + snapshots the captured ``params._meta``. + """ + received: list[JSONRPCRequest] = [] + + async def scripted_server(streams: MessageStream) -> None: + server_read, server_write = streams + message = await server_read.receive() + assert isinstance(message, SessionMessage) + request = message.message + assert isinstance(request, JSONRPCRequest) + assert request.method == "tools/list" + received.append(request) + result = ListToolsResult(tools=[], cache_scope="public", ttl_ms=0) + await server_write.send( + SessionMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=request.id, + # Serialized exactly as a real server serializes results onto the wire. + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + + async with ( + create_client_server_memory_streams() as ((client_read, client_write), server_streams), + anyio.create_task_group() as tg, + ClientSession( + client_read, + client_write, + client_info=Implementation(name="pin-client", version="1.0.0"), + protocol_version="2026-07-28", + ) as session, + ): + tg.start_soon(scripted_server, server_streams) + with anyio.fail_after(5): + result = await session.list_tools() + assert isinstance(result, ListToolsResult) + + assert len(received) == 1 + only = received[0] + assert only.params is not None + assert only.params["_meta"] == snapshot( + { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": {"name": "pin-client", "version": "1.0.0"}, + "io.modelcontextprotocol/clientCapabilities": {}, + } + ) + + +@requirement("lifecycle:stateless:no-initialize") +async def test_initialize_on_a_pinned_session_is_rejected_before_any_frame_is_sent() -> None: + """``initialize()`` on a pinned session raises immediately, never reaching the wire. + + After the rejection the client's send stream is closed and the server-side read drains to + EndOfStream with no buffered frame, proving the guard fired before any write. + """ + async with create_client_server_memory_streams() as ((client_read, client_write), (server_read, _server_write)): + async with ClientSession(client_read, client_write, protocol_version="2026-07-28") as session: + with anyio.fail_after(5): + with pytest.raises(RuntimeError) as exc_info: + await session.initialize() + assert str(exc_info.value) == snapshot( + "initialize() must not be called on a session pinned to a stateless protocol version" + ) + # Nothing left the client: closing the sender turns an empty buffer into EndOfStream. + await client_write.aclose() + with anyio.fail_after(5): + with pytest.raises(anyio.EndOfStream): + await server_read.receive() + + +@requirement("lifecycle:stateless:caller-meta-preserved") +async def test_caller_supplied_meta_is_preserved_under_the_envelope_merge() -> None: + """A caller's ``meta=`` keys survive the pinned session's envelope stamp on the same ``_meta`` object. + + The envelope merge is additive, so a caller-supplied key sits alongside the three + ``io.modelcontextprotocol/*`` keys rather than being overwritten. The scripted peer captures the + single ``tools/call`` frame and answers with an ``is_error`` result so the client skips its + implicit output-schema fetch; the test then snapshots the captured ``params._meta``. + """ + received: list[JSONRPCRequest] = [] + + async def scripted_server(streams: MessageStream) -> None: + server_read, server_write = streams + message = await server_read.receive() + assert isinstance(message, SessionMessage) + request = message.message + assert isinstance(request, JSONRPCRequest) + assert request.method == "tools/call" + received.append(request) + result = CallToolResult(content=[], is_error=True) + await server_write.send( + SessionMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=request.id, + # Serialized exactly as a real server serializes results onto the wire. + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + + async with ( + create_client_server_memory_streams() as ((client_read, client_write), server_streams), + anyio.create_task_group() as tg, + ClientSession( + client_read, + client_write, + client_info=Implementation(name="pin-client", version="1.0.0"), + protocol_version="2026-07-28", + ) as session, + ): + tg.start_soon(scripted_server, server_streams) + with anyio.fail_after(5): + result = await session.call_tool("add", {"a": 2, "b": 3}, meta={"custom-key": "x"}) + assert isinstance(result, CallToolResult) + + assert len(received) == 1 + only = received[0] + assert only.params is not None + assert only.params["_meta"] == snapshot( + { + "custom-key": "x", + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": {"name": "pin-client", "version": "1.0.0"}, + "io.modelcontextprotocol/clientCapabilities": {}, + } + ) + + +@requirement("lifecycle:stateless:unpinned-legacy-wire") +async def test_unpinned_session_round_trip_carries_no_modern_protocol_vocabulary() -> None: + """An unpinned session's handshake-plus-request emits no 2026-07-28 vocabulary on any frame. + + The JSON-RPC-seam complement to ``test_legacy_wire.py`` (which records the HTTP seam): a + ``RecordingTransport`` wrapped around the client side of the in-process memory streams captures + every frame in either direction, the scripted peer answers ``initialize`` at ``2025-11-25`` then + ``tools/list``, and every captured frame body is scanned for :data:`MODERN_BODY_TOKENS` so any + leak of the envelope keys, the result-envelope fields, or the version literal onto the legacy + session path fails here. + """ + + async def scripted_server(streams: MessageStream) -> None: + server_read, server_write = streams + init = await server_read.receive() + assert isinstance(init, SessionMessage) + assert isinstance(init.message, JSONRPCRequest) + assert init.message.method == "initialize" + result = InitializeResult( + protocol_version="2025-11-25", + capabilities=ServerCapabilities(), + server_info=Implementation(name="legacy-server", version="0.0.0"), + ) + await server_write.send( + SessionMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=init.message.id, + # Serialized exactly as a real server serializes results onto the wire. + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + initialized = await server_read.receive() + assert isinstance(initialized, SessionMessage) + assert isinstance(initialized.message, JSONRPCNotification) + listing = await server_read.receive() + assert isinstance(listing, SessionMessage) + assert isinstance(listing.message, JSONRPCRequest) + assert listing.message.method == "tools/list" + await server_write.send( + SessionMessage(JSONRPCResponse(jsonrpc="2.0", id=listing.message.id, result={"tools": []})) + ) + + async with create_client_server_memory_streams() as (client_streams, server_streams): + recording = RecordingTransport(nullcontext(client_streams)) + async with ( + anyio.create_task_group() as tg, + recording as (client_read, client_write), + ClientSession(client_read, client_write) as session, + ): + tg.start_soon(scripted_server, server_streams) + with anyio.fail_after(5): + await session.initialize() + result = await session.list_tools() + assert isinstance(result, ListToolsResult) + + frames = list(recording.sent) + [m for m in recording.received if isinstance(m, SessionMessage)] + methods = [m.message.method for m in recording.sent if isinstance(m.message, JSONRPCRequest | JSONRPCNotification)] + assert methods == snapshot(["initialize", "notifications/initialized", "tools/list"]) + bodies = [m.message.model_dump_json(by_alias=True, exclude_none=True) for m in frames] + leaked = sorted({token for token in MODERN_BODY_TOKENS for body in bodies if token in body}) + assert leaked == [] diff --git a/tests/interaction/transports/test_client_transport_http_modern.py b/tests/interaction/transports/test_client_transport_http_modern.py new file mode 100644 index 0000000000..731c363c83 --- /dev/null +++ b/tests/interaction/transports/test_client_transport_http_modern.py @@ -0,0 +1,89 @@ +"""Behaviour of the streamable-HTTP client transport under the 2026-07-28 stateless protocol. + +A pinned session stamps the ``io.modelcontextprotocol/*`` `_meta` envelope onto every outgoing +request, and the streamable-HTTP transport derives the ``MCP-Protocol-Version`` / ``Mcp-Method`` / +``Mcp-Name`` headers from that body. These tests pin the transport-level derivation as pure unit +assertions on the private helpers -- the headers are an HTTP-seam observation that the public +client never exposes, and no in-process 2026 server exists yet to record them against. +""" + +import base64 + +import pytest +from inline_snapshot import snapshot + +from mcp.client.streamable_http import _body_derived_headers, _encode_header_value +from mcp.types import PROTOCOL_VERSION_META_KEY, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest +from tests.interaction._requirements import requirement + +pytestmark = pytest.mark.anyio + + +_ENVELOPE = {PROTOCOL_VERSION_META_KEY: "2026-07-28"} + + +@requirement("client-transport:http:body-derived-headers") +@pytest.mark.parametrize( + ("message", "expected"), + [ + ( + JSONRPCRequest( + jsonrpc="2.0", id=1, method="tools/call", params={"name": "add", "arguments": {}, "_meta": _ENVELOPE} + ), + snapshot({"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "add"}), + ), + ( + JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list", params={"_meta": _ENVELOPE}), + snapshot({"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/list"}), + ), + ( + JSONRPCRequest(jsonrpc="2.0", id=3, method="tools/call", params={"name": "add", "arguments": {}}), + snapshot({}), + ), + ( + JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized"), + snapshot({}), + ), + ], +) +def test_body_derived_headers_reflect_the_envelope_on_the_request_body( + message: JSONRPCMessage, expected: dict[str, str] +) -> None: + """An envelope-bearing body yields the three stateless headers; a legacy body yields none. + + Spec-mandated for the headers themselves; tested as a unit on the private helper because the + headers are an HTTP-seam observation -- the public client never exposes outbound request headers, + and no in-process 2026 server exists to record them against. Legacy bodies returning ``{}`` is + what keeps the unpinned wire byte-identical (see ``test_legacy_wire.py``). + """ + assert _body_derived_headers(message) == expected + + +@requirement("client-transport:http:mcp-name-encoding") +@pytest.mark.parametrize( + ("raw", "expected", "wrapped"), + [ + ("add", snapshot("add"), False), + ("tool with spaces", snapshot("tool with spaces"), False), + ("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, and sentinel-shaped names are wrapped. + + Spec-mandated: the ``=?base64?...?=`` sentinel is the spec's RFC 7230 safety gate for the + ``Mcp-Name`` header. Unit test of the private helper for the same reason as + :func:`_body_derived_headers` -- the encoded value is only observable on the raw HTTP request. + Wrapped values round-trip through base64 so the server can recover the original name. + """ + 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 diff --git a/tests/interaction/transports/test_hosting_http.py b/tests/interaction/transports/test_hosting_http.py index 85e64ded42..e4a92ea7e4 100644 --- a/tests/interaction/transports/test_hosting_http.py +++ b/tests/interaction/transports/test_hosting_http.py @@ -179,6 +179,26 @@ async def test_protocol_version_header_is_validated() -> None: assert defaulted.status_code == 202 +@requirement("hosting:http:protocol-version-rejection-literal") +async def test_unsupported_protocol_version_rejection_body_contains_the_sniffed_literal() -> None: + """The 400 body for an unsupported MCP-Protocol-Version contains the substring peer SDKs sniff. + + SDK-defined: other SDKs detect this rejection by substring-matching ``Unsupported protocol + version`` in the response body, so the literal must survive any rewording of the surrounding + message. Asserted at the wire because the SDK client never surfaces the rejection body. + """ + async with mounted_app(_server()) as (http, _): + session_id = await initialize_via_http(http) + response = await http.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 2, "method": "ping"}, + headers=base_headers(session_id=session_id) | {"mcp-protocol-version": "1991-01-01"}, + ) + + assert response.status_code == 400 + assert "Unsupported protocol version" in response.text + + @requirement("hosting:http:json-response-mode") async def test_json_response_mode_answers_with_application_json_not_sse() -> None: """With JSON response mode enabled, request POSTs are answered with a single application/json body. diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py new file mode 100644 index 0000000000..9267c4ed2d --- /dev/null +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -0,0 +1,126 @@ +"""Streamable HTTP at protocol version 2026-07-28: the single-exchange stateless serving entry. + +These tests speak HTTP directly to the server's mounted ASGI app via the in-process bridge, +asserting the wire contract for a 2026-07-28 POST -- one self-contained request, no initialize +handshake, no ``Mcp-Session-Id``, JSON response body -- and that 2025-era traffic on the same +endpoint is byte-unchanged. The SDK client never exposes the response headers or the raw +result-envelope shape, so every assertion here is necessarily wire-level. +""" + +import pytest +from inline_snapshot import snapshot + +from mcp.server import Server, ServerRequestContext +from mcp.types import ( + METHOD_NOT_FOUND, + CallToolRequestParams, + CallToolResult, + JSONRPCError, + JSONRPCResponse, + TextContent, +) +from tests.interaction._connect import base_headers, initialize_body, mounted_app +from tests.interaction._requirements import requirement + +pytestmark = pytest.mark.anyio + +MODERN_VERSION = "2026-07-28" + + +def _modern_headers(*, method: str, name: str | None = None) -> dict[str, str]: + """Request headers for a 2026-07-28 POST. + + The Accept/Content-Type baseline plus the ``MCP-Protocol-Version`` routing header and the + ``Mcp-Method`` / ``Mcp-Name`` advisory headers a 2026-era client always sends. + """ + headers = base_headers() | {"mcp-protocol-version": MODERN_VERSION, "mcp-method": method} + if name is not None: + headers["mcp-name"] = name + return headers + + +def _meta_envelope() -> dict[str, object]: + """The per-request ``_meta`` envelope a 2026-07-28 client stamps on every request. + + Replaces the 2025-era initialize handshake: protocol version, client info, and client + capabilities travel on each request instead of once per session. + """ + return { + "io.modelcontextprotocol/protocolVersion": MODERN_VERSION, + "io.modelcontextprotocol/clientInfo": {"name": "raw", "version": "0.0.0"}, + "io.modelcontextprotocol/clientCapabilities": {}, + } + + +def _server() -> Server: + """A low-level server with one ``add`` tool, for the 2026-07-28 happy-path tools/call.""" + + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + assert params.name == "add" + assert params.arguments is not None + return CallToolResult(content=[TextContent(text=str(params.arguments["a"] + params.arguments["b"]))]) + + return Server("modern", on_call_tool=call_tool) + + +@requirement("hosting:http:modern:tools-call-stateless") +async def test_modern_tools_call_returns_result_type_complete_without_initialize() -> None: + """A 2026-07-28 tools/call is served without an initialize handshake and returns resultType: complete. + + Spec-mandated under the draft transport: the per-request ``_meta`` envelope replaces initialize, + and ``resultType`` is the 2026 result-envelope discriminator (``complete`` for the monolith + result). Asserted at the wire because the SDK client never surfaces ``resultType`` and because + the absence of any prior request on the connection is the assertion. + """ + body = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "add", "arguments": {"a": 2, "b": 3}, "_meta": _meta_envelope()}, + } + async with mounted_app(_server()) as (http, _): + response = await http.post("/mcp", json=body, headers=_modern_headers(method="tools/call", name="add")) + + assert response.status_code == 200 + assert response.headers["content-type"].split(";", 1)[0] == "application/json" + parsed = JSONRPCResponse.model_validate(response.json()) + assert parsed.id == 1 + assert parsed.result == snapshot( + {"content": [{"text": "5", "type": "text"}], "isError": False, "resultType": "complete"} + ) + + +@requirement("hosting:http:modern:no-session-id") +async def test_modern_response_carries_no_session_id_header() -> None: + """A 2026-07-28 response never sets ``Mcp-Session-Id``. + + Spec-mandated under the draft transport: the 2026-07-28 exchange is sessionless by definition, + so the header that the 2025-era transport always sets on responses must be absent. Asserted at + the wire because the SDK client never exposes response headers. + """ + body = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "add", "arguments": {"a": 2, "b": 3}, "_meta": _meta_envelope()}, + } + async with mounted_app(_server()) as (http, _): + response = await http.post("/mcp", json=body, headers=_modern_headers(method="tools/call", name="add")) + + assert response.status_code == 200 + assert "mcp-session-id" not in response.headers + + +@requirement("hosting:http:modern:initialize-removed") +async def test_modern_initialize_is_method_not_found() -> None: + """A 2026-07-28 initialize request is answered with METHOD_NOT_FOUND. + + Spec-mandated under the draft: initialize is not a defined method at 2026-07-28, so the + method/version gate rejects it before any handler runs. Asserted at the wire because the SDK + client at 2026-07-28 never sends initialize, so only a raw POST can drive the negative. + """ + async with mounted_app(_server()) as (http, _): + response = await http.post("/mcp", json=initialize_body(), headers=_modern_headers(method="initialize")) + + assert response.status_code == 200 + assert JSONRPCError.model_validate(response.json()).error.code == METHOD_NOT_FOUND diff --git a/tests/interaction/transports/test_legacy_wire.py b/tests/interaction/transports/test_legacy_wire.py new file mode 100644 index 0000000000..b65a50759d --- /dev/null +++ b/tests/interaction/transports/test_legacy_wire.py @@ -0,0 +1,85 @@ +"""Legacy-wire protection: a 2025-era streamable-HTTP exchange stays free of 2026 vocabulary. + +Records a full SDK client -> SDK server round trip at both seams (HTTP request/response headers +via httpx event hooks; JSON-RPC frames in both directions via the recording transport) and runs +the result through :func:`tests.interaction._modern_vocab.assert_no_modern_vocabulary`. The test +pins today's wire so any future 2026-07-28 work that leaks new fields, `_meta` keys, or headers +onto a connection negotiated at the current protocol version fails here. +""" + +import httpx +import pytest +from inline_snapshot import snapshot + +from mcp.client.client import Client +from mcp.client.streamable_http import streamable_http_client +from mcp.server import Server, ServerRequestContext +from mcp.shared.message import SessionMessage +from mcp.types import ( + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) +from tests.interaction._connect import BASE_URL, mounted_app +from tests.interaction._helpers import RecordingTransport +from tests.interaction._modern_vocab import RecordedExchange, assert_no_modern_vocabulary +from tests.interaction._requirements import requirement + +pytestmark = pytest.mark.anyio + + +def _server() -> Server: + """A low-level server with one echo tool, so the recorded exchange covers tools/list and tools/call.""" + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[Tool(name="echo", description="Echo text.", input_schema={"type": "object"})]) + + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + assert params.name == "echo" + assert params.arguments is not None + return CallToolResult(content=[TextContent(text=str(params.arguments["text"]))]) + + return Server("legacy", on_list_tools=list_tools, on_call_tool=call_tool) + + +@requirement("hosting:http:legacy-no-modern-vocabulary") +async def test_legacy_streamable_http_exchange_carries_no_modern_protocol_vocabulary() -> None: + """A 2025-era client/server round trip emits none of the 2026-07-28 wire vocabulary. + + SDK-defined under the draft versioning rules: pins the current wire so future 2026 work cannot + leak `resultType` / `ttlMs` / `cacheScope`, `io.modelcontextprotocol/*` `_meta` keys, the + `2026-07-28` literal, or `Mcp-Method` / `Mcp-Name` / `Mcp-Param-*` headers onto a connection + negotiated at the current protocol version. Recorded at the HTTP seam (every request and + response header) and the transport seam (every JSON-RPC frame in either direction); the SDK + client never exposes either, so the assertion is necessarily wire-level. + """ + recorded = RecordedExchange(requests=[], responses=[], frames=[]) + + async def on_request(request: httpx.Request) -> None: + recorded.requests.append(request) + + async def on_response(response: httpx.Response) -> None: + recorded.responses.append(response) + + async with mounted_app(_server(), on_request=on_request, on_response=on_response) as (http, _): + recording = RecordingTransport(streamable_http_client(f"{BASE_URL}/mcp", http_client=http)) + async with Client(recording) as client: + result = await client.call_tool("echo", {"text": "legacy"}) + + assert result == snapshot(CallToolResult(content=[TextContent(text="legacy")])) + + recorded.frames.extend(m.message for m in recording.sent) + recorded.frames.extend(m.message for m in recording.received if isinstance(m, SessionMessage)) + + # The handshake, the implicit tools/list (output-schema cache), tools/call, the standalone GET + # stream, and the closing DELETE all crossed the HTTP seam; the transport seam saw a JSON-RPC + # frame for each direction of each. Asserting non-empty so the vocabulary scan cannot pass on + # nothing recorded. + assert {r.method for r in recorded.requests} == snapshot({"POST", "GET", "DELETE"}) + assert len(recorded.responses) == len(recorded.requests) + assert len(recorded.frames) >= 6 + + assert_no_modern_vocabulary(recorded) From a4f0939030b6ce8b72ebc2d3b98100e7a52b2482 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:29:52 +0000 Subject: [PATCH 05/21] Register 2026-07-28 stateless requirements in the interaction-suite manifest Eleven new entries: nine with added_in="2026-07-28" sourced from SPEC_2026_BASE_URL, plus the two cross-era guard entries (protocol-version-rejection-literal, legacy-no-modern-vocabulary). Each transports-restricted entry carries a note per the manifest invariant. Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK --- tests/interaction/_requirements.py | 101 +++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 5fb2c91b16..454d258778 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -319,6 +319,34 @@ def __post_init__(self) -> None: "support fails initialization with an error rather than proceeding with the session." ), ), + "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 rejects initialize() before any frame is sent.", + added_in="2026-07-28", + ), + "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 " + "io.modelcontextprotocol/* keys are added alongside, never overwriting the caller's keys." + ), + 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." + ), + ), # ═══════════════════════════════════════════════════════════════════════════ # Protocol primitives: cancellation, timeout, progress, errors, _meta # ═══════════════════════════════════════════════════════════════════════════ @@ -2861,6 +2889,57 @@ def __post_init__(self) -> None: 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."), + ), # ═══════════════════════════════════════════════════════════════════════════ # Client transport: streamable HTTP # ═══════════════════════════════════════════════════════════════════════════ @@ -3034,6 +3113,28 @@ def __post_init__(self) -> None: 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:mcp-name-encoding": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/transports#stateless-request-headers", + behavior=( + "Mcp-Name header values that are not safe for an HTTP field are wrapped in the =?base64?...?= " + "sentinel; printable-ASCII values pass verbatim." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "Only observable over streamable HTTP: the Base64-sentinel encoding is the spec's HTTP header-safety rule." + ), + ), # ═══════════════════════════════════════════════════════════════════════════ # Client auth # ═══════════════════════════════════════════════════════════════════════════ From cf65e8b7dcb678c9719f039e47660ba30576a8d6 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:44:16 +0000 Subject: [PATCH 06/21] Add MockTransport, capstone, and client-unit tests for the 2026-07-28 path - transports/test_client_transport_http_modern.py: pinned session POST carries body-derived headers on the wire; a returned session id is ignored and no GET/DELETE is sent - transports/test_hosting_http_modern.py: non-2026 headers fall through to the legacy transport unchanged; handler exceptions map to INTERNAL_ERROR with a generic message; capstone end-to-end stateless tools/call (real ClientSession against the modern entry) - tests/client/test_streamable_http.py: unit tests for _body_derived_headers and the _encode_header_value Base64-sentinel gate (private-helper coverage, kept out of the interaction suite) Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK --- tests/client/test_streamable_http.py | 76 ++++++++ .../test_client_transport_http_modern.py | 165 ++++++++++-------- .../transports/test_hosting_http_modern.py | 136 ++++++++++++++- 3 files changed, 303 insertions(+), 74 deletions(-) create mode 100644 tests/client/test_streamable_http.py diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py new file mode 100644 index 0000000000..7b6f47b8d9 --- /dev/null +++ b/tests/client/test_streamable_http.py @@ -0,0 +1,76 @@ +"""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 private header-derivation helpers +directly because the headers are an HTTP-seam observation the public client never exposes. +""" + +import base64 + +import pytest +from inline_snapshot import snapshot + +from mcp.client.streamable_http import _body_derived_headers, _encode_header_value +from mcp.types import PROTOCOL_VERSION_META_KEY, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest + +_ENVELOPE = {PROTOCOL_VERSION_META_KEY: "2026-07-28"} + + +@pytest.mark.parametrize( + ("message", "expected"), + [ + ( + JSONRPCRequest( + jsonrpc="2.0", id=1, method="tools/call", params={"name": "add", "arguments": {}, "_meta": _ENVELOPE} + ), + snapshot({"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "add"}), + ), + ( + JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list", params={"_meta": _ENVELOPE}), + snapshot({"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/list"}), + ), + ( + JSONRPCRequest(jsonrpc="2.0", id=3, method="tools/call", params={"name": "add", "arguments": {}}), + snapshot({}), + ), + ( + JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized"), + snapshot({}), + ), + ], +) +def test_body_derived_headers_reflect_the_envelope_on_the_request_body( + message: JSONRPCMessage, expected: dict[str, str] +) -> None: + """An envelope-bearing body yields the three stateless headers; a legacy body yields none. + + Legacy bodies returning ``{}`` is what keeps the unpinned wire byte-identical to a pre-2026 client. + """ + assert _body_derived_headers(message) == expected + + +@pytest.mark.parametrize( + ("raw", "expected", "wrapped"), + [ + ("add", snapshot("add"), False), + ("tool with spaces", snapshot("tool with spaces"), False), + ("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, 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. + """ + 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 diff --git a/tests/interaction/transports/test_client_transport_http_modern.py b/tests/interaction/transports/test_client_transport_http_modern.py index 731c363c83..bcfd4b8499 100644 --- a/tests/interaction/transports/test_client_transport_http_modern.py +++ b/tests/interaction/transports/test_client_transport_http_modern.py @@ -2,88 +2,111 @@ A pinned session stamps the ``io.modelcontextprotocol/*`` `_meta` envelope onto every outgoing request, and the streamable-HTTP transport derives the ``MCP-Protocol-Version`` / ``Mcp-Method`` / -``Mcp-Name`` headers from that body. These tests pin the transport-level derivation as pure unit -assertions on the private helpers -- the headers are an HTTP-seam observation that the public -client never exposes, and no in-process 2026 server exists yet to record them against. +``Mcp-Name`` headers from that body. These tests pin the composition through a real ``httpx`` +request against a canned ``httpx.MockTransport`` -- no in-process 2026 server exists yet to record +the headers against. The header-derivation helpers themselves are unit-tested in +``tests/client/test_streamable_http.py``. """ -import base64 +import json +import anyio +import httpx import pytest from inline_snapshot import snapshot -from mcp.client.streamable_http import _body_derived_headers, _encode_header_value -from mcp.types import PROTOCOL_VERSION_META_KEY, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest +from mcp.client import ClientSession +from mcp.client.streamable_http import streamable_http_client +from mcp.types import Implementation +from tests.interaction._connect import BASE_URL from tests.interaction._requirements import requirement pytestmark = pytest.mark.anyio -_ENVELOPE = {PROTOCOL_VERSION_META_KEY: "2026-07-28"} - - @requirement("client-transport:http:body-derived-headers") -@pytest.mark.parametrize( - ("message", "expected"), - [ - ( - JSONRPCRequest( - jsonrpc="2.0", id=1, method="tools/call", params={"name": "add", "arguments": {}, "_meta": _ENVELOPE} - ), - snapshot({"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "add"}), - ), - ( - JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list", params={"_meta": _ENVELOPE}), - snapshot({"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/list"}), - ), - ( - JSONRPCRequest(jsonrpc="2.0", id=3, method="tools/call", params={"name": "add", "arguments": {}}), - snapshot({}), - ), - ( - JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized"), - snapshot({}), - ), - ], -) -def test_body_derived_headers_reflect_the_envelope_on_the_request_body( - message: JSONRPCMessage, expected: dict[str, str] -) -> None: - """An envelope-bearing body yields the three stateless headers; a legacy body yields none. - - Spec-mandated for the headers themselves; tested as a unit on the private helper because the - headers are an HTTP-seam observation -- the public client never exposes outbound request headers, - and no in-process 2026 server exists to record them against. Legacy bodies returning ``{}`` is - what keeps the unpinned wire byte-identical (see ``test_legacy_wire.py``). +@requirement("lifecycle:stateless:request-envelope") +async def test_pinned_session_post_carries_body_derived_headers_on_the_wire() -> None: + """A pinned ``call_tool`` over streamable HTTP lands as a POST whose headers were derived from its body. + + Spec-mandated for the body-derived headers and the request envelope: this is the wire-seam proof + that the ``ClientSession`` envelope stamp and the transport's header derivation are actually + composed -- the streamable-HTTP POST wiring is driven through a real ``httpx`` request. A canned + ``httpx.MockTransport`` stands in for the (not-yet-existing) 2026 server; the ``isError`` result + skips the client's implicit ``tools/list`` output-schema fetch so the recorded log is the single + POST. """ - assert _body_derived_headers(message) == expected - - -@requirement("client-transport:http:mcp-name-encoding") -@pytest.mark.parametrize( - ("raw", "expected", "wrapped"), - [ - ("add", snapshot("add"), False), - ("tool with spaces", snapshot("tool with spaces"), False), - ("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, and sentinel-shaped names are wrapped. - - Spec-mandated: the ``=?base64?...?=`` sentinel is the spec's RFC 7230 safety gate for the - ``Mcp-Name`` header. Unit test of the private helper for the same reason as - :func:`_body_derived_headers` -- the encoded value is only observable on the raw HTTP request. - Wrapped values round-trip through base64 so the server can recover the original name. + recorded: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + recorded.append(request) + body = json.loads(request.content) + result = {"content": [{"type": "text", "text": "5"}], "isError": True, "resultType": "complete"} + return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": result}) + + with anyio.fail_after(5): + async with ( + httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http, + streamable_http_client(f"{BASE_URL}/mcp", http_client=http) as (read, write), + ClientSession( + read, + write, + client_info=Implementation(name="pin-client", version="1.0.0"), + 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 = recorded[0] + assert {k: v for k, v in post.headers.items() if k.startswith("mcp-")} == snapshot( + {"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "add"} + ) + assert json.loads(post.content)["params"]["_meta"] == snapshot( + { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": {"name": "pin-client", "version": "1.0.0"}, + "io.modelcontextprotocol/clientCapabilities": {}, + } + ) + + +@requirement("client-transport:http:stateless-ignores-session-id") +async def test_pinned_session_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. + + Spec-mandated for the stateless transport: the session-id capture, the standalone GET listening + stream, and the DELETE-on-close are all gated on state a pinned session never produces (no + ``initialize``, no ``notifications/initialized``), so even when the canned server 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. """ - 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 + 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(f"{BASE_URL}/mcp", http_client=http) 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) diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py index 9267c4ed2d..0d444cd427 100644 --- a/tests/interaction/transports/test_hosting_http_modern.py +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -7,19 +7,30 @@ result-envelope shape, so every assertion here is necessarily wire-level. """ +import json + +import anyio +import httpx import pytest from inline_snapshot import snapshot +from mcp.client.session import ClientSession +from mcp.client.streamable_http import streamable_http_client from mcp.server import Server, ServerRequestContext from mcp.types import ( + INTERNAL_ERROR, METHOD_NOT_FOUND, CallToolRequestParams, CallToolResult, + Implementation, JSONRPCError, JSONRPCResponse, + ListToolsResult, + PaginatedRequestParams, TextContent, + Tool, ) -from tests.interaction._connect import base_headers, initialize_body, mounted_app +from tests.interaction._connect import BASE_URL, base_headers, initialize_body, initialize_via_http, mounted_app from tests.interaction._requirements import requirement pytestmark = pytest.mark.anyio @@ -53,14 +64,18 @@ def _meta_envelope() -> dict[str, object]: def _server() -> Server: - """A low-level server with one ``add`` tool, for the 2026-07-28 happy-path tools/call.""" + """A low-level server with one ``add`` tool, listed so the SDK client's implicit tools/list resolves.""" + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + tool = Tool(name="add", input_schema={"type": "object"}) + return ListToolsResult(tools=[tool], ttl_ms=0, cache_scope="public") async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: assert params.name == "add" assert params.arguments is not None return CallToolResult(content=[TextContent(text=str(params.arguments["a"] + params.arguments["b"]))]) - return Server("modern", on_call_tool=call_tool) + return Server("modern", on_list_tools=list_tools, on_call_tool=call_tool) @requirement("hosting:http:modern:tools-call-stateless") @@ -124,3 +139,118 @@ async def test_modern_initialize_is_method_not_found() -> None: assert response.status_code == 200 assert JSONRPCError.model_validate(response.json()).error.code == METHOD_NOT_FOUND + + +@requirement("hosting:http:modern:legacy-fallthrough") +async def test_non_modern_version_header_falls_through_to_legacy_transport_unchanged() -> None: + """The 2026-07-28 routing branch fires only on its exact header; everything else reaches legacy. + + SDK-defined under the draft versioning rules: the modern entry must not change any 2025-era + byte. A 2025-era initialize on the same endpoint still completes (legacy serves it), and an + unrecognised ``MCP-Protocol-Version`` still falls through to the legacy gate and produces the + ``Unsupported protocol version`` literal that peer SDKs substring-sniff. Asserted at the wire + because the literal is only observable in the raw response body. + """ + async with mounted_app(_server()) as (http, _): + # 2025-era initialize through the same endpoint: the modern branch must not intercept it. + session_id = await initialize_via_http(http) + unrecognised = await http.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 2, "method": "ping"}, + headers=base_headers(session_id=session_id) | {"mcp-protocol-version": "9999-01-01"}, + ) + + assert unrecognised.status_code == 400 + assert "Unsupported protocol version" in unrecognised.text + + +@requirement("hosting:http:modern:handler-exception-internal-error") +async def test_modern_handler_exception_maps_to_internal_error_without_leaking_the_message() -> None: + """A handler exception on the 2026-07-28 path returns -32603 with a generic message. + + Spec-mandated for the code: -32603 is the JSON-RPC Internal error code. SDK-defined for the + message: the 2026-07-28 entry deliberately does not echo ``str(exc)`` (the legacy dispatcher's + code-0 leak is the recorded divergence on ``protocol:error:internal-error``). Asserted at the + wire because the SDK client surfaces only the error object, not the HTTP status it travelled on. + """ + + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + assert params.name == "boom" + raise RuntimeError("kaboom") + + body = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "boom", "arguments": {}, "_meta": _meta_envelope()}, + } + async with mounted_app(Server("modern", on_call_tool=call_tool)) as (http, _): + response = await http.post("/mcp", json=body, headers=_modern_headers(method="tools/call", name="boom")) + + assert response.status_code == 200 + error = JSONRPCError.model_validate(response.json()).error + assert error.code == INTERNAL_ERROR + assert "kaboom" not in error.message + + +@requirement("hosting:http:modern:tools-call-stateless") +async def test_pinned_client_stateless_tools_call_round_trips_against_the_modern_entry() -> None: + """First end-to-end exercise of the 2026-07-28 stateless request style: SDK client to SDK server. + + Spec-mandated under the draft stateless transport: the pinned ``ClientSession`` and the + single-exchange serving entry compose so that ``call_tool`` returns ``resultType: complete`` + with no ``initialize`` ever sent, no ``Mcp-Session-Id`` on any request or response, and every + POST carrying the body-derived ``MCP-Protocol-Version`` / ``Mcp-Method`` / ``Mcp-Name`` headers + plus the three-key ``io.modelcontextprotocol/*`` ``_meta`` envelope. Asserted at the wire via + the ``mounted_app`` httpx event hooks because none of the headers, the envelope, or the + handshake-absence is observable through the public client API. The recorded log shows two + POSTs: the ``tools/call`` itself and the client's implicit ``tools/list`` output-schema fetch + (see ``client:output-schema:auto-list``), both of which must satisfy the stateless contract. + """ + requests: list[httpx.Request] = [] + responses: list[httpx.Response] = [] + + async def on_request(request: httpx.Request) -> None: + requests.append(request) + + async def on_response(response: httpx.Response) -> None: + responses.append(response) + + client_info = Implementation(name="e2e-client", version="1.0.0") + with anyio.fail_after(5): + async with ( + mounted_app(_server(), on_request=on_request, on_response=on_response) as (http, _), + streamable_http_client(f"{BASE_URL}/mcp", http_client=http) as (read, write), + ClientSession(read, write, client_info=client_info, protocol_version=MODERN_VERSION) as session, + ): + result = await session.call_tool("add", {"a": 2, "b": 3}) + + assert result.model_dump(by_alias=True, mode="json", exclude_none=True) == snapshot( + {"content": [{"type": "text", "text": "5"}], "isError": False, "resultType": "complete"} + ) + + # Exactly the tools/call POST and the implicit tools/list POST -- no initialize, no + # notifications/initialized, no standalone GET stream, no closing DELETE. + bodies = [json.loads(r.content) for r in requests] + assert [(r.method, body["method"]) for r, body in zip(requests, bodies, strict=True)] == snapshot( + [("POST", "tools/call"), ("POST", "tools/list")] + ) + assert all("initialize" not in body["method"] for body in bodies) + + # The tools/call POST carries the body-derived headers and the three-key envelope. + call = requests[0] + assert {k: v for k, v in call.headers.items() if k.startswith("mcp-")} == snapshot( + {"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "add"} + ) + assert bodies[0]["params"]["_meta"] == snapshot( + { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": {"name": "e2e-client", "version": "1.0.0"}, + "io.modelcontextprotocol/clientCapabilities": {}, + } + ) + + # No session id on any request or response: the exchange is sessionless end to end. + assert len(responses) == len(requests) + assert all("mcp-session-id" not in r.headers for r in requests) + assert all("mcp-session-id" not in r.headers for r in responses) From 081c564712e8ea1de8d7a4fc6a1a6f7a83178b81 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:44:26 +0000 Subject: [PATCH 07/21] Register remaining 2026-07-28 stateless requirements Adds client-transport:http:stateless-ignores-session-id, hosting:http:modern:legacy-fallthrough and hosting:http:modern:handler-exception-internal-error. Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK --- tests/interaction/_requirements.py | 34 ++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 454d258778..84da860269 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -2940,6 +2940,30 @@ def __post_init__(self) -> None: 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 # ═══════════════════════════════════════════════════════════════════════════ @@ -3123,17 +3147,15 @@ def __post_init__(self) -> None: transports=("streamable-http",), note="Only observable over streamable HTTP: headers are derived from the body envelope at the transport seam.", ), - "client-transport:http:mcp-name-encoding": Requirement( + "client-transport:http:stateless-ignores-session-id": Requirement( source=f"{SPEC_2026_BASE_URL}/basic/transports#stateless-request-headers", behavior=( - "Mcp-Name header values that are not safe for an HTTP field are wrapped in the =?base64?...?= " - "sentinel; printable-ASCII values pass verbatim." + "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: the Base64-sentinel encoding is the spec's HTTP header-safety rule." - ), + note="Only observable over streamable HTTP: session-id, GET stream and DELETE are streamable-HTTP mechanics.", ), # ═══════════════════════════════════════════════════════════════════════════ # Client auth From ae383c54a69476167aa011b3eda29d6ec1492b33 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:13:37 +0000 Subject: [PATCH 08/21] Add coverage tests for the experimental modern HTTP entry Unit tests for SingleExchangeDispatcher (NoBackChannelError, no-op notify, run() raises) and _SingleExchangeDispatchContext, plus handle_modern_request edge paths (non-POST 405, malformed-body PARSE_ERROR, transport-security rejection, ValidationError mapping). One additional _body_derived_headers case covers the name-absent branch. Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK --- tests/client/test_streamable_http.py | 4 + ...est_experimental_streamable_http_modern.py | 118 ++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 tests/server/test_experimental_streamable_http_modern.py diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index 7b6f47b8d9..2ecee4a3d9 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -29,6 +29,10 @@ JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list", params={"_meta": _ENVELOPE}), snapshot({"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/list"}), ), + ( + JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/call", params={"_meta": _ENVELOPE}), + snapshot({"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call"}), + ), ( JSONRPCRequest(jsonrpc="2.0", id=3, method="tools/call", params={"name": "add", "arguments": {}}), snapshot({}), diff --git a/tests/server/test_experimental_streamable_http_modern.py b/tests/server/test_experimental_streamable_http_modern.py new file mode 100644 index 0000000000..53191d47e3 --- /dev/null +++ b/tests/server/test_experimental_streamable_http_modern.py @@ -0,0 +1,118 @@ +"""Unit tests for the experimental 2026-07-28 single-exchange HTTP serving entry. + +The interaction suite under ``tests/interaction/transports/test_hosting_http_modern.py`` pins +the wire contract end to end; these tests cover the module's internal seams directly -- +the closed back-channel on the dispatcher and dispatch context, the exception-to-error +mapping in ``handle()``, and the request-validation ladder in ``handle_modern_request``. +""" + +from collections.abc import Mapping +from typing import Any + +import httpx +import pytest +from starlette.requests import Request +from starlette.types import Receive, Scope, Send + +from mcp.server import Server +from mcp.server._experimental.streamable_http_modern import ( + SingleExchangeDispatcher, + _SingleExchangeDispatchContext, + handle_modern_request, +) +from mcp.server.transport_security import TransportSecuritySettings +from mcp.shared.dispatcher import DispatchContext +from mcp.shared.exceptions import NoBackChannelError +from mcp.shared.transport_context import TransportContext +from mcp.types import INVALID_PARAMS, PARSE_ERROR, JSONRPCError, JSONRPCRequest + +pytestmark = pytest.mark.anyio + + +def _request() -> Request: + return Request({"type": "http", "method": "POST", "headers": []}) + + +async def test_single_exchange_dispatcher_has_no_back_channel_and_is_never_driven() -> None: + """The dispatcher refuses server-initiated requests, drops notifications, and is not run-driven. + + A 2026-07-28 POST has no channel for the server to push to the client, and ``ServerRunner`` + never calls ``run()`` on this dispatcher -- ``handle()`` is invoked directly per request. + """ + dispatcher = SingleExchangeDispatcher(_request()) + with pytest.raises(NoBackChannelError): + await dispatcher.send_raw_request("sampling/createMessage", None) + assert await dispatcher.notify("notifications/message", None) is None + + async def on_request(ctx: DispatchContext[Any], method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: + raise AssertionError("unreachable") # pragma: no cover + + async def on_notify(ctx: DispatchContext[Any], method: str, params: Mapping[str, Any] | None) -> None: + raise AssertionError("unreachable") # pragma: no cover + + with pytest.raises(RuntimeError, match="never driven"): + await dispatcher.run(on_request, on_notify) + + +async def test_single_exchange_dispatch_context_has_no_back_channel() -> None: + """The per-request dispatch context refuses server-initiated requests and drops notify/progress.""" + dctx = _SingleExchangeDispatchContext( + transport=TransportContext(kind="streamable-http", can_send_request=False), + request_id=1, + message_metadata=None, + ) + assert dctx.can_send_request is False + with pytest.raises(NoBackChannelError): + await dctx.send_raw_request("roots/list", None) + assert await dctx.notify("notifications/message", None) is None + assert await dctx.progress(0.5, total=1.0, message="half") is None + + +async def test_handle_maps_validation_error_to_invalid_params() -> None: + """A handler raising ``ValidationError`` is mapped to a ``-32602`` JSON-RPC error. + + Mirrors ``JSONRPCDispatcher``'s exception-to-wire boundary: a Pydantic validation failure + inside the handler becomes ``INVALID_PARAMS`` rather than the generic internal error. + """ + + async def on_request(ctx: DispatchContext[Any], method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: + JSONRPCRequest.model_validate({}) # raises ValidationError + raise AssertionError("unreachable") # pragma: no cover + + dispatcher = SingleExchangeDispatcher(_request()) + msg = await dispatcher.handle(JSONRPCRequest(jsonrpc="2.0", id=7, method="tools/call", params={}), on_request) + assert isinstance(msg, JSONRPCError) + assert msg.id == 7 + assert msg.error.code == INVALID_PARAMS + + +def _asgi_client(server: Server[Any], security_settings: TransportSecuritySettings | None = None) -> httpx.AsyncClient: + async def app(scope: Scope, receive: Receive, send: Send) -> None: + await handle_modern_request(server, security_settings, scope, receive, send) + + return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") + + +async def test_handle_modern_request_rejects_non_post_with_405() -> None: + """A GET on the 2026-07-28 entry is answered with 405 before any body is read.""" + async with _asgi_client(Server("test")) as http: + response = await http.get("/mcp") + assert response.status_code == 405 + + +async def test_handle_modern_request_rejects_malformed_body_with_parse_error() -> None: + """A POST whose body is not a valid ``JSONRPCRequest`` returns 400 with ``-32700``.""" + async with _asgi_client(Server("test")) as http: + response = await http.post("/mcp", content=b"not json", headers={"content-type": "application/json"}) + assert response.status_code == 400 + assert response.headers["content-type"].split(";", 1)[0] == "application/json" + assert response.json() == {"jsonrpc": "2.0", "error": {"code": PARSE_ERROR, "message": "Parse error"}} + + +async def test_handle_modern_request_returns_transport_security_error_response() -> None: + """The transport-security middleware's error response is sent verbatim and short-circuits.""" + settings = TransportSecuritySettings(enable_dns_rebinding_protection=True, allowed_hosts=["good.example"]) + async with _asgi_client(Server("test"), security_settings=settings) as http: + response = await http.post("/mcp", json={}, headers={"content-type": "application/json"}) + assert response.status_code == 421 + assert response.text == "Invalid Host header" From 26ff9221827289d8be4deb9db1e5987135e0e8aa Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:13:48 +0000 Subject: [PATCH 09/21] Reconcile conformance baselines for the stateless serving path The modern entry now serves 13 carried-forward scenarios (tools/call, prompts/get, completion, dns-rebinding) at the 2026-07-28 wire; remove them from the 2026 baseline. List-result scenarios remain expected-fail pending cacheScope/ttlMs defaults (SEP-2549). input-required-result-validate-input is now baselined per the comment that predicted it; input-required-result-ignore-extra-params now passes. Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK --- .../expected-failures.2026-07-28.yml | 18 ++---------------- .../actions/conformance/expected-failures.yml | 16 ++++++++-------- 2 files changed, 10 insertions(+), 24 deletions(-) diff --git a/.github/actions/conformance/expected-failures.2026-07-28.yml b/.github/actions/conformance/expected-failures.2026-07-28.yml index c155d4afe8..e34fac16b5 100644 --- a/.github/actions/conformance/expected-failures.2026-07-28.yml +++ b/.github/actions/conformance/expected-failures.2026-07-28.yml @@ -89,26 +89,13 @@ server: # 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 - - 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,6 +117,7 @@ server: - input-required-result-result-type - input-required-result-tampered-state - input-required-result-capability-check + - input-required-result-validate-input # SEP-2549 (caching): no ttlMs/cacheScope support. - caching # SEP-2243 (HTTP header standardization): -32020 HeaderMismatch handling and @@ -143,7 +131,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 diff --git a/.github/actions/conformance/expected-failures.yml b/.github/actions/conformance/expected-failures.yml index 674fa78eeb..51f4d96795 100644 --- a/.github/actions/conformance/expected-failures.yml +++ b/.github/actions/conformance/expected-failures.yml @@ -76,12 +76,12 @@ server: # 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 From 92c078a5717a887a8a74b32f48ef29fa8821857e Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:38:36 +0000 Subject: [PATCH 10/21] Harden the experimental modern HTTP entry's error and cleanup paths - Parse-error response keeps the required "id": null member - 405 carries the Allow: POST header - exit_stack.aclose() is shielded, bounded, and exception-suppressed, matching ServerRunner.run()'s contract - The success/error response is sent inside the per-request lifespan scope so a teardown error cannot drop an already-computed result - Coverage tests for the cleanup-raises and cleanup-hangs arms - Two no-branch pragmas for the 3.14 nested-async-with coverage quirk Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK --- .../_experimental/streamable_http_modern.py | 35 ++++++--- .../lowlevel/test_lifecycle_stateless.py | 4 +- ...est_experimental_streamable_http_modern.py | 77 ++++++++++++++++++- 3 files changed, 100 insertions(+), 16 deletions(-) diff --git a/src/mcp/server/_experimental/streamable_http_modern.py b/src/mcp/server/_experimental/streamable_http_modern.py index 0210c5cfa9..2032d3c68d 100644 --- a/src/mcp/server/_experimental/streamable_http_modern.py +++ b/src/mcp/server/_experimental/streamable_http_modern.py @@ -24,7 +24,11 @@ from starlette.responses import Response from starlette.types import Receive, Scope, Send -from mcp.server.runner import ServerRunner, otel_middleware +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 @@ -180,7 +184,7 @@ async def handle_modern_request( if request.method != "POST": # TODO: GET/DELETE rejection (405 + -32601) lands with the validation ladder. - await Response(status_code=405)(scope, receive, send) + await Response(status_code=405, headers={"Allow": "POST"})(scope, receive, send) return body = await request.body() @@ -189,7 +193,7 @@ async def handle_modern_request( 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, exclude_none=True), + msg.model_dump_json(by_alias=True), status_code=400, media_type="application/json", )(scope, receive, send) @@ -210,11 +214,20 @@ async def handle_modern_request( try: msg = await dispatcher.handle(req, runner._compose_on_request()) # type: ignore[reportPrivateUsage] finally: - await runner.connection.exit_stack.aclose() - - # 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) + 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) diff --git a/tests/interaction/lowlevel/test_lifecycle_stateless.py b/tests/interaction/lowlevel/test_lifecycle_stateless.py index 5c1a5a4b07..86cf4b8ec7 100644 --- a/tests/interaction/lowlevel/test_lifecycle_stateless.py +++ b/tests/interaction/lowlevel/test_lifecycle_stateless.py @@ -102,7 +102,7 @@ async def test_initialize_on_a_pinned_session_is_rejected_before_any_frame_is_se async with create_client_server_memory_streams() as ((client_read, client_write), (server_read, _server_write)): async with ClientSession(client_read, client_write, protocol_version="2026-07-28") as session: with anyio.fail_after(5): - with pytest.raises(RuntimeError) as exc_info: + with pytest.raises(RuntimeError) as exc_info: # pragma: no branch await session.initialize() assert str(exc_info.value) == snapshot( "initialize() must not be called on a session pinned to a stateless protocol version" @@ -110,7 +110,7 @@ async def test_initialize_on_a_pinned_session_is_rejected_before_any_frame_is_se # Nothing left the client: closing the sender turns an empty buffer into EndOfStream. await client_write.aclose() with anyio.fail_after(5): - with pytest.raises(anyio.EndOfStream): + with pytest.raises(anyio.EndOfStream): # pragma: no branch await server_read.receive() diff --git a/tests/server/test_experimental_streamable_http_modern.py b/tests/server/test_experimental_streamable_http_modern.py index 53191d47e3..f62d6ed23c 100644 --- a/tests/server/test_experimental_streamable_http_modern.py +++ b/tests/server/test_experimental_streamable_http_modern.py @@ -6,15 +6,18 @@ mapping in ``handle()``, and the request-validation ladder in ``handle_modern_request``. """ +import logging from collections.abc import Mapping from typing import Any +import anyio import httpx import pytest from starlette.requests import Request from starlette.types import Receive, Scope, Send -from mcp.server import Server +import mcp.server._experimental.streamable_http_modern as modern +from mcp.server import Server, ServerRequestContext from mcp.server._experimental.streamable_http_modern import ( SingleExchangeDispatcher, _SingleExchangeDispatchContext, @@ -24,7 +27,7 @@ from mcp.shared.dispatcher import DispatchContext from mcp.shared.exceptions import NoBackChannelError from mcp.shared.transport_context import TransportContext -from mcp.types import INVALID_PARAMS, PARSE_ERROR, JSONRPCError, JSONRPCRequest +from mcp.types import INVALID_PARAMS, PARSE_ERROR, JSONRPCError, JSONRPCRequest, ListToolsResult, PaginatedRequestParams pytestmark = pytest.mark.anyio @@ -98,6 +101,7 @@ async def test_handle_modern_request_rejects_non_post_with_405() -> None: async with _asgi_client(Server("test")) as http: response = await http.get("/mcp") assert response.status_code == 405 + assert response.headers["allow"] == "POST" async def test_handle_modern_request_rejects_malformed_body_with_parse_error() -> None: @@ -106,7 +110,11 @@ async def test_handle_modern_request_rejects_malformed_body_with_parse_error() - response = await http.post("/mcp", content=b"not json", headers={"content-type": "application/json"}) assert response.status_code == 400 assert response.headers["content-type"].split(";", 1)[0] == "application/json" - assert response.json() == {"jsonrpc": "2.0", "error": {"code": PARSE_ERROR, "message": "Parse error"}} + assert response.json() == { + "jsonrpc": "2.0", + "id": None, + "error": {"code": PARSE_ERROR, "message": "Parse error", "data": None}, + } async def test_handle_modern_request_returns_transport_security_error_response() -> None: @@ -116,3 +124,66 @@ async def test_handle_modern_request_returns_transport_security_error_response() response = await http.post("/mcp", json={}, headers={"content-type": "application/json"}) assert response.status_code == 421 assert response.text == "Invalid Host header" + + +def _list_tools_body() -> dict[str, Any]: + """A minimal valid 2026-07-28 ``tools/list`` request body, including the required ``_meta`` envelope.""" + meta = { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": {"name": "raw", "version": "0.0.0"}, + "io.modelcontextprotocol/clientCapabilities": {}, + } + return {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": meta}} + + +async def test_handle_modern_request_sends_response_when_exit_stack_cleanup_raises( + caplog: pytest.LogCaptureFixture, +) -> None: + """A raising ``connection.exit_stack`` callback is logged and swallowed; the computed result still ships. + + The exit-stack guard mirrors ``ServerRunner.run``: cleanup runs in a ``finally`` after the + handler, and an exception there must not displace the JSON-RPC response that was already built. + """ + + async def boom() -> None: + raise RuntimeError("cleanup failed") + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + ctx.session._connection.exit_stack.push_async_callback(boom) + return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public") + + with caplog.at_level(logging.ERROR, logger=modern.__name__): + async with _asgi_client(Server("test", on_list_tools=list_tools)) as http: + response = await http.post("/mcp", json=_list_tools_body(), headers={"content-type": "application/json"}) + + assert response.status_code == 200 + assert response.json()["result"]["tools"] == [] + assert "connection exit_stack cleanup raised" in caplog.text + + +async def test_handle_modern_request_sends_response_when_exit_stack_cleanup_hangs( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """A blocking ``connection.exit_stack`` callback is abandoned at the grace deadline; the response still ships. + + Grace patched to 0 so the deadline is already expired on entry: the bounded unwind cancels the + blocker at its first checkpoint, the abandonment warning is logged, and the JSON-RPC response + that was built before cleanup is sent unchanged. + """ + monkeypatch.setattr(modern, "_EXIT_STACK_CLOSE_TIMEOUT", 0) + + async def block() -> None: + await anyio.Event().wait() + raise AssertionError("unreachable") # pragma: no cover + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + ctx.session._connection.exit_stack.push_async_callback(block) + return ListToolsResult(tools=[], ttl_ms=0, cache_scope="public") + + with anyio.fail_after(5), caplog.at_level(logging.WARNING, logger=modern.__name__): + async with _asgi_client(Server("test", on_list_tools=list_tools)) as http: + response = await http.post("/mcp", json=_list_tools_body(), headers={"content-type": "application/json"}) + + assert response.status_code == 200 + assert response.json()["result"]["tools"] == [] + assert "abandoning remaining callbacks" in caplog.text From 06d149286e377fad28d58e718a932812099d421c Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:38:46 +0000 Subject: [PATCH 11/21] Derive transport headers from a constructor protocol_version pin streamablehttp_client() and StreamableHTTPTransport now take protocol_version: str | None = None, seeding the existing self.protocol_version field that _prepare_headers() already reads. _body_derived_headers (which sniffed params._meta) is replaced by _per_message_headers, gated on the pin and reading message.method directly so requests and notifications are handled uniformly. The _meta envelope is request-only per spec and stays the session's responsibility; the transport no longer treats the body as the source of truth for connection-level headers. The constructor pin also wins over the InitializeResult snoop. ClientSession: pinned sessions set cancel_on_abandon=False so the dispatcher never emits notifications/cancelled (a stateless server cannot correlate it); the envelope keys now overwrite caller-supplied _meta values rather than setdefault. For now the pin is passed to both streamablehttp_client and ClientSession; the high-level Client will collapse this to one argument. Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK --- src/mcp/client/session.py | 16 +++-- src/mcp/client/streamable_http.py | 57 +++++++++------- tests/client/test_streamable_http.py | 66 ++++++++++++------- .../test_client_transport_http_modern.py | 24 +++---- .../transports/test_hosting_http_modern.py | 5 +- 5 files changed, 102 insertions(+), 66 deletions(-) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index edbd790037..1b6a20b9b0 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -231,16 +231,18 @@ async def send_request( if self._pinned_version is not None: params = data.setdefault("params", {}) envelope_meta = params.setdefault("_meta", {}) - envelope_meta.setdefault(PROTOCOL_VERSION_META_KEY, self._pinned_version) - envelope_meta.setdefault( - CLIENT_INFO_META_KEY, - self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True), + 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.setdefault( - CLIENT_CAPABILITIES_META_KEY, - self._build_capabilities().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 ) opts: CallOptions = {} + if self._pinned_version is not None: + # 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 diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index d6bc2393e3..0d50df1218 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -25,7 +25,6 @@ INTERNAL_ERROR, INVALID_REQUEST, PARSE_ERROR, - PROTOCOL_VERSION_META_KEY, ErrorData, InitializeResult, JSONRPCError, @@ -66,24 +65,6 @@ def _encode_header_value(value: str) -> str: return f"=?base64?{base64.b64encode(value.encode('utf-8')).decode('ascii')}?=" -def _body_derived_headers(message: JSONRPCMessage) -> dict[str, str]: - """Derive 2026-era headers from an envelope-bearing request body. Empty dict for legacy bodies.""" - if not isinstance(message, JSONRPCRequest) or message.params is None: - return {} - meta = message.params.get("_meta") - if meta is None: - return {} - version = meta.get(PROTOCOL_VERSION_META_KEY) - if not isinstance(version, str): - return {} - headers: dict[str, str] = {MCP_PROTOCOL_VERSION: version, MCP_METHOD: message.method} - if message.method == "tools/call": - name = message.params.get("name") - if isinstance(name, str): - headers[MCP_NAME] = _encode_header_value(name) - return headers - - class StreamableHTTPError(Exception): """Base exception for StreamableHTTP transport errors.""" @@ -106,15 +87,39 @@ 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 + + # TODO: header derivation from the pin (not body _meta) per spec; body envelope is request-only. + 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 is None or self.protocol_version < "2026-07-28": + return {} + if not isinstance(message, JSONRPCRequest | JSONRPCNotification): + return {} + headers: dict[str, str] = {MCP_METHOD: message.method} + 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. @@ -150,6 +155,9 @@ def _maybe_extract_session_id_from_response(self, response: httpx.Response) -> N 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 @@ -289,7 +297,7 @@ async def _handle_post_request(self, ctx: RequestContext) -> None: """Handle a POST request with response processing.""" headers = self._prepare_headers() message = ctx.session_message.message - headers.update(_body_derived_headers(message)) + headers.update(self._per_message_headers(message)) is_initialization = self._is_initialization_request(message) async with ctx.client.stream( @@ -556,6 +564,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. @@ -565,6 +574,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: @@ -582,7 +593,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}") diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index 2ecee4a3d9..9402fe22c4 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -1,8 +1,9 @@ """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 private header-derivation helpers -directly because the headers are an HTTP-seam observation the public client never exposes. +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 @@ -10,47 +11,50 @@ import pytest from inline_snapshot import snapshot -from mcp.client.streamable_http import _body_derived_headers, _encode_header_value -from mcp.types import PROTOCOL_VERSION_META_KEY, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest - -_ENVELOPE = {PROTOCOL_VERSION_META_KEY: "2026-07-28"} +from mcp.client.streamable_http import StreamableHTTPTransport, _encode_header_value +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": {}, "_meta": _ENVELOPE} - ), - snapshot({"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "add"}), - ), - ( - JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list", params={"_meta": _ENVELOPE}), - snapshot({"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/list"}), + 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/call", params={"_meta": _ENVELOPE}), - snapshot({"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call"}), + JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list", params={}), + snapshot({"mcp-method": "tools/list"}), ), ( - JSONRPCRequest(jsonrpc="2.0", id=3, method="tools/call", params={"name": "add", "arguments": {}}), - snapshot({}), + JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled"), + snapshot({"mcp-method": "notifications/cancelled"}), ), ( - JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized"), + JSONRPCResponse(jsonrpc="2.0", id=3, result={}), snapshot({}), ), ], ) -def test_body_derived_headers_reflect_the_envelope_on_the_request_body( +def test_per_message_headers_for_pinned_transport_carry_method_and_name( message: JSONRPCMessage, expected: dict[str, str] ) -> None: - """An envelope-bearing body yields the three stateless headers; a legacy body yields none. + """A 2026-07-28-pinned transport derives ``Mcp-Method`` (and ``Mcp-Name`` for tools/call) from the body. - Legacy bodies returning ``{}`` is what keeps the unpinned wire byte-identical to a pre-2026 client. + ``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. """ - assert _body_derived_headers(message) == expected + 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( @@ -78,3 +82,19 @@ def test_mcp_name_header_values_are_base64_wrapped_when_unsafe_for_an_http_field assert base64.b64decode(encoded.removeprefix("=?base64?").removesuffix("?=")).decode() == raw else: assert encoded == raw + + +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" diff --git a/tests/interaction/transports/test_client_transport_http_modern.py b/tests/interaction/transports/test_client_transport_http_modern.py index bcfd4b8499..37e9074c51 100644 --- a/tests/interaction/transports/test_client_transport_http_modern.py +++ b/tests/interaction/transports/test_client_transport_http_modern.py @@ -1,10 +1,11 @@ """Behaviour of the streamable-HTTP client transport under the 2026-07-28 stateless protocol. A pinned session stamps the ``io.modelcontextprotocol/*`` `_meta` envelope onto every outgoing -request, and the streamable-HTTP transport derives the ``MCP-Protocol-Version`` / ``Mcp-Method`` / -``Mcp-Name`` headers from that body. These tests pin the composition through a real ``httpx`` -request against a canned ``httpx.MockTransport`` -- no in-process 2026 server exists yet to record -the headers against. The header-derivation helpers themselves are unit-tested in +request, and a pinned streamable-HTTP transport adds the ``MCP-Protocol-Version`` / ``Mcp-Method`` / +``Mcp-Name`` headers to each POST. The pin is a tracer-bullet duplication: both +``streamable_http_client()`` and ``ClientSession()`` take ``protocol_version`` until the higher-level +client wires them together. These tests drive the composition through a real ``httpx`` request +against a canned ``httpx.MockTransport``; the per-message header derivation itself is unit-tested in ``tests/client/test_streamable_http.py``. """ @@ -27,14 +28,13 @@ @requirement("client-transport:http:body-derived-headers") @requirement("lifecycle:stateless:request-envelope") async def test_pinned_session_post_carries_body_derived_headers_on_the_wire() -> None: - """A pinned ``call_tool`` over streamable HTTP lands as a POST whose headers were derived from its body. + """A pinned ``call_tool`` over streamable HTTP lands as a POST carrying the three stateless headers. Spec-mandated for the body-derived headers and the request envelope: this is the wire-seam proof - that the ``ClientSession`` envelope stamp and the transport's header derivation are actually - composed -- the streamable-HTTP POST wiring is driven through a real ``httpx`` request. A canned - ``httpx.MockTransport`` stands in for the (not-yet-existing) 2026 server; the ``isError`` result - skips the client's implicit ``tools/list`` output-schema fetch so the recorded log is the single - POST. + that the ``ClientSession`` envelope stamp and the pinned transport's header derivation are + actually composed -- the streamable-HTTP POST wiring is driven through a real ``httpx`` request. + A canned ``httpx.MockTransport`` stands in for the server; the ``isError`` result skips the + client's implicit ``tools/list`` output-schema fetch so the recorded log is the single POST. """ recorded: list[httpx.Request] = [] @@ -47,7 +47,7 @@ def handler(request: httpx.Request) -> httpx.Response: with anyio.fail_after(5): async with ( httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http, - streamable_http_client(f"{BASE_URL}/mcp", http_client=http) as (read, write), + streamable_http_client(f"{BASE_URL}/mcp", http_client=http, protocol_version="2026-07-28") as (read, write), ClientSession( read, write, @@ -103,7 +103,7 @@ def handler(request: httpx.Request) -> httpx.Response: with anyio.fail_after(5): async with ( httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http, - streamable_http_client(f"{BASE_URL}/mcp", http_client=http) as (read, write), + streamable_http_client(f"{BASE_URL}/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}) diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py index 0d444cd427..a7ab629d8a 100644 --- a/tests/interaction/transports/test_hosting_http_modern.py +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -220,7 +220,10 @@ async def on_response(response: httpx.Response) -> None: with anyio.fail_after(5): async with ( mounted_app(_server(), on_request=on_request, on_response=on_response) as (http, _), - streamable_http_client(f"{BASE_URL}/mcp", http_client=http) as (read, write), + streamable_http_client(f"{BASE_URL}/mcp", http_client=http, protocol_version=MODERN_VERSION) as ( + read, + write, + ), ClientSession(read, write, client_info=client_info, protocol_version=MODERN_VERSION) as session, ): result = await session.call_tool("add", {"a": 2, "b": 3}) From 4378d15937b4793132d294222178ffa1fa64e796 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 19 Jun 2026 15:50:22 +0000 Subject: [PATCH 12/21] Mark post-shielded-cancel assertions as lax-no-cover for 3.11 The exit-stack-hangs test passes on all Python versions, but coverage.py on 3.11 misreports the assertions after the shielded move_on_after cancellation as unhit (the tracer in the test frame is disrupted by the cancel inside the request task). lax no cover is the sanctioned exclusion for lines covered on some versions but not others. Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK --- tests/server/test_experimental_streamable_http_modern.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/server/test_experimental_streamable_http_modern.py b/tests/server/test_experimental_streamable_http_modern.py index f62d6ed23c..0f6654bde4 100644 --- a/tests/server/test_experimental_streamable_http_modern.py +++ b/tests/server/test_experimental_streamable_http_modern.py @@ -183,7 +183,8 @@ async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | with anyio.fail_after(5), caplog.at_level(logging.WARNING, logger=modern.__name__): async with _asgi_client(Server("test", on_list_tools=list_tools)) as http: response = await http.post("/mcp", json=_list_tools_body(), headers={"content-type": "application/json"}) - - assert response.status_code == 200 - assert response.json()["result"]["tools"] == [] - assert "abandoning remaining callbacks" in caplog.text + # coverage.py on Python 3.11 misreports the lines below as unhit (the test passes there); + # the shielded-cancel path inside the request task disrupts the tracer in this frame. + assert response.status_code == 200 # pragma: lax no cover + assert response.json()["result"]["tools"] == [] # pragma: lax no cover + assert "abandoning remaining callbacks" in caplog.text # pragma: lax no cover From 194f22523e84e798a6235811376a6c0845e1dc07 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:48:27 +0000 Subject: [PATCH 13/21] Address review feedback and consolidate 2026-07-28 interaction tests Review-response changes: - Merge the duplicate _pinned_version guard in ClientSession.send_request - Use is_version_at_least() instead of a raw string compare for the per-message-headers gate - Base64-wrap Mcp-Name values with leading/trailing spaces (RFC 7230 forbids them; h11 rejects on real transports) - Add a TODO at the Mcp-Name gate naming prompts/get and resources/read - Type the protocol_version pin as Literal["2026-07-28"] via StatelessProtocolVersion so 2025-era values are a type error - Reword the _related_request_id TODO in ServerSession to point at the per-request Outbound shape (not at widening the Protocol) Interaction-suite consolidation: - Drop test_lifecycle_stateless.py and test_client_transport_http_modern.py; their assertions are now proven by the capstone in test_hosting_http_modern.py (envelope, headers, no-initialize) or moved to tests/client/ (initialize-raises, session-id-ignore against a misbehaving peer) - Extend the capstone to capture ctx.meta server-side and assert the caller-supplied _meta key survives the envelope merge - Reconcile _requirements.py: stack request-envelope and caller-meta-preserved on the capstone; defer no-initialize and stateless-ignores-session-id to tests/client/; drop the duplicate unpinned-legacy-wire and body-derived-headers entries --- src/mcp/client/session.py | 7 +- src/mcp/client/streamable_http.py | 9 +- src/mcp/server/session.py | 7 +- src/mcp/shared/version.py | 5 +- tests/client/test_session.py | 13 + tests/client/test_streamable_http.py | 57 ++++- tests/interaction/_requirements.py | 19 +- .../lowlevel/test_lifecycle_stateless.py | 238 ------------------ .../test_client_transport_http_modern.py | 112 --------- .../transports/test_hosting_http_modern.py | 46 +++- 10 files changed, 122 insertions(+), 391 deletions(-) delete mode 100644 tests/interaction/lowlevel/test_lifecycle_stateless.py delete mode 100644 tests/interaction/transports/test_client_transport_http_modern.py diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 1b6a20b9b0..06abea57de 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -21,7 +21,7 @@ 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.shared.version import SUPPORTED_PROTOCOL_VERSIONS, StatelessProtocolVersion from mcp.types import ( CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, @@ -149,7 +149,7 @@ def __init__( message_handler: MessageHandlerFnT | None = None, client_info: types.Implementation | None = None, *, - protocol_version: str | None = None, + protocol_version: StatelessProtocolVersion | None = None, sampling_capabilities: types.SamplingCapability | None = None, dispatcher: Dispatcher[Any] | None = None, ) -> None: @@ -228,6 +228,7 @@ async def send_request( """ data = request.model_dump(by_alias=True, mode="json", exclude_none=True) method: str = data["method"] + opts: CallOptions = {} if self._pinned_version is not None: params = data.setdefault("params", {}) envelope_meta = params.setdefault("_meta", {}) @@ -238,8 +239,6 @@ async def send_request( envelope_meta[CLIENT_CAPABILITIES_META_KEY] = self._build_capabilities().model_dump( by_alias=True, mode="json", exclude_none=True ) - opts: CallOptions = {} - if self._pinned_version is not None: # 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 diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index 0d50df1218..014b5b038f 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -21,6 +21,7 @@ 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 StatelessProtocolVersion, is_version_at_least from mcp.types import ( INTERNAL_ERROR, INVALID_REQUEST, @@ -60,7 +61,7 @@ def _encode_header_value(value: str) -> str: - if _HEADER_SAFE.fullmatch(value) and not _B64_SENTINEL.fullmatch(value): + 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')}?=" @@ -107,11 +108,13 @@ def _per_message_headers(self, message: JSONRPCMessage) -> dict[str, str]: MCP-Protocol-Version is not emitted here — `_prepare_headers()` already adds it from `self.protocol_version` for every request. """ - if self.protocol_version is None or self.protocol_version < "2026-07-28": + if self.protocol_version is None or not is_version_at_least(self.protocol_version, "2026-07-28"): 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" @@ -564,7 +567,7 @@ async def streamable_http_client( *, http_client: httpx.AsyncClient | None = None, terminate_on_close: bool = True, - protocol_version: str | None = None, + protocol_version: StatelessProtocolVersion | None = None, ) -> AsyncGenerator[TransportStreams, None]: """Client transport for StreamableHTTP. diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index 583210ddb4..f56b87c9a3 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -91,8 +91,11 @@ async def send_request( # Fail fast instead of parking forever on a response that cannot # arrive; matches `Connection.send_raw_request`. raise NoBackChannelError(data["method"]) - # TODO: _related_request_id is not on the Dispatcher Protocol; either - # add it there or refactor ServerSession once the legacy path is compat-only. + # 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( diff --git a/src/mcp/shared/version.py b/src/mcp/shared/version.py index 2299de72ec..f03b2ee01a 100644 --- a/src/mcp/shared/version.py +++ b/src/mcp/shared/version.py @@ -7,10 +7,13 @@ ordering questions go through KNOWN_PROTOCOL_VERSIONS. """ -from typing import Final +from typing import Final, Literal from mcp.types import LATEST_PROTOCOL_VERSION +StatelessProtocolVersion = Literal["2026-07-28"] +"""Protocol revisions that use the stateless per-request envelope (no `initialize`).""" + KNOWN_PROTOCOL_VERSIONS: Final[tuple[str, ...]] = ( "2024-11-05", "2025-03-26", diff --git a/tests/client/test_session.py b/tests/client/test_session.py index 0f68a066fb..06b98a8a18 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -1400,6 +1400,19 @@ async def notify(self, method: str, params: Mapping[str, Any] | None) -> None: assert session._task_group is None +@pytest.mark.anyio +async def test_initialize_on_a_pinned_session_raises_before_any_frame_is_sent(): + """A session pinned to the 2026-07-28 stateless protocol rejects ``initialize()`` locally. + + The 2026-07-28 lifecycle replaces the initialize handshake with a per-request ``_meta`` + envelope, so calling ``initialize()`` on a pinned session is a programmer error and raises + immediately rather than reaching the wire. + """ + async with raw_client_session(protocol_version="2026-07-28") as (session, _send, _recv): + with pytest.raises(RuntimeError, match="pinned to a stateless"): + await session.initialize() + + @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, diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py index 9402fe22c4..0aecb971e8 100644 --- a/tests/client/test_streamable_http.py +++ b/tests/client/test_streamable_http.py @@ -7,11 +7,15 @@ """ import base64 +import json +import anyio +import httpx import pytest from inline_snapshot import snapshot -from mcp.client.streamable_http import StreamableHTTPTransport, _encode_header_value +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 @@ -61,7 +65,10 @@ def test_per_message_headers_are_empty_for_legacy_or_unpinned_transport(protocol ("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), @@ -70,10 +77,12 @@ def test_per_message_headers_are_empty_for_legacy_or_unpinned_transport(protocol 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, and sentinel-shaped names are wrapped. + """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. + 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 @@ -84,6 +93,48 @@ def test_mcp_name_header_values_are_base64_wrapped_when_unsafe_for_an_http_field 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") diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 84da860269..2aa119bd27 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -331,6 +331,7 @@ def __post_init__(self) -> None: source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#stateless-operation", behavior="A ClientSession pinned to 2026-07-28 rejects initialize() before any frame is 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", @@ -340,13 +341,6 @@ def __post_init__(self) -> None: ), 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." - ), - ), # ═══════════════════════════════════════════════════════════════════════════ # Protocol primitives: cancellation, timeout, progress, errors, _meta # ═══════════════════════════════════════════════════════════════════════════ @@ -3137,16 +3131,6 @@ def __post_init__(self) -> None: 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=( @@ -3156,6 +3140,7 @@ def __post_init__(self) -> None: 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 diff --git a/tests/interaction/lowlevel/test_lifecycle_stateless.py b/tests/interaction/lowlevel/test_lifecycle_stateless.py deleted file mode 100644 index 86cf4b8ec7..0000000000 --- a/tests/interaction/lowlevel/test_lifecycle_stateless.py +++ /dev/null @@ -1,238 +0,0 @@ -"""Stateless lifecycle at protocol version 2026-07-28, driven through a bare ClientSession. - -Under the 2026-07-28 lifecycle the initialize handshake is replaced by a per-request envelope: -every request carries the protocol version, client info, and client capabilities under -``params._meta`` and the server never sees an ``initialize`` frame. These tests pin the session -to that version and observe the outgoing JSON-RPC frame directly, so they drop below the -``connect`` fixture to a bare ClientSession over in-process memory streams. No 2026-aware Server -exists yet, so the receiving side is a scripted peer that hand-builds the wire response — reserve -this pattern for behaviour no real server can be made to produce. -""" - -from contextlib import nullcontext - -import anyio -import pytest -from inline_snapshot import snapshot - -from mcp.client import ClientSession -from mcp.shared.memory import MessageStream, create_client_server_memory_streams -from mcp.shared.message import SessionMessage -from mcp.types import ( - CallToolResult, - Implementation, - InitializeResult, - JSONRPCNotification, - JSONRPCRequest, - JSONRPCResponse, - ListToolsResult, - ServerCapabilities, -) -from tests.interaction._helpers import RecordingTransport -from tests.interaction._modern_vocab import MODERN_BODY_TOKENS -from tests.interaction._requirements import requirement - -pytestmark = pytest.mark.anyio - - -@requirement("lifecycle:stateless:request-envelope") -async def test_pinned_session_stamps_the_envelope_meta_on_every_request_and_never_initializes() -> None: - """A pinned session's first request is the feature request itself, carrying the three-key envelope. - - The scripted peer asserts the only frame on the wire is ``tools/list`` (no ``initialize``, no - ``notifications/initialized``) and answers with a hand-built 2026-07-28 result; the test then - snapshots the captured ``params._meta``. - """ - received: list[JSONRPCRequest] = [] - - async def scripted_server(streams: MessageStream) -> None: - server_read, server_write = streams - message = await server_read.receive() - assert isinstance(message, SessionMessage) - request = message.message - assert isinstance(request, JSONRPCRequest) - assert request.method == "tools/list" - received.append(request) - result = ListToolsResult(tools=[], cache_scope="public", ttl_ms=0) - await server_write.send( - SessionMessage( - JSONRPCResponse( - jsonrpc="2.0", - id=request.id, - # Serialized exactly as a real server serializes results onto the wire. - result=result.model_dump(by_alias=True, mode="json", exclude_none=True), - ) - ) - ) - - async with ( - create_client_server_memory_streams() as ((client_read, client_write), server_streams), - anyio.create_task_group() as tg, - ClientSession( - client_read, - client_write, - client_info=Implementation(name="pin-client", version="1.0.0"), - protocol_version="2026-07-28", - ) as session, - ): - tg.start_soon(scripted_server, server_streams) - with anyio.fail_after(5): - result = await session.list_tools() - assert isinstance(result, ListToolsResult) - - assert len(received) == 1 - only = received[0] - assert only.params is not None - assert only.params["_meta"] == snapshot( - { - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientInfo": {"name": "pin-client", "version": "1.0.0"}, - "io.modelcontextprotocol/clientCapabilities": {}, - } - ) - - -@requirement("lifecycle:stateless:no-initialize") -async def test_initialize_on_a_pinned_session_is_rejected_before_any_frame_is_sent() -> None: - """``initialize()`` on a pinned session raises immediately, never reaching the wire. - - After the rejection the client's send stream is closed and the server-side read drains to - EndOfStream with no buffered frame, proving the guard fired before any write. - """ - async with create_client_server_memory_streams() as ((client_read, client_write), (server_read, _server_write)): - async with ClientSession(client_read, client_write, protocol_version="2026-07-28") as session: - with anyio.fail_after(5): - with pytest.raises(RuntimeError) as exc_info: # pragma: no branch - await session.initialize() - assert str(exc_info.value) == snapshot( - "initialize() must not be called on a session pinned to a stateless protocol version" - ) - # Nothing left the client: closing the sender turns an empty buffer into EndOfStream. - await client_write.aclose() - with anyio.fail_after(5): - with pytest.raises(anyio.EndOfStream): # pragma: no branch - await server_read.receive() - - -@requirement("lifecycle:stateless:caller-meta-preserved") -async def test_caller_supplied_meta_is_preserved_under_the_envelope_merge() -> None: - """A caller's ``meta=`` keys survive the pinned session's envelope stamp on the same ``_meta`` object. - - The envelope merge is additive, so a caller-supplied key sits alongside the three - ``io.modelcontextprotocol/*`` keys rather than being overwritten. The scripted peer captures the - single ``tools/call`` frame and answers with an ``is_error`` result so the client skips its - implicit output-schema fetch; the test then snapshots the captured ``params._meta``. - """ - received: list[JSONRPCRequest] = [] - - async def scripted_server(streams: MessageStream) -> None: - server_read, server_write = streams - message = await server_read.receive() - assert isinstance(message, SessionMessage) - request = message.message - assert isinstance(request, JSONRPCRequest) - assert request.method == "tools/call" - received.append(request) - result = CallToolResult(content=[], is_error=True) - await server_write.send( - SessionMessage( - JSONRPCResponse( - jsonrpc="2.0", - id=request.id, - # Serialized exactly as a real server serializes results onto the wire. - result=result.model_dump(by_alias=True, mode="json", exclude_none=True), - ) - ) - ) - - async with ( - create_client_server_memory_streams() as ((client_read, client_write), server_streams), - anyio.create_task_group() as tg, - ClientSession( - client_read, - client_write, - client_info=Implementation(name="pin-client", version="1.0.0"), - protocol_version="2026-07-28", - ) as session, - ): - tg.start_soon(scripted_server, server_streams) - with anyio.fail_after(5): - result = await session.call_tool("add", {"a": 2, "b": 3}, meta={"custom-key": "x"}) - assert isinstance(result, CallToolResult) - - assert len(received) == 1 - only = received[0] - assert only.params is not None - assert only.params["_meta"] == snapshot( - { - "custom-key": "x", - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientInfo": {"name": "pin-client", "version": "1.0.0"}, - "io.modelcontextprotocol/clientCapabilities": {}, - } - ) - - -@requirement("lifecycle:stateless:unpinned-legacy-wire") -async def test_unpinned_session_round_trip_carries_no_modern_protocol_vocabulary() -> None: - """An unpinned session's handshake-plus-request emits no 2026-07-28 vocabulary on any frame. - - The JSON-RPC-seam complement to ``test_legacy_wire.py`` (which records the HTTP seam): a - ``RecordingTransport`` wrapped around the client side of the in-process memory streams captures - every frame in either direction, the scripted peer answers ``initialize`` at ``2025-11-25`` then - ``tools/list``, and every captured frame body is scanned for :data:`MODERN_BODY_TOKENS` so any - leak of the envelope keys, the result-envelope fields, or the version literal onto the legacy - session path fails here. - """ - - async def scripted_server(streams: MessageStream) -> None: - server_read, server_write = streams - init = await server_read.receive() - assert isinstance(init, SessionMessage) - assert isinstance(init.message, JSONRPCRequest) - assert init.message.method == "initialize" - result = InitializeResult( - protocol_version="2025-11-25", - capabilities=ServerCapabilities(), - server_info=Implementation(name="legacy-server", version="0.0.0"), - ) - await server_write.send( - SessionMessage( - JSONRPCResponse( - jsonrpc="2.0", - id=init.message.id, - # Serialized exactly as a real server serializes results onto the wire. - result=result.model_dump(by_alias=True, mode="json", exclude_none=True), - ) - ) - ) - initialized = await server_read.receive() - assert isinstance(initialized, SessionMessage) - assert isinstance(initialized.message, JSONRPCNotification) - listing = await server_read.receive() - assert isinstance(listing, SessionMessage) - assert isinstance(listing.message, JSONRPCRequest) - assert listing.message.method == "tools/list" - await server_write.send( - SessionMessage(JSONRPCResponse(jsonrpc="2.0", id=listing.message.id, result={"tools": []})) - ) - - async with create_client_server_memory_streams() as (client_streams, server_streams): - recording = RecordingTransport(nullcontext(client_streams)) - async with ( - anyio.create_task_group() as tg, - recording as (client_read, client_write), - ClientSession(client_read, client_write) as session, - ): - tg.start_soon(scripted_server, server_streams) - with anyio.fail_after(5): - await session.initialize() - result = await session.list_tools() - assert isinstance(result, ListToolsResult) - - frames = list(recording.sent) + [m for m in recording.received if isinstance(m, SessionMessage)] - methods = [m.message.method for m in recording.sent if isinstance(m.message, JSONRPCRequest | JSONRPCNotification)] - assert methods == snapshot(["initialize", "notifications/initialized", "tools/list"]) - bodies = [m.message.model_dump_json(by_alias=True, exclude_none=True) for m in frames] - leaked = sorted({token for token in MODERN_BODY_TOKENS for body in bodies if token in body}) - assert leaked == [] diff --git a/tests/interaction/transports/test_client_transport_http_modern.py b/tests/interaction/transports/test_client_transport_http_modern.py deleted file mode 100644 index 37e9074c51..0000000000 --- a/tests/interaction/transports/test_client_transport_http_modern.py +++ /dev/null @@ -1,112 +0,0 @@ -"""Behaviour of the streamable-HTTP client transport under the 2026-07-28 stateless protocol. - -A pinned session stamps the ``io.modelcontextprotocol/*`` `_meta` envelope onto every outgoing -request, and a pinned streamable-HTTP transport adds the ``MCP-Protocol-Version`` / ``Mcp-Method`` / -``Mcp-Name`` headers to each POST. The pin is a tracer-bullet duplication: both -``streamable_http_client()`` and ``ClientSession()`` take ``protocol_version`` until the higher-level -client wires them together. These tests drive the composition through a real ``httpx`` request -against a canned ``httpx.MockTransport``; the per-message header derivation itself is unit-tested in -``tests/client/test_streamable_http.py``. -""" - -import json - -import anyio -import httpx -import pytest -from inline_snapshot import snapshot - -from mcp.client import ClientSession -from mcp.client.streamable_http import streamable_http_client -from mcp.types import Implementation -from tests.interaction._connect import BASE_URL -from tests.interaction._requirements import requirement - -pytestmark = pytest.mark.anyio - - -@requirement("client-transport:http:body-derived-headers") -@requirement("lifecycle:stateless:request-envelope") -async def test_pinned_session_post_carries_body_derived_headers_on_the_wire() -> None: - """A pinned ``call_tool`` over streamable HTTP lands as a POST carrying the three stateless headers. - - Spec-mandated for the body-derived headers and the request envelope: this is the wire-seam proof - that the ``ClientSession`` envelope stamp and the pinned transport's header derivation are - actually composed -- the streamable-HTTP POST wiring is driven through a real ``httpx`` request. - A canned ``httpx.MockTransport`` stands in for the server; the ``isError`` result skips the - client's implicit ``tools/list`` output-schema fetch so the recorded log is the single POST. - """ - recorded: list[httpx.Request] = [] - - def handler(request: httpx.Request) -> httpx.Response: - recorded.append(request) - body = json.loads(request.content) - result = {"content": [{"type": "text", "text": "5"}], "isError": True, "resultType": "complete"} - return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": result}) - - with anyio.fail_after(5): - async with ( - httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http, - streamable_http_client(f"{BASE_URL}/mcp", http_client=http, protocol_version="2026-07-28") as (read, write), - ClientSession( - read, - write, - client_info=Implementation(name="pin-client", version="1.0.0"), - 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 = recorded[0] - assert {k: v for k, v in post.headers.items() if k.startswith("mcp-")} == snapshot( - {"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "add"} - ) - assert json.loads(post.content)["params"]["_meta"] == snapshot( - { - "io.modelcontextprotocol/protocolVersion": "2026-07-28", - "io.modelcontextprotocol/clientInfo": {"name": "pin-client", "version": "1.0.0"}, - "io.modelcontextprotocol/clientCapabilities": {}, - } - ) - - -@requirement("client-transport:http:stateless-ignores-session-id") -async def test_pinned_session_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. - - Spec-mandated for the stateless transport: the session-id capture, the standalone GET listening - stream, and the DELETE-on-close are all gated on state a pinned session never produces (no - ``initialize``, no ``notifications/initialized``), so even when the canned server 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(f"{BASE_URL}/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) diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py index a7ab629d8a..0257313e6d 100644 --- a/tests/interaction/transports/test_hosting_http_modern.py +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -64,11 +64,10 @@ def _meta_envelope() -> dict[str, object]: def _server() -> Server: - """A low-level server with one ``add`` tool, listed so the SDK client's implicit tools/list resolves.""" + """A low-level server with one ``add`` tool for the raw-httpx tests below.""" async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: - tool = Tool(name="add", input_schema={"type": "object"}) - return ListToolsResult(tools=[tool], ttl_ms=0, cache_scope="public") + raise NotImplementedError async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: assert params.name == "add" @@ -194,6 +193,8 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> @requirement("hosting:http:modern:tools-call-stateless") +@requirement("lifecycle:stateless:request-envelope") +@requirement("lifecycle:stateless:caller-meta-preserved") async def test_pinned_client_stateless_tools_call_round_trips_against_the_modern_entry() -> None: """First end-to-end exercise of the 2026-07-28 stateless request style: SDK client to SDK server. @@ -201,12 +202,30 @@ async def test_pinned_client_stateless_tools_call_round_trips_against_the_modern single-exchange serving entry compose so that ``call_tool`` returns ``resultType: complete`` with no ``initialize`` ever sent, no ``Mcp-Session-Id`` on any request or response, and every POST carrying the body-derived ``MCP-Protocol-Version`` / ``Mcp-Method`` / ``Mcp-Name`` headers - plus the three-key ``io.modelcontextprotocol/*`` ``_meta`` envelope. Asserted at the wire via - the ``mounted_app`` httpx event hooks because none of the headers, the envelope, or the - handshake-absence is observable through the public client API. The recorded log shows two - POSTs: the ``tools/call`` itself and the client's implicit ``tools/list`` output-schema fetch - (see ``client:output-schema:auto-list``), both of which must satisfy the stateless contract. + plus the three-key ``io.modelcontextprotocol/*`` ``_meta`` envelope. The caller passes a + ``custom-key`` under ``meta=`` and the server handler captures the incoming ``ctx.meta``, + proving the envelope merge is additive: the caller's key sits alongside the three envelope keys + on the wire and inside the handler. Asserted at the wire via the ``mounted_app`` httpx event + hooks because none of the headers, the envelope, or the handshake-absence is observable through + the public client API. The recorded log shows two POSTs: the ``tools/call`` itself and the + client's implicit ``tools/list`` output-schema fetch (see ``client:output-schema:auto-list``), + both of which must satisfy the stateless contract. """ + observed_metas: list[dict[str, object]] = [] + + async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + tool = Tool(name="add", input_schema={"type": "object"}) + return ListToolsResult(tools=[tool], ttl_ms=0, cache_scope="public") + + async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + assert params.name == "add" + assert params.arguments is not None + assert ctx.meta is not None + observed_metas.append(dict(ctx.meta)) + return CallToolResult(content=[TextContent(text=str(params.arguments["a"] + params.arguments["b"]))]) + + server = Server("modern", on_list_tools=list_tools, on_call_tool=call_tool) + requests: list[httpx.Request] = [] responses: list[httpx.Response] = [] @@ -219,14 +238,14 @@ async def on_response(response: httpx.Response) -> None: client_info = Implementation(name="e2e-client", version="1.0.0") with anyio.fail_after(5): async with ( - mounted_app(_server(), on_request=on_request, on_response=on_response) as (http, _), + mounted_app(server, on_request=on_request, on_response=on_response) as (http, _), streamable_http_client(f"{BASE_URL}/mcp", http_client=http, protocol_version=MODERN_VERSION) as ( read, write, ), ClientSession(read, write, client_info=client_info, protocol_version=MODERN_VERSION) as session, ): - result = await session.call_tool("add", {"a": 2, "b": 3}) + result = await session.call_tool("add", {"a": 2, "b": 3}, meta={"custom-key": "x"}) assert result.model_dump(by_alias=True, mode="json", exclude_none=True) == snapshot( {"content": [{"type": "text", "text": "5"}], "isError": False, "resultType": "complete"} @@ -240,19 +259,24 @@ async def on_response(response: httpx.Response) -> None: ) assert all("initialize" not in body["method"] for body in bodies) - # The tools/call POST carries the body-derived headers and the three-key envelope. + # The tools/call POST carries the body-derived headers, and its _meta envelope merges the + # caller's key alongside the three io.modelcontextprotocol/* keys. call = requests[0] assert {k: v for k, v in call.headers.items() if k.startswith("mcp-")} == snapshot( {"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "add"} ) assert bodies[0]["params"]["_meta"] == snapshot( { + "custom-key": "x", "io.modelcontextprotocol/protocolVersion": "2026-07-28", "io.modelcontextprotocol/clientInfo": {"name": "e2e-client", "version": "1.0.0"}, "io.modelcontextprotocol/clientCapabilities": {}, } ) + # The server handler observed the same merged _meta on ctx.meta. + assert observed_metas == [bodies[0]["params"]["_meta"]] + # No session id on any request or response: the exchange is sessionless end to end. assert len(responses) == len(requests) assert all("mcp-session-id" not in r.headers for r in requests) From 3afe0f0c0c9c4940141cbf9ccd097aff335b8cc3 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 19 Jun 2026 18:18:09 +0000 Subject: [PATCH 14/21] Tighten the consolidated 2026-07-28 interaction tests - Prove the envelope is stamped when the caller passes no _meta by snapshotting the implicit tools/list body in the capstone - Give _server() an on_meta hook so the capstone reuses it instead of duplicating its handlers - Restore the wire-emptiness check on the pinned-initialize-raises unit test (buffer-used == 0 after the raise) - Restore lifecycle:stateless:unpinned-legacy-wire (deferred) and client-transport:http:body-derived-headers (stacked on the capstone) in the requirements ledger - Drop the redundant strip(" ") arg in _encode_header_value --- src/mcp/client/streamable_http.py | 2 +- tests/client/test_session.py | 3 +- tests/interaction/_requirements.py | 21 +++++++++++ .../transports/test_hosting_http_modern.py | 36 ++++++++++--------- 4 files changed, 44 insertions(+), 18 deletions(-) diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index 014b5b038f..b66b63dfb8 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -61,7 +61,7 @@ def _encode_header_value(value: str) -> str: - if _HEADER_SAFE.fullmatch(value) and value == value.strip(" ") and not _B64_SENTINEL.fullmatch(value): + 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')}?=" diff --git a/tests/client/test_session.py b/tests/client/test_session.py index 06b98a8a18..e918ec33c6 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -1408,9 +1408,10 @@ async def test_initialize_on_a_pinned_session_raises_before_any_frame_is_sent(): envelope, so calling ``initialize()`` on a pinned session is a programmer error and raises immediately rather than reaching the wire. """ - async with raw_client_session(protocol_version="2026-07-28") as (session, _send, _recv): + async with raw_client_session(protocol_version="2026-07-28") as (session, _send, from_client): with pytest.raises(RuntimeError, match="pinned to a stateless"): await session.initialize() + assert from_client.statistics().current_buffer_used == 0 @pytest.mark.anyio diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 2aa119bd27..2d65d4e17b 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -341,6 +341,17 @@ def __post_init__(self) -> None: ), 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 # ═══════════════════════════════════════════════════════════════════════════ @@ -3131,6 +3142,16 @@ def __post_init__(self) -> None: 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=( diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py index 0257313e6d..d494cebbcf 100644 --- a/tests/interaction/transports/test_hosting_http_modern.py +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -8,6 +8,8 @@ """ import json +from collections.abc import Callable +from typing import Any import anyio import httpx @@ -63,15 +65,19 @@ def _meta_envelope() -> dict[str, object]: } -def _server() -> Server: +def _server(*, on_meta: Callable[[dict[str, Any]], None] | None = None) -> Server: """A low-level server with one ``add`` tool for the raw-httpx tests below.""" async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: - raise NotImplementedError + tool = Tool(name="add", input_schema={"type": "object"}) + return ListToolsResult(tools=[tool], ttl_ms=0, cache_scope="public") async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: assert params.name == "add" assert params.arguments is not None + if on_meta is not None: + assert ctx.meta is not None + on_meta(dict(ctx.meta)) return CallToolResult(content=[TextContent(text=str(params.arguments["a"] + params.arguments["b"]))]) return Server("modern", on_list_tools=list_tools, on_call_tool=call_tool) @@ -195,6 +201,7 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> @requirement("hosting:http:modern:tools-call-stateless") @requirement("lifecycle:stateless:request-envelope") @requirement("lifecycle:stateless:caller-meta-preserved") +@requirement("client-transport:http:body-derived-headers") async def test_pinned_client_stateless_tools_call_round_trips_against_the_modern_entry() -> None: """First end-to-end exercise of the 2026-07-28 stateless request style: SDK client to SDK server. @@ -211,20 +218,8 @@ async def test_pinned_client_stateless_tools_call_round_trips_against_the_modern client's implicit ``tools/list`` output-schema fetch (see ``client:output-schema:auto-list``), both of which must satisfy the stateless contract. """ - observed_metas: list[dict[str, object]] = [] - - async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: - tool = Tool(name="add", input_schema={"type": "object"}) - return ListToolsResult(tools=[tool], ttl_ms=0, cache_scope="public") - - async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: - assert params.name == "add" - assert params.arguments is not None - assert ctx.meta is not None - observed_metas.append(dict(ctx.meta)) - return CallToolResult(content=[TextContent(text=str(params.arguments["a"] + params.arguments["b"]))]) - - server = Server("modern", on_list_tools=list_tools, on_call_tool=call_tool) + observed_metas: list[dict[str, Any]] = [] + server = _server(on_meta=observed_metas.append) requests: list[httpx.Request] = [] responses: list[httpx.Response] = [] @@ -273,6 +268,15 @@ async def on_response(response: httpx.Response) -> None: "io.modelcontextprotocol/clientCapabilities": {}, } ) + # The implicit tools/list carries the envelope but no caller meta: proves the envelope is + # stamped on every request, not just on requests where the caller passed meta=. + assert bodies[1]["params"]["_meta"] == snapshot( + { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": {"name": "e2e-client", "version": "1.0.0"}, + "io.modelcontextprotocol/clientCapabilities": {}, + } + ) # The server handler observed the same merged _meta on ctx.meta. assert observed_metas == [bodies[0]["params"]["_meta"]] From 12f2539601fbeab37ae8f9f2a72abffa18ba22d1 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:57:50 +0000 Subject: [PATCH 15/21] Dispatch ClientSession.protocol_version by era instead of restricting to a Literal A pin to a stateful version now flows through initialize() as the requested version (instead of LATEST_PROTOCOL_VERSION); only a pin to a 2026-07-28+ stateless version raises. The envelope-stamp and cancel_on_abandon=False in send_request key off the same is_version_at_least gate. StatelessProtocolVersion is dropped. --- src/mcp/client/session.py | 13 +++++++----- src/mcp/client/streamable_http.py | 4 ++-- src/mcp/shared/version.py | 5 +---- tests/client/test_session.py | 33 ++++++++++++++++++++++++++++--- 4 files changed, 41 insertions(+), 14 deletions(-) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 06abea57de..00b798b1d8 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -21,7 +21,7 @@ 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, StatelessProtocolVersion +from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS, is_version_at_least from mcp.types import ( CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, @@ -149,13 +149,14 @@ def __init__( message_handler: MessageHandlerFnT | None = None, client_info: types.Implementation | None = None, *, - protocol_version: StatelessProtocolVersion | 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 is not None and is_version_at_least(protocol_version, "2026-07-28") self._sampling_callback = sampling_callback or _default_sampling_callback self._sampling_capabilities = sampling_capabilities self._elicitation_callback = elicitation_callback or _default_elicitation_callback @@ -229,7 +230,7 @@ async def send_request( data = request.model_dump(by_alias=True, mode="json", exclude_none=True) method: str = data["method"] opts: CallOptions = {} - if self._pinned_version is not None: + if self._stateless_pinned: params = data.setdefault("params", {}) envelope_meta = params.setdefault("_meta", {}) envelope_meta[PROTOCOL_VERSION_META_KEY] = self._pinned_version @@ -299,13 +300,15 @@ def _build_capabilities(self) -> types.ClientCapabilities: return types.ClientCapabilities(sampling=sampling, elicitation=elicitation, experimental=None, roots=roots) async def initialize(self) -> types.InitializeResult: - if self._pinned_version is not None: + if self._stateless_pinned: raise RuntimeError("initialize() must not be called on a session pinned to a stateless protocol version") capabilities = self._build_capabilities() result = await self.send_request( types.InitializeRequest( params=types.InitializeRequestParams( - protocol_version=types.LATEST_PROTOCOL_VERSION, + protocol_version=self._pinned_version + if self._pinned_version is not None + else types.LATEST_PROTOCOL_VERSION, capabilities=capabilities, client_info=self._client_info, ), diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index b66b63dfb8..4257ea9583 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -21,7 +21,7 @@ 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 StatelessProtocolVersion, is_version_at_least +from mcp.shared.version import is_version_at_least from mcp.types import ( INTERNAL_ERROR, INVALID_REQUEST, @@ -567,7 +567,7 @@ async def streamable_http_client( *, http_client: httpx.AsyncClient | None = None, terminate_on_close: bool = True, - protocol_version: StatelessProtocolVersion | None = None, + protocol_version: str | None = None, ) -> AsyncGenerator[TransportStreams, None]: """Client transport for StreamableHTTP. diff --git a/src/mcp/shared/version.py b/src/mcp/shared/version.py index f03b2ee01a..2299de72ec 100644 --- a/src/mcp/shared/version.py +++ b/src/mcp/shared/version.py @@ -7,13 +7,10 @@ ordering questions go through KNOWN_PROTOCOL_VERSIONS. """ -from typing import Final, Literal +from typing import Final from mcp.types import LATEST_PROTOCOL_VERSION -StatelessProtocolVersion = Literal["2026-07-28"] -"""Protocol revisions that use the stateless per-request envelope (no `initialize`).""" - KNOWN_PROTOCOL_VERSIONS: Final[tuple[str, ...]] = ( "2024-11-05", "2025-03-26", diff --git a/tests/client/test_session.py b/tests/client/test_session.py index e918ec33c6..940d12b1e7 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -1401,12 +1401,12 @@ async def notify(self, method: str, params: Mapping[str, Any] | None) -> None: @pytest.mark.anyio -async def test_initialize_on_a_pinned_session_raises_before_any_frame_is_sent(): +async def test_initialize_on_a_stateless_pinned_session_raises_before_any_frame_is_sent(): """A session pinned to the 2026-07-28 stateless protocol rejects ``initialize()`` locally. The 2026-07-28 lifecycle replaces the initialize handshake with a per-request ``_meta`` - envelope, so calling ``initialize()`` on a pinned session is a programmer error and raises - immediately rather than reaching the wire. + envelope, so calling ``initialize()`` on a stateless-pinned session is a programmer error + and raises immediately rather than reaching the wire. """ async with raw_client_session(protocol_version="2026-07-28") as (session, _send, from_client): with pytest.raises(RuntimeError, match="pinned to a stateless"): @@ -1414,6 +1414,33 @@ async def test_initialize_on_a_pinned_session_raises_before_any_frame_is_sent(): assert from_client.statistics().current_buffer_used == 0 +@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): + async with anyio.create_task_group() as tg: + tg.start_soon(session.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" + result = InitializeResult( + protocol_version="2025-06-18", + 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), + ) + ) + ) + + @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, From 4954ed41286d3ec08c5696fe912b1ff1fd6e07a5 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:34:33 +0000 Subject: [PATCH 16/21] Make a pinned ClientSession born-initialized and centralize the modern-version constants - A 2026-07-28-pinned ClientSession is born initialized: __init__ synthesizes an InitializeResult (placeholder server_info until server/discover lands) and initialize() returns it idempotently with no wire traffic. A repeat initialize() on a stateful session likewise returns the cached result. A stateful pin still requests that version on first call. - Add FIRST_MODERN_VERSION and MODERN_PROTOCOL_VERSIONS to mcp/shared/version and import them at the four call sites that previously inlined the literal. - Move the modern HTTP serving entry from _experimental/ to a private sibling module (mcp.server._streamable_http_modern); drop the empty package. --- src/mcp/client/session.py | 30 +++++++++++++++---- src/mcp/client/streamable_http.py | 5 ++-- src/mcp/server/_experimental/__init__.py | 1 - ...p_modern.py => _streamable_http_modern.py} | 15 ++++------ src/mcp/server/streamable_http_manager.py | 5 ++-- src/mcp/shared/version.py | 6 ++++ tests/client/test_session.py | 29 +++++++++++++----- tests/interaction/_requirements.py | 5 +++- ...dern.py => test_streamable_http_modern.py} | 6 ++-- 9 files changed, 70 insertions(+), 32 deletions(-) delete mode 100644 src/mcp/server/_experimental/__init__.py rename src/mcp/server/{_experimental/streamable_http_modern.py => _streamable_http_modern.py} (94%) rename tests/server/{test_experimental_streamable_http_modern.py => test_streamable_http_modern.py} (97%) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 00b798b1d8..24d2b69ba4 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -21,7 +21,7 @@ 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, is_version_at_least +from mcp.shared.version import FIRST_MODERN_VERSION, SUPPORTED_PROTOCOL_VERSIONS, is_version_at_least from mcp.types import ( CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, @@ -156,7 +156,9 @@ def __init__( 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 is not None and is_version_at_least(protocol_version, "2026-07-28") + self._stateless_pinned = protocol_version is not None and is_version_at_least( + protocol_version, FIRST_MODERN_VERSION + ) self._sampling_callback = sampling_callback or _default_sampling_callback self._sampling_capabilities = sampling_capabilities self._elicitation_callback = elicitation_callback or _default_elicitation_callback @@ -164,7 +166,19 @@ def __init__( 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: @@ -300,8 +314,8 @@ def _build_capabilities(self) -> types.ClientCapabilities: return types.ClientCapabilities(sampling=sampling, elicitation=elicitation, experimental=None, roots=roots) async def initialize(self) -> types.InitializeResult: - if self._stateless_pinned: - raise RuntimeError("initialize() must not be called on a session pinned to a stateless protocol version") + if self._initialize_result is not None: + return self._initialize_result capabilities = self._build_capabilities() result = await self.send_request( types.InitializeRequest( @@ -329,7 +343,11 @@ async def initialize(self) -> types.InitializeResult: 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 diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index 4257ea9583..e5dee2c727 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -21,7 +21,7 @@ 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 is_version_at_least +from mcp.shared.version import FIRST_MODERN_VERSION, is_version_at_least from mcp.types import ( INTERNAL_ERROR, INVALID_REQUEST, @@ -101,14 +101,13 @@ def __init__(self, url: str, protocol_version: str | None = None) -> None: self.session_id: str | None = None self.protocol_version: str | None = protocol_version - # TODO: header derivation from the pin (not body _meta) per spec; body envelope is request-only. 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 is None or not is_version_at_least(self.protocol_version, "2026-07-28"): + if self.protocol_version is None or not is_version_at_least(self.protocol_version, FIRST_MODERN_VERSION): return {} if not isinstance(message, JSONRPCRequest | JSONRPCNotification): return {} diff --git a/src/mcp/server/_experimental/__init__.py b/src/mcp/server/_experimental/__init__.py deleted file mode 100644 index 669c31c28f..0000000000 --- a/src/mcp/server/_experimental/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Experimental, unstable. No public API; may change or vanish without deprecation.""" diff --git a/src/mcp/server/_experimental/streamable_http_modern.py b/src/mcp/server/_streamable_http_modern.py similarity index 94% rename from src/mcp/server/_experimental/streamable_http_modern.py rename to src/mcp/server/_streamable_http_modern.py index 2032d3c68d..d9ee5b4c46 100644 --- a/src/mcp/server/_experimental/streamable_http_modern.py +++ b/src/mcp/server/_streamable_http_modern.py @@ -1,8 +1,8 @@ -"""Experimental, unstable. Single-exchange HTTP serving for protocol version 2026-07-28. +"""Single-exchange HTTP serving for protocol version 2026-07-28. -No public API; everything in this module may change or vanish without -deprecation. The legacy streamable-HTTP transport is untouched and remains the -supported entry point. +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 @@ -15,7 +15,7 @@ import logging from collections.abc import Mapping from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Final +from typing import TYPE_CHECKING, Any import anyio import anyio.abc @@ -34,6 +34,7 @@ from mcp.shared.exceptions import MCPError, NoBackChannelError from mcp.shared.message import MessageMetadata, ServerMessageMetadata from mcp.shared.transport_context import TransportContext +from mcp.shared.version import FIRST_MODERN_VERSION as MODERN_PROTOCOL_VERSION from mcp.types import ( INTERNAL_ERROR, INVALID_PARAMS, @@ -50,10 +51,6 @@ logger = logging.getLogger(__name__) -MODERN_PROTOCOL_VERSION: Final[str] = "2026-07-28" -"""The protocol version this module serves. Kept local so it does not leak into -`SUPPORTED_PROTOCOL_VERSIONS` or the legacy handshake.""" - @dataclass class _SingleExchangeDispatchContext: diff --git a/src/mcp/server/streamable_http_manager.py b/src/mcp/server/streamable_http_manager.py index c312dd7833..ba1a5458ac 100644 --- a/src/mcp/server/streamable_http_manager.py +++ b/src/mcp/server/streamable_http_manager.py @@ -14,7 +14,7 @@ from starlette.responses import Response from starlette.types import Receive, Scope, Send -from mcp.server._experimental.streamable_http_modern import handle_modern_request +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, @@ -23,6 +23,7 @@ ) 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: @@ -154,7 +155,7 @@ async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> No # 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 == "2026-07-28": + if pv in MODERN_PROTOCOL_VERSIONS: await handle_modern_request(self.app, self.security_settings, scope, receive, send) return diff --git a/src/mcp/shared/version.py b/src/mcp/shared/version.py index 2299de72ec..745ad68f18 100644 --- a/src/mcp/shared/version.py +++ b/src/mcp/shared/version.py @@ -20,6 +20,12 @@ ) """Every released protocol revision, oldest to newest.""" +FIRST_MODERN_VERSION: Final[str] = "2026-07-28" +"""First protocol revision with the stateless per-request envelope (no `initialize`).""" + +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.""" diff --git a/tests/client/test_session.py b/tests/client/test_session.py index 940d12b1e7..3dff4beea8 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -1401,17 +1401,19 @@ async def notify(self, method: str, params: Mapping[str, Any] | None) -> None: @pytest.mark.anyio -async def test_initialize_on_a_stateless_pinned_session_raises_before_any_frame_is_sent(): - """A session pinned to the 2026-07-28 stateless protocol rejects ``initialize()`` locally. +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 calling ``initialize()`` on a stateless-pinned session is a programmer error - and raises immediately rather than reaching the wire. + 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): - with pytest.raises(RuntimeError, match="pinned to a stateless"): - await session.initialize() + 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 @@ -1419,8 +1421,13 @@ 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(session.initialize) + tg.start_soon(do_initialize) out = await from_client.receive() assert isinstance(out.message, JSONRPCRequest) assert out.message.params is not None @@ -1439,6 +1446,14 @@ async def test_initialize_on_a_stateful_pin_requests_the_pinned_version(): ) ) ) + # 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) + # 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 diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 2d65d4e17b..fa218e3eb6 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -329,7 +329,10 @@ def __post_init__(self) -> None: ), "lifecycle:stateless:no-initialize": Requirement( source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#stateless-operation", - behavior="A ClientSession pinned to 2026-07-28 rejects initialize() before any frame is sent.", + 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", ), diff --git a/tests/server/test_experimental_streamable_http_modern.py b/tests/server/test_streamable_http_modern.py similarity index 97% rename from tests/server/test_experimental_streamable_http_modern.py rename to tests/server/test_streamable_http_modern.py index 0f6654bde4..7041d92686 100644 --- a/tests/server/test_experimental_streamable_http_modern.py +++ b/tests/server/test_streamable_http_modern.py @@ -1,4 +1,4 @@ -"""Unit tests for the experimental 2026-07-28 single-exchange HTTP serving entry. +"""Unit tests for the 2026-07-28 single-exchange HTTP serving entry. The interaction suite under ``tests/interaction/transports/test_hosting_http_modern.py`` pins the wire contract end to end; these tests cover the module's internal seams directly -- @@ -16,9 +16,9 @@ from starlette.requests import Request from starlette.types import Receive, Scope, Send -import mcp.server._experimental.streamable_http_modern as modern +import mcp.server._streamable_http_modern as modern from mcp.server import Server, ServerRequestContext -from mcp.server._experimental.streamable_http_modern import ( +from mcp.server._streamable_http_modern import ( SingleExchangeDispatcher, _SingleExchangeDispatchContext, handle_modern_request, From 47a422c3920f7b68fe6b1ccdf17a786cb259aa7c Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:33:44 +0000 Subject: [PATCH 17/21] Use MODERN_PROTOCOL_VERSIONS membership instead of a threshold constant The stateless-era predicate is set membership, not an ordering threshold; drop FIRST_MODERN_VERSION and is_version_at_least at the four call sites. The modern entry now takes the protocol_version that matched (threaded from the manager) instead of hard-coding it. --- src/mcp/client/session.py | 6 ++---- src/mcp/client/streamable_http.py | 4 ++-- src/mcp/server/_streamable_http_modern.py | 9 +++++---- src/mcp/server/streamable_http_manager.py | 2 +- src/mcp/shared/version.py | 3 --- tests/server/test_streamable_http_modern.py | 2 +- 6 files changed, 11 insertions(+), 15 deletions(-) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 24d2b69ba4..e64b9c2a68 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -21,7 +21,7 @@ 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 FIRST_MODERN_VERSION, SUPPORTED_PROTOCOL_VERSIONS, is_version_at_least +from mcp.shared.version import MODERN_PROTOCOL_VERSIONS, SUPPORTED_PROTOCOL_VERSIONS from mcp.types import ( CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, @@ -156,9 +156,7 @@ def __init__( 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 is not None and is_version_at_least( - protocol_version, FIRST_MODERN_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 diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index e5dee2c727..3fa8bed1f5 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -21,7 +21,7 @@ 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 FIRST_MODERN_VERSION, is_version_at_least +from mcp.shared.version import MODERN_PROTOCOL_VERSIONS from mcp.types import ( INTERNAL_ERROR, INVALID_REQUEST, @@ -107,7 +107,7 @@ def _per_message_headers(self, message: JSONRPCMessage) -> dict[str, str]: MCP-Protocol-Version is not emitted here — `_prepare_headers()` already adds it from `self.protocol_version` for every request. """ - if self.protocol_version is None or not is_version_at_least(self.protocol_version, FIRST_MODERN_VERSION): + if self.protocol_version not in MODERN_PROTOCOL_VERSIONS: return {} if not isinstance(message, JSONRPCRequest | JSONRPCNotification): return {} diff --git a/src/mcp/server/_streamable_http_modern.py b/src/mcp/server/_streamable_http_modern.py index d9ee5b4c46..051265a84d 100644 --- a/src/mcp/server/_streamable_http_modern.py +++ b/src/mcp/server/_streamable_http_modern.py @@ -34,7 +34,6 @@ from mcp.shared.exceptions import MCPError, NoBackChannelError from mcp.shared.message import MessageMetadata, ServerMessageMetadata from mcp.shared.transport_context import TransportContext -from mcp.shared.version import FIRST_MODERN_VERSION as MODERN_PROTOCOL_VERSION from mcp.types import ( INTERNAL_ERROR, INVALID_PARAMS, @@ -160,14 +159,16 @@ async def handle(self, req: JSONRPCRequest, on_request: OnRequest) -> JSONRPCRes 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 2026-07-28 POST. + """ASGI handler for a single stateless-era POST. Called from `StreamableHTTPSessionManager.handle_request` when the - `MCP-Protocol-Version` header is `2026-07-28`. Never sets `Mcp-Session-Id`. + `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) @@ -207,7 +208,7 @@ async def handle_modern_request( stateless=True, dispatch_middleware=[otel_middleware], ) - runner.connection.protocol_version = MODERN_PROTOCOL_VERSION + runner.connection.protocol_version = protocol_version try: msg = await dispatcher.handle(req, runner._compose_on_request()) # type: ignore[reportPrivateUsage] finally: diff --git a/src/mcp/server/streamable_http_manager.py b/src/mcp/server/streamable_http_manager.py index ba1a5458ac..5c6ea531d0 100644 --- a/src/mcp/server/streamable_http_manager.py +++ b/src/mcp/server/streamable_http_manager.py @@ -156,7 +156,7 @@ async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> No # (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, scope, receive, send) + await handle_modern_request(self.app, self.security_settings, pv, scope, receive, send) return # Dispatch to the appropriate handler diff --git a/src/mcp/shared/version.py b/src/mcp/shared/version.py index 745ad68f18..09aacb6956 100644 --- a/src/mcp/shared/version.py +++ b/src/mcp/shared/version.py @@ -20,9 +20,6 @@ ) """Every released protocol revision, oldest to newest.""" -FIRST_MODERN_VERSION: Final[str] = "2026-07-28" -"""First protocol revision with the stateless per-request envelope (no `initialize`).""" - MODERN_PROTOCOL_VERSIONS: Final[tuple[str, ...]] = ("2026-07-28",) """Protocol revisions that use the stateless per-request envelope.""" diff --git a/tests/server/test_streamable_http_modern.py b/tests/server/test_streamable_http_modern.py index 7041d92686..ce62d44cec 100644 --- a/tests/server/test_streamable_http_modern.py +++ b/tests/server/test_streamable_http_modern.py @@ -91,7 +91,7 @@ async def on_request(ctx: DispatchContext[Any], method: str, params: Mapping[str def _asgi_client(server: Server[Any], security_settings: TransportSecuritySettings | None = None) -> httpx.AsyncClient: async def app(scope: Scope, receive: Receive, send: Send) -> None: - await handle_modern_request(server, security_settings, scope, receive, send) + await handle_modern_request(server, security_settings, "2026-07-28", scope, receive, send) return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") From 7bf87f8a4dfa811fcb5e0338950f97d014db404a Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sat, 20 Jun 2026 10:19:42 +0000 Subject: [PATCH 18/21] Thread protocol_version through Client and default CacheableResult to immediately-stale private - Client gains protocol_version: str | None; threads to ClientSession and streamable_http_client. The interaction-suite connect factories forward the parameter they already accepted. - CacheableResult defaults to ttl_ms=0, cache_scope="private" so list/read results constructed without explicit hints validate against the 2026-07-28 surface and never accidentally enable shared caching. - TRANSPORT_SPEC_VERSIONS era-locks in-memory and streamable-http-stateless to 2025-11-25 (the former pending a modern in-memory entry; the latter collapses into stateful at the newer revision). --- docs/migration.md | 2 +- src/mcp/client/client.py | 12 +++++++++++- src/mcp/types/_types.py | 10 ++++++---- tests/client/test_client.py | 2 +- tests/interaction/_connect.py | 4 +++- tests/interaction/_requirements.py | 7 +++++++ tests/interaction/test_coverage.py | 2 -- tests/test_types.py | 11 ++++++----- tests/types/test_wire_frames.py | 7 ++++--- 9 files changed, 39 insertions(+), 18 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index d13b164aa1..9fbbbf2ed2 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -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 diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index 3868891f27..b5ae59daa3 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -92,6 +92,15 @@ async def main(): 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 @@ def __post_init__(self) -> None: 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 @@ async def __aenter__(self) -> Client: message_handler=self.message_handler, client_info=self.client_info, elicitation_callback=self.elicitation_callback, + protocol_version=self.protocol_version, ) ) diff --git a/src/mcp/types/_types.py b/src/mcp/types/_types.py index d64f5ea4ff..82b4a084d5 100644 --- a/src/mcp/types/_types.py +++ b/src/mcp/types/_types.py @@ -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.""" diff --git a/tests/client/test_client.py b/tests/client/test_client.py index d9c7924090..64b6666eca 100644 --- a/tests/client/test_client.py +++ b/tests/client/test_client.py @@ -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): diff --git a/tests/interaction/_connect.py b/tests/interaction/_connect.py index b709181f30..575a742632 100644 --- a/tests/interaction/_connect.py +++ b/tests/interaction/_connect.py @@ -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 diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index fa218e3eb6..26ab4dd5a4 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -63,6 +63,13 @@ 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().""" diff --git a/tests/interaction/test_coverage.py b/tests/interaction/test_coverage.py index 02322f3b47..2c7e486ab3 100644 --- a/tests/interaction/test_coverage.py +++ b/tests/interaction/test_coverage.py @@ -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", ] diff --git a/tests/test_types.py b/tests/test_types.py index 8c85411eba..3756bd893d 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -401,9 +401,10 @@ def test_concrete_wire_results_always_dump_result_type_complete(): assert _wire_dump(result)["resultType"] == "complete", type(result).__name__ -def test_cacheable_results_omit_unset_caching_directives(): - """`ttl_ms`/`cache_scope` default to None: the SDK declares no caching policy - so a 2026-07-28 handler must set them explicitly.""" +def test_cacheable_results_default_to_immediately_stale_private(): + """`ttl_ms`/`cache_scope` default to 0/"private" so list-results validate at + 2026-07-28 without the handler setting them, and never accidentally enable + shared caching.""" cacheable: list[Result] = [ ReadResourceResult(contents=[]), ListPromptsResult(prompts=[]), @@ -418,8 +419,8 @@ def test_cacheable_results_omit_unset_caching_directives(): ] for result in cacheable: dumped = _wire_dump(result) - assert "ttlMs" not in dumped, type(result).__name__ - assert "cacheScope" not in dumped, type(result).__name__ + assert dumped["ttlMs"] == 0, type(result).__name__ + assert dumped["cacheScope"] == "private", type(result).__name__ explicit = _wire_dump(ListToolsResult(tools=[], ttl_ms=5, cache_scope="public")) assert explicit["ttlMs"] == 5 assert explicit["cacheScope"] == "public" diff --git a/tests/types/test_wire_frames.py b/tests/types/test_wire_frames.py index c4d40945b0..a48180634d 100644 --- a/tests/types/test_wire_frames.py +++ b/tests/types/test_wire_frames.py @@ -61,12 +61,13 @@ def test_non_empty_result_dump_carries_result_type_complete_before_the_sieve(): ) -def test_cacheable_list_result_dump_omits_unset_caching_directives(): - """`ttl_ms`/`cache_scope` default to None so the raw dump omits them; 2026 handlers set them explicitly.""" +def test_cacheable_list_result_dump_carries_default_caching_directives(): + """`ttl_ms`/`cache_scope` default to 0/"private" so the raw dump carries them; the + runner's per-version sieve drops them for pre-2026 peers.""" result = ListToolsResult(tools=[Tool(name="echo", input_schema={"type": "object"})]) frame = JSONRPCResponse(jsonrpc="2.0", id=2, result=_body(result)) assert _frame(frame) == snapshot( - '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"echo","inputSchema":{"type":"object"}}],"resultType":"complete"}}' + '{"jsonrpc":"2.0","id":2,"result":{"ttlMs":0,"cacheScope":"private","tools":[{"name":"echo","inputSchema":{"type":"object"}}],"resultType":"complete"}}' ) From 3fcf832dc05b3079e98500e8f78184cdd063dfc5 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sat, 20 Jun 2026 10:32:48 +0000 Subject: [PATCH 19/21] Turn on the 2026-07-28 column in the interaction-suite matrix - SPEC_VERSIONS = ("2025-11-25", "2026-07-28"); node ids gain a -version suffix suite-wide - 13 lifecycle:initialize:* / lifecycle:version:* / initialized-notification / requests-before-initialized requirements get removed_in="2026-07-28" (the handshake does not exist at this revision) - 10 progress/logging requirements get a KnownFailure at 2026-07-28 (the modern entry does not yet stream handler-emitted notifications onto the per-request response; burns down once the SSE response mode lands) - protocol:progress:client-to-server gets a requires-session arm exclusion (a bare client->server notification has no route at the stateless entry) - 6 error-shape requirements get a modern-error-surface arm exclusion (the tests pin the legacy code-0/leaked-message divergence; the modern arm returns the spec-correct -32603; needs era-aware assertions to re-admit) [streamable-http-2026-07-28]: 60 pass, 8 xfail. --- tests/interaction/_requirements.py | 70 ++++++++++++++++++++- tests/interaction/mcpserver/test_context.py | 1 + 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 26ab4dd5a4..15d6775684 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -45,7 +45,7 @@ """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" @@ -96,6 +96,12 @@ "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: @@ -212,14 +218,20 @@ def __post_init__(self) -> None: "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", @@ -227,14 +239,20 @@ def __post_init__(self) -> None: "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( @@ -243,6 +261,8 @@ def __post_init__(self) -> None: "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( @@ -251,6 +271,8 @@ def __post_init__(self) -> None: "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", @@ -276,6 +298,8 @@ def __post_init__(self) -> None: 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", @@ -304,6 +328,8 @@ def __post_init__(self) -> None: "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", @@ -311,6 +337,8 @@ def __post_init__(self) -> None: "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", @@ -318,6 +346,8 @@ def __post_init__(self) -> None: "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", @@ -325,6 +355,8 @@ def __post_init__(self) -> None: "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", @@ -482,6 +514,17 @@ def __post_init__(self) -> None: "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", @@ -537,6 +580,7 @@ def __post_init__(self) -> None: "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", @@ -549,6 +593,7 @@ def __post_init__(self) -> None: "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", @@ -561,6 +606,7 @@ def __post_init__(self) -> None: "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", @@ -594,6 +640,7 @@ def __post_init__(self) -> None: "protocol:progress:client-to-server": Requirement( source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow", behavior="A progress notification sent by the client is delivered to the server's progress handler.", + arm_exclusions=(ArmExclusion(reason="requires-session", spec_version="2026-07-28"),), ), "protocol:timeout:basic": Requirement( source=f"{SPEC_BASE_URL}/basic/lifecycle#timeouts", @@ -697,6 +744,7 @@ def __post_init__(self) -> None: "Log notifications emitted by a tool handler during execution reach the client's logging " "callback before the tool result returns." ), + 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", @@ -704,6 +752,7 @@ def __post_init__(self) -> None: "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", @@ -924,12 +973,14 @@ def __post_init__(self) -> None: "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", @@ -1091,6 +1142,7 @@ def __post_init__(self) -> None: "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", @@ -1115,6 +1167,7 @@ def __post_init__(self) -> None: "the low-level server converts to error code 0." ), ), + arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),), ), # ═══════════════════════════════════════════════════════════════════════════ # Prompts @@ -1145,6 +1198,7 @@ def __post_init__(self) -> None: "which the low-level server converts to error code 0 with the exception text as the message." ), ), + arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),), ), "prompts:get:multi-message": Requirement( source=f"{SPEC_BASE_URL}/server/prompts#getting-a-prompt", @@ -1187,6 +1241,7 @@ def __post_init__(self) -> None: "mcpserver:prompt:args-validation": Requirement( source=f"{SPEC_BASE_URL}/server/prompts#implementation-considerations", behavior="prompts/get arguments that fail the prompt's argument schema are rejected before the function runs.", + arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),), ), "mcpserver:prompt:decorated": Requirement( source="sdk", @@ -1218,6 +1273,7 @@ def __post_init__(self) -> None: "ValueError, which the low-level server converts to error code 0." ), ), + arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),), ), # ═══════════════════════════════════════════════════════════════════════════ # Completion @@ -1284,6 +1340,7 @@ def __post_init__(self) -> None: "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", @@ -1291,6 +1348,7 @@ def __post_init__(self) -> None: "A log message sent by a server handler is delivered to the client's logging callback with its " "severity level, logger name, and data." ), + 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", @@ -1910,6 +1968,16 @@ def __post_init__(self) -> None: "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 diff --git a/tests/interaction/mcpserver/test_context.py b/tests/interaction/mcpserver/test_context.py index fdf898c99f..3e6bb542e6 100644 --- a/tests/interaction/mcpserver/test_context.py +++ b/tests/interaction/mcpserver/test_context.py @@ -121,6 +121,7 @@ async def whoami(ctx: Context) -> str: 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. From 05211a3d590625249e21b7bfaef714a7d8ddac0b Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:08:33 +0000 Subject: [PATCH 20/21] Reconcile conformance baselines after CacheableResult default change The ttl_ms=0 / cache_scope="private" defaults make list and read results validate at the 2026-07-28 surface, so caching, the carried-forward list/read scenarios, and http-custom-header-server-validation now pass. tools-call-with-progress stays expected-fail (progress notifications are not yet delivered on the single-exchange path). --- .../expected-failures.2026-07-28.yml | 20 ++++--------------- .../actions/conformance/expected-failures.yml | 4 ---- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/.github/actions/conformance/expected-failures.2026-07-28.yml b/.github/actions/conformance/expected-failures.2026-07-28.yml index e34fac16b5..1d010abc0d 100644 --- a/.github/actions/conformance/expected-failures.2026-07-28.yml +++ b/.github/actions/conformance/expected-failures.2026-07-28.yml @@ -82,20 +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. - - tools-list + # --- 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 - - resources-list - - resources-read-text - - resources-read-binary - - resources-templates-read - - prompts-list # 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 @@ -118,12 +109,9 @@ server: - input-required-result-tampered-state - input-required-result-capability-check - input-required-result-validate-input - # SEP-2549 (caching): no ttlMs/cacheScope support. - - 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 diff --git a/.github/actions/conformance/expected-failures.yml b/.github/actions/conformance/expected-failures.yml index 51f4d96795..9b676ea475 100644 --- a/.github/actions/conformance/expected-failures.yml +++ b/.github/actions/conformance/expected-failures.yml @@ -65,13 +65,9 @@ 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. From 1044f42ee2649c5db47a0f2d9e1ea1e8b6493279 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:01:50 +0000 Subject: [PATCH 21/21] Report the negotiated protocol_version once initialized; clarify envelope-overwrite semantics - ClientSession.protocol_version returns the value from the InitializeResult once one exists (negotiated for stateful, the pin for stateless via the synthesized result), falling back to the pin only before the handshake. A stateful pin is the requested version, not a guarantee of the negotiated one; inbound validation now keys off what the server actually agreed to. - Reword lifecycle:stateless:caller-meta-preserved: the three envelope keys overwrite caller-supplied values for those keys; non-colliding caller keys are preserved. The capstone now passes a colliding key and the wire snapshot proves the overwrite. --- src/mcp/client/session.py | 12 ++++++++---- tests/client/test_session.py | 6 +++++- tests/interaction/_requirements.py | 3 ++- .../transports/test_hosting_http_modern.py | 10 +++++++--- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index e64b9c2a68..cd18a67541 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -351,10 +351,14 @@ def initialize_result(self) -> types.InitializeResult | None: @property def protocol_version(self) -> str | None: - """Negotiated or pinned protocol version. None until initialize() unless pinned at construction.""" - if self._pinned_version is not None: - return self._pinned_version - 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.""" diff --git a/tests/client/test_session.py b/tests/client/test_session.py index 3dff4beea8..c171360de2 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -1432,8 +1432,10 @@ async def do_initialize() -> None: 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-06-18", + protocol_version="2025-03-26", capabilities=ServerCapabilities(), server_info=Implementation(name="mock-server", version="0.1.0"), ) @@ -1450,6 +1452,8 @@ async def do_initialize() -> None: # 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] diff --git a/tests/interaction/_requirements.py b/tests/interaction/_requirements.py index 15d6775684..0e7b2d25c0 100644 --- a/tests/interaction/_requirements.py +++ b/tests/interaction/_requirements.py @@ -379,7 +379,8 @@ def __post_init__(self) -> None: 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 " - "io.modelcontextprotocol/* keys are added alongside, never overwriting the caller's keys." + "three io.modelcontextprotocol/* envelope keys overwrite any caller-supplied values for " + "those keys; non-colliding caller keys are preserved." ), added_in="2026-07-28", ), diff --git a/tests/interaction/transports/test_hosting_http_modern.py b/tests/interaction/transports/test_hosting_http_modern.py index d494cebbcf..1f043510fe 100644 --- a/tests/interaction/transports/test_hosting_http_modern.py +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -240,7 +240,11 @@ async def on_response(response: httpx.Response) -> None: ), ClientSession(read, write, client_info=client_info, protocol_version=MODERN_VERSION) as session, ): - result = await session.call_tool("add", {"a": 2, "b": 3}, meta={"custom-key": "x"}) + result = await session.call_tool( + "add", + {"a": 2, "b": 3}, + meta={"custom-key": "x", "io.modelcontextprotocol/protocolVersion": "evil"}, + ) assert result.model_dump(by_alias=True, mode="json", exclude_none=True) == snapshot( {"content": [{"type": "text", "text": "5"}], "isError": False, "resultType": "complete"} @@ -254,8 +258,8 @@ async def on_response(response: httpx.Response) -> None: ) assert all("initialize" not in body["method"] for body in bodies) - # The tools/call POST carries the body-derived headers, and its _meta envelope merges the - # caller's key alongside the three io.modelcontextprotocol/* keys. + # The tools/call POST carries the body-derived headers, and its _meta envelope overwrites the + # caller's colliding io.modelcontextprotocol/* key while preserving the non-colliding caller key. call = requests[0] assert {k: v for k, v in call.headers.items() if k.startswith("mcp-")} == snapshot( {"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "add"}