Skip to content

Commit 4a38c8b

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 e73b740 commit 4a38c8b

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
@@ -41,6 +41,7 @@ def app_config() -> str:
4141

4242

4343
@requirement("mcpserver:resource:static")
44+
@requirement("mcpserver:resource:template")
4445
async def test_list_static_and_templated_resources(connect: Connect) -> None:
4546
"""Statically-registered resources appear in resources/list; templated ones only in templates/list.
4647

tests/interaction/mcpserver/test_tools.py

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

413415
@mcp.tool()
414-
async def grow(ctx: Context) -> str:
415-
mcp.add_tool(extra, name="extra")
416-
mcp.remove_tool("doomed")
417-
await ctx.info("tool set changed") # pyright: ignore[reportDeprecated]
418-
return "mutated"
416+
async def sentinel(ctx: Context) -> str:
417+
await ctx.info("after the mutation") # pyright: ignore[reportDeprecated]
418+
return "sentinel ran"
419419

420420
async def collect(message: IncomingMessage) -> None:
421421
received.append(message)
422422

423423
async with connect(mcp, message_handler=collect) as client:
424424
before = await client.list_tools()
425-
await client.call_tool("grow", {})
425+
# Out-of-band: no request is in flight while the set changes.
426+
mcp.add_tool(extra, name="extra")
427+
mcp.remove_tool("doomed")
428+
await client.call_tool("sentinel", {})
426429
after = await client.list_tools()
427430

428-
assert [tool.name for tool in before.tools] == ["doomed", "grow"]
429-
assert [tool.name for tool in after.tools] == ["grow", "extra"]
431+
assert [tool.name for tool in before.tools] == ["doomed", "sentinel"]
432+
assert [tool.name for tool in after.tools] == ["sentinel", "extra"]
430433
assert received == snapshot(
431-
[LoggingMessageNotification(params=LoggingMessageNotificationParams(level="info", data="tool set changed"))]
434+
[LoggingMessageNotification(params=LoggingMessageNotificationParams(level="info", data="after the mutation"))]
432435
)

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,12 +23,15 @@
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
CallToolRequestParams,
2831
CallToolResult,
2932
DiscoverResult,
33+
ElicitRequestParams,
34+
ElicitResult,
3035
EmptyResult,
3136
ErrorData,
3237
GetPromptRequestParams,
@@ -55,10 +60,12 @@
5560
from starlette.requests import Request as StarletteRequest
5661

5762
from mcp import MCPError
63+
from mcp.client import ClientRequestContext
5864
from mcp.client.client import Client
5965
from mcp.client.session import ClientSession
6066
from mcp.client.streamable_http import streamable_http_client
6167
from mcp.server import Server, ServerRequestContext
68+
from mcp.shared.exceptions import NoBackChannelError
6269
from tests.interaction._connect import (
6370
BASE_URL,
6471
base_headers,
@@ -1419,6 +1426,81 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) ->
14191426
)
14201427

14211428

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

0 commit comments

Comments
 (0)