Make CacheConfig() the Client cache default and None the off switch (#3164)

This commit is contained in:
Max 2026-07-25 17:50:53 +01:00 • committed by GitHub
commit 0cb920f126
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 18 additions and 17 deletions

View file

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

View file

@ -349,14 +349,15 @@ class Client:
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)
@ -388,8 +389,8 @@ class Client:
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):

View file

@ -212,11 +212,11 @@ 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:
@ -224,7 +224,7 @@ async def test_cache_false_disables_the_cache_and_the_handler_wrap() -> None:
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)
@ -637,7 +637,7 @@ async def test_a_read_resource_carrying_meta_is_fetched_and_replaces_the_warm_en
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")

View file

@ -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]

View file

@ -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]