Skip to content

Commit ec24be9

Browse files
committed
Backlog hardening: complete the push-API divergence matrix and sharpen three tests
Add the HTTP request-scoped loud-fail test, completing all four legs of the push-API divergence record (both transports x both legs). Add the missing templates/list decorator on the static-and-templated listing test. Redesign the post-connect registration fixture to mutate the tool set between requests rather than from inside a handler, so the fixture itself no longer violates the 2026 list-stability requirement on live cells. Assert that an iss-mismatch rejection never exchanges the authorization code (with a liveness guard on the recorded /token calls). 909 -> 910 cells.
1 parent c16f6dc commit ec24be9

4 files changed

Lines changed: 107 additions & 15 deletions

File tree

‎tests/interaction/auth/test_authorize_token.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,10 @@ async def test_a_mismatched_iss_on_the_callback_aborts_the_flow() -> None:
192192
"""A callback whose RFC 9207 iss does not match the authorization server issuer aborts the flow.
193193
194194
`iss_override` makes the headless callback return an issuer the AS never advertised; the SDK
195-
compares it to `oauth_metadata.issuer` and raises `OAuthFlowError` before the token exchange.
195+
compares it to `oauth_metadata.issuer` and raises `OAuthFlowError` before the token exchange --
196+
the recorded traffic shows no /token POST, so the tainted authorization code is never exchanged.
196197
"""
198+
recorded, on_request = record_requests()
197199
provider = InMemoryAuthorizationServerProvider()
198200
server = Server("guarded", on_list_tools=list_tools)
199201
headless = HeadlessOAuth(iss_override="https://attacker.example.com")
@@ -202,7 +204,11 @@ async def test_a_mismatched_iss_on_the_callback_aborts_the_flow() -> None:
202204
with pytest.RaisesGroup(
203205
pytest.RaisesExc(OAuthFlowError, match="^Authorization response iss mismatch:"), flatten_subgroups=True
204206
):
205-
await connect_with_oauth(server, provider=provider, headless=headless).__aenter__()
207+
await connect_with_oauth(server, provider=provider, headless=headless, on_request=on_request).__aenter__()
208+
209+
# The recorded unauthenticated trigger POST guards the negative below against an unwired hook.
210+
assert find(recorded, "POST", "/mcp") != []
211+
assert find(recorded, "POST", "/token") == []
206212

207213

208214
@requirement("client-auth:resource-parameter")

‎tests/interaction/mcpserver/test_resources.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def app_config() -> str:
4242

4343

4444
@requirement("mcpserver:resource:static")
45+
@requirement("mcpserver:resource:template")
4546
async def test_list_static_and_templated_resources(connect: Connect, unstamped: Unstamp) -> None:
4647
"""Statically-registered resources appear in resources/list; templated ones only in templates/list.
4748

‎tests/interaction/mcpserver/test_tools.py‎

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -405,9 +405,11 @@ async def test_adding_and_removing_tools_does_not_notify_connected_clients(conne
405405
406406
add_tool and remove_tool only update the registry: a connected client that listed the tools
407407
before the mutation has no way to learn it should list them again. The spec provides
408-
notifications/tools/list_changed for exactly this; MCPServer never sends it. The tool emits
409-
one log message as a sentinel so the test proves notifications do reach the collector -- the
410-
log message arrives, a list_changed does not.
408+
notifications/tools/list_changed for exactly this; MCPServer never sends it. The mutation
409+
happens out-of-band, between requests -- the spec's "MAY change over time" allowance; varying
410+
the set as a side effect of another request on the connection is forbidden at 2026-07-28. The
411+
sentinel tool logs one message after the mutation so the test proves notifications do reach
412+
the collector -- the log message arrives, a list_changed does not.
411413
"""
412414
received: list[IncomingMessage] = []
413415
mcp = MCPServer("mutable")
@@ -422,22 +424,23 @@ def doomed() -> str:
422424
raise NotImplementedError
423425

424426
@mcp.tool()
425-
async def grow(ctx: Context) -> str:
426-
mcp.add_tool(extra, name="extra")
427-
mcp.remove_tool("doomed")
428-
await ctx.info("tool set changed") # pyright: ignore[reportDeprecated]
429-
return "mutated"
427+
async def sentinel(ctx: Context) -> str:
428+
await ctx.info("after the mutation") # pyright: ignore[reportDeprecated]
429+
return "sentinel ran"
430430

431431
async def collect(message: IncomingMessage) -> None:
432432
received.append(message)
433433

434434
async with connect(mcp, message_handler=collect) as client:
435435
before = await client.list_tools()
436-
await client.call_tool("grow", {})
436+
# Out-of-band: no request is in flight while the set changes.
437+
mcp.add_tool(extra, name="extra")
438+
mcp.remove_tool("doomed")
439+
await client.call_tool("sentinel", {})
437440
after = await client.list_tools()
438441

439-
assert [tool.name for tool in before.tools] == ["doomed", "grow"]
440-
assert [tool.name for tool in after.tools] == ["grow", "extra"]
442+
assert [tool.name for tool in before.tools] == ["doomed", "sentinel"]
443+
assert [tool.name for tool in after.tools] == ["sentinel", "extra"]
441444
assert received == snapshot(
442-
[LoggingMessageNotification(params=LoggingMessageNotificationParams(level="info", data="tool set changed"))]
445+
[LoggingMessageNotification(params=LoggingMessageNotificationParams(level="info", data="after the mutation"))]
443446
)

