Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1797d9a
Move CacheableMethod to mcp_types.methods and derive CACHEABLE_METHOD…
maxisbey Jun 29, 2026
d2d4a25
Add client response cache primitives: config, keys, store protocol, i…
maxisbey Jun 29, 2026
5f8a392
Add client response cache coordinator: scope arms, era gating, TTL re…
maxisbey Jun 29, 2026
ba95005
Wire the response cache into Client: configuration, server identity, …
maxisbey Jun 29, 2026
0a19550
Serve cacheable client verbs through the response cache
maxisbey Jun 29, 2026
8fae9cf
Treat negative inbound ttlMs as zero at the client parse seams
maxisbey Jun 29, 2026
98941ac
Document the client response cache
maxisbey Jun 29, 2026
c689c20
Add end-to-end hardening tests for the client response cache
maxisbey Jun 29, 2026
e3bb712
Cover float negative ttlMs on the discover seam in the auto-mode test
maxisbey Jun 29, 2026
b81b7dd
Document eviction timing, refetch policy, shared-store races, and the…
maxisbey Jun 29, 2026
efac31f
Tighten response cache: session guard, identity and meta handling, re…
maxisbey Jun 29, 2026
d1cebd1
Apply configured cache hints to mapping handler results and fix unkno…
maxisbey Jun 29, 2026
21a779a
Trim comments and docstrings
maxisbey Jun 29, 2026
804043b
Address review feedback: drop tutorial globals, plain-ASCII docs pros…
maxisbey Jun 29, 2026
fb1c510
Keep the tutorial handler a plain function with a separate state holder
maxisbey Jun 29, 2026
ef7fdff
Era-scope cache arms and harden store interaction paths
maxisbey Jun 29, 2026
af36ead
Strip userinfo textually and address review notes on the docs
maxisbey Jun 29, 2026
b44a891
Address review feedback on identity stripping and stale tool-map pruning
maxisbey Jun 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 61 additions & 8 deletions docs/advanced/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,19 +37,71 @@ One caveat on paginated lists: the protocol requires the **same `cacheScope` on

## What the client sees

On the client, the hints arrive as plain fields on every cacheable result — `ttl_ms` and `cache_scope`, already parsed:
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.
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated

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

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:
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`:

* **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.
* **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.
* **Notifications beat TTL.** If the server sends `list_changed` while your copy is still fresh, the copy is stale now — re-fetch.
* `"use"` (the default) serves a fresh entry if there is one, and stores the fetch if not.
* `"refresh"` never serves: it fetches and stores the result, replacing whatever was cached.
* `"bypass"` makes the round trip without touching the cache at all — no read, no write.

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.
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.

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.

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.

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"`.

### Configuring it: `CacheConfig`

```python
from mcp.client import CacheConfig

client = Client("https://api.example.com/mcp", cache=CacheConfig(default_ttl_ms=5_000))
```

* `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`.
* `partition` — the authorization-context label that keeps one principal's `"private"` entries from being served to another within a shared store.
* `target_id` — explicit server identity, for custom transports and in-process servers (below).
* `default_ttl_ms` — TTL applied to results that carry no `ttlMs` hint. The default `0` leaves hint-less results uncached.
* `share_public` — serve server-asserted-`"public"` entries across partitions (below). Off by default.
* `clock` — the wall-clock source, epoch seconds. Inject one, as the example above does, and expiry tests need no sleeping.

!!! warning "Partition = verified principal"
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.

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.

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.

!!! warning "`share_public` trusts the server, fleet-wide"
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.

### What the cache never does

* **Session-tier calls bypass it.** `client.session.list_tools()` and friends always make the round trip; the cache lives on the `Client` verbs.
* **`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.
* **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.
* **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).
* **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.
* **Eviction is eventual, not instantaneous.** Wire-path notifications are dispatched from spawned tasks, so a call racing a notification's arrival may be served the pre-eviction entry once more; the window is bounded by dispatch latency, and the eviction still lands.
* **No stale-if-error.** An expired entry is never served because the refetch failed; the error propagates.
* **No early re-fetch.** A stored entry is served until its TTL expires and the next call after that pays the round trip — nothing refreshes in the background.
* **No coalescing.** Two concurrent identical calls are two fetches.
* **No TTL beyond 24 hours.** A larger `ttlMs` — server-sent or configured — is clamped down on store (`mcp.client.caching.MAX_TTL_MS`), bounding how long any entry, however generously hinted, can be served.
* On a **shared store**, clients race each other. Each client drops its own write when an eviction overtook the fetch in flight, but a *co-tenant* client can still write back an entry that an eviction it never saw had removed; and that race bookkeeping is itself bounded — past 4096 tracked keys the oldest key's guard is dropped first. Both windows are accepted, and closed by the TTL cap above.
* 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 likewise bounded by the TTL cap.

### Reading the hints yourself

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.

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.

## Older clients

Expand All @@ -61,4 +113,5 @@ Clients on pre-2026 protocol versions never see either field — the SDK strips
* `cache_hints={method: CacheHint(...)}` at construction (both `MCPServer` and `Server`) sets server-wide values per method.
* A handler that sets the fields on its result overrides the map, per field.
* `"public"` is a promise that the result is identical for every caller. It is not access control.
* 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.
* `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.
* Per call, `cache_mode="refresh"` refetches and `"bypass"` skips the cache; `cache=False` at construction turns it off entirely.
4 changes: 4 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,10 @@ On `ClientSession`, `call_tool` / `get_prompt` / `read_resource` still return th

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.

