Skip to content

Commit cf1df26

Browse files
committed
Tighten the new interaction tests after review
Prove the bare input_required handler actually ran before asserting the rejection; record listen frames unconditionally in the message handler and check the subscription-id stamp outside it, so an unstamped frame fails the test instead of being swallowed by the session's handler guard; run the path-traversal rejection on every era, since the server applies it on handshake connections too; fold the two hand-rolled loud-fail twins into a parametrized fixture test; drop unused client_via_http parameters; say plainly that the max-total-timeout behaviour has no test yet; and make the README's example test one that passes as written. No-Verification-Needed: test-only change
1 parent 611dbaf commit cf1df26

6 files changed

Lines changed: 44 additions & 128 deletions

File tree

tests/interaction/README.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -205,22 +205,27 @@ The shortest complete example of the conventions:
205205

206206
```python
207207
@requirement("tools:call:content:text")
208-
async def test_call_tool_returns_text_content() -> None:
208+
async def test_call_tool_returns_text_content(connect: Connect, unstamped: Unstamp) -> None:
209209
"""Arguments reach the tool handler; its content comes back as the call result."""
210210

211211
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
212212
assert params.name == "add"
213213
assert params.arguments is not None
214214
return CallToolResult(content=[TextContent(text=str(params.arguments["a"] + params.arguments["b"]))])
215215

216-
server = Server("adder", on_call_tool=call_tool)
216+
server = Server("adder", on_list_tools=tool_listing("add"), on_call_tool=call_tool)
217217

218-
async with Client(server) as client:
218+
async with connect(server) as client:
219219
result = await client.call_tool("add", {"a": 2, "b": 3})
220220

221-
assert result == snapshot(CallToolResult(content=[TextContent(text="5")]))
221+
assert unstamped(result) == snapshot(CallToolResult(content=[TextContent(text="5")]))
222222
```
223223

224+
The server needs a tools/list handler even though the test never lists: `Client.call_tool`
225+
refreshes tools/list once per connection to validate output schemas, so a `Server` without one
226+
fails the call (`tool_listing` from `_helpers.py` is the one-line handler). `unstamped` strips the
227+
2026-era serverInfo `_meta` stamp so one expected payload holds on every cell.
228+
224229
- **The server is defined inside the test** (or in a small fixture at the top of the file when
225230
several tests genuinely share it). The whole observable behaviour fits on one screen.
226231
- **Test names are behaviour sentences** — they state the observable outcome, not the feature