‎tests/interaction/transports/test_hosting_http_modern.py‎

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
asserting the wire contract for a 2026-07-28 POST -- one self-contained request, no initialize
55
handshake, no ``Mcp-Session-Id``, JSON response body -- and that 2025-era traffic on the same
66
endpoint is byte-unchanged. The SDK client never exposes the response headers or the raw
7-
result-envelope shape, so every assertion here is necessarily wire-level.
7+
result-envelope shape, so every assertion here is necessarily wire-level. A handful of tests
8+
instead drive the SDK client against the mounted entry, for serving behaviour that is specific
9+
to this transport but observable through the public API.
810
"""
911

1012
import json
@@ -21,13 +23,16 @@
2123
HEADER_MISMATCH,
2224
INTERNAL_ERROR,
2325
INVALID_PARAMS,
26+
INVALID_REQUEST,
2427
METHOD_NOT_FOUND,
2528
MISSING_REQUIRED_CLIENT_CAPABILITY,
2629
PROTOCOL_VERSION_META_KEY,
2730
SERVER_INFO_META_KEY,
2831
CallToolRequestParams,
2932
CallToolResult,
3033
DiscoverResult,
34+
ElicitRequestParams,
35+
ElicitResult,
3136
EmptyResult,
3237
ErrorData,
3338
GetPromptRequestParams,
@@ -56,10 +61,12 @@
5661
from starlette.requests import Request as StarletteRequest
5762

5863
from mcp import MCPError
64+
from mcp.client import ClientRequestContext
5965
from mcp.client.client import Client
6066
from mcp.client.session import ClientSession
6167
from mcp.client.streamable_http import streamable_http_client
6268
from mcp.server import Server, ServerRequestContext
69+
from mcp.shared.exceptions import NoBackChannelError
6370
from tests.interaction._connect import (
6471
BASE_URL,
6572
base_headers,
@@ -1432,6 +1439,81 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) ->
14321439
)
14331440

14341441

1442+
@requirement("mrtr:push-api:loud-fail-2026")
1443+
async def test_modern_request_scoped_push_elicit_loud_fails_locally_and_the_call_still_completes() -> None:
1444+
"""A request-scoped push elicit over the modern HTTP entry raises the typed local error and the
1445+
call still completes -- the related_request_id leg that the in-memory pair transmits (the
1446+
divergence pinned in lowlevel/test_mrtr.py) loud-fails here, byte-identical to the standalone
1447+
legs, because this entry's per-request dispatch context hard-codes its back-channel away.
1448+
1449+
The outcome is spec-mandated (the previous server-initiated request pattern is no longer
1450+
supported) but the enforcement at this pin is incidental -- no back-channel, not an era gate.
1451+
Transport-pinned: the modern entry's gate is the only enforcement of the 2026 push prohibition
1452+
that holds on this leg, so it gets its own regression pin against the requirement's divergence
1453+
matrix.
1454+
"""
1455+
caught: list[NoBackChannelError] = []
1456+
1457+
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
1458+
# Live (not NotImplementedError): the client's output-schema cache refresh invokes
1459+
# tools/list right after the tools/call result.
1460+
return ListToolsResult(tools=[Tool(name="ask", input_schema={"type": "object"})])
1461+
1462+
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
1463+
assert params.name == "ask"
1464+
assert ctx.request_id is not None
1465+
try:
1466+
# The related id selects the per-request dispatch channel -- the leg whose in-memory
1467+
# twin still transmits the forbidden frame.
1468+
await ctx.session.elicit_form(
1469+
"Need a name",
1470+
{"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]},
1471+
related_request_id=ctx.request_id,
1472+
)
1473+
except NoBackChannelError as exc:
1474+
caught.append(exc)
1475+
return CallToolResult(content=[TextContent(text="fallback")])
1476+
1477+
server = Server("scoped-push", on_list_tools=list_tools, on_call_tool=call_tool)
1478+
1479+
# Registered so the client declares the elicitation capability in the per-request envelope,
1480+
# isolating the failure to the missing back-channel rather than to capability gating; the
1481+
# body is itself the never-delivered assertion.
1482+
async def never_deliverable(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
1483+
raise NotImplementedError
1484+
1485+
discover = DiscoverResult(
1486+
supported_versions=[LATEST_MODERN_VERSION],
1487+
capabilities=ServerCapabilities(),
1488+
server_info=Implementation(name="srv", version="0"),
1489+
)
1490+
with anyio.fail_after(5):
1491+
async with (
1492+
mounted_app(server) as (http, _),
1493+
Client(
1494+
streamable_http_client(f"{BASE_URL}/mcp", http_client=http),
1495+
mode=LATEST_MODERN_VERSION,
1496+
prior_discover=discover,
1497+
elicitation_callback=never_deliverable,
1498+
) as client,
1499+
):
1500+
result = await client.call_tool("ask", {})
1501+
1502+
# The failed push did not poison the request: the call completes with the handler's fallback.
1503+
assert result == snapshot(CallToolResult(content=[TextContent(text="fallback")]))
1504+
assert len(caught) == 1
1505+
assert caught[0].method == "elicitation/create"
1506+
assert caught[0].error == snapshot(
1507+
ErrorData(
1508+
code=INVALID_REQUEST,
1509+
message=(
1510+
"Cannot send 'elicitation/create': this transport context has no back-channel "
1511+
"for server-initiated requests."
1512+
),
1513+
)
1514+
)
1515+
1516+
14351517
@requirement("hosting:http:request-headers-in-handler")
14361518
async def test_custom_request_header_reaches_the_handler_request_context_on_both_serving_paths() -> None:
14371519
"""A custom HTTP header sent by the client reaches the handler's ctx.request on both serving paths.

0 commit comments

Comments
 (0)