Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions docs/client/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Four calls, three fetches. The second call found a fresh entry and never reached

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.
To turn caching off entirely, construct with `Client(server, cache=None)`: 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), while `"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. On a 2026-07-28 connection those notifications arrive on a `subscriptions/listen` stream you open with `client.listen(...)`, and eviction completes before your watcher sees the event; **[Subscriptions](subscriptions.md)** is that page.

Expand Down Expand Up @@ -114,4 +114,4 @@ Clients on pre-2026 protocol versions never see either field; the SDK strips the
* 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.
* `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.
* Per call, `cache_mode="refresh"` refetches and `"bypass"` skips the cache; `cache=None` at construction turns it off entirely.
17 changes: 9 additions & 8 deletions src/mcp/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,14 +349,15 @@ async def main():
transparently by `call_tool`), and its notification bindings. For an
ad-only entry use `mcp.client.advertise(identifier, settings)`."""

cache: CacheConfig | Literal[False] | None = None
cache: CacheConfig | None = field(default_factory=CacheConfig)
"""Client-side response caching for the SEP-2549 cacheable methods (2026-07-28).

`None` (the default) honors server `ttlMs`/`cacheScope` hints with a per-client
in-memory store; pass a `CacheConfig` to customize, or `False` to disable. The
cacheable verbs take a per-call `cache_mode` (see `CacheMode`); calls carrying
`meta` always reach the server. A `CacheConfig` with a custom `store` requires
`target_id` when the server is not a URL (no identity can be derived)."""
The default `CacheConfig()` honors server `ttlMs`/`cacheScope` hints with a
per-client in-memory store; pass a customized `CacheConfig`, or `None` to
disable. The cacheable verbs take a per-call `cache_mode` (see `CacheMode`);
calls carrying `meta` always reach the server. A `CacheConfig` with a custom
`store` requires `target_id` when the server is not a URL (no identity can be
derived)."""

_entered: bool = field(init=False, default=False)
_session: ClientSession | None = field(init=False, default=None)
Expand Down Expand Up @@ -388,8 +389,8 @@ def __post_init__(self) -> None:
else:
self._connect = _connect_transport(srv)

if self.cache is not False:
config = self.cache if self.cache is not None else CacheConfig()
if self.cache is not None:
config = self.cache
# Only the hash below leaves this scope - the raw identity may carry credentials; never log or store it.
target_id = config.target_id
if target_id is None and isinstance(self.server, str):
Expand Down
8 changes: 4 additions & 4 deletions tests/client/test_client_caching.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,19 +212,19 @@ def test_a_custom_store_with_an_explicit_target_id_constructs_for_any_server() -
assert _coordinator(client)._store is store


async def test_cache_false_disables_the_cache_and_the_handler_wrap() -> None:
async def test_cache_none_disables_the_cache_and_the_handler_wrap() -> None:
async def handler(message: IncomingMessage) -> None:
raise NotImplementedError

client = Client(_list_changed_server(), cache=False, message_handler=handler)
client = Client(_list_changed_server(), cache=None, message_handler=handler)
assert client._response_cache is None

async with client:
assert client.session._message_handler is handler


def test_the_default_cache_uses_a_per_client_in_memory_store() -> None:
"""`cache=None` (the default) is cache-on."""
"""The default `CacheConfig()` is cache-on."""
server = Server("plain")
first = Client(server)
second = Client(server)
Expand Down Expand Up @@ -637,7 +637,7 @@ def text(result: ReadResourceResult) -> str:
async def test_cache_mode_is_inert_when_caching_is_disabled() -> None:
server, fetches = _varying_tools_server()

async with Client(server, cache=False) as client:
async with Client(server, cache=None) as client:
await client.list_tools()
await client.list_tools(cache_mode="use")
await client.list_tools(cache_mode="refresh")
Expand Down
2 changes: 1 addition & 1 deletion tests/client/test_subscriptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,7 +595,7 @@ async def test_client_listen_installs_the_cache_eviction_barrier_exactly_when_a_
with anyio.fail_after(5):
async with cached_client.listen(tools_list_changed=True) as sub: # pragma: no branch
assert sub._on_event == cached_client._evict_for_listen_event # pyright: ignore[reportPrivateUsage]
async with Client(_bus_server(bus), cache=False) as uncached_client:
async with Client(_bus_server(bus), cache=None) as uncached_client:
with anyio.fail_after(5):
async with uncached_client.listen(tools_list_changed=True) as sub: # pragma: no branch
assert sub._on_event is None # pyright: ignore[reportPrivateUsage]
Expand Down
4 changes: 2 additions & 2 deletions tests/docs_src/test_caching.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,9 @@ async def test_a_hintless_result_is_not_cached_by_default() -> None:
assert fetches == [None, None]


async def test_cache_false_makes_every_call_a_round_trip() -> None:
async def test_cache_none_makes_every_call_a_round_trip() -> None:
server, fetches = _counting_tools_server()
async with Client(server, cache=False) as client:
async with Client(server, cache=None) as client:
await client.list_tools()
await client.list_tools()
assert fetches == [None, None]
Expand Down
Loading