Skip to main content

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}