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]
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 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 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 _ => {}
114 }
115 row
116}
117
118fn 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
135const 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(®istry, &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 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 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 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(®istry, &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(®istry, &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 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(®istry, &[]);
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(®istry, &[]);
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 let registry = Registry::builtin_2025_11_25().unwrap();
335 let report = validate(®istry, &[]);
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(®istry, &events)).unwrap();
362 let b = serde_json::to_string(&validate(®istry, &events)).unwrap();
363 assert_eq!(a, b);
364 }
365}