Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
c0397c0
Add experimental 2026-07-28 stateless HTTP serving entry
maxisbey Jun 18, 2026
980d57a
Add protocol_version pin to ClientSession for stateless 2026-07-28 mode
maxisbey Jun 18, 2026
188dc83
Pass app and security_settings explicitly to handle_modern_request
maxisbey Jun 18, 2026
9aebd53
Add interaction tests for the 2026-07-28 stateless lifecycle and HTTP…
maxisbey Jun 19, 2026
a4f0939
Register 2026-07-28 stateless requirements in the interaction-suite m…
maxisbey Jun 19, 2026
cf65e8b
Add MockTransport, capstone, and client-unit tests for the 2026-07-28…
maxisbey Jun 19, 2026
081c564
Register remaining 2026-07-28 stateless requirements
maxisbey Jun 19, 2026
ae383c5
Add coverage tests for the experimental modern HTTP entry
maxisbey Jun 19, 2026
26ff922
Reconcile conformance baselines for the stateless serving path
maxisbey Jun 19, 2026
92c078a
Harden the experimental modern HTTP entry's error and cleanup paths
maxisbey Jun 19, 2026
06d1492
Derive transport headers from a constructor protocol_version pin
maxisbey Jun 19, 2026
4378d15
Mark post-shielded-cancel assertions as lax-no-cover for 3.11
maxisbey Jun 19, 2026
194f225
Address review feedback and consolidate 2026-07-28 interaction tests
maxisbey Jun 19, 2026
3afe0f0
Tighten the consolidated 2026-07-28 interaction tests
maxisbey Jun 19, 2026
12f2539
Dispatch ClientSession.protocol_version by era instead of restricting…
maxisbey Jun 19, 2026
4954ed4
Make a pinned ClientSession born-initialized and centralize the moder…
maxisbey Jun 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 2 additions & 16 deletions .github/actions/conformance/expected-failures.2026-07-28.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,26 +89,13 @@ server:
# the server rejects before the handler runs. These scenarios all pass on
# the 2025 legs; they unblock once mcp-everything-server routes 2026
# requests through a stateless path.
- completion-complete
- tools-list
- tools-call-simple-text
- tools-call-image
- tools-call-audio
- tools-call-embedded-resource
- tools-call-mixed-content
- tools-call-error
- tools-call-with-progress
- server-sse-multiple-streams
- resources-list
- resources-read-text
- resources-read-binary
- resources-templates-read
- prompts-list
- prompts-get-simple
- prompts-get-with-args
- prompts-get-embedded-resource
- prompts-get-with-image
- dns-rebinding-protection
# SEP-2106 (JSON Schema 2020-12 in tool inputSchema): the fixture tool's
# schema has none of the 2020-12 keywords the scenario checks. The scenario
# is in `--suite all` but not `--suite active`, so this is the only leg that
Expand All @@ -130,6 +117,7 @@ server:
- input-required-result-result-type
- input-required-result-tampered-state
- input-required-result-capability-check
- input-required-result-validate-input
# SEP-2549 (caching): no ttlMs/cacheScope support.
- caching
# SEP-2243 (HTTP header standardization): -32020 HeaderMismatch handling and
Expand All @@ -143,7 +131,5 @@ server:
# as the draft suite in expected-failures.yml.
# SEP-2164: server returns -32600 (not -32602) and omits error.data.uri.
- sep-2164-resource-not-found
# SEP-2322 SHOULD-level behaviours (re-request missing inputResponses,
# ignore unrecognized inputResponses keys).
# SEP-2322 SHOULD-level behaviour (re-request missing inputResponses).
- input-required-result-missing-input-response
- input-required-result-ignore-extra-params
16 changes: 8 additions & 8 deletions .github/actions/conformance/expected-failures.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,12 @@ server:
# WARNINGs, but the expected-failures evaluator counts WARNINGs as failures.
# SEP-2164: server returns -32600 (not -32602) and omits error.data.uri.
- sep-2164-resource-not-found
# SEP-2322 SHOULD-level behaviours (re-request missing inputResponses, ignore
# unrecognized inputResponses keys).
# SEP-2322 SHOULD-level behaviour (re-request missing inputResponses).
- input-required-result-missing-input-response
- input-required-result-ignore-extra-params
# Intentionally NOT baselined (2 of 19 draft scenarios): the SEP-2322
# negative-case scenarios input-required-result-unsupported-methods and
# input-required-result-validate-input pass today only because the stateful
# server's -32600 "Missing session ID" satisfies their assertions. They will
# start failing for real once stateless mode lands; add them then.
# SEP-2322 negative-case scenarios: input-required-result-validate-input is
# now baselined (added when the stateless path landed — the stateless server
# reaches the handler, so the previous accidental pass via -32600 "Missing
# session ID" no longer applies). input-required-result-unsupported-methods
# is intentionally NOT baselined: it still passes for now; add it once it
# starts failing for real.
- input-required-result-validate-input
72 changes: 59 additions & 13 deletions src/mcp/client/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 FIRST_MODERN_VERSION, SUPPORTED_PROTOCOL_VERSIONS, is_version_at_least
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")
Expand Down Expand Up @@ -141,19 +149,36 @@ 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 is not None and is_version_at_least(
protocol_version, FIRST_MODERN_VERSION
)
self._sampling_callback = sampling_callback or _default_sampling_callback
self._sampling_capabilities = sampling_capabilities
self._elicitation_callback = elicitation_callback or _default_elicitation_callback
self._list_roots_callback = list_roots_callback or _default_list_roots_callback
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:
Expand Down Expand Up @@ -219,6 +244,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
Expand Down Expand Up @@ -254,7 +292,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
Expand All @@ -273,17 +311,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,
),
),
Expand All @@ -303,13 +343,19 @@ 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."""
"""Negotiated or pinned protocol version. None until initialize() unless pinned at construction."""
if self._pinned_version is not None:
return self._pinned_version
return self._initialize_result.protocol_version if self._initialize_result else None

async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> types.EmptyResult:
Expand Down
53 changes: 50 additions & 3 deletions src/mcp/client/streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 FIRST_MODERN_VERSION, is_version_at_least
from mcp.types import (
INTERNAL_ERROR,
INVALID_REQUEST,
Expand All @@ -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."""
Expand All @@ -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 is None or not is_version_at_least(self.protocol_version, FIRST_MODERN_VERSION):
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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.

Expand All @@ -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:
Expand All @@ -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}")

Expand Down
Loading