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 totals,
56 requirements: rows,
57 }
58}
59
60fn build_row(requirement: &Requirement, context: &TraceContext<'_>) -> RequirementReport {
61 let mut row = RequirementReport {
62 id: requirement.id.to_string(),
63 level: requirement.level.keyword().to_owned(),
64 outcome: Outcome::Unsupported,
65 findings: vec![],
66 exclusion: None,
67 missing_checks: vec![],
68 capability: None,
69 };
70 match &requirement.verification {
71 Verification::Excluded { exclusion } => {
72 row.outcome = Outcome::Excluded;
73 row.exclusion = Some(exclusion.clone());
74 }
75 Verification::Checks { checks: check_ids } => {
76 let mut resolved = Vec::with_capacity(check_ids.len());
80 for check_id in check_ids {
81 match checks::find(check_id) {
82 Some(check) => resolved.push(check),
83 None => row.missing_checks.push(check_id.clone()),
84 }
85 }
86 if !row.missing_checks.is_empty() {
87 row.outcome = Outcome::Unsupported;
88 } else if let Some(gate) = undeclared_gate(requirement, context) {
89 row.outcome = Outcome::NotApplicable;
90 row.capability = Some(gate.as_str().to_owned());
91 } else {
92 for check in resolved {
93 row.findings.extend(check.run(context));
94 }
95 row.outcome =
96 classify_outcome(requirement.level.is_error(), row.findings.is_empty());
97 }
98 }
99 _ => {}
103 }
104 row
105}
106
107fn undeclared_gate<'r>(
109 requirement: &'r Requirement,
110 context: &TraceContext<'_>,
111) -> Option<&'r CapabilityGate> {
112 let gate = requirement.capability.as_ref()?;
113 let capabilities = match gate.party() {
114 CapabilityParty::Server => context.server_capabilities(),
115 CapabilityParty::Client => context.client_capabilities(),
116 };
117 if gate.is_declared(capabilities) {
118 None
119 } else {
120 Some(gate)
121 }
122}
123
124const fn tally(totals: &mut Totals, outcome: Outcome) {
127 match outcome {
128 Outcome::Pass => totals.pass += 1,
129 Outcome::Fail => totals.fail += 1,
130 Outcome::Warn => totals.warn += 1,
131 Outcome::Excluded => totals.excluded += 1,
132 Outcome::Unsupported => totals.unsupported += 1,
133 Outcome::NotApplicable => totals.not_applicable += 1,
134 }
135}
136
137const fn classify_outcome(is_error_level: bool, clean: bool) -> Outcome {
138 if clean {
139 Outcome::Pass
140 } else if is_error_level {
141 Outcome::Fail
142 } else {
143 Outcome::Warn
144 }
145}
146
147#[cfg(test)]
148#[allow(clippy::unwrap_used)]
149mod tests {
150 use super::*;
151 use crate::reader::{Limits, parse_trace};
152 use mcp_conformance_core::requirement::Registry;
153
154 const HAPPY: &str = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"lifecycle","event":"transport-open"}
155{"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"}}}}
156{"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"}}}}
157{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/initialized"}}"#;
158
159 #[test]
160 fn happy_path_passes_every_checked_requirement() {
161 use mcp_conformance_core::requirement::Verification;
162 let registry = Registry::builtin_2025_11_25().unwrap();
163 let events = parse_trace(HAPPY, &Limits::default()).unwrap();
164 let report = validate(®istry, &events);
165 assert!(!report.has_errors(), "{}", report.render_human());
166 assert!(!report.has_warnings(), "{}", report.render_human());
167 let documented_exclusions = registry
168 .requirements()
169 .iter()
170 .filter(|requirement| matches!(requirement.verification, Verification::Excluded { .. }))
171 .count();
172 assert_eq!(
173 usize::try_from(report.totals.excluded).unwrap(),
174 documented_exclusions,
175 "every documented exclusion reports as excluded, regardless of trace"
176 );
177 let gated = registry
180 .requirements()
181 .iter()
182 .filter(|requirement| {
183 requirement.capability.is_some()
184 && matches!(requirement.verification, Verification::Checks { .. })
185 })
186 .count();
187 assert_eq!(
188 usize::try_from(report.totals.not_applicable).unwrap(),
189 gated,
190 "{}",
191 report.render_human()
192 );
193 assert_eq!(report.totals.unsupported, 0);
194 assert_eq!(
195 usize::try_from(
196 report.totals.pass
197 + report.totals.fail
198 + report.totals.warn
199 + report.totals.excluded
200 + report.totals.unsupported
201 + report.totals.not_applicable
202 )
203 .unwrap(),
204 registry.requirements().len(),
205 "every requirement is accounted for exactly once"
206 );
207 }
208
209 const GATED_REGISTRY: &str = r#"{
211 "revision": "2025-11-25",
212 "requirements": [
213 {"id": "TOOL-001", "level": "MUST", "actor": "server",
214 "capability": "server.tools",
215 "source": {"section": "server/tools#x", "quote": "MUST t"},
216 "checks": ["base.jsonrpc-version"]}
217 ]
218 }"#;
219
220 fn handshake(server_capabilities: &str) -> String {
221 format!(
222 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"}}}}}}}}
223{{"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"}}}}}}}}
224{{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{{"jsonrpc":"2.0","method":"notifications/initialized"}}}}"#
225 )
226 }
227
228 #[test]
229 fn undeclared_capability_reports_not_applicable_not_pass() {
230 let registry = Registry::from_json(GATED_REGISTRY).unwrap();
231 let trace = handshake(r#"{"prompts":{}}"#);
232 let events = parse_trace(&trace, &Limits::default()).unwrap();
233 let report = validate(®istry, &events);
234 assert_eq!(report.totals.not_applicable, 1);
235 assert_eq!(report.totals.pass, 0);
236 assert_eq!(
237 report.requirements[0].outcome,
238 crate::report::Outcome::NotApplicable
239 );
240 assert_eq!(
241 report.requirements[0].capability.as_deref(),
242 Some("server.tools")
243 );
244 assert_eq!(report.verdict(), crate::report::Verdict::Pass);
245 }
246
247 #[test]
248 fn declared_capability_runs_the_gated_checks() {
249 let registry = Registry::from_json(GATED_REGISTRY).unwrap();
250 let trace = handshake(r#"{"tools":{"listChanged":true}}"#);
251 let events = parse_trace(&trace, &Limits::default()).unwrap();
252 let report = validate(®istry, &events);
253 assert_eq!(report.totals.not_applicable, 0);
254 assert_eq!(report.totals.pass, 1);
255 assert!(report.requirements[0].capability.is_none());
256 }
257
258 #[test]
259 fn missing_checks_outrank_the_capability_gate() {
260 let registry_json = r#"{
264 "revision": "2025-11-25",
265 "requirements": [
266 {"id": "TOOL-001", "level": "MUST", "actor": "server",
267 "capability": "server.tools",
268 "source": {"section": "server/tools#x", "quote": "MUST t"},
269 "checks": ["future.not-built-yet"]}
270 ]
271 }"#;
272 let registry = Registry::from_json(registry_json).unwrap();
273 let report = validate(®istry, &[]);
274 assert_eq!(report.totals.unsupported, 1);
275 assert_eq!(report.totals.not_applicable, 0);
276 }
277
278 #[test]
279 fn unknown_check_reports_unsupported_not_silence() {
280 let registry_json = r#"{
281 "revision": "2025-11-25",
282 "requirements": [
283 {"id": "FUTR-001", "level": "MUST", "actor": "both",
284 "source": {"section": "future#x", "quote": "MUST do future things"},
285 "checks": ["future.not-built-yet"]}
286 ]
287 }"#;
288 let registry = Registry::from_json(registry_json).unwrap();
289 let report = validate(®istry, &[]);
290 assert_eq!(report.totals.unsupported, 1);
291 assert!(report.has_unsupported());
292 assert_eq!(
293 report.requirements[0].missing_checks,
294 ["future.not-built-yet"]
295 );
296 }
297
298 #[test]
299 fn empty_trace_passes_vacuously_with_gates_not_applicable() {
300 let registry = Registry::builtin_2025_11_25().unwrap();
307 let report = validate(®istry, &[]);
308 assert_eq!(report.verdict(), crate::report::Verdict::Pass);
309 assert_eq!(report.totals.fail, 0);
310 assert_eq!(report.totals.warn, 0);
311 assert_eq!(report.totals.unsupported, 0);
312 let gated = registry
313 .requirements()
314 .iter()
315 .filter(|requirement| {
316 requirement.capability.is_some()
317 && matches!(
318 requirement.verification,
319 mcp_conformance_core::requirement::Verification::Checks { .. }
320 )
321 })
322 .count();
323 assert_eq!(
324 usize::try_from(report.totals.not_applicable).unwrap(),
325 gated
326 );
327 }
328
329 #[test]
330 fn report_is_deterministic_across_runs() {
331 let registry = Registry::builtin_2025_11_25().unwrap();
332 let events = parse_trace(HAPPY, &Limits::default()).unwrap();
333 let a = serde_json::to_string(&validate(®istry, &events)).unwrap();
334 let b = serde_json::to_string(&validate(®istry, &events)).unwrap();
335 assert_eq!(a, b);
336 }
337}