Skip to content

Commit 9a0881f

Browse files
committed
Interaction matrix: era-aware unstamped fixture
The shared strip helper was if-present, so a modern cell whose result lost its serverInfo stamp would still pass. The helper is now strict (stamp must be present and well-formed before it is stripped), and the interaction matrix resolves 'unstamped' as a fixture keyed on the cell's spec_version: modern cells assert-and-strip, handshake-era cells assert the result was never stamped. Every existing comparison site now enforces the stamp contract in both directions. The connect fixture returns a CellConnect callable that names its cell's spec_version, so sibling fixtures can key on the era without re-deriving it. No-Verification-Needed: test-only change
1 parent d1258af commit 9a0881f

17 files changed

Lines changed: 156 additions & 95 deletions

tests/_stamp.py

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,43 @@
1-
"""Shared helper: drop the 2026-era serverInfo `_meta` stamp from a result.
1+
"""Shared helper: strip the 2026-era serverInfo `_meta` stamp from a result.
22
33
Servers stamp `io.modelcontextprotocol/serverInfo` into every 2026-era
44
result's `_meta` and never into handshake-era ones, and the stamp's `version`
5-
defaults to the installed package version. Suites that share one expected
6-
payload across eras (the interaction matrix) or that construct servers
7-
without a pinned version strip the stamp before exact comparison. Stamp
8-
semantics themselves have dedicated coverage in tests/server/test_runner.py.
5+
defaults to the installed package version. Suites whose expected payloads
6+
should stay identity-free strip the stamp before exact comparison - and the
7+
strip is strict, so a modern result that lost its stamp fails the test
8+
instead of passing silently.
9+
10+
The interaction matrix does not use this function directly: its `unstamped`
11+
fixture (tests/interaction/conftest.py) resolves per cell to this strict
12+
strip on modern cells and to a must-not-be-stamped assertion on
13+
handshake-era cells, so one comparison line enforces both eras.
914
"""
1015

11-
from typing import Any, TypeVar
16+
from typing import Any, Protocol, TypeVar
1217

1318
from mcp_types import SERVER_INFO_META_KEY, Result
1419

1520
R = TypeVar("R", bound=Result)
1621

1722

23+
class Unstamp(Protocol):
24+
"""An era-appropriate stamp normalizer: strips or forbids the stamp."""
25+
26+
def __call__(self, result: R) -> R: ...
27+
28+
1829
def unstamped(result: R) -> R:
19-
"""Remove the serverInfo stamp in place if present, asserting its shape.
30+
"""Assert the result carries a well-formed serverInfo stamp, then remove it.
2031
21-
Present-versus-absent is era-dependent and belongs to the runner tests;
22-
this only guarantees that when a stamp exists it is a well-formed
23-
identity object, then returns the result for inline use in comparisons.
32+
Returns the result for inline use in comparisons. Use only where a stamp
33+
is required (a 2026-era result); the interaction matrix's `unstamped`
34+
fixture handles the era split.
2435
"""
2536
meta = result.meta
26-
if meta is not None and SERVER_INFO_META_KEY in meta:
27-
stamp: Any = meta.pop(SERVER_INFO_META_KEY)
28-
assert isinstance(stamp, dict)
29-
assert "name" in stamp and "version" in stamp
30-
if not meta:
31-
result.meta = None
37+
assert meta is not None and SERVER_INFO_META_KEY in meta, "expected a serverInfo stamp on this result"
38+
stamp: Any = meta.pop(SERVER_INFO_META_KEY)
39+
assert isinstance(stamp, dict)
40+
assert "name" in stamp and "version" in stamp
41+
if not meta:
42+
result.meta = None
3243
return result

tests/interaction/conftest.py

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,14 @@
1010
from typing import Any
1111

1212
import pytest
13+
from mcp_types import SERVER_INFO_META_KEY
14+
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
1315

