feat!: implement SEP-2549 cache hints (#889)

implements https://github.com/modelcontextprotocol/rust-sdk/issues/875

Co-authored-by: Jack Amadeo <jackamadeo@squareup.com>
This commit is contained in:
Michael Neale 2026-07-10 10:54:18 -07:00 • committed by GitHub
commit d8331d9442
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1246,6 +1246,34 @@ pub type ProgressNotification = Notification<ProgressNotificationMethod, Progres
pub type Cursor = String;
/// Scope describing who may cache cacheable list/read results (SEP-2549).
///
/// Defaults to [`CacheScope::Public`] when absent from the wire.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum CacheScope {
/// Any client or intermediary may cache and serve the response to any user.
#[default]
Public,
/// Only the requesting user's client may cache the response.
Private,
}
/// Normalize a `ttlMs` value during deserialization.
///
/// Per SEP-2549, `ttlMs` MUST be `>= 0`; if a server returns a negative value,
/// clients SHOULD treat it as `0` (immediately stale). This tolerates that case
/// rather than erroring, while still accepting an absent field as `None`.
fn deserialize_ttl_ms<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<i64>::deserialize(deserializer)?;
Ok(value.map(|ttl_ms| ttl_ms.max(0) as u64))
}
macro_rules! paginated_result {
($t:ident {
$i_item: ident: $t_item: ty
@ -1258,24 +1286,50 @@ macro_rules! paginated_result {
/// Result type discriminator. Absent values deserialize as `"complete"`.
#[serde(default)]
pub result_type: ResultType,
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
pub meta: Option<Meta>,
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<Cursor>,
/// Time, in milliseconds, that this result may be treated as fresh (SEP-2549).
/// Required by spec version 2026-07-28, but optional here to maintain compatibility
/// with older spec versions.
#[serde(
default,
deserialize_with = "deserialize_ttl_ms",
skip_serializing_if = "Option::is_none"
)]
pub ttl_ms: Option<u64>,
/// Scope describing who may cache this result (SEP-2549).
/// Required by spec version 2026-07-28, but optional here to maintain compatibility
/// with older spec versions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_scope: Option<CacheScope>,
pub $i_item: $t_item,
}
impl $t {
pub fn with_all_items(
items: $t_item,
) -> Self {
pub fn with_all_items(items: $t_item) -> Self {
Self {
result_type: ResultType::default(),
meta: None,
next_cursor: None,
ttl_ms: None,
cache_scope: None,
$i_item: items,
}
}
/// Set the time, in milliseconds, that this result may be treated as fresh.
pub fn with_ttl_ms(mut self, ttl_ms: u64) -> Self {
self.ttl_ms = Some(ttl_ms);
self
}
/// Set the cache scope for this result.
pub fn with_cache_scope(mut self, cache_scope: CacheScope) -> Self {
self.cache_scope = Some(cache_scope);
self
}
}
};
}
@ -1368,12 +1422,27 @@ pub type ReadResourceRequestParam = ReadResourceRequestParams;
/// Result containing the contents of a read resource
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct ReadResourceResult {
/// Result type discriminator. Absent values deserialize as `"complete"`.
#[serde(default)]
pub result_type: ResultType,
/// Time, in milliseconds, that this result may be treated as fresh (SEP-2549).
/// Required by spec version 2026-07-28, but optional here to maintain compatibility
/// with older spec versions.
#[serde(
default,
deserialize_with = "deserialize_ttl_ms",
skip_serializing_if = "Option::is_none"
)]
pub ttl_ms: Option<u64>,
/// Scope describing who may cache this result (SEP-2549).
/// Required by spec version 2026-07-28, but optional here to maintain compatibility
/// with older spec versions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_scope: Option<CacheScope>,
/// The actual content of the resource
pub contents: Vec<ResourceContents>,
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
@ -1385,10 +1454,24 @@ impl ReadResourceResult {
pub fn new(contents: Vec<ResourceContents>) -> Self {
Self {
result_type: ResultType::default(),
ttl_ms: None,
cache_scope: None,
contents,
meta: None,
}
}
/// Set the time, in milliseconds, that this result may be treated as fresh.
pub fn with_ttl_ms(mut self, ttl_ms: u64) -> Self {
self.ttl_ms = Some(ttl_ms);
self
}
/// Set the cache scope for this result.
pub fn with_cache_scope(mut self, cache_scope: CacheScope) -> Self {
self.cache_scope = Some(cache_scope);
self
}
}
/// Request to read a specific resource