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

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