Skip to main content

rmcp/service/client/
cache.rs

1use std::{
2    collections::HashMap,
3    sync::Arc,
4    time::{Duration, Instant},
5};
6
7use super::RoleClient;
8use crate::{
9    model::CacheScope,
10    service::{Peer, ServiceRole},
11};
12
13/// Maximum server-provided cache TTL honoured by the client response cache.
14pub const MAX_CLIENT_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
15
16/// Configuration for the built-in MCP client response cache.
17///
18/// A cache is allocated per client [`Peer`]. Public responses may be reused
19/// throughout that client connection. Private responses are additionally
20/// partitioned by `private_partition`; changing the partition drops every
21/// private entry while preserving public entries.
22#[derive(Debug, Clone, PartialEq, Eq)]
23#[non_exhaustive]
24pub struct ClientCacheConfig {
25    /// Enables cache reads and writes.
26    pub enabled: bool,
27    /// TTL used when a backwards-compatible server omits `ttlMs`.
28    ///
29    /// The default is zero, which leaves such responses immediately stale.
30    pub default_ttl: Duration,
31    /// Upper bound applied to both server-provided and default TTLs.
32    pub max_ttl: Duration,
33    /// Stable opaque identity for the current authorization context.
34    ///
35    /// A single-principal client may leave this unset because each client owns
36    /// its own in-memory store. Gateways or clients that change principals on an
37    /// existing connection should set this value and update it whenever the
38    /// authorization context changes.
39    pub private_partition: Option<String>,
40    /// Maximum number of responses retained by the in-memory cache.
41    ///
42    /// A value of zero disables the size limit.
43    pub max_entries: usize,
44    /// Serves an expired cached response when a re-fetch fails.
45    ///
46    /// SEP-2549 permits clients to serve stale responses if errors occur while
47    /// re-fetching (for example, network issues or server downtime). When this
48    /// is enabled the client retains expired entries so it can fall back to the
49    /// last known response instead of surfacing the transport or server error.
50    /// A successful re-fetch always overwrites the stale entry.
51    pub serve_stale_on_error: bool,
52}
53
54impl Default for ClientCacheConfig {
55    fn default() -> Self {
56        Self {
57            enabled: true,
58            default_ttl: Duration::ZERO,
59            max_ttl: MAX_CLIENT_CACHE_TTL,
60            private_partition: None,
61            max_entries: 512,
62            serve_stale_on_error: true,
63        }
64    }
65}
66
67impl ClientCacheConfig {
68    /// Returns a configuration that disables all cache reads and writes.
69    pub fn disabled() -> Self {
70        Self {
71            enabled: false,
72            ..Self::default()
73        }
74    }
75
76    /// Sets the TTL used when a response omits `ttlMs`.
77    pub fn with_default_ttl(mut self, default_ttl: Duration) -> Self {
78        self.default_ttl = default_ttl;
79        self
80    }
81
82    /// Sets the maximum TTL the client will honour.
83    pub fn with_max_ttl(mut self, max_ttl: Duration) -> Self {
84        self.max_ttl = max_ttl;
85        self
86    }
87
88    /// Sets the stable partition for private responses.
89    pub fn with_private_partition(mut self, partition: impl Into<String>) -> Self {
90        self.private_partition = Some(partition.into());
91        self
92    }
93
94    /// Sets the maximum number of retained responses.
95    pub fn with_max_entries(mut self, max_entries: usize) -> Self {
96        self.max_entries = max_entries;
97        self
98    }
99
100    /// Controls whether an expired response may be served when a re-fetch fails.
101    pub fn with_serve_stale_on_error(mut self, serve_stale_on_error: bool) -> Self {
102        self.serve_stale_on_error = serve_stale_on_error;
103        self
104    }
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Hash)]
108enum CachePartition {
109    Public,
110    Private(Arc<str>),
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Hash)]
114struct CacheKey {
115    logical_key: String,
116    partition: CachePartition,
117}
118
119#[derive(Debug, Clone)]
120struct CachedPeerResponse<T> {
121    value: T,
122    expires_at: Instant,
123    inserted_at: Instant,
124    scope: CacheScope,
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub(crate) struct CacheGeneration(u64);
129
130#[derive(Debug)]
131pub(crate) struct PeerResponseCacheState<R: ServiceRole> {
132    entries: HashMap<CacheKey, CachedPeerResponse<R::PeerResp>>,
133    config: ClientCacheConfig,
134    generation: u64,
135}
136
137impl<R: ServiceRole> Default for PeerResponseCacheState<R> {
138    fn default() -> Self {
139        Self {
140            entries: HashMap::new(),
141            config: ClientCacheConfig::default(),
142            generation: 0,
143        }
144    }
145}
146
147impl<R: ServiceRole> PeerResponseCacheState<R> {
148    fn trim_to_limit(&mut self) {
149        while self.config.max_entries > 0 && self.entries.len() > self.config.max_entries {
150            let Some(oldest_key) = self
151                .entries
152                .iter()
153                .min_by_key(|(_, entry)| entry.inserted_at)
154                .map(|(key, _)| key.clone())
155            else {
156                break;
157            };
158            self.entries.remove(&oldest_key);
159        }
160    }
161}
162
163pub(crate) type PeerResponseCache<R> = Arc<tokio::sync::RwLock<PeerResponseCacheState<R>>>;
164
165impl<R: ServiceRole> Peer<R> {
166    fn private_partition(config: &ClientCacheConfig) -> Arc<str> {
167        Arc::from(config.private_partition.as_deref().unwrap_or("connection"))
168    }
169
170    fn cache_key(logical_key: &str, partition: CachePartition) -> CacheKey {
171        CacheKey {
172            logical_key: logical_key.to_owned(),
173            partition,
174        }
175    }
176
177    fn scoped_cache_key(
178        logical_key: &str,
179        scope: CacheScope,
180        config: &ClientCacheConfig,
181    ) -> CacheKey {
182        let partition = match scope {
183            CacheScope::Public => CachePartition::Public,
184            CacheScope::Private => CachePartition::Private(Self::private_partition(config)),
185        };
186        Self::cache_key(logical_key, partition)
187    }
188
189    /// Captures the cache generation before a request crosses the transport.
190    ///
191    /// Any configuration change, explicit clear, or notification invalidation
192    /// advances the generation. A response from an older generation is not
193    /// written back, preventing an in-flight stale response from undoing an
194    /// invalidation.
195    pub(crate) async fn capture_response_cache_generation(&self) -> CacheGeneration {
196        CacheGeneration(self.response_cache.read().await.generation)
197    }
198
199    /// Returns a fresh cached response, preferring the current private partition
200    /// before the public partition.
201    ///
202    /// Expired entries are removed on access unless `serve_stale_on_error` is
203    /// enabled, in which case they are retained so a later re-fetch failure can
204    /// fall back to them via [`Peer::stale_cached_response`].
205    pub(crate) async fn cached_response(&self, logical_key: &str) -> Option<R::PeerResp> {
206        let now = Instant::now();
207        let mut cache = self.response_cache.write().await;
208        if !cache.config.enabled {
209            return None;
210        }
211        let keep_stale = cache.config.serve_stale_on_error;
212
213        let private_key = Self::cache_key(
214            logical_key,
215            CachePartition::Private(Self::private_partition(&cache.config)),
216        );
217        let private_fresh = cache.entries.get(&private_key).and_then(|entry| {
218            (entry.expires_at > now && entry.scope == CacheScope::Private)
219                .then(|| entry.value.clone())
220        });
221        if let Some(value) = private_fresh {
222            return Some(value);
223        }
224        if !keep_stale {
225            cache.entries.remove(&private_key);
226        }
227
228        let public_key = Self::cache_key(logical_key, CachePartition::Public);
229        let public_fresh = cache.entries.get(&public_key).and_then(|entry| {
230            (entry.expires_at > now && entry.scope == CacheScope::Public)
231                .then(|| entry.value.clone())
232        });
233        if let Some(value) = public_fresh {
234            return Some(value);
235        }
236        if !keep_stale {
237            cache.entries.remove(&public_key);
238        }
239        None
240    }
241
242    /// Returns a cached response ignoring its TTL, for use as a fallback when a
243    /// re-fetch fails (SEP-2549 permits serving stale responses on error).
244    ///
245    /// Returns `None` when the cache is disabled or `serve_stale_on_error` is
246    /// turned off. The private partition is preferred over the public one. The
247    /// entry is left in place so repeated failures keep serving it until a
248    /// successful re-fetch overwrites it or a notification invalidates it.
249    pub(crate) async fn stale_cached_response(&self, logical_key: &str) -> Option<R::PeerResp> {
250        let cache = self.response_cache.read().await;
251        if !cache.config.enabled || !cache.config.serve_stale_on_error {
252            return None;
253        }
254
255        let private_key = Self::cache_key(
256            logical_key,
257            CachePartition::Private(Self::private_partition(&cache.config)),
258        );
259        if let Some(entry) = cache.entries.get(&private_key)
260            && entry.scope == CacheScope::Private
261        {
262            return Some(entry.value.clone());
263        }
264
265        let public_key = Self::cache_key(logical_key, CachePartition::Public);
266        if let Some(entry) = cache.entries.get(&public_key)
267            && entry.scope == CacheScope::Public
268        {
269            return Some(entry.value.clone());
270        }
271        None
272    }
273
274    /// Stores a response when the configured effective TTL is positive.
275    ///
276    /// Missing `cacheScope` is treated as private. This is deliberately more
277    /// conservative than the model's backwards-compatible wire default and
278    /// prevents an older or malformed server response from becoming shareable.
279    pub(crate) async fn cache_response_with_generation(
280        &self,
281        logical_key: String,
282        value: R::PeerResp,
283        ttl_ms: Option<u64>,
284        cache_scope: Option<CacheScope>,
285        generation: CacheGeneration,
286    ) {
287        let now = Instant::now();
288        let mut cache = self.response_cache.write().await;
289        if !cache.config.enabled || generation.0 != cache.generation {
290            return;
291        }
292
293        let requested_ttl = ttl_ms
294            .map(Duration::from_millis)
295            .unwrap_or(cache.config.default_ttl);
296        let ttl = requested_ttl.min(cache.config.max_ttl);
297        if ttl.is_zero() {
298            return;
299        }
300        let Some(expires_at) = now.checked_add(ttl) else {
301            return;
302        };
303        let scope = cache_scope.unwrap_or(CacheScope::Private);
304        let target_key = Self::scoped_cache_key(&logical_key, scope, &cache.config);
305        let opposite_key = match scope {
306            CacheScope::Public => Self::cache_key(
307                &logical_key,
308                CachePartition::Private(Self::private_partition(&cache.config)),
309            ),
310            CacheScope::Private => Self::cache_key(&logical_key, CachePartition::Public),
311        };
312
313        if !cache.config.serve_stale_on_error {
314            cache.entries.retain(|_, entry| entry.expires_at > now);
315        }
316        cache.entries.remove(&opposite_key);
317
318        if cache.config.max_entries > 0
319            && !cache.entries.contains_key(&target_key)
320            && cache.entries.len() >= cache.config.max_entries
321            && let Some(oldest_key) = cache
322                .entries
323                .iter()
324                .min_by_key(|(_, entry)| entry.inserted_at)
325                .map(|(key, _)| key.clone())
326        {
327            cache.entries.remove(&oldest_key);
328        }
329
330        cache.entries.insert(
331            target_key,
332            CachedPeerResponse {
333                value,
334                expires_at,
335                inserted_at: now,
336                scope,
337            },
338        );
339    }
340
341    #[cfg(test)]
342    pub(crate) async fn cache_response(
343        &self,
344        logical_key: String,
345        value: R::PeerResp,
346        ttl_ms: Option<u64>,
347        cache_scope: Option<CacheScope>,
348    ) {
349        let generation = self.capture_response_cache_generation().await;
350        self.cache_response_with_generation(logical_key, value, ttl_ms, cache_scope, generation)
351            .await;
352    }
353
354    pub(crate) async fn invalidate_cached_responses(&self, prefix: &str) {
355        let mut cache = self.response_cache.write().await;
356        cache.generation = cache.generation.wrapping_add(1);
357        cache
358            .entries
359            .retain(|key, _| !key.logical_key.starts_with(prefix));
360    }
361}
362
363impl Peer<RoleClient> {
364    /// Replaces the response-cache configuration.
365    ///
366    /// Changing the private partition invalidates private entries from the old
367    /// authorization context. Disabling the cache clears every entry. Any
368    /// configuration change also suppresses writes from requests that were
369    /// already in flight under the previous configuration.
370    pub async fn set_response_cache_config(&self, config: ClientCacheConfig) {
371        let mut cache = self.response_cache.write().await;
372        let config_changed = cache.config != config;
373        let partition_changed = cache.config.private_partition != config.private_partition;
374        let ttl_policy_changed = cache.config.default_ttl != config.default_ttl
375            || cache.config.max_ttl != config.max_ttl;
376        cache.config = config;
377        if config_changed {
378            cache.generation = cache.generation.wrapping_add(1);
379        }
380        if !cache.config.enabled || ttl_policy_changed {
381            cache.entries.clear();
382        } else if partition_changed {
383            cache
384                .entries
385                .retain(|_, entry| entry.scope == CacheScope::Public);
386        }
387        cache.trim_to_limit();
388    }
389
390    /// Returns a snapshot of the active response-cache configuration.
391    pub async fn response_cache_config(&self) -> ClientCacheConfig {
392        self.response_cache.read().await.config.clone()
393    }
394
395    /// Clears every cached client response without changing the configuration.
396    pub async fn clear_response_cache(&self) {
397        let mut cache = self.response_cache.write().await;
398        cache.generation = cache.generation.wrapping_add(1);
399        cache.entries.clear();
400    }
401}