Skip to content

Commit 9aebd53

Browse files
committed
Add interaction tests for the 2026-07-28 stateless lifecycle and HTTP path
- assert_no_modern_vocabulary helper and on_response= hook on mounted_app - lowlevel/test_lifecycle_stateless.py: pinned ClientSession stamps the envelope on every request, initialize() is rejected, caller _meta survives the merge, unpinned sessions carry no 2026 vocabulary - transports/test_legacy_wire.py: a 2025-era exchange carries no 2026 vocabulary at the HTTP seam - transports/test_client_transport_http_modern.py: body-derived header table and the Mcp-Name Base64-sentinel encoding - transports/test_hosting_http_modern.py: stateless tools/call returns resultType complete, no Mcp-Session-Id, initialize is METHOD_NOT_FOUND - transports/test_hosting_http.py: the Unsupported-protocol-version rejection literal stays sniffable Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
1 parent 188dc83 commit 9aebd53

7 files changed

Lines changed: 646 additions & 3 deletions

File tree

tests/interaction/_connect.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ async def mounted_app(
166166
retry_interval: int | None = None,
167167
transport_security: TransportSecuritySettings | None = NO_DNS_REBINDING_PROTECTION,
168168
on_request: Callable[[httpx.Request], Awaitable[None]] | None = None,
169+
on_response: Callable[[httpx.Response], Awaitable[None]] | None = None,
169170
headers: dict[str, str] | None = None,
170171
auth: AuthSettings | None = None,
171172
token_verifier: TokenVerifier | None = None,
@@ -177,8 +178,9 @@ async def mounted_app(
177178
use this in two ways: for raw-httpx assertions (status codes, headers, SSE bytes) the test
178179
speaks HTTP through the yielded client directly; for client-driven assertions the test wraps
179180
that client in `client_via_http(http)`, which lets several `Client`s share the one mounted
180-
session manager. `on_request` records every outgoing HTTP request before it leaves the
181-
yielded client.
181+
session manager. `on_request` observes every outgoing HTTP request before it leaves the
182+
yielded client; `on_response` observes every HTTP response as its headers arrive (response
183+
bodies of SSE streams are not yet read at that point).
182184
183185
DNS-rebinding protection is disabled by default; pass explicit settings (or `None` for the
184186
localhost auto-enable behaviour) to test the protection itself.
@@ -194,7 +196,11 @@ async def mounted_app(
194196
token_verifier=token_verifier,
195197
auth_server_provider=auth_server_provider,
196198
)
197-
event_hooks = {"request": [on_request]} if on_request is not None else None
199+
event_hooks: dict[str, list[Callable[..., Awaitable[None]]]] = {}
200+
if on_request is not None:
201+
event_hooks["request"] = [on_request]
202+
if on_response is not None:
203+
event_hooks["response"] = [on_response]
198204
async with (
199205
server.session_manager.run(),
200206
httpx.AsyncClient(

tests/interaction/_modern_vocab.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""Guard against 2026-era protocol vocabulary leaking onto legacy (2025-era) exchanges.
2+
3+
The 2026-07-28 spec revision introduces wire vocabulary that did not exist before it --
4+
result-envelope fields (`resultType`, `ttlMs`, `cacheScope`), namespaced
5+
`io.modelcontextprotocol/*` `_meta` keys, the version literal itself, and the per-request HTTP
6+
headers `Mcp-Method` / `Mcp-Name` / `Mcp-Param-*`. None of that may appear on a connection
7+
negotiated at an earlier protocol version: a test that records a plain legacy round trip and
8+
runs it through :func:`assert_no_modern_vocabulary` will start failing the moment a 2026 change
9+
leaks onto the existing wire.
10+
11+
Tests construct a :class:`RecordedExchange` from whatever instrumentation they have to hand --
12+
the `on_request` / `on_response` hooks on :func:`tests.interaction._connect.mounted_app` for the
13+
HTTP seam, and :class:`tests.interaction._helpers.RecordingTransport` for the JSON-RPC frames --
14+
and pass it to the assertion. The helper scans header names and serialised bodies; it makes no
15+
assumptions about which side produced what.
16+
"""
17+
18+
from dataclasses import dataclass
19+
20+
import httpx
21+
22+
from mcp.types import JSONRPCMessage, jsonrpc_message_adapter
23+
24+
#: Substrings that must not appear anywhere in a request body or JSON-RPC frame on a legacy
25+
#: exchange. Matching is by raw substring against the by-alias JSON serialisation, so a leaked
26+
#: field name, `_meta` key prefix, or version literal is caught regardless of where in the
27+
#: payload it sits.
28+
MODERN_BODY_TOKENS: frozenset[str] = frozenset(
29+
{
30+
"resultType",
31+
"ttlMs",
32+
"cacheScope",
33+
"io.modelcontextprotocol/",
34+
"2026-07-28",
35+
}
36+
)
37+
38+
#: Lower-cased HTTP header names introduced by the 2026-07-28 transport.
39+
MODERN_HEADER_NAMES: frozenset[str] = frozenset({"mcp-method", "mcp-name"})
40+
41+
#: Lower-cased prefix for the 2026-07-28 per-parameter header family.
42+
MODERN_HEADER_PREFIX = "mcp-param-"
43+
44+
45+
@dataclass
46+
class RecordedExchange:
47+
"""Everything a test captured from one streamable-HTTP conversation, for vocabulary scanning.
48+
49+
`requests` and `responses` are inspected for header names and (for requests) body bytes;
50+
`frames` are re-serialised to their wire JSON and scanned as body text. Response bodies are
51+
not read here -- streamable-HTTP responses are SSE streams that are consumed elsewhere -- so
52+
the server-to-client body content must be supplied via `frames`.
53+
"""
54+
55+
requests: list[httpx.Request]
56+
responses: list[httpx.Response]
57+
frames: list[JSONRPCMessage]
58+
59+
60+
def assert_no_modern_vocabulary(recorded: RecordedExchange) -> None:
61+
"""Fail if any 2026-era header name or body token appears anywhere in `recorded`.
62+
63+
All findings are collected before asserting so a single failure reports every leak.
64+
"""
65+
header_names = [name.lower() for request in recorded.requests for name in request.headers]
66+
header_names += [name.lower() for response in recorded.responses for name in response.headers]
67+
leaked = [
68+
f"header {name!r}"
69+
for name in header_names
70+
if name in MODERN_HEADER_NAMES or name.startswith(MODERN_HEADER_PREFIX)
71+
]
72+
73+
corpus = b"".join(request.content for request in recorded.requests).decode()
74+
corpus += "".join(
75+
jsonrpc_message_adapter.dump_json(frame, by_alias=True, exclude_none=True).decode() for frame in recorded.frames
76+
)
77+
leaked.extend(f"body token {token!r}" for token in MODERN_BODY_TOKENS if token in corpus)
78+
79+
assert not leaked, f"Modern (2026-07-28) protocol vocabulary on a legacy exchange: {leaked}"
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
"""Stateless lifecycle at protocol version 2026-07-28, driven through a bare ClientSession.
2+
3+
Under the 2026-07-28 lifecycle the initialize handshake is replaced by a per-request envelope:
4+
every request carries the protocol version, client info, and client capabilities under
5+
``params._meta`` and the server never sees an ``initialize`` frame. These tests pin the session
6+
to that version and observe the outgoing JSON-RPC frame directly, so they drop below the
7+
``connect`` fixture to a bare ClientSession over in-process memory streams. No 2026-aware Server
8+
exists yet, so the receiving side is a scripted peer that hand-builds the wire response — reserve
9+
this pattern for behaviour no real server can be made to produce.
10+
"""
11+
12+
from contextlib import nullcontext
13+
14+
import anyio
15+
import pytest
16+
from inline_snapshot import snapshot
17+
18+
from mcp.client import ClientSession
19+
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
20+
from mcp.shared.message import SessionMessage
21+
from mcp.types import (
22+
CallToolResult,
23+
Implementation,
24+
InitializeResult,
25+
JSONRPCNotification,
26+
JSONRPCRequest,
27+
JSONRPCResponse,
28+
ListToolsResult,
29+
ServerCapabilities,
30+
)
31+
from tests.interaction._helpers import RecordingTransport
32+
from tests.interaction._modern_vocab import MODERN_BODY_TOKENS
33+
from tests.interaction._requirements import requirement
34+
35+
pytestmark = pytest.mark.anyio
36+
37+
38+
@requirement("lifecycle:stateless:request-envelope")
39+
async def test_pinned_session_stamps_the_envelope_meta_on_every_request_and_never_initializes() -> None:
40+
"""A pinned session's first request is the feature request itself, carrying the three-key envelope.
41+
42+
The scripted peer asserts the only frame on the wire is ``tools/list`` (no ``initialize``, no
43+
``notifications/initialized``) and answers with a hand-built 2026-07-28 result; the test then
44+
snapshots the captured ``params._meta``.
45+
"""
46+
received: list[JSONRPCRequest] = []
47+
48+
async def scripted_server(streams: MessageStream) -> None:
49+
server_read, server_write = streams
50+
message = await server_read.receive()
51+
assert isinstance(message, SessionMessage)
52+
request = message.message
53+
assert isinstance(request, JSONRPCRequest)
54+
assert request.method == "tools/list"
55+
received.append(request)
56+
result = ListToolsResult(tools=[], cache_scope="public", ttl_ms=0)
57+
await server_write.send(
58+
SessionMessage(
59+
JSONRPCResponse(
60+
jsonrpc="2.0",
61+
id=request.id,
62+
# Serialized exactly as a real server serializes results onto the wire.
63+
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
64+
)
65+
)
66+
)
67+
68+
async with (
69+
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
70+
anyio.create_task_group() as tg,
71+
ClientSession(
72+
client_read,
73+
client_write,
74+
client_info=Implementation(name="pin-client", version="1.0.0"),
75+
protocol_version="2026-07-28",
76+
) as session,
77+
):
78+
tg.start_soon(scripted_server, server_streams)
79+
with anyio.fail_after(5):
80+
result = await session.list_tools()
81+
assert isinstance(result, ListToolsResult)
82+
83+
assert len(received) == 1
84+
only = received[0]
85+
assert only.params is not None
86+
assert only.params["_meta"] == snapshot(
87+
{
88+
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
89+
"io.modelcontextprotocol/clientInfo": {"name": "pin-client", "version": "1.0.0"},
90+
"io.modelcontextprotocol/clientCapabilities": {},
91+
}
92+
)
93+
94+
95+
@requirement("lifecycle:stateless:no-initialize")
96+
async def test_initialize_on_a_pinned_session_is_rejected_before_any_frame_is_sent() -> None:
97+
"""``initialize()`` on a pinned session raises immediately, never reaching the wire.
98+
99+
After the rejection the client's send stream is closed and the server-side read drains to
100+
EndOfStream with no buffered frame, proving the guard fired before any write.
101+
"""
102+
async with create_client_server_memory_streams() as ((client_read, client_write), (server_read, _server_write)):
103+
async with ClientSession(client_read, client_write, protocol_version="2026-07-28") as session:
104+
with anyio.fail_after(5):
105+
with pytest.raises(RuntimeError) as exc_info:
106+
await session.initialize()
107+
assert str(exc_info.value) == snapshot(
108+
"initialize() must not be called on a session pinned to a stateless protocol version"
109+
)
110+
# Nothing left the client: closing the sender turns an empty buffer into EndOfStream.
111+
await client_write.aclose()
112+
with anyio.fail_after(5):
113+
with pytest.raises(anyio.EndOfStream):
114+
await server_read.receive()
115+
116+
117+
@requirement("lifecycle:stateless:caller-meta-preserved")
118+
async def test_caller_supplied_meta_is_preserved_under_the_envelope_merge() -> None:
119+
"""A caller's ``meta=`` keys survive the pinned session's envelope stamp on the same ``_meta`` object.
120+
121+
The envelope merge is additive, so a caller-supplied key sits alongside the three
122+
``io.modelcontextprotocol/*`` keys rather than being overwritten. The scripted peer captures the
123+
single ``tools/call`` frame and answers with an ``is_error`` result so the client skips its
124+
implicit output-schema fetch; the test then snapshots the captured ``params._meta``.
125+
"""
126+
received: list[JSONRPCRequest] = []
127+
128+
async def scripted_server(streams: MessageStream) -> None:
129+
server_read, server_write = streams
130+
message = await server_read.receive()
131+
assert isinstance(message, SessionMessage)
132+
request = message.message
133+
assert isinstance(request, JSONRPCRequest)
134+
assert request.method == "tools/call"
135+
received.append(request)
136+
result = CallToolResult(content=[], is_error=True)
137+
await server_write.send(
138+
SessionMessage(
139+
JSONRPCResponse(
140+
jsonrpc="2.0",
141+
id=request.id,
142+
# Serialized exactly as a real server serializes results onto the wire.
143+
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
144+
)
145+
)
146+
)
147+
148+
async with (
149+
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
150+
anyio.create_task_group() as tg,
151+
ClientSession(
152+
client_read,
153+
client_write,
154+
client_info=Implementation(name="pin-client", version="1.0.0"),
155+
protocol_version="2026-07-28",
156+
) as session,
157+
):
158+
tg.start_soon(scripted_server, server_streams)
159+
with anyio.fail_after(5):
160+
result = await session.call_tool("add", {"a": 2, "b": 3}, meta={"custom-key": "x"})
161+
assert isinstance(result, CallToolResult)
162+
163+
assert len(received) == 1
164+
only = received[0]
165+
assert only.params is not None
166+
assert only.params["_meta"] == snapshot(
167+
{
168+
"custom-key": "x",
169+
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
170+
"io.modelcontextprotocol/clientInfo": {"name": "pin-client", "version": "1.0.0"},
171+
"io.modelcontextprotocol/clientCapabilities": {},
172+
}
173+
)
174+
175+
176+
@requirement("lifecycle:stateless:unpinned-legacy-wire")
177+
async def test_unpinned_session_round_trip_carries_no_modern_protocol_vocabulary() -> None:
178+
"""An unpinned session's handshake-plus-request emits no 2026-07-28 vocabulary on any frame.
179+
180+
The JSON-RPC-seam complement to ``test_legacy_wire.py`` (which records the HTTP seam): a
181+
``RecordingTransport`` wrapped around the client side of the in-process memory streams captures
182+
every frame in either direction, the scripted peer answers ``initialize`` at ``2025-11-25`` then
183+
``tools/list``, and every captured frame body is scanned for :data:`MODERN_BODY_TOKENS` so any
184+
leak of the envelope keys, the result-envelope fields, or the version literal onto the legacy
185+
session path fails here.
186+
"""
187+
188+
async def scripted_server(streams: MessageStream) -> None:
189+
server_read, server_write = streams
190+
init = await server_read.receive()
191+
assert isinstance(init, SessionMessage)
192+
assert isinstance(init.message, JSONRPCRequest)
193+
assert init.message.method == "initialize"
194+
result = InitializeResult(
195+
protocol_version="2025-11-25",
196+
capabilities=ServerCapabilities(),
197+
server_info=Implementation(name="legacy-server", version="0.0.0"),
198+
)
199+
await server_write.send(
200+
SessionMessage(
201+
JSONRPCResponse(
202+
jsonrpc="2.0",
203+
id=init.message.id,
204+
# Serialized exactly as a real server serializes results onto the wire.
205+
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
206+
)
207+
)
208+
)
209+
initialized = await server_read.receive()
210+
assert isinstance(initialized, SessionMessage)
211+
assert isinstance(initialized.message, JSONRPCNotification)
212+
listing = await server_read.receive()
213+
assert isinstance(listing, SessionMessage)
214+
assert isinstance(listing.message, JSONRPCRequest)
215+
assert listing.message.method == "tools/list"
216+
await server_write.send(
217+
SessionMessage(JSONRPCResponse(jsonrpc="2.0", id=listing.message.id, result={"tools": []}))
218+
)
219+
220+
async with create_client_server_memory_streams() as (client_streams, server_streams):
221+
recording = RecordingTransport(nullcontext(client_streams))
222+
async with (
223+
anyio.create_task_group() as tg,
224+
recording as (client_read, client_write),
225+
ClientSession(client_read, client_write) as session,
226+
):
227+
tg.start_soon(scripted_server, server_streams)
228+
with anyio.fail_after(5):
229+
await session.initialize()
230+
result = await session.list_tools()
231+
assert isinstance(result, ListToolsResult)
232+
233+
frames = list(recording.sent) + [m for m in recording.received if isinstance(m, SessionMessage)]
234+
methods = [m.message.method for m in recording.sent if isinstance(m.message, JSONRPCRequest | JSONRPCNotification)]
235+
assert methods == snapshot(["initialize", "notifications/initialized", "tools/list"])
236+
bodies = [m.message.model_dump_json(by_alias=True, exclude_none=True) for m in frames]
237+
leaked = sorted({token for token in MODERN_BODY_TOKENS for body in bodies if token in body})
238+
assert leaked == []

0 commit comments

Comments
 (0)