Skip to content

Commit d38927c

Browse files
committed
Complete the MRTR core coverage: multi-round, bounds, and the 2026 directionality pins
Ten more tests: the multi-round completion loop, the rounds cap, the at-least-one-of construction-site rejection, the inputResponses structural validation and key correspondence, the -32042 emission-ban wire scan, and the 2026 directionality edges - the push-API loud-fail split (the standalone leg pins NoBackChannelError green on both 2026 cells; a dedicated in-memory test pins the request-scoped leg still transmitting the forbidden frame, recorded as a per-transport, per-leg divergence so the eventual era-gate fix re-pins mechanically), a wire-trace proof that a 2026 exchange contains no server-initiated requests and no client-sent responses, and the sampling and roots embed capability gates (both pinned un-gated with recorded divergences, completing the embed-gate family). Five entries flip from deferred, five origin-new entries are minted with their tests. 859 -> 876 collected cells, every node accounted; suite green three consecutive runs.
1 parent 0eb3d67 commit d38927c

3 files changed

Lines changed: 737 additions & 26 deletions

File tree

‎tests/interaction/_requirements.py‎

Lines changed: 96 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,20 @@ def __post_init__(self) -> None:
565565
"to a request the client sent or a notification carrying no id."
566566
),
567567
),
568+
"protocol:directionality:no-client-responses": Requirement(
569+
source=f"{SPEC_2026_BASE_URL}/basic/patterns",
570+
behavior=(
571+
"A 2026-07-28 wire trace contains no server-initiated JSON-RPC requests and no "
572+
"client-sent JSON-RPC responses: every client-to-server frame is a request and every "
573+
"server-to-client frame is a response, even across a multi-round-trip exchange that at "
574+
"2025-11-25 was a server-initiated request answered by the client."
575+
),
576+
added_in="2026-07-28",
577+
note=(
578+
"Asserted at the streamable HTTP wire seam: the in-memory 2026 transport dispatches "
579+
"typed objects directly with no JSON-RPC framing, so it has no trace to inspect."
580+
),
581+
),
568582
"protocol:cancel:abort-signal": Requirement(
569583
source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#cancellation-flow",
570584
behavior=(
@@ -1287,7 +1301,7 @@ def __post_init__(self) -> None:
12871301
note=(
12881302
"removed in 2026-07-28 (SEP-2322); in-tool elicitation now returns an input_required result from "
12891303
"the tool; the push Context API's 2026 failure mode is pinned separately by "
1290-
"mrtr:push-api:loud-fail-2026 when the mrtr add-batch lands it."
1304+
"mrtr:push-api:loud-fail-2026."
12911305
),
12921306
arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),),
12931307
),
@@ -2288,17 +2302,22 @@ def __post_init__(self) -> None:
22882302
"requests require sampling.tools; thisServer/allServers context -- itself deprecated -- should "
22892303
"not be used without sampling.context)."
22902304
),
2305+
divergence=Divergence(
2306+
note=(
2307+
"The embed gate is not implemented: an input_required result carrying a "
2308+
"sampling/createMessage request for a client that declared no sampling capability is "
2309+
"transmitted as-is, and the violation surfaces as the client driver's refusal "
2310+
"(INVALID_REQUEST, 'Sampling not supported') aborting the call. The sub-capability legs "
2311+
"(sampling.tools, sampling.context) are equally ungated and covered by this divergence "
2312+
"without separate pins."
2313+
),
2314+
),
22912315
added_in="2026-07-28",
22922316
supersedes=(
22932317
"sampling:create:not-supported",
22942318
"sampling:tools:server-gated-by-capability",
22952319
"sampling:context:server-gated-by-capability",
22962320
),
2297-
deferred=(
2298-
"Not implemented in the SDK: the server does not gate input_required input requests against the "
2299-
"client's declared capabilities -- a handler can embed a sampling/createMessage request for a "
2300-
"client that never declared the matching capability and it is sent as-is."
2301-
),
23022321
),
23032322
# ═══════════════════════════════════════════════════════════════════════════
23042323
# Elicitation (server → client)
@@ -2668,18 +2687,42 @@ def __post_init__(self) -> None:
26682687
# ═══════════════════════════════════════════════════════════════════════════
26692688
# MRTR (multi-round-trip requests, 2026-07-28)
26702689
# ═══════════════════════════════════════════════════════════════════════════
2690+
"mrtr:input-required-result:at-least-one-of": Requirement(
2691+
source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#server-requirements-basic-workflow",
2692+
behavior=(
2693+
"An InputRequiredResult carries at least one of inputRequests or requestState; a "
2694+
"handler-built violation fails at construction and surfaces to the client as a JSON-RPC "
2695+
"error, never as a malformed interim result."
2696+
),
2697+
added_in="2026-07-28",
2698+
note=(
2699+
"The at-least-one-of MUST is enforced by construction (mcp_types model validator). Both "
2700+
"2026 dispatchers map the handler's ValidationError to the shared "
2701+
"ErrorData(INVALID_PARAMS, 'Invalid request parameters', data='') shape "
2702+
"(handler_exception_to_error_data); INTERNAL_ERROR is arguably the more apt code for a "
2703+
"server-side construction bug, but the spec mandates no code for this failure."
2704+
),
2705+
),
26712706
"mrtr:input-responses:invalid-rejected": Requirement(
26722707
source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#error-handling",
26732708
behavior=(
2674-
"The server validates that a retry's inputResponses parse as valid results for the requests it "
2675-
"issued; content that violates the requested schema is rejected rather than silently accepted."
2709+
"The server validates that a retry's inputResponses parse as a valid InputResponses object; "
2710+
"a structurally malformed map is rejected with a JSON-RPC error before the handler runs."
26762711
),
26772712
added_in="2026-07-28",
26782713
supersedes=("elicitation:form:response-validation",),
2679-
deferred=(
2680-
"Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; "
2681-
"the behaviour is implemented and drivable (phase-4 verdict)."
2714+
note=(
2715+
"Elicited content is handed to the handler without requestedSchema re-validation; servers "
2716+
"validate semantic constraints themselves (spec asks only for structural validation)."
2717+
),
2718+
),
2719+
"mrtr:input-responses:key-correspondence": Requirement(
2720+
source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#inputresponses",
2721+
behavior=(
2722+
"A retry's inputResponses map is keyed by the originating inputRequests keys, each value "
2723+
"the client's typed result for that key's request (e.g. ElicitResult, ListRootsResult)."
26822724
),
2725+
added_in="2026-07-28",
26832726
),
26842727
"mrtr:url-elicitation:no-32042-on-2026": Requirement(
26852728
source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr",
@@ -2695,10 +2738,6 @@ def __post_init__(self) -> None:
26952738
"mcpserver:tool:url-elicitation-error",
26962739
"flow:elicitation:url-required-then-retry",
26972740
),
2698-
deferred=(
2699-
"Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; "
2700-
"the behaviour is implemented and drivable (phase-4 verdict)."
2701-
),
27022741
),
27032742
"mrtr:tools-call:write-once-roundtrip": Requirement(
27042743
source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#basic-workflow",
@@ -2747,10 +2786,41 @@ def __post_init__(self) -> None:
27472786
),
27482787
added_in="2026-07-28",
27492788
supersedes=("flow:elicitation:multi-step-form",),
2750-
deferred=(
2751-
"Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; "
2752-
"the behaviour is implemented and drivable (phase-4 verdict)."
2789+
),
2790+
"mrtr:rounds-cap": Requirement(
2791+
source="sdk",
2792+
behavior=(
2793+
"Client.call_tool / get_prompt / read_resource bound the input_required retry loop at the "
2794+
"configurable input_required_max_rounds; a server that keeps answering input_required past "
2795+
"the cap raises InputRequiredRoundsExceededError carrying the configured cap."
27532796
),
2797+
added_in="2026-07-28",
2798+
),
2799+
"mrtr:push-api:loud-fail-2026": Requirement(
2800+
source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr",
2801+
behavior=(
2802+
"The push-style server-to-client request APIs (ServerSession.elicit_form / elicit_url / "
2803+
"create_message / list_roots) on a 2026-07-28 connection fail with a typed local error "
2804+
"(NoBackChannelError, INVALID_REQUEST) before any request reaches the client; a handler "
2805+
"can catch it and fall back, and the originating call still completes."
2806+
),
2807+
divergence=Divergence(
2808+
note=(
2809+
"The prohibition is enforced by each transport's missing back-channel, not by an "
2810+
"era gate on the send path, and the enforcement splits per transport and per leg. "
2811+
"Standalone sends (no related_request_id) raise NoBackChannelError locally on both "
2812+
"2026 transports because the per-request Connection has no outbound channel. "
2813+
"Request-scoped sends (related_request_id=...) ride the per-request dispatch "
2814+
"context, whose can_send_request the modern HTTP entry hard-codes to False but the "
2815+
"in-memory direct-dispatcher pair leaves at its True default -- so in-memory the "
2816+
"forbidden elicitation/create frame IS transmitted, and the failure comes back from "
2817+
"the client's 2026 version gate (METHOD_NOT_FOUND) instead of arising locally. An "
2818+
"era-aware gate on the send path would loud-fail both legs on every transport; when "
2819+
"it lands, re-pin the request-scoped in-memory test to the local NoBackChannelError "
2820+
"and delete this divergence."
2821+
),
2822+
),
2823+
added_in="2026-07-28",
27542824
),
27552825
# ═══════════════════════════════════════════════════════════════════════════
27562826
# Roots (server → client)
@@ -2850,13 +2920,16 @@ def __post_init__(self) -> None:
28502920
"The server does not place a roots/list request in an input_required result's inputRequests "
28512921
"for a client that did not declare the roots capability."
28522922
),
2923+
divergence=Divergence(
2924+
note=(
2925+
"The embed gate is not implemented: an input_required result carrying a roots/list "
2926+
"request for a client that did not declare the roots capability is transmitted as-is, "
2927+
"and the violation surfaces as the client driver's refusal (INVALID_REQUEST, 'List "
2928+
"roots not supported') aborting the call."
2929+
),
2930+
),
28532931
added_in="2026-07-28",
28542932
supersedes=("roots:list:not-supported",),
2855-
deferred=(
2856-
"Not implemented in the SDK: the server does not gate input_required input requests against the "
2857-
"client's declared capabilities -- a handler can embed a roots/list request for a client that "
2858-
"never declared the roots capability and it is sent as-is."
2859-
),
28602933
),
28612934
"roots:uri:file-scheme": Requirement(
28622935
source=f"{SPEC_BASE_URL}/client/roots#root",

‎tests/interaction/lowlevel/test_elicitation.py‎

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import pytest
1010
from inline_snapshot import snapshot
1111
from mcp_types import (
12+
URL_ELICITATION_REQUIRED,
1213
CallToolResult,
1314
ElicitCompleteNotification,
1415
ElicitCompleteNotificationParams,
@@ -28,13 +29,17 @@
2829
ServerCapabilities,
2930
TextContent,
3031
)
32+
from mcp_types.version import LATEST_MODERN_VERSION
3133

3234
from mcp import MCPError, UrlElicitationRequiredError
3335
from mcp.client import ClientRequestContext, ClientSession, IncomingMessage
36+
from mcp.client.client import Client
37+
from mcp.client.streamable_http import streamable_http_client
3438
from mcp.server import Server, ServerRequestContext
3539
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
3640
from mcp.shared.message import SessionMessage
37-
from tests.interaction._connect import Connect
41+
from tests.interaction._connect import BASE_URL, Connect, mounted_app
42+
from tests.interaction._helpers import RecordingTransport
3843
from tests.interaction._requirements import requirement
3944

4045
pytestmark = pytest.mark.anyio
@@ -942,3 +947,88 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
942947
}
943948
)
944949
)
950+
951+
952+
@requirement("mrtr:url-elicitation:no-32042-on-2026")
953+
async def test_url_elicitation_rides_mrtr_and_no_32042_error_crosses_the_wire() -> None:
954+
"""URL-mode elicitation rides the MRTR loop at 2026-07-28: the embedded URL request reaches the
955+
callback, accepting it fulfils the loop, the retried call completes, and the retired -32042
956+
urlElicitationRequired code never appears in any frame of the exchange.
957+
958+
Spec-mandated (-32042 is reserved-never-reused at 2026). Asserted at the client transport seam
959+
over the modern streamable HTTP entry, the only transport serving 2026 JSON-RPC frames; the
960+
post-exchange scan needs no waiting because the exchange is POST request/response pairs only,
961+
each response fully consumed before its await returns.
962+
"""
963+
received: list[types.ElicitRequestParams] = []
964+
965+
async def list_tools(
966+
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
967+
) -> types.ListToolsResult:
968+
# Live (not NotImplementedError): the client's output-schema cache refresh invokes
969+
# tools/list right after the first successful tools/call result.
970+
return types.ListToolsResult(
971+
tools=[types.Tool(name="protected", description="Needs a sign-in.", input_schema={"type": "object"})]
972+
)
973+
974+
async def call_tool(
975+
ctx: ServerRequestContext, params: types.CallToolRequestParams
976+
) -> CallToolResult | InputRequiredResult:
977+
assert params.name == "protected"
978+
if not params.input_responses:
979+
return InputRequiredResult(
980+
input_requests={
981+
"link": ElicitRequest(
982+
params=ElicitRequestURLParams(message="Sign in to continue.", url="https://example.com/auth")
983+
)
984+
}
985+
)
986+
answer = params.input_responses["link"]
987+
assert isinstance(answer, ElicitResult)
988+
return CallToolResult(content=[TextContent(text=f"{answer.action} content={answer.content}")])
989+
990+
server = Server("guard", on_list_tools=list_tools, on_call_tool=call_tool)
991+
992+
async def answer_url(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
993+
received.append(params)
994+
# Accept means the user agreed to visit the URL; there is never content to carry.
995+
return ElicitResult(action="accept")
996+
997+
with anyio.fail_after(5):
998+
# One combined async-with, the recorder bound via := -- a separately nested `async with`
999+
# line mis-traces its exit arcs under branch coverage on 3.11+.
1000+
async with (
1001+
mounted_app(server) as (http, _),
1002+
Client(
1003+
recording := RecordingTransport(streamable_http_client(f"{BASE_URL}/mcp", http_client=http)),
1004+
mode=LATEST_MODERN_VERSION,
1005+
elicitation_callback=answer_url,
1006+
) as client,
1007+
):
1008+
result = await client.call_tool("protected", {})
1009+
1010+
# The URL params crossed the real wire intact; elicitation_id rides its None default at 2026.
1011+
assert received == snapshot(
1012+
[ElicitRequestURLParams(message="Sign in to continue.", url="https://example.com/auth")]
1013+
)
1014+
assert result == snapshot(CallToolResult(content=[TextContent(text="accept content=None")]))
1015+
# Positive control: the recording demonstrably captured the MRTR interim leg, so the scan
1016+
# below covers a conversation that contained the input_required round, not an empty log.
1017+
interim = [
1018+
message.message
1019+
for message in recording.received
1020+
if isinstance(message, SessionMessage)
1021+
and isinstance(message.message, JSONRPCResponse)
1022+
and message.message.result.get("resultType") == "input_required"
1023+
]
1024+
assert len(interim) == 1
1025+
# The negative: no serialized frame in either direction carries the retired code. A substring
1026+
# scan also catches the code smuggled inside a result body -- the exact shape the 2025 era
1027+
# surfaced it in. Test-controlled payloads and single-digit request ids leave no legitimate
1028+
# occurrence of the digits.
1029+
frames = [
1030+
message.message.model_dump_json(by_alias=True, exclude_none=True)
1031+
for message in [*recording.sent, *recording.received]
1032+
if isinstance(message, SessionMessage)
1033+
]
1034+
assert all(str(URL_ELICITATION_REQUIRED) not in frame for frame in frames)

0 commit comments

Comments
 (0)