Skip to content

Commit 63eaad5

Browse files
committed
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.
1 parent 629ca29 commit 63eaad5

5 files changed

Lines changed: 18 additions & 17 deletions

File tree

docs/client/caching.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ Four calls, three fetches. The second call found a fresh entry and never reached
5151

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

54-
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.
54+
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.
5555

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

@@ -114,4 +114,4 @@ Clients on pre-2026 protocol versions never see either field; the SDK strips the
114114
* A handler that sets the fields on its result overrides the map, per field.
115115
* `"public"` is a promise that the result is identical for every caller. It is not access control.
116116
* `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.
117-
* Per call, `cache_mode="refresh"` refetches and `"bypass"` skips the cache; `cache=False` at construction turns it off entirely.
117+
* Per call, `cache_mode="refresh"` refetches and `"bypass"` skips the cache; `cache=None` at construction turns it off entirely.

src/mcp/client/client.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -349,14 +349,15 @@ async def main():
349349
transparently by `call_tool`), and its notification bindings. For an
350350
ad-only entry use `mcp.client.advertise(identifier, settings)`."""
351351

352-
cache: CacheConfig | Literal[False] | None = None
352+
cache: CacheConfig | None = field(default_factory=CacheConfig)
353353
"""Client-side response caching for the SEP-2549 cacheable methods (2026-07-28).
354354
355-
`None` (the default) honors server `ttlMs`/`cacheScope` hints with a per-client
356-
in-memory store; pass a `CacheConfig` to customize, or `False` to disable. The
357-
cacheable verbs take a per-call `cache_mode` (see `CacheMode`); calls carrying
358-
`meta` always reach the server. A `CacheConfig` with a custom `store` requires
359-
`target_id` when the server is not a URL (no identity can be derived)."""
355+
The default `CacheConfig()` honors server `ttlMs`/`cacheScope` hints with a
356+
per-client in-memory store; pass a customized `CacheConfig`, or `None` to
357+
disable. The cacheable verbs take a per-call `cache_mode` (see `CacheMode`);
358+
calls carrying `meta` always reach the server. A `CacheConfig` with a custom
359+
`store` requires `target_id` when the server is not a URL (no identity can be
360+
derived)."""
360361

361362
_entered: bool = field(init=False, default=False)
362363
_session: ClientSession | None = field(init=False, default=None)
@@ -388,8 +389,8 @@ def __post_init__(self) -> None:
388389
else:
389390
self._connect = _connect_transport(srv)
390391

391-
if self.cache is not False:
392-
config = self.cache if self.cache is not None else CacheConfig()
392+
if self.cache is not None:
393+
config = self.cache
393394
# Only the hash below leaves this scope - the raw identity may carry credentials; never log or store it.
394395
target_id = config.target_id
395396
if target_id is None and isinstance(self.server, str):

tests/client/test_client_caching.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -212,19 +212,19 @@ def test_a_custom_store_with_an_explicit_target_id_constructs_for_any_server() -
212212
assert _coordinator(client)._store is store
213213

214214

215-
async def test_cache_false_disables_the_cache_and_the_handler_wrap() -> None:
215+
async def test_cache_none_disables_the_cache_and_the_handler_wrap() -> None:
216216
async def handler(message: IncomingMessage) -> None:
217217
raise NotImplementedError
218218

219-
client = Client(_list_changed_server(), cache=False, message_handler=handler)
219+
client = Client(_list_changed_server(), cache=None, message_handler=handler)
220220
assert client._response_cache is None
221221

222222
async with client:
223223
assert client.session._message_handler is handler
224224

225225

226226
def test_the_default_cache_uses_a_per_client_in_memory_store() -> None:
227-
"""`cache=None` (the default) is cache-on."""
227+
"""The default `CacheConfig()` is cache-on."""
228228
server = Server("plain")
229229
first = Client(server)
230230
second = Client(server)
@@ -637,7 +637,7 @@ def text(result: ReadResourceResult) -> str:
637637
async def test_cache_mode_is_inert_when_caching_is_disabled() -> None:
638638
server, fetches = _varying_tools_server()
639639

640-
async with Client(server, cache=False) as client:
640+
async with Client(server, cache=None) as client:
641641
await client.list_tools()
642642
await client.list_tools(cache_mode="use")
643643
await client.list_tools(cache_mode="refresh")

tests/client/test_subscriptions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -595,7 +595,7 @@ async def test_client_listen_installs_the_cache_eviction_barrier_exactly_when_a_
595595
with anyio.fail_after(5):
596596
async with cached_client.listen(tools_list_changed=True) as sub: # pragma: no branch
597597
assert sub._on_event == cached_client._evict_for_listen_event # pyright: ignore[reportPrivateUsage]
598-
async with Client(_bus_server(bus), cache=False) as uncached_client:
598+
async with Client(_bus_server(bus), cache=None) as uncached_client:
599599
with anyio.fail_after(5):
600600
async with uncached_client.listen(tools_list_changed=True) as sub: # pragma: no branch
601601
assert sub._on_event is None # pyright: ignore[reportPrivateUsage]

tests/docs_src/test_caching.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,9 @@ async def test_a_hintless_result_is_not_cached_by_default() -> None:
102102
assert fetches == [None, None]
103103

104104

105-
async def test_cache_false_makes_every_call_a_round_trip() -> None:
105+
async def test_cache_none_makes_every_call_a_round_trip() -> None:
106106
server, fetches = _counting_tools_server()
107-
async with Client(server, cache=False) as client:
107+
async with Client(server, cache=None) as client:
108108
await client.list_tools()
109109
await client.list_tools()
110110
assert fetches == [None, None]

0 commit comments

Comments
 (0)