First end-to-end 2026-07-28 stateless tools/call (experimental entry + ClientSession pin) (#2917)
This commit is contained in:
parent
1cec2d60f4
commit
84bf9bde05
14 changed files with 1515 additions and 93 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
151
tests/client/test_streamable_http.py
Normal file
151
tests/client/test_streamable_http.py
Normal 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"
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
79
tests/interaction/_modern_vocab.py
Normal file
79
tests/interaction/_modern_vocab.py
Normal 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}"
|
||||
|
|
@ -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
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
291
tests/interaction/transports/test_hosting_http_modern.py
Normal file
291
tests/interaction/transports/test_hosting_http_modern.py
Normal 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)
|
||||
85
tests/interaction/transports/test_legacy_wire.py
Normal file
85
tests/interaction/transports/test_legacy_wire.py
Normal 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)
|
||||
190
tests/server/test_streamable_http_modern.py
Normal file
190
tests/server/test_streamable_http_modern.py
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"}}'
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue