mcp_trace_validator/checks/mod.rs
1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! Checks: what one is, and what every one of them promises.
5//!
6//! A *check* is a pure function from a [`TraceContext`] to findings, registered under a
7//! stable ID that requirement-registry entries reference. The contract for every check:
8//!
9//! - **Falsifiable**: the corpus contains at least one trace it passes and one it fails
10//! (enforced by the corpus invariant test).
11//! - **Deterministic**: findings are emitted in event order with stable details.
12//! - **Lenient input, precise output**: checks never refuse malformed messages — they
13//! report them.
14//! - **Its own clause, and no neighbour's**: the engine attributes a check's finding to
15//! every requirement naming it, so a check that bundles adjacent rules makes each of
16//! them unable to say which one broke. Requirements share a check only where they
17//! state one rule across several sections.
18//!
19//! The registered list itself lives in the private `inventory` module, re-exported
20//! here as [`ALL`].
21
22mod base;
23#[cfg(feature = "draft-2026-07-28")]
24mod draft;
25mod inventory;
26mod lifecycle;
27mod negotiation;
28mod prompts;
29mod resources;
30mod support;
31mod tools;
32mod transport;
33mod utilities;
34
35use crate::context::TraceContext;
36use crate::report::Finding;
37
38/// A check function: examines the trace, pushes findings into the sink.
39type CheckFn = fn(&TraceContext<'_>, &mut FindingSink);
40
41/// A registered check.
42#[derive(Debug, Clone, Copy)]
43pub struct Check {
44 /// Stable check identifier referenced by registry entries (e.g.
45 /// `lifecycle.first-interaction-initialize`).
46 pub id: &'static str,
47 run: CheckFn,
48}
49
50/// What running one check produced.
51#[derive(Debug, Default)]
52#[non_exhaustive]
53pub struct CheckOutcome {
54 /// Findings, each stamped with the check's ID.
55 pub findings: Vec<Finding>,
56 /// How many subjects the check examined.
57 ///
58 /// Zero means the trace held nothing this check's clause binds, which is
59 /// what separates a *pass* from a *not-observed* row. Every registered
60 /// check counts its subjects — `checks_count_their_subjects` in
61 /// `tests/golden.rs` holds that line, so a new check that forgets to can
62 /// never quietly report a clause as passing on evidence it never had.
63 pub subjects: u32,
64}
65
66impl Check {
67 /// Runs the check, returning its findings and what it examined.
68 #[must_use]
69 pub fn run(&self, context: &TraceContext<'_>) -> CheckOutcome {
70 let mut sink = FindingSink {
71 check: self.id,
72 findings: Vec::new(),
73 subjects: 0,
74 };
75 (self.run)(context, &mut sink);
76 CheckOutcome {
77 findings: sink.findings,
78 subjects: sink.subjects,
79 }
80 }
81}
82
83/// Collects findings on behalf of one check, stamping each with the check ID.
84#[derive(Debug)]
85pub struct FindingSink {
86 check: &'static str,
87 findings: Vec<Finding>,
88 subjects: u32,
89}
90
91impl FindingSink {
92 /// Records a finding at an event (`seq`) with an actionable detail sentence.
93 pub fn push(&mut self, seq: Option<u64>, detail: String) {
94 self.findings.push(Finding {
95 check: self.check.to_owned(),
96 seq,
97 detail,
98 });
99 }
100
101 /// Records that this check considered one more subject.
102 ///
103 /// **The counting rule**, applied identically by every check:
104 ///
105 /// > A subject is a trace element the check *considered* — one that, with
106 /// > different content, could have produced a finding. Count it after the
107 /// > filters that define the clause's scope, and before the condition that
108 /// > makes an element a violation.
109 ///
110 /// So a prohibition over server messages counts every server message,
111 /// because any of them could have been the violation; a clause about
112 /// subscription streams counts only messages on such a stream, because a
113 /// session without one gave the clause nothing to bind to. The difference
114 /// is what separates "complied with" from "never came up", and reporting
115 /// the second as the first states evidence the trace does not carry.
116 ///
117 /// **Prohibitions come in two shapes, and they count differently.** Where
118 /// a clause forbids an element from having some property ("notifications
119 /// MUST NOT include an ID"), the element is the subject: no notifications,
120 /// nothing judged. Where it forbids the element from *existing at all*
121 /// inside a window ("the server SHOULD NOT send requests before
122 /// `initialized`"), the **window** is the subject, because sending nothing
123 /// through a window the trace shows is exactly what compliance looks like.
124 /// Counting the forbidden element there would report every clean session
125 /// as unjudged — and, worse, report two clauses of identical shape
126 /// differently depending on which party happened to send something.
127 pub const fn examined(&mut self) {
128 self.subjects = self.subjects.saturating_add(1);
129 }
130}
131
132pub use inventory::{ALL, find};