88from collections .abc import Awaitable , Callable , Mapping
99from contextlib import AsyncExitStack
1010from dataclasses import KW_ONLY , dataclass , field
11- from typing import Any , Literal , TypeVar
11+ from typing import Any , Literal , TypeVar , cast
1212from urllib .parse import urlsplit , urlunsplit
1313
1414import anyio
1515import anyio .lowlevel
1616import mcp_types as types
1717from mcp_types import (
18+ INVALID_PARAMS ,
19+ CacheableResult ,
1820 CallToolResult ,
1921 CompleteResult ,
2022 EmptyResult ,
4446from mcp .client ._memory import InMemoryTransport
4547from mcp .client ._probe import negotiate_auto
4648from mcp .client ._transport import Transport
47- from mcp .client .caching import CacheConfig , ClientResponseCache , InMemoryResponseCacheStore
49+ from mcp .client .caching import CacheConfig , CacheMode , ClientResponseCache , InMemoryResponseCacheStore
4850from mcp .client .session import (
4951 ClientRequestContext ,
5052 ClientSession ,
6062from mcp .server .runner import modern_on_request
6163from mcp .shared .direct_dispatcher import create_direct_dispatcher_pair
6264from mcp .shared .dispatcher import Dispatcher , ProgressFnT
63- from mcp .shared .exceptions import MCPDeprecationWarning
65+ from mcp .shared .exceptions import MCPDeprecationWarning , MCPError
6466from mcp .shared .jsonrpc_dispatcher import JSONRPCDispatcher
6567from mcp .shared .session import RequestResponder
6668
7375
7476_T = TypeVar ("_T" )
7577_ResultT = TypeVar ("_ResultT" )
78+ _CacheableT = TypeVar ("_CacheableT" , bound = CacheableResult )
7679
7780_Connector = Callable [[AsyncExitStack , ConnectMode , bool ], Awaitable ["Dispatcher[Any]" ]]
7881"""Resolved at ``__post_init__`` from the shape of ``server`` alone: enter whatever resources
@@ -275,7 +278,9 @@ async def main():
275278 `None` (the default) honors server `ttlMs`/`cacheScope` hints with a per-client
276279 in-memory store; results carrying no hints are not cached. Pass a `CacheConfig`
277280 to customize (shared store, partition, default TTL), or `False` to disable
278- caching entirely.
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.
279284
280285 Construction raises `ValueError` for a `CacheConfig` with a custom `store` when
281286 no server identity can be derived (an in-process server or a `Transport`
@@ -454,23 +459,79 @@ async def set_logging_level(self, level: LoggingLevel, *, meta: RequestParamsMet
454459 """Set the logging level on the server."""
455460 return await self .session .set_logging_level (level = level , meta = meta ) # pyright: ignore[reportDeprecated]
456461
462+ async def _cached_fetch (
463+ self ,
464+ method : str ,
465+ * ,
466+ cursor : str | None ,
467+ cache_mode : CacheMode ,
468+ send : Callable [[], Awaitable [_CacheableT ]],
469+ absorb : Callable [[_CacheableT ], _CacheableT ] | None = None ,
470+ ) -> _CacheableT :
471+ """Serve one of the four list verbs through the response cache.
472+
473+ `send` performs the fetch via the session; `absorb` (tools/list only)
474+ re-applies session-side derived state to a served cache hit.
475+ """
476+ cache = self ._response_cache
477+ if cache is None or cache_mode == "bypass" :
478+ return await send () # no read, no write, no eviction side-effects
479+ if cursor is not None :
480+ # Continuation pages never read or write the (cursor-less) entry, but an
481+ # expired-cursor rejection signals the listing changed since the entry was
482+ # fetched, so it is evicted (spec SHOULD; over-eviction is harmless).
483+ try :
484+ return await send ()
485+ except MCPError as e :
486+ if e .code == INVALID_PARAMS :
487+ await cache .evict_method (method )
488+ raise
489+ if cache_mode == "use" and (hit := await cache .read (method , "" )) is not None :
490+ # The store key carries the method, so the entry under it has `send`'s
491+ # result type. The hit is already a private deep copy of the stored
492+ # value, so absorption may mutate it freely.
493+ served = cast (_CacheableT , hit )
494+ return served if absorb is None else absorb (served )
495+ gen = cache .capture (method , "" )
496+ result = await send ()
497+ await cache .write (method , "" , result , gen , cache_mode )
498+ return result
499+
457500 async def list_resources (
458501 self ,
459502 * ,
460503 cursor : str | None = None ,
461504 meta : RequestParamsMeta | None = None ,
505+ cache_mode : CacheMode = "use" ,
462506 ) -> ListResourcesResult :
463- """List available resources from the server."""
464- return await self .session .list_resources (params = PaginatedRequestParams (cursor = cursor , _meta = meta ))
507+ """List available resources from the server.
508+
509+ `cache_mode` adjusts the response cache's behavior for this call (see `CacheMode`).
510+ """
511+ return await self ._cached_fetch (
512+ "resources/list" ,
513+ cursor = cursor ,
514+ cache_mode = cache_mode ,
515+ send = lambda : self .session .list_resources (params = PaginatedRequestParams (cursor = cursor , _meta = meta )),
516+ )
465517
466518 async def list_resource_templates (
467519 self ,
468520 * ,
469521 cursor : str | None = None ,
470522 meta : RequestParamsMeta | None = None ,
523+ cache_mode : CacheMode = "use" ,
471524 ) -> ListResourceTemplatesResult :
472- """List available resource templates from the server."""
473- return await self .session .list_resource_templates (params = PaginatedRequestParams (cursor = cursor , _meta = meta ))
525+ """List available resource templates from the server.
526+
527+ `cache_mode` adjusts the response cache's behavior for this call (see `CacheMode`).
528+ """
529+ return await self ._cached_fetch (
530+ "resources/templates/list" ,
531+ cursor = cursor ,
532+ cache_mode = cache_mode ,
533+ send = lambda : self .session .list_resource_templates (params = PaginatedRequestParams (cursor = cursor , _meta = meta )),
534+ )
474535
475536 async def read_resource (
476537 self ,
@@ -479,6 +540,7 @@ async def read_resource(
479540 input_responses : InputResponses | None = None ,
480541 request_state : str | None = None ,
481542 meta : RequestParamsMeta | None = None ,
543+ cache_mode : CacheMode = "use" ,
482544 ) -> ReadResourceResult :
483545 """Read a resource from the server.
484546
@@ -493,6 +555,11 @@ async def read_resource(
493555 resuming from a persisted `InputRequiredResult`).
494556 request_state: Opaque state to seed the first call with.
495557 meta: Additional metadata for the request.
558+ cache_mode: Adjusts the response cache's behavior for this call
559+ (see `CacheMode`). Seeded calls (either `input_responses` or
560+ `request_state` set) are resumptions of a multi-round-trip
561+ read and ignore it entirely: no cache read, no write, no
562+ refresh purge.
496563
497564 Returns:
498565 The resource content.
@@ -507,7 +574,31 @@ async def retry(r: InputResponses | None, s: str | None) -> ReadResourceResult |
507574 uri , input_responses = r , request_state = s , meta = meta , allow_input_required = True
508575 )
509576
510- return await self ._drive_input_required (await retry (input_responses , request_state ), retry )
577+ # Results of requests carrying inputResponses or requestState must never be
578+ # cached (spec MUST), and a seeded call exists to resume a specific exchange -
579+ # serving it from the cache would skip the resumption.
580+ seeded = input_responses is not None or request_state is not None
581+ cache = None if seeded else self ._response_cache
582+ if cache is None or cache_mode == "bypass" :
583+ return await self ._drive_input_required (await retry (input_responses , request_state ), retry )
584+ if cache_mode == "use" and (hit := await cache .read ("resources/read" , uri )) is not None :
585+ # InputRequiredResult is never stored (only terminal first-round results
586+ # are written below), so a hit is always terminal and legitimately skips
587+ # the driver.
588+ return cast (ReadResourceResult , hit )
589+ gen = cache .capture ("resources/read" , uri )
590+ first = await retry (None , None )
591+ if not isinstance (first , InputRequiredResult ):
592+ await cache .write ("resources/read" , uri , first , gen , cache_mode )
593+ elif cache_mode == "refresh" :
594+ # An input_required resolution can never be stored, but the explicit
595+ # refresh still superseded whatever was cached: purge the warm entry
596+ # so it cannot be served again (the same supersession rule as a
597+ # refreshed ttl<=0 result in `ClientResponseCache.write`).
598+ await cache .evict_key ("resources/read" , uri )
599+ # A terminal result reached through driver rounds is never cached: the rounds
600+ # carried inputResponses (the same spec MUST as the seeded skip above).
601+ return await self ._drive_input_required (first , retry )
511602
512603 async def subscribe_resource (self , uri : str , * , meta : RequestParamsMeta | None = None ) -> EmptyResult :
513604 """Subscribe to resource updates."""
@@ -574,9 +665,18 @@ async def list_prompts(
574665 * ,
575666 cursor : str | None = None ,
576667 meta : RequestParamsMeta | None = None ,
668+ cache_mode : CacheMode = "use" ,
577669 ) -> ListPromptsResult :
578- """List available prompts from the server."""
579- return await self .session .list_prompts (params = PaginatedRequestParams (cursor = cursor , _meta = meta ))
670+ """List available prompts from the server.
671+
672+ `cache_mode` adjusts the response cache's behavior for this call (see `CacheMode`).
673+ """
674+ return await self ._cached_fetch (
675+ "prompts/list" ,
676+ cursor = cursor ,
677+ cache_mode = cache_mode ,
678+ send = lambda : self .session .list_prompts (params = PaginatedRequestParams (cursor = cursor , _meta = meta )),
679+ )
580680
581681 async def get_prompt (
582682 self ,
@@ -658,9 +758,27 @@ async def complete(
658758 """
659759 return await self .session .complete (ref = ref , argument = argument , context_arguments = context_arguments )
660760
661- async def list_tools (self , * , cursor : str | None = None , meta : RequestParamsMeta | None = None ) -> ListToolsResult :
662- """List available tools from the server."""
663- return await self .session .list_tools (params = PaginatedRequestParams (cursor = cursor , _meta = meta ))
761+ async def list_tools (
762+ self ,
763+ * ,
764+ cursor : str | None = None ,
765+ meta : RequestParamsMeta | None = None ,
766+ cache_mode : CacheMode = "use" ,
767+ ) -> ListToolsResult :
768+ """List available tools from the server.
769+
770+ `cache_mode` adjusts the response cache's behavior for this call (see `CacheMode`).
771+ """
772+ return await self ._cached_fetch (
773+ "tools/list" ,
774+ cursor = cursor ,
775+ cache_mode = cache_mode ,
776+ send = lambda : self .session .list_tools (params = PaginatedRequestParams (cursor = cursor , _meta = meta )),
777+ # A cache hit skips session.list_tools, so the session re-absorbs the
778+ # served listing to rebuild its derived per-tool state (header maps,
779+ # output schemas) - idempotent on the already-filtered stored value.
780+ absorb = self .session ._absorb_tool_listing , # pyright: ignore[reportPrivateUsage]
781+ )
664782
665783 @deprecated ("The roots capability is deprecated as of 2026-07-28 (SEP-2577)." , category = MCPDeprecationWarning )
666784 async def send_roots_list_changed (self ) -> None :
0 commit comments