Skip to content

Commit 1f3b5f0

Browse files
committed
Rewrite the unrecognized-resultType pin onto the extension seam; simplify four tests
The server extension API can now author an arbitrary resultType through public surface, so the scripted memory-stream peer is replaced with an MCPServer extension driven through the connect fixture (one node becomes the entry's two cells). The parallel-MRTR isolation test drops its wire recording for handler capture (the rendezvous still forces interleaving), the GET-stream step-up shim moves into the auth harness beside its POST twin, a redundant metadata shim argument goes away, and the claimed-shape extension test pins its ValidationError by structured fields instead of accepting any validation failure.
1 parent f38302d commit 1f3b5f0

6 files changed

Lines changed: 110 additions & 153 deletions

File tree

tests/interaction/_requirements.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3799,13 +3799,14 @@ def __post_init__(self) -> None:
37993799
added_in="2026-07-28",
38003800
divergence=Divergence(
38013801
note=(
3802-
"The client never rejects an unrecognized resultType: ResultType is a "
3803-
"deliberately open Literal-or-str union (src/mcp-types/mcp_types/_types.py), "
3804-
"the 2026-07-28 wire surface types resultType as a bare str, and the client's "
3805-
"only result-kind dispatch is isinstance(result, InputRequiredResult) "
3806-
"(src/mcp/client/session.py), so an unrecognized value round-trips and is "
3807-
"surfaced on the returned result unchanged -- on both eras (the in-code TODO "
3808-
"in src/mcp/server/runner.py records the missing rejection)."
3802+
"The client accepts an unrecognized resultType whenever the body also parses "
3803+
"as a complete core result: ResultType is a deliberately open Literal-or-str "
3804+
"union (src/mcp-types/mcp_types/_types.py), and the client's discriminated "
3805+
"claim adapter (src/mcp/client/session.py) routes unknown tags to the core "
3806+
"arm, so such a value is surfaced on the returned result unchanged. A body "
3807+
"that does not parse as a core result fails result validation -- that reject "
3808+
"arm is pinned by extensions:client:claimed-result-undeclared-invalid. The "
3809+
"in-code TODO in src/mcp/server/runner.py records the missing rejection."
38093810
),
38103811
issue="L117",
38113812
),
@@ -4917,9 +4918,9 @@ def __post_init__(self) -> None:
49174918
),
49184919
added_in="2026-07-28",
49194920
note=(
4920-
"Known leniency: the monolith result surface still accepts an unknown tag when the payload "
4921-
"also parses as a complete core result (open result_type, extras ignored). Rejecting tags "
4922-
"outside core plus active claims is a tracked follow-up ruling."
4921+
"The lenient accept arm -- an unknown tag whose body still parses as a complete core result "
4922+
"surfaces unchanged -- is a recorded divergence owned by "
4923+
"protocol:result-type:unrecognized-invalid; this entry pins only the reject arm."
49234924
),
49244925
),
49254926
"extensions:client:capability-ad:gates-server-behaviour": Requirement(

tests/interaction/auth/_harness.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@
1616
from typing import Any
1717
from urllib.parse import parse_qs, parse_qsl, urlsplit
1818

19+
import anyio
1920
import httpx
2021
from pydantic import AnyHttpUrl, AnyUrl, BaseModel
21-
from starlette.types import ASGIApp, Receive, Scope, Send
22+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
2223

2324
from mcp.client.auth import OAuthClientProvider
2425
from mcp.client.client import Client
@@ -371,6 +372,55 @@ async def wrapped(scope: Scope, receive: Receive, send: Send) -> None:
371372
return factory
372373

373374

375+
def get_stream_step_up_shim(www_authenticate: str) -> tuple[list[int], anyio.Event, AppShim]:
376+
"""Build an `app_shim` that 403s the first authenticated GET to `/mcp` with the given challenge.
377+
378+
Returns:
379+
The statuses of every authenticated GET response (live-updated), an event set when one
380+
of those responses starts with status 200 (the reopened stream), and the shim factory.
381+
"""
382+
statuses: list[int] = []
383+
reopened = anyio.Event()
384+
fired = False
385+
386+
def factory(app: ASGIApp) -> ASGIApp:
387+
async def wrapped(scope: Scope, receive: Receive, send: Send) -> None:
388+
nonlocal fired
389+
if not (
390+
scope["type"] == "http"
391+
and scope["path"] == "/mcp"
392+
and scope["method"] == "GET"
393+
and b"authorization" in dict(scope["headers"])
394+
):
395+
await app(scope, receive, send)
396+
return
397+
398+
async def recording_send(message: Message) -> None:
399+
if message["type"] == "http.response.start":
400+
statuses.append(message["status"])
401+
if message["status"] == 200:
402+
reopened.set()
403+
await send(message)
404+
405+
if not fired:
406+
fired = True
407+
await recording_send(
408+
{
409+
"type": "http.response.start",
410+
"status": 403,
411+
"headers": [(b"www-authenticate", www_authenticate.encode())],
412+
}
413+
)
414+
await recording_send({"type": "http.response.body", "body": b""})
415+
return
416+
# The reopened SSE stream stays open until the test's exit cancels it; nothing may follow this await.
417+
await app(scope, receive, recording_send)
418+
419+
return wrapped
420+
421+
return statuses, reopened, factory
422+
423+
374424
def m2m_token_shim(provider: InMemoryAuthorizationServerProvider, *, scopes: list[str]) -> AppShim:
375425
"""Build an `app_shim` that handles `grant_type=client_credentials` at `/token`.
376426

tests/interaction/auth/test_authorize_token.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -509,12 +509,7 @@ async def test_an_iss_differing_only_by_a_trailing_slash_is_rejected_without_nor
509509

510510
with anyio.fail_after(5):
511511
with pytest.RaisesGroup(pytest.RaisesExc(OAuthFlowError, match=f"^{mismatch}$"), flatten_subgroups=True):
512-
await connect_with_oauth(
513-
server,
514-
provider=provider,
515-
app_shim=lambda app: shimmed_app(app, serve=canned_asm(iss_advertised=None)),
516-
on_request=on_request,
517-
).__aenter__()
512+
await connect_with_oauth(server, provider=provider, on_request=on_request).__aenter__()
518513

519514
# The recorded unauthenticated trigger POST guards the negative below against an unwired hook.
520515
assert find(recorded, "POST", "/mcp") != []

tests/interaction/auth/test_lifecycle.py

Lines changed: 2 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
from inline_snapshot import snapshot
1717
from mcp_types import INTERNAL_ERROR, ErrorData, ListToolsResult, Tool
1818
from pydantic import AnyHttpUrl, AnyUrl
19-
from starlette.types import ASGIApp, Message, Receive, Scope, Send
2019

2120
from mcp import MCPError
2221
from mcp.client.auth.extensions.client_credentials import ClientCredentialsOAuthProvider, PrivateKeyJWTOAuthProvider
@@ -26,11 +25,11 @@
2625
from tests.interaction._requirements import requirement
2726
from tests.interaction.auth._harness import (
2827
REDIRECT_URI,
29-
AppShim,
3028
InMemoryTokenStorage,
3129
RecordedRequest,
3230
auth_settings,
3331
connect_with_oauth,
32+
get_stream_step_up_shim,
3433
m2m_token_shim,
3534
metadata_body,
3635
record_requests,
@@ -738,61 +737,12 @@ async def test_a_second_insufficient_scope_403_after_a_step_up_surfaces_without_
738737
assert counts[("POST", "/token")] == 2
739738

740739

741-
def get_stream_step_up_shim(www_authenticate: str) -> tuple[list[int], anyio.Event, AppShim]:
742-
"""Build an `app_shim` that 403s the first authenticated GET to `/mcp` with the given challenge.
743-
744-
Returns:
745-
The statuses of every authenticated GET response (live-updated), an event set when one
746-
of those responses starts with status 200 (the reopened stream), and the shim factory.
747-
"""
748-
statuses: list[int] = []
749-
reopened = anyio.Event()
750-
fired = False
751-
752-
def factory(app: ASGIApp) -> ASGIApp:
753-
async def wrapped(scope: Scope, receive: Receive, send: Send) -> None:
754-
nonlocal fired
755-
if not (
756-
scope["type"] == "http"
757-
and scope["path"] == "/mcp"
758-
and scope["method"] == "GET"
759-
and b"authorization" in dict(scope["headers"])
760-
):
761-
await app(scope, receive, send)
762-
return
763-
764-
async def recording_send(message: Message) -> None:
765-
if message["type"] == "http.response.start":
766-
statuses.append(message["status"])
767-
if message["status"] == 200:
768-
reopened.set()
769-
await send(message)
770-
771-
if not fired:
772-
fired = True
773-
await recording_send(
774-
{
775-
"type": "http.response.start",
776-
"status": 403,
777-
"headers": [(b"www-authenticate", www_authenticate.encode())],
778-
}
779-
)
780-
await recording_send({"type": "http.response.body", "body": b""})
781-
return
782-
# The reopened SSE stream stays open until the test's exit cancels it; nothing may follow this await.
783-
await app(scope, receive, recording_send)
784-
785-
return wrapped
786-
787-
return statuses, reopened, factory
788-
789-
790740
@requirement("client-auth:stepup:get-stream-403")
791741
async def test_a_403_on_the_get_stream_open_steps_up_and_reopens_the_stream_with_the_upgraded_token() -> None:
792742
"""A 403 `insufficient_scope` on the standalone GET stream open steps up and reopens the stream.
793743
794744
The standalone GET (a 2025-11-25 mechanism, removed at 2026-07-28) is opened by the SDK in the
795-
background and is invisible to `Client`, so the file-local shim records each authenticated
745+
background and is invisible to `Client`, so the harness shim records each authenticated
796746
GET's response status and the test waits on the reopened stream's 200 before acting. The
797747
failure arm stays unpinned: the transport swallows GET failures into a timed reconnect loop
798748
this suite cannot observe without sleeps.

tests/interaction/lowlevel/test_mrtr.py

Lines changed: 42 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
ClientCapabilities,
2323
CreateMessageRequest,
2424
CreateMessageRequestParams,
25-
DiscoverResult,
2625
ElicitRequest,
2726
ElicitRequestFormParams,
2827
ElicitResult,
@@ -50,7 +49,9 @@
5049
from mcp.client import ClientRequestContext, ClientSession
5150
from mcp.client.client import Client
5251
from mcp.client.streamable_http import streamable_http_client
53-
from mcp.server import Server, ServerRequestContext
52+
from mcp.server import MCPServer, Server, ServerRequestContext
53+
from mcp.server.context import CallNext, HandlerResult
54+
from mcp.server.extension import Extension
5455
from mcp.shared.exceptions import NoBackChannelError
5556
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
5657
from mcp.shared.message import SessionMessage
@@ -754,8 +755,10 @@ async def test_parallel_mrtr_calls_keep_request_state_and_responses_isolated() -
754755
"""Parallel MRTR calls keep requestState and inputResponses scoped to their originating request.
755756
756757
A symmetric rendezvous in the elicitation callback forces both loops mid-flight before either
757-
retry leaves; the exhaustive scan over every recorded tools/call frame proves no leak (spec MUST NOT).
758+
retry leaves (spec MUST NOT). Handler capture suffices: every tools/call the client sends is
759+
delivered to the handler, so the captured rounds are 1:1 with the sent frames.
758760
"""
761+
rounds: list[tuple[str, str | None, set[str] | None]] = []
759762

760763
async def list_tools(
761764
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
@@ -772,12 +775,14 @@ async def call_tool(
772775
) -> CallToolResult | InputRequiredResult:
773776
assert params.name in ("alpha", "beta")
774777
name = params.name
778+
rounds.append(
779+
(name, params.request_state, None if params.input_responses is None else set(params.input_responses))
780+
)
775781
if params.input_responses is None:
776782
return InputRequiredResult(
777783
input_requests={f"q-{name}": _form_request(f"for {name}")},
778784
request_state=f"state-{name}",
779785
)
780-
assert params.request_state == f"state-{name}"
781786
return CallToolResult(content=[TextContent(text=name)])
782787

783788
server = Server("parallel", on_list_tools=list_tools, on_call_tool=call_tool)
@@ -799,12 +804,7 @@ async def answer(context: ClientRequestContext, params: types.ElicitRequestParam
799804

800805
with anyio.fail_after(5):
801806
async with (
802-
mounted_app(server) as (http, _),
803-
Client(
804-
recording := RecordingTransport(streamable_http_client(f"{BASE_URL}/mcp", http_client=http)),
805-
mode=LATEST_MODERN_VERSION,
806-
elicitation_callback=answer,
807-
) as client,
807+
Client(server, mode=LATEST_MODERN_VERSION, elicitation_callback=answer) as client,
808808
# Last item so it exits first: both calls complete while the client is still open.
809809
anyio.create_task_group() as task_group,
810810
):
@@ -815,28 +815,10 @@ async def call(name: str) -> None:
815815
task_group.start_soon(call, "alpha")
816816
task_group.start_soon(call, "beta")
817817

818-
frames = [
819-
message.message
820-
for message in recording.sent
821-
if isinstance(message.message, JSONRPCRequest) and message.message.method == "tools/call"
822-
]
823-
by_name: dict[str, list[dict[str, Any]]] = {"alpha": [], "beta": []}
824-
for frame in frames:
825-
assert frame.params is not None
826-
by_name[frame.params["name"]].append(frame.params)
827-
for name, sent_params in by_name.items():
828-
assert len(sent_params) == 2
829-
initial, retry = sent_params
830-
assert "requestState" not in initial
831-
assert "inputResponses" not in initial
832-
assert retry["requestState"] == f"state-{name}"
833-
assert set(retry["inputResponses"]) == {f"q-{name}"}
834-
# The exhaustive negative: no frame anywhere carries the other call's state or responses.
835-
for params in (frame.params for frame in frames):
836-
assert params is not None
837-
other = "beta" if params["name"] == "alpha" else "alpha"
838-
assert params.get("requestState") in (None, f"state-{params['name']}")
839-
assert f"q-{other}" not in params.get("inputResponses", {})
818+
# The rendezvous guarantees both initial rounds land before either retry; order within a phase is free.
819+
assert sorted(rounds[:2]) == [("alpha", None, None), ("beta", None, None)]
820+
# Each retry carries exactly its own call's state and response key -- nothing crossed over.
821+
assert sorted(rounds[2:]) == [("alpha", "state-alpha", {"q-alpha"}), ("beta", "state-beta", {"q-beta"})]
840822
assert results == {
841823
"alpha": CallToolResult(content=[TextContent(text="alpha")]),
842824
"beta": CallToolResult(content=[TextContent(text="beta")]),
@@ -966,7 +948,7 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
966948
assert error.error == snapshot(ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data=""))
967949

968950

969-
# --- scripted server peer: result bodies a real Server cannot emit ---
951+
# --- scripted server peer: byte-controlled absence of the resultType key ---
970952

971953

972954
@requirement("protocol:result-type:absent-is-complete")
@@ -1034,63 +1016,40 @@ def respond(request_id: types.RequestId, result: dict[str, object]) -> SessionMe
10341016
assert result == snapshot(CallToolResult(content=[TextContent(text="plain")]))
10351017

10361018

1019+
# --- unrecognized resultType: a server extension puts an arbitrary tag on the wire ---
1020+
1021+
10371022
@requirement("protocol:result-type:unrecognized-invalid")
1038-
async def test_an_unrecognized_result_type_value_is_surfaced_unchanged_instead_of_treated_as_invalid() -> None:
1023+
async def test_an_unrecognized_result_type_value_is_surfaced_unchanged_instead_of_treated_as_invalid(
1024+
connect: Connect,
1025+
) -> None:
10391026
"""PINS A KNOWN GAP: an unrecognized resultType round-trips instead of being treated as invalid (spec MUST).
10401027
1041-
The client's open ResultType union accepts any string. Scripted peer over memory streams
1042-
because the typed Server cannot author an arbitrary resultType. When the client starts
1043-
rejecting unrecognized resultType values: re-pin to the typed rejection and delete the Divergence.
1028+
The leniency is narrow: the unknown tag survives only because the body also parses as a
1029+
complete core result. When the client starts rejecting unrecognized resultType values:
1030+
re-pin to the typed rejection and delete the Divergence.
10441031
"""
10451032

1046-
async def scripted_server(streams: MessageStream) -> None:
1047-
server_read, server_write = streams
1033+
class BogusIssuer(Extension):
1034+
identifier = "com.example/bogus"
10481035

1049-
def respond(request_id: types.RequestId, result: dict[str, object]) -> SessionMessage:
1050-
return SessionMessage(JSONRPCResponse(jsonrpc="2.0", id=request_id, result=result))
1036+
async def intercept_tool_call(
1037+
self, params: types.CallToolRequestParams, ctx: ServerRequestContext[Any, Any], call_next: CallNext
1038+
) -> HandlerResult:
1039+
assert params.name == "probe"
1040+
# "bogus" is in no core or extension vocabulary -- exactly the value the MUST addresses.
1041+
return {"resultType": "bogus", "content": [{"type": "text", "text": "still here"}]}
10511042

1052-
call = await server_read.receive()
1053-
assert isinstance(call, SessionMessage)
1054-
assert isinstance(call.message, JSONRPCRequest)
1055-
assert call.message.method == "tools/call"
1056-
# "bogus" is in no core or extension vocabulary -- exactly the value the MUST addresses.
1057-
await server_write.send(
1058-
respond(call.message.id, {"resultType": "bogus", "content": [{"type": "text", "text": "still here"}]})
1059-
)
1043+
server = MCPServer("bogus-issuer", extensions=[BogusIssuer()])
10601044

1061-
# The client's output-schema cache refresh follows the call result; stopping here hangs the test.
1062-
refresh = await server_read.receive()
1063-
assert isinstance(refresh, SessionMessage)
1064-
assert isinstance(refresh.message, JSONRPCRequest)
1065-
assert refresh.message.method == "tools/list"
1066-
await server_write.send(
1067-
respond(
1068-
refresh.message.id,
1069-
{
1070-
"tools": [{"name": "x", "inputSchema": {"type": "object"}}],
1071-
"resultType": "complete",
1072-
"ttlMs": 0,
1073-
"cacheScope": "private",
1074-
},
1075-
)
1076-
)
1045+
@server.tool()
1046+
def probe() -> CallToolResult:
1047+
"""Probe the unrecognized-tag path."""
1048+
raise NotImplementedError # the server extension answers before the tool runs
10771049

1078-
async with (
1079-
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
1080-
anyio.create_task_group() as task_group,
1081-
ClientSession(client_read, client_write, client_info=Implementation(name="cli", version="0")) as session,
1082-
):
1083-
task_group.start_soon(scripted_server, server_streams)
1084-
session.adopt(
1085-
DiscoverResult(
1086-
supported_versions=[LATEST_MODERN_VERSION],
1087-
capabilities=ServerCapabilities(),
1088-
server_info=Implementation(name="srv", version="0"),
1089-
)
1090-
)
1091-
with anyio.fail_after(5):
1092-
result = await session.call_tool("x", {})
1050+
async with connect(server) as client:
1051+
result = await client.call_tool("probe", {})
10931052

1094-
# The divergent observable: the unrecognized discriminator survives unchanged, never a rejection.
1095-
assert result.result_type == "bogus"
1096-
assert result == snapshot(CallToolResult(content=[TextContent(text="still here")], result_type="bogus"))
1053+
# The divergent observable: the unrecognized discriminator survives unchanged, never a rejection.
1054+
assert result.result_type == "bogus"
1055+
assert result == snapshot(CallToolResult(content=[TextContent(text="still here")], result_type="bogus"))

0 commit comments

Comments
 (0)