Skip to main content

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};