Files
modelcontextprotocol--pytho…/tests/client/test_caching.py
T
wehub-resource-sync 49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

1088 lines
48 KiB
Python

"""Tests for `mcp.client.caching`. The store-contract tests are parametrized
over `STORE_FACTORIES`; a third-party store can be run against the same
contract by adding its factory."""
import json
import logging
import time
from collections.abc import Awaitable, Callable
from typing import Any
import anyio
import anyio.lowlevel
import pytest
from inline_snapshot import snapshot
from mcp_types import (
ListPromptsResult,
ListToolsResult,
LoggingMessageNotification,
LoggingMessageNotificationParams,
PromptListChangedNotification,
ReadResourceResult,
ResourceListChangedNotification,
ResourceUpdatedNotification,
ResourceUpdatedNotificationParams,
ServerNotification,
ToolListChangedNotification,
)
from mcp.client.caching import (
MAX_TTL_MS,
CacheConfig,
CacheEntry,
CacheKey,
ClientResponseCache,
InMemoryResponseCacheStore,
ResponseCacheStore,
)
pytestmark = pytest.mark.anyio
STORE_FACTORIES: list[Callable[[], ResponseCacheStore]] = [InMemoryResponseCacheStore]
store_contract = pytest.mark.parametrize("make_store", STORE_FACTORIES, ids=["InMemoryResponseCacheStore"])
def _entry(value: Any = "cached") -> CacheEntry:
"""Entries are opaque payloads at the store layer; only the key matters here."""
return CacheEntry(value=value, scope="private", expires_at=None)
def _read_key(uri: str) -> CacheKey:
return CacheKey("resources/read", uri)
# --- Store contract ---
@store_contract
async def test_a_set_entry_round_trips_through_get(make_store: Callable[[], ResponseCacheStore]) -> None:
store = make_store()
key = CacheKey("tools/list", "", "partition-1")
entry = CacheEntry(value={"tools": []}, scope="public", expires_at=1700000000.0)
await store.set(key, entry)
assert await store.get(key) == entry
@store_contract
async def test_get_misses_for_a_key_never_set(make_store: Callable[[], ResponseCacheStore]) -> None:
store = make_store()
assert await store.get(CacheKey("tools/list")) is None
@store_contract
async def test_keys_differing_in_only_one_field_do_not_collide(
make_store: Callable[[], ResponseCacheStore],
) -> None:
"""Spec-mandated: collapsing any key field would serve responses across method, params, or principal boundaries."""
store = make_store()
base = CacheKey("resources/read", "file:///a", "partition-1")
keys = [
base,
CacheKey("resources/list", base.params_key, base.partition),
CacheKey(base.method, "file:///b", base.partition),
CacheKey(base.method, base.params_key, "partition-2"),
]
for i, key in enumerate(keys):
await store.set(key, _entry(i))
for i, key in enumerate(keys):
assert await store.get(key) == _entry(i)
@store_contract
async def test_swapped_params_key_and_partition_values_are_distinct_keys(
make_store: Callable[[], ResponseCacheStore],
) -> None:
store = make_store()
await store.set(CacheKey("m", "a", "b"), _entry("params=a"))
await store.set(CacheKey("m", "b", "a"), _entry("params=b"))
assert await store.get(CacheKey("m", "a", "b")) == _entry("params=a")
assert await store.get(CacheKey("m", "b", "a")) == _entry("params=b")
@store_contract
async def test_keys_with_field_values_that_concatenate_identically_do_not_collide(
make_store: Callable[[], ResponseCacheStore],
) -> None:
"""Keys compare as the field tuple - flattening would let crafted values collide across boundaries."""
store = make_store()
keys = [
CacheKey("a", "b.c", "p"),
CacheKey("a.b", "c", "p"),
CacheKey("m", "x", "y:z"),
CacheKey("m", "x:y", "z"),
CacheKey("m", "u/v", ""),
CacheKey("m/u", "v", ""),
CacheKey("ab", "", ""),
CacheKey("a", "b", ""),
CacheKey("", "ab", ""),
]
for i, key in enumerate(keys):
await store.set(key, _entry(i))
for i, key in enumerate(keys):
assert await store.get(key) == _entry(i)
@store_contract
async def test_set_replaces_the_entry_for_an_existing_key(make_store: Callable[[], ResponseCacheStore]) -> None:
store = make_store()
key = CacheKey("tools/list")
await store.set(key, _entry("first"))
await store.set(key, _entry("second"))
assert await store.get(key) == _entry("second")
@store_contract
async def test_delete_removes_only_the_given_key(make_store: Callable[[], ResponseCacheStore]) -> None:
store = make_store()
doomed = CacheKey("tools/list", "", "partition-1")
survivor = CacheKey("tools/list", "", "partition-2")
await store.set(doomed, _entry("doomed"))
await store.set(survivor, _entry("survivor"))
await store.delete(doomed)
assert await store.get(doomed) is None
assert await store.get(survivor) == _entry("survivor")
@store_contract
async def test_delete_is_idempotent(make_store: Callable[[], ResponseCacheStore]) -> None:
"""The SDK issues unconditional deletes during eviction, so deleting an absent key must be a no-op."""
store = make_store()
key = CacheKey("prompts/list")
await store.delete(key)
await store.set(key, _entry())
await store.delete(key)
await store.delete(key)
assert await store.get(key) is None
@store_contract
async def test_clear_removes_every_entry_across_methods_and_partitions(
make_store: Callable[[], ResponseCacheStore],
) -> None:
store = make_store()
keys = [
CacheKey("tools/list", "", "partition-1"),
CacheKey("prompts/list", "", "partition-2"),
CacheKey("resources/read", "file:///a", "partition-1"),
]
for key in keys:
await store.set(key, _entry())
await store.clear()
for key in keys:
assert await store.get(key) is None
# --- CacheConfig guards ---
def test_cache_config_defaults_construct_an_unshared_zero_ttl_config() -> None:
config = CacheConfig()
assert config.store is None
assert config.partition == ""
assert config.target_id is None
assert config.default_ttl_ms == 0
assert config.clock is time.time
assert config.share_public is False
def test_a_custom_store_without_a_partition_is_rejected_at_construction() -> None:
"""A custom store is shareable, so a missing partition would let private entries cross principals."""
with pytest.raises(ValueError) as exc:
CacheConfig(store=InMemoryResponseCacheStore())
assert str(exc.value) == snapshot("a custom store requires an explicit partition")
def test_a_custom_store_with_an_explicit_partition_constructs() -> None:
store = InMemoryResponseCacheStore()
config = CacheConfig(store=store, partition="token-subject-1")
assert config.store is store
assert config.partition == "token-subject-1"
def test_an_empty_target_id_is_rejected_at_construction() -> None:
"""An empty target_id would collapse distinct servers onto the one shared sha256("") identity."""
with pytest.raises(ValueError) as exc:
CacheConfig(target_id="")
assert str(exc.value) == snapshot("target_id must be a non-empty string or omitted")
def test_a_negative_default_ttl_is_rejected_at_construction() -> None:
"""A configured negative TTL is a programming error; negative wire ttlMs is tolerated as 0 at the parse seam."""
with pytest.raises(ValueError) as exc:
CacheConfig(default_ttl_ms=-1)
assert str(exc.value) == snapshot("default_ttl_ms must be >= 0, got -1")
# --- InMemoryResponseCacheStore LRU cap ---
async def test_a_new_entry_past_the_cap_evicts_the_least_recently_used_one() -> None:
store = InMemoryResponseCacheStore(max_entries=2)
await store.set(_read_key("file:///a"), _entry("a"))
await store.set(_read_key("file:///b"), _entry("b"))
await store.set(_read_key("file:///c"), _entry("c"))
assert await store.get(_read_key("file:///a")) is None
assert await store.get(_read_key("file:///b")) == _entry("b")
assert await store.get(_read_key("file:///c")) == _entry("c")
async def test_a_get_refreshes_an_entrys_recency() -> None:
"""Eviction order is recency (LRU), not insertion order: serving an entry keeps it alive."""
store = InMemoryResponseCacheStore(max_entries=2)
await store.set(_read_key("file:///a"), _entry("a"))
await store.set(_read_key("file:///b"), _entry("b"))
assert await store.get(_read_key("file:///a")) == _entry("a") # a is now the most recent
await store.set(_read_key("file:///c"), _entry("c")) # evicts b, not a
assert await store.get(_read_key("file:///a")) == _entry("a")
assert await store.get(_read_key("file:///b")) is None
assert await store.get(_read_key("file:///c")) == _entry("c")
async def test_replacing_an_entry_at_the_cap_refreshes_its_recency_without_evicting() -> None:
store = InMemoryResponseCacheStore(max_entries=2)
await store.set(_read_key("file:///a"), _entry("a"))
await store.set(_read_key("file:///b"), _entry("b"))
await store.set(_read_key("file:///a"), _entry("a-replaced")) # still two entries; a is now the most recent
await store.set(_read_key("file:///c"), _entry("c")) # evicts b
assert await store.get(_read_key("file:///a")) == _entry("a-replaced")
assert await store.get(_read_key("file:///b")) is None
assert await store.get(_read_key("file:///c")) == _entry("c")
async def test_a_touched_list_entry_survives_read_key_churn_through_the_cap() -> None:
"""The reason the cap is LRU over all entries: a hot list singleton each principal
keeps re-reading must survive churn from per-uri resources/read keys."""
store = InMemoryResponseCacheStore(max_entries=3)
await store.set(CacheKey("tools/list"), _entry("tools"))
for i in range(10):
assert await store.get(CacheKey("tools/list")) == _entry("tools") # each serve re-touches it
await store.set(_read_key(f"file:///{i}"), _entry(i))
assert await store.get(CacheKey("tools/list")) == _entry("tools")
async def test_a_zero_cap_disables_eviction() -> None:
store = InMemoryResponseCacheStore(max_entries=0)
uris = [f"file:///{i}" for i in range(5)]
for uri in uris:
await store.set(_read_key(uri), _entry(uri))
for uri in uris:
assert await store.get(_read_key(uri)) == _entry(uri)
async def test_deleting_an_entry_frees_its_cap_slot() -> None:
store = InMemoryResponseCacheStore(max_entries=1)
await store.set(_read_key("file:///a"), _entry("a"))
await store.delete(_read_key("file:///a"))
await store.set(_read_key("file:///b"), _entry("b"))
assert await store.get(_read_key("file:///b")) == _entry("b")
def test_a_negative_cap_is_rejected_at_construction() -> None:
with pytest.raises(ValueError) as exc:
InMemoryResponseCacheStore(max_entries=-1)
assert str(exc.value) == snapshot("max_entries must be >= 0, got -1")
# --- ClientResponseCache coordinator ---
MODERN_VERSION = "2026-07-28"
LEGACY_VERSION = "2025-11-25"
class _ManualClock:
"""Injected wall clock: tests advance `now` instead of sleeping."""
def __init__(self) -> None:
self.now = 1_000_000.0
def __call__(self) -> float:
return self.now
def _coordinator(
store: ResponseCacheStore,
*,
partition: str = "",
arm_id: str = "arm",
default_ttl_ms: int = 0,
clock: _ManualClock | None = None,
share_public: bool = False,
version: str | None = MODERN_VERSION,
generation_map_cap: int = 4096,
store_cleanup_timeout: float = 5,
) -> ClientResponseCache:
return ClientResponseCache(
store=store,
partition=partition,
arm_id=arm_id,
default_ttl_ms=default_ttl_ms,
clock=clock or _ManualClock(),
share_public=share_public,
negotiated_version=lambda: version,
generation_map_cap=generation_map_cap,
store_cleanup_timeout=store_cleanup_timeout,
)
def _private_arm(arm_id: str = "arm", partition: str = "", era: str | None = MODERN_VERSION) -> str:
return json.dumps(["private", era, arm_id, partition])
def _public_arm(arm_id: str = "arm", partition: str = "", era: str | None = MODERN_VERSION) -> str:
return json.dumps(["public", era, arm_id, partition])
def _wire_result(ttl_ms: int | None = None, cache_scope: str | None = None) -> ListToolsResult:
"""A wire-parsed `tools/list` result; `None` keeps the hint out of `model_fields_set`."""
payload: dict[str, Any] = {"tools": []}
if ttl_ms is not None:
payload["ttlMs"] = ttl_ms
if cache_scope is not None:
payload["cacheScope"] = cache_scope
return ListToolsResult.model_validate(payload)
def _read_result(ttl_ms: int) -> ReadResourceResult:
return ReadResourceResult.model_validate({"contents": [], "ttlMs": ttl_ms})
class _ScriptedStore:
"""Logs `(op, key)` and awaits one-shot hooks around commits, modelling an async store mid-commit."""
def __init__(self) -> None:
self.inner = InMemoryResponseCacheStore()
self.ops: list[tuple[str, CacheKey]] = []
self.before_set_commits: Callable[[], Awaitable[None]] | None = None
self.after_set_commits: Callable[[], Awaitable[None]] | None = None
self.after_delete_commits: Callable[[], Awaitable[None]] | None = None
async def get(self, key: CacheKey) -> CacheEntry | None:
self.ops.append(("get", key))
return await self.inner.get(key)
async def set(self, key: CacheKey, entry: CacheEntry) -> None:
self.ops.append(("set", key))
if self.before_set_commits is not None:
hook, self.before_set_commits = self.before_set_commits, None
await hook()
await self.inner.set(key, entry)
if self.after_set_commits is not None:
hook, self.after_set_commits = self.after_set_commits, None
await hook()
async def delete(self, key: CacheKey) -> None:
self.ops.append(("delete", key))
await self.inner.delete(key)
if self.after_delete_commits is not None:
hook, self.after_delete_commits = self.after_delete_commits, None
await hook()
async def clear(self) -> None:
raise NotImplementedError
class _FailingStore:
"""Operations raise while their flag is set; toggling a flag models recovery."""
def __init__(self, *, fail_get: bool = False, fail_set: bool = False, fail_delete: bool = False) -> None:
self.inner = InMemoryResponseCacheStore()
self.fail_get = fail_get
self.fail_set = fail_set
self.fail_delete = fail_delete
async def get(self, key: CacheKey) -> CacheEntry | None:
if self.fail_get:
raise RuntimeError("store get failed")
return await self.inner.get(key)
async def set(self, key: CacheKey, entry: CacheEntry) -> None:
if self.fail_set:
raise RuntimeError("store set failed")
await self.inner.set(key, entry)
async def delete(self, key: CacheKey) -> None:
if self.fail_delete:
raise RuntimeError("store delete failed")
await self.inner.delete(key)
async def clear(self) -> None:
raise NotImplementedError
class _ArmDeleteFailingStore:
"""`delete` raises only for keys on the given arm, modelling a failed opposite-arm cleanup."""
def __init__(self, failing_arm: str) -> None:
self.inner = InMemoryResponseCacheStore()
self.failing_arm = failing_arm
async def get(self, key: CacheKey) -> CacheEntry | None:
return await self.inner.get(key)
async def set(self, key: CacheKey, entry: CacheEntry) -> None:
raise NotImplementedError
async def delete(self, key: CacheKey) -> None:
if key.partition == self.failing_arm:
raise RuntimeError("store delete failed")
await self.inner.delete(key)
async def clear(self) -> None:
raise NotImplementedError
# The lax pragmas here and in the wedged-store tests: 3.11's settrace-based coverage loses
# tracing in frames resumed after the coordinator's bounded-shield cleanup cancellation.
class _WedgingDeleteStore:
"""Once `wedged` flips, every `delete` blocks forever (an Event nothing sets),
modelling a remote store with no socket timeout of its own."""
before_set_commits: Callable[[], Awaitable[None]]
"""Awaited before `set` commits; assigned by the one test whose write reaches `set`."""
def __init__(self, *, wedged: bool = False) -> None:
self.inner = InMemoryResponseCacheStore()
self.wedged = wedged
self.deletes_started = 0
async def get(self, key: CacheKey) -> CacheEntry | None:
raise NotImplementedError
async def set(self, key: CacheKey, entry: CacheEntry) -> None:
await self.before_set_commits()
await self.inner.set(key, entry) # pragma: lax no cover
async def delete(self, key: CacheKey) -> None:
self.deletes_started += 1
if self.wedged:
await anyio.Event().wait()
await self.inner.delete(key)
async def clear(self) -> None:
raise NotImplementedError
class _RehydratingStore:
"""`get` returns whatever a persistent store's deserializer produced - not necessarily what `set` received."""
def __init__(self, rehydrated: Any) -> None:
self.rehydrated = rehydrated
async def get(self, key: CacheKey) -> CacheEntry | None:
return self.rehydrated
async def set(self, key: CacheKey, entry: CacheEntry) -> None:
raise NotImplementedError
async def delete(self, key: CacheKey) -> None:
raise NotImplementedError
async def clear(self) -> None:
raise NotImplementedError
# --- Coordinator: era gate ---
@pytest.mark.parametrize("version", [LEGACY_VERSION, None], ids=["legacy", "pre-negotiation"])
async def test_hints_from_a_non_modern_session_are_ignored(version: str | None) -> None:
"""The hints are 2026-07-28 assertions a legacy peer can still inject onto the wire (unknown keys
reach `model_fields_set`), so on a non-modern session every result is treated as hint-absent."""
store = InMemoryResponseCacheStore()
cache = _coordinator(store, version=version)
gen = cache.capture("tools/list", "")
await cache.write("tools/list", "", _wire_result(ttl_ms=60_000, cache_scope="public"), gen, "use")
assert await cache.read("tools/list", "") is None
assert await store.get(CacheKey("tools/list", "", _private_arm(era=version))) is None
assert await store.get(CacheKey("tools/list", "", _public_arm(era=version))) is None
async def test_a_legacy_session_with_a_default_ttl_caches_on_the_private_arm_only() -> None:
"""The operator's default TTL still applies on legacy sessions; injected hints cannot promote or re-clock."""
store = InMemoryResponseCacheStore()
clock = _ManualClock()
cache = _coordinator(store, version=LEGACY_VERSION, default_ttl_ms=60_000, clock=clock)
gen = cache.capture("tools/list", "")
await cache.write("tools/list", "", _wire_result(ttl_ms=5, cache_scope="public"), gen, "use")
private_entry = await store.get(CacheKey("tools/list", "", _private_arm(era=LEGACY_VERSION)))
assert private_entry is not None
assert private_entry.scope == "private"
assert await store.get(CacheKey("tools/list", "", _public_arm(era=LEGACY_VERSION))) is None
clock.now += 1.0 # well past the injected 5ms; the default 60s governs
assert await cache.read("tools/list", "") == _wire_result(ttl_ms=5, cache_scope="public")
async def test_entries_never_cross_negotiated_eras_on_a_shared_store() -> None:
"""Arms fold in the negotiated version: the same listing genuinely differs by era
(the SDK strips the 2026 fields for legacy sessions), so a 2025-negotiated session
is never served an entry a 2026 session wrote - on either arm - nor vice versa."""
store = InMemoryResponseCacheStore()
modern = _coordinator(store, partition="p", default_ttl_ms=60_000)
legacy = _coordinator(store, partition="p", version=LEGACY_VERSION, default_ttl_ms=60_000)
gen = modern.capture("tools/list", "")
await modern.write("tools/list", "", _wire_result(ttl_ms=60_000, cache_scope="public"), gen, "use") # public arm
private_result = ListPromptsResult.model_validate({"prompts": [], "ttlMs": 60_000})
gen = modern.capture("prompts/list", "")
await modern.write("prompts/list", "", private_result, gen, "use") # private arm
assert await legacy.read("tools/list", "") is None
assert await legacy.read("prompts/list", "") is None
gen = legacy.capture("resources/read", "file:///a")
await legacy.write("resources/read", "file:///a", _read_result(ttl_ms=60_000), gen, "use")
assert await legacy.read("resources/read", "file:///a") is not None # cached for legacy itself...
assert await modern.read("resources/read", "file:///a") is None # ...but invisible across the era boundary
async def test_coordinators_negotiating_the_same_era_share_entries_through_the_store() -> None:
"""Era scoping splits eras only: same-era clients sharing a store still share both arms."""
store = InMemoryResponseCacheStore()
writer = _coordinator(store, partition="p")
reader = _coordinator(store, partition="p")
gen = writer.capture("tools/list", "")
await writer.write("tools/list", "", _wire_result(ttl_ms=60_000, cache_scope="public"), gen, "use")
private_result = ListPromptsResult.model_validate({"prompts": [], "ttlMs": 60_000})
gen = writer.capture("prompts/list", "")
await writer.write("prompts/list", "", private_result, gen, "use")
assert await reader.read("tools/list", "") == _wire_result(ttl_ms=60_000, cache_scope="public")
assert await reader.read("prompts/list", "") == private_result
# --- Coordinator: TTL and scope resolution ---
async def test_an_explicit_zero_ttl_is_not_overridden_by_the_default_ttl() -> None:
"""Spec-mandated: ttlMs 0 means immediately stale; the default fills in only for hint-absent results."""
store = InMemoryResponseCacheStore()
cache = _coordinator(store, default_ttl_ms=60_000)
gen = cache.capture("tools/list", "")
await cache.write("tools/list", "", _wire_result(ttl_ms=0), gen, "use")
assert await store.get(CacheKey("tools/list", "", _private_arm())) is None
assert await store.get(CacheKey("tools/list", "", _public_arm())) is None
async def test_a_hint_absent_modern_result_uses_the_default_ttl_privately() -> None:
store = InMemoryResponseCacheStore()
clock = _ManualClock()
cache = _coordinator(store, default_ttl_ms=60_000, clock=clock)
gen = cache.capture("tools/list", "")
await cache.write("tools/list", "", _wire_result(), gen, "use")
entry = await store.get(CacheKey("tools/list", "", _private_arm()))
assert entry is not None
assert entry.scope == "private"
assert entry.expires_at == clock.now + 60.0
assert await cache.read("tools/list", "") == _wire_result()
clock.now += 60.0
assert await cache.read("tools/list", "") is None
async def test_a_ttl_above_24_hours_is_clamped_to_the_cap() -> None:
"""SEP-2549 hardening: a server cannot pin an entry beyond `MAX_TTL_MS`."""
store = InMemoryResponseCacheStore()
clock = _ManualClock()
cache = _coordinator(store, clock=clock)
gen = cache.capture("tools/list", "")
await cache.write("tools/list", "", _wire_result(ttl_ms=7 * MAX_TTL_MS), gen, "use")
entry = await store.get(CacheKey("tools/list", "", _private_arm()))
assert entry is not None
assert entry.expires_at == clock.now + MAX_TTL_MS / 1000
async def test_a_public_result_lands_on_the_public_arm_and_clears_the_private_arm() -> None:
"""On a scope flip, writing the new arm deletes the other so the two arms never both answer."""
store = InMemoryResponseCacheStore()
cache = _coordinator(store)
gen = cache.capture("tools/list", "")
await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use")
assert await store.get(CacheKey("tools/list", "", _private_arm())) is not None
await cache.write("tools/list", "", _wire_result(ttl_ms=60_000, cache_scope="public"), gen, "use")
public_entry = await store.get(CacheKey("tools/list", "", _public_arm()))
assert public_entry is not None
assert public_entry.scope == "public"
assert await store.get(CacheKey("tools/list", "", _private_arm())) is None
# --- Coordinator: partition arms and the scope guard ---
async def test_arm_key_layout_is_pinned_for_shared_store_compatibility() -> None:
"""Arm strings are cross-process store key material; changing their layout breaks shared stores."""
store = InMemoryResponseCacheStore()
cache = _coordinator(store, partition="tenant-a", arm_id="abc123", default_ttl_ms=60_000)
assert cache._arm("private") == snapshot('["private", "2026-07-28", "abc123", "tenant-a"]')
assert cache._arm("public") == snapshot('["public", "2026-07-28", "abc123", "tenant-a"]')
shared = _coordinator(store, partition="tenant-a", arm_id="abc123", share_public=True)
assert shared._arm("public") == snapshot('["public", "2026-07-28", "abc123"]')
# And entries genuinely land under those strings.
gen = cache.capture("tools/list", "")
await cache.write("tools/list", "", _wire_result(), gen, "use")
assert await store.get(CacheKey("tools/list", "", '["private", "2026-07-28", "abc123", "tenant-a"]')) is not None
await cache.write("tools/list", "", _wire_result(ttl_ms=60_000, cache_scope="public"), gen, "use")
assert await store.get(CacheKey("tools/list", "", '["public", "2026-07-28", "abc123", "tenant-a"]')) is not None
gen = shared.capture("tools/list", "")
await shared.write("tools/list", "", _wire_result(ttl_ms=60_000, cache_scope="public"), gen, "use")
assert await store.get(CacheKey("tools/list", "", '["public", "2026-07-28", "abc123"]')) is not None
async def test_public_entries_do_not_cross_partitions_by_default() -> None:
"""Security default (deviates from the TypeScript SDK): a server stamping per-tenant data public
(bug or malice) cannot leak one tenant's response to another through a shared store."""
store = InMemoryResponseCacheStore()
tenant_a = _coordinator(store, partition="tenant-a")
tenant_b = _coordinator(store, partition="tenant-b")
gen = tenant_a.capture("tools/list", "")
await tenant_a.write("tools/list", "", _wire_result(ttl_ms=60_000, cache_scope="public"), gen, "use")
assert await tenant_a.read("tools/list", "") == _wire_result(ttl_ms=60_000, cache_scope="public")
assert await tenant_b.read("tools/list", "") is None
async def test_share_public_serves_public_entries_across_partitions_but_never_private_ones() -> None:
store = InMemoryResponseCacheStore()
tenant_a = _coordinator(store, partition="tenant-a", share_public=True)
tenant_b = _coordinator(store, partition="tenant-b", share_public=True)
gen = tenant_a.capture("tools/list", "")
await tenant_a.write("tools/list", "", _wire_result(ttl_ms=60_000, cache_scope="public"), gen, "use")
assert await tenant_b.read("tools/list", "") == _wire_result(ttl_ms=60_000, cache_scope="public")
private_result = ListPromptsResult.model_validate({"prompts": [], "ttlMs": 60_000})
gen = tenant_a.capture("prompts/list", "")
await tenant_a.write("prompts/list", "", private_result, gen, "use")
assert await tenant_b.read("prompts/list", "") is None
async def test_a_private_scoped_entry_under_the_public_arm_is_not_served() -> None:
"""Defense in depth against a corrupted or pre-seeded store: the arm routes, the entry's scope verifies."""
store = InMemoryResponseCacheStore()
cache = _coordinator(store)
await store.set(
CacheKey("tools/list", "", _public_arm()),
CacheEntry(value=_wire_result(), scope="private", expires_at=2_000_000.0),
)
assert await cache.read("tools/list", "") is None
async def test_a_stale_private_entry_does_not_shadow_a_fresh_public_one() -> None:
"""A stale private entry is an arm-probe miss, so the fall-through finds a public entry seeded by
another client after a server scope flip."""
store = InMemoryResponseCacheStore()
clock = _ManualClock()
cache = _coordinator(store, clock=clock)
await store.set(
CacheKey("tools/list", "", _private_arm()),
CacheEntry(value=_wire_result(), scope="private", expires_at=clock.now - 1.0),
)
public_result = _wire_result(ttl_ms=60_000, cache_scope="public")
await store.set(
CacheKey("tools/list", "", _public_arm()),
CacheEntry(value=public_result, scope="public", expires_at=clock.now + 60.0),
)
assert await cache.read("tools/list", "") == public_result
async def test_an_entry_without_an_expiry_is_never_fresh() -> None:
"""Entries rehydrated without expiry metadata are misses, not immortal."""
store = InMemoryResponseCacheStore()
cache = _coordinator(store)
await store.set(
CacheKey("tools/list", "", _private_arm()),
CacheEntry(value=_wire_result(), scope="private", expires_at=None),
)
assert await cache.read("tools/list", "") is None
# --- Coordinator: write ordering ---
async def test_write_deletes_the_opposite_arm_before_setting_its_own() -> None:
"""Delete-then-set: a cancellation between the two operations leaves a miss, never two answering arms."""
store = _ScriptedStore()
cache = _coordinator(store)
gen = cache.capture("tools/list", "")
await cache.write("tools/list", "", _wire_result(ttl_ms=60_000, cache_scope="public"), gen, "use")
assert store.ops == [
("delete", CacheKey("tools/list", "", _private_arm())),
("set", CacheKey("tools/list", "", _public_arm())),
]
async def test_an_eviction_landing_during_an_async_set_is_compensated() -> None:
"""TOCTOU re-check: the eviction's deletes see nothing (the set has not committed yet), so the
post-set generation re-check must fire a compensating delete."""
store = _ScriptedStore()
cache = _coordinator(store)
gen = cache.capture("tools/list", "")
async def evict_mid_commit() -> None:
await cache.evict_method("tools/list")
store.before_set_commits = evict_mid_commit
await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use")
private_key = CacheKey("tools/list", "", _private_arm())
public_key = CacheKey("tools/list", "", _public_arm())
assert store.ops == [
("delete", public_key), # write: opposite arm first
("set", private_key), # write: own arm, commit still pending
("delete", private_key), # eviction (sees nothing - not committed yet)
("delete", public_key), # eviction
("delete", private_key), # post-set re-check compensation
]
assert await store.inner.get(private_key) is None
assert await cache.read("tools/list", "") is None
async def test_a_cancellation_landing_as_the_set_commits_still_compensates_an_eviction() -> None:
"""The compensating delete is shielded: a timeout firing while the store's set is already on the
wire must not resurrect the evicted entry for its full TTL."""
store = _ScriptedStore()
cache = _coordinator(store)
gen = cache.capture("tools/list", "")
private_key = CacheKey("tools/list", "", _private_arm())
public_key = CacheKey("tools/list", "", _public_arm())
with anyio.CancelScope() as scope:
async def evict_then_cancel() -> None:
await cache.evict_method("tools/list")
scope.cancel()
store.before_set_commits = evict_then_cancel
store.after_set_commits = anyio.lowlevel.checkpoint # first checkpoint after the commit
await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use")
assert scope.cancelled_caught
assert store.ops == [
("delete", public_key), # write: opposite arm first
("set", private_key), # write: own arm, commit still pending
("delete", private_key), # eviction (sees nothing - not committed yet)
("delete", public_key), # eviction
("delete", private_key), # post-set re-check compensation, shielded
]
assert await store.inner.get(private_key) is None
async def test_a_cancellation_during_the_refresh_purge_still_purges_both_arms() -> None:
"""The refresh purge is shielded - a mid-purge cancellation must not leave the superseded opposite arm."""
store = _ScriptedStore()
cache = _coordinator(store)
gen = cache.capture("tools/list", "")
await cache.write("tools/list", "", _wire_result(ttl_ms=60_000, cache_scope="public"), gen, "use")
public_key = CacheKey("tools/list", "", _public_arm())
assert await store.inner.get(public_key) is not None
with anyio.CancelScope() as scope:
scope.cancel()
# Delivers at the first checkpoint after the private-arm delete commits.
store.after_delete_commits = anyio.lowlevel.checkpoint
await cache.write("tools/list", "", _wire_result(ttl_ms=0), gen, "refresh")
assert await store.inner.get(public_key) is None
async def test_a_cancellation_during_an_eviction_still_evicts_both_arms() -> None:
"""Eviction's arm deletes are shielded - a notification task cancelled mid-eviction (session
teardown) must not leave one arm serving the evicted entry."""
store = _ScriptedStore()
cache = _coordinator(store)
gen = cache.capture("tools/list", "")
await cache.write("tools/list", "", _wire_result(ttl_ms=60_000, cache_scope="public"), gen, "use")
public_key = CacheKey("tools/list", "", _public_arm())
with anyio.CancelScope() as scope:
scope.cancel()
# Delivers at the first checkpoint after the private-arm delete commits.
store.after_delete_commits = anyio.lowlevel.checkpoint
await cache.evict_method("tools/list")
assert await store.inner.get(public_key) is None
# --- Coordinator: bounded must-complete cleanup ---
# These tests inject a tiny `store_cleanup_timeout` because the bound itself is the
# behavior under test; the wedged delete only ever blocks for that injected bound.
async def test_evict_key_with_a_wedged_store_delete_returns_at_the_cleanup_bound(
caplog: pytest.LogCaptureFixture,
) -> None:
"""A store delete that never completes cannot make eviction - and with it client
teardown - hang uncancellably: the must-complete cleanup is bounded, the remaining
deletes are abandoned, and the unreaped entries age out by TTL."""
store = _WedgingDeleteStore(wedged=True)
cache = _coordinator(store, store_cleanup_timeout=0.01)
with caplog.at_level(logging.WARNING, logger="mcp.client.caching"), anyio.fail_after(5):
await cache.evict_key("tools/list", "")
assert store.deletes_started == 1 # pragma: lax no cover # the second arm's delete was abandoned with the first
assert caplog.messages == snapshot( # pragma: lax no cover
["Response cache store delete timed out; the entry will age out by TTL"]
)
async def test_a_refresh_purge_with_a_wedged_store_delete_returns_at_the_cleanup_bound() -> None:
store = _WedgingDeleteStore(wedged=True)
cache = _coordinator(store, store_cleanup_timeout=0.01)
gen = cache.capture("tools/list", "")
with anyio.fail_after(5):
await cache.write("tools/list", "", _wire_result(ttl_ms=0), gen, "refresh")
assert store.deletes_started == 1 # pragma: lax no cover
async def test_an_eviction_mid_set_with_a_wedged_store_delete_returns_at_the_cleanup_bound() -> None:
"""The post-set compensating delete is bounded like every other must-complete delete;
the entry it could not reap stays in the store and ages out by TTL."""
store = _WedgingDeleteStore()
cache = _coordinator(store, store_cleanup_timeout=0.01)
gen = cache.capture("tools/list", "")
async def wedge_then_evict() -> None:
store.wedged = True
await cache.evict_method("tools/list") # its own cleanup hits the bound too
store.before_set_commits = wedge_then_evict
with anyio.fail_after(5):
await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use")
# Opposite-arm delete, the eviction's first delete, the compensating delete.
assert store.deletes_started == 3 # pragma: lax no cover
# The accepted degradation: the unreaped entry stays until its TTL expires.
assert await store.inner.get(CacheKey("tools/list", "", _private_arm())) is not None # pragma: lax no cover
# --- Coordinator: store error discipline ---
async def test_a_raising_store_get_is_a_cache_miss() -> None:
store = _FailingStore(fail_get=True)
cache = _coordinator(store)
assert await cache.read("tools/list", "") is None
@pytest.mark.parametrize(
"rehydrated",
[
CacheEntry(value={"tools": []}, scope="private", expires_at=2_000_000.0),
{"value": {"tools": []}, "scope": "private", "expires_at": 2_000_000.0},
],
ids=["dict-value", "dict-entry"],
)
async def test_an_entry_rehydrated_into_the_wrong_shape_is_a_warned_miss(
rehydrated: Any, caplog: pytest.LogCaptureFixture
) -> None:
"""A persistent store has no method-to-model mapping, so its `get` may return serialized shapes;
the warned miss is one burst, not one warning per cached read."""
cache = _coordinator(_RehydratingStore(rehydrated))
with caplog.at_level(logging.WARNING, logger="mcp.client.caching"):
assert await cache.read("tools/list", "") is None
assert await cache.read("tools/list", "") is None
assert len(caplog.records) == 1
async def test_a_raising_opposite_arm_delete_aborts_the_write() -> None:
"""Setting after a failed opposite-arm delete could leave both arms populated."""
store = _FailingStore(fail_delete=True)
cache = _coordinator(store)
gen = cache.capture("tools/list", "")
await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use")
assert await store.inner.get(CacheKey("tools/list", "", _private_arm())) is None
assert await store.inner.get(CacheKey("tools/list", "", _public_arm())) is None
async def test_a_failed_opposite_arm_delete_degrades_the_key_to_a_full_miss() -> None:
"""The fetch superseded the warm own-arm entry, so it is best-effort deleted too; the write never raises."""
store = _ArmDeleteFailingStore(failing_arm=_public_arm())
cache = _coordinator(store)
await store.inner.set(
CacheKey("tools/list", "", _private_arm()),
CacheEntry(value=_wire_result(), scope="private", expires_at=2_000_000.0),
)
assert await cache.read("tools/list", "") is not None # the warm own-arm entry
gen = cache.capture("tools/list", "")
await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use")
assert await store.inner.get(CacheKey("tools/list", "", _private_arm())) is None
assert await store.inner.get(CacheKey("tools/list", "", _public_arm())) is None
assert await cache.read("tools/list", "") is None
async def test_a_raising_store_set_caches_nothing_and_does_not_raise() -> None:
store = _FailingStore(fail_set=True)
cache = _coordinator(store)
gen = cache.capture("tools/list", "")
await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use")
assert await cache.read("tools/list", "") is None
async def test_a_failed_set_purges_the_pre_existing_own_arm_entry() -> None:
"""The fetch superseded the warm own-arm entry, and the failed set left it in place:
without the purge it would keep serving the superseded value for its full TTL."""
store = _FailingStore()
cache = _coordinator(store)
gen = cache.capture("tools/list", "")
await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use")
assert await cache.read("tools/list", "") is not None # the warm own-arm entry
store.fail_set = True
gen = cache.capture("tools/list", "")
await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use") # the caller's fetch is unaffected
assert await store.inner.get(CacheKey("tools/list", "", _private_arm())) is None
assert await store.inner.get(CacheKey("tools/list", "", _public_arm())) is None
assert await cache.read("tools/list", "") is None
async def test_eviction_with_a_raising_delete_still_bumps_the_generation() -> None:
"""Bump-first: a fetch captured before the eviction cannot write back even when the deletes raise."""
store = _FailingStore()
cache = _coordinator(store)
stale_gen = cache.capture("tools/list", "") # fetch in flight when the eviction lands
store.fail_delete = True
await cache.evict_method("tools/list") # deletes raise; the bump already happened
store.fail_delete = False
await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), stale_gen, "use")
assert await store.inner.get(CacheKey("tools/list", "", _private_arm())) is None
fresh_gen = cache.capture("tools/list", "")
await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), fresh_gen, "use")
assert await cache.read("tools/list", "") == _wire_result(ttl_ms=60_000)
async def test_store_failures_warn_once_per_burst(caplog: pytest.LogCaptureFixture) -> None:
store = _FailingStore(fail_get=True)
cache = _coordinator(store)
with caplog.at_level(logging.WARNING, logger="mcp.client.caching"):
await cache.read("tools/list", "") # consecutive failing reads, one burst
await cache.read("tools/list", "")
assert len(caplog.records) == 1
store.fail_get = False
await cache.read("tools/list", "") # success re-arms the warning
store.fail_get = True
await cache.read("tools/list", "")
assert len(caplog.records) == 2
assert caplog.messages[0] == snapshot("Response cache store operation failed; continuing without the cache")
async def test_a_set_only_store_failure_warns_once_across_write_cycles(caplog: pytest.LogCaptureFixture) -> None:
"""Bursts are tracked per operation kind - the healthy deletes between failing sets never re-arm."""
store = _FailingStore(fail_set=True)
cache = _coordinator(store)
with caplog.at_level(logging.WARNING, logger="mcp.client.caching"):
for _ in range(3): # each cycle: opposite-arm delete succeeds, then the set fails
gen = cache.capture("tools/list", "")
await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use")
assert len(caplog.records) == 1
store.fail_set = False
gen = cache.capture("tools/list", "")
await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use") # set succeeds, re-arms
store.fail_set = True
gen = cache.capture("tools/list", "")
await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use")
assert len(caplog.records) == 2
# --- Coordinator: generation discipline ---
async def test_an_eviction_between_capture_and_write_discards_the_write() -> None:
"""Spec-aligned: a fetch in flight when its key is evicted must not write the evicted entry back."""
store = InMemoryResponseCacheStore()
cache = _coordinator(store)
gen = cache.capture("tools/list", "")
await cache.evict_method("tools/list")
await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use")
assert await store.get(CacheKey("tools/list", "", _private_arm())) is None
assert await store.get(CacheKey("tools/list", "", _public_arm())) is None
async def test_recapturing_a_registered_key_returns_its_current_generation() -> None:
store = InMemoryResponseCacheStore()
cache = _coordinator(store)
gen_before = cache.capture("tools/list", "")
await cache.evict_method("tools/list")
gen_after = cache.capture("tools/list", "")
assert gen_after != gen_before
await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen_after, "use")
assert await cache.read("tools/list", "") == _wire_result(ttl_ms=60_000)
async def test_the_generation_map_drops_the_oldest_key_at_its_cap() -> None:
"""A dropped key's race guard degrades to the accepted co-tenant class - an eviction racing its
in-flight fetch goes undetected (cap is 4096 in production, parametrized small here)."""
store = InMemoryResponseCacheStore()
cache = _coordinator(store, generation_map_cap=2)
gen_a = cache.capture("resources/read", "file:///a")
gen_b = cache.capture("resources/read", "file:///b")
cache.capture("resources/read", "file:///c") # at the cap: drops file:///a
await cache.evict_key("resources/read", "file:///a") # unregistered: no bump
await cache.evict_key("resources/read", "file:///b") # registered: bump
await cache.write("resources/read", "file:///a", _read_result(ttl_ms=60_000), gen_a, "use")
await cache.write("resources/read", "file:///b", _read_result(ttl_ms=60_000), gen_b, "use")
assert await cache.read("resources/read", "file:///a") is not None # degraded guard fails open
assert await cache.read("resources/read", "file:///b") is None # guard held
# --- Coordinator: eviction ---
async def test_a_refresh_resolving_uncacheable_purges_the_warm_entry() -> None:
"""The refetch superseded the warm entry, which must not be served again."""
store = InMemoryResponseCacheStore()
cache = _coordinator(store)
gen = cache.capture("tools/list", "")
await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use")
assert await cache.read("tools/list", "") is not None
await cache.write("tools/list", "", _wire_result(ttl_ms=0), gen, "refresh")
assert await store.get(CacheKey("tools/list", "", _private_arm())) is None
assert await store.get(CacheKey("tools/list", "", _public_arm())) is None
async def test_evict_key_on_an_unregistered_key_still_deletes_both_arms() -> None:
"""A persistent store may hold warm entries from a prior process this coordinator never captured."""
store = InMemoryResponseCacheStore()
await store.set(
CacheKey("resources/read", "file:///warm", _private_arm()),
CacheEntry(value=_read_result(ttl_ms=60_000), scope="private", expires_at=2_000_000.0),
)
await store.set(
CacheKey("resources/read", "file:///warm", _public_arm()),
CacheEntry(value=_read_result(ttl_ms=60_000), scope="public", expires_at=2_000_000.0),
)
cache = _coordinator(store)
await cache.evict_key("resources/read", "file:///warm")
assert await store.get(CacheKey("resources/read", "file:///warm", _private_arm())) is None
assert await store.get(CacheKey("resources/read", "file:///warm", _public_arm())) is None
@pytest.mark.parametrize(
("notification", "evicted"),
[
(ToolListChangedNotification(), {("tools/list", "")}),
(PromptListChangedNotification(), {("prompts/list", "")}),
(ResourceListChangedNotification(), {("resources/list", ""), ("resources/templates/list", "")}),
(
ResourceUpdatedNotification(params=ResourceUpdatedNotificationParams(uri="file:///a")),
{("resources/read", "file:///a")},
),
(
LoggingMessageNotification(params=LoggingMessageNotificationParams(level="info", data="x")),
set[tuple[str, str]](),
),
],
ids=["tools-list-changed", "prompts-list-changed", "resources-list-changed", "resource-updated", "unrelated"],
)
async def test_notifications_evict_exactly_their_mapped_entries(
notification: ServerNotification, evicted: set[tuple[str, str]]
) -> None:
"""Spec SHOULD: notifications invalidate - and nothing beyond their mapped entries."""
store = InMemoryResponseCacheStore()
cache = _coordinator(store)
seeded = [
("tools/list", ""),
("prompts/list", ""),
("resources/list", ""),
("resources/templates/list", ""),
("resources/read", "file:///a"),
("resources/read", "file:///b"),
]
for method, params_key in seeded:
# The value's content is irrelevant to eviction; any cacheable model serves.
await store.set(
CacheKey(method, params_key, _private_arm()),
CacheEntry(value=_wire_result(), scope="private", expires_at=2_000_000.0),
)
await cache.evict_for_notification(notification)
for method, params_key in seeded:
if (method, params_key) in evicted:
assert await cache.read(method, params_key) is None
else:
assert await cache.read(method, params_key) is not None