From f8edae7ae8cfde0a3659cc98d6c7f37fa8e77c0c Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Fri, 5 Jun 2026 19:39:41 +1000 Subject: [PATCH 1/4] feat: implement SEP-2549 cache hints --- crates/rmcp/src/model.rs | 69 +++++++++++++++++++++++++-- crates/rmcp/tests/test_cache_hints.rs | 35 ++++++++++++++ 2 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 crates/rmcp/tests/test_cache_hints.rs diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 4aabab1d0..a263618e5 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -1142,6 +1142,18 @@ pub type ProgressNotification = Notification Self { + pub fn with_all_items(items: $t_item) -> Self { Self { meta: None, next_cursor: 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 { + let meta = self.meta.get_or_insert_with(Meta::new); + meta.insert("ttlMs".to_string(), Value::Number(ttl_ms.into())); + self + } + + /// Set the cache scope for this result. + pub fn with_cache_scope(mut self, cache_scope: CacheScope) -> Self { + let meta = self.meta.get_or_insert_with(Meta::new); + let cache_scope = match cache_scope { + CacheScope::User => "user", + CacheScope::Shared => "shared", + }; + meta.insert( + "cacheScope".to_string(), + Value::String(cache_scope.to_string()), + ); + self + } } }; } @@ -1239,6 +1270,7 @@ 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 { @@ -1251,6 +1283,37 @@ impl ReadResourceResult { pub fn new(contents: Vec) -> Self { Self { contents } } + + /// 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.contents.iter_mut().for_each(|content| match content { + ResourceContents::TextResourceContents { meta, .. } + | ResourceContents::BlobResourceContents { meta, .. } => { + let meta = meta.get_or_insert_with(Meta::new); + meta.insert("ttlMs".to_string(), Value::Number(ttl_ms.into())); + } + }); + self + } + + /// Set the cache scope for this result. + pub fn with_cache_scope(mut self, cache_scope: CacheScope) -> Self { + let cache_scope = match cache_scope { + CacheScope::User => "user", + CacheScope::Shared => "shared", + }; + self.contents.iter_mut().for_each(|content| match content { + ResourceContents::TextResourceContents { meta, .. } + | ResourceContents::BlobResourceContents { meta, .. } => { + let meta = meta.get_or_insert_with(Meta::new); + meta.insert( + "cacheScope".to_string(), + Value::String(cache_scope.to_string()), + ); + } + }); + self + } } /// Request to read a specific resource diff --git a/crates/rmcp/tests/test_cache_hints.rs b/crates/rmcp/tests/test_cache_hints.rs new file mode 100644 index 000000000..a18fe553b --- /dev/null +++ b/crates/rmcp/tests/test_cache_hints.rs @@ -0,0 +1,35 @@ +use rmcp::model::{CacheScope, ListToolsResult, ReadResourceResult, ResourceContents}; +use serde_json::json; + +#[test] +fn paginated_results_serialize_cache_hints_in_meta() { + let result = ListToolsResult::with_all_items(Vec::new()) + .with_ttl_ms(5_000) + .with_cache_scope(CacheScope::User); + + let actual = serde_json::to_value(result).expect("serialize list tools result"); + + assert_eq!( + actual, + json!({ + "_meta": { + "ttlMs": 5000, + "cacheScope": "user" + }, + "tools": [] + }) + ); +} + +#[test] +fn read_resource_results_serialize_cache_hints_in_content_meta() { + let result = + ReadResourceResult::new(vec![ResourceContents::text("hello", "file:///example.txt")]) + .with_ttl_ms(10_000) + .with_cache_scope(CacheScope::Shared); + + let actual = serde_json::to_value(result).expect("serialize read resource result"); + + assert_eq!(actual["contents"][0]["_meta"]["ttlMs"], 10000); + assert_eq!(actual["contents"][0]["_meta"]["cacheScope"], "shared"); +} From c012157c0345ad3684e83c0a6e7a855e4daf11fe Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Sat, 6 Jun 2026 16:41:38 +1000 Subject: [PATCH 2/4] fix: move cache hints to result fields --- crates/rmcp/src/model.rs | 426 ++++++++++++++++-- crates/rmcp/tests/test_cache_hints.rs | 39 +- .../list_tools_result.json | 2 + .../server_json_rpc_message_schema.json | 108 ++++- ...erver_json_rpc_message_schema_current.json | 108 ++++- 5 files changed, 619 insertions(+), 64 deletions(-) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index a263618e5..db491a5da 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -1143,33 +1143,318 @@ pub type ProgressNotification = Notification(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + CacheScope::Public | CacheScope::Shared => "public", + CacheScope::Private | CacheScope::User => "private", + } + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for CacheScope { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + match String::deserialize(deserializer)?.as_str() { + "public" => Ok(CacheScope::Public), + "private" => Ok(CacheScope::Private), + // Accept the earlier draft values for read-side compatibility. + "shared" => Ok(CacheScope::Public), + "user" => Ok(CacheScope::Private), + other => Err(serde::de::Error::unknown_variant( + other, + &["public", "private"], + )), + } + } +} + +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for CacheScope { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("CacheScope") + } + + fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { + serde_json::json!({ + "description": "Scope describing who may cache cacheable list/read results.", + "enum": ["private", "public"], + "type": "string" + }) + .as_object() + .expect("schema is an object") + .clone() + .into() + } +} + +fn deserialize_ttl_ms<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let value = Value::deserialize(deserializer)?; + ttl_ms_from_value(&value).map_err(serde::de::Error::custom) +} + +fn ttl_ms_from_value(value: &Value) -> Result { + match value { + Value::Number(number) => { + if let Some(value) = number.as_u64() { + Ok(value) + } else if let Some(value) = number.as_i64() { + Ok(value.max(0) as u64) + } else { + Err("ttlMs must be an integer") + } + } + _ => Err("ttlMs must be an integer"), + } +} + +const TTL_MS_FIELD: &str = "ttlMs"; +const CACHE_SCOPE_FIELD: &str = "cacheScope"; + +fn ttl_ms_from_meta(meta: Option<&Meta>) -> u64 { + meta.and_then(|meta| meta.get(TTL_MS_FIELD)) + .and_then(|value| ttl_ms_from_value(value).ok()) + .unwrap_or_default() +} + +fn cache_scope_from_meta(meta: Option<&Meta>) -> CacheScope { + meta.and_then(|meta| meta.get(CACHE_SCOPE_FIELD)) + .and_then(|value| serde_json::from_value(value.clone()).ok()) + .unwrap_or_default() +} + +fn set_meta_cache_hint(meta: &mut Option, key: &str, value: Option) { + if let Some(value) = value { + meta.get_or_insert_with(Meta::new) + .insert(key.to_string(), value); + } else if let Some(existing_meta) = meta.as_mut() { + existing_meta.remove(key); + if existing_meta.is_empty() { + *meta = None; + } + } +} + +fn set_meta_ttl_ms(meta: &mut Option, ttl_ms: u64) { + set_meta_cache_hint( + meta, + TTL_MS_FIELD, + (ttl_ms != 0).then(|| Value::Number(ttl_ms.into())), + ); +} + +fn set_meta_cache_scope(meta: &mut Option, cache_scope: CacheScope) { + set_meta_cache_hint( + meta, + CACHE_SCOPE_FIELD, + (cache_scope != CacheScope::default()).then(|| { + serde_json::to_value(cache_scope).expect("CacheScope serializes to a valid JSON value") + }), + ); +} + +fn meta_without_cache_hints(meta: Option<&Meta>) -> Option { + let mut meta = meta.cloned()?; + meta.remove(TTL_MS_FIELD); + meta.remove(CACHE_SCOPE_FIELD); + (!meta.is_empty()).then_some(meta) +} + +fn to_camel_case(field: &str) -> String { + let mut output = String::new(); + let mut uppercase_next = false; + for ch in field.chars() { + if ch == '_' { + uppercase_next = true; + } else if uppercase_next { + output.extend(ch.to_uppercase()); + uppercase_next = false; + } else { + output.push(ch); + } + } + output +} + macro_rules! paginated_result { ($t:ident { $i_item: ident: $t_item: ty }) => { - #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] - #[serde(rename_all = "camelCase")] - #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] + #[derive(Debug, Clone, PartialEq, Default)] #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct $t { - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] pub meta: Option, - #[serde(skip_serializing_if = "Option::is_none")] pub next_cursor: Option, pub $i_item: $t_item, } + impl Serialize for $t { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeMap; + + let meta = meta_without_cache_hints(self.meta.as_ref()); + let mut len = 3; + if meta.is_some() { + len += 1; + } + if self.next_cursor.is_some() { + len += 1; + } + + let mut map = serializer.serialize_map(Some(len))?; + if let Some(meta) = meta.as_ref() { + map.serialize_entry("_meta", meta)?; + } + if let Some(next_cursor) = self.next_cursor.as_ref() { + map.serialize_entry("nextCursor", next_cursor)?; + } + map.serialize_entry(TTL_MS_FIELD, &self.ttl_ms())?; + map.serialize_entry(CACHE_SCOPE_FIELD, &self.cache_scope())?; + map.serialize_entry(&to_camel_case(stringify!($i_item)), &self.$i_item)?; + map.end() + } + } + + impl<'de> Deserialize<'de> for $t { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let mut value = Value::deserialize(deserializer)?; + let object = value + .as_object_mut() + .ok_or_else(|| serde::de::Error::custom("expected an object"))?; + + let mut meta: Option = object + .remove("_meta") + .map(serde_json::from_value) + .transpose() + .map_err(serde::de::Error::custom)?; + if let Some(existing_meta) = meta.as_mut() { + existing_meta.remove(TTL_MS_FIELD); + existing_meta.remove(CACHE_SCOPE_FIELD); + if existing_meta.is_empty() { + meta = None; + } + } + + let next_cursor = object + .remove("nextCursor") + .map(serde_json::from_value) + .transpose() + .map_err(serde::de::Error::custom)?; + let ttl_ms = object + .remove(TTL_MS_FIELD) + .map(|value| ttl_ms_from_value(&value).map_err(serde::de::Error::custom)) + .transpose()? + .unwrap_or_default(); + let cache_scope = object + .remove(CACHE_SCOPE_FIELD) + .map(serde_json::from_value) + .transpose() + .map_err(serde::de::Error::custom)? + .unwrap_or_default(); + let item_field = to_camel_case(stringify!($i_item)); + let items = object + .remove(&item_field) + .ok_or_else(|| serde::de::Error::custom(format!("missing field `{item_field}`"))) + .and_then(|value| { + serde_json::from_value(value).map_err(serde::de::Error::custom) + })?; + + let mut result = Self { + meta, + next_cursor, + $i_item: items, + }; + result.set_ttl_ms(ttl_ms); + result.set_cache_scope(cache_scope); + Ok(result) + } + } + + #[cfg(feature = "schemars")] + impl schemars::JsonSchema for $t { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed(stringify!($t)) + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + use serde_json::{Map, json}; + + let item_field = to_camel_case(stringify!($i_item)); + let mut properties = Map::new(); + properties.insert( + "_meta".to_string(), + serde_json::to_value(generator.subschema_for::>()) + .expect("schema serializes to JSON"), + ); + properties.insert( + "nextCursor".to_string(), + serde_json::to_value(generator.subschema_for::>()) + .expect("schema serializes to JSON"), + ); + properties.insert( + TTL_MS_FIELD.to_string(), + json!({ + "description": "Time, in milliseconds, that this result may be treated as fresh.", + "type": "integer", + "format": "uint64", + "default": 0, + "minimum": 0 + }), + ); + properties.insert( + CACHE_SCOPE_FIELD.to_string(), + json!({ + "description": "Scope describing who may cache this result.", + "allOf": [generator.subschema_for::()], + "default": "public" + }), + ); + properties.insert( + item_field.clone(), + serde_json::to_value(generator.subschema_for::<$t_item>()) + .expect("schema serializes to JSON"), + ); + + let mut schema = Map::new(); + schema.insert("type".to_string(), json!("object")); + schema.insert("properties".to_string(), Value::Object(properties)); + schema.insert( + "required".to_string(), + json!([CACHE_SCOPE_FIELD, item_field, TTL_MS_FIELD]), + ); + schemars::Schema::from(schema) + } + } + impl $t { pub fn with_all_items(items: $t_item) -> Self { Self { @@ -1179,24 +1464,35 @@ macro_rules! paginated_result { } } + /// Return the time, in milliseconds, that this result may be treated as fresh. + pub fn ttl_ms(&self) -> u64 { + ttl_ms_from_meta(self.meta.as_ref()) + } + + /// Set the time, in milliseconds, that this result may be treated as fresh. + pub fn set_ttl_ms(&mut self, ttl_ms: u64) { + set_meta_ttl_ms(&mut self.meta, ttl_ms); + } + /// Set the time, in milliseconds, that this result may be treated as fresh. pub fn with_ttl_ms(mut self, ttl_ms: u64) -> Self { - let meta = self.meta.get_or_insert_with(Meta::new); - meta.insert("ttlMs".to_string(), Value::Number(ttl_ms.into())); + self.set_ttl_ms(ttl_ms); self } + /// Return the cache scope for this result. + pub fn cache_scope(&self) -> CacheScope { + cache_scope_from_meta(self.meta.as_ref()) + } + + /// Set the cache scope for this result. + pub fn set_cache_scope(&mut self, cache_scope: CacheScope) { + set_meta_cache_scope(&mut self.meta, cache_scope); + } + /// Set the cache scope for this result. pub fn with_cache_scope(mut self, cache_scope: CacheScope) -> Self { - let meta = self.meta.get_or_insert_with(Meta::new); - let cache_scope = match cache_scope { - CacheScope::User => "user", - CacheScope::Shared => "shared", - }; - meta.insert( - "cacheScope".to_string(), - Value::String(cache_scope.to_string()), - ); + self.set_cache_scope(cache_scope); self } } @@ -1271,47 +1567,89 @@ 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 { + /// Time, in milliseconds, that this result may be treated as fresh. + #[serde(default, deserialize_with = "deserialize_ttl_ms")] + pub ttl_ms: u64, + /// Scope describing who may cache this result. + #[serde(default)] + pub cache_scope: CacheScope, /// The actual content of the resource pub contents: Vec, } +#[cfg(feature = "schemars")] +impl schemars::JsonSchema for ReadResourceResult { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("ReadResourceResult") + } + + fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + use serde_json::{Map, json}; + + let mut properties = Map::new(); + properties.insert( + TTL_MS_FIELD.to_string(), + json!({ + "description": "Time, in milliseconds, that this result may be treated as fresh.", + "type": "integer", + "format": "uint64", + "default": 0, + "minimum": 0 + }), + ); + properties.insert( + CACHE_SCOPE_FIELD.to_string(), + json!({ + "description": "Scope describing who may cache this result.", + "allOf": [generator.subschema_for::()], + "default": "public" + }), + ); + properties.insert( + "contents".to_string(), + serde_json::json!({ + "description": "The actual content of the resource", + "type": "array", + "items": generator.subschema_for::() + }), + ); + + let mut schema = Map::new(); + schema.insert( + "description".to_string(), + json!("Result containing the contents of a read resource"), + ); + schema.insert("type".to_string(), json!("object")); + schema.insert("properties".to_string(), Value::Object(properties)); + schema.insert( + "required".to_string(), + json!([CACHE_SCOPE_FIELD, "contents", TTL_MS_FIELD]), + ); + schemars::Schema::from(schema) + } +} + impl ReadResourceResult { /// Create a new ReadResourceResult with the given contents. pub fn new(contents: Vec) -> Self { - Self { contents } + Self { + ttl_ms: 0, + cache_scope: CacheScope::default(), + contents, + } } /// 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.contents.iter_mut().for_each(|content| match content { - ResourceContents::TextResourceContents { meta, .. } - | ResourceContents::BlobResourceContents { meta, .. } => { - let meta = meta.get_or_insert_with(Meta::new); - meta.insert("ttlMs".to_string(), Value::Number(ttl_ms.into())); - } - }); + self.ttl_ms = ttl_ms; self } /// Set the cache scope for this result. pub fn with_cache_scope(mut self, cache_scope: CacheScope) -> Self { - let cache_scope = match cache_scope { - CacheScope::User => "user", - CacheScope::Shared => "shared", - }; - self.contents.iter_mut().for_each(|content| match content { - ResourceContents::TextResourceContents { meta, .. } - | ResourceContents::BlobResourceContents { meta, .. } => { - let meta = meta.get_or_insert_with(Meta::new); - meta.insert( - "cacheScope".to_string(), - Value::String(cache_scope.to_string()), - ); - } - }); + self.cache_scope = cache_scope; self } } diff --git a/crates/rmcp/tests/test_cache_hints.rs b/crates/rmcp/tests/test_cache_hints.rs index a18fe553b..960b93c6e 100644 --- a/crates/rmcp/tests/test_cache_hints.rs +++ b/crates/rmcp/tests/test_cache_hints.rs @@ -2,34 +2,53 @@ use rmcp::model::{CacheScope, ListToolsResult, ReadResourceResult, ResourceConte use serde_json::json; #[test] -fn paginated_results_serialize_cache_hints_in_meta() { +fn paginated_results_serialize_cache_hints_as_top_level_fields() { let result = ListToolsResult::with_all_items(Vec::new()) .with_ttl_ms(5_000) - .with_cache_scope(CacheScope::User); + .with_cache_scope(CacheScope::Private); let actual = serde_json::to_value(result).expect("serialize list tools result"); assert_eq!( actual, json!({ - "_meta": { - "ttlMs": 5000, - "cacheScope": "user" - }, + "ttlMs": 5000, + "cacheScope": "private", "tools": [] }) ); + assert!(actual.get("_meta").is_none()); } #[test] -fn read_resource_results_serialize_cache_hints_in_content_meta() { +fn read_resource_results_serialize_cache_hints_as_top_level_fields() { let result = ReadResourceResult::new(vec![ResourceContents::text("hello", "file:///example.txt")]) .with_ttl_ms(10_000) - .with_cache_scope(CacheScope::Shared); + .with_cache_scope(CacheScope::Public); let actual = serde_json::to_value(result).expect("serialize read resource result"); - assert_eq!(actual["contents"][0]["_meta"]["ttlMs"], 10000); - assert_eq!(actual["contents"][0]["_meta"]["cacheScope"], "shared"); + assert_eq!(actual["ttlMs"], 10000); + assert_eq!(actual["cacheScope"], "public"); + assert!(actual["contents"][0].get("_meta").is_none()); +} + +#[test] +fn ttl_ms_deserialization_normalizes_absent_and_negative_values_to_zero() { + let absent: ListToolsResult = serde_json::from_value(json!({ + "tools": [] + })) + .expect("deserialize result without ttlMs"); + assert_eq!(absent.ttl_ms(), 0); + assert_eq!(absent.cache_scope(), CacheScope::Public); + + let negative: ReadResourceResult = serde_json::from_value(json!({ + "ttlMs": -42, + "cacheScope": "private", + "contents": [] + })) + .expect("deserialize result with negative ttlMs"); + assert_eq!(negative.ttl_ms, 0); + assert_eq!(negative.cache_scope, CacheScope::Private); } diff --git a/crates/rmcp/tests/test_list_tools_result/list_tools_result.json b/crates/rmcp/tests/test_list_tools_result/list_tools_result.json index 15325e8fa..f27b9daec 100644 --- a/crates/rmcp/tests/test_list_tools_result/list_tools_result.json +++ b/crates/rmcp/tests/test_list_tools_result/list_tools_result.json @@ -1,5 +1,7 @@ { "result": { + "ttlMs": 0, + "cacheScope": "public", "tools": [ { "name": "add", diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index c1c6d1b2c..7f6899ad1 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -344,6 +344,14 @@ "format": "const", "const": "boolean" }, + "CacheScope": { + "description": "Scope describing who may cache cacheable list/read results.", + "type": "string", + "enum": [ + "private", + "public" + ] + }, "CallToolResult": { "description": "The result of a tool call operation.\n\nContains the content returned by the tool execution and an optional\nflag indicating whether the operation resulted in an error.", "type": "object", @@ -1416,6 +1424,15 @@ ], "additionalProperties": true }, + "cacheScope": { + "description": "Scope describing who may cache this result.", + "allOf": [ + { + "$ref": "#/definitions/CacheScope" + } + ], + "default": "public" + }, "nextCursor": { "type": [ "string", @@ -1427,10 +1444,19 @@ "items": { "$ref": "#/definitions/Prompt" } + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh.", + "type": "integer", + "format": "uint64", + "default": 0, + "minimum": 0 } }, "required": [ - "prompts" + "cacheScope", + "prompts", + "ttlMs" ] }, "ListResourceTemplatesResult": { @@ -1443,6 +1469,15 @@ ], "additionalProperties": true }, + "cacheScope": { + "description": "Scope describing who may cache this result.", + "allOf": [ + { + "$ref": "#/definitions/CacheScope" + } + ], + "default": "public" + }, "nextCursor": { "type": [ "string", @@ -1454,10 +1489,19 @@ "items": { "$ref": "#/definitions/Annotated3" } + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh.", + "type": "integer", + "format": "uint64", + "default": 0, + "minimum": 0 } }, "required": [ - "resourceTemplates" + "cacheScope", + "resourceTemplates", + "ttlMs" ] }, "ListResourcesResult": { @@ -1470,6 +1514,15 @@ ], "additionalProperties": true }, + "cacheScope": { + "description": "Scope describing who may cache this result.", + "allOf": [ + { + "$ref": "#/definitions/CacheScope" + } + ], + "default": "public" + }, "nextCursor": { "type": [ "string", @@ -1481,10 +1534,19 @@ "items": { "$ref": "#/definitions/Annotated2" } + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh.", + "type": "integer", + "format": "uint64", + "default": 0, + "minimum": 0 } }, "required": [ - "resources" + "cacheScope", + "resources", + "ttlMs" ] }, "ListRootsRequestMethod": { @@ -1530,6 +1592,15 @@ ], "additionalProperties": true }, + "cacheScope": { + "description": "Scope describing who may cache this result.", + "allOf": [ + { + "$ref": "#/definitions/CacheScope" + } + ], + "default": "public" + }, "nextCursor": { "type": [ "string", @@ -1541,10 +1612,19 @@ "items": { "$ref": "#/definitions/Tool" } + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh.", + "type": "integer", + "format": "uint64", + "default": 0, + "minimum": 0 } }, "required": [ - "tools" + "cacheScope", + "tools", + "ttlMs" ] }, "LoggingLevel": { @@ -2403,16 +2483,34 @@ "description": "Result containing the contents of a read resource", "type": "object", "properties": { + "cacheScope": { + "description": "Scope describing who may cache this result.", + "allOf": [ + { + "$ref": "#/definitions/CacheScope" + } + ], + "default": "public" + }, "contents": { "description": "The actual content of the resource", "type": "array", "items": { "$ref": "#/definitions/ResourceContents" } + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh.", + "type": "integer", + "format": "uint64", + "default": 0, + "minimum": 0 } }, "required": [ - "contents" + "cacheScope", + "contents", + "ttlMs" ] }, "Request": { diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index c1c6d1b2c..7f6899ad1 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -344,6 +344,14 @@ "format": "const", "const": "boolean" }, + "CacheScope": { + "description": "Scope describing who may cache cacheable list/read results.", + "type": "string", + "enum": [ + "private", + "public" + ] + }, "CallToolResult": { "description": "The result of a tool call operation.\n\nContains the content returned by the tool execution and an optional\nflag indicating whether the operation resulted in an error.", "type": "object", @@ -1416,6 +1424,15 @@ ], "additionalProperties": true }, + "cacheScope": { + "description": "Scope describing who may cache this result.", + "allOf": [ + { + "$ref": "#/definitions/CacheScope" + } + ], + "default": "public" + }, "nextCursor": { "type": [ "string", @@ -1427,10 +1444,19 @@ "items": { "$ref": "#/definitions/Prompt" } + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh.", + "type": "integer", + "format": "uint64", + "default": 0, + "minimum": 0 } }, "required": [ - "prompts" + "cacheScope", + "prompts", + "ttlMs" ] }, "ListResourceTemplatesResult": { @@ -1443,6 +1469,15 @@ ], "additionalProperties": true }, + "cacheScope": { + "description": "Scope describing who may cache this result.", + "allOf": [ + { + "$ref": "#/definitions/CacheScope" + } + ], + "default": "public" + }, "nextCursor": { "type": [ "string", @@ -1454,10 +1489,19 @@ "items": { "$ref": "#/definitions/Annotated3" } + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh.", + "type": "integer", + "format": "uint64", + "default": 0, + "minimum": 0 } }, "required": [ - "resourceTemplates" + "cacheScope", + "resourceTemplates", + "ttlMs" ] }, "ListResourcesResult": { @@ -1470,6 +1514,15 @@ ], "additionalProperties": true }, + "cacheScope": { + "description": "Scope describing who may cache this result.", + "allOf": [ + { + "$ref": "#/definitions/CacheScope" + } + ], + "default": "public" + }, "nextCursor": { "type": [ "string", @@ -1481,10 +1534,19 @@ "items": { "$ref": "#/definitions/Annotated2" } + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh.", + "type": "integer", + "format": "uint64", + "default": 0, + "minimum": 0 } }, "required": [ - "resources" + "cacheScope", + "resources", + "ttlMs" ] }, "ListRootsRequestMethod": { @@ -1530,6 +1592,15 @@ ], "additionalProperties": true }, + "cacheScope": { + "description": "Scope describing who may cache this result.", + "allOf": [ + { + "$ref": "#/definitions/CacheScope" + } + ], + "default": "public" + }, "nextCursor": { "type": [ "string", @@ -1541,10 +1612,19 @@ "items": { "$ref": "#/definitions/Tool" } + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh.", + "type": "integer", + "format": "uint64", + "default": 0, + "minimum": 0 } }, "required": [ - "tools" + "cacheScope", + "tools", + "ttlMs" ] }, "LoggingLevel": { @@ -2403,16 +2483,34 @@ "description": "Result containing the contents of a read resource", "type": "object", "properties": { + "cacheScope": { + "description": "Scope describing who may cache this result.", + "allOf": [ + { + "$ref": "#/definitions/CacheScope" + } + ], + "default": "public" + }, "contents": { "description": "The actual content of the resource", "type": "array", "items": { "$ref": "#/definitions/ResourceContents" } + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh.", + "type": "integer", + "format": "uint64", + "default": 0, + "minimum": 0 } }, "required": [ - "contents" + "cacheScope", + "contents", + "ttlMs" ] }, "Request": { From 1103cbed249819b64601471d6550389a3bfe738a Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Tue, 9 Jun 2026 10:32:07 +1000 Subject: [PATCH 3/4] fix: remove stale cache scope aliases --- crates/rmcp/src/model.rs | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index db491a5da..096687ac7 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -1151,10 +1151,6 @@ pub enum CacheScope { Public, /// Only the requesting user's client may cache the response. Private, - /// Alias for [`CacheScope::Private`] kept for compatibility with earlier draft support. - User, - /// Alias for [`CacheScope::Public`] kept for compatibility with earlier draft support. - Shared, } impl Serialize for CacheScope { @@ -1163,8 +1159,8 @@ impl Serialize for CacheScope { S: serde::Serializer, { match self { - CacheScope::Public | CacheScope::Shared => "public", - CacheScope::Private | CacheScope::User => "private", + CacheScope::Public => "public", + CacheScope::Private => "private", } .serialize(serializer) } @@ -1178,9 +1174,6 @@ impl<'de> Deserialize<'de> for CacheScope { match String::deserialize(deserializer)?.as_str() { "public" => Ok(CacheScope::Public), "private" => Ok(CacheScope::Private), - // Accept the earlier draft values for read-side compatibility. - "shared" => Ok(CacheScope::Public), - "user" => Ok(CacheScope::Private), other => Err(serde::de::Error::unknown_variant( other, &["public", "private"], From 169b43dfa8c6400ad7d5d442c948b65104f09bb4 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Fri, 12 Jun 2026 12:06:23 +1000 Subject: [PATCH 4/4] refactor: make cache hints plain result fields (SEP-2549) Address review feedback: instead of stashing ttlMs/cacheScope inside _meta with hand-written Serialize/Deserialize/JsonSchema impls, model them as plain Option fields with the derives restored. Since the new spec (2026-07-28) is a breaking change by definition and v2.0 is a major bump, the previous _meta workaround (kept to preserve SemVer compatibility) is no longer needed. - CacheScope now derives Serialize/Deserialize/JsonSchema (rename_all = lowercase) instead of custom impls. - paginated_result! results and ReadResourceResult expose ttl_ms: Option and cache_scope: Option directly; absent hints serialize to nothing (cacheScope defaults to public when absent per spec). - A small deserialize_ttl_ms helper normalizes negative ttlMs to 0 per SEP-2549 client guidance. - Updated macros, examples, fixtures, and schema snapshots accordingly. --- crates/rmcp-macros/src/prompt_handler.rs | 2 + crates/rmcp-macros/src/tool_handler.rs | 2 + crates/rmcp/src/model.rs | 413 ++---------------- crates/rmcp/tests/test_cache_hints.rs | 34 +- .../list_tools_result.json | 2 - .../server_json_rpc_message_schema.json | 127 +++--- ...erver_json_rpc_message_schema_current.json | 127 +++--- examples/servers/src/common/counter.rs | 6 +- examples/servers/src/sampling_stdio.rs | 3 +- 9 files changed, 228 insertions(+), 488 deletions(-) diff --git a/crates/rmcp-macros/src/prompt_handler.rs b/crates/rmcp-macros/src/prompt_handler.rs index 4f2541ac6..96fc4dc1d 100644 --- a/crates/rmcp-macros/src/prompt_handler.rs +++ b/crates/rmcp-macros/src/prompt_handler.rs @@ -64,6 +64,8 @@ pub fn prompt_handler(attr: TokenStream, input: TokenStream) -> syn::Result syn::Result(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - match self { - CacheScope::Public => "public", - CacheScope::Private => "private", - } - .serialize(serializer) - } -} - -impl<'de> Deserialize<'de> for CacheScope { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - match String::deserialize(deserializer)?.as_str() { - "public" => Ok(CacheScope::Public), - "private" => Ok(CacheScope::Private), - other => Err(serde::de::Error::unknown_variant( - other, - &["public", "private"], - )), - } - } -} - -#[cfg(feature = "schemars")] -impl schemars::JsonSchema for CacheScope { - fn schema_name() -> Cow<'static, str> { - Cow::Borrowed("CacheScope") - } - - fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema { - serde_json::json!({ - "description": "Scope describing who may cache cacheable list/read results.", - "enum": ["private", "public"], - "type": "string" - }) - .as_object() - .expect("schema is an object") - .clone() - .into() - } -} - -fn deserialize_ttl_ms<'de, D>(deserializer: D) -> Result +/// 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, D::Error> where D: serde::Deserializer<'de>, { - let value = Value::deserialize(deserializer)?; - ttl_ms_from_value(&value).map_err(serde::de::Error::custom) -} - -fn ttl_ms_from_value(value: &Value) -> Result { - match value { - Value::Number(number) => { - if let Some(value) = number.as_u64() { - Ok(value) - } else if let Some(value) = number.as_i64() { - Ok(value.max(0) as u64) - } else { - Err("ttlMs must be an integer") - } - } - _ => Err("ttlMs must be an integer"), - } -} - -const TTL_MS_FIELD: &str = "ttlMs"; -const CACHE_SCOPE_FIELD: &str = "cacheScope"; - -fn ttl_ms_from_meta(meta: Option<&Meta>) -> u64 { - meta.and_then(|meta| meta.get(TTL_MS_FIELD)) - .and_then(|value| ttl_ms_from_value(value).ok()) - .unwrap_or_default() -} - -fn cache_scope_from_meta(meta: Option<&Meta>) -> CacheScope { - meta.and_then(|meta| meta.get(CACHE_SCOPE_FIELD)) - .and_then(|value| serde_json::from_value(value.clone()).ok()) - .unwrap_or_default() -} - -fn set_meta_cache_hint(meta: &mut Option, key: &str, value: Option) { - if let Some(value) = value { - meta.get_or_insert_with(Meta::new) - .insert(key.to_string(), value); - } else if let Some(existing_meta) = meta.as_mut() { - existing_meta.remove(key); - if existing_meta.is_empty() { - *meta = None; - } - } -} - -fn set_meta_ttl_ms(meta: &mut Option, ttl_ms: u64) { - set_meta_cache_hint( - meta, - TTL_MS_FIELD, - (ttl_ms != 0).then(|| Value::Number(ttl_ms.into())), - ); -} - -fn set_meta_cache_scope(meta: &mut Option, cache_scope: CacheScope) { - set_meta_cache_hint( - meta, - CACHE_SCOPE_FIELD, - (cache_scope != CacheScope::default()).then(|| { - serde_json::to_value(cache_scope).expect("CacheScope serializes to a valid JSON value") - }), - ); -} - -fn meta_without_cache_hints(meta: Option<&Meta>) -> Option { - let mut meta = meta.cloned()?; - meta.remove(TTL_MS_FIELD); - meta.remove(CACHE_SCOPE_FIELD); - (!meta.is_empty()).then_some(meta) -} - -fn to_camel_case(field: &str) -> String { - let mut output = String::new(); - let mut uppercase_next = false; - for ch in field.chars() { - if ch == '_' { - uppercase_next = true; - } else if uppercase_next { - output.extend(ch.to_uppercase()); - uppercase_next = false; - } else { - output.push(ch); - } - } - output + let value = Option::::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 }) => { - #[derive(Debug, Clone, PartialEq, Default)] + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] + #[serde(rename_all = "camelCase")] + #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct $t { + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub next_cursor: Option, + /// Time, in milliseconds, that this result may be treated as fresh (SEP-2549). + #[serde( + default, + deserialize_with = "deserialize_ttl_ms", + skip_serializing_if = "Option::is_none" + )] + pub ttl_ms: Option, + /// Scope describing who may cache this result (SEP-2549). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_scope: Option, pub $i_item: $t_item, } - impl Serialize for $t { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - use serde::ser::SerializeMap; - - let meta = meta_without_cache_hints(self.meta.as_ref()); - let mut len = 3; - if meta.is_some() { - len += 1; - } - if self.next_cursor.is_some() { - len += 1; - } - - let mut map = serializer.serialize_map(Some(len))?; - if let Some(meta) = meta.as_ref() { - map.serialize_entry("_meta", meta)?; - } - if let Some(next_cursor) = self.next_cursor.as_ref() { - map.serialize_entry("nextCursor", next_cursor)?; - } - map.serialize_entry(TTL_MS_FIELD, &self.ttl_ms())?; - map.serialize_entry(CACHE_SCOPE_FIELD, &self.cache_scope())?; - map.serialize_entry(&to_camel_case(stringify!($i_item)), &self.$i_item)?; - map.end() - } - } - - impl<'de> Deserialize<'de> for $t { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let mut value = Value::deserialize(deserializer)?; - let object = value - .as_object_mut() - .ok_or_else(|| serde::de::Error::custom("expected an object"))?; - - let mut meta: Option = object - .remove("_meta") - .map(serde_json::from_value) - .transpose() - .map_err(serde::de::Error::custom)?; - if let Some(existing_meta) = meta.as_mut() { - existing_meta.remove(TTL_MS_FIELD); - existing_meta.remove(CACHE_SCOPE_FIELD); - if existing_meta.is_empty() { - meta = None; - } - } - - let next_cursor = object - .remove("nextCursor") - .map(serde_json::from_value) - .transpose() - .map_err(serde::de::Error::custom)?; - let ttl_ms = object - .remove(TTL_MS_FIELD) - .map(|value| ttl_ms_from_value(&value).map_err(serde::de::Error::custom)) - .transpose()? - .unwrap_or_default(); - let cache_scope = object - .remove(CACHE_SCOPE_FIELD) - .map(serde_json::from_value) - .transpose() - .map_err(serde::de::Error::custom)? - .unwrap_or_default(); - let item_field = to_camel_case(stringify!($i_item)); - let items = object - .remove(&item_field) - .ok_or_else(|| serde::de::Error::custom(format!("missing field `{item_field}`"))) - .and_then(|value| { - serde_json::from_value(value).map_err(serde::de::Error::custom) - })?; - - let mut result = Self { - meta, - next_cursor, - $i_item: items, - }; - result.set_ttl_ms(ttl_ms); - result.set_cache_scope(cache_scope); - Ok(result) - } - } - - #[cfg(feature = "schemars")] - impl schemars::JsonSchema for $t { - fn schema_name() -> Cow<'static, str> { - Cow::Borrowed(stringify!($t)) - } - - fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { - use serde_json::{Map, json}; - - let item_field = to_camel_case(stringify!($i_item)); - let mut properties = Map::new(); - properties.insert( - "_meta".to_string(), - serde_json::to_value(generator.subschema_for::>()) - .expect("schema serializes to JSON"), - ); - properties.insert( - "nextCursor".to_string(), - serde_json::to_value(generator.subschema_for::>()) - .expect("schema serializes to JSON"), - ); - properties.insert( - TTL_MS_FIELD.to_string(), - json!({ - "description": "Time, in milliseconds, that this result may be treated as fresh.", - "type": "integer", - "format": "uint64", - "default": 0, - "minimum": 0 - }), - ); - properties.insert( - CACHE_SCOPE_FIELD.to_string(), - json!({ - "description": "Scope describing who may cache this result.", - "allOf": [generator.subschema_for::()], - "default": "public" - }), - ); - properties.insert( - item_field.clone(), - serde_json::to_value(generator.subschema_for::<$t_item>()) - .expect("schema serializes to JSON"), - ); - - let mut schema = Map::new(); - schema.insert("type".to_string(), json!("object")); - schema.insert("properties".to_string(), Value::Object(properties)); - schema.insert( - "required".to_string(), - json!([CACHE_SCOPE_FIELD, item_field, TTL_MS_FIELD]), - ); - schemars::Schema::from(schema) - } - } - impl $t { pub fn with_all_items(items: $t_item) -> Self { Self { meta: None, next_cursor: None, + ttl_ms: None, + cache_scope: None, $i_item: items, } } - /// Return the time, in milliseconds, that this result may be treated as fresh. - pub fn ttl_ms(&self) -> u64 { - ttl_ms_from_meta(self.meta.as_ref()) - } - - /// Set the time, in milliseconds, that this result may be treated as fresh. - pub fn set_ttl_ms(&mut self, ttl_ms: u64) { - set_meta_ttl_ms(&mut self.meta, ttl_ms); - } - /// 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.set_ttl_ms(ttl_ms); + self.ttl_ms = Some(ttl_ms); self } - /// Return the cache scope for this result. - pub fn cache_scope(&self) -> CacheScope { - cache_scope_from_meta(self.meta.as_ref()) - } - - /// Set the cache scope for this result. - pub fn set_cache_scope(&mut self, cache_scope: CacheScope) { - set_meta_cache_scope(&mut self.meta, cache_scope); - } - /// Set the cache scope for this result. pub fn with_cache_scope(mut self, cache_scope: CacheScope) -> Self { - self.set_cache_scope(cache_scope); + self.cache_scope = Some(cache_scope); self } } @@ -1560,89 +1290,42 @@ 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 { - /// Time, in milliseconds, that this result may be treated as fresh. - #[serde(default, deserialize_with = "deserialize_ttl_ms")] - pub ttl_ms: u64, - /// Scope describing who may cache this result. - #[serde(default)] - pub cache_scope: CacheScope, + /// Time, in milliseconds, that this result may be treated as fresh (SEP-2549). + #[serde( + default, + deserialize_with = "deserialize_ttl_ms", + skip_serializing_if = "Option::is_none" + )] + pub ttl_ms: Option, + /// Scope describing who may cache this result (SEP-2549). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_scope: Option, /// The actual content of the resource pub contents: Vec, } -#[cfg(feature = "schemars")] -impl schemars::JsonSchema for ReadResourceResult { - fn schema_name() -> Cow<'static, str> { - Cow::Borrowed("ReadResourceResult") - } - - fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { - use serde_json::{Map, json}; - - let mut properties = Map::new(); - properties.insert( - TTL_MS_FIELD.to_string(), - json!({ - "description": "Time, in milliseconds, that this result may be treated as fresh.", - "type": "integer", - "format": "uint64", - "default": 0, - "minimum": 0 - }), - ); - properties.insert( - CACHE_SCOPE_FIELD.to_string(), - json!({ - "description": "Scope describing who may cache this result.", - "allOf": [generator.subschema_for::()], - "default": "public" - }), - ); - properties.insert( - "contents".to_string(), - serde_json::json!({ - "description": "The actual content of the resource", - "type": "array", - "items": generator.subschema_for::() - }), - ); - - let mut schema = Map::new(); - schema.insert( - "description".to_string(), - json!("Result containing the contents of a read resource"), - ); - schema.insert("type".to_string(), json!("object")); - schema.insert("properties".to_string(), Value::Object(properties)); - schema.insert( - "required".to_string(), - json!([CACHE_SCOPE_FIELD, "contents", TTL_MS_FIELD]), - ); - schemars::Schema::from(schema) - } -} - impl ReadResourceResult { /// Create a new ReadResourceResult with the given contents. pub fn new(contents: Vec) -> Self { Self { - ttl_ms: 0, - cache_scope: CacheScope::default(), + ttl_ms: None, + cache_scope: None, contents, } } /// 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 = ttl_ms; + 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 = cache_scope; + self.cache_scope = Some(cache_scope); self } } diff --git a/crates/rmcp/tests/test_cache_hints.rs b/crates/rmcp/tests/test_cache_hints.rs index 960b93c6e..1716e1e94 100644 --- a/crates/rmcp/tests/test_cache_hints.rs +++ b/crates/rmcp/tests/test_cache_hints.rs @@ -35,13 +35,21 @@ fn read_resource_results_serialize_cache_hints_as_top_level_fields() { } #[test] -fn ttl_ms_deserialization_normalizes_absent_and_negative_values_to_zero() { +fn cache_hints_are_omitted_when_absent() { + let result = ListToolsResult::with_all_items(Vec::new()); + let actual = serde_json::to_value(result).expect("serialize list tools result"); + + assert_eq!(actual, json!({ "tools": [] })); +} + +#[test] +fn cache_hints_default_to_none_and_negative_ttl_is_normalized_to_zero() { let absent: ListToolsResult = serde_json::from_value(json!({ "tools": [] })) .expect("deserialize result without ttlMs"); - assert_eq!(absent.ttl_ms(), 0); - assert_eq!(absent.cache_scope(), CacheScope::Public); + assert_eq!(absent.ttl_ms, None); + assert_eq!(absent.cache_scope, None); let negative: ReadResourceResult = serde_json::from_value(json!({ "ttlMs": -42, @@ -49,6 +57,22 @@ fn ttl_ms_deserialization_normalizes_absent_and_negative_values_to_zero() { "contents": [] })) .expect("deserialize result with negative ttlMs"); - assert_eq!(negative.ttl_ms, 0); - assert_eq!(negative.cache_scope, CacheScope::Private); + assert_eq!(negative.ttl_ms, Some(0)); + assert_eq!(negative.cache_scope, Some(CacheScope::Private)); +} + +#[test] +fn cache_scope_round_trips() { + assert_eq!( + serde_json::to_value(CacheScope::Public).unwrap(), + json!("public") + ); + assert_eq!( + serde_json::to_value(CacheScope::Private).unwrap(), + json!("private") + ); + assert_eq!( + serde_json::from_value::(json!("private")).unwrap(), + CacheScope::Private + ); } diff --git a/crates/rmcp/tests/test_list_tools_result/list_tools_result.json b/crates/rmcp/tests/test_list_tools_result/list_tools_result.json index f27b9daec..15325e8fa 100644 --- a/crates/rmcp/tests/test_list_tools_result/list_tools_result.json +++ b/crates/rmcp/tests/test_list_tools_result/list_tools_result.json @@ -1,7 +1,5 @@ { "result": { - "ttlMs": 0, - "cacheScope": "public", "tools": [ { "name": "add", diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index 7f6899ad1..1deef932b 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json @@ -345,11 +345,18 @@ "const": "boolean" }, "CacheScope": { - "description": "Scope describing who may cache cacheable list/read results.", - "type": "string", - "enum": [ - "private", - "public" + "description": "Scope describing who may cache cacheable list/read results (SEP-2549).\n\nDefaults to [`CacheScope::Public`] when absent from the wire.", + "oneOf": [ + { + "description": "Any client or intermediary may cache and serve the response to any user.", + "type": "string", + "const": "public" + }, + { + "description": "Only the requesting user's client may cache the response.", + "type": "string", + "const": "private" + } ] }, "CallToolResult": { @@ -1425,13 +1432,15 @@ "additionalProperties": true }, "cacheScope": { - "description": "Scope describing who may cache this result.", - "allOf": [ + "description": "Scope describing who may cache this result (SEP-2549).", + "anyOf": [ { "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" } - ], - "default": "public" + ] }, "nextCursor": { "type": [ @@ -1446,17 +1455,17 @@ } }, "ttlMs": { - "description": "Time, in milliseconds, that this result may be treated as fresh.", - "type": "integer", + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).", + "type": [ + "integer", + "null" + ], "format": "uint64", - "default": 0, "minimum": 0 } }, "required": [ - "cacheScope", - "prompts", - "ttlMs" + "prompts" ] }, "ListResourceTemplatesResult": { @@ -1470,13 +1479,15 @@ "additionalProperties": true }, "cacheScope": { - "description": "Scope describing who may cache this result.", - "allOf": [ + "description": "Scope describing who may cache this result (SEP-2549).", + "anyOf": [ { "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" } - ], - "default": "public" + ] }, "nextCursor": { "type": [ @@ -1491,17 +1502,17 @@ } }, "ttlMs": { - "description": "Time, in milliseconds, that this result may be treated as fresh.", - "type": "integer", + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).", + "type": [ + "integer", + "null" + ], "format": "uint64", - "default": 0, "minimum": 0 } }, "required": [ - "cacheScope", - "resourceTemplates", - "ttlMs" + "resourceTemplates" ] }, "ListResourcesResult": { @@ -1515,13 +1526,15 @@ "additionalProperties": true }, "cacheScope": { - "description": "Scope describing who may cache this result.", - "allOf": [ + "description": "Scope describing who may cache this result (SEP-2549).", + "anyOf": [ { "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" } - ], - "default": "public" + ] }, "nextCursor": { "type": [ @@ -1536,17 +1549,17 @@ } }, "ttlMs": { - "description": "Time, in milliseconds, that this result may be treated as fresh.", - "type": "integer", + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).", + "type": [ + "integer", + "null" + ], "format": "uint64", - "default": 0, "minimum": 0 } }, "required": [ - "cacheScope", - "resources", - "ttlMs" + "resources" ] }, "ListRootsRequestMethod": { @@ -1593,13 +1606,15 @@ "additionalProperties": true }, "cacheScope": { - "description": "Scope describing who may cache this result.", - "allOf": [ + "description": "Scope describing who may cache this result (SEP-2549).", + "anyOf": [ { "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" } - ], - "default": "public" + ] }, "nextCursor": { "type": [ @@ -1614,17 +1629,17 @@ } }, "ttlMs": { - "description": "Time, in milliseconds, that this result may be treated as fresh.", - "type": "integer", + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).", + "type": [ + "integer", + "null" + ], "format": "uint64", - "default": 0, "minimum": 0 } }, "required": [ - "cacheScope", - "tools", - "ttlMs" + "tools" ] }, "LoggingLevel": { @@ -2484,13 +2499,15 @@ "type": "object", "properties": { "cacheScope": { - "description": "Scope describing who may cache this result.", - "allOf": [ + "description": "Scope describing who may cache this result (SEP-2549).", + "anyOf": [ { "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" } - ], - "default": "public" + ] }, "contents": { "description": "The actual content of the resource", @@ -2500,17 +2517,17 @@ } }, "ttlMs": { - "description": "Time, in milliseconds, that this result may be treated as fresh.", - "type": "integer", + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).", + "type": [ + "integer", + "null" + ], "format": "uint64", - "default": 0, "minimum": 0 } }, "required": [ - "cacheScope", - "contents", - "ttlMs" + "contents" ] }, "Request": { diff --git a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json index 7f6899ad1..1deef932b 100644 --- a/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json +++ b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema_current.json @@ -345,11 +345,18 @@ "const": "boolean" }, "CacheScope": { - "description": "Scope describing who may cache cacheable list/read results.", - "type": "string", - "enum": [ - "private", - "public" + "description": "Scope describing who may cache cacheable list/read results (SEP-2549).\n\nDefaults to [`CacheScope::Public`] when absent from the wire.", + "oneOf": [ + { + "description": "Any client or intermediary may cache and serve the response to any user.", + "type": "string", + "const": "public" + }, + { + "description": "Only the requesting user's client may cache the response.", + "type": "string", + "const": "private" + } ] }, "CallToolResult": { @@ -1425,13 +1432,15 @@ "additionalProperties": true }, "cacheScope": { - "description": "Scope describing who may cache this result.", - "allOf": [ + "description": "Scope describing who may cache this result (SEP-2549).", + "anyOf": [ { "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" } - ], - "default": "public" + ] }, "nextCursor": { "type": [ @@ -1446,17 +1455,17 @@ } }, "ttlMs": { - "description": "Time, in milliseconds, that this result may be treated as fresh.", - "type": "integer", + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).", + "type": [ + "integer", + "null" + ], "format": "uint64", - "default": 0, "minimum": 0 } }, "required": [ - "cacheScope", - "prompts", - "ttlMs" + "prompts" ] }, "ListResourceTemplatesResult": { @@ -1470,13 +1479,15 @@ "additionalProperties": true }, "cacheScope": { - "description": "Scope describing who may cache this result.", - "allOf": [ + "description": "Scope describing who may cache this result (SEP-2549).", + "anyOf": [ { "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" } - ], - "default": "public" + ] }, "nextCursor": { "type": [ @@ -1491,17 +1502,17 @@ } }, "ttlMs": { - "description": "Time, in milliseconds, that this result may be treated as fresh.", - "type": "integer", + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).", + "type": [ + "integer", + "null" + ], "format": "uint64", - "default": 0, "minimum": 0 } }, "required": [ - "cacheScope", - "resourceTemplates", - "ttlMs" + "resourceTemplates" ] }, "ListResourcesResult": { @@ -1515,13 +1526,15 @@ "additionalProperties": true }, "cacheScope": { - "description": "Scope describing who may cache this result.", - "allOf": [ + "description": "Scope describing who may cache this result (SEP-2549).", + "anyOf": [ { "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" } - ], - "default": "public" + ] }, "nextCursor": { "type": [ @@ -1536,17 +1549,17 @@ } }, "ttlMs": { - "description": "Time, in milliseconds, that this result may be treated as fresh.", - "type": "integer", + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).", + "type": [ + "integer", + "null" + ], "format": "uint64", - "default": 0, "minimum": 0 } }, "required": [ - "cacheScope", - "resources", - "ttlMs" + "resources" ] }, "ListRootsRequestMethod": { @@ -1593,13 +1606,15 @@ "additionalProperties": true }, "cacheScope": { - "description": "Scope describing who may cache this result.", - "allOf": [ + "description": "Scope describing who may cache this result (SEP-2549).", + "anyOf": [ { "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" } - ], - "default": "public" + ] }, "nextCursor": { "type": [ @@ -1614,17 +1629,17 @@ } }, "ttlMs": { - "description": "Time, in milliseconds, that this result may be treated as fresh.", - "type": "integer", + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).", + "type": [ + "integer", + "null" + ], "format": "uint64", - "default": 0, "minimum": 0 } }, "required": [ - "cacheScope", - "tools", - "ttlMs" + "tools" ] }, "LoggingLevel": { @@ -2484,13 +2499,15 @@ "type": "object", "properties": { "cacheScope": { - "description": "Scope describing who may cache this result.", - "allOf": [ + "description": "Scope describing who may cache this result (SEP-2549).", + "anyOf": [ { "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" } - ], - "default": "public" + ] }, "contents": { "description": "The actual content of the resource", @@ -2500,17 +2517,17 @@ } }, "ttlMs": { - "description": "Time, in milliseconds, that this result may be treated as fresh.", - "type": "integer", + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).", + "type": [ + "integer", + "null" + ], "format": "uint64", - "default": 0, "minimum": 0 } }, "required": [ - "cacheScope", - "contents", - "ttlMs" + "contents" ] }, "Request": { diff --git a/examples/servers/src/common/counter.rs b/examples/servers/src/common/counter.rs index d78acd59f..96fb9857d 100644 --- a/examples/servers/src/common/counter.rs +++ b/examples/servers/src/common/counter.rs @@ -255,8 +255,7 @@ impl ServerHandler for Counter { self._create_resource_text("str:////Users/to/some/path/", "cwd"), self._create_resource_text("memo://insights", "memo-name"), ], - next_cursor: None, - meta: None, + ..Default::default() }) } @@ -296,9 +295,8 @@ impl ServerHandler for Counter { _: RequestContext, ) -> Result { Ok(ListResourceTemplatesResult { - next_cursor: None, resource_templates: Vec::new(), - meta: None, + ..Default::default() }) } diff --git a/examples/servers/src/sampling_stdio.rs b/examples/servers/src/sampling_stdio.rs index 9c1d21d6d..244a4aa5c 100644 --- a/examples/servers/src/sampling_stdio.rs +++ b/examples/servers/src/sampling_stdio.rs @@ -113,8 +113,7 @@ impl ServerHandler for SamplingDemoServer { .unwrap(), ), )], - meta: None, - next_cursor: None, + ..Default::default() }) } }