Skip to content

Commit 074c99e

Browse files
committed
Reconcile with the hardened dual-era stream loop
The push-API prohibition is now enforced locally on every modern dispatch path: the request-scoped in-memory leg no longer transmits the forbidden frame, so its test flips from pinning the transmitted-and-refused shape to asserting the typed local NoBackChannelError, and the divergence record is removed — every constructible 2026 arm now refuses locally. The stdio serving entry's era-lock description is updated to the landed semantics: the era settles on the first era-distinctive frame to reach a client-visible success, no failure locks, and a cancelled-away success does not lock.
1 parent 7081c14 commit 074c99e

3 files changed

Lines changed: 45 additions & 44 deletions

File tree

‎tests/interaction/_requirements.py‎

Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4073,22 +4073,11 @@ def __post_init__(self) -> None:
40734073
"(NoBackChannelError, INVALID_REQUEST) before any request reaches the client; a handler "
40744074
"can catch it and fall back, and the originating call still completes."
40754075
),
4076-
divergence=Divergence(
4077-
note=(
4078-
"The prohibition is enforced by each transport's missing back-channel, not by an "
4079-
"era gate on the send path, and the enforcement splits per transport and per leg. "
4080-
"Standalone sends (no related_request_id) raise NoBackChannelError locally on both "
4081-
"2026 transports because the per-request Connection has no outbound channel. "
4082-
"Request-scoped sends (related_request_id=...) ride the per-request dispatch "
4083-
"context, whose can_send_request the modern HTTP entry hard-codes to False but the "
4084-
"in-memory direct-dispatcher pair leaves at its True default -- so in-memory the "
4085-
"forbidden elicitation/create frame IS transmitted, and the failure comes back from "
4086-
"the client's 2026 version gate (METHOD_NOT_FOUND) instead of arising locally. An "
4087-
"era-aware gate on the send path would loud-fail both legs on every transport; when "
4088-
"it lands, re-pin the request-scoped in-memory test to the local NoBackChannelError "
4089-
"and delete this divergence."
4090-
),
4091-
issue="L107",
4076+
note=(
4077+
"Era-routed by construction: every modern dispatch path hands handlers a request-scoped "
4078+
"channel that refuses server-initiated requests and a connection with no standalone "
4079+
"back-channel, so the refusal is local on both legs of every 2026 transport, while legacy "
4080+
"connections keep their live back-channel."
40924081
),
40934082
added_in="2026-07-28",
40944083
),
@@ -7647,15 +7636,18 @@ def __post_init__(self) -> None:
76477636
transports=("stdio",),
76487637
deferred=(
76497638
"Not yet covered here: stream-pair 2026 serving landed -- serve_dual_era_loop "
7650-
"(src/mcp/server/runner.py) locks each stream-pair connection's era on its first "
7651-
"era-distinctive frame (a request that classifies as modern locks modern before it is "
7652-
"served; a successful initialize locks legacy; a rejected classification never locks), "
7653-
"routing server/discover and envelope-bearing requests to a per-request "
7654-
"Connection.from_envelope, and Server.run drives it for stdio, so the suite's "
7655-
"subprocess server (tests/interaction/transports/_stdio_server.py) already serves both "
7656-
"eras unchanged. The test connects a mode='auto' client that negotiates 2026-07-28 via "
7657-
"server/discover alongside the existing legacy-mode connection against the same "
7658-
"factory, over a real child-process pipe."
7639+
"(src/mcp/server/runner.py) locks each stream-pair connection's era on the first "
7640+
"era-distinctive frame to succeed (a request that classifies as modern locks modern "
7641+
"only after it is served to a client-visible success; a successful initialize locks "
7642+
"legacy; no failure of any kind locks -- rejected classification, malformed envelope "
7643+
"content, unknown method, handler error -- and a success the peer cancelled away from "
7644+
"does not lock either; the lock settles once, so a straggling other-era success "
7645+
"cannot move it), routing server/discover and envelope-bearing requests to a "
7646+
"per-request Connection.from_envelope, and Server.run drives it for stdio, so the "
7647+
"suite's subprocess server (tests/interaction/transports/_stdio_server.py) already "
7648+
"serves both eras unchanged. The test connects a mode='auto' client that negotiates "
7649+
"2026-07-28 via server/discover alongside the existing legacy-mode connection against "
7650+
"the same factory, over a real child-process pipe."
76597651
),
76607652
note=(
76617653
"stdio-only by definition: the dual-era HTTP analogue is the session manager's "

‎tests/interaction/lowlevel/test_mrtr.py‎

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
CLIENT_INFO_META_KEY,
1717
INVALID_PARAMS,
1818
INVALID_REQUEST,
19-
METHOD_NOT_FOUND,
2019
PROTOCOL_VERSION_META_KEY,
2120
CallToolResult,
2221
ClientCapabilities,
@@ -481,8 +480,10 @@ async def call_tool(
481480
async def test_push_elicit_on_2026_raises_typed_local_error_and_call_still_completes(connect: Connect) -> None:
482481
"""A push API call on a 2026 connection raises a typed local error and the call still completes.
483482
484-
Spec-mandated outcome, incidental enforcement: the gate is "no back-channel", not "wrong era".
485-
One push API stands for all four: they share ServerSession.send_request's channel selection.
483+
Spec-mandated outcome, era-routed enforcement: every modern dispatch path installs a
484+
channel-less context by construction, so the gate is "no back-channel", never a send-time
485+
era check. One push API stands for all four: they share ServerSession.send_request's
486+
channel selection.
486487
"""
487488
caught: list[NoBackChannelError] = []
488489

@@ -524,14 +525,16 @@ async def never_deliverable(context: ClientRequestContext, params: types.ElicitR
524525

525526

526527
@requirement("mrtr:push-api:loud-fail-2026")
527-
async def test_request_scoped_push_elicit_on_in_memory_2026_transmits_the_forbidden_frame() -> None:
528-
"""PINS A KNOWN GAP: a request-scoped push elicit on in-memory 2026 transmits the forbidden frame.
529-
530-
The no-back-channel gate is per-transport and the in-memory request-scoped channel still has one,
531-
so the failure comes back from the client's 2026 version gate instead of arising locally. When an
532-
era-aware send gate lands: re-pin to the local NoBackChannelError and delete the Divergence.
528+
async def test_request_scoped_push_elicit_on_in_memory_2026_loud_fails_locally_and_the_call_still_completes() -> None:
529+
"""A request-scoped push elicit on in-memory 2026 loud-fails locally and the call still completes.
530+
531+
The related id routes the send onto the per-request dispatch channel -- the one leg whose
532+
channel is otherwise live in-memory -- so this pin proves local provenance: the typed
533+
NoBackChannelError (never a peer answer) and a callback that never fires. A delivered frame
534+
would raise NotImplementedError in the callback, surface as a non-NoBackChannelError error,
535+
escape the narrowed except, and fail the test loudly.
533536
"""
534-
caught: list[MCPError] = []
537+
caught: list[NoBackChannelError] = []
535538

536539
async def list_tools(
537540
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
@@ -544,26 +547,32 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara
544547
try:
545548
# The related id routes the send onto the per-request dispatch channel.
546549
await ctx.session.elicit_form("Need a name", _NAME_SCHEMA, related_request_id=ctx.request_id)
547-
except MCPError as exc:
548-
# MCPError, not NoBackChannelError: nothing is raised locally -- the failure is the peer's answer.
550+
except NoBackChannelError as exc:
551+
# Narrow on purpose: a peer-answered MCPError would propagate and fail the test.
549552
caught.append(exc)
550553
return CallToolResult(content=[TextContent(text="fallback")])
551554

552555
server = Server("scoped-push", on_list_tools=list_tools, on_call_tool=call_tool)
553556

554-
# Declares the elicitation capability; the body is itself the never-delivered assertion.
557+
# Registering the callback declares the elicitation capability; it must never fire.
555558
async def never_deliverable(context: ClientRequestContext, params: types.ElicitRequestParams) -> ElicitResult:
556559
raise NotImplementedError
557560

558561
async with Client(server, mode=LATEST_MODERN_VERSION, elicitation_callback=never_deliverable) as client:
559562
result = await client.call_tool("ask", {})
560563

561-
# The connection survives the rejected frame.
564+
# The failed push did not poison the request: the call completes with the handler's fallback.
562565
assert result == snapshot(CallToolResult(content=[TextContent(text="fallback")]))
563566
assert len(caught) == 1
564-
# Only the pre-dispatch client version gate answers data=<method>: transmission proven, callback never reached.
567+
assert caught[0].method == "elicitation/create"
565568
assert caught[0].error == snapshot(
566-
ErrorData(code=METHOD_NOT_FOUND, message="Method not found", data="elicitation/create")
569+
ErrorData(
570+
code=INVALID_REQUEST,
571+
message=(
572+
"Cannot send 'elicitation/create': this transport context has no back-channel "
573+
"for server-initiated requests."
574+
),
575+
)
567576
)
568577

569578

‎tests/interaction/transports/test_hosting_http_modern.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1377,9 +1377,9 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) ->
13771377
async def test_modern_request_scoped_push_elicit_loud_fails_locally_and_the_call_still_completes() -> None:
13781378
"""A request-scoped push elicit over the modern HTTP entry loud-fails locally and the call still completes.
13791379
1380-
Spec-mandated outcome; the enforcement here is incidental (no back-channel, not an era gate).
1381-
The in-memory twin of this leg still transmits the frame -- the divergence pinned in
1382-
lowlevel/test_mrtr.py -- so the modern entry's gate gets its own regression pin.
1380+
Spec-mandated outcome: the modern HTTP entry builds its per-request channel with no
1381+
back-channel, so the refusal is local by construction. The in-memory twin of this leg is
1382+
pinned in lowlevel/test_mrtr.py; this pin keeps the HTTP entry's own gate regression-covered.
13831383
"""
13841384
caught: list[NoBackChannelError] = []
13851385

0 commit comments

Comments
 (0)