Skip to content

Commit d8331d9

Browse files
feat!: implement SEP-2549 cache hints (#889)
implements #875 Co-authored-by: Jack Amadeo <jackamadeo@squareup.com>
1 parent 2d4c29f commit d8331d9

6 files changed

Lines changed: 403 additions & 7 deletions

File tree

crates/rmcp-macros/src/prompt_handler.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ pub fn prompt_handler(attr: TokenStream, input: TokenStream) -> syn::Result<Toke
6565
prompts,
6666
meta: #meta,
6767
next_cursor: None,
68+
ttl_ms: None,
69+
cache_scope: None,
6870
})
6971
}
7072
};

crates/rmcp-macros/src/tool_handler.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ pub fn tool_handler(attr: TokenStream, input: TokenStream) -> syn::Result<TokenS
7373
tools: #router.list_all(),
7474
meta: #result_meta,
7575
next_cursor: None,
76+
ttl_ms: None,
77+
cache_scope: None,
7678
})
7779
}
7880
})?;

crates/rmcp/src/model.rs

Lines changed: 88 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1246,6 +1246,34 @@ pub type ProgressNotification = Notification<ProgressNotificationMethod, Progres
12461246

12471247
pub type Cursor = String;
12481248

1249+
/// Scope describing who may cache cacheable list/read results (SEP-2549).
1250+
///
1251+
/// Defaults to [`CacheScope::Public`] when absent from the wire.
1252+
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1253+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
1254+
#[serde(rename_all = "lowercase")]
1255+
#[non_exhaustive]
1256+
pub enum CacheScope {
1257+
/// Any client or intermediary may cache and serve the response to any user.
1258+
#[default]
1259+
Public,
1260+
/// Only the requesting user's client may cache the response.
1261+
Private,
1262+
}
1263+
1264+
/// Normalize a `ttlMs` value during deserialization.
1265+
///
1266+
/// Per SEP-2549, `ttlMs` MUST be `>= 0`; if a server returns a negative value,
1267+
/// clients SHOULD treat it as `0` (immediately stale). This tolerates that case
1268+
/// rather than erroring, while still accepting an absent field as `None`.
1269+
fn deserialize_ttl_ms<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
1270+
where
1271+
D: serde::Deserializer<'de>,
1272+
{
1273+
let value = Option::<i64>::deserialize(deserializer)?;
1274+
Ok(value.map(|ttl_ms| ttl_ms.max(0) as u64))
1275+
}
1276+
12491277
macro_rules! paginated_result {
12501278
($t:ident {
12511279
$i_item: ident: $t_item: ty
@@ -1258,24 +1286,50 @@ macro_rules! paginated_result {
12581286
/// Result type discriminator. Absent values deserialize as `"complete"`.
12591287
#[serde(default)]
12601288
pub result_type: ResultType,
1261-
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
1289+
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
12621290
pub meta: Option<Meta>,
1263-
#[serde(skip_serializing_if = "Option::is_none")]
1291+
#[serde(default, skip_serializing_if = "Option::is_none")]
12641292
pub next_cursor: Option<Cursor>,
1293+
/// Time, in milliseconds, that this result may be treated as fresh (SEP-2549).
1294+
/// Required by spec version 2026-07-28, but optional here to maintain compatibility
1295+
/// with older spec versions.
1296+
#[serde(
1297+
default,
1298+
deserialize_with = "deserialize_ttl_ms",
1299+
skip_serializing_if = "Option::is_none"
1300+
)]
1301+
pub ttl_ms: Option<u64>,
1302+
/// Scope describing who may cache this result (SEP-2549).
1303+
/// Required by spec version 2026-07-28, but optional here to maintain compatibility
1304+
/// with older spec versions.
1305+
#[serde(default, skip_serializing_if = "Option::is_none")]
1306+
pub cache_scope: Option<CacheScope>,
12651307
pub $i_item: $t_item,
12661308
}
12671309

12681310
impl $t {
1269-
pub fn with_all_items(
1270-
items: $t_item,
1271-
) -> Self {
1311+
pub fn with_all_items(items: $t_item) -> Self {
12721312
Self {
12731313
result_type: ResultType::default(),
12741314
meta: None,
12751315
next_cursor: None,
1316+
ttl_ms: None,
1317+
cache_scope: None,
12761318
$i_item: items,
12771319
}
12781320
}
1321+
1322+
/// Set the time, in milliseconds, that this result may be treated as fresh.
1323+
pub fn with_ttl_ms(mut self, ttl_ms: u64) -> Self {
1324+
self.ttl_ms = Some(ttl_ms);
1325+
self
1326+
}
1327+
1328+
/// Set the cache scope for this result.
1329+
pub fn with_cache_scope(mut self, cache_scope: CacheScope) -> Self {
1330+
self.cache_scope = Some(cache_scope);
1331+
self
1332+
}
12791333
}
12801334
};
12811335
}
@@ -1368,12 +1422,27 @@ pub type ReadResourceRequestParam = ReadResourceRequestParams;
13681422

