Make CacheConfig() the Client cache default and None the off switch (#3164)
This commit is contained in:
parent
629ca297d2
commit
0cb920f126
5 changed files with 18 additions and 17 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue