Add a client-side response cache honoring SEP-2549 caching hints (#3023)

This commit is contained in:
Max 2026-06-30 11:31:06 +01:00 • committed by GitHub
commit b15b1d5f07
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 3789 additions and 88 deletions

1087
tests/client/test_caching.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -506,6 +506,100 @@ async def test_modern_list_tools_drops_tools_with_invalid_x_mcp_header_but_legac
assert [t.name for t in result.tools] == ["ok", "dropme"]
_RETIRED_TOOL = Tool(
name="retired",
input_schema={"type": "object", "properties": {"region": {"type": "string", "x-mcp-header": "Region"}}},
output_schema={"type": "object"},
)
_SURVIVOR_TOOL = Tool(name="survivor", input_schema={"type": "object"})
def _scripted_listing_server(listings: list[ListToolsResult]) -> Server:
"""Serves the given listings in order, one per tools/list request."""
async def on_list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
return listings.pop(0)
return Server("test", on_list_tools=on_list_tools)
async def test_a_complete_listing_prunes_per_tool_state_for_tools_it_no_longer_contains() -> None:
"""SDK-defined: a complete (uncursored, cursorless) listing is the full tool universe, so the
header map and output schema derived from an earlier listing of a now-absent tool are dropped."""
server = _scripted_listing_server(
[
ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]),
ListToolsResult(tools=[_SURVIVOR_TOOL]),
]
)
with anyio.fail_after(5):
async with Client(server) as client:
await client.session.list_tools()
assert set(client.session._x_mcp_header_maps) == {"retired", "survivor"}
assert set(client.session._tool_output_schemas) == {"retired", "survivor"}
await client.session.list_tools()
assert set(client.session._x_mcp_header_maps) == {"survivor"}
assert set(client.session._tool_output_schemas) == {"survivor"}
async def test_a_complete_listing_prunes_output_schemas_on_a_legacy_session_too() -> None:
"""SDK-defined: the prune is era-independent -- legacy sessions cache output schemas the same
way (their header-map dict just stays empty, since the x-mcp-header filter is 2026-only)."""
server = _scripted_listing_server(
[
ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]),
ListToolsResult(tools=[_SURVIVOR_TOOL]),
]
)
with anyio.fail_after(5):
async with Client(server, mode="legacy") as client:
await client.session.list_tools()
assert set(client.session._tool_output_schemas) == {"retired", "survivor"}
assert client.session._x_mcp_header_maps == {}
await client.session.list_tools()
assert set(client.session._tool_output_schemas) == {"survivor"}
async def test_a_listing_with_a_next_cursor_prunes_no_per_tool_state() -> None:
"""SDK-defined: a first page carrying next_cursor is not the full universe -- state for tools
expected on later pages must survive it."""
server = _scripted_listing_server(
[
ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]),
ListToolsResult(tools=[_SURVIVOR_TOOL], next_cursor="2"),
]
)
with anyio.fail_after(5):
async with Client(server) as client:
await client.session.list_tools()
await client.session.list_tools()
assert set(client.session._x_mcp_header_maps) == {"retired", "survivor"}
assert set(client.session._tool_output_schemas) == {"retired", "survivor"}
async def test_a_cursor_page_fetch_prunes_no_per_tool_state() -> None:
"""SDK-defined: a continuation page is partial even when it ends the pagination (no
next_cursor) -- only an uncursored single-page listing prunes."""
server = _scripted_listing_server(
[
ListToolsResult(tools=[_RETIRED_TOOL, _SURVIVOR_TOOL]),
ListToolsResult(tools=[_SURVIVOR_TOOL]),
]
)
with anyio.fail_after(5):
async with Client(server) as client:
await client.session.list_tools()
await client.session.list_tools(params=types.PaginatedRequestParams(cursor="2"))
assert set(client.session._x_mcp_header_maps) == {"retired", "survivor"}
assert set(client.session._tool_output_schemas) == {"retired", "survivor"}
def test_client_rejects_handshake_era_mode_at_construction() -> None:
"""A handshake-era protocol-version string passed as `mode=` is rejected by
`__post_init__` with a hint to use `mode='legacy'` — the version-pin path is

File diff suppressed because it is too large Load diff

View file

@ -1661,6 +1661,33 @@ async def test_discover_reraises_unsupported_version_with_malformed_error_data()
assert [m for m, _ in dispatcher.calls] == ["server/discover"]
# --- inbound ttlMs clamp ---
@pytest.mark.anyio
async def test_a_positive_inbound_ttl_reaches_the_result_unchanged() -> None:
listing: dict[str, Any] = {"resultType": "complete", "tools": [], "ttlMs": 60_000, "cacheScope": "private"}
dispatcher = _ScriptedDispatcher(_discover_result_dict(), listing)
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
await session.discover()
result = await session.list_tools()
assert result.ttl_ms == 60_000
@pytest.mark.anyio
@pytest.mark.parametrize("wire_ttl", [True, False])
async def test_a_boolean_inbound_ttl_is_not_clamped_only_coerced_by_validation(wire_ttl: bool) -> None:
"""SDK-defined: `bool` is an `int` subclass; the clamp skips it and pydantic's lax mode coerces it instead."""
listing: dict[str, Any] = {"resultType": "complete", "tools": [], "ttlMs": wire_ttl, "cacheScope": "private"}
dispatcher = _ScriptedDispatcher(_discover_result_dict(), listing)
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
await session.discover()
result = await session.list_tools()
assert result.ttl_ms == int(wire_ttl)
@pytest.mark.anyio
async def test_session_call_tool_returns_input_required_result_when_opted_in() -> None:
"""`ClientSession.call_tool(..., allow_input_required=True)` surfaces the

View file

@ -1,13 +1,19 @@
"""`docs/advanced/caching.md`: every claim the page makes, proved against the real SDK."""
from collections.abc import Mapping
from typing import Any, cast
import anyio
import pytest
from inline_snapshot import snapshot
from mcp_types import INTERNAL_ERROR, ListToolsResult, PaginatedRequestParams, Tool
from docs_src.caching import tutorial001, tutorial002, tutorial003
from mcp import Client
from mcp.server import CacheHint, MCPServer
from mcp import Client, MCPError
from mcp.client import CacheConfig
from mcp.client.caching import InMemoryResponseCacheStore
from mcp.server import CacheHint, MCPServer, Server, ServerRequestContext
from mcp.server.caching import CacheableMethod
# See test_index.py for why this is a per-module mark and not a conftest hook.
pytestmark = [pytest.mark.anyio, pytest.mark.filterwarnings("error::mcp.MCPDeprecationWarning")]
@ -42,7 +48,7 @@ async def test_a_non_cacheable_method_is_rejected_at_construction() -> None:
with pytest.raises(ValueError) as exc:
MCPServer("Weather", cache_hints=cast(Any, {"tools/call": CacheHint(ttl_ms=1_000)}))
assert str(exc.value) == snapshot(
"cache_hints keys must be cacheable methods (see CacheableMethod); got: tools/call"
"cache_hints keys must be cacheable methods (see CacheableMethod); got: 'tools/call'"
)
@ -55,16 +61,149 @@ async def test_the_handler_value_wins_over_the_map_per_field() -> None:
assert tools.cache_scope == "public"
async def test_the_client_program_on_the_page_reads_the_hints(capsys: pytest.CaptureFixture[str]) -> None:
"""tutorial003: `main()` is the literal client program on the page - the hints
arrive as parsed fields on the result."""
async def test_the_client_program_on_the_page_makes_three_fetches_for_four_calls(
capsys: pytest.CaptureFixture[str],
) -> None:
"""tutorial003: a cache hit, an expiry, and `cache_mode="refresh"` make four calls cost three fetches."""
await tutorial003.main()
assert capsys.readouterr().out == "1 tools, fresh for 60s, scope=public\n"
assert capsys.readouterr().out == "4 calls, 3 fetches\n"
def _counting_tools_server(*, ttl_ms: int | None = 60_000) -> tuple[Server[Any], list[str | None]]:
"""Each tools/list fetch returns a distinct tool name, so a cache hit is
payload-distinguishable from a refetch; `ttl_ms=None` sends no hints."""
fetches: list[str | None] = []
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult:
fetches.append(params.cursor if params is not None else None)
return ListToolsResult(tools=[Tool(name=f"t{len(fetches) - 1}", input_schema={"type": "object"})])
hints: Mapping[CacheableMethod, CacheHint] | None = None
if ttl_ms is not None:
hints = {"tools/list": CacheHint(ttl_ms=ttl_ms)}
return Server("counting", on_list_tools=list_tools, cache_hints=hints), fetches
async def test_caching_is_on_by_default_the_second_call_makes_no_fetch() -> None:
server, fetches = _counting_tools_server()
async with Client(server) as client:
first = await client.list_tools()
second = await client.list_tools()
assert fetches == [None]
assert second == first
async def test_a_hintless_result_is_not_cached_by_default() -> None:
"""`default_ttl_ms` defaults to 0, so a hintless server sees its usual call-for-call traffic."""
server, fetches = _counting_tools_server(ttl_ms=None)
async with Client(server) as client:
await client.list_tools()
await client.list_tools()
assert fetches == [None, None]
async def test_cache_false_makes_every_call_a_round_trip() -> None:
server, fetches = _counting_tools_server()
async with Client(server, cache=False) as client:
await client.list_tools()
await client.list_tools()
assert fetches == [None, None]
async def test_refresh_refetches_and_replaces_the_cached_entry() -> None:
server, fetches = _counting_tools_server()
async with Client(server) as client:
await client.list_tools()
refreshed = await client.list_tools(cache_mode="refresh")
served = await client.list_tools()
assert fetches == [None, None]
assert [tool.name for tool in refreshed.tools] == ["t1"]
assert served == refreshed
async def test_bypass_fetches_without_reading_or_writing_the_cache() -> None:
server, fetches = _counting_tools_server()
async with Client(server) as client:
first = await client.list_tools()
bypassed = await client.list_tools(cache_mode="bypass")
served = await client.list_tools()
assert fetches == [None, None]
assert [tool.name for tool in bypassed.tools] == ["t1"]
assert served == first
async def test_an_expired_entry_is_not_revived_when_the_refetch_fails() -> None:
"""SDK ruling: no stale-if-error - the refetch failure propagates."""
now = 1_000_000.0
fetches: list[None] = []
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult:
fetches.append(None)
if len(fetches) > 1:
raise MCPError(code=INTERNAL_ERROR, message="backend down")
return ListToolsResult(tools=[Tool(name="t0", input_schema={"type": "object"})])
server = Server("flaky", on_list_tools=list_tools, cache_hints={"tools/list": CacheHint(ttl_ms=60_000)})
async with Client(server, cache=CacheConfig(clock=lambda: now)) as client:
await client.list_tools()
now += 60.0 # past the 60s TTL
with pytest.raises(MCPError) as exc:
await client.list_tools()
assert exc.value.code == INTERNAL_ERROR
assert len(fetches) == 2
async def test_two_concurrent_identical_calls_are_two_fetches() -> None:
"""SDK ruling: no coalescing. The handler barrier releases only once both
calls are inside it, so the test passes only if the fetches were concurrent."""
both_fetching = anyio.Event()
fetches: list[None] = []
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult:
fetches.append(None)
if len(fetches) == 2:
both_fetching.set()
with anyio.fail_after(5):
await both_fetching.wait()
return ListToolsResult(tools=[Tool(name="t", input_schema={"type": "object"})])
server = Server("concurrent", on_list_tools=list_tools, cache_hints={"tools/list": CacheHint(ttl_ms=60_000)})
async with Client(server) as client:
async with anyio.create_task_group() as tg:
tg.start_soon(client.list_tools)
tg.start_soon(client.list_tools)
assert len(fetches) == 2
async def test_a_session_tier_call_always_makes_the_round_trip() -> None:
"""The cache lives on the `Client` verbs; `client.session` sits below it."""
server, fetches = _counting_tools_server()
async with Client(server) as client:
await client.list_tools()
await client.session.list_tools()
assert fetches == [None, None]
async def test_a_custom_store_requires_a_partition() -> None:
with pytest.raises(ValueError) as exc:
CacheConfig(store=InMemoryResponseCacheStore())
assert str(exc.value) == snapshot("a custom store requires an explicit partition")
async def test_a_custom_store_with_an_in_process_server_requires_target_id() -> None:
server, _ = _counting_tools_server()
with pytest.raises(ValueError) as exc:
Client(server, cache=CacheConfig(store=InMemoryResponseCacheStore(), partition="user-1"))
assert str(exc.value) == snapshot(
"a custom cache store requires CacheConfig.target_id when the server is not a URL: in-process servers "
"and Transport instances get a random per-client identity, so their entries in a shared store could "
"never be served to another client"
)
async def test_the_wire_presence_check_the_page_recommends_works() -> None:
"""The page's claim: `"ttl_ms" in result.model_fields_set` distinguishes a
server that sent the field from one that said nothing (model defaults)."""
async with Client(tutorial003.mcp) as client:
async with Client(tutorial001.mcp) as client:
tools = await client.list_tools()
assert "ttl_ms" in tools.model_fields_set

View file

@ -511,7 +511,8 @@ async def test_modern_client_stops_mirroring_after_a_re_list_drops_the_tool() ->
bad_schema = {"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "bad name"}}}
valid = Tool(name="run", input_schema=schema)
invalid = Tool(name="run", input_schema=bad_schema)
listings = iter([valid, invalid])
# Three pages: the call after the drop re-lists once because the prune also cleared `run`'s schema entry.
listings = iter([valid, invalid, invalid])
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[next(listings)], ttl_ms=0, cache_scope="public")

View file

@ -1,40 +1,27 @@
"""`mcp.server.caching`: `CacheHint` validation, per-field fills, and the
`cache_hints` constructor map reaching the wire on both server tiers."""
from types import UnionType
from typing import Any, cast, get_args
from typing import Any, cast
import pytest
from inline_snapshot import snapshot
from mcp_types import (
CacheableResult,
InputRequiredResult,
ListResourcesResult,
ListToolsResult,
PaginatedRequestParams,
ReadResourceRequestParams,
Resource,
Tool,
methods,
)
from mcp import Client
from mcp.server import CacheHint, MCPServer, Server, ServerRequestContext
from mcp.server.caching import CACHEABLE_METHODS, apply_cache_hint
from mcp.server.caching import apply_cache_hint
pytestmark = pytest.mark.anyio
def test_cacheable_methods_match_the_result_models() -> None:
"""Spec-mandated set (SEP-2549): `CACHEABLE_METHODS` mirrors exactly the
methods whose monolith result models mix in `CacheableResult` - if the
schema gains or loses a cacheable result, this weld breaks."""
derived: set[str] = set()
for method, model in methods.MONOLITH_RESULTS.items():
arms = get_args(model) if isinstance(model, UnionType) else (model,)
if any(isinstance(arm, type) and issubclass(arm, CacheableResult) for arm in arms):
derived.add(method)
assert CACHEABLE_METHODS == derived
def test_cache_hint_defaults_match_the_conservative_model_defaults() -> None:
"""SDK-defined: an unconfigured hint fills the same values the result models
already default to - immediately stale, not shared - so stamping it is
@ -83,7 +70,7 @@ def test_a_non_cacheable_method_in_cache_hints_is_rejected_at_server_constructio
with pytest.raises(ValueError) as exc:
Server("srv", cache_hints=cast(Any, {"tools/call": CacheHint()}))
assert str(exc.value) == snapshot(
"cache_hints keys must be cacheable methods (see CacheableMethod); got: tools/call"
"cache_hints keys must be cacheable methods (see CacheableMethod); got: 'tools/call'"
)
@ -96,6 +83,72 @@ def test_a_non_cache_hint_value_is_rejected_at_server_construction() -> None:
assert str(exc.value) == snapshot("cache_hints['tools/list'] must be a CacheHint, got dict")
def test_a_non_string_cache_hints_key_is_rejected_with_the_unknown_key_error() -> None:
"""A non-string key takes the same unknown-key ValueError as a typo, not a TypeError from message formatting."""
with pytest.raises(ValueError) as exc:
Server("srv", cache_hints=cast(Any, {42: CacheHint()}))
assert str(exc.value) == snapshot("cache_hints keys must be cacheable methods (see CacheableMethod); got: 42")
async def test_a_dict_returning_handler_takes_the_configured_hint() -> None:
"""The stamp covers raw-dict results too - 2026-07-28 requires both fields on the wire."""
hint = CacheHint(ttl_ms=60_000, scope="public")
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams) -> dict[str, Any]:
return {"tools": [], "resultType": "complete"}
server = Server("srv", cache_hints={"tools/list": hint})
server.add_request_handler("tools/list", PaginatedRequestParams, list_tools)
async with Client(server) as client:
result = await client.list_tools()
assert result.ttl_ms == hint.ttl_ms
assert result.cache_scope == hint.scope
async def test_a_dict_provided_ttl_wins_and_the_hint_fills_only_the_missing_scope() -> None:
"""Dict path mirrors the model path's `model_fields_set` precedence: present wire keys win."""
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams) -> dict[str, Any]:
return {"tools": [], "resultType": "complete", "ttlMs": 25}
server = Server("srv", cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")})
server.add_request_handler("tools/list", PaginatedRequestParams, list_tools)
async with Client(server) as client:
result = await client.list_tools()
assert result.ttl_ms == 25
assert result.cache_scope == "public"
async def test_a_dict_returning_handler_leaks_no_hint_fields_to_a_2025_session() -> None:
"""The stamp runs version-independently; the 2025 serialize sieve strips the fields."""
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams) -> dict[str, Any]:
return {"tools": []}
server = Server("srv", cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")})
server.add_request_handler("tools/list", PaginatedRequestParams, list_tools)
async with Client(server, mode="legacy") as client:
result = await client.list_tools()
assert "ttl_ms" not in result.model_fields_set
assert "cache_scope" not in result.model_fields_set
async def test_an_input_required_shaped_dict_is_never_stamped() -> None:
"""Spec carve-out: interim `input_required` results carry no cache hints, even on a hinted method."""
async def read_resource(ctx: ServerRequestContext[Any], params: ReadResourceRequestParams) -> dict[str, Any]:
return {"resultType": "input_required", "requestState": "s1"}
server = Server("srv", cache_hints={"resources/read": CacheHint(ttl_ms=60_000, scope="public")})
server.add_request_handler("resources/read", ReadResourceRequestParams, read_resource)
async with Client(server) as client:
result = await client.session.read_resource("res://x", allow_input_required=True)
assert isinstance(result, InputRequiredResult)
assert result.model_dump(by_alias=True, exclude_none=True) == snapshot(
{"resultType": "input_required", "requestState": "s1"}
)
async def test_server_cache_hints_reach_the_wire_for_a_bare_handler_result() -> None:
"""SDK-defined: a lowlevel handler that never thinks about caching emits the
server-wide hint configured at construction."""

View file

@ -548,6 +548,11 @@ def test_built_in_maps_are_immutable():
_assign_item(built_in)
def test_cacheable_methods_mirror_the_cacheable_method_literal():
"""SEP-2549 weld: the hand-written Literal and the set derived from `MONOLITH_RESULTS` must agree."""
assert methods.CACHEABLE_METHODS == frozenset(get_args(methods.CacheableMethod))
def test_minimal_request_bodies_parse_through_every_request_row():
for (method, version), surface_type in methods.CLIENT_REQUESTS.items():
parsed = methods.parse_client_request(method, version, REQUEST_PARAMS_FIXTURES[surface_type])