Skip to content

Commit efac31f

Browse files
committed
Tighten response cache: session guard, identity and meta handling, refresh purge
1 parent b81b7dd commit efac31f

6 files changed

Lines changed: 241 additions & 12 deletions

File tree

docs/advanced/caching.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,13 @@ Four calls, three fetches. The second call found a fresh entry and never reached
4949
* `"refresh"` never serves: it fetches and stores the result, replacing whatever was cached.
5050
* `"bypass"` makes the round trip without touching the cache at all — no read, no write.
5151

52+
One rule sits above `"use"`: **calls carrying `meta` always reach the server.** A request with `meta` set (a progress token, tracing fields) expects a wire request, so under `cache_mode="use"` it is treated as `"refresh"` — the cache read is skipped, and the fetched result still replaces the cached entry. `"bypass"` and an explicit `"refresh"` behave as they always do.
53+
5254
To turn caching off entirely, construct with `Client(server, cache=False)`: every call is a round trip again, and `cache_mode`, while still accepted, does nothing.
5355

54-
Scope is honored automatically too — `"private"` entries are keyed to the cache's *partition* (below); `"public"` ones may opt into wider sharing — and **notifications beat TTL**: a `list_changed` notification evicts the matching cached listing, and `resources/updated` evicts the cached read for its URI, however fresh they were.
56+
Scope is honored automatically too — `"private"` entries are keyed to the cache's *partition* (below); `"public"` ones may opt into wider sharing — and **notifications beat TTL** for the exact entries they name: a `list_changed` notification evicts the matching cached listing, and `resources/updated` evicts the cached read stored under exactly its URI, however fresh they were.
57+
58+
One caveat on `resources/updated`: eviction is exact-URI only. The store contract has no enumerate or scan operation (same as the reference TypeScript implementation), so a notification carrying a *sub*-resource URI does not evict a cached read of its parent. If your server signals sub-resources this way, refetch the parent with `cache_mode="refresh"`.
5559

5660
### Configuring it: `CacheConfig`
5761