1416
from mcp.client.client import Client
1517
from mcp.server import Server
1618
from mcp.server.mcpserver import MCPServer
19+
from tests._stamp import R, Unstamp
20+
from tests._stamp import unstamped as _strip_required_stamp
1721
from tests.interaction._connect import (
1822
Connect,
1923
connect_in_memory,
@@ -39,26 +43,56 @@ def pytest_generate_tests(metafunc: pytest.Metafunc) -> None:
3943
metafunc.parametrize("connect", compute_cells(requirements), indirect=True)
4044

4145

42-
@pytest.fixture
43-
def connect(request: pytest.FixtureRequest) -> Connect:
44-
"""The transport-parametrized connection factory: a test using it runs once per matrix cell.
46+
class CellConnect:
47+
"""The cell's connection factory, also naming the cell's `spec_version`.
4548
46-
Tests that are tied to one transport (the wire-recording tests, the bare-ClientSession tests,
47-
the transport-specific tests under transports/) do not use this fixture and connect directly.
49+
Callable exactly like the `Connect` factories it wraps; the attribute lets
50+
sibling fixtures (`unstamped`) key on the cell's era without re-deriving it.
4851
"""
49-
transport, spec_version = request.param
50-
assert isinstance(transport, str)
51-
assert isinstance(spec_version, str)
52-
factory = _FACTORIES[transport]
5352

54-
def _connect(server: Server | MCPServer, **kwargs: Any) -> AbstractAsyncContextManager[Client]:
53+
def __init__(self, factory: Connect, spec_version: str) -> None:
54+
self._factory = factory
55+
self.spec_version = spec_version
56+
57+
def __call__(self, server: Server | MCPServer, **kwargs: Any) -> AbstractAsyncContextManager[Client]:
5558
# The matrix compares exact result payloads, and the 2026-era serverInfo
5659
# `_meta` stamp carries the server version, which defaults to the
5760
# commit-dependent installed package version. Pin it so expected
5861
# payloads stay deterministic across commits.
5962
lowlevel = server._lowlevel_server if isinstance(server, MCPServer) else server
6063
if lowlevel.version is None:
6164
lowlevel.version = "1.0.0"
62-
return factory(server, spec_version=spec_version, **kwargs)
65+
return self._factory(server, spec_version=self.spec_version, **kwargs)
66+
67+
68+
@pytest.fixture
69+
def connect(request: pytest.FixtureRequest) -> CellConnect:
70+
"""The transport-parametrized connection factory: a test using it runs once per matrix cell.
71+
72+
Tests that are tied to one transport (the wire-recording tests, the bare-ClientSession tests,
73+
the transport-specific tests under transports/) do not use this fixture and connect directly.
74+
"""
75+
transport, spec_version = request.param
76+
assert isinstance(transport, str)
77+
assert isinstance(spec_version, str)
78+
return CellConnect(_FACTORIES[transport], spec_version)
79+
80+
81+
@pytest.fixture
82+
def unstamped(connect: CellConnect) -> Unstamp:
83+
"""The cell's era-aware serverInfo-stamp normalizer, for full-result comparisons.
84+
85+
On a modern cell the stamp MUST be present (asserted) and is stripped so
86+
one expected payload stays valid across eras; on a handshake-era cell the
87+
result must not be stamped at all. Either direction failing is a runner
88+
regression, so the same comparison line enforces both.
89+
"""
90+
if connect.spec_version in MODERN_PROTOCOL_VERSIONS:
91+
return _strip_required_stamp
92+
93+
def _assert_never_stamped(result: R) -> R:
94+
meta = result.meta
95+
assert meta is None or SERVER_INFO_META_KEY not in meta, "handshake-era results are never stamped"
96+
return result
6397

64-
return _connect
98+
return _assert_never_stamped

tests/interaction/lowlevel/test_cancellation.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
from mcp.server import Server, ServerRequestContext
3434
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
3535
from mcp.shared.message import SessionMessage
36-
from tests._stamp import unstamped
36+
from tests._stamp import Unstamp
3737
from tests.interaction._connect import Connect
3838
from tests.interaction._helpers import IncomingMessage
3939
from tests.interaction._requirements import requirement
@@ -138,7 +138,7 @@ async def call_and_swallow_cancellation_error() -> None:
138138

139139

140140
@requirement("protocol:cancel:unknown-id-ignored")
141-
async def test_cancellation_for_unknown_request_is_ignored(connect: Connect) -> None:
141+
async def test_cancellation_for_unknown_request_is_ignored(connect: Connect, unstamped: Unstamp) -> None:
142142
"""A cancellation referencing a request id that is not in flight is ignored without error."""
143143

144144
async def list_tools(
@@ -351,7 +351,7 @@ async def scripted_server(streams: MessageStream) -> None:
351351

352352

353353
@requirement("protocol:cancel:abort-signal")
354-
async def test_abandoning_a_call_stops_the_server_handler(connect: Connect) -> None:
354+
async def test_abandoning_a_call_stops_the_server_handler(connect: Connect, unstamped: Unstamp) -> None:
355355
"""Cancelling the task that awaits a call cancels the request itself, not just the local wait:
356356
the server-side handler is interrupted, and the session serves later requests normally.
357357
@@ -402,7 +402,7 @@ async def call_and_abandon() -> None:
402402

403403

404404
@requirement("protocol:cancel:abort-scoped")
405-
async def test_abandoning_one_call_leaves_a_concurrent_call_running(connect: Connect) -> None:
405+
async def test_abandoning_one_call_leaves_a_concurrent_call_running(connect: Connect, unstamped: Unstamp) -> None:
406406
"""Cancellation is scoped to the request it names: with two calls genuinely in flight,
407407
abandoning the first interrupts only its handler and the second returns its result.
408408

tests/interaction/lowlevel/test_completion.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
from mcp import MCPError
1717
from mcp.server import Server, ServerRequestContext
18-
from tests._stamp import unstamped
18+
from tests._stamp import Unstamp
1919
from tests.interaction._connect import Connect
2020
from tests.interaction._requirements import requirement
2121

@@ -24,7 +24,7 @@
2424

2525
@requirement("completion:prompt-arg")
2626
@requirement("completion:result-shape")
27-
async def test_complete_prompt_argument(connect: Connect) -> None:
27+
async def test_complete_prompt_argument(connect: Connect, unstamped: Unstamp) -> None:
2828
"""Completing a prompt argument delivers the ref, argument name, and current value to the handler.
2929
3030
The returned values are filtered by the argument's value, proving the value reached the handler.
@@ -51,7 +51,7 @@ async def completion(ctx: ServerRequestContext, params: types.CompleteRequestPar
5151

5252

5353
@requirement("completion:resource-template-arg")
54-
async def test_complete_resource_template_variable(connect: Connect) -> None:
54+
async def test_complete_resource_template_variable(connect: Connect, unstamped: Unstamp) -> None:
5555
"""Completing a URI template variable delivers the template URI and variable name to the handler."""
5656

5757
async def completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> CompleteResult:
@@ -72,7 +72,7 @@ async def completion(ctx: ServerRequestContext, params: types.CompleteRequestPar
7272

7373

7474
@requirement("completion:context-arguments")
75-
async def test_complete_receives_context_arguments(connect: Connect) -> None:
75+
async def test_complete_receives_context_arguments(connect: Connect, unstamped: Unstamp) -> None:
7676
"""Previously-resolved arguments passed as completion context reach the handler.
7777
7878
The returned value is derived from the context, proving it arrived.

tests/interaction/lowlevel/test_flows.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
from mcp.client import ClientRequestContext
3333
from mcp.server import Server, ServerRequestContext
3434
from mcp.server.session import ServerSession
35-
from tests._stamp import unstamped
35+
from tests._stamp import Unstamp
3636
from tests.interaction._connect import Connect
3737
from tests.interaction._helpers import IncomingMessage
3838
from tests.interaction._requirements import requirement
@@ -54,7 +54,9 @@ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestPa
5454

5555

5656
@requirement("flow:tool-result:resource-link-follow")
57-
async def test_a_resource_link_returned_by_a_tool_can_be_followed_with_read(connect: Connect) -> None:
57+
async def test_a_resource_link_returned_by_a_tool_can_be_followed_with_read(
58+
connect: Connect, unstamped: Unstamp
59+
) -> None:
5860
"""A tool returns a resource_link; reading that link's URI returns the referenced contents.
5961
6062
Steps: (1) call the tool, (2) extract the link from its content, (3) read_resource on the

tests/interaction/lowlevel/test_logging.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from mcp_types import CallToolResult, EmptyResult, LoggingMessageNotificationParams, TextContent
1212

1313
from mcp.server import Server, ServerRequestContext
14-
from tests._stamp import unstamped
14+
from tests._stamp import Unstamp
1515
from tests.interaction._connect import Connect
1616
from tests.interaction._requirements import requirement
1717

@@ -47,7 +47,7 @@ async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelReq
4747

4848
@requirement("logging:message:fields")
4949
@requirement("tools:call:logging-mid-execution")
50-
async def test_log_messages_reach_logging_callback_in_order(connect: Connect) -> None:
50+
async def test_log_messages_reach_logging_callback_in_order(connect: Connect, unstamped: Unstamp) -> None:
5151
"""Log messages sent during a tool call arrive at the logging callback, in order, before the call returns.
5252
5353
The two messages pin the full notification shape: severity, optional logger name, and both

tests/interaction/lowlevel/test_meta.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from mcp_types import CallToolResult, RequestParamsMeta, TextContent
1212

1313
from mcp.server import Server, ServerRequestContext
14-
from tests._stamp import unstamped
14+
from tests._stamp import Unstamp
1515
from tests.interaction._connect import Connect
1616
from tests.interaction._requirements import requirement
1717

@@ -44,7 +44,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
4444

4545

4646
@requirement("meta:result-to-client")
47-
async def test_result_meta_reaches_client(connect: Connect) -> None:
47+
async def test_result_meta_reaches_client(connect: Connect, unstamped: Unstamp) -> None:
4848
"""The _meta object a handler attaches to its result is delivered to the client unchanged."""
4949
result_meta = {"example.com/cost": 3}
5050

tests/interaction/lowlevel/test_pagination.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,15 @@
2222

2323
from mcp import MCPError
2424
from mcp.server import Server, ServerRequestContext
25-
from tests._stamp import unstamped
25+
from tests._stamp import Unstamp
2626
from tests.interaction._connect import Connect
2727
from tests.interaction._requirements import requirement
2828

2929
pytestmark = pytest.mark.anyio
3030

3131

3232
@requirement("tools:list:pagination")
33-
async def test_next_cursor_round_trips_through_the_client(connect: Connect) -> None:
33+
async def test_next_cursor_round_trips_through_the_client(connect: Connect, unstamped: Unstamp) -> None:
3434
"""The next_cursor a list handler returns reaches the client, and the cursor the client sends
3535
back on the following call reaches the handler verbatim.
3636
"""

tests/interaction/lowlevel/test_progress.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from mcp.server import Server, ServerRequestContext
1919
from mcp.server.session import ServerSession
2020
from mcp.shared.session import ProgressFnT
21-
from tests._stamp import unstamped
21+
from tests._stamp import Unstamp
2222
from tests.interaction._connect import Connect
2323
from tests.interaction._helpers import IncomingMessage
2424
from tests.interaction._requirements import requirement
@@ -28,7 +28,7 @@
2828

2929
@requirement("protocol:progress:callback")
3030
@requirement("tools:call:progress")
31-
async def test_progress_during_tool_call_reaches_callback_in_order(connect: Connect) -> None:
31+
async def test_progress_during_tool_call_reaches_callback_in_order(connect: Connect, unstamped: Unstamp) -> None:
3232
"""Progress notifications emitted by a tool handler reach the caller's progress callback in order."""
3333
received: list[tuple[float, float | None, str | None]] = []
3434

@@ -84,7 +84,7 @@ async def ignore(progress: float, total: float | None, message: str | None) -> N
8484

8585

8686
@requirement("protocol:progress:no-token")
87-
async def test_no_progress_callback_means_no_token(connect: Connect) -> None:
87+
async def test_no_progress_callback_means_no_token(connect: Connect, unstamped: Unstamp) -> None:
8888
"""Without a progress callback the request carries no progress token.
8989
9090
The low-level API has no way to report request-scoped progress without a token, so a handler

tests/interaction/lowlevel/test_prompts.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,15 @@
2121

2222
from mcp import MCPError
2323
from mcp.server import Server, ServerRequestContext
24-
from tests._stamp import unstamped
24+
from tests._stamp import Unstamp
2525
from tests.interaction._connect import Connect
2626
from tests.interaction._requirements import requirement
2727

2828
pytestmark = pytest.mark.anyio
2929

3030

3131
@requirement("prompts:list:basic")
32-
async def test_list_prompts_returns_registered_prompts(connect: Connect) -> None:
32+
async def test_list_prompts_returns_registered_prompts(connect: Connect, unstamped: Unstamp) -> None:
3333
"""The prompts returned by the handler reach the client with their argument declarations intact."""
3434

3535
async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListPromptsResult:
@@ -72,7 +72,7 @@ async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequest
7272

7373

7474
@requirement("prompts:get:with-args")
75-
async def test_get_prompt_substitutes_arguments(connect: Connect) -> None:
75+
async def test_get_prompt_substitutes_arguments(connect: Connect, unstamped: Unstamp) -> None:
7676
"""Arguments supplied by the client reach the prompt handler; the templated message comes back."""
7777

7878
async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> GetPromptResult:
@@ -97,7 +97,7 @@ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestPa
9797

9898

9999
@requirement("prompts:get:multi-message")
100-
async def test_get_prompt_multiple_messages_preserve_roles_and_order(connect: Connect) -> None:
100+
async def test_get_prompt_multiple_messages_preserve_roles_and_order(connect: Connect, unstamped: Unstamp) -> None:
101101
"""A prompt returning a user/assistant conversation reaches the client with roles and order intact."""
102102

103103
async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> GetPromptResult:
@@ -127,7 +127,7 @@ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestPa
127127

128128

129129
@requirement("prompts:get:no-args")
130-
async def test_get_prompt_without_arguments_returns_the_messages(connect: Connect) -> None:
130+
async def test_get_prompt_without_arguments_returns_the_messages(connect: Connect, unstamped: Unstamp) -> None:
131131
"""A prompt fetched with no arguments delivers None as the handler's arguments and returns its messages."""
132132

133133
async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> GetPromptResult:
@@ -148,7 +148,7 @@ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestPa
148148
@requirement("prompts:get:content:image")
149149
@requirement("prompts:get:content:audio")
150150
@requirement("prompts:get:content:embedded-resource")
151-
async def test_get_prompt_with_non_text_content_round_trips(connect: Connect) -> None:
151+
async def test_get_prompt_with_non_text_content_round_trips(connect: Connect, unstamped: Unstamp) -> None:
152152
"""Prompt messages can carry image, audio, and embedded-resource content; all reach the client.
153153
154154
A single full-result snapshot proves all three content types round-trip: each block in the result

0 commit comments

Comments
 (0)