Skip to content

Commit ba95005

Browse files
committed
Wire the response cache into Client: configuration, server identity, notification eviction
1 parent 5f8a392 commit ba95005

3 files changed

Lines changed: 427 additions & 2 deletions

File tree

‎src/mcp/client/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22

33
from mcp.client._input_required import InputRequiredRoundsExceededError
44
from mcp.client._transport import Transport
5-
from mcp.client.caching import CacheConfig
5+
from mcp.client.caching import CacheConfig, CacheMode
66
from mcp.client.client import Client
77
from mcp.client.context import ClientRequestContext
88
from mcp.client.session import ClientSession
99

1010
__all__ = [
1111
"CacheConfig",
12+
"CacheMode",
1213
"Client",
1314
"ClientRequestContext",
1415
"ClientSession",

‎src/mcp/client/client.py‎

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@
22

33
from __future__ import annotations
44

5+
import hashlib
6+
import logging
7+
import uuid
58
from collections.abc import Awaitable, Callable, Mapping
69
from contextlib import AsyncExitStack
710
from dataclasses import KW_ONLY, dataclass, field
811
from typing import Any, Literal, TypeVar
12+
from urllib.parse import urlsplit, urlunsplit
913

1014
import anyio
15+
import anyio.lowlevel
1116
import mcp_types as types
1217
from mcp_types import (
1318
CallToolResult,
@@ -39,6 +44,7 @@
3944
from mcp.client._memory import InMemoryTransport
4045
from mcp.client._probe import negotiate_auto
4146
from mcp.client._transport import Transport
47+
from mcp.client.caching import CacheConfig, ClientResponseCache, InMemoryResponseCacheStore
4248
from mcp.client.session import (
4349
ClientRequestContext,
4450
ClientSession,
@@ -56,6 +62,9 @@
5662
from mcp.shared.dispatcher import Dispatcher, ProgressFnT
5763
from mcp.shared.exceptions import MCPDeprecationWarning
5864
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
65+
from mcp.shared.session import RequestResponder
66+
67+
logger = logging.getLogger(__name__)
5968

6069
ConnectMode = Literal["legacy", "auto"] | str
6170
"""``mode=`` value: ``"legacy"`` (initialize handshake), ``"auto"`` (discover, fall back to
@@ -115,6 +124,45 @@ def _connected(value: _T | None) -> _T:
115124
return value
116125

117126

127+
def _strip_userinfo(url: str) -> str:
128+
"""Drop any userinfo from the URL's authority component; byte-exact otherwise.
129+
130+
Cache identity must never over-normalize (case-folding or query rewriting could
131+
merge distinct servers, e.g. `?tenant=a` vs `?tenant=b`), and credentials must
132+
never enter cache-key material — userinfo removal is the single permitted rewrite.
133+
"""
134+
parts = urlsplit(url)
135+
if "@" not in parts.netloc:
136+
return url
137+
return urlunsplit(parts._replace(netloc=parts.netloc.rpartition("@")[2]))
138+
139+
140+
def _evicting_message_handler(cache: ClientResponseCache, user_handler: MessageHandlerFnT | None) -> MessageHandlerFnT:
141+
"""Wrap the session message handler with cache eviction on server notifications.
142+
143+
Eviction runs before delegation, inside its own boundary, so a cache fault can
144+
never suppress delivery. Every item — notification, `RequestResponder`, or
145+
transport `Exception` — then reaches the user's handler; with none supplied, the
146+
wrapper performs the same bare checkpoint `ClientSession` installs by default.
147+
"""
148+
149+
async def handler(
150+
message: RequestResponder[types.ServerRequest, types.ClientResult] | types.ServerNotification | Exception,
151+
) -> None:
152+
if isinstance(message, types.ServerNotification):
153+
try:
154+
await cache.evict_for_notification(message)
155+
except Exception: # boundary: eviction reaches user store code; a cache fault must not block delivery
156+
logger.exception("Response cache eviction failed; the notification is still delivered")
157+
if user_handler is not None:
158+
await user_handler(message)
159+
else:
160+
# Mirrors ClientSession's default handler (session._default_message_handler).
161+
await anyio.lowlevel.checkpoint()
162+
163+
return handler
164+
165+
118166
def _synthesize_discover(protocol_version: str) -> types.DiscoverResult:
119167
return types.DiscoverResult(
120168
supported_versions=[protocol_version],
@@ -221,10 +269,23 @@ async def main():
221269
"""SEP-2133 extension support to advertise under `ClientCapabilities.extensions`
222270
(identifier -> settings), e.g. `{"io.modelcontextprotocol/ui": {"mimeTypes": [...]}}`."""
223271

272+
cache: CacheConfig | Literal[False] | None = None
273+
"""Client-side response caching for the SEP-2549 cacheable methods (2026-07-28).
274+
275+
`None` (the default) honors server `ttlMs`/`cacheScope` hints with a per-client
276+
in-memory store; results carrying no hints are not cached. Pass a `CacheConfig`
277+
to customize (shared store, partition, default TTL), or `False` to disable
278+
caching entirely.
279+
280+
Construction raises `ValueError` for a `CacheConfig` with a custom `store` when
281+
no server identity can be derived (an in-process server or a `Transport`
282+
instance) — set `CacheConfig.target_id` to name the server."""
283+
224284
_entered: bool = field(init=False, default=False)
225285
_session: ClientSession | None = field(init=False, default=None)
226286
_exit_stack: AsyncExitStack | None = field(init=False, default=None)
227287
_connect: _Connector = field(init=False, repr=False, compare=False)
288+
_response_cache: ClientResponseCache | None = field(init=False, default=None, repr=False, compare=False)
228289

229290
def __post_init__(self) -> None:
230291
if self.mode not in ("legacy", "auto") and self.mode not in MODERN_PROTOCOL_VERSIONS:
@@ -247,16 +308,48 @@ def __post_init__(self) -> None:
247308
else:
248309
self._connect = _connect_transport(srv)
249310

311+
if self.cache is not False:
312+
config = self.cache if self.cache is not None else CacheConfig()
313+
# Server identity, in resolution order: explicit override, server URL
314+
# (userinfo stripped, byte-exact otherwise), per-Client random. Only the
315+
# hash below leaves this scope — the raw identity may carry credentials
316+
# in its query string and must never be logged or stored.
317+
target_id = config.target_id
318+
if target_id is None and isinstance(self.server, str):
319+
target_id = _strip_userinfo(self.server)
320+
if target_id is None:
321+
if config.store is not None:
322+
raise ValueError(
323+
"a custom cache store requires CacheConfig.target_id when the server is not a URL: "
324+
"in-process servers and Transport instances get a random per-client identity, so "
325+
"their entries in a shared store could never be served to another client"
326+
)
327+
target_id = uuid.uuid4().hex
328+
self._response_cache = ClientResponseCache(
329+
store=config.store if config.store is not None else InMemoryResponseCacheStore(),
330+
partition=config.partition,
331+
arm_id=hashlib.sha256(target_id.encode()).hexdigest(),
332+
default_ttl_ms=config.default_ttl_ms,
333+
clock=config.clock,
334+
share_public=config.share_public,
335+
# Lazy: the era is unknown until __aenter__'s handshake, and the
336+
# session is unpublished outside the context manager.
337+
negotiated_version=lambda: self._session.protocol_version if self._session is not None else None,
338+
)
339+
250340
async def _build_session(self, exit_stack: AsyncExitStack) -> ClientSession:
251341
"""Enter the resolved connector and return an un-entered ClientSession."""
252342
dispatcher = await self._connect(exit_stack, self.mode, self.raise_exceptions)
343+
message_handler = self.message_handler
344+
if self._response_cache is not None:
345+
message_handler = _evicting_message_handler(self._response_cache, self.message_handler)
253346
return ClientSession(
254347
dispatcher=dispatcher,
255348
read_timeout_seconds=self.read_timeout_seconds,
256349
sampling_callback=self.sampling_callback,
257350
list_roots_callback=self.list_roots_callback,
258351
logging_callback=self.logging_callback,
259-
message_handler=self.message_handler,
352+
message_handler=message_handler,
260353
client_info=self.client_info,
261354
elicitation_callback=self.elicitation_callback,
262355
extensions=self.extensions,

0 commit comments

Comments
 (0)