Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ For the full MCP specification, see [modelcontextprotocol.io](https://modelconte
- [Notifications](#notifications)
- [Subscriptions](#subscriptions)
- [Tasks](#tasks-long-running-tool-invocations)
- [Caching](#caching)
- [Examples](#examples)
- [OAuth Support](#oauth-support)
- [Related Resources](#related-resources)
Expand Down Expand Up @@ -1003,6 +1004,52 @@ async fn call_tool(&self, request: CallToolRequestParams, context: RequestContex
See [`servers_task_stdio`](examples/servers/src/task_stdio.rs) and the matching
[`clients_task_stdio`](examples/clients/src/task_stdio.rs) for a runnable end-to-end example.

## Caching

`rmcp` clients transparently cache responses that carry the
[SEP-2549](https://modelcontextprotocol.io/specification/draft/server/utilities/caching)
caching hints (`ttlMs` / `cacheScope`) for `server/discover`, `tools/list`,
`prompts/list`, `resources/list`, `resources/templates/list`, and `resources/read`.

Caching is on by default but only stores a response when the server sends a
positive `ttlMs`, so servers that omit the hint behave exactly as before. Entries
expire after their TTL, are partitioned by cache scope, and are invalidated
automatically by the matching `list_changed` / `resource updated` notifications.

No call-site changes are needed — existing calls benefit automatically:

```rust, ignore
let tools = peer.list_tools(None).await?; // served from cache while fresh
let res = peer.read_resource(params).await?; // cached per-URI
```

Tune or disable it per connection via the `Peer`:

```rust, ignore
use std::time::Duration;
use rmcp::ClientCacheConfig;

// Customize behavior.
peer.set_response_cache_config(
ClientCacheConfig::default()
.with_default_ttl(Duration::from_secs(30)) // TTL for servers that omit ttlMs
.with_max_ttl(Duration::from_secs(3600)) // upper bound on any TTL
.with_max_entries(1024)
.with_private_partition(user_id) // separate private caches per principal
.with_serve_stale_on_error(false), // surface errors instead of stale data
).await;

// Or turn it off entirely.
peer.set_response_cache_config(ClientCacheConfig::disabled()).await;

// Manually flush.
peer.clear_response_cache().await;
```

> **Note:** with the default `serve_stale_on_error`, a failed re-fetch returns the
> last cached response (even if expired) as `Ok(..)` instead of an error. Set
> `with_serve_stale_on_error(false)` if callers must observe fetch failures.

## Examples

See [examples](examples/README.md).
Expand Down
4 changes: 2 additions & 2 deletions crates/rmcp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ pub use handler::server::ServerHandler;
pub use handler::server::wrapper::Json;
#[cfg(feature = "client")]
pub use service::{
ClientLifecycleMode, ClientServiceExt, RoleClient, select_protocol_version, serve_client,
serve_client_with_lifecycle,
ClientCacheConfig, ClientLifecycleMode, ClientServiceExt, MAX_CLIENT_CACHE_TTL, RoleClient,
select_protocol_version, serve_client, serve_client_with_lifecycle,
};
#[cfg(any(feature = "client", feature = "server"))]
pub use service::{Peer, Service, ServiceError, ServiceExt};
Expand Down
20 changes: 20 additions & 0 deletions crates/rmcp/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,19 @@ pub trait ServiceRole: std::fmt::Debug + Send + Sync + 'static + Copy + Clone {
fn peer_cancelled_params(_notification: &Self::PeerNot) -> Option<&CancelledNotificationParam> {
None
}
/// Invalidate any response cache affected by an inbound peer notification.
///
/// The serve loop calls this for every notification *before* subscription
/// routing, so cache invalidation still runs when a notification is
/// delivered through a `listen` subscription channel rather than the
/// [`Service::handle_notification`] callbacks.
#[doc(hidden)]
fn invalidate_response_cache(
_peer: &Peer<Self>,
_notification: &Self::PeerNot,
) -> impl Future<Output = ()> + MaybeSendFuture {
async {}
}
}

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

impl<R: Clone + ServiceRole> Clone for Peer<R>
Expand All @@ -587,6 +602,8 @@ where
client_request_metadata: self.client_request_metadata.clone(),
request_metadata_required: self.request_metadata_required.clone(),
subscription_channels: self.subscription_channels.clone(),
#[cfg(feature = "client")]
response_cache: self.response_cache.clone(),
}
}
}
Expand Down Expand Up @@ -661,6 +678,8 @@ impl<R: ServiceRole> Peer<R> {
client_request_metadata: Default::default(),
request_metadata_required: Default::default(),
subscription_channels: Default::default(),
#[cfg(feature = "client")]
response_cache: Default::default(),
},
rx,
)
Expand Down Expand Up @@ -1402,6 +1421,7 @@ where
..
})) => {
tracing::info!(?notification, "received notification");
R::invalidate_response_cache(&peer, &notification).await;
let cancellation_request_id =
if let Some(cancelled) = R::peer_cancelled_params(&notification) {
let request_id = cancelled.request_id.clone();
Expand Down
Loading