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