Skip to content

Commit 0a19550

Browse files
committed
Serve cacheable client verbs through the response cache
1 parent ba95005 commit 0a19550

3 files changed

Lines changed: 776 additions & 17 deletions

File tree

‎src/mcp/client/client.py‎

Lines changed: 132 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
88
from collections.abc import Awaitable, Callable, Mapping
99
from contextlib import AsyncExitStack
1010
from dataclasses import KW_ONLY, dataclass, field
11-
from typing import Any, Literal, TypeVar
11+
from typing import Any, Literal, TypeVar, cast
1212
from urllib.parse import urlsplit, urlunsplit
1313

1414
import anyio
1515
import anyio.lowlevel
1616
import mcp_types as types
1717
from mcp_types import (
18+
INVALID_PARAMS,
19+
CacheableResult,
1820
CallToolResult,
1921
CompleteResult,
2022
EmptyResult,
@@ -44,7 +46,7 @@
4446
from mcp.client._memory import InMemoryTransport
4547
from mcp.client._probe import negotiate_auto
4648
from mcp.client._transport import Transport
47-
from mcp.client.caching import CacheConfig, ClientResponseCache, InMemoryResponseCacheStore
49+
from mcp.client.caching import CacheConfig, CacheMode, ClientResponseCache, InMemoryResponseCacheStore
4850
from mcp.client.session import (
4951
ClientRequestContext,
5052
ClientSession,
@@ -60,7 +62,7 @@
6062
from mcp.server.runner import modern_on_request
6163
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
6264
from mcp.shared.dispatcher import Dispatcher, ProgressFnT
63-
from mcp.shared.exceptions import MCPDeprecationWarning
65+
from mcp.shared.exceptions import MCPDeprecationWarning, MCPError
6466
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
6567
from mcp.shared.session import RequestResponder
6668

@@ -73,6 +75,7 @@
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:

‎src/mcp/client/session.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -895,7 +895,17 @@ async def list_tools(self, *, params: types.PaginatedRequestParams | None = None
895895
types.ListToolsRequest(params=params),
896896
types.ListToolsResult,
897897
)
898+
return self._absorb_tool_listing(result)
898899

900+
def _absorb_tool_listing(self, result: types.ListToolsResult) -> types.ListToolsResult:
901+
"""Filter a tool listing per the 2026 x-mcp-header MUST and rebuild the derived
902+
per-tool state (arg→header maps, output schemas) from it.
903+
904+
Idempotent, so the client response cache can re-absorb a served listing: stored
905+
values are already post-filter, making the re-filter a no-op that rebuilds the
906+
maps and schemas from the served value. `result` is mutated in place (the cache
907+
only ever passes a private deep copy).
908+
"""
899909
if self._negotiated_version in MODERN_PROTOCOL_VERSIONS:
900910
# 2026-07-28: clients MUST drop tools whose x-mcp-header annotations are invalid.
901911
kept: list[types.Tool] = []

0 commit comments

Comments
 (0)