Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 627
implementing SEP-2549 TTL for List Results#889
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1236,6 +1236,34 @@ pub type ProgressNotification = Notification<ProgressNotificationMethod, Progres | ||
| pub type Cursor = String; | ||
| /// Scope describing who may cache cacheable list/read results (SEP-2549). | ||
| /// | ||
| /// Defaults to [`CacheScope::Public`] when absent from the wire. | ||
| #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] | ||
| #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] | ||
| #[serde(rename_all = "lowercase")] | ||
| #[non_exhaustive] | ||
| pub enum CacheScope { | ||
| /// Any client or intermediary may cache and serve the response to any user. | ||
| #[default] | ||
| Public, | ||
| /// Only the requesting user's client may cache the response. | ||
| Private, | ||
| } | ||
| /// 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<Option<u64>, D::Error> | ||
| where | ||
| D: serde::Deserializer<'de>, | ||
| { | ||
| let value = Option::<i64>::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 | ||
| @@ -1248,24 +1276,50 @@ macro_rules! paginated_result { | ||
| /// Result type discriminator. Absent values deserialize as `"complete"`. | ||
| #[serde(default)] | ||
| pub result_type: ResultType, | ||
| #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] | ||
| #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] | ||
| pub meta: Option<Meta>, | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
jamadeo marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| pub next_cursor: Option<Cursor>, | ||
| /// Time, in milliseconds, that this result may be treated as fresh (SEP-2549). | ||
| /// Required by spec version 2026-07-28, but optional here to maintain compatibility | ||
| /// with older spec versions. | ||
| #[serde( | ||
| default, | ||
| deserialize_with = "deserialize_ttl_ms", | ||
| skip_serializing_if = "Option::is_none" | ||
| )] | ||
| pub ttl_ms: Option<u64>, | ||
| /// Scope describing who may cache this result (SEP-2549). | ||
| /// Required by spec version 2026-07-28, but optional here to maintain compatibility | ||
| /// with older spec versions. | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| pub cache_scope: Option<CacheScope>, | ||
DaleSeo marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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 { | ||
| result_type: ResultType::default(), | ||
| 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 | ||
| } | ||
| } | ||
| }; | ||
| } | ||
| @@ -1358,12 +1412,27 @@ 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 { | ||
| /// Result type discriminator. Absent values deserialize as `"complete"`. | ||
| #[serde(default)] | ||
| pub result_type: ResultType, | ||
| /// Time, in milliseconds, that this result may be treated as fresh (SEP-2549). | ||
| /// Required by spec version 2026-07-28, but optional here to maintain compatibility | ||
| /// with older spec versions. | ||
| #[serde( | ||
| default, | ||
| deserialize_with = "deserialize_ttl_ms", | ||
| skip_serializing_if = "Option::is_none" | ||
| )] | ||
| pub ttl_ms: Option<u64>, | ||
| /// Scope describing who may cache this result (SEP-2549). | ||
| /// Required by spec version 2026-07-28, but optional here to maintain compatibility | ||
| /// with older spec versions. | ||
| #[serde(default, skip_serializing_if = "Option::is_none")] | ||
| pub cache_scope: Option<CacheScope>, | ||
DaleSeo marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| /// The actual content of the resource | ||
| pub contents: Vec<ResourceContents>, | ||
| #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] | ||
| @@ -1375,10 +1444,24 @@ impl ReadResourceResult { | ||
| pub fn new(contents: Vec<ResourceContents>) -> Self { | ||
| Self { | ||
| result_type: ResultType::default(), | ||
| ttl_ms: None, | ||
| cache_scope: None, | ||
| contents, | ||
| meta: None, | ||
| } | ||
| } | ||
| /// 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 | ||
| } | ||
| } | ||
| /// Request to read a specific resource | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| 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": [], | ||
| "resultType": "complete" | ||
| }) | ||
| ); | ||
| 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": [], "resultType": "complete" })); | ||
| } | ||
| #[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::<CacheScope>(json!("private")).unwrap(), | ||
| CacheScope::Private | ||
| ); | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.