use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CacheScope {
Public,
#[default]
Private,
}
#[cfg(test)]
const ALL_SCOPES: &[CacheScope] = &[CacheScope::Public, CacheScope::Private];
pub const DEFAULT_TTL_MS: u64 = 0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Cacheable {
Yes,
No,
}
pub(crate) fn project_caching_hints(
value: &mut serde_json::Value,
era: Option<crate::types::protocol::Era>,
cacheable: Cacheable,
) {
if matches!(cacheable, Cacheable::No) {
return;
}
let Some(object) = value.as_object_mut() else {
return;
};
if matches!(era, Some(crate::types::protocol::Era::V2)) {
object
.entry("ttlMs")
.or_insert_with(|| serde_json::Value::from(DEFAULT_TTL_MS));
object.entry("cacheScope").or_insert_with(|| {
serde_json::to_value(CacheScope::default()).expect("a unit enum always serializes")
});
} else {
object.shift_remove("ttlMs");
object.shift_remove("cacheScope");
}
}
#[cfg(test)]
mod projection_tests {
use super::{project_caching_hints, CacheScope, Cacheable, DEFAULT_TTL_MS};
use crate::types::protocol::Era;
use serde_json::json;
#[test]
fn v2_inserts_the_safe_defaults() {
let mut value = json!({ "tools": [] });
project_caching_hints(&mut value, Some(Era::V2), Cacheable::Yes);
assert_eq!(
value["ttlMs"],
json!(DEFAULT_TTL_MS),
"a v2 projection must carry the required `ttlMs`, got {value}"
);
assert_eq!(
value["cacheScope"],
json!("private"),
"an un-considered response must default to the non-leaking scope, got {value}"
);
assert_eq!(value["tools"], json!([]), "existing keys must be untouched");
}
#[test]
fn v2_preserves_handler_set_values() {
let mut value = json!({ "ttlMs": 300_000, "cacheScope": "public" });
project_caching_hints(&mut value, Some(Era::V2), Cacheable::Yes);
assert_eq!(
value["ttlMs"],
json!(300_000),
"a handler-set ttlMs must survive the projection verbatim, got {value}"
);
assert_eq!(
value["cacheScope"],
json!("public"),
"a handler-set cacheScope must survive the projection verbatim, got {value}"
);
}
#[test]
fn v1_strips_handler_set_values() {
let mut value = json!({ "resources": [], "ttlMs": 300_000, "cacheScope": "public" });
project_caching_hints(&mut value, Some(Era::V1), Cacheable::Yes);
assert!(
value.get("ttlMs").is_none(),
"D-11: a v1 response must never carry `ttlMs`, got {value}"
);
assert!(
value.get("cacheScope").is_none(),
"D-11: a v1 response must never carry `cacheScope`, got {value}"
);
assert_eq!(
value["resources"],
json!([]),
"the strip must not disturb any other key"
);
}
#[test]
fn no_context_strips_both_keys_which_is_the_wasm_path() {
let mut value = json!({
"contents": [],
"ttlMs": 300_000,
"cacheScope": "public",
"_meta": { "keep": true }
});
project_caching_hints(&mut value, None, Cacheable::Yes);
assert!(
value.get("ttlMs").is_none(),
"an era-less dispatcher must strip `ttlMs`, got {value}"
);
assert!(
value.get("cacheScope").is_none(),
"an era-less dispatcher must strip `cacheScope`, got {value}"
);
assert_eq!(
value["contents"],
json!([]),
"every other key must be untouched by the strip"
);
assert_eq!(
value["_meta"],
json!({ "keep": true }),
"every other key must be untouched by the strip"
);
}
#[test]
fn not_cacheable_is_the_identity() {
let before = json!({ "content": [], "ttlMs": 5, "cacheScope": "public" });
let mut value = before.clone();
project_caching_hints(&mut value, Some(Era::V2), Cacheable::No);
assert_eq!(
value, before,
"a non-CacheableResult body must not be touched at all"
);
let mut value = before.clone();
project_caching_hints(&mut value, Some(Era::V1), Cacheable::No);
assert_eq!(
value, before,
"the identity must hold on every era, not just v2"
);
}
#[test]
fn a_non_object_value_is_untouched() {
for mut value in [json!(null), json!([1, 2, 3]), json!("a string"), json!(7)] {
let before = value.clone();
project_caching_hints(&mut value, Some(Era::V2), Cacheable::Yes);
assert_eq!(
value, before,
"a non-object result body cannot carry a key and must be left alone"
);
}
assert_eq!(CacheScope::default(), CacheScope::Private);
}
}
#[cfg(test)]
mod caching_properties {
use super::{CacheScope, ALL_SCOPES};
proptest::proptest! {
#[test]
fn property_cache_scope_serde_round_trips_for_every_variant(
index in 0usize..ALL_SCOPES.len(),
) {
let scope = ALL_SCOPES[index];
let raw = serde_json::to_string(&scope).expect("a unit enum always serializes");
proptest::prop_assert!(
raw == "\"public\"" || raw == "\"private\"",
"the schema declares a two-value enum; {:?} serialized to {}",
scope,
raw
);
let back: CacheScope =
serde_json::from_str(&raw).expect("the serialized form must deserialize");
proptest::prop_assert_eq!(
back,
scope,
"a CacheScope round trip must be the identity"
);
}
#[test]
fn property_an_arbitrary_string_is_accepted_as_a_cache_scope_iff_it_is_one_of_the_two(
candidate in "[a-zA-Z_-]{0,16}",
) {
let json = serde_json::to_string(&candidate).expect("a string always serializes");
let parsed = serde_json::from_str::<CacheScope>(&json);
let is_declared = candidate == "public" || candidate == "private";
proptest::prop_assert_eq!(
parsed.is_ok(),
is_declared,
"CacheScope is a CLOSED union: {} must deserialize iff it is one of the two \
declared values, but from_str returned {:?}",
json,
parsed.map(|scope| format!("{scope:?}"))
);
}
}
}
#[cfg(test)]
mod cacheable_result_serde_locks {
use super::{CacheScope, DEFAULT_TTL_MS};
use serde_json::Value;
const CORE_SCHEMA_JSON: &str =
include_str!("../../schema/vendored/core-2026-07-28/schema.json");
const REMEDY: &str = "if the vendored contract changed, re-run the `## Change protocol` in \
schema/vendored/core-2026-07-28/PROVENANCE.md and update the RUST side, never this assertion";
const INJECTED_ELSEWHERE: &[&str] = &["resultType"];
fn cacheable_result_def() -> Value {
let schema: Value =
serde_json::from_str(CORE_SCHEMA_JSON).expect("the vendored core schema parses");
schema
.pointer("/$defs/CacheableResult")
.unwrap_or_else(|| panic!("/$defs/CacheableResult must resolve — {REMEDY}"))
.clone()
}
fn cacheable_result_required() -> Vec<String> {
let def = cacheable_result_def();
let mut required: Vec<String> = def["required"]
.as_array()
.unwrap_or_else(|| panic!("$defs.CacheableResult.required is an array — {REMEDY}"))
.iter()
.map(|v| {
v.as_str()
.expect("a required entry is a string")
.to_string()
})
.collect();
required.sort();
required
}
fn hinted_list_resources() -> crate::types::ListResourcesResult {
crate::types::ListResourcesResult::new(vec![])
.with_ttl_ms(60_000)
.with_cache_scope(CacheScope::Public)
}
#[test]
fn rust_field_spellings_match_the_vendored_required_set() {
let required = cacheable_result_required();
assert_eq!(
required,
vec!["cacheScope", "resultType", "ttlMs"],
"the vendored CacheableResult.required set moved — {REMEDY}"
);
let raw = serde_json::to_string(&hinted_list_resources()).expect("serializes");
let emitted: Value = serde_json::from_str(&raw).expect("round-trips");
let emitted = emitted
.as_object()
.expect("a result serializes to an object");
for key in &required {
if INJECTED_ELSEWHERE.contains(&key.as_str()) {
assert!(
!emitted.contains_key(key),
"`{key}` is injected by inject_v2_result_envelope and must NOT be a \
struct field; found it in {raw}"
);
} else {
assert!(
emitted.contains_key(key),
"the vendored contract requires `{key}` but no Rust field emits it — {REMEDY}"
);
}
}
assert!(
!raw.contains("ttl_ms"),
"the wire spelling is `ttlMs`; a snake_case key leaked into {raw}"
);
assert!(
!raw.contains("cache_scope"),
"the wire spelling is `cacheScope`; a snake_case key leaked into {raw}"
);
}
#[test]
fn cache_scope_wire_values_match_the_vendored_enum() {
let def = cacheable_result_def();
let mut declared: Vec<String> = def["properties"]["cacheScope"]["enum"]
.as_array()
.unwrap_or_else(|| panic!("cacheScope declares an enum — {REMEDY}"))
.iter()
.map(|v| v.as_str().expect("an enum entry is a string").to_string())
.collect();
declared.sort();
assert_eq!(
declared,
vec!["private", "public"],
"the vendored cacheScope variant set moved — {REMEDY}"
);
assert_eq!(
serde_json::to_string(&CacheScope::Public).expect("serializes"),
"\"public\"",
"CacheScope::Public must spell `public` on the wire"
);
assert_eq!(
serde_json::to_string(&CacheScope::Private).expect("serializes"),
"\"private\"",
"CacheScope::Private must spell `private` on the wire"
);
for variant in [CacheScope::Public, CacheScope::Private] {
let raw = serde_json::to_string(&variant).expect("serializes");
let back: CacheScope = serde_json::from_str(&raw).expect("round-trips");
assert_eq!(
back, variant,
"a CacheScope round-trip must be the identity, {raw} came back as {back:?}"
);
}
}
#[test]
fn ttl_ms_rust_type_matches_the_vendored_json_schema_type() {
let def = cacheable_result_def();
let ttl = &def["properties"]["ttlMs"];
assert_eq!(
ttl["type"], "integer",
"ttlMs is no longer an integer; `u64` would reject a conformant peer — {REMEDY}"
);
assert_eq!(
ttl["minimum"], 0,
"ttlMs's declared minimum moved — {REMEDY}"
);
assert_eq!(u64::MIN, 0, "u64 must represent the declared minimum of 0");
let extreme = crate::types::ListResourcesResult::new(vec![]).with_ttl_ms(u64::MAX);
let emitted: Value =
serde_json::from_str(&serde_json::to_string(&extreme).expect("serializes"))
.expect("round-trips");
assert!(
emitted["ttlMs"].is_u64(),
"ttlMs must serialize as a JSON integer, not a float or a string; got {}",
emitted["ttlMs"]
);
}
#[test]
fn an_unknown_cache_scope_value_is_rejected() {
let parsed = serde_json::from_str::<CacheScope>("\"shared\"");
assert!(
parsed.is_err(),
"CacheScope is a CLOSED union; `shared` must not deserialize, got {parsed:?}"
);
}
#[test]
fn unset_hints_emit_no_key_at_all() {
let bodies = vec![
(
"ListToolsResult",
serde_json::to_string(&crate::types::ListToolsResult::new(vec![])),
),
(
"ListResourcesResult",
serde_json::to_string(&crate::types::ListResourcesResult::new(vec![])),
),
(
"ListResourceTemplatesResult",
serde_json::to_string(&crate::types::ListResourceTemplatesResult::new(vec![])),
),
(
"ReadResourceResult",
serde_json::to_string(&crate::types::ReadResourceResult::new(vec![])),
),
(
"ListPromptsResult",
serde_json::to_string(&crate::types::ListPromptsResult::new(vec![])),
),
(
"ServerDiscoverResult",
serde_json::to_string(&crate::types::ServerDiscoverResult {
protocol_version: "2026-07-28".to_string(),
capabilities: crate::types::ServerCapabilities::default(),
server_info: crate::types::Implementation::new("t", "0.0.0"),
ttl_ms: None,
cache_scope: None,
}),
),
];
for (name, raw) in bodies {
let raw = raw.expect("serializes");
assert!(
!raw.contains("ttlMs"),
"{name} with an unset hint must not emit `ttlMs`, got {raw}"
);
assert!(
!raw.contains("cacheScope"),
"{name} with an unset hint must not emit `cacheScope`, got {raw}"
);
}
}
#[test]
fn the_default_cache_scope_is_private_and_the_default_ttl_is_zero() {
assert_eq!(
CacheScope::default(),
CacheScope::Private,
"changing the default to Public is a cross-authorization-context data leak: a \
shared gateway would be authorized to serve one caller's response body to \
another caller holding a different access token"
);
assert_eq!(
DEFAULT_TTL_MS, 0,
"the SDK default must assert NOTHING about cacheability; 0 means immediately stale"
);
}
#[test]
fn the_vendored_schema_lookup_is_not_vacuous() {
let schema: Value =
serde_json::from_str(CORE_SCHEMA_JSON).expect("the vendored core schema parses");
assert!(
schema.pointer("/$defs/CacheableResult").is_some(),
"the CacheableResult definition must resolve at /$defs/CacheableResult — {REMEDY}"
);
assert_eq!(
schema
.pointer("/$defs/CacheableResult/required")
.and_then(Value::as_array)
.map(Vec::len),
Some(3),
"CacheableResult.required must have exactly three entries — {REMEDY}"
);
assert!(
CORE_SCHEMA_JSON.len() > 150_000,
"the vendored artifact shrank to {} bytes; these locks may be asserting over \
a truncated schema — {REMEDY}",
CORE_SCHEMA_JSON.len()
);
}
}