From 63eaad501eced165aa7b37c12b32e42a9b94d449 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:36:02 +0000 Subject: [PATCH] Make CacheConfig() the Client cache default and None the off switch The cache field was a three-state CacheConfig | Literal[False] | None where None meant "use the default config" and False meant "disabled". Since CacheConfig is a frozen dataclass and the store is instantiated per client, a plain CacheConfig() default is inert to share, so the sentinel bought nothing. Default to CacheConfig() and let None disable caching. --- docs/client/caching.md | 4 ++-- src/mcp/client/client.py | 17 +++++++++-------- tests/client/test_client_caching.py | 8 ++++---- tests/client/test_subscriptions.py | 2 +- tests/docs_src/test_caching.py | 4 ++-- 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/docs/client/caching.md b/docs/client/caching.md index dc4ae97acc..78de2a59f1 100644 --- a/docs/client/caching.md +++ b/docs/client/caching.md @@ -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. diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index e840675011..9e26d40859 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -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) @@ -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): diff --git a/tests/client/test_client_caching.py b/tests/client/test_client_caching.py index 4ebf6483ed..aa6ed22e20 100644 --- a/tests/client/test_client_caching.py +++ b/tests/client/test_client_caching.py @@ -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 handler(message: IncomingMessage) -> 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 @@ 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") diff --git a/tests/client/test_subscriptions.py b/tests/client/test_subscriptions.py index 698e1eb013..c9877c4ab0 100644 --- a/tests/client/test_subscriptions.py +++ b/tests/client/test_subscriptions.py @@ -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] diff --git a/tests/docs_src/test_caching.py b/tests/docs_src/test_caching.py index 2fafde0a1c..751ca86121 100644 --- a/tests/docs_src/test_caching.py +++ b/tests/docs_src/test_caching.py @@ -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]