|
25 | 25 | INVALID_PARAMS, |
26 | 26 | LATEST_PROTOCOL_VERSION, |
27 | 27 | METHOD_NOT_FOUND, |
| 28 | + PROTOCOL_VERSION_META_KEY, |
28 | 29 | REQUEST_TIMEOUT, |
| 30 | + UNSUPPORTED_PROTOCOL_VERSION, |
29 | 31 | CallToolResult, |
30 | 32 | Implementation, |
31 | 33 | InitializedNotification, |
@@ -1435,3 +1437,195 @@ async def test_send_notification_after_close_is_dropped_silently(): |
1435 | 1437 | finally: |
1436 | 1438 | for s in (s2c_send, s2c_recv, c2s_send, c2s_recv): |
1437 | 1439 | s.close() |
| 1440 | + |
| 1441 | + |
| 1442 | +# --- discover() ladder --- |
| 1443 | + |
| 1444 | + |
| 1445 | +class _ScriptedDispatcher: |
| 1446 | + """Records every `send_raw_request` and plays back scripted answers in order. |
| 1447 | +
|
| 1448 | + A script entry that is an `Exception` is raised; a dict is returned.""" |
| 1449 | + |
| 1450 | + def __init__(self, *script: dict[str, Any] | Exception) -> None: |
| 1451 | + self.calls: list[tuple[str, Mapping[str, Any] | None]] = [] |
| 1452 | + self.notifies: list[str] = [] |
| 1453 | + self._script: list[dict[str, Any] | Exception] = list(script) |
| 1454 | + |
| 1455 | + async def run( |
| 1456 | + self, |
| 1457 | + on_request: OnRequest, |
| 1458 | + on_notify: OnNotify, |
| 1459 | + *, |
| 1460 | + task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED, |
| 1461 | + ) -> None: |
| 1462 | + task_status.started() |
| 1463 | + await anyio.sleep_forever() |
| 1464 | + |
| 1465 | + async def send_raw_request( |
| 1466 | + self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None |
| 1467 | + ) -> dict[str, Any]: |
| 1468 | + self.calls.append((method, params)) |
| 1469 | + item = self._script.pop(0) |
| 1470 | + if isinstance(item, Exception): |
| 1471 | + raise item |
| 1472 | + return item |
| 1473 | + |
| 1474 | + async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None: |
| 1475 | + self.notifies.append(method) |
| 1476 | + |
| 1477 | + |
| 1478 | +def _discover_result_dict() -> dict[str, Any]: |
| 1479 | + return types.DiscoverResult( |
| 1480 | + supported_versions=["2026-07-28"], |
| 1481 | + capabilities=ServerCapabilities(), |
| 1482 | + server_info=Implementation(name="stub", version="0"), |
| 1483 | + ).model_dump(by_alias=True, mode="json", exclude_none=True) |
| 1484 | + |
| 1485 | + |
| 1486 | +def _initialize_result_dict() -> dict[str, Any]: |
| 1487 | + return InitializeResult( |
| 1488 | + protocol_version=HANDSHAKE_PROTOCOL_VERSIONS[-1], |
| 1489 | + capabilities=ServerCapabilities(), |
| 1490 | + server_info=Implementation(name="stub", version="0"), |
| 1491 | + ).model_dump(by_alias=True, mode="json", exclude_none=True) |
| 1492 | + |
| 1493 | + |
| 1494 | +@pytest.mark.anyio |
| 1495 | +async def test_discover_adopts_the_returned_result_and_installs_the_modern_stamp() -> None: |
| 1496 | + """SDK-defined: a successful `server/discover` is adopted and subsequent requests |
| 1497 | + carry the modern `_meta` envelope (protocol version + client info + capabilities).""" |
| 1498 | + dispatcher = _ScriptedDispatcher(_discover_result_dict(), {}) |
| 1499 | + with anyio.fail_after(5): |
| 1500 | + async with ClientSession(dispatcher=dispatcher) as session: |
| 1501 | + await session.discover() |
| 1502 | + assert session.protocol_version == "2026-07-28" |
| 1503 | + await session.send_ping() |
| 1504 | + ping_method, ping_params = dispatcher.calls[-1] |
| 1505 | + assert ping_method == "ping" |
| 1506 | + assert ping_params is not None |
| 1507 | + assert ping_params["_meta"][PROTOCOL_VERSION_META_KEY] == "2026-07-28" |
| 1508 | + |
| 1509 | + |
| 1510 | +@pytest.mark.anyio |
| 1511 | +async def test_discover_retries_once_on_unsupported_version_then_adopts() -> None: |
| 1512 | + """Spec SHOULD: a -32022 reply that names a mutually-supported version |
| 1513 | + triggers exactly one retry at that version, and the retry's result is adopted.""" |
| 1514 | + dispatcher = _ScriptedDispatcher( |
| 1515 | + MCPError( |
| 1516 | + UNSUPPORTED_PROTOCOL_VERSION, |
| 1517 | + "unsupported", |
| 1518 | + data={"supported": ["2026-07-28"], "requested": "2026-07-28"}, |
| 1519 | + ), |
| 1520 | + _discover_result_dict(), |
| 1521 | + ) |
| 1522 | + with anyio.fail_after(5): |
| 1523 | + async with ClientSession(dispatcher=dispatcher) as session: |
| 1524 | + await session.discover() |
| 1525 | + assert session.protocol_version == "2026-07-28" |
| 1526 | + assert [m for m, _ in dispatcher.calls] == ["server/discover", "server/discover"] |
| 1527 | + |
| 1528 | + |
| 1529 | +@pytest.mark.anyio |
| 1530 | +async def test_discover_raises_when_retry_intersection_is_empty() -> None: |
| 1531 | + """Spec SHOULD: a -32022 reply whose `supported` list shares nothing with the |
| 1532 | + client's modern versions is unrecoverable — the original error is re-raised |
| 1533 | + without a second probe.""" |
| 1534 | + dispatcher = _ScriptedDispatcher( |
| 1535 | + MCPError( |
| 1536 | + UNSUPPORTED_PROTOCOL_VERSION, |
| 1537 | + "unsupported", |
| 1538 | + data={"supported": ["1999-01-01"], "requested": "2026-07-28"}, |
| 1539 | + ), |
| 1540 | + ) |
| 1541 | + with anyio.fail_after(5): |
| 1542 | + async with ClientSession(dispatcher=dispatcher) as session: |
| 1543 | + with pytest.raises(MCPError) as exc: |
| 1544 | + await session.discover() |
| 1545 | + assert exc.value.error.code == UNSUPPORTED_PROTOCOL_VERSION |
| 1546 | + assert [m for m, _ in dispatcher.calls] == ["server/discover"] |
| 1547 | + |
| 1548 | + |
| 1549 | +@pytest.mark.anyio |
| 1550 | +async def test_discover_falls_back_to_initialize_on_method_not_found() -> None: |
| 1551 | + """Spec SHOULD: a legacy server that answers -32601 to `server/discover` is |
| 1552 | + transparently driven through the handshake instead.""" |
| 1553 | + dispatcher = _ScriptedDispatcher( |
| 1554 | + MCPError(METHOD_NOT_FOUND, "Method not found"), |
| 1555 | + _initialize_result_dict(), |
| 1556 | + ) |
| 1557 | + with anyio.fail_after(5): |
| 1558 | + async with ClientSession(dispatcher=dispatcher) as session: |
| 1559 | + await session.discover() |
| 1560 | + assert session.protocol_version in HANDSHAKE_PROTOCOL_VERSIONS |
| 1561 | + assert [m for m, _ in dispatcher.calls] == ["server/discover", "initialize"] |
| 1562 | + assert dispatcher.notifies == ["notifications/initialized"] |
| 1563 | + |
| 1564 | + |
| 1565 | +@pytest.mark.anyio |
| 1566 | +async def test_discover_falls_back_to_initialize_on_timeout() -> None: |
| 1567 | + """Spec SHOULD: a `REQUEST_TIMEOUT` from the dispatcher is treated the same as |
| 1568 | + method-not-found — the server is presumed legacy and `initialize()` runs.""" |
| 1569 | + dispatcher = _ScriptedDispatcher( |
| 1570 | + MCPError(REQUEST_TIMEOUT, "timed out"), |
| 1571 | + _initialize_result_dict(), |
| 1572 | + ) |
| 1573 | + with anyio.fail_after(5): |
| 1574 | + async with ClientSession(dispatcher=dispatcher) as session: |
| 1575 | + await session.discover() |
| 1576 | + assert session.protocol_version in HANDSHAKE_PROTOCOL_VERSIONS |
| 1577 | + assert [m for m, _ in dispatcher.calls] == ["server/discover", "initialize"] |
| 1578 | + |
| 1579 | + |
| 1580 | +@pytest.mark.anyio |
| 1581 | +async def test_discover_reraises_on_other_errors() -> None: |
| 1582 | + """SDK-defined: any error outside the retry/fallback ladder propagates verbatim |
| 1583 | + — `discover()` does not mask server failures by falling back to `initialize()`.""" |
| 1584 | + dispatcher = _ScriptedDispatcher(MCPError(INTERNAL_ERROR, "boom")) |
| 1585 | + with anyio.fail_after(5): |
| 1586 | + async with ClientSession(dispatcher=dispatcher) as session: |
| 1587 | + with pytest.raises(MCPError) as exc: |
| 1588 | + await session.discover() |
| 1589 | + assert exc.value.error.code == INTERNAL_ERROR |
| 1590 | + assert [m for m, _ in dispatcher.calls] == ["server/discover"] |
| 1591 | + |
| 1592 | + |
| 1593 | +@pytest.mark.anyio |
| 1594 | +async def test_discover_validates_the_response_shape_before_adopting() -> None: |
| 1595 | + """SDK-defined: the raw response is run through `DiscoverResult` validation |
| 1596 | + before any state is installed, so a malformed reply leaves the session |
| 1597 | + un-adopted rather than half-configured.""" |
| 1598 | + dispatcher = _ScriptedDispatcher({"supportedVersions": ["2026-07-28"]}) |
| 1599 | + session = ClientSession(dispatcher=dispatcher) |
| 1600 | + with anyio.fail_after(5): |
| 1601 | + async with session: |
| 1602 | + with pytest.raises(ValidationError): |
| 1603 | + await session.discover() |
| 1604 | + assert session.protocol_version is None |
| 1605 | + |
| 1606 | + |
| 1607 | +@pytest.mark.anyio |
| 1608 | +async def test_discover_is_idempotent_and_returns_the_cached_result() -> None: |
| 1609 | + """SDK-defined: a second `discover()` returns the already-adopted result without |
| 1610 | + re-probing — the script holds exactly one entry, so a second wire call would |
| 1611 | + `IndexError` on the empty script.""" |
| 1612 | + dispatcher = _ScriptedDispatcher(_discover_result_dict()) |
| 1613 | + with anyio.fail_after(5): |
| 1614 | + async with ClientSession(dispatcher=dispatcher) as session: |
| 1615 | + first = await session.discover() |
| 1616 | + assert await session.discover() is first |
| 1617 | + assert [m for m, _ in dispatcher.calls] == ["server/discover"] |
| 1618 | + |
| 1619 | + |
| 1620 | +@pytest.mark.anyio |
| 1621 | +async def test_discover_reraises_unsupported_version_with_malformed_error_data() -> None: |
| 1622 | + """SDK-defined: a -32022 reply whose `data` is not a valid |
| 1623 | + `UnsupportedProtocolVersionErrorData` payload is unrecoverable — the original |
| 1624 | + error is re-raised without a retry probe.""" |
| 1625 | + dispatcher = _ScriptedDispatcher(MCPError(UNSUPPORTED_PROTOCOL_VERSION, "unsupported", data="not-an-object")) |
| 1626 | + with anyio.fail_after(5): |
| 1627 | + async with ClientSession(dispatcher=dispatcher) as session: |
| 1628 | + with pytest.raises(MCPError) as exc: |
| 1629 | + await session.discover() |
| 1630 | + assert exc.value.error.code == UNSUPPORTED_PROTOCOL_VERSION |
| 1631 | + assert [m for m, _ in dispatcher.calls] == ["server/discover"] |
0 commit comments