1use 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#[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 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 _ => {}
96 }
97 row
98}
99
100fn 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
117const 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(®istry, &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 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 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(®istry, &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(®istry, &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 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(®istry, &[]);
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(®istry, &[]);
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 let registry = Registry::builtin_2025_11_25().unwrap();
300 let report = validate(®istry, &[]);
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(®istry, &events)).unwrap();
327 let b = serde_json::to_string(&validate(®istry, &events)).unwrap();
328 assert_eq!(a, b);
329 }
330}