Skip to content
Open
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
34 changes: 32 additions & 2 deletions crates/rmcp/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1572,6 +1572,28 @@ where
Ok(value.map(|ttl_ms| ttl_ms.max(0) as u64))
}

/// Normalize a `cacheScope` value during deserialization.
///
/// SEP-2549 permits `"public"` or `"private"`. Omission is also valid. Some
/// hosted servers emit an empty string; treat that exact sentinel as omitted
/// instead of failing the entire list/read result. Unknown or whitespace
/// values still error.
fn deserialize_cache_scope<'de, D>(deserializer: D) -> Result<Option<CacheScope>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<String>::deserialize(deserializer)?;
match value.as_deref() {
None | Some("") => Ok(None),
Some("public") => Ok(Some(CacheScope::Public)),
Some("private") => Ok(Some(CacheScope::Private)),
Some(other) => Err(serde::de::Error::unknown_variant(
other,
&["public", "private"],
)),
}
}

macro_rules! paginated_result {
($t:ident {
$i_item: ident: $t_item: ty
Expand Down Expand Up @@ -1609,7 +1631,11 @@ macro_rules! paginated_result {
/// 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")]
#[serde(
default,
deserialize_with = "deserialize_cache_scope",
skip_serializing_if = "Option::is_none"
)]
pub cache_scope: Option<CacheScope>,
pub $i_item: $t_item,
}
Expand Down Expand Up @@ -1759,7 +1785,11 @@ pub struct ReadResourceResult {
/// 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")]
#[serde(
default,
deserialize_with = "deserialize_cache_scope",
skip_serializing_if = "Option::is_none"
)]
pub cache_scope: Option<CacheScope>,
/// The actual content of the resource
pub contents: Vec<ResourceContents>,
Expand Down
39 changes: 39 additions & 0 deletions crates/rmcp/tests/test_cache_hints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,45 @@ fn cache_hints_default_to_none_and_negative_ttl_is_normalized_to_zero() {
assert_eq!(negative.cache_scope, Some(CacheScope::Private));
}

#[test]
fn empty_cache_scope_is_treated_as_omitted() {
let result: ListToolsResult = serde_json::from_value(json!({
"tools": [{ "name": "search", "inputSchema": { "type": "object" } }],
"ttlMs": 0,
"cacheScope": ""
}))
.expect("empty cacheScope should deserialize as omitted");

assert_eq!(result.ttl_ms, Some(0));
assert_eq!(result.cache_scope, None);
assert_eq!(result.tools.len(), 1);
assert_eq!(result.tools[0].name.as_ref(), "search");

let resources: ReadResourceResult = serde_json::from_value(json!({
"contents": [],
"cacheScope": ""
}))
.expect("empty cacheScope should deserialize as omitted on read results");
assert_eq!(resources.cache_scope, None);
}

#[test]
fn unknown_cache_scope_still_errors() {
let err = serde_json::from_value::<ListToolsResult>(json!({
"tools": [],
"cacheScope": "shared"
}))
.expect_err("unknown cacheScope values must still fail");
assert!(err.to_string().contains("shared"), "{err}");

let err = serde_json::from_value::<ListToolsResult>(json!({
"tools": [],
"cacheScope": " "
}))
.expect_err("whitespace cacheScope values must still fail");
assert!(err.to_string().contains("unknown variant"), "{err}");
}

#[test]
fn cache_scope_round_trips() {
assert_eq!(
Expand Down
14 changes: 14 additions & 0 deletions crates/rmcp/tests/test_deserialization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,20 @@ mod untagged_server_result {
);
}

#[test]
fn empty_cache_scope_list_tools_result_does_not_fall_through() {
let result = parse_result(wrap_response(json!({
"tools": [{ "name": "search", "inputSchema": { "type": "object" } }],
"ttlMs": 0,
"cacheScope": ""
})));
let ServerResult::ListToolsResult(result) = result else {
panic!("expected ListToolsResult, got {result:?}");
};
assert_eq!(result.cache_scope, None);
assert_eq!(result.tools.len(), 1);
}

#[test]
fn unknown_shape_falls_through_to_custom_result() {
// A value that doesn't match any known result type should land in
Expand Down