|
4 | 4 | asserting the wire contract for a 2026-07-28 POST -- one self-contained request, no initialize |
5 | 5 | handshake, no ``Mcp-Session-Id``, JSON response body -- and that 2025-era traffic on the same |
6 | 6 | 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. |
8 | 10 | """ |
9 | 11 |
|
10 | 12 | import json |
|
21 | 23 | HEADER_MISMATCH, |
22 | 24 | INTERNAL_ERROR, |
23 | 25 | INVALID_PARAMS, |
| 26 | + INVALID_REQUEST, |
24 | 27 | METHOD_NOT_FOUND, |
25 | 28 | MISSING_REQUIRED_CLIENT_CAPABILITY, |
26 | 29 | PROTOCOL_VERSION_META_KEY, |
27 | 30 | CallToolRequestParams, |
28 | 31 | CallToolResult, |
29 | 32 | DiscoverResult, |
| 33 | + ElicitRequestParams, |
| 34 | + ElicitResult, |
30 | 35 | EmptyResult, |
31 | 36 | ErrorData, |
32 | 37 | GetPromptRequestParams, |
|
55 | 60 | from starlette.requests import Request as StarletteRequest |
56 | 61 |
|
57 | 62 | from mcp import MCPError |
| 63 | +from mcp.client import ClientRequestContext |
58 | 64 | from mcp.client.client import Client |
59 | 65 | from mcp.client.session import ClientSession |
60 | 66 | from mcp.client.streamable_http import streamable_http_client |
61 | 67 | from mcp.server import Server, ServerRequestContext |
| 68 | +from mcp.shared.exceptions import NoBackChannelError |
62 | 69 | from tests.interaction._connect import ( |
63 | 70 | BASE_URL, |
64 | 71 | base_headers, |
@@ -1419,6 +1426,81 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> |
1419 | 1426 | ) |
1420 | 1427 |
|
1421 | 1428 |
|
| 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 | + |
1422 | 1504 | @requirement("hosting:http:request-headers-in-handler") |
1423 | 1505 | async def test_custom_request_header_reaches_the_handler_request_context_on_both_serving_paths() -> None: |
1424 | 1506 | """A custom HTTP header sent by the client reaches the handler's ctx.request on both serving paths. |
|
0 commit comments