Skip to main content

mcp_trace_validator/checks/
support.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! Shared helpers for feature-area checks: declared-capability lookups and the
5//! zero-dependency encoding validators (base64, RFC 3986 scheme syntax) that several
6//! areas judge against.
7
8use serde_json::Value;
9
10use crate::context::TraceContext;
11
12/// What a trace says about one capability.
13///
14/// A tri-state, because *"this session did not declare it"* and *"this session
15/// could not have declared it"* are different facts and only the first is a
16/// violation. Both were reachable in the shipped corpus: a stdio capture that
17/// begins after the handshake, an `initialize` answered with an error, and —
18/// most simply — `corpus/violations/life-001-first-message-not-initialize.jsonl`,
19/// two events long, which answers `tools/list` without ever handshaking.
20///
21/// **Deliberately no `PartialEq`.** This was an `Option<bool>` whose doc said
22/// "judgment must abstain" on `None`, and eight of its nine callers discarded
23/// that arm — six wrote `!= Some(false)` and one `== Some(false)`, each a
24/// character away from correct and each turning an unjudgeable session into a
25/// green row. TOOL-001 and LIFE-009 reported *pass* on the two-event trace
26/// above, and the committed golden had blessed both. Without an equality impl
27/// the only way to read a `Declaration` is to name all three arms, so the
28/// abstention has to be answered rather than compared away.
29#[derive(Clone, Copy, Debug)]
30pub(super) enum Declaration {
31    /// The declaration surface resolves `path` to something that is neither
32    /// `false` nor `null` — the ADR-0006 reading.
33    Declared,
34    /// The declaration surface is present and `path` is not on it. This is the
35    /// only arm a "supported implies declared" clause may fail on.
36    Withheld,
37    /// There is no declaration surface at all: the trace carries no `initialize`
38    /// result, so nothing in it could have declared anything. A check that
39    /// reaches this must abstain — reporting a pass here states evidence the
40    /// trace does not carry (ADR-0012).
41    Unknowable,
42}
43
44/// What the server declared for the capability at `path` (e.g. `["tools"]` or
45/// `["resources", "subscribe"]`), read from the `initialize` result.
46pub(super) fn server_capability(context: &TraceContext<'_>, path: &[&str]) -> Declaration {
47    capability_in(context.server_capabilities(), path, context)
48}
49
50/// The client-side counterpart of [`server_capability`], read from the `initialize`
51/// request params.
52pub(super) fn client_capability(context: &TraceContext<'_>, path: &[&str]) -> Declaration {
53    capability_in(context.client_capabilities(), path, context)
54}
55
56fn capability_in(
57    capabilities: Option<&Value>,
58    path: &[&str],
59    context: &TraceContext<'_>,
60) -> Declaration {
61    // No initialize result at all: there is no declaration surface, so the session's
62    // capability state is unknowable rather than empty.
63    if context.initialize().result.is_none() {
64        return Declaration::Unknowable;
65    }
66    let Some(mut current) = capabilities else {
67        return Declaration::Withheld;
68    };
69    for segment in path {
70        match current.get(segment) {
71            Some(next) => current = next,
72            None => return Declaration::Withheld,
73        }
74    }
75    if current.is_null() || matches!(current, Value::Bool(false)) {
76        Declaration::Withheld
77    } else {
78        Declaration::Declared
79    }
80}
81
82/// `true` when `text` is standard base64 (RFC 4648 §4 alphabet, `=` padding to a
83/// multiple of four, padding only at the end). Validation only — nothing is decoded.
84///
85/// The empty string validates: it is the base64 encoding of zero bytes. The
86/// image/audio/blob content checks therefore accept an empty `data`/`blob` as
87/// "properly encoded" — a deliberate decision, because the rule the registry
88/// quotes is about *encoding*, and flagging empty content would be a
89/// content-completeness judgment the spec does not make here (and one the
90/// official suite does not make, which the agreement check would surface as a
91/// divergence). Empty-but-present content is thus a pass at this layer.
92pub(super) fn is_base64(text: &str) -> bool {
93    let bytes = text.as_bytes();
94    if !bytes.len().is_multiple_of(4) {
95        return false;
96    }
97    let padding = bytes.iter().rev().take_while(|&&b| b == b'=').count();
98    if padding > 2 {
99        return false;
100    }
101    let content = &bytes[..bytes.len() - padding];
102    content
103        .iter()
104        .all(|&b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/')
105}
106
107/// The bytes `text` encodes, for standard base64 (RFC 4648 §4), as UTF-8.
108///
109/// `None` when `text` is not valid base64 or does not decode to UTF-8. Written
110/// here rather than pulled in as a dependency: the judgment surface is
111/// deliberately dependency-free (only `serde`/`serde_json`), and one alphabet
112/// with one padding rule is all the `2026-07-28` header sentinel needs — its
113/// values are "Base64 encoding of the UTF-8 representation"
114/// (`basic/transports/streamable-http#value-encoding`). Gated with its only
115/// caller, since a decoder no build path reaches is dead weight.
116#[cfg(feature = "draft-2026-07-28")]
117pub(super) fn decode_base64(text: &str) -> Option<String> {
118    if !is_base64(text) {
119        return None;
120    }
121    let mut bytes = Vec::with_capacity(text.len() / 4 * 3);
122    let mut accumulator: u32 = 0;
123    let mut bits: u32 = 0;
124    // `is_base64` has already established that `=` appears only as trailing
125    // padding, so stopping at the first one cannot truncate real data.
126    for byte in text.bytes().take_while(|&byte| byte != b'=') {
127        let sextet = match byte {
128            b'A'..=b'Z' => u32::from(byte - b'A'),
129            b'a'..=b'z' => u32::from(byte - b'a') + 26,
130            b'0'..=b'9' => u32::from(byte - b'0') + 52,
131            b'+' => 62,
132            b'/' => 63,
133            _ => return None,
134        };
135        // `+`, not `|`: the shift clears the low six bits and a sextet occupies
136        // only those, so the two are numerically identical here — and `|` would
137        // be an operator no test could ever distinguish from its mutations.
138        accumulator = (accumulator << 6) + sextet;
139        bits += 6;
140        if bits >= 8 {
141            bits -= 8;
142            bytes.push(u8::try_from((accumulator >> bits) & 0xff).ok()?);
143        }
144    }
145    String::from_utf8(bytes).ok()
146}
147
148/// `true` when `uri` begins with an RFC 3986 §3.1 scheme followed by `:`:
149/// `ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )`. Judges scheme syntax only — the
150/// registry documents that deeper RFC 3986 validation is out of trace scope.
151pub(super) fn has_rfc3986_scheme(uri: &str) -> bool {
152    let Some((scheme, _)) = uri.split_once(':') else {
153        return false;
154    };
155    let mut chars = scheme.chars();
156    let Some(first) = chars.next() else {
157        return false;
158    };
159    first.is_ascii_alphabetic()
160        && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
161}
162
163#[cfg(test)]
164#[allow(clippy::unwrap_used)]
165mod tests {
166    use super::*;
167
168    use crate::checks;
169    use crate::reader::{Limits, parse_trace};
170
171    /// Every check that reads a [`Declaration`], and the traffic that evidences
172    /// the support each one judges.
173    const CAPABILITY_CHECKS: [&str; 7] = [
174        "tools.capability-declared",
175        "tools.embedded-resource-capability",
176        "resources.capability-declared",
177        "prompts.capability-declared",
178        "logging.capability-declared",
179        "completion.capability-declared",
180        "lifecycle.negotiated-capabilities-only",
181    ];
182
183    /// A session exercising every capability-gated feature, optionally preceded
184    /// by a handshake declaring all of them.
185    fn session(handshake: bool) -> String {
186        let mut lines: Vec<String> = Vec::new();
187        if handshake {
188            lines.push(r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}}"#.to_owned());
189            lines.push(r#"{"seq":1,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{},"resources":{},"prompts":{},"logging":{},"completions":{}},"serverInfo":{"name":"s","version":"0"}}}}"#.to_owned());
190        }
191        for line in [
192            r#"{"seq":2,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"method":"tools/list"}}"#,
193            r#"{"seq":3,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":2,"result":{"tools":[]}}}"#,
194            r#"{"seq":4,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":3,"method":"resources/read","params":{"uri":"file:///a"}}}"#,
195            r#"{"seq":5,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":3,"result":{"contents":[]}}}"#,
196            r#"{"seq":6,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":4,"method":"prompts/get","params":{"name":"p"}}}"#,
197            r#"{"seq":7,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":4,"result":{"messages":[]}}}"#,
198            r#"{"seq":8,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":5,"method":"completion/complete","params":{}}}"#,
199            r#"{"seq":9,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":5,"result":{"completion":{"values":[]}}}}"#,
200            r#"{"seq":10,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","data":"x"}}}"#,
201            r#"{"seq":11,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"t"}}}"#,
202            r#"{"seq":12,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":6,"result":{"content":[{"type":"resource","resource":{"uri":"file:///a","text":"x"}}]}}}"#,
203        ] {
204            lines.push(line.to_owned());
205        }
206        lines.join("\n")
207    }
208
209    fn subjects_and_findings(check: &str, trace: &str) -> (u32, usize) {
210        let events = parse_trace(trace, &Limits::default()).unwrap();
211        let context = TraceContext::new(&events);
212        let outcome = checks::find(check).unwrap().run(&context);
213        (outcome.subjects, outcome.findings.len())
214    }
215
216    /// The rule [`Declaration::Unknowable`] exists for, over every check that
217    /// reads one: a session whose declarations are not in the trace earns no
218    /// verdict on whether it declared them.
219    ///
220    /// This asserts on the *subject count*, not on findings, and that is the
221    /// whole point. An abstention and a pass both have no findings, so
222    /// `findings.is_empty()` — which is what each area's own tests assert —
223    /// cannot tell them apart. Eight of these callers used to read the
224    /// declaration as "present unless explicitly denied"; every one still
225    /// produced no findings here, and every one reported a green row on a
226    /// session that never carried a declaration at all.
227    #[test]
228    fn no_declaration_surface_means_no_verdict() {
229        let trace = session(false);
230        for check in CAPABILITY_CHECKS {
231            let (subjects, findings) = subjects_and_findings(check, &trace);
232            assert_eq!(
233                subjects, 0,
234                "{check} counted a subject in a session with no initialize result, \
235                 so the clause it backs reports a pass it cannot support"
236            );
237            assert_eq!(findings, 0, "{check} judged an unjudgeable session");
238        }
239    }
240
241    /// The other half, without which the abstention above could be satisfied by
242    /// a check that never judges anything: the same traffic, behind a handshake,
243    /// is judged.
244    #[test]
245    fn a_declaration_surface_is_judged() {
246        let trace = session(true);
247        for check in CAPABILITY_CHECKS {
248            let (subjects, findings) = subjects_and_findings(check, &trace);
249            assert!(subjects > 0, "{check} found nothing to judge");
250            assert_eq!(
251                findings, 0,
252                "{check} faulted a session that declared everything it used"
253            );
254        }
255    }
256
257    #[test]
258    fn a_present_surface_that_withholds_the_capability_is_a_violation() {
259        // The arm that must stay distinguishable from the abstention: the
260        // handshake is there and declares nothing.
261        let trace = session(true).replace(
262            r#""capabilities":{"tools":{},"resources":{},"prompts":{},"logging":{},"completions":{}}"#,
263            r#""capabilities":{}"#,
264        );
265        for check in CAPABILITY_CHECKS {
266            let (subjects, findings) = subjects_and_findings(check, &trace);
267            assert!(subjects > 0, "{check} found nothing to judge");
268            assert!(findings > 0, "{check} excused an undeclared capability");
269        }
270    }
271
272    #[test]
273    fn base64_validation_is_exact() {
274        for valid in ["", "aGk=", "aGV5", "aGV5bw==", "AB+/", "QUJDRA=="] {
275            assert!(is_base64(valid), "{valid:?} should validate");
276        }
277        for invalid in [
278            "aGk",     // length not a multiple of four
279            "aGk =",   // space in alphabet
280            "aGk!",    // symbol outside alphabet
281            "====",    // padding longer than two
282            "aG=k",    // padding before the end
283            "aGV5bw=", // wrong padding length for content
284        ] {
285            assert!(!is_base64(invalid), "{invalid:?} should not validate");
286        }
287    }
288
289    #[cfg(feature = "draft-2026-07-28")]
290    #[test]
291    fn base64_decoding_round_trips_the_specification_examples() {
292        // The encoding table in `basic/transports/streamable-http#value-encoding`,
293        // verbatim: each encoded header value must decode back to its original.
294        for (encoded, original) in [
295            ("SGVsbG8sIOS4lueVjA==", "Hello, 世界"),
296            ("IHBhZGRlZCA=", " padded "),
297            ("bGluZTEKbGluZTI=", "line1\nline2"),
298            ("PT9iYXNlNjQ/bGl0ZXJhbD89", "=?base64?literal?="),
299        ] {
300            assert_eq!(
301                decode_base64(encoded).as_deref(),
302                Some(original),
303                "{encoded:?} should decode to {original:?}"
304            );
305        }
306        assert_eq!(decode_base64("").as_deref(), Some(""));
307    }
308
309    #[cfg(feature = "draft-2026-07-28")]
310    #[test]
311    fn base64_decoding_covers_the_whole_alphabet_and_every_padding_length() {
312        // `+` and `/` are the two alphabet entries a lazy table would omit.
313        assert_eq!(decode_base64("fn5+").as_deref(), Some("~~~"));
314        assert_eq!(decode_base64("fn4/").as_deref(), Some("~~?"));
315        // Each padding length exercises a different number of emitted bytes.
316        assert_eq!(decode_base64("YQ==").as_deref(), Some("a")); // 1 byte
317        assert_eq!(decode_base64("YWI=").as_deref(), Some("ab")); // 2 bytes
318        assert_eq!(decode_base64("YWJj").as_deref(), Some("abc")); // 3 bytes
319        // Ordering matters: the bits accumulate most-significant sextet first,
320        // so a transposition must not decode to the same text.
321        assert_eq!(decode_base64("YmFj").as_deref(), Some("bac"));
322    }
323
324    #[cfg(feature = "draft-2026-07-28")]
325    #[test]
326    fn base64_decoding_refuses_what_it_cannot_represent() {
327        // Not base64 at all.
328        assert_eq!(decode_base64("aGk"), None);
329        assert_eq!(decode_base64("aG=k"), None);
330        // Valid base64 whose bytes are not UTF-8 (0xFF is never a UTF-8 lead byte).
331        assert_eq!(decode_base64("/w=="), None);
332    }
333
334    #[test]
335    fn rfc3986_scheme_validation_is_exact() {
336        for valid in ["https://x", "file:///a", "git://r", "a:", "z+ssh.2-x:rest"] {
337            assert!(has_rfc3986_scheme(valid), "{valid:?} should validate");
338        }
339        for invalid in [
340            "",           // no scheme at all
341            "no-colon",   // not a URI
342            ":rest",      // empty scheme
343            "1https://x", // scheme must start with ALPHA
344            "ht tp://x",  // space inside scheme
345            "ht_tp://x",  // underscore is not scheme syntax
346        ] {
347            assert!(
348                !has_rfc3986_scheme(invalid),
349                "{invalid:?} should not validate"
350            );
351        }
352    }
353}