Skip to main content

mcp_trace_validator/
engine.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! The validation engine: registry × trace → report.
5//!
6//! [`validate`] is a pure function. It builds the [`TraceContext`] once, then walks the
7//! registry **in registry order**, producing exactly one [`RequirementReport`] per
8//! requirement. Checks shared across requirements run once per referencing requirement —
9//! the same evidence violating two clauses is two findings, which is what
10//! requirement-level accounting means.
11
12use mcp_conformance_core::capability::{CapabilityGate, CapabilityParty};
13use mcp_conformance_core::requirement::{Registry, Requirement, Verification};
14use mcp_conformance_core::trace::TraceEvent;
15
16use crate::checks;
17use crate::context::TraceContext;
18use crate::report::{Outcome, Report, RequirementReport, Totals};
19
20/// Validates a parsed trace against a requirement registry.
21///
22/// ```
23/// use mcp_conformance_core::requirement::Registry;
24/// use mcp_trace_validator::report::Verdict;
25/// use mcp_trace_validator::{engine, reader};
26///
27/// let registry = Registry::builtin_2025_11_25()?;
28/// let trace = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"tools/list"}}"#;
29/// let events = reader::parse_trace(trace, &reader::Limits::default())?;
30/// let report = engine::validate(&registry, &events);
31/// assert_eq!(report.verdict(), Verdict::Fail); // tools/list before initialize
32/// # Ok::<(), Box<dyn core::error::Error>>(())
33/// ```
34#[must_use]
35pub fn validate(registry: &Registry, events: &[TraceEvent]) -> Report {
36    let context = TraceContext::new(events);
37    let mut totals = Totals::default();
38    let mut rows = Vec::with_capacity(registry.requirements().len());
39
40    for requirement in registry.requirements() {
41        let row = build_row(requirement, &context);
42        tally(&mut totals, row.outcome);
43        rows.push(row);
44    }
45
46    Report {
47        revision: registry.revision().to_string(),
48        totals,
49        requirements: rows,
50    }
51}
52
53fn build_row(requirement: &Requirement, context: &TraceContext<'_>) -> RequirementReport {
54    let mut row = RequirementReport {
55        id: requirement.id.to_string(),
56        level: requirement.level.keyword().to_owned(),
57        outcome: Outcome::Unsupported,
58        findings: vec![],
59        exclusion: None,
60        missing_checks: vec![],
61        capability: None,
62    };
63    match &requirement.verification {
64        Verification::Excluded { exclusion } => {
65            row.outcome = Outcome::Excluded;
66            row.exclusion = Some(exclusion.clone());
67        }
68        Verification::Checks { checks: check_ids } => {
69            // Resolve the inventory before consulting the capability gate:
70            // `unsupported` is a property of (registry, build) and must not vary
71            // with what a particular trace negotiated (ADR-0006 precedence).
72            let mut resolved = Vec::with_capacity(check_ids.len());
73            for check_id in check_ids {
74                match checks::find(check_id) {
75                    Some(check) => resolved.push(check),
76                    None => row.missing_checks.push(check_id.clone()),
77                }
78            }
79            if !row.missing_checks.is_empty() {
80                row.outcome = Outcome::Unsupported;
81            } else if let Some(gate) = undeclared_gate(requirement, context) {
82                row.outcome = Outcome::NotApplicable;
83                row.capability = Some(gate.as_str().to_owned());
84            } else {
85                for check in resolved {
86                    row.findings.extend(check.run(context));
87                }
88                row.outcome =
89                    classify_outcome(requirement.level.is_error(), row.findings.is_empty());
90            }
91        }
92        // Verification is #[non_exhaustive]; a future arm must be handled
93        // deliberately, and the pre-set "unsupported" outcome is the conservative
94        // reading until then.
95        _ => {}
96    }
97    row
98}
99
100/// The requirement's capability gate, when the session never declared it.
101fn undeclared_gate<'r>(
102    requirement: &'r Requirement,
103    context: &TraceContext<'_>,
104) -> Option<&'r CapabilityGate> {
105    let gate = requirement.capability.as_ref()?;
106    let capabilities = match gate.party() {
107        CapabilityParty::Server => context.server_capabilities(),
108        CapabilityParty::Client => context.client_capabilities(),
109    };
110    if gate.is_declared(capabilities) {
111        None
112    } else {
113        Some(gate)
114    }
115}
116
117/// Exhaustive on purpose (same-crate enum): adding an Outcome variant must force a
118/// deliberate decision about how totals count it.
119const fn tally(totals: &mut Totals, outcome: Outcome) {
120    match outcome {
121        Outcome::Pass => totals.pass += 1,
122        Outcome::Fail => totals.fail += 1,
123        Outcome::Warn => totals.warn += 1,
124        Outcome::Excluded => totals.excluded += 1,
125        Outcome::Unsupported => totals.unsupported += 1,
126        Outcome::NotApplicable => totals.not_applicable += 1,
127    }
128}
129
130const fn classify_outcome(is_error_level: bool, clean: bool) -> Outcome {
131    if clean {
132        Outcome::Pass
133    } else if is_error_level {
134        Outcome::Fail
135    } else {
136        Outcome::Warn
137    }
138}
139
140#[cfg(test)]
141#[allow(clippy::unwrap_used)]
142mod tests {
143    use super::*;
144    use crate::reader::{Limits, parse_trace};
145    use mcp_conformance_core::requirement::Registry;
146
147    const HAPPY: &str = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"lifecycle","event":"transport-open"}
148{"seq":1,"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"}}}}
149{"seq":2,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"s","version":"0"}}}}
150{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/initialized"}}"#;
151
152    #[test]
153    fn happy_path_passes_every_checked_requirement() {
154        use mcp_conformance_core::requirement::Verification;
155        let registry = Registry::builtin_2025_11_25().unwrap();
156        let events = parse_trace(HAPPY, &Limits::default()).unwrap();
157        let report = validate(&registry, &events);
158        assert!(!report.has_errors(), "{}", report.render_human());
159        assert!(!report.has_warnings(), "{}", report.render_human());
160        let documented_exclusions = registry
161            .requirements()
162            .iter()
163            .filter(|requirement| matches!(requirement.verification, Verification::Excluded { .. }))
164            .count();
165        assert_eq!(
166            usize::try_from(report.totals.excluded).unwrap(),
167            documented_exclusions,
168            "every documented exclusion reports as excluded, regardless of trace"
169        );
170        // This handshake declares no capabilities, so every gated requirement
171        // must surface as not-applicable — never as a vacuous pass.
172        let gated = registry
173            .requirements()
174            .iter()
175            .filter(|requirement| {
176                requirement.capability.is_some()
177                    && matches!(requirement.verification, Verification::Checks { .. })
178            })
179            .count();
180        assert_eq!(
181            usize::try_from(report.totals.not_applicable).unwrap(),
182            gated,
183            "{}",
184            report.render_human()
185        );
186        assert_eq!(report.totals.unsupported, 0);
187        assert_eq!(
188            usize::try_from(
189                report.totals.pass
190                    + report.totals.fail
191                    + report.totals.warn
192                    + report.totals.excluded
193                    + report.totals.unsupported
194                    + report.totals.not_applicable
195            )
196            .unwrap(),
197            registry.requirements().len(),
198            "every requirement is accounted for exactly once"
199        );
200    }
201
202    /// One-requirement registry gated on `server.tools`, with a real check.
203    const GATED_REGISTRY: &str = r#"{
204        "revision": "2025-11-25",
205        "requirements": [
206            {"id": "TOOL-001", "level": "MUST", "actor": "server",
207             "capability": "server.tools",
208             "source": {"section": "server/tools#x", "quote": "MUST t"},
209             "checks": ["base.jsonrpc-version"]}
210        ]
211    }"#;
212
213    fn handshake(server_capabilities: &str) -> String {
214        format!(
215            r#"{{"seq":1,"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"}}}}}}}}
216{{"seq":2,"direction":"server-to-client","transport":"stdio","kind":"message","payload":{{"jsonrpc":"2.0","id":1,"result":{{"protocolVersion":"2025-11-25","capabilities":{server_capabilities},"serverInfo":{{"name":"s","version":"0"}}}}}}}}
217{{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{{"jsonrpc":"2.0","method":"notifications/initialized"}}}}"#
218        )
219    }
220
221    #[test]
222    fn undeclared_capability_reports_not_applicable_not_pass() {
223        let registry = Registry::from_json(GATED_REGISTRY).unwrap();
224        let trace = handshake(r#"{"prompts":{}}"#);
225        let events = parse_trace(&trace, &Limits::default()).unwrap();
226        let report = validate(&registry, &events);
227        assert_eq!(report.totals.not_applicable, 1);
228        assert_eq!(report.totals.pass, 0);
229        assert_eq!(
230            report.requirements[0].outcome,
231            crate::report::Outcome::NotApplicable
232        );
233        assert_eq!(
234            report.requirements[0].capability.as_deref(),
235            Some("server.tools")
236        );
237        assert_eq!(report.verdict(), crate::report::Verdict::Pass);
238    }
239
240    #[test]
241    fn declared_capability_runs_the_gated_checks() {
242        let registry = Registry::from_json(GATED_REGISTRY).unwrap();
243        let trace = handshake(r#"{"tools":{"listChanged":true}}"#);
244        let events = parse_trace(&trace, &Limits::default()).unwrap();
245        let report = validate(&registry, &events);
246        assert_eq!(report.totals.not_applicable, 0);
247        assert_eq!(report.totals.pass, 1);
248        assert!(report.requirements[0].capability.is_none());
249    }
250
251    #[test]
252    fn missing_checks_outrank_the_capability_gate() {
253        // `unsupported` must be a property of (registry, build), not of what one
254        // trace negotiated — a gated requirement with an unknown check is
255        // unsupported even when the capability was never declared.
256        let registry_json = r#"{
257            "revision": "2025-11-25",
258            "requirements": [
259                {"id": "TOOL-001", "level": "MUST", "actor": "server",
260                 "capability": "server.tools",
261                 "source": {"section": "server/tools#x", "quote": "MUST t"},
262                 "checks": ["future.not-built-yet"]}
263            ]
264        }"#;
265        let registry = Registry::from_json(registry_json).unwrap();
266        let report = validate(&registry, &[]);
267        assert_eq!(report.totals.unsupported, 1);
268        assert_eq!(report.totals.not_applicable, 0);
269    }
270
271    #[test]
272    fn unknown_check_reports_unsupported_not_silence() {
273        let registry_json = r#"{
274            "revision": "2025-11-25",
275            "requirements": [
276                {"id": "FUTR-001", "level": "MUST", "actor": "both",
277                 "source": {"section": "future#x", "quote": "MUST do future things"},
278                 "checks": ["future.not-built-yet"]}
279            ]
280        }"#;
281        let registry = Registry::from_json(registry_json).unwrap();
282        let report = validate(&registry, &[]);
283        assert_eq!(report.totals.unsupported, 1);
284        assert!(report.has_unsupported());
285        assert_eq!(
286            report.requirements[0].missing_checks,
287            ["future.not-built-yet"]
288        );
289    }
290
291    #[test]
292    fn empty_trace_passes_vacuously_with_gates_not_applicable() {
293        // The deliberate verdict for "nothing happened": no clause was
294        // violated, so the trace passes — while every capability-gated
295        // requirement reports not-applicable rather than a vacuous pass,
296        // and the totals make the vacuity visible. (Whether an *empty
297        // session* is acceptable evidence is the caller's question: the
298        // agreement check, for one, rejects empty tap directories.)
299        let registry = Registry::builtin_2025_11_25().unwrap();
300        let report = validate(&registry, &[]);
301        assert_eq!(report.verdict(), crate::report::Verdict::Pass);
302        assert_eq!(report.totals.fail, 0);
303        assert_eq!(report.totals.warn, 0);
304        assert_eq!(report.totals.unsupported, 0);
305        let gated = registry
306            .requirements()
307            .iter()
308            .filter(|requirement| {
309                requirement.capability.is_some()
310                    && matches!(
311                        requirement.verification,
312                        mcp_conformance_core::requirement::Verification::Checks { .. }
313                    )
314            })
315            .count();
316        assert_eq!(
317            usize::try_from(report.totals.not_applicable).unwrap(),
318            gated
319        );
320    }
321
322    #[test]
323    fn report_is_deterministic_across_runs() {
324        let registry = Registry::builtin_2025_11_25().unwrap();
325        let events = parse_trace(HAPPY, &Limits::default()).unwrap();
326        let a = serde_json::to_string(&validate(&registry, &events)).unwrap();
327        let b = serde_json::to_string(&validate(&registry, &events)).unwrap();
328        assert_eq!(a, b);
329    }
330}