tests/interaction/_connect.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -238,8 +238,6 @@ async def client_via_http(
238238
http_client: httpx2.AsyncClient,
239239
*,
240240
mode: ConnectMode = "legacy",
241-
sampling_callback: SamplingFnT | None = None,
242-
list_roots_callback: ListRootsFnT | None = None,
243241
logging_callback: LoggingFnT | None = None,
244242
log_level: LoggingLevel | None = None,
245243
message_handler: MessageHandlerFnT | None = None,
@@ -259,8 +257,6 @@ async def client_via_http(
259257
async with Client(
260258
transport,
261259
mode=mode,
262-
sampling_callback=sampling_callback,
263-
list_roots_callback=list_roots_callback,
264260
logging_callback=logging_callback,
265261
log_level=log_level,
266262
message_handler=message_handler,

tests/interaction/_requirements.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -950,9 +950,9 @@ def __post_init__(self) -> None:
950950
source=f"{SPEC_BASE_URL}/basic/lifecycle#timeouts",
951951
behavior="A maximum total timeout is enforced even when progress notifications keep arriving.",
952952
deferred=(
953-
"Not yet covered here: the per-request read timeout is the enforced maximum, since it does not reset "
954-
"when progress notifications arrive (reset-on-progress is not offered); the test drives a continuously "
955-
"progressing tool against a short read timeout."
953+
"Not yet covered here: the per-request read timeout is the enforced maximum and does not reset when "
954+
"progress notifications arrive, so a call whose handler keeps reporting progress still fails with "
955+
"-32001 once the read timeout elapses; reset-on-progress is not offered. No test drives this yet."
956956
),
957957
),
958958
"protocol:timeout:reset-on-progress": Requirement(
@@ -1630,12 +1630,11 @@ def __post_init__(self) -> None:
16301630
"parameter is rejected with a JSON-RPC error and the resource function is never "
16311631
"invoked."
16321632
),
1633-
added_in="2026-07-28",
16341633
note=(
16351634
"New security MUST in 2026-07-28. MCPServer's default resource-security policy rejects traversal, "
16361635
"absolute-path and null-byte parameter values when matching the template and reports the same -32602 "
16371636
"'Unknown resource' error as a non-match, so the wire offers no probing oracle; the SDK applies the "
1638-
"policy on 2025-11-25 connections too."
1637+
"policy on 2025-11-25 connections too, so the entry carries no version window."
16391638
),
16401639
),
16411640
"resources:read:template-vars": Requirement(

tests/interaction/lowlevel/test_mrtr.py

Lines changed: 15 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -277,8 +277,10 @@ async def test_input_required_result_with_neither_field_cannot_reach_the_client(
277277
ValidationError to the same SDK-defined invalid-params error, so one snapshot serves both cells.
278278
"""
279279

280+
calls: list[str] = []
281+
280282
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> InputRequiredResult:
281-
assert params.name == "bare"
283+
calls.append(params.name)
282284
# Statically legal (both fields default None); raises pydantic's ValidationError here.
283285
return InputRequiredResult()
284286

@@ -291,6 +293,8 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
291293
assert exc_info.value.error == snapshot(
292294
ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="")
293295
)
296+
# The handler ran: the error is its ValidationError, not a pre-dispatch params rejection.
297+
assert calls == ["bare"]
294298

295299

296300
@requirement("mrtr:input-responses:key-correspondence")
@@ -446,23 +450,30 @@ async def call_tool(
446450

447451

448452
@requirement("mrtr:push-api:loud-fail-2026")
453+
@pytest.mark.parametrize("request_scoped", [False, True], ids=["no-related-request-id", "related-request-id"])
449454
async def test_push_elicit_on_2026_raises_typed_local_error_and_call_still_completes(
450-
connect: Connect, unstamped: Unstamp
455+
connect: Connect, unstamped: Unstamp, request_scoped: bool
451456
) -> None:
452457
"""A push API call on a 2026 connection raises a typed local error and the call still completes.
453458
454459
Spec-mandated outcome, era-routed enforcement: every modern dispatch path installs a
455460
channel-less context by construction, so the gate is "no back-channel", never a send-time
456461
era check. One push API stands for all four: they share ServerSession.send_request's
457-
channel selection.
462+
channel selection. The request-scoped variant passes the originating request id, which
463+
routes the send onto the per-request dispatch channel (the one leg otherwise live in
464+
memory), so both channel selections prove local provenance: the typed NoBackChannelError
465+
and a callback that never fires.
458466
"""
459467
caught: list[NoBackChannelError] = []
460468

461469
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
462470
assert params.name == "ask"
471+
assert ctx.request_id is not None
472+
related_request_id = ctx.request_id if request_scoped else None
463473
try:
464-
await ctx.session.elicit_form("Need a name", _NAME_SCHEMA)
474+
await ctx.session.elicit_form("Need a name", _NAME_SCHEMA, related_request_id=related_request_id)
465475
except NoBackChannelError as exc:
476+
# Narrow on purpose: a peer-answered MCPError would propagate and fail the test.
466477
caught.append(exc)
467478
return CallToolResult(content=[TextContent(text="fallback")])
468479

@@ -490,53 +501,6 @@ async def never_deliverable(context: ClientRequestContext, params: types.ElicitR
490501
)
491502

492503

493-
@requirement("mrtr:push-api:loud-fail-2026")
494-
async def test_request_scoped_push_elicit_on_in_memory_2026_loud_fails_locally_and_the_call_still_completes() -> None:
495-
"""A request-scoped push elicit on in-memory 2026 loud-fails locally and the call still completes.
496-
497-
The related id routes the send onto the per-request dispatch channel -- the one leg whose
498-
channel is otherwise live in-memory -- so this pin proves local provenance: the typed
499-
NoBackChannelError (never a peer answer) and a callback that never fires. A delivered frame
500-
would raise NotImplementedError in the callback, surface as a non-NoBackChannelError error,
501-
escape the narrowed except, and fail the test loudly.
502-
"""
503-
caught: list[NoBackChannelError] = []
504-
505-
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
506-
assert params.name == "ask"
507-
assert ctx.request_id is not None
508-
try:
509-
# The related id routes the send onto the per-request dispatch channel.
510-
await ctx.session.elicit_form("Need a name", _NAME_SCHEMA, related_request_id=ctx.request_id)
511-
except NoBackChannelError as exc:
512-
# Narrow on purpose: a peer-answered MCPError would propagate and fail the test.
513-
caught.append(exc)
514-
return CallToolResult(content=[TextContent(text="fallback")])
515-
516-
server = Server("scoped-push", on_list_tools=tool_listing("ask"), on_call_tool=call_tool)
517-
518-
# Registering the callback declares the elicitation capability; it must never fire.
519-
async def never_deliverable(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
520-
raise NotImplementedError
521-
522-
async with Client(server, mode=LATEST_MODERN_VERSION, elicitation_callback=never_deliverable) as client:
523-
result = await client.call_tool("ask", {})
524-
525-
# The failed push did not poison the request: the call completes with the handler's fallback.
526-
assert strip_stamp(result) == snapshot(CallToolResult(content=[TextContent(text="fallback")]))
527-
assert len(caught) == 1
528-
assert caught[0].method == "elicitation/create"
529-
assert caught[0].error == snapshot(
530-
ErrorData(
531-
code=INVALID_REQUEST,
532-
message=(
533-
"Cannot send 'elicitation/create': this transport context has no back-channel "
534-
"for server-initiated requests."
535-
),
536-
)
537-
)
538-
539-
540504
@requirement("sampling:mrtr:capability:not-declared")
541505
async def test_sampling_request_embedded_for_a_non_sampling_client_is_sent_and_refused_client_side(
542506
connect: Connect,

tests/interaction/lowlevel/test_subscriptions.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@
1818
pytestmark = pytest.mark.anyio
1919

2020

21+
def _stamps(teed: list[IncomingMessage]) -> list[tuple[str, object]]:
22+
"""(method, subscriptionId stamp) per teed frame, checked here because the session swallows a raising handler."""
23+
stamps: list[tuple[str, object]] = []
24+
for message in teed:
25+
assert isinstance(message, types.ServerNotification)
26+
assert message.params is not None
27+
stamps.append((message.method, (message.params.meta or {}).get(SUBSCRIPTION_ID_META_KEY)))
28+
return stamps
29+
30+
2131
@requirement("subscriptions:listen:graceful-close")
2232
async def test_a_graceful_server_close_ends_iteration_after_buffered_events(connect: Connect) -> None:
2333
"""`ListenHandler.close()` sends the result last; iteration drains published events, then ends cleanly."""
@@ -157,12 +167,10 @@ async def test_every_event_frame_on_a_listen_stream_is_stamped_with_the_listen_r
157167
bus = InMemorySubscriptionBus()
158168
handler = ListenHandler(bus)
159169
server = Server("subs", on_subscriptions_listen=handler)
160-
stamped: list[tuple[str, object]] = []
170+
teed: list[IncomingMessage] = []
161171

162172
async def record(message: IncomingMessage) -> None:
163-
assert isinstance(message, types.ServerNotification)
164-
assert message.params is not None
165-
stamped.append((message.method, (message.params.meta or {})[SUBSCRIPTION_ID_META_KEY]))
173+
teed.append(message)
166174

167175
async with connect(server, message_handler=record) as client:
168176
with anyio.fail_after(5):
@@ -173,7 +181,7 @@ async def record(message: IncomingMessage) -> None:
173181
events = [event async for event in sub]
174182
assert events == [ToolsListChanged(), PromptsListChanged()]
175183

176-
assert stamped == [
184+
assert _stamps(teed) == [
177185
("notifications/tools/list_changed", sub.subscription_id),
178186
("notifications/prompts/list_changed", sub.subscription_id),
179187
]
@@ -192,12 +200,10 @@ async def test_concurrent_streams_each_yield_only_the_kinds_their_own_filter_req
192200
bus = InMemorySubscriptionBus()
193201
handler = ListenHandler(bus)
194202
server = Server("subs", on_subscriptions_listen=handler)
195-
stamped: list[tuple[str, object]] = []
203+
teed: list[IncomingMessage] = []
196204

197205
async def record(message: IncomingMessage) -> None:
198-
assert isinstance(message, types.ServerNotification)
199-
assert message.params is not None
200-
stamped.append((message.method, (message.params.meta or {})[SUBSCRIPTION_ID_META_KEY]))
206+
teed.append(message)
201207

202208
async with connect(server, message_handler=record) as client:
203209
with anyio.fail_after(5):
@@ -213,6 +219,7 @@ async def record(message: IncomingMessage) -> None:
213219
assert tools_events == [ToolsListChanged()]
214220
assert prompts_events == [PromptsListChanged()]
215221

222+
stamped = _stamps(teed)
216223
assert len(stamped) == 2
217224
assert set(stamped) == {
218225
("notifications/prompts/list_changed", prompts_sub.subscription_id),

tests/interaction/transports/test_hosting_http_modern.py

Lines changed: 0 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,6 @@
2828
CallToolRequestParams,
2929
CallToolResult,
3030
DiscoverResult,
31-
ElicitRequestParams,
32-
ElicitResult,
3331
EmptyResult,
3432
ErrorData,
3533
GetPromptRequestParams,
@@ -61,11 +59,9 @@
6159
from starlette.requests import Request as StarletteRequest
6260

6361
from mcp import MCPError
64-
from mcp.client import ClientRequestContext
6562
from mcp.client.session import ClientSession
6663
from mcp.client.streamable_http import streamable_http_client
6764
from mcp.server import Server, ServerRequestContext
68-
from mcp.shared.exceptions import NoBackChannelError
6965
from tests._stamp import unstamped as strip_stamp
7066
from tests.interaction._connect import (
7167
BASE_URL,
@@ -1108,54 +1104,3 @@ async def get_prompt(ctx: ServerRequestContext, params: GetPromptRequestParams)
11081104
assert strip_stamp(result) == snapshot(
11091105
GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="bonjour"))])
11101106
)
1111-
1112-
1113-
@requirement("mrtr:push-api:loud-fail-2026")
1114-
async def test_modern_request_scoped_push_elicit_loud_fails_locally_and_the_call_still_completes() -> None:
1115-
"""A request-scoped push elicit over the modern HTTP entry loud-fails locally and the call still completes.
1116-
1117-
Spec-mandated outcome: the modern HTTP entry builds its per-request channel with no
1118-
back-channel, so the refusal is local by construction. The in-memory twin of this leg is
1119-
pinned in lowlevel/test_mrtr.py; this pin keeps the HTTP entry's own gate regression-covered.
1120-
"""
1121-
caught: list[NoBackChannelError] = []
1122-
1123-
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
1124-
assert params.name == "ask"
1125-
assert ctx.request_id is not None
1126-
try:
1127-
# The related id selects the per-request dispatch channel.
1128-
await ctx.session.elicit_form(
1129-
"Need a name",
1130-
{"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]},
1131-
related_request_id=ctx.request_id,
1132-
)
1133-
except NoBackChannelError as exc:
1134-
caught.append(exc)
1135-
return CallToolResult(content=[TextContent(text="fallback")])
1136-
1137-
server = Server("scoped-push", on_list_tools=tool_listing("ask"), on_call_tool=call_tool)
1138-
1139-
# Declares the elicitation capability, isolating the failure to the missing back-channel.
1140-
async def never_deliverable(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
1141-
raise NotImplementedError
1142-
1143-
with anyio.fail_after(5):
1144-
async with (
1145-
mounted_app(server) as (http, _),
1146-
client_via_http(http, mode=LATEST_MODERN_VERSION, elicitation_callback=never_deliverable) as client,
1147-
):
1148-
result = await client.call_tool("ask", {})
1149-
1150-
assert strip_stamp(result) == snapshot(CallToolResult(content=[TextContent(text="fallback")]))
1151-
assert len(caught) == 1
1152-
assert caught[0].method == "elicitation/create"
1153-
assert caught[0].error == snapshot(
1154-
ErrorData(
1155-
code=INVALID_REQUEST,
1156-
message=(
1157-
"Cannot send 'elicitation/create': this transport context has no back-channel "
1158-
"for server-initiated requests."
1159-
),
1160-
)
1161-
)

0 commit comments

Comments
 (0)