@@ -61,7 +65,7 @@ from mcp.client import CacheConfig
6165
client = Client("https://api.example.com/mcp", cache=CacheConfig(default_ttl_ms=5_000))
6266
```
6367

64-
* `store` — where entries live. The default is a fresh in-memory store per client; pass your own `ResponseCacheStore` implementation (Redis-backed, say) to share a cache across clients or processes. A custom store **requires** an explicit `partition`.
68+
* `store` — where entries live. The default is a fresh in-memory store per client; pass your own `ResponseCacheStore` implementation (Redis-backed, say) to share a cache across clients or processes — the contract types (`ResponseCacheStore`, `CacheKey`, `CacheEntry`, and the default `InMemoryResponseCacheStore`) are importable from `mcp.client`. A lookup may issue up to two sequential store `get`s (the private arm, then the public one), so size a remote store's latency expectations accordingly. A custom store **requires** an explicit `partition`.
6569
* `partition` — the authorization-context label that keeps one principal's `"private"` entries from being served to another within a shared store.
6670
* `target_id` — explicit server identity, for custom transports and in-process servers (below).
6771
* `default_ttl_ms` — TTL applied to results that carry no `ttlMs` hint. The default `0` leaves hint-less results uncached.

src/mcp/client/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,28 @@
22

33
from mcp.client._input_required import InputRequiredRoundsExceededError
44
from mcp.client._transport import Transport
5-
from mcp.client.caching import CacheConfig, CacheMode
5+
from mcp.client.caching import (
6+
CacheConfig,
7+
CacheEntry,
8+
CacheKey,
9+
CacheMode,
10+
InMemoryResponseCacheStore,
11+
ResponseCacheStore,
12+
)
613
from mcp.client.client import Client
714
from mcp.client.context import ClientRequestContext
815
from mcp.client.session import ClientSession
916

1017
__all__ = [
1118
"CacheConfig",
19+
"CacheEntry",
20+
"CacheKey",
1221
"CacheMode",
1322
"Client",
1423
"ClientRequestContext",
1524
"ClientSession",
25+
"InMemoryResponseCacheStore",
1626
"InputRequiredRoundsExceededError",
27+
"ResponseCacheStore",
1728
"Transport",
1829
]

src/mcp/client/caching.py

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ class ResponseCacheStore(Protocol):
103103
no rehydration hook to rebuild it from serialized data. An entry that
104104
comes back in the wrong shape (e.g. with a plain-dict value) degrades to
105105
a cache miss, never an error.
106+
107+
A cache lookup may issue up to two sequential `get` calls - the private
108+
arm, then the public one - so remote-store implementers should size
109+
latency expectations accordingly.
106110
"""
107111

108112
async def get(self, key: CacheKey) -> CacheEntry | None: ...
@@ -119,8 +123,8 @@ class CacheConfig:
119123
"""Configuration for a `Client`'s response cache.
120124
121125
Raises:
122-
ValueError: If a custom `store` is given without a `partition`, or if
123-
`default_ttl_ms` is negative.
126+
ValueError: If a custom `store` is given without a `partition`, if
127+
`target_id` is an empty string, or if `default_ttl_ms` is negative.
124128
"""
125129

126130
store: ResponseCacheStore | None = None
@@ -146,7 +150,9 @@ class CacheConfig:
146150

147151
target_id: str | None = None
148152
"""Explicit server-identity override, for custom transports and proxies
149-
where the SDK cannot derive an identity from a server URL."""
153+
where the SDK cannot derive an identity from a server URL. Must be
154+
non-empty when provided - an empty string would collapse distinct servers
155+
onto one identity."""
150156

151157
default_ttl_ms: int = 0
152158
"""Time-to-live, in milliseconds, applied to results that carry no `ttlMs`
@@ -172,6 +178,8 @@ class CacheConfig:
172178
def __post_init__(self) -> None:
173179
if self.store is not None and not self.partition:
174180
raise ValueError("a custom store requires an explicit partition")
181+
if self.target_id == "":
182+
raise ValueError("target_id must be a non-empty string or omitted")
175183
if self.default_ttl_ms < 0:
176184
raise ValueError(f"default_ttl_ms must be >= 0, got {self.default_ttl_ms}")
177185

@@ -200,7 +208,14 @@ async def get(self, key: CacheKey) -> CacheEntry | None:
200208
return self._entries.get(key)
201209

202210
async def set(self, key: CacheKey, entry: CacheEntry) -> None:
203-
if self._max_read_entries and key.method == "resources/read" and key not in self._entries:
211+
if (
212+
self._max_read_entries
213+
and key.method == "resources/read"
214+
and key not in self._entries
215+
# Strictly below the cap the read-key subset cannot be at the cap,
216+
# so the scan only runs when an eviction is actually possible.
217+
and len(self._entries) >= self._max_read_entries
218+
):
204219
# dict preserves insertion order and replacement keeps position, so
205220
# the dict itself is the FIFO ledger - no parallel structure to drift.
206221
read_keys = [k for k in self._entries if k.method == "resources/read"]
@@ -337,6 +352,12 @@ async def write(
337352
# Opposite arm first: a failed (or cancelled) delete aborts before the
338353
# set, leaving a miss - never two arms answering for one key.
339354
if not await self._delete(opposite):
355+
# The fetch superseded whatever the own arm holds, so it must not
356+
# keep serving either: best-effort delete it too (shielded like the
357+
# other cleanup deletes), degrading the key to a full miss - no
358+
# stale pair AND no superseded entry.
359+
with anyio.CancelScope(shield=True):
360+
await self._delete(own)
340361
return
341362
entry = CacheEntry(value=result.model_copy(deep=True), scope=scope, expires_at=self._clock() + ttl_ms / 1000)
342363
try:

src/mcp/client/client.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,7 @@ async def _cached_fetch(
464464
method: str,
465465
*,
466466
cursor: str | None,
467+
meta: RequestParamsMeta | None,
467468
cache_mode: CacheMode,
468469
send: Callable[[], Awaitable[_CacheableT]],
469470
absorb: Callable[[_CacheableT], _CacheableT] | None = None,
@@ -476,6 +477,15 @@ async def _cached_fetch(
476477
cache = self._response_cache
477478
if cache is None or cache_mode == "bypass":
478479
return await send() # no read, no write, no eviction side-effects
480+
# Cache participation requires a live session: a closed (or never-entered)
481+
# client raises the no-context RuntimeError on every verb, exactly as the
482+
# verbs did before the cache existed - never serving stale entries.
483+
_ = self.session
484+
if meta is not None and cache_mode == "use":
485+
# A call carrying meta (a progress token, tracing fields) expects a
486+
# wire request, so it is never served from the cache; the fetched
487+
# result still replaces the entry, the same as an explicit refresh.
488+
cache_mode = "refresh"
479489
if cursor is not None:
480490
# Continuation pages never read or write the (cursor-less) entry, but an
481491
# expired-cursor rejection signals the listing changed since the entry was
@@ -506,11 +516,13 @@ async def list_resources(
506516
) -> ListResourcesResult:
507517
"""List available resources from the server.
508518
509-
`cache_mode` adjusts the response cache's behavior for this call (see `CacheMode`).
519+
`cache_mode` adjusts the response cache's behavior for this call (see `CacheMode`);
520+
calls carrying `meta` always reach the server.
510521
"""
511522
return await self._cached_fetch(
512523
"resources/list",
513524
cursor=cursor,
525+
meta=meta,
514526
cache_mode=cache_mode,
515527
send=lambda: self.session.list_resources(params=PaginatedRequestParams(cursor=cursor, _meta=meta)),
516528
)
@@ -524,11 +536,13 @@ async def list_resource_templates(
524536
) -> ListResourceTemplatesResult:
525537
"""List available resource templates from the server.
526538
527-
`cache_mode` adjusts the response cache's behavior for this call (see `CacheMode`).
539+
`cache_mode` adjusts the response cache's behavior for this call (see `CacheMode`);
540+
calls carrying `meta` always reach the server.
528541
"""
529542
return await self._cached_fetch(
530543
"resources/templates/list",
531544
cursor=cursor,
545+
meta=meta,
532546
cache_mode=cache_mode,
533547
send=lambda: self.session.list_resource_templates(params=PaginatedRequestParams(cursor=cursor, _meta=meta)),
534548
)
@@ -554,7 +568,8 @@ async def read_resource(
554568
input_responses: Responses to seed the first call with (e.g. when
555569
resuming from a persisted `InputRequiredResult`).
556570
request_state: Opaque state to seed the first call with.
557-
meta: Additional metadata for the request.
571+
meta: Additional metadata for the request. Calls carrying `meta`
572+
always reach the server.
558573
cache_mode: Adjusts the response cache's behavior for this call
559574
(see `CacheMode`). Seeded calls (either `input_responses` or
560575
`request_state` set) are resumptions of a multi-round-trip
@@ -581,6 +596,12 @@ async def retry(r: InputResponses | None, s: str | None) -> ReadResourceResult |
581596
cache = None if seeded else self._response_cache
582597
if cache is None or cache_mode == "bypass":
583598
return await self._drive_input_required(await retry(input_responses, request_state), retry)
599+
# Cache participation requires a live session: a closed (or never-entered)
600+
# client raises the no-context RuntimeError here, never serving stale entries.
601+
_ = self.session
602+
if meta is not None and cache_mode == "use":
603+
# Calls carrying meta always reach the server (mirrors `_cached_fetch`).
604+
cache_mode = "refresh"
584605
if cache_mode == "use" and (hit := await cache.read("resources/read", uri)) is not None:
585606
# InputRequiredResult is never stored (only terminal first-round results
586607
# are written below), so a hit is always terminal and legitimately skips
@@ -669,11 +690,13 @@ async def list_prompts(
669690
) -> ListPromptsResult:
670691
"""List available prompts from the server.
671692
672-
`cache_mode` adjusts the response cache's behavior for this call (see `CacheMode`).
693+
`cache_mode` adjusts the response cache's behavior for this call (see `CacheMode`);
694+
calls carrying `meta` always reach the server.
673695
"""
674696
return await self._cached_fetch(
675697
"prompts/list",
676698
cursor=cursor,
699+
meta=meta,
677700
cache_mode=cache_mode,
678701
send=lambda: self.session.list_prompts(params=PaginatedRequestParams(cursor=cursor, _meta=meta)),
679702
)
@@ -767,11 +790,13 @@ async def list_tools(
767790
) -> ListToolsResult:
768791
"""List available tools from the server.
769792
770-
`cache_mode` adjusts the response cache's behavior for this call (see `CacheMode`).
793+
`cache_mode` adjusts the response cache's behavior for this call (see `CacheMode`);
794+
calls carrying `meta` always reach the server.
771795
"""
772796
return await self._cached_fetch(
773797
"tools/list",
774798
cursor=cursor,
799+
meta=meta,
775800
cache_mode=cache_mode,
776801
send=lambda: self.session.list_tools(params=PaginatedRequestParams(cursor=cursor, _meta=meta)),
777802
# A cache hit skips session.list_tools, so the session re-absorbs the

tests/client/test_caching.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,15 @@ def test_a_custom_store_with_an_explicit_partition_constructs() -> None:
229229
assert config.partition == "token-subject-1"
230230

231231

232+
def test_an_empty_target_id_is_rejected_at_construction() -> None:
233+
"""SDK-defined guard: an explicit empty `target_id` would hash to the one
234+
shared `sha256("")` identity, collapsing distinct servers onto it -
235+
rejected at construction; omit the field (None) to derive an identity."""
236+
with pytest.raises(ValueError) as exc:
237+
CacheConfig(target_id="")
238+
assert str(exc.value) == snapshot("target_id must be a non-empty string or omitted")
239+
240+
232241
def test_a_negative_default_ttl_is_rejected_at_construction() -> None:
233242
"""SDK-defined guard: a negative configured TTL is a programming error,
234243
rejected at construction (negative `ttlMs` from the wire is tolerated as 0
@@ -457,6 +466,30 @@ async def clear(self) -> None:
457466
raise NotImplementedError
458467

459468

469+
class _ArmDeleteFailingStore:
470+
"""In-memory store whose `delete` raises only for keys on the given arm,
471+
modelling a write whose opposite-arm cleanup fails while everything else
472+
works. A write hitting that failure never reaches `set`."""
473+
474+
def __init__(self, failing_arm: str) -> None:
475+
self.inner = InMemoryResponseCacheStore()
476+
self.failing_arm = failing_arm
477+
478+
async def get(self, key: CacheKey) -> CacheEntry | None:
479+
return await self.inner.get(key)
480+
481+
async def set(self, key: CacheKey, entry: CacheEntry) -> None:
482+
raise NotImplementedError
483+
484+
async def delete(self, key: CacheKey) -> None:
485+
if key.partition == self.failing_arm:
486+
raise RuntimeError("store delete failed")
487+
await self.inner.delete(key)
488+
489+
async def clear(self) -> None:
490+
raise NotImplementedError
491+
492+
460493
class _RehydratingStore:
461494
"""Models a persistent store whose `get` returns what its deserializer
462495
produced - possibly not the shape `set` received."""
@@ -832,6 +865,25 @@ async def test_a_raising_opposite_arm_delete_aborts_the_write() -> None:
832865
assert await store.inner.get(CacheKey("tools/list", "", _public_arm())) is None
833866

834867

868+
async def test_a_failed_opposite_arm_delete_degrades_the_key_to_a_full_miss() -> None:
869+
"""SDK error discipline: when only the opposite-arm delete fails, the write
870+
cannot set its own arm (two arms might answer) - but the warm own-arm
871+
entry was superseded by the fetch, so it is best-effort deleted too: both
872+
arms read as misses, and the write itself never raises."""
873+
store = _ArmDeleteFailingStore(failing_arm=_public_arm())
874+
cache = _coordinator(store)
875+
await store.inner.set(
876+
CacheKey("tools/list", "", _private_arm()),
877+
CacheEntry(value=_wire_result(), scope="private", expires_at=2_000_000.0),
878+
)
879+
assert await cache.read("tools/list", "") is not None # the warm own-arm entry
880+
gen = cache.capture("tools/list", "")
881+
await cache.write("tools/list", "", _wire_result(ttl_ms=60_000), gen, "use")
882+
assert await store.inner.get(CacheKey("tools/list", "", _private_arm())) is None
883+
assert await store.inner.get(CacheKey("tools/list", "", _public_arm())) is None
884+
assert await cache.read("tools/list", "") is None
885+
886+
835887
async def test_a_raising_store_set_caches_nothing_and_does_not_raise() -> None:
836888
"""SDK error discipline: a `set` raise is logged and swallowed - the fetch
837889
already succeeded, the result just is not cached."""

0 commit comments

Comments
 (0)