Skip to content

Commit 98941ac

Browse files
committed
Document the client response cache
1 parent 8fae9cf commit 98941ac

6 files changed

Lines changed: 317 additions & 23 deletions

File tree

‎docs/advanced/caching.md‎

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,19 +37,63 @@ One caveat on paginated lists: the protocol requires the **same `cacheScope` on
3737

3838
## What the client sees
3939

40-
On the client, the hints arrive as plain fields on every cacheable result — `ttl_ms` and `cache_scope`, already parsed:
40+
On a 2026-07-28 session, `Client` honors the hints for you: it has a built-in response cache, on by default. A result that arrives carrying a `ttlMs` is stored, and an identical call within that TTL is served from the cache — no round trip. A result that carries *no* hint is not cached: hint-less results get `CacheConfig.default_ttl_ms`, which defaults to `0` (immediately stale), so a server that declares nothing sees exactly the call-for-call traffic it always did.
4141

42-
```python title="client.py" hl_lines="15"
42+
```python title="client.py" hl_lines="28 30 33"
4343
--8<-- "docs_src/caching/tutorial003.py"
4444
```
4545

46-
The SDK parses; it does not (yet) act. There is no built-in response cache: calling `list_tools()` twice makes two round trips, whatever the TTL said. The spec makes honoring optional — a client that ignores the hints entirely is fully conformant — so until the SDK grows a response cache, the supported path is to read the fields and do your own bookkeeping:
46+
Four calls, three fetches. The second call found a fresh entry and never reached the server; advancing the (injected) clock past the TTL made the third fetch again; the fourth said `cache_mode="refresh"`. That kwarg exists on the five caching verbs — `list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, `read_resource`:
4747

48-
* **Freshness** is `now < t_received + ttl_ms / 1000`: record the clock when the response arrives, and treat the result as reusable until the TTL runs out. `ttl_ms == 0` means *immediately stale* — don't reuse it at all.
49-
* **Scope is a sharing rule, not a suggestion.** A `"private"` result may be reused only within the same authorization context — same access token, same cache. Never put `"private"` results in a cache shared across users.
50-
* **Notifications beat TTL.** If the server sends `list_changed` while your copy is still fresh, the copy is stale now — re-fetch.
48+
* `"use"` (the default) serves a fresh entry if there is one, and stores the fetch if not.
49+
* `"refresh"` never serves: it fetches and stores the result, replacing whatever was cached.
50+
* `"bypass"` makes the round trip without touching the cache at all — no read, no write.
5151

