diff --git a/.github/actions/conformance/expected-failures.2026-07-28.yml b/.github/actions/conformance/expected-failures.2026-07-28.yml index c155d4afe8..1d010abc0d 100644 --- a/.github/actions/conformance/expected-failures.2026-07-28.yml +++ b/.github/actions/conformance/expected-failures.2026-07-28.yml @@ -82,33 +82,11 @@ client: # neither run nor evaluated on this leg. server: - # --- No stateless server path on main yet (carried-forward 2025-era scenarios) --- - # mcp-everything-server only runs in 2025 stateful mode. With - # --spec-version 2026-07-28 the harness sends stateless requests - # (MCP-Protocol-Version: 2026-07-28, _meta envelope, no initialize), which - # the server rejects before the handler runs. These scenarios all pass on - # the 2025 legs; they unblock once mcp-everything-server routes 2026 - # requests through a stateless path. - - completion-complete - - tools-list - - tools-call-simple-text - - tools-call-image - - tools-call-audio - - tools-call-embedded-resource - - tools-call-mixed-content - - tools-call-error + # --- Carried-forward 2025-era scenarios still failing on the 2026 wire --- + # The stateless 2026 path now reaches handlers for plain request/response + # scenarios; tools-call-with-progress still fails because the stateless + # server has no channel for server→client progress notifications. - tools-call-with-progress - - server-sse-multiple-streams - - resources-list - - resources-read-text - - resources-read-binary - - resources-templates-read - - prompts-list - - prompts-get-simple - - prompts-get-with-args - - prompts-get-embedded-resource - - prompts-get-with-image - - dns-rebinding-protection # SEP-2106 (JSON Schema 2020-12 in tool inputSchema): the fixture tool's # schema has none of the 2020-12 keywords the scenario checks. The scenario # is in `--suite all` but not `--suite active`, so this is the only leg that @@ -130,12 +108,10 @@ server: - input-required-result-result-type - input-required-result-tampered-state - input-required-result-capability-check - # SEP-2549 (caching): no ttlMs/cacheScope support. - - caching + - input-required-result-validate-input # SEP-2243 (HTTP header standardization): -32020 HeaderMismatch handling and # case-insensitive/whitespace-trimmed header validation not implemented. - http-header-validation - - http-custom-header-server-validation # --- WARNING-only entries --- # These scenarios emit no FAILURE checks, only SHOULD-level WARNINGs, but @@ -143,7 +119,5 @@ server: # as the draft suite in expected-failures.yml. # SEP-2164: server returns -32600 (not -32602) and omits error.data.uri. - sep-2164-resource-not-found - # SEP-2322 SHOULD-level behaviours (re-request missing inputResponses, - # ignore unrecognized inputResponses keys). + # SEP-2322 SHOULD-level behaviour (re-request missing inputResponses). - input-required-result-missing-input-response - - input-required-result-ignore-extra-params diff --git a/.github/actions/conformance/expected-failures.yml b/.github/actions/conformance/expected-failures.yml index 674fa78eeb..9b676ea475 100644 --- a/.github/actions/conformance/expected-failures.yml +++ b/.github/actions/conformance/expected-failures.yml @@ -65,23 +65,19 @@ server: - input-required-result-result-type - input-required-result-tampered-state - input-required-result-capability-check - # SEP-2549 (caching): no ttlMs/cacheScope support; scenario also hits the - # stateful-mode "Missing session ID" error. - - caching # SEP-2243 (HTTP header standardization): -32020 HeaderMismatch handling and # case-insensitive/whitespace-trimmed header validation not implemented. - http-header-validation - - http-custom-header-server-validation # WARNING-only entries: these scenarios emit no FAILURE checks, only SHOULD-level # WARNINGs, but the expected-failures evaluator counts WARNINGs as failures. # SEP-2164: server returns -32600 (not -32602) and omits error.data.uri. - sep-2164-resource-not-found - # SEP-2322 SHOULD-level behaviours (re-request missing inputResponses, ignore - # unrecognized inputResponses keys). + # SEP-2322 SHOULD-level behaviour (re-request missing inputResponses). - input-required-result-missing-input-response - - input-required-result-ignore-extra-params - # Intentionally NOT baselined (2 of 19 draft scenarios): the SEP-2322 - # negative-case scenarios input-required-result-unsupported-methods and - # input-required-result-validate-input pass today only because the stateful - # server's -32600 "Missing session ID" satisfies their assertions. They will - # start failing for real once stateless mode lands; add them then. + # SEP-2322 negative-case scenarios: input-required-result-validate-input is + # now baselined (added when the stateless path landed — the stateless server + # reaches the handler, so the previous accidental pass via -32600 "Missing + # session ID" no longer applies). input-required-result-unsupported-methods + # is intentionally NOT baselined: it still passes for now; add it once it + # starts failing for real. + - input-required-result-validate-input 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/client/session.py b/src/mcp/client/session.py index dda241035d..cd18a67541 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -21,8 +21,16 @@ from mcp.shared.message import ClientMessageMetadata, SessionMessage from mcp.shared.session import RequestResponder from mcp.shared.transport_context import TransportContext -from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS -from mcp.types import INTERNAL_ERROR, METHOD_NOT_FOUND, RequestId, RequestParamsMeta +from mcp.shared.version import MODERN_PROTOCOL_VERSIONS, SUPPORTED_PROTOCOL_VERSIONS +from mcp.types import ( + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + INTERNAL_ERROR, + METHOD_NOT_FOUND, + PROTOCOL_VERSION_META_KEY, + RequestId, + RequestParamsMeta, +) from mcp.types import methods as _methods DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0") @@ -141,11 +149,14 @@ 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._stateless_pinned = protocol_version in MODERN_PROTOCOL_VERSIONS self._sampling_callback = sampling_callback or _default_sampling_callback self._sampling_capabilities = sampling_capabilities self._elicitation_callback = elicitation_callback or _default_elicitation_callback @@ -153,7 +164,19 @@ 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: @@ -219,6 +242,19 @@ async def send_request( data = request.model_dump(by_alias=True, mode="json", exclude_none=True) method: str = data["method"] opts: CallOptions = {} + if self._stateless_pinned: + params = data.setdefault("params", {}) + envelope_meta = params.setdefault("_meta", {}) + envelope_meta[PROTOCOL_VERSION_META_KEY] = self._pinned_version + envelope_meta[CLIENT_INFO_META_KEY] = self._client_info.model_dump( + by_alias=True, mode="json", exclude_none=True + ) + envelope_meta[CLIENT_CAPABILITIES_META_KEY] = self._build_capabilities().model_dump( + by_alias=True, mode="json", exclude_none=True + ) + # Stateless pinned mode: disconnect-as-cancel is the spec mechanism, so the + # dispatcher must not emit notifications/cancelled when the caller abandons. + opts["cancel_on_abandon"] = False timeout = ( request_read_timeout_seconds if request_read_timeout_seconds is not None @@ -254,7 +290,7 @@ 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 +309,19 @@ 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._initialize_result is not None: + return self._initialize_result + capabilities = self._build_capabilities() result = await self.send_request( types.InitializeRequest( params=types.InitializeRequestParams( - protocol_version=types.LATEST_PROTOCOL_VERSION, - capabilities=types.ClientCapabilities( - sampling=sampling, - elicitation=elicitation, - experimental=None, - roots=roots, - ), + protocol_version=self._pinned_version + if self._pinned_version is not None + else types.LATEST_PROTOCOL_VERSION, + capabilities=capabilities, client_info=self._client_info, ), ), @@ -303,14 +341,24 @@ 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 @property def protocol_version(self) -> str | None: - """The negotiated protocol version. None until `initialize()` has completed.""" - return self._initialize_result.protocol_version if self._initialize_result else None + """Negotiated or pinned protocol version. None until initialize() unless pinned at construction. + + Once `initialize()` has completed, this is the version the server actually + negotiated (which can differ from a stateful pin); before that, the pin. + """ + if self._initialize_result is not None: + return self._initialize_result.protocol_version + return self._pinned_version async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult: """Send a ping request.""" diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index b5950d3b5c..3fa8bed1f5 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 @@ -19,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 MODERN_PROTOCOL_VERSIONS from mcp.types import ( INTERNAL_ERROR, INVALID_REQUEST, @@ -44,12 +47,24 @@ MCP_SESSION_ID = "mcp-session-id" MCP_PROTOCOL_VERSION = "mcp-protocol-version" +MCP_METHOD = "mcp-method" +MCP_NAME = "mcp-name" LAST_EVENT_ID = "last-event-id" # Reconnection defaults DEFAULT_RECONNECTION_DELAY_MS = 1000 # 1 second fallback when server doesn't provide retry MAX_RECONNECTION_ATTEMPTS = 2 # Max retry attempts before giving up +_B64_SENTINEL = re.compile(r"^=\?base64\?.*\?=$") +# RFC 7230 token chars minus DEL; visible ASCII 0x20-0x7E is the practical bound for a header value. +_HEADER_SAFE = re.compile(r"^[\x20-\x7E]*$") + + +def _encode_header_value(value: str) -> str: + if _HEADER_SAFE.fullmatch(value) and value == value.strip() and not _B64_SENTINEL.fullmatch(value): + return value + return f"=?base64?{base64.b64encode(value.encode('utf-8')).decode('ascii')}?=" + class StreamableHTTPError(Exception): """Base exception for StreamableHTTP transport errors.""" @@ -73,15 +88,40 @@ class RequestContext: class StreamableHTTPTransport: """StreamableHTTP client transport implementation.""" - def __init__(self, url: str) -> None: + def __init__(self, url: str, protocol_version: str | None = None) -> None: """Initialize the StreamableHTTP transport. Args: url: The endpoint URL. + protocol_version: Pin the MCP-Protocol-Version header from the first request + instead of waiting to snoop it from an InitializeResult. Required for + stateless 2026-07-28 sessions that never send initialize. """ self.url = url self.session_id: str | None = None - self.protocol_version: str | None = None + self.protocol_version: str | None = protocol_version + + def _per_message_headers(self, message: JSONRPCMessage) -> dict[str, str]: + """Per-POST routing headers (Mcp-Method, Mcp-Name) for 2026-07-28+ pinned transports. + + MCP-Protocol-Version is not emitted here — `_prepare_headers()` already adds it + from `self.protocol_version` for every request. + """ + if self.protocol_version not in MODERN_PROTOCOL_VERSIONS: + return {} + if not isinstance(message, JSONRPCRequest | JSONRPCNotification): + return {} + headers: dict[str, str] = {MCP_METHOD: message.method} + # TODO: Mcp-Name is also REQUIRED for prompts/get (params.name) and resources/read + # (params.uri); a method->param-key map replaces this gate when those land. + if ( + isinstance(message, JSONRPCRequest) + and message.method == "tools/call" + and message.params + and isinstance(name := message.params.get("name"), str) + ): + headers[MCP_NAME] = _encode_header_value(name) + return headers def _prepare_headers(self) -> dict[str, str]: """Build MCP-specific request headers. @@ -117,6 +157,9 @@ 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 @@ -256,6 +299,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(self._per_message_headers(message)) is_initialization = self._is_initialization_request(message) async with ctx.client.stream( @@ -522,6 +566,7 @@ async def streamable_http_client( *, http_client: httpx.AsyncClient | None = None, terminate_on_close: bool = True, + protocol_version: str | None = None, ) -> AsyncGenerator[TransportStreams, None]: """Client transport for StreamableHTTP. @@ -531,6 +576,8 @@ async def streamable_http_client( client with recommended MCP timeouts will be created. To configure headers, authentication, or other HTTP settings, create an httpx.AsyncClient and pass it here. terminate_on_close: If True, send a DELETE request to terminate the session when the context exits. + protocol_version: Pin the MCP-Protocol-Version header for stateless 2026-07-28 sessions. + Tracer-bullet duplication — also pass to `ClientSession(protocol_version=...)`. Yields: Tuple containing: @@ -548,7 +595,7 @@ async def streamable_http_client( # Create default client with recommended MCP timeouts client = create_mcp_http_client() - transport = StreamableHTTPTransport(url) + transport = StreamableHTTPTransport(url, protocol_version=protocol_version) logger.debug(f"Connecting to StreamableHTTP endpoint: {url}") diff --git a/src/mcp/server/_streamable_http_modern.py b/src/mcp/server/_streamable_http_modern.py new file mode 100644 index 0000000000..051265a84d --- /dev/null +++ b/src/mcp/server/_streamable_http_modern.py @@ -0,0 +1,231 @@ +"""Single-exchange HTTP serving for protocol version 2026-07-28. + +Private module — entry is via `StreamableHTTPSessionManager.handle_request`. +The legacy streamable-HTTP transport is untouched and remains the supported +path for earlier protocol revisions. + +A 2026-07-28 request is a self-contained POST: no `initialize` handshake, no +`Mcp-Session-Id`, one JSON-RPC request in, one JSON-RPC response out. This +module handles such a request directly in the ASGI task - no memory streams, +no per-request task group, no `JSONRPCDispatcher`. +""" + +from __future__ import annotations + +import logging +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +import anyio +import anyio.abc +from pydantic import ValidationError +from starlette.requests import Request +from starlette.responses import Response +from starlette.types import Receive, Scope, Send + +from mcp.server.runner import ( + _EXIT_STACK_CLOSE_TIMEOUT, # type: ignore[reportPrivateUsage] + ServerRunner, + otel_middleware, +) +from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings +from mcp.shared.dispatcher import CallOptions, OnNotify, OnRequest +from mcp.shared.exceptions import MCPError, NoBackChannelError +from mcp.shared.message import MessageMetadata, ServerMessageMetadata +from mcp.shared.transport_context import TransportContext +from mcp.types import ( + INTERNAL_ERROR, + INVALID_PARAMS, + PARSE_ERROR, + ErrorData, + JSONRPCError, + JSONRPCRequest, + JSONRPCResponse, + RequestId, +) + +if TYPE_CHECKING: + from mcp.server.lowlevel.server import Server + +logger = logging.getLogger(__name__) + + +@dataclass +class _SingleExchangeDispatchContext: + """`DispatchContext` for one inbound HTTP request. + + Structurally satisfies `mcp.shared.dispatcher.DispatchContext`. The + back-channel is closed by construction: a 2026-07-28 server cannot send + requests to the client. + """ + + transport: TransportContext + request_id: RequestId + message_metadata: MessageMetadata + cancel_requested: anyio.Event = field(default_factory=anyio.Event) + can_send_request: bool = False + + async def send_raw_request( + self, + method: str, + params: Mapping[str, Any] | None, + opts: CallOptions | None = None, + ) -> dict[str, Any]: + raise NoBackChannelError(method) + + async def notify(self, method: str, params: Mapping[str, Any] | None) -> None: + return None + + async def progress(self, progress: float, total: float | None = None, message: str | None = None) -> None: + # TODO: no progressToken plumbing yet. + return None + + +class SingleExchangeDispatcher: + """Dispatcher for exactly one inbound JSON-RPC request over a single HTTP POST. + + The exception->wire boundary lives here (mirrors `JSONRPCDispatcher`'s + role). Implements the `Dispatcher` Protocol so `ServerRunner` / + `Connection` / `ServerSession` accept it; `run()` is never driven. + """ + + def __init__(self, request: Request) -> None: + self._request = request + self._tctx = TransportContext( + kind="streamable-http", + can_send_request=False, + headers=request.headers, + ) + + async def send_raw_request( + self, + method: str, + params: Mapping[str, Any] | None, + opts: CallOptions | None = None, + *, + _related_request_id: RequestId | None = None, + ) -> dict[str, Any]: + raise NoBackChannelError(method) + + async def notify( + self, + method: str, + params: Mapping[str, Any] | None, + *, + _related_request_id: RequestId | None = None, + ) -> None: + # TODO: buffer and stream as SSE once the response-mode design lands. + return None + + async def run( + self, + on_request: OnRequest, + on_notify: OnNotify, + *, + task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, + ) -> None: + raise RuntimeError("SingleExchangeDispatcher.run() is never driven; use handle()") + + async def handle(self, req: JSONRPCRequest, on_request: OnRequest) -> JSONRPCResponse | JSONRPCError: + """Dispatch one request and map any exception to a `JSONRPCError`.""" + dctx = _SingleExchangeDispatchContext( + transport=self._tctx, + request_id=req.id, + message_metadata=ServerMessageMetadata(request_context=self._request), + ) + try: + result = await on_request(dctx, req.method, req.params) + return JSONRPCResponse(jsonrpc="2.0", id=req.id, result=result) + except MCPError as e: + return JSONRPCError(jsonrpc="2.0", id=req.id, error=e.error) + except ValidationError: + return JSONRPCError( + jsonrpc="2.0", + id=req.id, + error=ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data=""), + ) + # TODO: consolidate the three exception->ErrorData copies once the + # code=0 compat pin in JSONRPCDispatcher is lifted. + except Exception: + logger.exception("handler for %r raised", req.method) + return JSONRPCError( + jsonrpc="2.0", + id=req.id, + error=ErrorData(code=INTERNAL_ERROR, message="Internal server error"), + ) + + +async def handle_modern_request( + app: Server[Any], + security_settings: TransportSecuritySettings | None, + protocol_version: str, + scope: Scope, + receive: Receive, + send: Send, +) -> None: + """ASGI handler for a single stateless-era POST. + + Called from `StreamableHTTPSessionManager.handle_request` when the + `MCP-Protocol-Version` header is in `MODERN_PROTOCOL_VERSIONS`; the header + value is passed as `protocol_version`. Never sets `Mcp-Session-Id`. + """ + request = Request(scope, receive) + + security = TransportSecurityMiddleware(security_settings) + err = await security.validate_request(request, is_post=(request.method == "POST")) + if err is not None: + await err(scope, receive, send) + return + + # TODO: validate Accept header once the JSON-vs-SSE response-mode design is settled. + + if request.method != "POST": + # TODO: GET/DELETE rejection (405 + -32601) lands with the validation ladder. + await Response(status_code=405, headers={"Allow": "POST"})(scope, receive, send) + return + + body = await request.body() + try: + req = JSONRPCRequest.model_validate_json(body) + except ValidationError: + msg = JSONRPCError(jsonrpc="2.0", id=None, error=ErrorData(code=PARSE_ERROR, message="Parse error")) + await Response( + msg.model_dump_json(by_alias=True), + status_code=400, + media_type="application/json", + )(scope, receive, send) + return + + dispatcher = SingleExchangeDispatcher(request) + # TODO: per-request lifespan re-entry matches stateless_http=True today; revisit in #2893. + async with app.lifespan(app) as lifespan_state: + runner = ServerRunner( + server=app, + dispatcher=dispatcher, + lifespan_state=lifespan_state, + has_standalone_channel=False, + stateless=True, + dispatch_middleware=[otel_middleware], + ) + runner.connection.protocol_version = protocol_version + try: + msg = await dispatcher.handle(req, runner._compose_on_request()) # type: ignore[reportPrivateUsage] + finally: + with anyio.move_on_after(_EXIT_STACK_CLOSE_TIMEOUT, shield=True) as cancel_scope: + try: + await runner.connection.exit_stack.aclose() + except Exception: + logger.exception("connection exit_stack cleanup raised") + if cancel_scope.cancelled_caught: + logger.warning( + "connection exit_stack cleanup exceeded %s seconds; abandoning remaining callbacks", + _EXIT_STACK_CLOSE_TIMEOUT, + ) + + # TODO: error.code -> HTTP status mapping is a follow-up; 200 for all JSONRPCError bodies for now. + await Response( + msg.model_dump_json(by_alias=True, exclude_none=True), + status_code=200, + media_type="application/json", + )(scope, receive, send) 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..f56b87c9a3 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,19 @@ 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 (and must not + # be — it's transport-specific). The fix is to give `ctx.session` a per-request + # Outbound (the DispatchContext, which threads its own request_id) alongside + # the connection-level one, with `related_request_id` as the selector; that + # belongs with the ServerSession/Context rework, not here. + result = cast( + "dict[str, Any]", + await self._dispatcher.send_raw_request( + data["method"], + data.get("params"), + opts or None, + _related_request_id=related, # type: ignore[call-arg] + ), ) # Literal fallback covers pre-handshake and stateless; matches runner.py. version = self.protocol_version or "2025-11-25" @@ -110,7 +120,7 @@ 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..5c6ea531d0 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._streamable_http_modern import handle_modern_request from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context from mcp.server.streamable_http import ( MCP_SESSION_ID_HEADER, @@ -22,6 +23,7 @@ ) from mcp.server.transport_security import TransportSecuritySettings from mcp.shared._compat import resync_tracer +from mcp.shared.version import MODERN_PROTOCOL_VERSIONS from mcp.types import INVALID_REQUEST, ErrorData, JSONRPCError if TYPE_CHECKING: @@ -150,6 +152,13 @@ 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 in MODERN_PROTOCOL_VERSIONS: + await handle_modern_request(self.app, self.security_settings, pv, scope, receive, send) + return + # Dispatch to the appropriate handler if self.stateless: await self._handle_stateless_request(scope, receive, send) diff --git a/src/mcp/shared/version.py b/src/mcp/shared/version.py index 2299de72ec..09aacb6956 100644 --- a/src/mcp/shared/version.py +++ b/src/mcp/shared/version.py @@ -20,6 +20,9 @@ ) """Every released protocol revision, oldest to newest.""" +MODERN_PROTOCOL_VERSIONS: Final[tuple[str, ...]] = ("2026-07-28",) +"""Protocol revisions that use the stateless per-request envelope.""" + SUPPORTED_PROTOCOL_VERSIONS: list[str] = ["2024-11-05", "2025-03-26", "2025-06-18", LATEST_PROTOCOL_VERSION] """Protocol revisions this SDK can negotiate.""" 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/client/test_session.py b/tests/client/test_session.py index 0f68a066fb..c171360de2 100644 --- a/tests/client/test_session.py +++ b/tests/client/test_session.py @@ -1400,6 +1400,66 @@ 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_stateless_pinned_session_returns_the_synthesized_result_without_any_frame_sent(): + """A session pinned to the 2026-07-28 stateless protocol is born initialized. + + The 2026-07-28 lifecycle replaces the initialize handshake with a per-request ``_meta`` + envelope, so ``initialize()`` is idempotent and returns a locally-synthesized result + without ever touching the wire. + """ + async with raw_client_session(protocol_version="2026-07-28") as (session, _send, from_client): + result = await session.initialize() + assert result.protocol_version == "2026-07-28" + assert isinstance(result.capabilities, ServerCapabilities) + assert from_client.statistics().current_buffer_used == 0 + assert (await session.initialize()) is result + + +@pytest.mark.anyio +async def test_initialize_on_a_stateful_pin_requests_the_pinned_version(): + """A session pinned to a pre-2026 stateful version still runs the handshake, but the + outgoing ``initialize`` frame requests the pinned version rather than ``LATEST``.""" + async with raw_client_session(protocol_version="2025-06-18") as (session, to_client, from_client): + first: list[InitializeResult] = [] + + async def do_initialize() -> None: + first.append(await session.initialize()) + + async with anyio.create_task_group() as tg: + tg.start_soon(do_initialize) + out = await from_client.receive() + assert isinstance(out.message, JSONRPCRequest) + assert out.message.params is not None + assert out.message.params["protocolVersion"] == "2025-06-18" + assert session.protocol_version == "2025-06-18" + # Server negotiates a different (older) supported version than the pin requested. + result = InitializeResult( + protocol_version="2025-03-26", + capabilities=ServerCapabilities(), + server_info=Implementation(name="mock-server", version="0.1.0"), + ) + await to_client.send( + SessionMessage( + JSONRPCResponse( + jsonrpc="2.0", + id=out.message.id, + result=result.model_dump(by_alias=True, mode="json", exclude_none=True), + ) + ) + ) + # Drain the notifications/initialized frame so the buffer-used assertion below + # measures only what the second initialize() emits. + notif = await from_client.receive() + assert isinstance(notif.message, JSONRPCNotification) + # The property reports the negotiated version, not the pin, once the handshake is done. + assert session.protocol_version == "2025-03-26" + # A second call returns the cached result without a second handshake frame. + again = await session.initialize() + assert again is first[0] + assert from_client.statistics().current_buffer_used == 0 + + @pytest.mark.anyio async def test_send_notification_after_close_is_dropped_silently(): """Post-close `send_notification` is fire-and-forget: the notification is dropped, diff --git a/tests/client/test_streamable_http.py b/tests/client/test_streamable_http.py new file mode 100644 index 0000000000..0aecb971e8 --- /dev/null +++ b/tests/client/test_streamable_http.py @@ -0,0 +1,151 @@ +"""Unit tests for the streamable-HTTP client transport. + +The full client<->server round trip is pinned by the interaction suite under +tests/interaction/transports/; these tests cover the transport's per-message header +derivation directly because the headers are an HTTP-seam observation the public client +never exposes. +""" + +import base64 +import json + +import anyio +import httpx +import pytest +from inline_snapshot import snapshot + +from mcp.client import ClientSession +from mcp.client.streamable_http import StreamableHTTPTransport, _encode_header_value, streamable_http_client +from mcp.types import JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse + + +@pytest.mark.parametrize( + ("message", "expected"), + [ + ( + JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={"name": "add", "arguments": {}}), + snapshot({"mcp-method": "tools/call", "mcp-name": "add"}), + ), + ( + JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list", params={}), + snapshot({"mcp-method": "tools/list"}), + ), + ( + JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled"), + snapshot({"mcp-method": "notifications/cancelled"}), + ), + ( + JSONRPCResponse(jsonrpc="2.0", id=3, result={}), + snapshot({}), + ), + ], +) +def test_per_message_headers_for_pinned_transport_carry_method_and_name( + message: JSONRPCMessage, expected: dict[str, str] +) -> None: + """A 2026-07-28-pinned transport derives ``Mcp-Method`` (and ``Mcp-Name`` for tools/call) from the body. + + ``MCP-Protocol-Version`` is not in the per-message set: ``_prepare_headers()`` adds it from the + pin for every request, so only the method/name advisory headers vary per POST. Responses yield + nothing because the spec only defines the headers for requests and notifications. + """ + transport = StreamableHTTPTransport("http://test/mcp", protocol_version="2026-07-28") + assert transport._per_message_headers(message) == expected # pyright: ignore[reportPrivateUsage] + + +@pytest.mark.parametrize("protocol_version", [None, "2025-11-25"]) +def test_per_message_headers_are_empty_for_legacy_or_unpinned_transport(protocol_version: str | None) -> None: + """An unpinned or 2025-era transport emits no per-message headers, keeping the wire byte-identical to v1.""" + transport = StreamableHTTPTransport("http://test/mcp", protocol_version=protocol_version) + message = JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={"name": "add", "arguments": {}}) + assert transport._per_message_headers(message) == {} # pyright: ignore[reportPrivateUsage] + + +@pytest.mark.parametrize( + ("raw", "expected", "wrapped"), + [ + ("add", snapshot("add"), False), + ("", snapshot(""), False), + ("tool with spaces", snapshot("tool with spaces"), False), + (" add", snapshot("=?base64?IGFkZA==?="), True), + ("add ", snapshot("=?base64?YWRkIA==?="), True), + ("résumé", snapshot("=?base64?csOpc3Vtw6k=?="), True), + ("a\r\nb", snapshot("=?base64?YQ0KYg==?="), True), + ("=?base64?Zm9v?=", snapshot("=?base64?PT9iYXNlNjQ/Wm05dj89?="), True), + ], +) +def test_mcp_name_header_values_are_base64_wrapped_when_unsafe_for_an_http_field( + raw: str, expected: str, wrapped: bool +) -> None: + """Printable-ASCII names pass verbatim; CR/LF, non-ASCII, edge-whitespace, and sentinel-shaped names are wrapped. + + The ``=?base64?...?=`` sentinel is the spec's RFC 7230 safety gate for the ``Mcp-Name`` header. + Wrapped values round-trip through base64 so the server can recover the original name. A leading + or trailing space is wrapped because RFC 7230 forbids it in field-values (h11 rejects on real + transports); an empty value is allowed and passes verbatim. + """ + encoded = _encode_header_value(raw) + assert encoded == expected + if wrapped: + assert encoded.startswith("=?base64?") and encoded.endswith("?=") + assert base64.b64decode(encoded.removeprefix("=?base64?").removesuffix("?=")).decode() == raw + else: + assert encoded == raw + + +@pytest.mark.anyio +async def test_pinned_transport_ignores_returned_session_id_and_never_opens_get_or_delete() -> None: + """A server-issued ``Mcp-Session-Id`` never reaches a pinned client's wire: only POSTs are sent. + + The session-id capture, the standalone GET listening stream, and the DELETE-on-close are all + gated implicitly: a pinned ``ClientSession`` never sends ``initialize`` (no InitializeResult to + capture an id from) and never sends ``notifications/initialized`` (which is what triggers the + standalone GET), so even when a misbehaving peer volunteers a session id on every response the + recorded log stays POST-only and no request echoes the id back. The successful ``tools/call`` + triggers the client's implicit ``tools/list`` output-schema fetch so there is a second POST + after the id was offered. + """ + recorded: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + recorded.append(request) + body = json.loads(request.content) + if body["method"] == "tools/list": + result: dict[str, object] = { + "tools": [{"name": "add", "inputSchema": {"type": "object"}}], + "resultType": "complete", + "ttlMs": 0, + "cacheScope": "public", + } + else: + result = {"content": [{"type": "text", "text": "5"}], "isError": False, "resultType": "complete"} + return httpx.Response( + 200, json={"jsonrpc": "2.0", "id": body["id"], "result": result}, headers={"mcp-session-id": "srv-123"} + ) + + with anyio.fail_after(5): + async with ( + httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http, + streamable_http_client("http://test/mcp", http_client=http, protocol_version="2026-07-28") as (read, write), + ClientSession(read, write, protocol_version="2026-07-28") as session, + ): + await session.call_tool("add", {"a": 2, "b": 3}) + + assert [r.method for r in recorded] == snapshot(["POST", "POST"]) + assert all("mcp-session-id" not in r.headers for r in recorded) + + +def test_constructor_pin_is_not_overwritten_by_an_initialize_result() -> None: + """A protocol_version passed at construction wins over the InitializeResult snoop.""" + transport = StreamableHTTPTransport("http://test/mcp", protocol_version="2026-07-28") + init = JSONRPCResponse( + jsonrpc="2.0", + id=1, + result={ + "protocolVersion": "2025-11-25", + "capabilities": {}, + "serverInfo": {"name": "s", "version": "0"}, + }, + ) + transport._maybe_extract_protocol_version_from_message(init) # pyright: ignore[reportPrivateUsage] + assert transport.protocol_version == "2026-07-28" diff --git a/tests/interaction/_connect.py b/tests/interaction/_connect.py index db585a0520..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 @@ -166,6 +168,7 @@ async def mounted_app( retry_interval: int | None = None, transport_security: TransportSecuritySettings | None = NO_DNS_REBINDING_PROTECTION, on_request: Callable[[httpx.Request], Awaitable[None]] | None = None, + on_response: Callable[[httpx.Response], Awaitable[None]] | None = None, headers: dict[str, str] | None = None, auth: AuthSettings | None = None, token_verifier: TokenVerifier | None = None, @@ -177,8 +180,9 @@ async def mounted_app( use this in two ways: for raw-httpx assertions (status codes, headers, SSE bytes) the test speaks HTTP through the yielded client directly; for client-driven assertions the test wraps that client in `client_via_http(http)`, which lets several `Client`s share the one mounted - session manager. `on_request` records every outgoing HTTP request before it leaves the - yielded client. + session manager. `on_request` observes every outgoing HTTP request before it leaves the + yielded client; `on_response` observes every HTTP response as its headers arrive (response + bodies of SSE streams are not yet read at that point). DNS-rebinding protection is disabled by default; pass explicit settings (or `None` for the localhost auto-enable behaviour) to test the protection itself. @@ -194,7 +198,11 @@ async def mounted_app( token_verifier=token_verifier, auth_server_provider=auth_server_provider, ) - event_hooks = {"request": [on_request]} if on_request is not None else None + event_hooks: dict[str, list[Callable[..., Awaitable[None]]]] = {} + if on_request is not None: + event_hooks["request"] = [on_request] + if on_response is not None: + event_hooks["response"] = [on_response] async with ( server.session_manager.run(), httpx.AsyncClient( 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/_requirements.py b/tests/interaction/_requirements.py index 5fb2c91b16..0e7b2d25c0 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" @@ -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().""" @@ -89,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: @@ -205,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", @@ -220,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( @@ -236,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( @@ -244,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", @@ -269,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", @@ -297,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", @@ -304,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", @@ -311,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", @@ -318,6 +355,45 @@ 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", + behavior=( + "At protocol_version 2026-07-28, every request carries io.modelcontextprotocol/protocolVersion, " + "/clientInfo, and /clientCapabilities in params._meta; no initialize handshake occurs." + ), + added_in="2026-07-28", + ), + "lifecycle:stateless:no-initialize": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#stateless-operation", + behavior=( + "A ClientSession pinned to 2026-07-28 is born initialized: initialize() is idempotent " + "and returns the synthesized result without any frame sent." + ), + added_in="2026-07-28", + deferred="covered by a tests/client/ unit test; not observable as an interaction", + ), + "lifecycle:stateless:caller-meta-preserved": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#stateless-operation", + behavior=( + "Caller-supplied _meta keys on a request survive the per-request envelope merge: the " + "three io.modelcontextprotocol/* envelope keys overwrite any caller-supplied values for " + "those keys; non-colliding caller keys are preserved." + ), + added_in="2026-07-28", + ), + "lifecycle:stateless:unpinned-legacy-wire": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/versioning", + behavior=( + "An unpinned session that negotiates an earlier protocol version emits no 2026-07-28 " + "vocabulary on any JSON-RPC frame in either direction." + ), + deferred=( + "bare-ClientSession seam; the high-level Client + HTTP-seam scan in " + "hosting:http:legacy-no-modern-vocabulary covers the same vocabulary set" + ), ), # ═══════════════════════════════════════════════════════════════════════════ # Protocol primitives: cancellation, timeout, progress, errors, _meta @@ -439,6 +515,17 @@ 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", @@ -494,6 +581,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", @@ -506,6 +594,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", @@ -518,6 +607,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", @@ -551,6 +641,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", @@ -654,6 +745,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", @@ -661,6 +753,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", @@ -881,12 +974,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", @@ -1048,6 +1143,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", @@ -1072,6 +1168,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 @@ -1102,6 +1199,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", @@ -1144,6 +1242,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", @@ -1175,6 +1274,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 @@ -1241,6 +1341,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", @@ -1248,6 +1349,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", @@ -1867,6 +1969,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 @@ -2861,6 +2973,81 @@ 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."), + ), + "hosting:http:modern:legacy-fallthrough": Requirement( + source=f"{SPEC_2026_BASE_URL}/basic/versioning", + behavior=( + "Non-2026-07-28 traffic on the same /mcp endpoint reaches the legacy transport " + "byte-unchanged: a 2025-era initialize handshake still completes, and an unrecognised " + "MCP-Protocol-Version header still produces the legacy 400 'Unsupported protocol version' literal." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note=( + "Only observable over streamable HTTP: routing branches on the MCP-Protocol-Version " + "header at the same /mcp endpoint." + ), + ), + "hosting:http:modern:handler-exception-internal-error": Requirement( + source="sdk", + behavior=( + "An unhandled handler exception on the 2026-07-28 entry is returned as JSON-RPC error " + "-32603 with a generic message that does not echo str(exc)." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note="Only observable over streamable HTTP: the modern entry's exception-to-JSONRPCError boundary.", + ), # ═══════════════════════════════════════════════════════════════════════════ # Client transport: streamable HTTP # ═══════════════════════════════════════════════════════════════════════════ @@ -3034,6 +3221,27 @@ 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=( + "A pinned client never echoes a server-issued Mcp-Session-Id and never opens the standalone " + "GET stream or the closing DELETE: the recorded wire is POST-only." + ), + added_in="2026-07-28", + transports=("streamable-http",), + note="Only observable over streamable HTTP: session-id, GET stream and DELETE are streamable-HTTP mechanics.", + deferred="defensive against a misbehaving peer; covered by a tests/client/ unit test", + ), # ═══════════════════════════════════════════════════════════════════════════ # Client auth # ═══════════════════════════════════════════════════════════════════════════ 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. 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/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..1f043510fe --- /dev/null +++ b/tests/interaction/transports/test_hosting_http_modern.py @@ -0,0 +1,291 @@ +"""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 json +from collections.abc import Callable +from typing import Any + +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_URL, base_headers, initialize_body, initialize_via_http, 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(*, 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: + 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) + + +@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 + + +@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") +@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. + + 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. 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, Any]] = [] + server = _server(on_meta=observed_metas.append) + + 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, 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}, + 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"} + ) + + # 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 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"} + ) + 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 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"]] + + # 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) 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) diff --git a/tests/server/test_streamable_http_modern.py b/tests/server/test_streamable_http_modern.py new file mode 100644 index 0000000000..ce62d44cec --- /dev/null +++ b/tests/server/test_streamable_http_modern.py @@ -0,0 +1,190 @@ +"""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 -- +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``. +""" + +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 + +import mcp.server._streamable_http_modern as modern +from mcp.server import Server, ServerRequestContext +from mcp.server._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, ListToolsResult, PaginatedRequestParams + +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, "2026-07-28", 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 + assert response.headers["allow"] == "POST" + + +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", + "id": None, + "error": {"code": PARSE_ERROR, "message": "Parse error", "data": None}, + } + + +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" + + +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"}) + # 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 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"}}' )