13691423
/// Result containing the contents of a read resource
13701424
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
1425+
#[serde(rename_all = "camelCase")]
13711426
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
13721427
#[non_exhaustive]
13731428
pub struct ReadResourceResult {
13741429
/// Result type discriminator. Absent values deserialize as `"complete"`.
13751430
#[serde(default)]
13761431
pub result_type: ResultType,
1432+
/// Time, in milliseconds, that this result may be treated as fresh (SEP-2549).
1433+
/// Required by spec version 2026-07-28, but optional here to maintain compatibility
1434+
/// with older spec versions.
1435+
#[serde(
1436+
default,
1437+
deserialize_with = "deserialize_ttl_ms",
1438+
skip_serializing_if = "Option::is_none"
1439+
)]
1440+
pub ttl_ms: Option<u64>,
1441+
/// Scope describing who may cache this result (SEP-2549).
1442+
/// Required by spec version 2026-07-28, but optional here to maintain compatibility
1443+
/// with older spec versions.
1444+
#[serde(default, skip_serializing_if = "Option::is_none")]
1445+
pub cache_scope: Option<CacheScope>,
13771446
/// The actual content of the resource
13781447
pub contents: Vec<ResourceContents>,
13791448
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
@@ -1385,10 +1454,24 @@ impl ReadResourceResult {
13851454
pub fn new(contents: Vec<ResourceContents>) -> Self {
13861455
Self {
13871456
result_type: ResultType::default(),
1457+
ttl_ms: None,
1458+
cache_scope: None,
13881459
contents,
13891460
meta: None,
13901461
}
13911462
}
1463+
1464+
/// Set the time, in milliseconds, that this result may be treated as fresh.
1465+
pub fn with_ttl_ms(mut self, ttl_ms: u64) -> Self {
1466+
self.ttl_ms = Some(ttl_ms);
1467+
self
1468+
}
1469+
1470+
/// Set the cache scope for this result.
1471+
pub fn with_cache_scope(mut self, cache_scope: CacheScope) -> Self {
1472+
self.cache_scope = Some(cache_scope);
1473+
self
1474+
}
13921475
}
13931476

13941477
/// Request to read a specific resource
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
use rmcp::model::{CacheScope, ListToolsResult, ReadResourceResult, ResourceContents};
2+
use serde_json::json;
3+
4+
#[test]
5+
fn paginated_results_serialize_cache_hints_as_top_level_fields() {
6+
let result = ListToolsResult::with_all_items(Vec::new())
7+
.with_ttl_ms(5_000)
8+
.with_cache_scope(CacheScope::Private);
9+
10+
let actual = serde_json::to_value(result).expect("serialize list tools result");
11+
12+
assert_eq!(
13+
actual,
14+
json!({
15+
"ttlMs": 5000,
16+
"cacheScope": "private",
17+
"tools": [],
18+
"resultType": "complete"
19+
})
20+
);
21+
assert!(actual.get("_meta").is_none());
22+
}
23+
24+
#[test]
25+
fn read_resource_results_serialize_cache_hints_as_top_level_fields() {
26+
let result =
27+
ReadResourceResult::new(vec![ResourceContents::text("hello", "file:///example.txt")])
28+
.with_ttl_ms(10_000)
29+
.with_cache_scope(CacheScope::Public);
30+
31+
let actual = serde_json::to_value(result).expect("serialize read resource result");
32+
33+
assert_eq!(actual["ttlMs"], 10000);
34+
assert_eq!(actual["cacheScope"], "public");
35+
assert!(actual["contents"][0].get("_meta").is_none());
36+
}
37+
38+
#[test]
39+
fn cache_hints_are_omitted_when_absent() {
40+
let result = ListToolsResult::with_all_items(Vec::new());
41+
let actual = serde_json::to_value(result).expect("serialize list tools result");
42+
43+
assert_eq!(actual, json!({ "tools": [], "resultType": "complete" }));
44+
}
45+
46+
#[test]
47+
fn cache_hints_default_to_none_and_negative_ttl_is_normalized_to_zero() {
48+
let absent: ListToolsResult = serde_json::from_value(json!({
49+
"tools": []
50+
}))
51+
.expect("deserialize result without ttlMs");
52+
assert_eq!(absent.ttl_ms, None);
53+
assert_eq!(absent.cache_scope, None);
54+
55+
let negative: ReadResourceResult = serde_json::from_value(json!({
56+
"ttlMs": -42,
57+
"cacheScope": "private",
58+
"contents": []
59+
}))
60+
.expect("deserialize result with negative ttlMs");
61+
assert_eq!(negative.ttl_ms, Some(0));
62+
assert_eq!(negative.cache_scope, Some(CacheScope::Private));
63+
}
64+
65+
#[test]
66+
fn cache_scope_round_trips() {
67+
assert_eq!(
68+
serde_json::to_value(CacheScope::Public).unwrap(),
69+
json!("public")
70+
);
71+
assert_eq!(
72+
serde_json::to_value(CacheScope::Private).unwrap(),
73+
json!("private")
74+
);
75+
assert_eq!(
76+
serde_json::from_value::<CacheScope>(json!("private")).unwrap(),
77+
CacheScope::Private
78+
);
79+
}

0 commit comments

Comments
 (0)