From f8edae7ae8cfde0a3659cc98d6c7f37fa8e77c0c Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Fri, 5 Jun 2026 19:39:41 +1000 Subject: [PATCH 1/2] 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 4aabab1d..a263618e 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 00000000..a18fe553 --- /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/2] 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 a263618e..db491a5d 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 a18fe553..960b93c6 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 15325e8f..f27b9dae 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 c1c6d1b2..7f6899ad 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 c1c6d1b2..7f6899ad 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": {