Skip to content

Commit 611dbaf

Browse files
committed
Extend the interaction suite to the 2026-07-28 spec
Take tests/interaction/ from its 2025-11-25 baseline to the 2026-07-28 revision, keeping the suite's rule that every manifest entry is backed by a high-level, public-API scenario test. Manifest groundwork on the existing entries: dead 2026 source URLs repointed at live sections, ids aligned with the typescript-sdk e2e vocabulary, over-claiming behaviour strings narrowed to what their tests prove, and the era pass: behaviours the new revision retired carry removed_in, their replacements carry added_in, and the pairs are linked supersedes / superseded_by (enforced bidirectionally by the coverage gate at import), so no test body branches on a version literal. New coverage, all driven through Client against a server declared inside the test and run across the transport x spec-version matrix where the behaviour allows: multi round-trip requests end to end (the input_required round trip, requestState echo, omission, opacity and the default sealing that rejects a tampered or replayed token, parallel-call isolation, multi-round completion and bounds, all three origin methods, and the MCPServer authoring surface: Resolve with Elicit, Sample and ListRoots dependencies plus input_required pass-through for resources and prompts); the 2026 message-direction rules; server/discover, version modes and the -32022 retry; the per-request envelope; the modern streamable-HTTP entry (response modes, lazy SSE upgrade, the header-validation ladder, cacheable stamping); the x-mcp-header pipeline in both directions; response caching hints; subscriptions/listen (typed events per kind, acknowledgment stamping, per-stream filtering, concurrent demultiplexing, the capacity guard, MCPServer change notifications); stdio serving both eras from the same server; OAuth negotiating the modern era after authenticating, RFC 9207 iss validation, step-up bounds and refresh rotation; resultType handling; JSON Schema 2020-12 output validation; and the era method gate. Where the SDK's current behaviour differs from the spec the test pins today's behaviour and the entry records the divergence with the re-pin instruction in the test docstring. Deferred entries are limited to behaviour the SDK cannot express through its public API, each with a one-line reason. No-Verification-Needed: test-only change
1 parent d2290ca commit 611dbaf

36 files changed

Lines changed: 5976 additions & 474 deletions

