Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/rmcp-macros/src/prompt_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ pub fn prompt_handler(attr: TokenStream, input: TokenStream) -> syn::Result<Toke
prompts,
meta: #meta,
next_cursor: None,
ttl_ms: None,
cache_scope: None,
})
}
};
Expand Down
2 changes: 2 additions & 0 deletions crates/rmcp-macros/src/tool_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ pub fn tool_handler(attr: TokenStream, input: TokenStream) -> syn::Result<TokenS
tools: #router.list_all(),
meta: #result_meta,
next_cursor: None,
ttl_ms: None,
cache_scope: None,
})
}
})?;
Expand Down
8 changes: 4 additions & 4 deletions crates/rmcp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -357,10 +357,10 @@ path = "tests/test_unix_socket_transport.rs"
[[test]]
name = "test_streamable_http_stale_session"
required-features = [
"server",
"client",
"transport-streamable-http-server",
"transport-streamable-http-client",
"server",
"client",
"transport-streamable-http-server",
"transport-streamable-http-client",
"transport-streamable-http-client-reqwest"
]
path = "tests/test_streamable_http_stale_session.rs"
Expand Down
93 changes: 88 additions & 5 deletions crates/rmcp/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment thread
michaelneale marked this conversation as resolved.
/// 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
Expand All @@ -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")]
Comment thread
jamadeo marked this conversation as resolved.
#[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>,
Comment thread
DaleSeo marked this conversation as resolved.
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
}
}
};
}
Expand Down Expand Up @@ -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>,
Comment thread
DaleSeo marked this conversation as resolved.
/// The actual content of the resource
pub contents: Vec<ResourceContents>,
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
Expand All @@ -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
Expand Down
79 changes: 79 additions & 0 deletions crates/rmcp/tests/test_cache_hints.rs
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
);
}
Loading