### `Client` verbs may serve cached responses ([SEP-2549](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549))

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).
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated

### Server extensions API ([SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133))

`MCPServer` now accepts opt-in extensions that bundle MCP behaviour behind a
Expand Down
35 changes: 27 additions & 8 deletions docs_src/caching/tutorial003.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,34 @@
from typing import Any

from mcp_types import ListToolsResult, PaginatedRequestParams, Tool

from mcp import Client
from mcp.server import CacheHint, MCPServer
from mcp.client import CacheConfig
from mcp.server import CacheHint, Server, ServerRequestContext

fetches = 0
now = 1_000_000.0


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


@mcp.tool()
def forecast(city: str) -> str:
return f"Sunny in {city}"
server = Server(
"Weather",
on_list_tools=list_tools,
cache_hints={"tools/list": CacheHint(ttl_ms=60_000, scope="public")},
)


async def main() -> None:
async with Client(mcp) as client:
tools = await client.list_tools()
print(f"{len(tools.tools)} tools, fresh for {tools.ttl_ms / 1000:.0f}s, scope={tools.cache_scope}")
global now

@cubic-dev-ai cubic-dev-ai Bot Jun 29, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: tutorial003.main() mutates module-global cache demo state without resetting it, so repeated runs produce incorrect/non-deterministic output.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs_src/caching/tutorial003.py, line 27:

<comment>`tutorial003.main()` mutates module-global cache demo state without resetting it, so repeated runs produce incorrect/non-deterministic output.</comment>

<file context>
@@ -1,15 +1,34 @@
-    async with Client(mcp) as client:
-        tools = await client.list_tools()
-        print(f"{len(tools.tools)} tools, fresh for {tools.ttl_ms / 1000:.0f}s, scope={tools.cache_scope}")
+    global now
+    async with Client(server, cache=CacheConfig(clock=lambda: now)) as client:
+        await client.list_tools()  # fetch 1
</file context>
Suggested change
global now
global now, fetches
now = 1_000_000.0
fetches = 0
Fix with cubic

async with Client(server, cache=CacheConfig(clock=lambda: now)) as client:
await client.list_tools() # fetch 1
await client.list_tools() # fresh for 60s: served from the cache
now += 60.0
await client.list_tools() # the TTL ran out: fetch 2
await client.list_tools(cache_mode="refresh") # skip the cache read: fetch 3
print(f"4 calls, {fetches} fetches")
27 changes: 26 additions & 1 deletion src/mcp-types/mcp_types/methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from collections.abc import Mapping
from functools import cache
from types import MappingProxyType, UnionType
from typing import Any, Final, TypeVar
from typing import Any, Final, Literal, TypeVar, get_args

from pydantic import BaseModel, TypeAdapter

Expand All @@ -23,9 +23,11 @@
from mcp_types.version import KNOWN_PROTOCOL_VERSIONS

__all__ = [
"CACHEABLE_METHODS",
"CLIENT_NOTIFICATIONS",
"CLIENT_REQUESTS",
"CLIENT_RESULTS",
"CacheableMethod",
"MONOLITH_NOTIFICATIONS",
"MONOLITH_REQUESTS",
"MONOLITH_RESULTS",
Expand Down Expand Up @@ -404,6 +406,29 @@
"""Monolith result model (or two-arm union) per request method."""


# --- Cacheable methods ---

CacheableMethod = Literal[
"prompts/list",
"resources/list",
"resources/read",
"resources/templates/list",
"server/discover",
"tools/list",
]
"""The methods whose results carry `ttlMs`/`cacheScope`. Closed set: the spec
defines caching hints on exactly these six. Hand-written because a Literal
cannot be computed at runtime; tests weld it to `CACHEABLE_METHODS`."""

CACHEABLE_METHODS: Final[frozenset[str]] = frozenset(
method
for method, row in MONOLITH_RESULTS.items()
if any(issubclass(arm, types.CacheableResult) for arm in (get_args(row) if isinstance(row, UnionType) else (row,)))
)
"""Runtime mirror of `CacheableMethod`, derived from `MONOLITH_RESULTS`: a
method is cacheable iff its result row has a `CacheableResult` arm."""


# --- Parse functions ---

# Envelope stubs merged into bodies for surface validation (surface classes are full frames).
Expand Down
22 changes: 21 additions & 1 deletion src/mcp/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,28 @@

from mcp.client._input_required import InputRequiredRoundsExceededError
from mcp.client._transport import Transport
from mcp.client.caching import (
CacheConfig,
CacheEntry,
CacheKey,
CacheMode,
InMemoryResponseCacheStore,
ResponseCacheStore,
)
from mcp.client.client import Client
from mcp.client.context import ClientRequestContext
from mcp.client.session import ClientSession

__all__ = ["Client", "ClientRequestContext", "ClientSession", "InputRequiredRoundsExceededError", "Transport"]
__all__ = [
"CacheConfig",
"CacheEntry",
"CacheKey",
"CacheMode",
"Client",
"ClientRequestContext",
"ClientSession",
"InMemoryResponseCacheStore",
"InputRequiredRoundsExceededError",
"ResponseCacheStore",
"Transport",
]
Loading
Loading