From 611dc3472c5de98ea9d642092bc7143dcce1c590 Mon Sep 17 00:00:00 2001 From: M Ismail Date: Wed, 2 Sep 2026 14:20:33 -0500 Subject: [PATCH] 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. --- crates/rmcp/src/model.rs | 34 ++++++++++++++++++-- crates/rmcp/tests/test_cache_hints.rs | 39 +++++++++++++++++++++++ crates/rmcp/tests/test_deserialization.rs | 14 ++++++++ 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 6a1409870..c59cd3069 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -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, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::::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 @@ -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, pub $i_item: $t_item, } @@ -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, /// The actual content of the resource pub contents: Vec, diff --git a/crates/rmcp/tests/test_cache_hints.rs b/crates/rmcp/tests/test_cache_hints.rs index 2b6aecd8c..da97ea64a 100644 --- a/crates/rmcp/tests/test_cache_hints.rs +++ b/crates/rmcp/tests/test_cache_hints.rs @@ -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::(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::(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!( diff --git a/crates/rmcp/tests/test_deserialization.rs b/crates/rmcp/tests/test_deserialization.rs index 857077015..f12967699 100644 --- a/crates/rmcp/tests/test_deserialization.rs +++ b/crates/rmcp/tests/test_deserialization.rs @@ -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