@@ -130,9 +130,7 @@ def _connected(value: _T | None) -> _T:
130130def _strip_userinfo (url : str ) -> str :
131131 """Drop any userinfo from the URL's authority component; byte-exact otherwise.
132132
133- Cache identity must never over-normalize (case-folding or query rewriting could
134- merge distinct servers, e.g. `?tenant=a` vs `?tenant=b`), and credentials must
135- never enter cache-key material — userinfo removal is the single permitted rewrite.
133+ Credentials must not enter cache-key material; any further normalization could merge distinct servers.
136134 """
137135 parts = urlsplit (url )
138136 if "@" not in parts .netloc :
@@ -141,13 +139,7 @@ def _strip_userinfo(url: str) -> str:
141139
142140
143141def _evicting_message_handler (cache : ClientResponseCache , user_handler : MessageHandlerFnT | None ) -> MessageHandlerFnT :
144- """Wrap the session message handler with cache eviction on server notifications.
145-
146- Eviction runs before delegation, inside its own boundary, so a cache fault can
147- never suppress delivery. Every item — notification, `RequestResponder`, or
148- transport `Exception` — then reaches the user's handler; with none supplied, the
149- wrapper performs the same bare checkpoint `ClientSession` installs by default.
150- """
142+ """Wrap the session message handler with cache eviction on server notifications."""
151143
152144 async def handler (
153145 message : RequestResponder [types .ServerRequest , types .ClientResult ] | types .ServerNotification | Exception ,
@@ -276,15 +268,10 @@ async def main():
276268 """Client-side response caching for the SEP-2549 cacheable methods (2026-07-28).
277269
278270 `None` (the default) honors server `ttlMs`/`cacheScope` hints with a per-client
279- in-memory store; results carrying no hints are not cached. Pass a `CacheConfig`
280- to customize (shared store, partition, default TTL), or `False` to disable
281- caching entirely. The cacheable verbs (`list_tools`, `list_prompts`,
282- `list_resources`, `list_resource_templates`, `read_resource`) take a per-call
283- `cache_mode` to narrow caching for one call; with `cache=False` it is inert.
284-
285- Construction raises `ValueError` for a `CacheConfig` with a custom `store` when
286- no server identity can be derived (an in-process server or a `Transport`
287- instance) — set `CacheConfig.target_id` to name the server."""
271+ in-memory store; pass a `CacheConfig` to customize, or `False` to disable. The
272+ cacheable verbs take a per-call `cache_mode` (see `CacheMode`); calls carrying
273+ `meta` always reach the server. A `CacheConfig` with a custom `store` requires
274+ `target_id` when the server is not a URL (no identity can be derived)."""
288275
289276 _entered : bool = field (init = False , default = False )
290277 _session : ClientSession | None = field (init = False , default = None )
@@ -315,10 +302,7 @@ def __post_init__(self) -> None:
315302
316303 if self .cache is not False :
317304 config = self .cache if self .cache is not None else CacheConfig ()
318- # Server identity, in resolution order: explicit override, server URL
319- # (userinfo stripped, byte-exact otherwise), per-Client random. Only the
320- # hash below leaves this scope — the raw identity may carry credentials
321- # in its query string and must never be logged or stored.
305+ # Only the hash below leaves this scope - the raw identity may carry credentials; never log or store it.
322306 target_id = config .target_id
323307 if target_id is None and isinstance (self .server , str ):
324308 target_id = _strip_userinfo (self .server )
@@ -337,8 +321,7 @@ def __post_init__(self) -> None:
337321 default_ttl_ms = config .default_ttl_ms ,
338322 clock = config .clock ,
339323 share_public = config .share_public ,
340- # Lazy: the era is unknown until __aenter__'s handshake, and the
341- # session is unpublished outside the context manager.
324+ # Lazy: the negotiated version is unknown until __aenter__'s handshake.
342325 negotiated_version = lambda : self ._session .protocol_version if self ._session is not None else None ,
343326 )
344327
@@ -471,35 +454,26 @@ async def _cached_fetch(
471454 ) -> _CacheableT :
472455 """Serve one of the four list verbs through the response cache.
473456
474- `send` performs the fetch via the session; `absorb` (tools/list only)
475- re-applies session-side derived state to a served cache hit.
457+ `absorb` (tools/list only) re-applies session-side derived state to a served cache hit.
476458 """
477459 cache = self ._response_cache
478460 if cache is None or cache_mode == "bypass" :
479- return await send () # no read, no write, no eviction side-effects
480- # Cache participation requires a live session: a closed (or never-entered)
481- # client raises the no-context RuntimeError on every verb, exactly as the
482- # verbs did before the cache existed - never serving stale entries.
461+ return await send ()
462+ # A closed (or never-entered) client must raise, never serve cached entries.
483463 _ = self .session
484464 if meta is not None and cache_mode == "use" :
485- # A call carrying meta (a progress token, tracing fields) expects a
486- # wire request, so it is never served from the cache; the fetched
487- # result still replaces the entry, the same as an explicit refresh.
465+ # meta (a progress token, tracing fields) expects a wire request; fetch and replace the entry.
488466 cache_mode = "refresh"
489467 if cursor is not None :
490- # Continuation pages never read or write the (cursor-less) entry, but an
491- # expired-cursor rejection signals the listing changed since the entry was
492- # fetched, so it is evicted (spec SHOULD; over-eviction is harmless).
468+ # Continuation pages skip the cache, but an expired cursor means the listing changed (spec SHOULD evict).
493469 try :
494470 return await send ()
495471 except MCPError as e :
496472 if e .code == INVALID_PARAMS :
497473 await cache .evict_method (method )
498474 raise
499475 if cache_mode == "use" and (hit := await cache .read (method , "" )) is not None :
500- # The store key carries the method, so the entry under it has `send`'s
501- # result type. The hit is already a private deep copy of the stored
502- # value, so absorption may mutate it freely.
476+ # The hit is a private deep copy, so absorption may mutate it freely.
503477 served = cast (_CacheableT , hit )
504478 return served if absorb is None else absorb (served )
505479 gen = cache .capture (method , "" )
@@ -514,11 +488,7 @@ async def list_resources(
514488 meta : RequestParamsMeta | None = None ,
515489 cache_mode : CacheMode = "use" ,
516490 ) -> ListResourcesResult :
517- """List available resources from the server.
518-
519- `cache_mode` adjusts the response cache's behavior for this call (see `CacheMode`);
520- calls carrying `meta` always reach the server.
521- """
491+ """List available resources from the server."""
522492 return await self ._cached_fetch (
523493 "resources/list" ,
524494 cursor = cursor ,
@@ -534,11 +504,7 @@ async def list_resource_templates(
534504 meta : RequestParamsMeta | None = None ,
535505 cache_mode : CacheMode = "use" ,
536506 ) -> ListResourceTemplatesResult :
537- """List available resource templates from the server.
538-
539- `cache_mode` adjusts the response cache's behavior for this call (see `CacheMode`);
540- calls carrying `meta` always reach the server.
541- """
507+ """List available resource templates from the server."""
542508 return await self ._cached_fetch (
543509 "resources/templates/list" ,
544510 cursor = cursor ,
@@ -568,13 +534,9 @@ async def read_resource(
568534 input_responses: Responses to seed the first call with (e.g. when
569535 resuming from a persisted `InputRequiredResult`).
570536 request_state: Opaque state to seed the first call with.
571- meta: Additional metadata for the request. Calls carrying `meta`
572- always reach the server.
573- cache_mode: Adjusts the response cache's behavior for this call
574- (see `CacheMode`). Seeded calls (either `input_responses` or
575- `request_state` set) are resumptions of a multi-round-trip
576- read and ignore it entirely: no cache read, no write, no
577- refresh purge.
537+ meta: Additional metadata for the request.
538+ cache_mode: Cache behavior for this call (see `CacheMode`); seeded
539+ calls (`input_responses` or `request_state` set) ignore it.
578540
579541 Returns:
580542 The resource content.
@@ -589,36 +551,28 @@ async def retry(r: InputResponses | None, s: str | None) -> ReadResourceResult |
589551 uri , input_responses = r , request_state = s , meta = meta , allow_input_required = True
590552 )
591553
592- # Results of requests carrying inputResponses or requestState must never be
593- # cached (spec MUST), and a seeded call exists to resume a specific exchange -
594- # serving it from the cache would skip the resumption.
554+ # Seeded calls resume a specific exchange and must never be cached (spec MUST).
595555 seeded = input_responses is not None or request_state is not None
596556 cache = None if seeded else self ._response_cache
597557 if cache is None or cache_mode == "bypass" :
598558 return await self ._drive_input_required (await retry (input_responses , request_state ), retry )
599- # Cache participation requires a live session: a closed (or never-entered)
600- # client raises the no-context RuntimeError here, never serving stale entries.
559+ # A closed (or never-entered) client must raise, never serve cached entries.
601560 _ = self .session
602561 if meta is not None and cache_mode == "use" :
603562 # Calls carrying meta always reach the server (mirrors `_cached_fetch`).
604563 cache_mode = "refresh"
605564 if cache_mode == "use" and (hit := await cache .read ("resources/read" , uri )) is not None :
606- # InputRequiredResult is never stored (only terminal first-round results
607- # are written below), so a hit is always terminal and legitimately skips
608- # the driver.
565+ # Only terminal first-round results are stored, so a hit legitimately skips the driver.
609566 return cast (ReadResourceResult , hit )
610567 gen = cache .capture ("resources/read" , uri )
611568 first = await retry (None , None )
612569 if not isinstance (first , InputRequiredResult ):
613570 await cache .write ("resources/read" , uri , first , gen , cache_mode )
614571 elif cache_mode == "refresh" :
615- # An input_required resolution can never be stored, but the explicit
616- # refresh still superseded whatever was cached: purge the warm entry
617- # so it cannot be served again (the same supersession rule as a
618- # refreshed ttl<=0 result in `ClientResponseCache.write`).
572+ # The refresh superseded whatever was cached, but an input_required resolution
573+ # cannot be stored: purge the warm entry so it cannot be served again.
619574 await cache .evict_key ("resources/read" , uri )
620- # A terminal result reached through driver rounds is never cached: the rounds
621- # carried inputResponses (the same spec MUST as the seeded skip above).
575+ # Driver rounds carry inputResponses, so a terminal result reached through them is never cached (spec MUST).
622576 return await self ._drive_input_required (first , retry )
623577
624578 async def subscribe_resource (self , uri : str , * , meta : RequestParamsMeta | None = None ) -> EmptyResult :
@@ -688,11 +642,7 @@ async def list_prompts(
688642 meta : RequestParamsMeta | None = None ,
689643 cache_mode : CacheMode = "use" ,
690644 ) -> ListPromptsResult :
691- """List available prompts from the server.
692-
693- `cache_mode` adjusts the response cache's behavior for this call (see `CacheMode`);
694- calls carrying `meta` always reach the server.
695- """
645+ """List available prompts from the server."""
696646 return await self ._cached_fetch (
697647 "prompts/list" ,
698648 cursor = cursor ,
@@ -788,20 +738,15 @@ async def list_tools(
788738 meta : RequestParamsMeta | None = None ,
789739 cache_mode : CacheMode = "use" ,
790740 ) -> ListToolsResult :
791- """List available tools from the server.
792-
793- `cache_mode` adjusts the response cache's behavior for this call (see `CacheMode`);
794- calls carrying `meta` always reach the server.
795- """
741+ """List available tools from the server."""
796742 return await self ._cached_fetch (
797743 "tools/list" ,
798744 cursor = cursor ,
799745 meta = meta ,
800746 cache_mode = cache_mode ,
801747 send = lambda : self .session .list_tools (params = PaginatedRequestParams (cursor = cursor , _meta = meta )),
802748 # A cache hit skips session.list_tools, so the session re-absorbs the
803- # served listing to rebuild its derived per-tool state (header maps,
804- # output schemas) - idempotent on the already-filtered stored value.
749+ # served listing to rebuild its derived per-tool state.
805750 absorb = self .session ._absorb_tool_listing , # pyright: ignore[reportPrivateUsage]
806751 )
807752
0 commit comments