52-
Against an **older server** (pre-2026 protocol), the fields are simply absent from the wire, and the models show their conservative defaults: `ttl_ms == 0`, `cache_scope == "private"` — stale and unshared, the right assumption for a server that declared nothing. If you need to distinguish "the server said 0" from "the server said nothing", check `"ttl_ms" in result.model_fields_set`: it's only set when the field actually arrived.
52+
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.
53+
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.
55+
56+
### Configuring it: `CacheConfig`
57+
58+
```python
59+
from mcp.client import CacheConfig
60+
61+
client = Client("https://api.example.com/mcp", cache=CacheConfig(default_ttl_ms=5_000))
62+
```
63+
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`.
65+
* `partition` — the authorization-context label that keeps one principal's `"private"` entries from being served to another within a shared store.
66+
* `target_id` — explicit server identity, for custom transports and in-process servers (below).
67+
* `default_ttl_ms` — TTL applied to results that carry no `ttlMs` hint. The default `0` leaves hint-less results uncached.
68+
* `share_public` — serve server-asserted-`"public"` entries across partitions (below). Off by default.
69+
* `clock` — the wall-clock source, epoch seconds. Inject one, as the example above does, and expiry tests need no sleeping.
70+
71+
!!! warning "Partition = verified principal"
72+
Derive `partition` from a **verified credential** — a validated token's subject, for example. Never from request-supplied data, and never from the server URL (server identity is a separate key axis). The SDK is a library with no authentication of its own: whoever constructs the `CacheConfig` — the deployment, not the tenant — is the trust anchor. A multi-tenant gateway mints one `CacheConfig` per authenticated principal.
73+
74+
The partition is also fixed for the `Client`'s lifetime. If the connection's authorization context changes mid-session — a re-authentication as a different principal, say — the cache does not follow; construct a new `Client` for the new principal.
75+
76+
Cache keys also carry the **server's identity**: the URL string you dialed, with any `user:pass@` userinfo stripped and otherwise byte-exact. No case folding, no query reordering, no trailing-slash cleanup — under-normalizing only costs sharing, while over-normalizing could merge two tenants (`?tenant=a` vs `?tenant=b`), so superficially different URLs simply don't share entries. When there is no URL — an in-process server, or a `Transport` instance — the client gets a random per-instance identity instead; set `CacheConfig.target_id` to name the server (with a custom store this is required, and construction says so). The identity is sha256-hashed before it enters key material, so a URL carrying secrets in its query string never appears in store keys — don't log the pre-hash form yourself, either.
77+
78+
!!! warning "`share_public` trusts the server, fleet-wide"
79+
By default even `"public"` entries stay within their partition. `share_public=True` serves entries the server marked `cacheScope: "public"` to **every** partition using the store — trusting the server's classification on behalf of all of them. A server that stamps `"public"` on per-tenant data (by bug or by malice) then leaks one tenant's response to the others. The flag is deliberately constructor-level only: the per-call `cache_mode` can narrow caching, but nothing per-call can widen sharing.
80+
81+
### What the cache never does
82+
83+
* **Session-tier calls bypass it.** `client.session.list_tools()` and friends always make the round trip; the cache lives on the `Client` verbs.
84+
* **`server/discover` stays out of it.** The discover result is delivered once, at connect, and never enters the response cache — even when it carries a `ttlMs`. If you persist one yourself to skip the reconnect probe ([`prior_discover`](../client/protocol-versions.md#reconnecting-with-prior_discover)), its freshness is your bookkeeping: `DiscoverResult` carries `ttl_ms` and `cache_scope`, already parsed, for exactly that purpose.
85+
* **Continuation pages are never cached.** Only cursor-less calls participate. A continuation page rejected for an expired cursor does *evict* the cached listing — the listing changed under it.
86+
* **Multi-round-trip reads are never cached.** A `read_resource` seeded with `input_responses`/`request_state`, or one that resolves through input rounds, never enters the cache (a spec MUST).
87+
* **Notification eviction needs notifications.** Eviction is only as good as the transport's delivery — the modern in-process path (`Client(server)` with the default `mode="auto"`) does not deliver standalone notifications today.
88+
* **No stale-if-error.** An expired entry is never served because the refetch failed; the error propagates.
89+
* **No coalescing.** Two concurrent identical calls are two fetches.
90+
* On a **shared persistent store**, a session that negotiated a different protocol era than the entry's writer may be served the writer's entry until TTL or eviction — accepted, and bounded by the cache's 24-hour TTL cap.
91+
92+
### Reading the hints yourself
93+
94+
The hints are also plain fields on every cacheable result — `result.ttl_ms` and `result.cache_scope`, already parsed — if you want to layer your own bookkeeping on top of (or instead of) the built-in cache.
95+
96+
Against an **older server** (pre-2026 protocol), the fields are simply absent from the wire, and the models show their conservative defaults: `ttl_ms == 0`, `cache_scope == "private"` — stale and unshared, the right assumption for a server that declared nothing. The cache treats a legacy session the same way: hints are never consulted there (whatever keys appear on the wire), only `default_ttl_ms` applies, and its default of `0` caches nothing — a pre-2026 connection behaves exactly as it did before the cache existed. If you need to distinguish "the server said 0" from "the server said nothing", check `"ttl_ms" in result.model_fields_set`: it's only set when the field actually arrived.
5397

5498
## Older clients
5599

@@ -61,4 +105,5 @@ Clients on pre-2026 protocol versions never see either field — the SDK strips
61105
* `cache_hints={method: CacheHint(...)}` at construction (both `MCPServer` and `Server`) sets server-wide values per method.
62106
* A handler that sets the fields on its result overrides the map, per field.
63107
* `"public"` is a promise that the result is identical for every caller. It is not access control.
64-
* Clients read the hints as `result.ttl_ms` / `result.cache_scope` and own the caching decision themselves — the SDK has no built-in response cache yet.
108+
* `Client` honors the hints automatically: its response cache is on by default, serves fresh entries instead of refetching, and caches nothing for servers (or sessions) that provide no hints.
109+
* Per call, `cache_mode="refresh"` refetches and `"bypass"` skips the cache; `cache=False` at construction turns it off entirely.

‎docs/migration.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,10 @@ On `ClientSession`, `call_tool` / `get_prompt` / `read_resource` still return th
427427

428428
For protocol 2026-07-28 over Streamable HTTP, a tool's input-schema property may carry an `x-mcp-header` annotation. When a tool the client has listed is called, each annotated argument is mirrored into an `Mcp-Param-<name>` request header (string verbatim, integer as decimal, boolean as `true`/`false`, base64-sentinel-wrapped when not header-safe; `null`/absent arguments are omitted). The argument is also left in the request body. `list_tools` caches a tool's annotations, so list a tool before calling it to enable mirroring; a tool the client never listed emits no `Mcp-Param-*` headers. Other transports ignore the annotation.
429429

430+
### `Client` verbs may serve cached responses ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549))
431+
432+
On protocol 2026-07-28, servers attach caching hints (`ttlMs`, `cacheScope`) to the cacheable results, and `Client` now honors them: `list_tools`, `list_prompts`, `list_resources`, `list_resource_templates`, and `read_resource` may serve a cached response instead of making a round trip, for as long as the server's `ttlMs` says the result is fresh. Servers that send no hints — including every pre-2026 server — see identical call-for-call behavior, because hint-less results are not cached. Pass `Client(..., cache=False)` to disable the cache and restore v1 behavior exactly; per-call control (`cache_mode`) and configuration (`CacheConfig`) are described in [Caching hints](advanced/caching.md).
433+
430434
### Server extensions API ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133))
431435

432436
`MCPServer` now accepts opt-in extensions that bundle MCP behaviour behind a

‎docs_src/caching/tutorial003.py‎

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,34 @@
1+
from typing import Any
2+
3+
from mcp_types import ListToolsResult, PaginatedRequestParams, Tool
4+
15
from mcp import Client
2-
from mcp.server import CacheHint, MCPServer
6+
from mcp.client import CacheConfig
7+
from mcp.server import CacheHint, Server, ServerRequestContext
8+
9+
fetches = 0
10+
now = 1_000_000.0
11+
312

4-
mcp = MCPServer("Weather", cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")})
13+
async def list_tools(ctx: ServerRequestContext[Any], params: PaginatedRequestParams | None) -> ListToolsResult:
14+
global fetches
15+
fetches += 1
16+
return ListToolsResult(tools=[Tool(name="forecast", input_schema={"type": "object"})])
517

618

7-
@mcp.tool()
8-
def forecast(city: str) -> str:
9-
return f"Sunny in {city}"
19+
server = Server(
20+
"Weather",
21+
on_list_tools=list_tools,
22+
cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")},
23+
)
1024

1125

1226
async def main() -> None:
13-
async with Client(mcp) as client:
14-
tools = await client.list_tools()
15-
print(f"{len(tools.tools)} tools, fresh for {tools.ttl_ms / 1000:.0f}s, scope={tools.cache_scope}")
27+
global now
28+
async with Client(server, cache=CacheConfig(clock=lambda: now)) as client:
29+
await client.list_tools() # fetch 1
30+
await client.list_tools() # fresh for 60s: served from the cache
31+
now += 60.0
32+
await client.list_tools() # the TTL ran out: fetch 2
33+
await client.list_tools(cache_mode="refresh") # skip the cache read: fetch 3
34+
print(f"4 calls, {fetches} fetches")

‎src/mcp/client/caching.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,11 @@ class CacheConfig:
137137
authentication of its own: whoever constructs the `CacheConfig` - the
138138
deployment, not the tenant - is the trust anchor. Multi-tenant gateways
139139
mint one `CacheConfig` per authenticated principal.
140+
141+
The partition is fixed for the `Client`'s lifetime: if the connection's
142+
authorization context changes mid-session (a re-authentication as a
143+
different principal), the cache does not follow - construct a new
144+
`Client` for the new principal.
140145
"""
141146

142147
target_id: str | None = None

‎tests/client/test_client_caching.py‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
The coordinator's own behavior is covered in `test_caching.py`.
77
"""
88

9+
import hashlib
910
import json
1011
import time
1112
from collections.abc import AsyncIterator
@@ -148,6 +149,16 @@ def test_userinfo_variants_of_a_server_url_share_one_cache_identity() -> None:
148149
assert _private_arm(bare) == _private_arm(with_password) == _private_arm(with_token)
149150

150151

152+
def test_the_server_url_is_sha256_hashed_before_it_enters_key_material() -> None:
153+
"""The arm carries sha256(url-sans-userinfo), not the URL itself, so a secret
154+
in the query string never appears in store keys. SDK-defined; pins the docs'
155+
secrets-never-in-keys claim — raw-URL key material would fail here."""
156+
client = Client("https://user:pass@example.com/mcp?api_key=SECRET")
157+
158+
arm_id = hashlib.sha256(b"https://example.com/mcp?api_key=SECRET").hexdigest()
159+
assert _private_arm(client) == json.dumps(["private", arm_id, ""])
160+
161+
151162
def test_urls_differing_only_in_query_have_distinct_cache_identities() -> None:
152163
"""URL identity is byte-exact outside userinfo — `?tenant=a` and `?tenant=b`
153164
must never share entries (over-normalization would merge tenants). SDK-defined."""
@@ -966,6 +977,56 @@ async def on_message(message: IncomingMessage) -> None:
966977
assert reads == [uri, uri]
967978

968979

980+
async def test_the_modern_in_process_path_drops_the_eviction_notification() -> None:
981+
"""Pins the documented transport gap: the default in-process connection
982+
(mode="auto", DirectDispatcher) does not deliver standalone server notifications,
983+
so a tools/list_changed emitted mid-call never reaches the cache - the warm entry
984+
survives and the next `list_tools` is still served from it. Delivery on this path
985+
would happen inline within the awaited `call_tool`, so asserting after it returns
986+
is race-free. If this test starts failing, the path gained delivery: flip the
987+
`docs/advanced/caching.md` eviction caveat and the legacy-mode notification tests."""
988+
fetches: list[str | None] = []
989+
990+
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
991+
fetches.append(params.cursor if params is not None else None)
992+
return ListToolsResult(tools=[Tool(name="touch", input_schema={"type": "object"})])
993+
994+
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
995+
assert params.name == "touch"
996+
await ctx.session.send_tool_list_changed()
997+
return CallToolResult(content=[TextContent(text="ok")])
998+
999+
server = Server(
1000+
"notify",
1001+
on_list_tools=list_tools,
1002+
on_call_tool=call_tool,
1003+
cache_hints={"tools/list": CacheHint(ttl_ms=60_000)},
1004+
)
1005+
1006+
async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client:
1007+
await client.list_tools()
1008+
await client.call_tool("touch", {})
1009+
await client.list_tools() # still served from the warm entry: no eviction arrived
1010+
1011+
assert fetches == [None]
1012+
1013+
1014+
async def test_a_discover_result_never_enters_the_response_cache() -> None:
1015+
"""SDK ruling (documented): the response cache covers the five `Client` verbs
1016+
only. The connect-time server/discover result is never stored, even when it
1017+
carries a `ttlMs` hint - a persisted `prior_discover`'s freshness is the user's
1018+
bookkeeping (`DiscoverResult` carries the parsed hints for it)."""
1019+
server = Server("hinted", cache_hints={"server/discover": CacheHint(ttl_ms=60_000)})
1020+
1021+
async with Client(server, cache=CacheConfig(clock=_ManualClock())) as client:
1022+
discover = client.session.discover_result
1023+
assert discover is not None
1024+
assert discover.ttl_ms == 60_000 # the hint arrived with the probe result...
1025+
store = _coordinator(client)._store
1026+
assert isinstance(store, InMemoryResponseCacheStore)
1027+
assert store._entries == {} # ...and nothing entered the cache
1028+
1029+
9691030
# --- The inbound ttlMs clamp (parse seam) ---
9701031

9711032

0 commit comments

Comments
 (0)