Skip to content

Commit d2d4a25

Browse files
committed
Add client response cache primitives: config, keys, store protocol, in-memory store
1 parent 1797d9a commit d2d4a25

3 files changed

Lines changed: 505 additions & 1 deletion

File tree

‎src/mcp/client/__init__.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,16 @@
22

33
from mcp.client._input_required import InputRequiredRoundsExceededError
44
from mcp.client._transport import Transport
5+
from mcp.client.caching import CacheConfig
56
from mcp.client.client import Client
67
from mcp.client.context import ClientRequestContext
78
from mcp.client.session import ClientSession
89

9-
__all__ = ["Client", "ClientRequestContext", "ClientSession", "InputRequiredRoundsExceededError", "Transport"]
10+
__all__ = [
11+
"CacheConfig",
12+
"Client",
13+
"ClientRequestContext",
14+
"ClientSession",
15+
"InputRequiredRoundsExceededError",
16+
"Transport",
17+
]

‎src/mcp/client/caching.py‎

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
"""Client-side response caching primitives (SEP-2549, protocol revision 2026-07-28).
2+
3+
Results for the cacheable methods carry `ttlMs`/`cacheScope` freshness hints;
4+
the client honors them through a response cache configured with `CacheConfig`.
5+
This module defines the configuration, the store contract (`ResponseCacheStore`
6+
keyed by `CacheKey`, holding `CacheEntry` values), and the default in-process
7+
store. Wiring into `Client` lives in `mcp.client.client`.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import time
13+
from collections.abc import Callable
14+
from dataclasses import dataclass
15+
from typing import Any, Final, Literal, Protocol
16+
17+
__all__ = [
18+
"MAX_TTL_MS",
19+
"CacheConfig",
20+
"CacheEntry",
21+
"CacheKey",
22+
"CacheMode",
23+
"InMemoryResponseCacheStore",
24+
"ResponseCacheStore",
25+
]
26+
27+
CacheMode = Literal["use", "refresh", "bypass"]
28+
"""Per-call cache behavior: `"use"` serves fresh entries and stores fetches,
29+
`"refresh"` skips the read but stores the fetch, `"bypass"` touches the cache
30+
not at all."""
31+
32+
MAX_TTL_MS: Final[int] = 24 * 60 * 60 * 1000
33+
"""Upper bound on any entry's time-to-live (24 hours, in milliseconds): a
34+
server-provided or configured `ttlMs` above it is clamped down, bounding how
35+
long a stale entry can be served."""
36+
37+
38+
@dataclass(frozen=True, slots=True)
39+
class CacheKey:
40+
"""Identity of one cached response.
41+
42+
Stores MUST compare keys as the `(method, params_key, partition)` field
43+
tuple - never by flattening the fields into one delimited string, which
44+
lets crafted values collide across field boundaries.
45+
"""
46+
47+
method: str
48+
"""The request method, e.g. `"tools/list"`."""
49+
50+
params_key: str = ""
51+
"""Result-affecting params discriminator: the uri for `resources/read`,
52+
`""` for the list methods (only cursor-less calls participate in caching)."""
53+
54+
partition: str = ""
55+
"""Coordinator-computed arm identifier; opaque to stores."""
56+
57+
58+
@dataclass(frozen=True, slots=True)
59+
class CacheEntry:
60+
"""One cached response with its freshness and sharing metadata."""
61+
62+
value: Any
63+
"""The cached result. The SDK deep-copies it on write and on serve, so a
64+
store may hold the object as-is."""
65+
66+
scope: Literal["public", "private"]
67+
"""The server-asserted `cacheScope`: whether the entry may be shared
68+
across authorization contexts (`"public"`) or only reused within the one
69+
that produced it (`"private"`)."""
70+
71+
expires_at: float | None
72+
"""Epoch seconds after which the entry is stale; `None` is never fresh."""
73+
74+
75+
class ResponseCacheStore(Protocol):
76+
"""Storage contract for the client response cache.
77+
78+
Keys MUST be compared as the `(method, params_key, partition)` field tuple -
79+
no delimiter-based flattening (collision hazard). Each `Client` calls its
80+
store from a single event loop; cross-loop sharing and per-operation
81+
atomicity are the implementation's responsibility. Operations may raise;
82+
the SDK degrades per its error discipline (a failing store never fails a
83+
successful fetch).
84+
"""
85+
86+
async def get(self, key: CacheKey) -> CacheEntry | None: ...
87+
88+
async def set(self, key: CacheKey, entry: CacheEntry) -> None: ...
89+
90+
async def delete(self, key: CacheKey) -> None: ...
91+
92+
async def clear(self) -> None: ...
93+
94+
95+
@dataclass(frozen=True, slots=True)
96+
class CacheConfig:
97+
"""Configuration for a `Client`'s response cache.
98+
99+
Raises:
100+
ValueError: If a custom `store` is given without a `partition`, or if
101+
`default_ttl_ms` is negative.
102+
"""
103+
104+
store: ResponseCacheStore | None = None
105+
"""Backing store; `None` means a store-per-client `InMemoryResponseCacheStore`.
106+
A custom store requires an explicit `partition`."""
107+
108+
partition: str = ""
109+
"""Authorization-context identifier isolating `"private"`-scoped entries
110+
within a shared store.
111+
112+
Derive it from a verified credential (e.g. a validated token's subject) -
113+
never from request-supplied data, and never from the server URL (server
114+
identity is a separate key axis). The SDK is a library with no
115+
authentication of its own: whoever constructs the `CacheConfig` - the
116+
deployment, not the tenant - is the trust anchor. Multi-tenant gateways
117+
mint one `CacheConfig` per authenticated principal.
118+
"""
119+
120+
target_id: str | None = None
121+
"""Explicit server-identity override, for custom transports and proxies
122+
where the SDK cannot derive an identity from a server URL."""
123+
124+
default_ttl_ms: int = 0
125+
"""Time-to-live, in milliseconds, applied to results that carry no `ttlMs`
126+
hint. The default `0` leaves hint-less results uncached."""
127+
128+
clock: Callable[[], float] = time.time
129+
"""Wall-clock source returning epoch seconds; injectable so expiry tests
130+
need no sleeping."""
131+
132+
share_public: bool = False
133+
"""Serve entries the server marked `cacheScope: "public"` across every
134+
partition using the store, instead of only within the partition that
135+
fetched them.
136+
137+
WARNING: enabling this trusts the server's public classification for every
138+
principal sharing the store - a server that stamps `"public"` on
139+
per-tenant data (by bug or by malice) leaks one tenant's response to the
140+
others. It is deliberately constructor-level only, set once by the
141+
operator: the per-call `cache_mode` kwarg can narrow caching but can never
142+
widen sharing.
143+
"""
144+
145+
def __post_init__(self) -> None:
146+
if self.store is not None and not self.partition:
147+
raise ValueError("a custom store requires an explicit partition")
148+
if self.default_ttl_ms < 0:
149+
raise ValueError(f"default_ttl_ms must be >= 0, got {self.default_ttl_ms}")
150+
151+
152+
class InMemoryResponseCacheStore:
153+
"""Default in-process `ResponseCacheStore`.
154+
155+
Method bodies are synchronous (no awaits), so each operation completes
156+
without an event-loop checkpoint and concurrent tasks can never observe a
157+
torn write. Memory is bounded: the methods other than `resources/read`
158+
form a small closed set of keys, and `max_read_entries` caps the
159+
`resources/read` entries (one per uri) - storing a new read key at the cap
160+
evicts the oldest read key, first-in-first-out. `0` disables the cap.
161+
162+
Raises:
163+
ValueError: If `max_read_entries` is negative.
164+
"""
165+
166+
def __init__(self, *, max_read_entries: int = 512) -> None:
167+
if max_read_entries < 0:
168+
raise ValueError(f"max_read_entries must be >= 0, got {max_read_entries}")
169+
self._max_read_entries = max_read_entries
170+
self._entries: dict[CacheKey, CacheEntry] = {}
171+
172+
async def get(self, key: CacheKey) -> CacheEntry | None:
173+
return self._entries.get(key)
174+
175+
async def set(self, key: CacheKey, entry: CacheEntry) -> None:
176+
if self._max_read_entries and key.method == "resources/read" and key not in self._entries:
177+
# dict preserves insertion order and replacement keeps position, so
178+
# the dict itself is the FIFO ledger - no parallel structure to drift.
179+
read_keys = [k for k in self._entries if k.method == "resources/read"]
180+
if len(read_keys) >= self._max_read_entries:
181+
del self._entries[read_keys[0]]
182+
self._entries[key] = entry
183+
184+
async def delete(self, key: CacheKey) -> None:
185+
self._entries.pop(key, None)
186+
187+
async def clear(self) -> None:
188+
self._entries.clear()

0 commit comments

Comments
 (0)