Skip to content

Commit 2f6fee5

Browse files
committed
Address review findings on the 2026-07-28 interaction coverage
Split the unknown-resource-URI requirement by era: the handshake-era entry returns to the 2025 spec's -32002 wording with the SDK's era-agnostic -32602 recorded as a divergence and pinned on the 2025 cells, and a 2026 successor carries the SEP-2164 -32602 rule. Narrow six behaviour or note sentences to what their tests prove (the x-mcp-header type allow-list, -32022 fallback when the supported list names a handshake version, body-derived headers, the listen close stamp, iss comparison against the parsed issuer, X-Accel-Buffering on the handshake transports, the directionality rule with notifications). Bound the rounds-cap test with fail_after, pass an unannotated argument so the Mcp-Param exclusion is actually exercised, seed the no-token-reuse test with an expired token so it guards the discard ordering, qualify the lazy-SSE docstring to the deferral window, and type three request-header handlers for the newer starlette Request generic. No-Verification-Needed: test-only change
1 parent 944b917 commit 2f6fee5

5 files changed

Lines changed: 90 additions & 39 deletions

File tree

tests/interaction/_requirements.py

Lines changed: 45 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,9 @@ def __post_init__(self) -> None:
435435
source=f"{SPEC_2026_BASE_URL}/basic/versioning#protocol-version-negotiation",
436436
behavior=(
437437
"When server/discover returns -32022 UnsupportedProtocolVersion, the client retries once with "
438-
"the intersection of error.data.supported and its own modern versions; an empty intersection raises."
438+
"the intersection of error.data.supported and its own modern versions; an empty intersection raises "
439+
"when error.data.supported names no handshake-era version; a list that names one falls back to the "
440+
"initialize handshake."
439441
),
440442
added_in="2026-07-28",
441443
supersedes=("lifecycle:version:downgrade", "lifecycle:version:reject-unsupported"),
@@ -529,8 +531,9 @@ def __post_init__(self) -> None:
529531
"lifecycle:discover:fallback-method-not-found": Requirement(
530532
source=f"{SPEC_2026_BASE_URL}/basic/transports/stdio#backward-compatibility",
531533
behavior=(
532-
"When server/discover is rejected with a JSON-RPC error other than -32022 (see "
533-
"lifecycle:discover:retry-on-32022), or with a bare HTTP 4xx, an auto-negotiating client falls back to "
534+
"When server/discover is rejected with a JSON-RPC error other than -32022 (which retries, raises, or "
535+
"falls back to the handshake according to the supported list; see lifecycle:discover:retry-on-32022), "
536+
"or with a bare HTTP 4xx, an auto-negotiating client falls back to "
534537
"the legacy initialize handshake and connects at a handshake-era version; the fallback is not keyed to "
535538
"specific codes because legacy servers reject the probe with various codes."
536539
),
@@ -622,11 +625,9 @@ def __post_init__(self) -> None:
622625
"protocol:directionality:no-client-responses": Requirement(
623626
source=f"{SPEC_2026_BASE_URL}/basic/patterns",
624627
behavior=(
625-
"A 2026-07-28 wire trace contains no server-initiated JSON-RPC requests and no "
626-
"client-sent JSON-RPC responses: every client-to-server frame is a request and every "
627-
"server-to-client frame is a response or a request-scoped notification, never a request, even "
628-
"across a multi-round-trip exchange that at 2025-11-25 was a server-initiated request answered "
629-
"by the client."
628+
"A 2026-07-28 wire trace contains no server-initiated JSON-RPC requests and no client-sent "
629+
"JSON-RPC responses; notifications flow in both directions. This holds even across a "
630+
"multi-round-trip exchange that at 2025-11-25 was a server-initiated request answered by the client."
630631
),
631632
added_in="2026-07-28",
632633
note=(
@@ -1337,8 +1338,8 @@ def __post_init__(self) -> None:
13371338
"client:x-mcp-header:invalid-definition-rejected:non-primitive": Requirement(
13381339
source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#schema-extension",
13391340
behavior=(
1340-
"An x-mcp-header annotation on a non-primitive property (e.g. type number, which "
1341-
"the spec explicitly forbids) makes the tool definition invalid and the modern "
1341+
"An x-mcp-header annotation is permitted only on integer, string or boolean properties; a tool "
1342+
"that annotates any other type (for example number) has an invalid definition and the modern "
13421343
"client excludes it from tools/list."
13431344
),
13441345
added_in="2026-07-28",
@@ -1646,11 +1647,26 @@ def __post_init__(self) -> None:
16461647
behavior="resources/read returns text contents carrying uri, mimeType, and the text.",
16471648
),
16481649
"resources:read:unknown-uri": Requirement(
1650+
source=f"{SPEC_BASE_URL}/server/resources#error-handling",
1651+
behavior="resources/read for an unknown URI returns JSON-RPC error -32002 (resource not found).",
1652+
divergence=Divergence(
1653+
note=(
1654+
"The server answers -32602 (invalid params, URI in error.data) on handshake-era connections too; "
1655+
"when a 2025-era -32002 arm lands, re-pin the 2025 cells to -32002 and delete this divergence."
1656+
),
1657+
),
1658+
note="replaced at 2026-07-28 (SEP-2164): unknown URIs are Invalid params (-32602).",
1659+
removed_in="2026-07-28",
1660+
superseded_by="resources:read:unknown-uri-invalid-params",
1661+
),
1662+
"resources:read:unknown-uri-invalid-params": Requirement(
16491663
source=f"{SPEC_2026_BASE_URL}/server/resources#error-handling",
16501664
behavior=(
16511665
"resources/read for a URI matching no registered resource returns JSON-RPC error -32602 "
16521666
"(invalid params) with the requested URI in error.data, per SEP-2164."
16531667
),
1668+
added_in="2026-07-28",
1669+
supersedes=("resources:read:unknown-uri",),
16541670
),
16551671
"resources:subscribe": Requirement(
16561672
source=f"{SPEC_BASE_URL}/server/resources#subscriptions",
@@ -1719,14 +1735,14 @@ def __post_init__(self) -> None:
17191735
source=f"{SPEC_2026_BASE_URL}/basic/patterns/subscriptions#graceful-closure",
17201736
behavior=(
17211737
"A server ending a subscription on its own initiative answers the original "
1722-
"subscriptions/listen request with an empty result (stamped with the subscriptionId) before "
1723-
"closing the stream, so the client can distinguish a graceful close from a transport drop."
1738+
"subscriptions/listen request with an empty result before closing the stream, so the "
1739+
"client can distinguish a graceful close from a transport drop."
17241740
),
17251741
added_in="2026-07-28",
17261742
note=(
17271743
"The deliberate close is a lowlevel-Server surface: ListenHandler.close() ends every open "
1728-
"stream with the stamped empty result; MCPServer registers its ListenHandler by default "
1729-
"and exposes no teardown handle."
1744+
"stream with the empty result, which carries the subscriptionId stamp the bound test does not "
1745+
"observe. MCPServer registers its ListenHandler by default and exposes no teardown handle."
17301746
),
17311747
),
17321748
"subscriptions:listen:notification-stamped": Requirement(
@@ -4658,8 +4674,9 @@ def __post_init__(self) -> None:
46584674
added_in="2026-07-28",
46594675
transports=("streamable-http",),
46604676
note=(
4661-
"Only observable over streamable HTTP. Scoped to the 2026-07-28 entry, where the SHOULD is new; the "
4662-
"legacy SSE and streamable-HTTP transports send no such header and are not bound by this entry."
4677+
"Only observable over streamable HTTP. Scoped to the 2026-07-28 entry, where the SHOULD is stated; "
4678+
"the handshake-era transports also emit the header through their SSE response class but are not "
4679+
"bound by this entry."
46634680
),
46644681
),
46654682
"hosting:http:modern:header-name-case-insensitive": Requirement(
@@ -4954,12 +4971,16 @@ def __post_init__(self) -> None:
49544971
"client-transport:http:body-derived-headers": Requirement(
49554972
source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#standard-request-headers",
49564973
behavior=(
4957-
"An envelope-bearing request body yields MCP-Protocol-Version, Mcp-Method, and (for tools/call) "
4958-
"Mcp-Name headers on the outgoing HTTP request; a body without the envelope yields none."
4974+
"An envelope-bearing request body yields MCP-Protocol-Version, Mcp-Method and (for tools/call) "
4975+
"Mcp-Name headers on the outgoing HTTP request, derived from the body at the transport seam."
49594976
),
49604977
added_in="2026-07-28",
49614978
transports=("streamable-http",),
4962-
note="Only observable over streamable HTTP: headers are derived from the body envelope at the transport seam.",
4979+
note=(
4980+
"Only observable over streamable HTTP. Handshake-era requests carry no envelope; their "
4981+
"MCP-Protocol-Version header is stamped from the negotiated session instead "
4982+
"(client-transport:http:protocol-version-header)."
4983+
),
49634984
),
49644985
"client-transport:http:mcp-name-base64-sentinel": Requirement(
49654986
source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#standard-request-headers",
@@ -5450,16 +5471,15 @@ def __post_init__(self) -> None:
54505471
"client-auth:iss:no-normalize": Requirement(
54515472
source=f"{SPEC_2026_BASE_URL}/basic/authorization#authorization-response-validation",
54525473
behavior=(
5453-
"The iss comparison is simple string comparison (RFC 3986 section 6.2.1): a value "
5454-
"differing from the recorded issuer only by a trailing slash is rejected as a "
5455-
"mismatch -- no scheme or host case folding, default-port elision, trailing-slash, "
5456-
"or percent-encoding normalization is applied before comparison."
5474+
"The iss comparison is a plain string comparison against the recorded issuer (RFC 3986 "
5475+
"section 6.2.1): a callback iss differing only by a trailing slash is rejected as a mismatch."
54575476
),
54585477
added_in="2026-07-28",
54595478
transports=("streamable-http",),
54605479
note=(
5461-
"OAuth is HTTP-only. The comparison is a single string inequality; the test pins the "
5462-
"trailing-slash arm as the representative normalization class."
5480+
"OAuth is HTTP-only. The recorded issuer is the parsed metadata URL, so scheme and host case "
5481+
"and default ports are already normalized when the metadata is read; trailing-slash and "
5482+
"percent-encoding differences are not, and the test pins the trailing-slash arm."
54635483
),
54645484
),
54655485
"client-auth:iss:supported-missing-reject": Requirement(

tests/interaction/auth/test_lifecycle.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ async def test_tokens_from_the_previous_authorization_server_are_never_replayed_
268268
storage.tokens = OAuthToken(
269269
access_token="stale-access-token",
270270
token_type="Bearer",
271-
expires_in=3600,
271+
expires_in=-3600,
272272
scope="mcp",
273273
refresh_token="stale-refresh-token",
274274
)

tests/interaction/lowlevel/test_mrtr.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,8 +227,9 @@ async def answer_again(context: ClientRequestContext, params: types.ElicitReques
227227
async with Client(
228228
server, mode=LATEST_MODERN_VERSION, elicitation_callback=answer_again, input_required_max_rounds=2
229229
) as client:
230-
# Raised inside the block: Client.__aexit__ would wrap the error in an ExceptionGroup.
231-
with pytest.raises(InputRequiredRoundsExceededError) as exc_info:
230+
# Raised inside the block: Client.__aexit__ would wrap the error in an ExceptionGroup. The
231+
# fail_after is the only guard against an unbounded retry loop if the cap stops being enforced.
232+
with anyio.fail_after(5), pytest.raises(InputRequiredRoundsExceededError) as exc_info:
232233
await client.call_tool("never-done", {})
233234

234235
assert exc_info.value.max_rounds == 2

tests/interaction/mcpserver/test_resources.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -113,17 +113,37 @@ def user_profile(user_id: str) -> str:
113113
)
114114

115115

116-
@requirement("resources:read:unknown-uri")
117-
async def test_read_unknown_uri_is_error(connect: Connect) -> None:
118-
"""Reading a URI that matches no registered resource fails with -32602 and the URI in data (SEP-2164)."""
116+
def _library_with_one_resource() -> MCPServer:
117+
"""A server with one registered resource at config://app; the unknown-URI tests read a different URI."""
119118
mcp = MCPServer("library")
120119

121120
@mcp.resource("config://app")
122121
def app_config() -> str:
123-
"""A registered resource; the test reads a different URI."""
124122
raise NotImplementedError
125123

126-
async with connect(mcp) as client:
124+
return mcp
125+
126+
127+
@requirement("resources:read:unknown-uri-invalid-params")
128+
async def test_read_unknown_uri_is_invalid_params(connect: Connect) -> None:
129+
"""Reading a URI that matches no registered resource fails with -32602 and the URI in data (SEP-2164)."""
130+
async with connect(_library_with_one_resource()) as client:
131+
with pytest.raises(MCPError) as exc_info:
132+
await client.read_resource("config://missing")
133+
134+
assert exc_info.value.error == snapshot(
135+
ErrorData(code=-32602, message="Unknown resource: config://missing", data={"uri": "config://missing"})
136+
)
137+
138+
139+
@requirement("resources:read:unknown-uri")
140+
async def test_read_unknown_uri_on_handshake_era_is_invalid_params_too(connect: Connect) -> None:
141+
"""Reading a URI that matches no registered resource fails with -32602 on handshake-era connections as well.
142+
143+
On 2025-11-25 cells this pins the divergence recorded on resources:read:unknown-uri: the spec's
144+
code there is -32002. When a 2025-era -32002 arm lands, re-pin this assertion to -32002.
145+
"""
146+
async with connect(_library_with_one_resource()) as client:
127147
with pytest.raises(MCPError) as exc_info:
128148
await client.read_resource("config://missing")
129149

tests/interaction/transports/test_hosting_http_modern.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,8 @@ async def test_modern_tools_call_returns_result_type_complete_without_initialize
134134
result), and the server identifies itself via the result `_meta` serverInfo stamp. Asserted at
135135
the wire because the SDK client never surfaces `resultType` and because the absence of any
136136
prior request on the connection is the assertion. The `application/json` Content-Type also
137-
pins the lazy-upgrade JSON arm: a silent handler never commits SSE.
137+
pins the lazy-upgrade JSON arm: a silent handler that completes within the deferral window
138+
never commits SSE.
138139
"""
139140
body = {
140141
"jsonrpc": "2.0",
@@ -503,7 +504,9 @@ def _custom_header_server(*, on_call: Callable[[tuple[Headers, dict[str, Any] |
503504
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
504505
return ListToolsResult(tools=[_CUSTOM_HEADER_TOOL], ttl_ms=0, cache_scope="public")
505506

506-
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
507+
async def call_tool(
508+
ctx: ServerRequestContext[Any, StarletteRequest], params: CallToolRequestParams
509+
) -> CallToolResult:
507510
if on_call is not None:
508511
assert isinstance(ctx.request, StarletteRequest)
509512
on_call((ctx.request.headers, params.arguments))
@@ -533,9 +536,12 @@ async def on_request(request: httpx2.Request) -> None:
533536
client_via_http(http, mode=LATEST_MODERN_VERSION) as client,
534537
):
535538
await client.list_tools()
536-
await client.call_tool("run", {"region": "us-west1", "priority": 42, "verbose": False, "note": "héllo"})
539+
await client.call_tool(
540+
"run", {"region": "us-west1", "priority": 42, "verbose": False, "note": "héllo", "query": "status"}
541+
)
537542

538543
call = next(r for r in requests if json.loads(r.content)["method"] == "tools/call")
544+
# Exact set: the unannotated `query` is passed but yields no header.
539545
assert {k: v for k, v in call.headers.items() if k.startswith("mcp-param-")} == snapshot(
540546
{
541547
"mcp-param-region": "us-west1",
@@ -546,7 +552,7 @@ async def on_request(request: httpx2.Request) -> None:
546552
)
547553
# Mirroring is additive: the arguments are unchanged in the body.
548554
assert json.loads(call.content)["params"]["arguments"] == snapshot(
549-
{"region": "us-west1", "priority": 42, "verbose": False, "note": "héllo"}
555+
{"region": "us-west1", "priority": 42, "verbose": False, "note": "héllo", "query": "status"}
550556
)
551557

552558

@@ -681,7 +687,9 @@ async def test_non_header_safe_tool_name_is_carried_as_base64_sentinel_mcp_name(
681687
"""
682688
seen: list[str] = []
683689

684-
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
690+
async def call_tool(
691+
ctx: ServerRequestContext[Any, StarletteRequest], params: CallToolRequestParams
692+
) -> CallToolResult:
685693
assert params.name == "hëllo"
686694
assert isinstance(ctx.request, StarletteRequest)
687695
seen.append(ctx.request.headers["mcp-name"])
@@ -1088,7 +1096,9 @@ async def test_modern_client_non_ascii_prompt_name_round_trips_via_sentinel_enco
10881096
"""
10891097
seen: list[str] = []
10901098

1091-
async def get_prompt(ctx: ServerRequestContext, params: GetPromptRequestParams) -> GetPromptResult:
1099+
async def get_prompt(
1100+
ctx: ServerRequestContext[Any, StarletteRequest], params: GetPromptRequestParams
1101+
) -> GetPromptResult:
10921102
assert params.name == "héllo"
10931103
assert isinstance(ctx.request, StarletteRequest)
10941104
seen.append(ctx.request.headers["mcp-name"])

0 commit comments

Comments
 (0)