tests/_stamp.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@
66
and the strip is strict, so a modern result that lost its stamp fails the
77
test instead of passing silently.
88
9-
The interaction matrix does not use this function directly: its `unstamped`
10-
fixture (tests/interaction/conftest.py) resolves per cell to this strict
9+
Era-parametrized interaction tests go through the `unstamped` fixture
10+
(tests/interaction/conftest.py), which resolves per cell to this strict
1111
strip on modern cells and to a must-not-be-stamped assertion on
12-
handshake-era cells, so one comparison line enforces both eras.
12+
handshake-era cells, so one comparison line enforces both eras. Interaction
13+
tests pinned to a single modern connection call this function directly
14+
(imported as `strip_stamp` where a file also uses the fixture, so the two
15+
never share a name).
1316
"""
1417

1518
from typing import Any, Protocol, TypeVar
@@ -29,8 +32,8 @@ def unstamped(result: R) -> R:
2932
"""Assert the result carries a well-formed serverInfo stamp, then remove it.
3033
3134
Returns the result for inline use in comparisons. Use only where a stamp
32-
is required (a 2026-era result); the interaction matrix's `unstamped`
33-
fixture handles the era split.
35+
is required (a 2026-era result); tests that also run on handshake-era
36+
cells use the `unstamped` fixture instead, which handles the era split.
3437
"""
3538
meta = result.meta
3639
assert meta is not None and SERVER_INFO_META_KEY in meta, "expected a serverInfo stamp on this result"

tests/interaction/README.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ flows — with a single subprocess test for stdio.
4040
```text
4141
tests/interaction/
4242
_requirements.py the requirements manifest (see below)
43-
_helpers.py the wire-recording transport
43+
_helpers.py the wire-recording transport and the tool_listing handler helper
4444
_connect.py the transport-parametrized connection factories
4545
conftest.py the connect fixture (the transport matrix)
4646
test_coverage.py enforces the manifest ↔ test contract
@@ -64,6 +64,12 @@ stateless configurations), and over the legacy SSE transport the same way. A tes
6464
`async with connect(server, ...) as client:` and asserts the same output on every leg, because the
6565
transport is not supposed to change observable behaviour. Requirements that need a server-to-client
6666
back-channel or persisted session state are carved out of the stateless arm via `arm_exclusions`.
67+
68+
The 2026 cells run the client's response cache in its default-on configuration. Servers stamp
69+
`ttlMs: 0` by default, so nothing is served from cache unless a test opts in server-side by
70+
authoring a positive `ttl_ms` — a test that does so and then repeats a call must expect the
71+
repeat to be served from cache instead of reaching the handler.
72+
6773
Tests that are tied to one transport do not use the fixture: the wire-recording tests
6874
(their seam is the in-memory stream pair), the bare-`ClientSession` lifecycle tests, the
6975
real-clock timeout tests (the timeout machinery is transport-independent and must not race
@@ -166,20 +172,19 @@ What admits or excludes a cell:
166172
closes, grep for the reason string to find every cell to re-admit.
167173
- **`known_failures`** keep a cell in the grid but mark it as a strict xfail — the test runs and
168174
must fail; an unexpected pass fails the suite.
169-
- **`TRANSPORT_SPEC_VERSIONS`** era-locks a transport to a subset of spec versions (currently only
170-
`sse` is locked to `2025-11-25`). A `(transport, version)` cell is dropped if the version is not
171-
in the transport's entry; transports absent from the map serve every spec version. This is the
172-
mechanism for cutting an entire transport off from a new revision (or admitting it).
175+
- **`TRANSPORT_SPEC_VERSIONS`** era-locks a transport to a subset of spec versions (currently
176+
`sse` and `streamable-http-stateless` are locked to `2025-11-25`). A `(transport, version)`
177+
cell is dropped if the version is not in the transport's entry; transports absent from the
178+
map serve every spec version. This is the mechanism for cutting an entire transport off from
179+
a new revision (or admitting it).
173180
- **`transports`** is descriptive metadata for the non-`connect` transport-specific suites under
174181
`transports/` and does **not** drive cell generation. Only `arm_exclusions`, `added_in`,
175182
`removed_in`, and `TRANSPORT_SPEC_VERSIONS` filter the grid.
176183
- **`supersedes` / `superseded_by`** link a retired entry to its replacement. `test_coverage.py`
177184
enforces that links are bidirectional and versioned: the retired entry carries `removed_in`, the
178185
replacement carries `added_in`.
179186

180-
Node IDs stay `[transport]` while `len(SPEC_VERSIONS) == 1`, so today's test IDs are
181-
byte-identical to before the era axis existed. They become `[transport-version]` the moment a
182-
second version is appended to `SPEC_VERSIONS`.
187+
Node IDs are `[transport-version]`; with a single-member `SPEC_VERSIONS` they collapse to `[transport]`.
183188

184189
When a new spec revision lands:
185190

tests/interaction/_connect.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
from starlette.responses import Response
3131
from starlette.routing import Mount, Route
3232

33-
from mcp.client.client import Client
33+
from mcp.client.client import Client, ConnectMode
3434
from mcp.client.extension import ClientExtension
3535
from mcp.client.session import ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
3636
from mcp.client.sse import sse_client
@@ -237,6 +237,9 @@ async def mounted_app(
237237
async def client_via_http(
238238
http_client: httpx2.AsyncClient,
239239
*,
240+
mode: ConnectMode = "legacy",
241+
sampling_callback: SamplingFnT | None = None,
242+
list_roots_callback: ListRootsFnT | None = None,
240243
logging_callback: LoggingFnT | None = None,
241244
log_level: LoggingLevel | None = None,
242245
message_handler: MessageHandlerFnT | None = None,
@@ -247,13 +250,17 @@ async def client_via_http(
247250
Use with `mounted_app(...)` so several `Client`s share the one session manager, or so a
248251
client-driven assertion can sit alongside raw-httpx2 assertions in the same test. The
249252
underlying `httpx2.AsyncClient` is left open when the `Client` exits.
253+
254+
`mode` defaults to "legacy" because most callers assert the handshake-era HTTP shape
255+
(session id, standalone GET, closing DELETE); pass a modern version to pin the 2026-07-28
256+
per-request flow instead (no probe, no `prior_discover` needed: the client synthesizes one).
250257
"""
251258
transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client)
252259
async with Client(
253260
transport,
254-
# Callers assert the legacy HTTP wire shape (session-id header, standalone GET stream,
255-
# closing DELETE); the modern flow is sessionless and would silently change the subject.
256-
mode="legacy",
261+
mode=mode,
262+
sampling_callback=sampling_callback,
263+
list_roots_callback=list_roots_callback,
257264
logging_callback=logging_callback,
258265
log_level=log_level,
259266
message_handler=message_handler,
@@ -397,7 +404,9 @@ def httpx_client_factory(
397404
transport = sse_client(f"{BASE_URL}/sse", httpx_client_factory=httpx_client_factory)
398405
async with Client(
399406
transport,
400-
# SSE is a legacy-only transport; the modern path has no SSE story.
407+
# A policy lock, not a capability one: the dual-era server loop behind build_sse_app
408+
# would negotiate 2026 if probed, but SSE is the deprecated legacy transport and its
409+
# clients run the handshake era by design.
401410
mode="legacy",
402411
read_timeout_seconds=read_timeout_seconds,
403412
sampling_callback=sampling_callback,

tests/interaction/_helpers.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,38 @@
11
"""Shared helpers for the interaction suite.
22
3-
Keep this module small: it exists only for the recording transport used by the wire-level
4-
tests. Server fixtures and assertion helpers belong in the test that uses them.
3+
Keep this module small: it holds the recording transport used by the wire-level tests and the
4+
`tool_listing` handler factory. Servers and assertion helpers belong in the test that uses them.
55
"""
66

7+
from collections.abc import Awaitable, Callable
78
from types import TracebackType
9+
from typing import Any
810

911
import anyio
12+
from mcp_types import ListToolsResult, PaginatedRequestParams, Tool
1013
from typing_extensions import Self
1114

1215
from mcp.client._transport import ReadStream, Transport, TransportStreams, WriteStream
16+
from mcp.server import ServerRequestContext
1317
from mcp.shared.message import SessionMessage
1418

1519

20+
def tool_listing(
21+
*tools: Tool | str,
22+
) -> Callable[[ServerRequestContext[Any, Any], PaginatedRequestParams | None], Awaitable[ListToolsResult]]:
23+
"""An `on_list_tools` handler advertising `tools`; a bare name becomes an argument-less tool.
24+
25+
`Client.call_tool` lists tools once per connection to learn their output schemas, so a server
26+
whose tools are called needs a tools/list handler even when the test never lists.
27+
"""
28+
listed = [Tool(name=t, input_schema={"type": "object"}) if isinstance(t, str) else t for t in tools]
29+
30+
async def list_tools(ctx: ServerRequestContext[Any, Any], params: PaginatedRequestParams | None) -> ListToolsResult:
31+
return ListToolsResult(tools=listed)
32+
33+
return list_tools
34+
35+
1636
class _RecordingReadStream:
1737
"""Delegates to a read stream, appending every received message to a log."""
1838

0 commit comments

Comments
 (0)