|
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 | SERVER_INFO_META_KEY, |
28 | 31 | CallToolRequestParams, |
29 | 32 | CallToolResult, |
30 | 33 | DiscoverResult, |
| 34 | + ElicitRequestParams, |
| 35 | + ElicitResult, |
31 | 36 | EmptyResult, |
32 | 37 | ErrorData, |
33 | 38 | GetPromptRequestParams, |
|
56 | 61 | from starlette.requests import Request as StarletteRequest |
57 | 62 |
|
58 | 63 | from mcp import MCPError |
| 64 | +from mcp.client import ClientRequestContext |
59 | 65 | from mcp.client.client import Client |
60 | 66 | from mcp.client.session import ClientSession |
61 | 67 | from mcp.client.streamable_http import streamable_http_client |
62 | 68 | from mcp.server import Server, ServerRequestContext |
| 69 | +from mcp.shared.exceptions import NoBackChannelError |
63 | 70 | from tests.interaction._connect import ( |
64 | 71 | BASE_URL, |
65 | 72 | base_headers, |
@@ -1432,6 +1439,81 @@ async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> |
1432 | 1439 | ) |
1433 | 1440 |
|
1434 | 1441 |
|
| 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 | + |
1435 | 1517 | @requirement("hosting:http:request-headers-in-handler") |
1436 | 1518 | async def test_custom_request_header_reaches_the_handler_request_context_on_both_serving_paths() -> None: |
1437 | 1519 | """A custom HTTP header sent by the client reaches the handler's ctx.request on both serving paths. |
|
0 commit comments