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}