mcp_trace_validator/checks/draft/caching.rs
1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! Caching hints: `ttlMs` and `cacheScope` on the results a client may reuse.
5//!
6//! Fourteen of the page's eighteen clauses carry exclusions, and they share one
7//! shape: the page is mostly about what a *client* does with a hint, and a cache
8//! hit is exactly the case where nothing reaches the wire. The freshness rules
9//! are stated in elapsed time as well, which checks may not consult. What is
10//! left — and what is judged here — is the server's side: the hints must be
11//! there, the TTL must be a non-negative number, and a paginated list must not
12//! change its scope halfway through.
13
14use std::collections::HashMap;
15
16use mcp_conformance_core::trace::Direction;
17use serde_json::Value;
18
19use super::super::FindingSink;
20use crate::context::TraceContext;
21
22#[cfg(test)]
23mod tests;
24
25/// The operations whose `complete` results must carry caching hints.
26const CACHEABLE: &[&str] = &[
27 "server/discover",
28 "tools/list",
29 "prompts/list",
30 "resources/list",
31 "resources/templates/list",
32 "resources/read",
33];
34
35/// The `resultType` of a result that is cacheable at all.
36const COMPLETE: &str = "complete";
37
38/// `CACH-001`: cacheable results carry caching hints.
39///
40/// `ttlMs` is the hint required, and `cacheScope` is not: no clause on the page
41/// makes the scope mandatory, while CACH-008 governs the TTL's value and CACH-006
42/// treats an absent TTL as a legacy server. Demanding a `cacheScope` would be
43/// inventing a rule the specification declines to state.
44///
45/// A retry's result is exempt. CACH-003 says a result produced through MRTR
46/// "MUST NOT be cached", and the page's own treatment of `input_required`
47/// results — "not cacheable and carry no caching hints" — is the principle:
48/// uncacheable results need no hints. Without the exemption a server would be
49/// reported for correctly withholding a freshness hint nobody may act on.
50pub(in crate::checks) fn hints_on_cacheable_results(
51 context: &TraceContext<'_>,
52 sink: &mut FindingSink,
53) {
54 for exchange in context.exchanges() {
55 if !CACHEABLE.contains(&exchange.method) {
56 continue;
57 }
58 let Some(result) = exchange.result else {
59 continue;
60 };
61 if result.get("resultType").and_then(Value::as_str) != Some(COMPLETE) {
62 continue;
63 }
64 let from_retry = exchange.params.is_some_and(|params| {
65 params.get("inputResponses").is_some() || params.get("requestState").is_some()
66 });
67 if from_retry {
68 continue;
69 }
70 sink.examined();
71 if result.get("ttlMs").is_some() {
72 continue;
73 }
74 sink.push(
75 Some(exchange.response.seq),
76 format!(
77 "the `complete` result of `{}` carries no `ttlMs` caching hint",
78 exchange.method
79 ),
80 );
81 }
82}
83
84/// `CACH-008`: a server's `ttlMs` is a number, and not a negative one.
85///
86/// Judged wherever a server result carries the field, not only on the six
87/// cacheable operations: the clause binds the value a server *provides*, and a
88/// negative TTL is no more permitted on a result that did not have to carry one.
89pub(in crate::checks) fn ttl_non_negative(context: &TraceContext<'_>, sink: &mut FindingSink) {
90 for (event, _, _) in context.messages() {
91 if event.direction != Direction::ServerToClient {
92 continue;
93 }
94 let Some(ttl) = event
95 .message_payload()
96 .and_then(|payload| payload.get("result"))
97 .and_then(|result| result.get("ttlMs"))
98 else {
99 continue;
100 };
101 sink.examined();
102 match ttl.as_i64() {
103 Some(value) if value >= 0 => {}
104 Some(value) => sink.push(
105 Some(event.seq),
106 format!("`ttlMs` is {value}; servers must provide a value that is >= 0"),
107 ),
108 None => sink.push(
109 Some(event.seq),
110 format!("`ttlMs` is {ttl}, which is not an integer number of milliseconds"),
111 ),
112 }
113 }
114}
115
116/// `CACH-015` and `CACH-016`: every page of one list request shares a scope.
117///
118/// "A given list request" is the cursor chain, and the chain is followed
119/// exactly: a request whose `cursor` equals a previous result's `nextCursor`
120/// continues that page sequence, and a request with no cursor starts a new one.
121/// Grouping by method instead would merge two independent `tools/list` calls,
122/// which the clause does not bind together — a server may legitimately answer
123/// them with different scopes.
124pub(in crate::checks) fn page_scope_consistent(context: &TraceContext<'_>, sink: &mut FindingSink) {
125 // (method, cursor a continuation would present) → the chain it continues.
126 let mut awaiting: HashMap<(&str, String), usize> = HashMap::new();
127 // Chain → the scope its first page declared, and where.
128 let mut scopes: Vec<(Option<String>, u64)> = Vec::new();
129 for exchange in context.exchanges() {
130 let Some(result) = exchange.result else {
131 continue;
132 };
133 let scope = result.get("cacheScope").map(ToString::to_string);
134 let cursor = exchange
135 .params
136 .and_then(|params| params.get("cursor"))
137 .map(ToString::to_string);
138 // Only a continuation page can disagree with the page before it: a
139 // first page is the one that *sets* the scope, so a session with no
140 // multi-page listing leaves this clause untested.
141 let chain = cursor
142 .and_then(|cursor| awaiting.remove(&(exchange.method, cursor)))
143 .map_or_else(
144 || {
145 scopes.push((scope.clone(), exchange.response.seq));
146 scopes.len() - 1
147 },
148 |chain| {
149 sink.examined();
150 chain
151 },
152 );
153 let (first, first_seq) = &scopes[chain];
154 if *first != scope {
155 sink.push(
156 Some(exchange.response.seq),
157 format!(
158 "this `{}` page declares cacheScope {} while the page at seq {first_seq} \
159 in the same request declared {}",
160 exchange.method,
161 scope.as_deref().unwrap_or("none"),
162 first.as_deref().unwrap_or("none")
163 ),
164 );
165 }
166 if let Some(next) = result.get("nextCursor") {
167 awaiting.insert((exchange.method, next.to_string()), chain);
168 }
169 }
170}