Skip to content

Commit 2ccc934

Browse files
fix: treat empty cacheScope as omitted on list and read results
An empty cacheScope string is invalid under SEP-2549, but some hosted servers emit it. Rejecting the whole tools/list payload currently drops every tool from an otherwise valid response. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3b5ca4d commit 2ccc934

3 files changed

Lines changed: 85 additions & 2 deletions

File tree

crates/rmcp/src/model.rs

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1572,6 +1572,28 @@ where
15721572
Ok(value.map(|ttl_ms| ttl_ms.max(0) as u64))
15731573
}
15741574

1575+
/// Normalize a `cacheScope` value during deserialization.
1576+
///
1577+
/// SEP-2549 permits `"public"` or `"private"`. Omission is also valid. Some
1578+
/// hosted servers emit an empty string; treat that exact sentinel as omitted
1579+
/// instead of failing the entire list/read result. Unknown or whitespace
1580+
/// values still error.
1581+
fn deserialize_cache_scope<'de, D>(deserializer: D) -> Result<Option<CacheScope>, D::Error>
1582+
where
1583+
D: serde::Deserializer<'de>,
1584+
{
1585+
let value = Option::<String>::deserialize(deserializer)?;
1586+
match value.as_deref() {
1587+
None | Some("") => Ok(None),
1588+
Some("public") => Ok(Some(CacheScope::Public)),
1589+
Some("private") => Ok(Some(CacheScope::Private)),
1590+
Some(other) => Err(serde::de::Error::unknown_variant(
1591+
other,
1592+
&["public", "private"],
1593+
)),
1594+
}
1595+
}
1596+
15751597
macro_rules! paginated_result {
15761598
($t:ident {
15771599
$i_item: ident: $t_item: ty
@@ -1609,7 +1631,11 @@ macro_rules! paginated_result {
16091631
/// Scope describing who may cache this result (SEP-2549).
16101632
/// Required by spec version 2026-07-28, but optional here to maintain compatibility
16111633
/// with older spec versions.
1612-
#[serde(default, skip_serializing_if = "Option::is_none")]
1634+
#[serde(
1635+
default,
1636+
deserialize_with = "deserialize_cache_scope",
1637+
skip_serializing_if = "Option::is_none"
1638+
)]
16131639
pub cache_scope: Option<CacheScope>,
16141640
pub $i_item: $t_item,
16151641
}
@@ -1759,7 +1785,11 @@ pub struct ReadResourceResult {
17591785
/// Scope describing who may cache this result (SEP-2549).
17601786
/// Required by spec version 2026-07-28, but optional here to maintain compatibility
17611787
/// with older spec versions.
1762-
#[serde(default, skip_serializing_if = "Option::is_none")]
1788+
#[serde(
1789+
default,
1790+
deserialize_with = "deserialize_cache_scope",
1791+
skip_serializing_if = "Option::is_none"
1792+
)]
17631793
pub cache_scope: Option<CacheScope>,
17641794
/// The actual content of the resource
17651795
pub contents: Vec<ResourceContents>,

crates/rmcp/tests/test_cache_hints.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,45 @@ fn cache_hints_default_to_none_and_negative_ttl_is_normalized_to_zero() {
6262
assert_eq!(negative.cache_scope, Some(CacheScope::Private));
6363
}
6464

65+
#[test]
66+
fn empty_cache_scope_is_treated_as_omitted() {
67+
let result: ListToolsResult = serde_json::from_value(json!({
68+
"tools": [{ "name": "search", "inputSchema": { "type": "object" } }],
69+
"ttlMs": 0,
70+
"cacheScope": ""
71+
}))
72+
.expect("empty cacheScope should deserialize as omitted");
73+
74+
assert_eq!(result.ttl_ms, Some(0));
75+
assert_eq!(result.cache_scope, None);
76+
assert_eq!(result.tools.len(), 1);
77+
assert_eq!(result.tools[0].name.as_ref(), "search");
78+
79+
let resources: ReadResourceResult = serde_json::from_value(json!({
80+
"contents": [],
81+
"cacheScope": ""
82+
}))
83+
.expect("empty cacheScope should deserialize as omitted on read results");
84+
assert_eq!(resources.cache_scope, None);
85+
}
86+
87+
#[test]
88+
fn unknown_cache_scope_still_errors() {
89+
let err = serde_json::from_value::<ListToolsResult>(json!({
90+
"tools": [],
91+
"cacheScope": "shared"
92+
}))
93+
.expect_err("unknown cacheScope values must still fail");
94+
assert!(err.to_string().contains("shared"), "{err}");
95+
96+
let err = serde_json::from_value::<ListToolsResult>(json!({
97+
"tools": [],
98+
"cacheScope": " "
99+
}))
100+
.expect_err("whitespace cacheScope values must still fail");
101+
assert!(err.to_string().contains("unknown variant"), "{err}");
102+
}
103+
65104
#[test]
66105
fn cache_scope_round_trips() {
67106
assert_eq!(

crates/rmcp/tests/test_deserialization.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,20 @@ mod untagged_server_result {
154154
);
155155
}
156156

157+
#[test]
158+
fn empty_cache_scope_list_tools_result_does_not_fall_through() {
159+
let result = parse_result(wrap_response(json!({
160+
"tools": [{ "name": "search", "inputSchema": { "type": "object" } }],
161+
"ttlMs": 0,
162+
"cacheScope": ""
163+
})));
164+
let ServerResult::ListToolsResult(result) = result else {
165+
panic!("expected ListToolsResult, got {result:?}");
166+
};
167+
assert_eq!(result.cache_scope, None);
168+
assert_eq!(result.tools.len(), 1);
169+
}
170+
157171
#[test]
158172
fn unknown_shape_falls_through_to_custom_result() {
159173
// A value that doesn't match any known result type should land in

0 commit comments

Comments
 (0)