Skip to content

Commit 8fae9cf

Browse files
committed
Treat negative inbound ttlMs as zero at the client parse seams
1 parent 0a19550 commit 8fae9cf

3 files changed

Lines changed: 155 additions & 1 deletion

File tree

‎src/mcp/client/session.py‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,19 @@
5555
logger = logging.getLogger("client")
5656

5757

58+
def _clamp_inbound_ttl(raw: dict[str, Any]) -> None:
59+
"""Floor a negative inbound `ttlMs` to 0, in place (2026-07-28 caching SHOULD).
60+
61+
Runs before the surface validation, whose `ge=0` would otherwise fail the
62+
whole call over one bad hint. Emit-side strictness is untouched — only a
63+
misbehaving peer reaches this. Floats are floored too; bools are not numbers
64+
here and are left for the validation to reject.
65+
"""
66+
ttl = raw.get("ttlMs")
67+
if isinstance(ttl, int | float) and not isinstance(ttl, bool) and ttl < 0:
68+
raw["ttlMs"] = 0
69+
70+
5871
def _preconnect_stamp(data: dict[str, Any], opts: CallOptions) -> None:
5972
# initialize/discover forbid cancellation; other pre-handshake requests (lowlevel
6073
# ClientSession callers may skip the handshake entirely) keep the courtesy cancel.
@@ -331,6 +344,7 @@ async def send_request(
331344
if metadata.on_resumption_token_update is not None:
332345
opts["on_resumption_token"] = metadata.on_resumption_token_update
333346
raw = await self._dispatcher.send_raw_request(method, data.get("params"), opts)
347+
_clamp_inbound_ttl(raw)
334348
# Literal fallback covers pre-handshake and stateless; matches runner.py.
335349
version = self._negotiated_version or "2025-11-25"
336350
try:
@@ -458,7 +472,13 @@ async def send_discover(self, version: str) -> dict[str, Any]:
458472
"cancel_on_abandon": False,
459473
"headers": {MCP_PROTOCOL_VERSION_HEADER: version, MCP_METHOD_HEADER: data["method"]},
460474
}
461-
return await self._dispatcher.send_raw_request(data["method"], data.get("params"), opts)
475+
raw = await self._dispatcher.send_raw_request(data["method"], data.get("params"), opts)
476+
# Clamping here (not in the callers) covers both discover() and the
477+
# mode='auto' probe — un-floored, a negative ttl fails DiscoverResult
478+
# validation in the probe, which reads as "not modern evidence" and
479+
# silently downgrades the connection to the legacy handshake.
480+
_clamp_inbound_ttl(raw)
481+
return raw
462482

463483
async def discover(self) -> types.DiscoverResult:
464484
"""Probe `server/discover` and adopt the result.

‎tests/client/test_client_caching.py‎

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
import json
1010
import time
11+
from collections.abc import AsyncIterator
12+
from contextlib import asynccontextmanager
1113
from types import TracebackType
1214
from typing import Any
1315

@@ -54,6 +56,8 @@
5456
from mcp.server import Server, ServerRequestContext
5557
from mcp.server.caching import CacheHint
5658
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
5761
from mcp.shared.session import RequestResponder
5862
from tests.interaction._connect import BASE_URL, mounted_app
5963

@@ -960,3 +964,102 @@ async def on_message(message: IncomingMessage) -> None:
960964
# The notification carried the exact string the entry was stored under.
961965
assert delivered == [uri]
962966
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"]

‎tests/client/test_session.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1661,6 +1661,37 @@ async def test_discover_reraises_unsupported_version_with_malformed_error_data()
16611661
assert [m for m, _ in dispatcher.calls] == ["server/discover"]
16621662

16631663

1664+
# --- inbound ttlMs clamp ---
1665+
1666+
1667+
@pytest.mark.anyio
1668+
async def test_a_positive_inbound_ttl_reaches_the_result_unchanged() -> None:
1669+
"""SDK-defined: the inbound clamp only floors negative `ttlMs` values — a valid
1670+
positive hint passes through to the typed result untouched."""
1671+
listing: dict[str, Any] = {"resultType": "complete", "tools": [], "ttlMs": 60_000, "cacheScope": "private"}
1672+
dispatcher = _ScriptedDispatcher(_discover_result_dict(), listing)
1673+
with anyio.fail_after(5):
1674+
async with ClientSession(dispatcher=dispatcher) as session:
1675+
await session.discover()
1676+
result = await session.list_tools()
1677+
assert result.ttl_ms == 60_000
1678+
1679+
1680+
@pytest.mark.anyio
1681+
@pytest.mark.parametrize("wire_ttl", [True, False])
1682+
async def test_a_boolean_inbound_ttl_is_not_clamped_only_coerced_by_validation(wire_ttl: bool) -> None:
1683+
"""SDK-defined: `bool` is an `int` subclass but the clamp does not treat it as a
1684+
number — the value reaches validation untouched, where pydantic's lax mode
1685+
coerces it (True -> 1, False -> 0) rather than rejecting it."""
1686+
listing: dict[str, Any] = {"resultType": "complete", "tools": [], "ttlMs": wire_ttl, "cacheScope": "private"}
1687+
dispatcher = _ScriptedDispatcher(_discover_result_dict(), listing)
1688+
with anyio.fail_after(5):
1689+
async with ClientSession(dispatcher=dispatcher) as session:
1690+
await session.discover()
1691+
result = await session.list_tools()
1692+
assert result.ttl_ms == int(wire_ttl)
1693+
1694+
16641695
@pytest.mark.anyio
16651696
async def test_session_call_tool_returns_input_required_result_when_opted_in() -> None:
16661697
"""`ClientSession.call_tool(..., allow_input_required=True)` surfaces the

0 commit comments

Comments
 (0)