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= 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 = 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 @@ -1151,23 +1179,45 @@ macro_rules! paginated_result { #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] #[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")] pub struct $t { - #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] pub meta: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[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 $t { - pub fn with_all_items( - items: $t_item, - ) -> Self { + pub fn with_all_items(items: $t_item) -> Self { Self { meta: None, next_cursor: None, + ttl_ms: None, + cache_scope: 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 { + 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 = Some(cache_scope); + self + } } }; } @@ -1239,9 +1289,20 @@ 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 (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, } @@ -1249,7 +1310,23 @@ pub struct ReadResourceResult { impl ReadResourceResult { /// Create a new ReadResourceResult with the given contents. pub fn new(contents: Vec) -> Self { - Self { contents } + Self { + 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 = 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 = Some(cache_scope); + self } } diff --git a/crates/rmcp/tests/test_cache_hints.rs b/crates/rmcp/tests/test_cache_hints.rs new file mode 100644 index 000000000..1716e1e94 --- /dev/null +++ b/crates/rmcp/tests/test_cache_hints.rs @@ -0,0 +1,78 @@ +use rmcp::model::{CacheScope, ListToolsResult, ReadResourceResult, ResourceContents}; +use serde_json::json; + +#[test] +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::Private); + + let actual = serde_json::to_value(result).expect("serialize list tools result"); + + assert_eq!( + actual, + json!({ + "ttlMs": 5000, + "cacheScope": "private", + "tools": [] + }) + ); + assert!(actual.get("_meta").is_none()); +} + +#[test] +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::Public); + + let actual = serde_json::to_value(result).expect("serialize read resource result"); + + assert_eq!(actual["ttlMs"], 10000); + assert_eq!(actual["cacheScope"], "public"); + assert!(actual["contents"][0].get("_meta").is_none()); +} + +#[test] +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, None); + assert_eq!(absent.cache_scope, None); + + let negative: ReadResourceResult = serde_json::from_value(json!({ + "ttlMs": -42, + "cacheScope": "private", + "contents": [] + })) + .expect("deserialize result with negative ttlMs"); + 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_message_schema/server_json_rpc_message_schema.json b/crates/rmcp/tests/test_message_schema/server_json_rpc_message_schema.json index c1c6d1b2c..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 @@ -344,6 +344,21 @@ "format": "const", "const": "boolean" }, + "CacheScope": { + "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": { "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 +1431,17 @@ ], "additionalProperties": true }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, "nextCursor": { "type": [ "string", @@ -1427,6 +1453,15 @@ "items": { "$ref": "#/definitions/Prompt" } + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, "required": [ @@ -1443,6 +1478,17 @@ ], "additionalProperties": true }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, "nextCursor": { "type": [ "string", @@ -1454,6 +1500,15 @@ "items": { "$ref": "#/definitions/Annotated3" } + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, "required": [ @@ -1470,6 +1525,17 @@ ], "additionalProperties": true }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, "nextCursor": { "type": [ "string", @@ -1481,6 +1547,15 @@ "items": { "$ref": "#/definitions/Annotated2" } + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, "required": [ @@ -1530,6 +1605,17 @@ ], "additionalProperties": true }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, "nextCursor": { "type": [ "string", @@ -1541,6 +1627,15 @@ "items": { "$ref": "#/definitions/Tool" } + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, "required": [ @@ -2403,12 +2498,32 @@ "description": "Result containing the contents of a read resource", "type": "object", "properties": { + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, "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 (SEP-2549).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, "required": [ 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..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 @@ -344,6 +344,21 @@ "format": "const", "const": "boolean" }, + "CacheScope": { + "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": { "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 +1431,17 @@ ], "additionalProperties": true }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, "nextCursor": { "type": [ "string", @@ -1427,6 +1453,15 @@ "items": { "$ref": "#/definitions/Prompt" } + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, "required": [ @@ -1443,6 +1478,17 @@ ], "additionalProperties": true }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, "nextCursor": { "type": [ "string", @@ -1454,6 +1500,15 @@ "items": { "$ref": "#/definitions/Annotated3" } + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, "required": [ @@ -1470,6 +1525,17 @@ ], "additionalProperties": true }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, "nextCursor": { "type": [ "string", @@ -1481,6 +1547,15 @@ "items": { "$ref": "#/definitions/Annotated2" } + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, "required": [ @@ -1530,6 +1605,17 @@ ], "additionalProperties": true }, + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, "nextCursor": { "type": [ "string", @@ -1541,6 +1627,15 @@ "items": { "$ref": "#/definitions/Tool" } + }, + "ttlMs": { + "description": "Time, in milliseconds, that this result may be treated as fresh (SEP-2549).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, "required": [ @@ -2403,12 +2498,32 @@ "description": "Result containing the contents of a read resource", "type": "object", "properties": { + "cacheScope": { + "description": "Scope describing who may cache this result (SEP-2549).", + "anyOf": [ + { + "$ref": "#/definitions/CacheScope" + }, + { + "type": "null" + } + ] + }, "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 (SEP-2549).", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 } }, "required": [ 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() }) } }