Skip to content

Commit 1519707

Browse files
feat: add client-side TTL-honoring response cache (SEP-2549) (#1025)
* feat: add client-side TTL-honoring response cache (SEP-2549) Add a configurable client response cache in the service layer that honors SEP-2549 caching hints (ttlMs / cacheScope) for tools/list, prompts/list, resources/list, resources/templates/list, and resources/read. Closes #974 Co-Authored-by: John-Francis Nnadi <nnadifrancis23@gmail.com> * fix: address PR feedback --------- Co-authored-by: John-Francis Nnadi <nnadifrancis23@gmail.com>
1 parent 07abfdf commit 1519707

5 files changed

Lines changed: 1022 additions & 29 deletions

File tree

README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ For the full MCP specification, see [modelcontextprotocol.io](https://modelconte
3232
- [Notifications](#notifications)
3333
- [Subscriptions](#subscriptions)
3434
- [Tasks](#tasks-long-running-tool-invocations)
35+
- [Caching](#caching)
3536
- [Examples](#examples)
3637
- [OAuth Support](#oauth-support)
3738
- [Related Resources](#related-resources)
@@ -1003,6 +1004,52 @@ async fn call_tool(&self, request: CallToolRequestParams, context: RequestContex
10031004
See [`servers_task_stdio`](examples/servers/src/task_stdio.rs) and the matching
10041005
[`clients_task_stdio`](examples/clients/src/task_stdio.rs) for a runnable end-to-end example.
10051006

1007+
## Caching
1008+
1009+
`rmcp` clients transparently cache responses that carry the
1010+
[SEP-2549](https://modelcontextprotocol.io/specification/draft/server/utilities/caching)
1011+
caching hints (`ttlMs` / `cacheScope`) for `server/discover`, `tools/list`,
1012+
`prompts/list`, `resources/list`, `resources/templates/list`, and `resources/read`.
1013+
1014+
Caching is on by default but only stores a response when the server sends a
1015+
positive `ttlMs`, so servers that omit the hint behave exactly as before. Entries
1016+
expire after their TTL, are partitioned by cache scope, and are invalidated
1017+
automatically by the matching `list_changed` / `resource updated` notifications.
1018+
1019+
No call-site changes are needed — existing calls benefit automatically:
1020+
1021+
```rust, ignore
1022+
let tools = peer.list_tools(None).await?; // served from cache while fresh
1023+
let res = peer.read_resource(params).await?; // cached per-URI
1024+
```
1025+
1026+
Tune or disable it per connection via the `Peer`:
1027+
1028+
```rust, ignore
1029+
use std::time::Duration;
1030+
use rmcp::ClientCacheConfig;
1031+
1032+
// Customize behavior.
1033+
peer.set_response_cache_config(
1034+
ClientCacheConfig::default()
1035+
.with_default_ttl(Duration::from_secs(30)) // TTL for servers that omit ttlMs
1036+
.with_max_ttl(Duration::from_secs(3600)) // upper bound on any TTL
1037+
.with_max_entries(1024)
1038+
.with_private_partition(user_id) // separate private caches per principal
1039+
.with_serve_stale_on_error(false), // surface errors instead of stale data
1040+
).await;
1041+
1042+
// Or turn it off entirely.
1043+
peer.set_response_cache_config(ClientCacheConfig::disabled()).await;
1044+
1045+
// Manually flush.
1046+
peer.clear_response_cache().await;
1047+
```
1048+
1049+
> **Note:** with the default `serve_stale_on_error`, a failed re-fetch returns the
1050+
> last cached response (even if expired) as `Ok(..)` instead of an error. Set
1051+
> `with_serve_stale_on_error(false)` if callers must observe fetch failures.
1052+
10061053
## Examples
10071054

10081055
See [examples](examples/README.md).

crates/rmcp/src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ pub use handler::server::ServerHandler;
1818
pub use handler::server::wrapper::Json;
1919
#[cfg(feature = "client")]
2020
pub use service::{
21-
ClientLifecycleMode, ClientServiceExt, RoleClient, select_protocol_version, serve_client,
22-
serve_client_with_lifecycle,
21+
ClientCacheConfig, ClientLifecycleMode, ClientServiceExt, MAX_CLIENT_CACHE_TTL, RoleClient,
22+
select_protocol_version, serve_client, serve_client_with_lifecycle,
2323
};
2424
#[cfg(any(feature = "client", feature = "server"))]
2525
pub use service::{Peer, Service, ServiceError, ServiceExt};

crates/rmcp/src/service.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,19 @@ pub trait ServiceRole: std::fmt::Debug + Send + Sync + 'static + Copy + Clone {
136136
fn peer_cancelled_params(_notification: &Self::PeerNot) -> Option<&CancelledNotificationParam> {
137137
None
138138
}
139+
/// Invalidate any response cache affected by an inbound peer notification.
140+
///
141+
/// The serve loop calls this for every notification *before* subscription
142+
/// routing, so cache invalidation still runs when a notification is
143+
/// delivered through a `listen` subscription channel rather than the
144+
/// [`Service::handle_notification`] callbacks.
145+
#[doc(hidden)]
146+
fn invalidate_response_cache(
147+
_peer: &Peer<Self>,
148+
_notification: &Self::PeerNot,
149+
) -> impl Future<Output = ()> + MaybeSendFuture {
150+
async {}
151+
}
139152
}
140153

141154
pub(crate) fn uses_legacy_lifecycle(
@@ -571,6 +584,8 @@ pub struct Peer<R: ServiceRole> {
571584
client_request_metadata: Arc<OnceLock<ClientRequestMetadata>>,
572585
request_metadata_required: Arc<std::sync::atomic::AtomicBool>,
573586
subscription_channels: Arc<std::sync::RwLock<SubscriptionChannelMap<R::PeerNot>>>,
587+
#[cfg(feature = "client")]
588+
response_cache: client::cache::PeerResponseCache<R>,
574589
}
575590

576591
impl<R: Clone + ServiceRole> Clone for Peer<R>
@@ -587,6 +602,8 @@ where
587602
client_request_metadata: self.client_request_metadata.clone(),
588603
request_metadata_required: self.request_metadata_required.clone(),
589604
subscription_channels: self.subscription_channels.clone(),
605+
#[cfg(feature = "client")]
606+
response_cache: self.response_cache.clone(),
590607
}
591608
}
592609
}
@@ -661,6 +678,8 @@ impl<R: ServiceRole> Peer<R> {
661678
client_request_metadata: Default::default(),
662679
request_metadata_required: Default::default(),
663680
subscription_channels: Default::default(),
681+
#[cfg(feature = "client")]
682+
response_cache: Default::default(),
664683
},
665684
rx,
666685
)
@@ -1402,6 +1421,7 @@ where
14021421
..
14031422
})) => {
14041423
tracing::info!(?notification, "received notification");
1424+
R::invalidate_response_cache(&peer, &notification).await;
14051425
let cancellation_request_id =
14061426
if let Some(cancelled) = R::peer_cancelled_params(&notification) {
14071427
let request_id = cancelled.request_id.clone();

0 commit comments

Comments
 (0)