22
33from __future__ import annotations
44
5+ import hashlib
6+ import logging
7+ import uuid
58from collections .abc import Awaitable , Callable , Mapping
69from contextlib import AsyncExitStack
710from dataclasses import KW_ONLY , dataclass , field
811from typing import Any , Literal , TypeVar
12+ from urllib .parse import urlsplit , urlunsplit
913
1014import anyio
15+ import anyio .lowlevel
1116import mcp_types as types
1217from mcp_types import (
1318 CallToolResult ,
3944from mcp .client ._memory import InMemoryTransport
4045from mcp .client ._probe import negotiate_auto
4146from mcp .client ._transport import Transport
47+ from mcp .client .caching import CacheConfig , ClientResponseCache , InMemoryResponseCacheStore
4248from mcp .client .session import (
4349 ClientRequestContext ,
4450 ClientSession ,
5662from mcp .shared .dispatcher import Dispatcher , ProgressFnT
5763from mcp .shared .exceptions import MCPDeprecationWarning
5864from mcp .shared .jsonrpc_dispatcher import JSONRPCDispatcher
65+ from mcp .shared .session import RequestResponder
66+
67+ logger = logging .getLogger (__name__ )
5968
6069ConnectMode = 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+
118166def _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