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/// Whether the server declared the capability at `path` (e.g. `["tools"]` or
13/// `["resources", "subscribe"]`): every segment resolves and the final value is
14/// neither `false` nor `null` — the ADR-0006 reading. Returns `None` when the trace
15/// has no `initialize` result to read declarations from (judgment must abstain), and
16/// `Some(declared)` otherwise.
17pub(super) fn server_capability(context: &TraceContext<'_>, path: &[&str]) -> Option<bool> {
18    capability_in(context.server_capabilities(), path, context)
19}
20
21/// The client-side counterpart of [`server_capability`], read from the `initialize`
22/// request params.
23pub(super) fn client_capability(context: &TraceContext<'_>, path: &[&str]) -> Option<bool> {
24    capability_in(context.client_capabilities(), path, context)
25}
26
27fn capability_in(
28    capabilities: Option<&Value>,
29    path: &[&str],
30    context: &TraceContext<'_>,
31) -> Option<bool> {
32    // No initialize result at all: there is no declaration surface, so the session's
33    // capability state is unknowable rather than empty.
34    context.initialize().result?;
35    let Some(mut current) = capabilities else {
36        return Some(false);
37    };
38    for segment in path {
39        match current.get(segment) {
40            Some(next) => current = next,
41            None => return Some(false),
42        }
43    }
44    Some(!(current.is_null() || matches!(current, Value::Bool(false))))
45}
46
47/// `true` when `text` is standard base64 (RFC 4648 §4 alphabet, `=` padding to a
48/// multiple of four, padding only at the end). Validation only — nothing is decoded.
49///
50/// The empty string validates: it is the base64 encoding of zero bytes. The
51/// image/audio/blob content checks therefore accept an empty `data`/`blob` as
52/// "properly encoded" — a deliberate decision, because the rule the registry
53/// quotes is about *encoding*, and flagging empty content would be a
54/// content-completeness judgment the spec does not make here (and one the
55/// official suite does not make, which the agreement check would surface as a
56/// divergence). Empty-but-present content is thus a pass at this layer.
57pub(super) fn is_base64(text: &str) -> bool {
58    let bytes = text.as_bytes();
59    if !bytes.len().is_multiple_of(4) {
60        return false;
61    }
62    let padding = bytes.iter().rev().take_while(|&&b| b == b'=').count();
63    if padding > 2 {
64        return false;
65    }
66    let content = &bytes[..bytes.len() - padding];
67    content
68        .iter()
69        .all(|&b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/')
70}
71
72/// `true` when `uri` begins with an RFC 3986 §3.1 scheme followed by `:`:
73/// `ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )`. Judges scheme syntax only — the
74/// registry documents that deeper RFC 3986 validation is out of trace scope.
75pub(super) fn has_rfc3986_scheme(uri: &str) -> bool {
76    let Some((scheme, _)) = uri.split_once(':') else {
77        return false;
78    };
79    let mut chars = scheme.chars();
80    let Some(first) = chars.next() else {
81        return false;
82    };
83    first.is_ascii_alphabetic()
84        && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn base64_validation_is_exact() {
93        for valid in ["", "aGk=", "aGV5", "aGV5bw==", "AB+/", "QUJDRA=="] {
94            assert!(is_base64(valid), "{valid:?} should validate");
95        }
96        for invalid in [
97            "aGk",     // length not a multiple of four
98            "aGk =",   // space in alphabet
99            "aGk!",    // symbol outside alphabet
100            "====",    // padding longer than two
101            "aG=k",    // padding before the end
102            "aGV5bw=", // wrong padding length for content
103        ] {
104            assert!(!is_base64(invalid), "{invalid:?} should not validate");
105        }
106    }
107
108    #[test]
109    fn rfc3986_scheme_validation_is_exact() {
110        for valid in ["https://x", "file:///a", "git://r", "a:", "z+ssh.2-x:rest"] {
111            assert!(has_rfc3986_scheme(valid), "{valid:?} should validate");
112        }
113        for invalid in [
114            "",           // no scheme at all
115            "no-colon",   // not a URI
116            ":rest",      // empty scheme
117            "1https://x", // scheme must start with ALPHA
118            "ht tp://x",  // space inside scheme
119            "ht_tp://x",  // underscore is not scheme syntax
120        ] {
121            assert!(
122                !has_rfc3986_scheme(invalid),
123                "{invalid:?} should not validate"
124            );
125        }
126    }
127}