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