|
8 | 8 |
|
9 | 9 | import json |
10 | 10 | import time |
| 11 | +from collections.abc import AsyncIterator |
| 12 | +from contextlib import asynccontextmanager |
11 | 13 | from types import TracebackType |
12 | 14 | from typing import Any |
13 | 15 |
|
|
54 | 56 | from mcp.server import Server, ServerRequestContext |
55 | 57 | from mcp.server.caching import CacheHint |
56 | 58 | from mcp.shared.exceptions import MCPError |
| 59 | +from mcp.shared.memory import MessageStream, create_client_server_memory_streams |
| 60 | +from mcp.shared.message import SessionMessage |
57 | 61 | from mcp.shared.session import RequestResponder |
58 | 62 | from tests.interaction._connect import BASE_URL, mounted_app |
59 | 63 |
|
@@ -960,3 +964,102 @@ async def on_message(message: IncomingMessage) -> None: |
960 | 964 | # The notification carried the exact string the entry was stored under. |
961 | 965 | assert delivered == [uri] |
962 | 966 | assert reads == [uri, uri] |
| 967 | + |
| 968 | + |
| 969 | +# --- The inbound ttlMs clamp (parse seam) --- |
| 970 | + |
| 971 | + |
| 972 | +@pytest.mark.parametrize("wire_ttl", [-5, -5.0]) |
| 973 | +async def test_a_negative_inbound_ttl_is_served_as_zero_and_never_cached(wire_ttl: int | float) -> None: |
| 974 | + """Spec SHOULD (2026-07-28 caching): a negative `ttlMs` is treated as 0 — the |
| 975 | + call succeeds instead of failing the `ge=0` wire validation, and a zero ttl is |
| 976 | + never stored, so the next call goes back to the server. The peer is scripted |
| 977 | + over raw streams because an SDK server cannot emit a negative ttl (server-side |
| 978 | + `ge=0` enforcement).""" |
| 979 | + listings_served = 0 |
| 980 | + |
| 981 | + async def scripted_server(streams: MessageStream) -> None: |
| 982 | + nonlocal listings_served |
| 983 | + server_read, server_write = streams |
| 984 | + async for message in server_read: |
| 985 | + assert isinstance(message, SessionMessage) |
| 986 | + frame = message.message |
| 987 | + assert isinstance(frame, types.JSONRPCRequest) |
| 988 | + if frame.method == "server/discover": |
| 989 | + result: dict[str, Any] = { |
| 990 | + "supportedVersions": [LATEST_MODERN_VERSION], |
| 991 | + "capabilities": {}, |
| 992 | + "serverInfo": {"name": "negative-ttl", "version": "0.0.1"}, |
| 993 | + "resultType": "complete", |
| 994 | + "ttlMs": 0, |
| 995 | + } |
| 996 | + else: |
| 997 | + assert frame.method == "tools/list" |
| 998 | + listings_served += 1 |
| 999 | + result = {"resultType": "complete", "tools": [], "ttlMs": wire_ttl, "cacheScope": "private"} |
| 1000 | + await server_write.send(SessionMessage(types.JSONRPCResponse(jsonrpc="2.0", id=frame.id, result=result))) |
| 1001 | + |
| 1002 | + @asynccontextmanager |
| 1003 | + async def scripted_transport() -> AsyncIterator[TransportStreams]: |
| 1004 | + async with ( |
| 1005 | + create_client_server_memory_streams() as ((client_read, client_write), server_streams), |
| 1006 | + anyio.create_task_group() as tg, |
| 1007 | + ): |
| 1008 | + tg.start_soon(scripted_server, server_streams) |
| 1009 | + yield client_read, client_write |
| 1010 | + tg.cancel_scope.cancel() |
| 1011 | + |
| 1012 | + with anyio.fail_after(5): |
| 1013 | + async with Client(scripted_transport(), mode="auto") as client: |
| 1014 | + first = await client.list_tools() |
| 1015 | + second = await client.list_tools() |
| 1016 | + |
| 1017 | + assert first.ttl_ms == 0 |
| 1018 | + assert second.ttl_ms == 0 |
| 1019 | + assert listings_served == 2 # the clamped-to-zero ttl was never stored: the second call re-fetched |
| 1020 | + |
| 1021 | + |
| 1022 | +async def test_a_negative_discover_ttl_still_connects_modern_in_auto_mode() -> None: |
| 1023 | + """Spec SHOULD (2026-07-28 caching) — silent-downgrade regression: before the |
| 1024 | + parse-seam clamp, a negative `ttlMs` on `server/discover` failed `DiscoverResult` |
| 1025 | + validation inside the mode='auto' probe, which reads as "not modern evidence" and |
| 1026 | + silently fell back to the legacy initialize handshake. Clamped, the probe adopts |
| 1027 | + the modern era and the result carries `ttl_ms == 0`.""" |
| 1028 | + methods_seen: list[str] = [] |
| 1029 | + |
| 1030 | + async def scripted_server(streams: MessageStream) -> None: |
| 1031 | + server_read, server_write = streams |
| 1032 | + async for message in server_read: |
| 1033 | + assert isinstance(message, SessionMessage) |
| 1034 | + frame = message.message |
| 1035 | + assert isinstance(frame, types.JSONRPCRequest) |
| 1036 | + methods_seen.append(frame.method) |
| 1037 | + # A legacy downgrade would send `initialize` next; fail loudly instead. |
| 1038 | + assert frame.method == "server/discover" |
| 1039 | + result: dict[str, Any] = { |
| 1040 | + "supportedVersions": [LATEST_MODERN_VERSION], |
| 1041 | + "capabilities": {}, |
| 1042 | + "serverInfo": {"name": "negative-ttl", "version": "0.0.1"}, |
| 1043 | + "resultType": "complete", |
| 1044 | + "ttlMs": -5, |
| 1045 | + } |
| 1046 | + await server_write.send(SessionMessage(types.JSONRPCResponse(jsonrpc="2.0", id=frame.id, result=result))) |
| 1047 | + |
| 1048 | + @asynccontextmanager |
| 1049 | + async def scripted_transport() -> AsyncIterator[TransportStreams]: |
| 1050 | + async with ( |
| 1051 | + create_client_server_memory_streams() as ((client_read, client_write), server_streams), |
| 1052 | + anyio.create_task_group() as tg, |
| 1053 | + ): |
| 1054 | + tg.start_soon(scripted_server, server_streams) |
| 1055 | + yield client_read, client_write |
| 1056 | + tg.cancel_scope.cancel() |
| 1057 | + |
| 1058 | + with anyio.fail_after(5): |
| 1059 | + async with Client(scripted_transport(), mode="auto") as client: |
| 1060 | + assert client.protocol_version == LATEST_MODERN_VERSION |
| 1061 | + discover = client.session.discover_result |
| 1062 | + assert discover is not None |
| 1063 | + assert discover.ttl_ms == 0 |
| 1064 | + |
| 1065 | + assert methods_seen == ["server/discover"] |
0 commit comments