use std::collections::HashMap;
use mcp_conformance_core::trace::Direction;
use serde_json::Value;
use super::super::FindingSink;
use crate::context::TraceContext;
#[cfg(test)]
mod tests;
const CACHEABLE: &[&str] = &[
"server/discover",
"tools/list",
"prompts/list",
"resources/list",
"resources/templates/list",
"resources/read",
];
const COMPLETE: &str = "complete";
pub(in crate::checks) fn hints_on_cacheable_results(
context: &TraceContext<'_>,
sink: &mut FindingSink,
) {
for exchange in context.exchanges() {
if !CACHEABLE.contains(&exchange.method) {
continue;
}
let Some(result) = exchange.result else {
continue;
};
if result.get("resultType").and_then(Value::as_str) != Some(COMPLETE) {
continue;
}
let from_retry = exchange.params.is_some_and(|params| {
params.get("inputResponses").is_some() || params.get("requestState").is_some()
});
if from_retry {
continue;
}
sink.examined();
if result.get("ttlMs").is_some() {
continue;
}
sink.push(
Some(exchange.response.seq),
format!(
"the `complete` result of `{}` carries no `ttlMs` caching hint",
exchange.method
),
);
}
}
pub(in crate::checks) fn ttl_non_negative(context: &TraceContext<'_>, sink: &mut FindingSink) {
for (event, _, _) in context.messages() {
if event.direction != Direction::ServerToClient {
continue;
}
let Some(ttl) = event
.message_payload()
.and_then(|payload| payload.get("result"))
.and_then(|result| result.get("ttlMs"))
else {
continue;
};
sink.examined();
match ttl.as_i64() {
Some(value) if value >= 0 => {}
Some(value) => sink.push(
Some(event.seq),
format!("`ttlMs` is {value}; servers must provide a value that is >= 0"),
),
None => sink.push(
Some(event.seq),
format!("`ttlMs` is {ttl}, which is not an integer number of milliseconds"),
),
}
}
}
pub(in crate::checks) fn page_scope_consistent(context: &TraceContext<'_>, sink: &mut FindingSink) {
let mut awaiting: HashMap<(&str, String), usize> = HashMap::new();
let mut scopes: Vec<(Option<String>, u64)> = Vec::new();
for exchange in context.exchanges() {
let Some(result) = exchange.result else {
continue;
};
let scope = result.get("cacheScope").map(ToString::to_string);
let cursor = exchange
.params
.and_then(|params| params.get("cursor"))
.map(ToString::to_string);
let chain = cursor
.and_then(|cursor| awaiting.remove(&(exchange.method, cursor)))
.map_or_else(
|| {
scopes.push((scope.clone(), exchange.response.seq));
scopes.len() - 1
},
|chain| {
sink.examined();
chain
},
);
let (first, first_seq) = &scopes[chain];
if *first != scope {
sink.push(
Some(exchange.response.seq),
format!(
"this `{}` page declares cacheScope {} while the page at seq {first_seq} \
in the same request declared {}",
exchange.method,
scope.as_deref().unwrap_or("none"),
first.as_deref().unwrap_or("none")
),
);
}
if let Some(next) = result.get("nextCursor") {
awaiting.insert((exchange.method, next.to_string()), chain);
}
}
}