Skip to content

Commit 6031f89

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 a76cc2c commit 6031f89

3 files changed

Lines changed: 737 additions & 27 deletions

File tree

tests/interaction/_requirements.py

Lines changed: 96 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,20 @@ def __post_init__(self) -> None:
551551
"to a request the client sent or a notification carrying no id."
552552
),
553553
),
554+
"protocol:directionality:no-client-responses": Requirement(
555+
source=f"{SPEC_2026_BASE_URL}/basic/patterns",
556+
behavior=(
557+
"A 2026-07-28 wire trace contains no server-initiated JSON-RPC requests and no "
558+
"client-sent JSON-RPC responses: every client-to-server frame is a request and every "
559+
"server-to-client frame is a response, even across a multi-round-trip exchange that at "
560+
"2025-11-25 was a server-initiated request answered by the client."
561+
),
562+
added_in="2026-07-28",
563+
note=(
564+
"Asserted at the streamable HTTP wire seam: the in-memory 2026 transport dispatches "
565+
"typed objects directly with no JSON-RPC framing, so it has no trace to inspect."
566+
),
567+
),
554568
"protocol:cancel:abort-signal": Requirement(
555569
source=f"{SPEC_BASE_URL}/basic/utilities/cancellation#cancellation-flow",
556570
behavior=(
@@ -1250,7 +1264,7 @@ def __post_init__(self) -> None:
12501264
note=(
12511265
"removed in 2026-07-28 (SEP-2322); in-tool elicitation now returns an input_required result from "
12521266
"the tool; the push Context API's 2026 failure mode is pinned separately by "
1253-
"mrtr:push-api:loud-fail-2026 when the mrtr add-batch lands it."
1267+
"mrtr:push-api:loud-fail-2026."
12541268
),
12551269
arm_exclusions=(ArmExclusion(reason="server-initiated-request", transport="streamable-http-stateless"),),
12561270
),
@@ -2197,17 +2211,22 @@ def __post_init__(self) -> None:
21972211
"requests require sampling.tools; thisServer/allServers context -- itself deprecated -- should "
21982212
"not be used without sampling.context)."
21992213
),
2214+
divergence=Divergence(
2215+
note=(
2216+
"The embed gate is not implemented: an input_required result carrying a "
2217+
"sampling/createMessage request for a client that declared no sampling capability is "
2218+
"transmitted as-is, and the violation surfaces as the client driver's refusal "
2219+
"(INVALID_REQUEST, 'Sampling not supported') aborting the call. The sub-capability legs "
2220+
"(sampling.tools, sampling.context) are equally ungated and covered by this divergence "
2221+
"without separate pins."
2222+
),
2223+
),
22002224
added_in="2026-07-28",
22012225
supersedes=(
22022226
"sampling:create:not-supported",
22032227
"sampling:tools:server-gated-by-capability",
22042228
"sampling:context:server-gated-by-capability",
22052229
),
2206-
deferred=(
2207-
"Not implemented in the SDK: the server does not gate input_required input requests against the "
2208-
"client's declared capabilities -- a handler can embed a sampling/createMessage request for a "
2209-
"client that never declared the matching capability and it is sent as-is."
2210-
),
22112230
),
22122231
# ═══════════════════════════════════════════════════════════════════════════
22132232
# Elicitation (server → client)
@@ -2577,18 +2596,42 @@ def __post_init__(self) -> None:
25772596
# ═══════════════════════════════════════════════════════════════════════════
25782597
# MRTR (multi-round-trip requests, 2026-07-28)
25792598
# ═══════════════════════════════════════════════════════════════════════════
2599+
"mrtr:input-required-result:at-least-one-of": Requirement(
2600+
source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#server-requirements-basic-workflow",
2601+
behavior=(
2602+
"An InputRequiredResult carries at least one of inputRequests or requestState; a "
2603+
"handler-built violation fails at construction and surfaces to the client as a JSON-RPC "
2604+
"error, never as a malformed interim result."
2605+
),
2606+
added_in="2026-07-28",
2607+
note=(
2608+
"The at-least-one-of MUST is enforced by construction (mcp_types model validator). Both "
2609+
"2026 dispatchers map the handler's ValidationError to the shared "
2610+
"ErrorData(INVALID_PARAMS, 'Invalid request parameters', data='') shape "
2611+
"(handler_exception_to_error_data); INTERNAL_ERROR is arguably the more apt code for a "
2612+
"server-side construction bug, but the spec mandates no code for this failure."
2613+
),
2614+
),
25802615
"mrtr:input-responses:invalid-rejected": Requirement(
25812616
source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#error-handling",
25822617
behavior=(
2583-
"The server validates that a retry's inputResponses parse as valid results for the requests it "
2584-
"issued; content that violates the requested schema is rejected rather than silently accepted."
2618+
"The server validates that a retry's inputResponses parse as a valid InputResponses object; "
2619+
"a structurally malformed map is rejected with a JSON-RPC error before the handler runs."
25852620
),
25862621
added_in="2026-07-28",
25872622
supersedes=("elicitation:form:response-validation",),
2588-
deferred=(
2589-
"Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; "
2590-
"the behaviour is implemented and drivable (phase-4 verdict)."
2623+
note=(
2624+
"Elicited content is handed to the handler without requestedSchema re-validation; servers "
2625+
"validate semantic constraints themselves (spec asks only for structural validation)."
2626+
),
2627+
),
2628+
"mrtr:input-responses:key-correspondence": Requirement(
2629+
source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#inputresponses",
2630+
behavior=(
2631+
"A retry's inputResponses map is keyed by the originating inputRequests keys, each value "
2632+
"the client's typed result for that key's request (e.g. ElicitResult, ListRootsResult)."
25912633
),
2634+
added_in="2026-07-28",
25922635
),
25932636
"mrtr:url-elicitation:no-32042-on-2026": Requirement(
25942637
source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr",
@@ -2604,10 +2647,6 @@ def __post_init__(self) -> None:
26042647
"mcpserver:tool:url-elicitation-error",
26052648
"flow:elicitation:url-required-then-retry",
26062649
),
2607-
deferred=(
2608-
"Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; "
2609-
"the behaviour is implemented and drivable (phase-4 verdict)."
2610-
),
26112650
),
26122651
"mrtr:tools-call:write-once-roundtrip": Requirement(
26132652
source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr#basic-workflow",
@@ -2656,10 +2695,41 @@ def __post_init__(self) -> None:
26562695
),
26572696
added_in="2026-07-28",
26582697
supersedes=("flow:elicitation:multi-step-form",),
2659-
deferred=(
2660-
"Not yet covered here: 2026-07-28 successor entry registered by the era pass ahead of its test; "
2661-
"the behaviour is implemented and drivable (phase-4 verdict)."
2698+
),
2699+
"mrtr:rounds-cap": Requirement(
2700+
source="sdk",
2701+
behavior=(
2702+
"Client.call_tool / get_prompt / read_resource bound the input_required retry loop at the "
2703+
"configurable input_required_max_rounds; a server that keeps answering input_required past "
2704+
"the cap raises InputRequiredRoundsExceededError carrying the configured cap."
26622705
),
2706+
added_in="2026-07-28",
2707+
),
2708+
"mrtr:push-api:loud-fail-2026": Requirement(
2709+
source=f"{SPEC_2026_BASE_URL}/basic/patterns/mrtr",
2710+
behavior=(
2711+
"The push-style server-to-client request APIs (ServerSession.elicit_form / elicit_url / "
2712+
"create_message / list_roots) on a 2026-07-28 connection fail with a typed local error "
2713+
"(NoBackChannelError, INVALID_REQUEST) before any request reaches the client; a handler "
2714+
"can catch it and fall back, and the originating call still completes."
2715+
),
2716+
divergence=Divergence(
2717+
note=(
2718+
"The prohibition is enforced by each transport's missing back-channel, not by an "
2719+
"era gate on the send path, and the enforcement splits per transport and per leg. "
2720+
"Standalone sends (no related_request_id) raise NoBackChannelError locally on both "
2721+
"2026 transports because the per-request Connection has no outbound channel. "
2722+
"Request-scoped sends (related_request_id=...) ride the per-request dispatch "
2723+
"context, whose can_send_request the modern HTTP entry hard-codes to False but the "
2724+
"in-memory direct-dispatcher pair leaves at its True default -- so in-memory the "
2725+
"forbidden elicitation/create frame IS transmitted, and the failure comes back from "
2726+
"the client's 2026 version gate (METHOD_NOT_FOUND) instead of arising locally. An "
2727+
"era-aware gate on the send path would loud-fail both legs on every transport; when "
2728+
"it lands, re-pin the request-scoped in-memory test to the local NoBackChannelError "
2729+
"and delete this divergence."
2730+
),
2731+
),
2732+
added_in="2026-07-28",
26632733
),
26642734
# ═══════════════════════════════════════════════════════════════════════════
26652735
# Roots (server → client)
@@ -2759,13 +2829,16 @@ def __post_init__(self) -> None:
27592829
"The server does not place a roots/list request in an input_required result's inputRequests "
27602830
"for a client that did not declare the roots capability."
27612831
),
2832+
divergence=Divergence(
2833+
note=(
2834+
"The embed gate is not implemented: an input_required result carrying a roots/list "
2835+
"request for a client that did not declare the roots capability is transmitted as-is, "
2836+
"and the violation surfaces as the client driver's refusal (INVALID_REQUEST, 'List "
2837+
"roots not supported') aborting the call."
2838+
),
2839+
),
27622840
added_in="2026-07-28",
27632841
supersedes=("roots:list:not-supported",),
2764-
deferred=(
2765-
"Not implemented in the SDK: the server does not gate input_required input requests against the "
2766-
"client's declared capabilities -- a handler can embed a roots/list request for a client that "
2767-
"never declared the roots capability and it is sent as-is."
2768-
),
27692842
),
27702843
"roots:uri:file-scheme": Requirement(
27712844
source=f"{SPEC_BASE_URL}/client/roots#root",

tests/interaction/lowlevel/test_elicitation.py

Lines changed: 91 additions & 2 deletions
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,14 +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
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
38-
from tests.interaction._helpers import IncomingMessage
41+
from tests.interaction._connect import BASE_URL, Connect, mounted_app
42+
from tests.interaction._helpers import IncomingMessage, RecordingTransport
3943
from tests.interaction._requirements import requirement
4044

4145
pytestmark = pytest.mark.anyio
@@ -943,3 +947,88 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
943947
}
944948
)
945949
)
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)