Skip to content

Commit e75603f

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 3c3eed4 commit e75603f

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
@@ -3975,22 +3975,11 @@ def __post_init__(self) -> None:
39753975
"(NoBackChannelError, INVALID_REQUEST) before any request reaches the client; a handler "
39763976
"can catch it and fall back, and the originating call still completes."
39773977
),
3978-
divergence=Divergence(
3979-
note=(
3980-
"The prohibition is enforced by each transport's missing back-channel, not by an "
3981-
"era gate on the send path, and the enforcement splits per transport and per leg. "
3982-
"Standalone sends (no related_request_id) raise NoBackChannelError locally on both "
3983-
"2026 transports because the per-request Connection has no outbound channel. "
3984-
"Request-scoped sends (related_request_id=...) ride the per-request dispatch "
3985-
"context, whose can_send_request the modern HTTP entry hard-codes to False but the "
3986-
"in-memory direct-dispatcher pair leaves at its True default -- so in-memory the "
3987-
"forbidden elicitation/create frame IS transmitted, and the failure comes back from "
3988-
"the client's 2026 version gate (METHOD_NOT_FOUND) instead of arising locally. An "
3989-
"era-aware gate on the send path would loud-fail both legs on every transport; when "
3990-
"it lands, re-pin the request-scoped in-memory test to the local NoBackChannelError "
3991-
"and delete this divergence."
3992-
),
3993-
issue="L107",
3978+
note=(
3979+
"Era-routed by construction: every modern dispatch path hands handlers a request-scoped "
3980+
"channel that refuses server-initiated requests and a connection with no standalone "
3981+
"back-channel, so the refusal is local on both legs of every 2026 transport, while legacy "
3982+
"connections keep their live back-channel."
39943983
),
39953984
added_in="2026-07-28",
39963985
),
@@ -7478,15 +7467,18 @@ def __post_init__(self) -> None:
74787467
transports=("stdio",),
74797468
deferred=(
74807469
"Not yet covered here: stream-pair 2026 serving landed -- serve_dual_era_loop "
7481-
"(src/mcp/server/runner.py) locks each stream-pair connection's era on its first "
7482-
"era-distinctive frame (a request that classifies as modern locks modern before it is "
7483-
"served; a successful initialize locks legacy; a rejected classification never locks), "
7484-
"routing server/discover and envelope-bearing requests to a per-request "
7485-
"Connection.from_envelope, and Server.run drives it for stdio, so the suite's "
7486-
"subprocess server (tests/interaction/transports/_stdio_server.py) already serves both "
7487-
"eras unchanged. The test connects a mode='auto' client that negotiates 2026-07-28 via "
7488-
"server/discover alongside the existing legacy-mode connection against the same "
7489-
"factory, over a real child-process pipe."
7470+
"(src/mcp/server/runner.py) locks each stream-pair connection's era on the first "
7471+
"era-distinctive frame to succeed (a request that classifies as modern locks modern "
7472+
"only after it is served to a client-visible success; a successful initialize locks "
7473+
"legacy; no failure of any kind locks -- rejected classification, malformed envelope "
7474+
"content, unknown method, handler error -- and a success the peer cancelled away from "
7475+
"does not lock either; the lock settles once, so a straggling other-era success "
7476+
"cannot move it), routing server/discover and envelope-bearing requests to a "
7477+
"per-request Connection.from_envelope, and Server.run drives it for stdio, so the "
7478+
"suite's subprocess server (tests/interaction/transports/_stdio_server.py) already "
7479+
"serves both eras unchanged. The test connects a mode='auto' client that negotiates "
7480+
"2026-07-28 via server/discover alongside the existing legacy-mode connection against "
7481+
"the same factory, over a real child-process pipe."
74907482
),
74917483
note=(
74927484
"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
@@ -1331,9 +1331,9 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) ->
13311331
async def test_modern_request_scoped_push_elicit_loud_fails_locally_and_the_call_still_completes() -> None:
13321332
"""A request-scoped push elicit over the modern HTTP entry loud-fails locally and the call still completes.
13331333
1334-
Spec-mandated outcome; the enforcement here is incidental (no back-channel, not an era gate).
1335-
The in-memory twin of this leg still transmits the frame -- the divergence pinned in
1336-
lowlevel/test_mrtr.py -- so the modern entry's gate gets its own regression pin.
1334+
Spec-mandated outcome: the modern HTTP entry builds its per-request channel with no
1335+
back-channel, so the refusal is local by construction. The in-memory twin of this leg is
1336+
pinned in lowlevel/test_mrtr.py; this pin keeps the HTTP entry's own gate regression-covered.
13371337
"""
13381338
caught: list[NoBackChannelError] = []
13391339

0 commit comments

Comments
 (0)