First end-to-end 2026-07-28 stateless tools/call (experimental entry + ClientSession pin) (#2917)

This commit is contained in:
Max 2026-06-20 14:55:59 +01:00 • committed by GitHub
commit 84bf9bde05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1515 additions and 93 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -45,7 +45,7 @@ SpecVersion = Literal["2025-11-25", "2026-07-28"]
"""A protocol version the suite parametrizes over. Both values are typed even though only one is
on the active axis (SPEC_VERSIONS) until the 2026-07-28 implementation lands."""
SPEC_VERSIONS: tuple[SpecVersion, ...] = ("2025-11-25",)
SPEC_VERSIONS: tuple[SpecVersion, ...] = ("2025-11-25", "2026-07-28")
"""The active spec-version matrix axis, ordered oldest to newest. Every entry must be in KNOWN_PROTOCOL_VERSIONS."""
SPEC_BASE_URL = "https://modelcontextprotocol.io/specification/2025-11-25"
@ -63,6 +63,13 @@ CONNECTABLE_TRANSPORTS: tuple[Transport, ...] = ("in-memory", "sse", "streamable
TRANSPORT_SPEC_VERSIONS: dict[Transport, tuple[SpecVersion, ...]] = {
"sse": ("2025-11-25",),
# Temporary lock: the in-memory transport has no modern entry point yet, so it cannot
# negotiate the newer revision. Remove once an in-memory factory for the modern path lands.
"in-memory": ("2025-11-25",),
# At the newer revision the protocol-version header check runs before the stateless branch is
# taken, so a stateless connection at that revision behaves identically to the stateful one.
# Locked to avoid a redundant matrix column; revisit if the header/stateless ordering changes.
"streamable-http-stateless": ("2025-11-25",),
}
"""Transports that only serve a subset of SPEC_VERSIONS. Absent => serves all. Consulted by compute_cells()."""
@ -89,6 +96,12 @@ _TASKS_DEFERRAL = (
"unimplemented."
)
_MODERN_NOTIFY_DROP = (
"SingleExchangeDispatcher.notify() no-ops on the modern streamable-http driver; handler-emitted "
"logging/progress notifications never reach the per-request SSE response. Passes once SSE "
"response mode lands."
)
@dataclass(frozen=True, kw_only=True)
class Divergence:
@ -205,14 +218,20 @@ REQUIREMENTS: dict[str, Requirement] = {
"Connecting sends initialize with the protocol version, client capabilities, and client "
"info; the server responds with its own and the connection is established."
),
removed_in="2026-07-28",
note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.",
),
"lifecycle:initialize:server-info": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization",
behavior="The initialize result identifies the server: name and version, plus title when declared.",
removed_in="2026-07-28",
note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.",
),
"lifecycle:initialize:instructions": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization",
behavior="A server may include an instructions string in the initialize result; the client exposes it.",
removed_in="2026-07-28",
note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.",
),
"lifecycle:initialize:capabilities:from-handlers": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#capability-negotiation",
@ -220,14 +239,20 @@ REQUIREMENTS: dict[str, Requirement] = {
"The server advertises a capability for each feature area it has a registered handler for, "
"and omits the capability for areas it does not."
),
removed_in="2026-07-28",
note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.",
),
"lifecycle:initialize:capabilities:minimal": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#capability-negotiation",
behavior="A server with no feature handlers advertises no feature capabilities.",
removed_in="2026-07-28",
note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.",
),
"lifecycle:initialize:client-info": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization",
behavior="The client's name, version, and title are visible to server handlers after initialization.",
removed_in="2026-07-28",
note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.",
arm_exclusions=(ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),),
),
"lifecycle:initialize:client-capabilities": Requirement(
@ -236,6 +261,8 @@ REQUIREMENTS: dict[str, Requirement] = {
"The client capabilities visible to the server reflect which client callbacks are configured "
"(sampling, elicitation, roots)."
),
removed_in="2026-07-28",
note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.",
arm_exclusions=(ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),),
),
"lifecycle:initialized-notification": Requirement(
@ -244,6 +271,8 @@ REQUIREMENTS: dict[str, Requirement] = {
"After successful initialization, the client sends exactly one initialized notification, "
"before any non-ping request."
),
removed_in="2026-07-28",
note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.",
),
"lifecycle:ping": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/ping#behavior-requirements",
@ -269,6 +298,8 @@ REQUIREMENTS: dict[str, Requirement] = {
behavior=(
"A request other than ping sent before the initialization handshake completes is rejected with an error."
),
removed_in="2026-07-28",
note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.",
),
"lifecycle:pre-initialization-ordering": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization",
@ -297,6 +328,8 @@ REQUIREMENTS: dict[str, Requirement] = {
"When the server returns an older supported protocol version, the client downgrades to it "
"and the connection succeeds at that version."
),
removed_in="2026-07-28",
note="initialize-time version negotiation removed at 2026-07-28; version carried per-request in _meta.",
),
"lifecycle:version:match": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#version-negotiation",
@ -304,6 +337,8 @@ REQUIREMENTS: dict[str, Requirement] = {
"When the server supports the requested protocol version it echoes that version in the "
"initialize result, and the connection proceeds at that version."
),
removed_in="2026-07-28",
note="initialize-time version negotiation removed at 2026-07-28; version carried per-request in _meta.",
),
"lifecycle:version:server-fallback-latest": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#version-negotiation",
@ -311,6 +346,8 @@ REQUIREMENTS: dict[str, Requirement] = {
"An initialize request carrying a protocol version the server does not support is answered "
"with another version the server supports — the latest one — rather than an error."
),
removed_in="2026-07-28",
note="initialize-time version negotiation removed at 2026-07-28; version carried per-request in _meta.",
),
"lifecycle:version:reject-unsupported": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#version-negotiation",
@ -318,6 +355,45 @@ REQUIREMENTS: dict[str, Requirement] = {
"A client that receives an initialize response carrying a protocol version it does not "
"support fails initialization with an error rather than proceeding with the session."
),
removed_in="2026-07-28",
note="initialize-time version negotiation removed at 2026-07-28; version carried per-request in _meta.",
),
"lifecycle:stateless:request-envelope": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#stateless-operation",
behavior=(
"At protocol_version 2026-07-28, every request carries io.modelcontextprotocol/protocolVersion, "
"/clientInfo, and /clientCapabilities in params._meta; no initialize handshake occurs."
),
added_in="2026-07-28",
),
"lifecycle:stateless:no-initialize": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#stateless-operation",
behavior=(
"A ClientSession pinned to 2026-07-28 is born initialized: initialize() is idempotent "
"and returns the synthesized result without any frame sent."
),
added_in="2026-07-28",
deferred="covered by a tests/client/ unit test; not observable as an interaction",
),
"lifecycle:stateless:caller-meta-preserved": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#stateless-operation",
behavior=(
"Caller-supplied _meta keys on a request survive the per-request envelope merge: the "
"three io.modelcontextprotocol/* envelope keys overwrite any caller-supplied values for "
"those keys; non-colliding caller keys are preserved."
),
added_in="2026-07-28",
),
"lifecycle:stateless:unpinned-legacy-wire": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/versioning",
behavior=(
"An unpinned session that negotiates an earlier protocol version emits no 2026-07-28 "
"vocabulary on any JSON-RPC frame in either direction."
),
deferred=(
"bare-ClientSession seam; the high-level Client + HTTP-seam scan in "
"hosting:http:legacy-no-modern-vocabulary covers the same vocabulary set"
),
),
# ═══════════════════════════════════════════════════════════════════════════
# Protocol primitives: cancellation, timeout, progress, errors, _meta
@ -439,6 +515,17 @@ REQUIREMENTS: dict[str, Requirement] = {
"leaks str(exc) as the error message."
),
),
arm_exclusions=(
ArmExclusion(
reason="modern-error-surface",
spec_version="2026-07-28",
note=(
"The modern entry maps Exception->INTERNAL_ERROR (-32603) with an opaque message, so the "
"2026 arm SATISFIES this requirement; the test pins the legacy code-0 divergence and "
"needs an era-aware assertion before re-admission."
),
),
),
),
"protocol:error:invalid-params": Requirement(
source=f"{SPEC_BASE_URL}/basic#responses",
@ -494,6 +581,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"Progress notifications emitted by a handler during a request are delivered to the caller's "
"progress callback, in order, with their progress, total, and message."
),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
),
"protocol:progress:token-injected": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
@ -506,6 +594,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"protocol:progress:token-unique": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
behavior=("Concurrent in-flight requests that each supply a progress callback carry distinct progress tokens."),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
),
"protocol:progress:monotonic": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
@ -518,6 +607,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"handler that emits non-increasing values has them forwarded to the callback unchanged."
),
),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
),
"protocol:progress:stops-after-completion": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/progress#behavior-requirements",
@ -551,6 +641,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"protocol:progress:client-to-server": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
behavior="A progress notification sent by the client is delivered to the server's progress handler.",
arm_exclusions=(ArmExclusion(reason="requires-session", spec_version="2026-07-28"),),
),
"protocol:timeout:basic": Requirement(
source=f"{SPEC_BASE_URL}/basic/lifecycle#timeouts",
@ -654,6 +745,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"Log notifications emitted by a tool handler during execution reach the client's logging "
"callback before the tool result returns."
),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
),
"tools:call:progress": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
@ -661,6 +753,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"Progress notifications emitted by a tool handler reach the caller's progress callback before "
"the tool result returns."
),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
),
"tools:call:sampling-roundtrip": Requirement(
source=f"{SPEC_BASE_URL}/client/sampling#creating-messages",
@ -881,12 +974,14 @@ REQUIREMENTS: dict[str, Requirement] = {
"The Context logging helpers (debug/info/warning/error) send log message notifications at the "
"corresponding severity."
),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
),
"mcpserver:context:progress": Requirement(
source="sdk",
behavior=(
"Context.report_progress sends a progress notification against the requesting client's progress token."
),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
),
"mcpserver:context:elicit": Requirement(
source="sdk",
@ -1048,6 +1143,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"mcpserver:resource:read-throws-surfaced": Requirement(
source="sdk",
behavior="A resource function that raises is surfaced to the caller as a JSON-RPC error response.",
arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),),
),
"mcpserver:resource:static": Requirement(
source="sdk",
@ -1072,6 +1168,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"the low-level server converts to error code 0."
),
),
arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),),
),
# ═══════════════════════════════════════════════════════════════════════════
# Prompts
@ -1102,6 +1199,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"which the low-level server converts to error code 0 with the exception text as the message."
),
),
arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),),
),
"prompts:get:multi-message": Requirement(
source=f"{SPEC_BASE_URL}/server/prompts#getting-a-prompt",
@ -1144,6 +1242,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"mcpserver:prompt:args-validation": Requirement(
source=f"{SPEC_BASE_URL}/server/prompts#implementation-considerations",
behavior="prompts/get arguments that fail the prompt's argument schema are rejected before the function runs.",
arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),),
),
"mcpserver:prompt:decorated": Requirement(
source="sdk",
@ -1175,6 +1274,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"ValueError, which the low-level server converts to error code 0."
),
),
arm_exclusions=(ArmExclusion(reason="modern-error-surface", spec_version="2026-07-28"),),
),
# ═══════════════════════════════════════════════════════════════════════════
# Completion
@ -1241,6 +1341,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"logging:message:all-levels": Requirement(
source=f"{SPEC_BASE_URL}/server/utilities/logging#log-levels",
behavior="All eight RFC 5424 severity levels are deliverable as log message notifications.",
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
),
"logging:message:fields": Requirement(
source=f"{SPEC_BASE_URL}/server/utilities/logging#log-message-notifications",
@ -1248,6 +1349,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"A log message sent by a server handler is delivered to the client's logging callback with its "
"severity level, logger name, and data."
),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
),
"logging:message:filtered": Requirement(
source=f"{SPEC_BASE_URL}/server/utilities/logging#setting-log-level",
@ -1867,6 +1969,16 @@ REQUIREMENTS: dict[str, Requirement] = {
"client cannot learn that the set changed without polling."
),
),
known_failures=(
KnownFailure(
spec_version="2026-07-28",
note=(
"List-mutation assertions hold; only the sentinel ctx.info() never reaches the client. "
+ _MODERN_NOTIFY_DROP
),
issue=None,
),
),
),
# ═══════════════════════════════════════════════════════════════════════════
# Pagination
@ -2861,6 +2973,81 @@ REQUIREMENTS: dict[str, Requirement] = {
removed_in="2026-07-28",
note="removed in 2026-07-28 (SEP-2575); the standalone GET endpoint is replaced by subscriptions/listen.",
),
"hosting:http:protocol-version-rejection-literal": Requirement(
source="sdk",
behavior=(
"The legacy streamable-HTTP transport's version-rejection body contains the literal substring "
"'Unsupported protocol version', which other-SDK clients substring-match during negotiation."
),
transports=("streamable-http",),
note=(
"Only observable over streamable HTTP: cross-SDK clients sniff this exact substring in the rejection body."
),
),
"hosting:http:legacy-no-modern-vocabulary": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/versioning",
behavior=(
"A 2025-era streamable-HTTP exchange carries none of the 2026-07-28 wire vocabulary "
"(resultType, ttlMs, cacheScope, io.modelcontextprotocol/* _meta keys, the 2026-07-28 "
"version string, or Mcp-Method/Mcp-Name/Mcp-Param-* headers)."
),
transports=("streamable-http",),
note=(
"Only observable over streamable HTTP: the assertion records HTTP headers and SSE frames "
"at the transport seam."
),
),
"hosting:http:modern:tools-call-stateless": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http",
behavior=(
"A 2026-07-28 tools/call POST is served without an initialize handshake and returns a "
"result body carrying resultType: complete."
),
added_in="2026-07-28",
transports=("streamable-http",),
note=(
"Only observable over streamable HTTP: the modern entry handles a 2026-07-28 POST without "
"an initialize handshake."
),
),
"hosting:http:modern:no-session-id": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http",
behavior="A 2026-07-28 response never carries an Mcp-Session-Id header.",
added_in="2026-07-28",
transports=("streamable-http",),
note="Only observable over streamable HTTP: Mcp-Session-Id is a streamable-HTTP response header.",
),
"hosting:http:modern:initialize-removed": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/index",
behavior="A 2026-07-28 initialize request is answered with METHOD_NOT_FOUND.",
added_in="2026-07-28",
transports=("streamable-http",),
note=("Only observable over streamable HTTP: the modern entry's method registry omits initialize."),
),
"hosting:http:modern:legacy-fallthrough": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/versioning",
behavior=(
"Non-2026-07-28 traffic on the same /mcp endpoint reaches the legacy transport "
"byte-unchanged: a 2025-era initialize handshake still completes, and an unrecognised "
"MCP-Protocol-Version header still produces the legacy 400 'Unsupported protocol version' literal."
),
added_in="2026-07-28",
transports=("streamable-http",),
note=(
"Only observable over streamable HTTP: routing branches on the MCP-Protocol-Version "
"header at the same /mcp endpoint."
),
),
"hosting:http:modern:handler-exception-internal-error": Requirement(
source="sdk",
behavior=(
"An unhandled handler exception on the 2026-07-28 entry is returned as JSON-RPC error "
"-32603 with a generic message that does not echo str(exc)."
),
added_in="2026-07-28",
transports=("streamable-http",),
note="Only observable over streamable HTTP: the modern entry's exception-to-JSONRPCError boundary.",
),
# ═══════════════════════════════════════════════════════════════════════════
# Client transport: streamable HTTP
# ═══════════════════════════════════════════════════════════════════════════
@ -3034,6 +3221,27 @@ REQUIREMENTS: dict[str, Requirement] = {
removed_in="2026-07-28",
note="removed in 2026-07-28 (SEP-2567); session DELETE removed with Mcp-Session-Id, no replacement.",
),
"client-transport:http:body-derived-headers": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/transports#stateless-request-headers",
behavior=(
"An envelope-bearing request body yields MCP-Protocol-Version, Mcp-Method, and (for tools/call) "
"Mcp-Name headers on the outgoing HTTP request; a body without the envelope yields none."
),
added_in="2026-07-28",
transports=("streamable-http",),
note="Only observable over streamable HTTP: headers are derived from the body envelope at the transport seam.",
),
"client-transport:http:stateless-ignores-session-id": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/transports#stateless-request-headers",
behavior=(
"A pinned client never echoes a server-issued Mcp-Session-Id and never opens the standalone "
"GET stream or the closing DELETE: the recorded wire is POST-only."
),
added_in="2026-07-28",
transports=("streamable-http",),
note="Only observable over streamable HTTP: session-id, GET stream and DELETE are streamable-HTTP mechanics.",
deferred="defensive against a misbehaving peer; covered by a tests/client/ unit test",
),
# ═══════════════════════════════════════════════════════════════════════════
# Client auth
# ═══════════════════════════════════════════════════════════════════════════

View file

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

View file

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

View file

@ -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.

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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"

View file

@ -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"}}'
)