use mcp_conformance_core::capability::{CapabilityGate, CapabilityParty};
use mcp_conformance_core::requirement::{Registry, Requirement, Verification};
use mcp_conformance_core::trace::TraceEvent;
use crate::checks;
use crate::context::TraceContext;
use crate::report::{Outcome, Report, RequirementReport, Totals};
#[must_use]
pub fn validate(registry: &Registry, events: &[TraceEvent]) -> Report {
let context = TraceContext::new(events);
let mut totals = Totals::default();
let mut rows = Vec::with_capacity(registry.requirements().len());
for requirement in registry.requirements() {
let row = build_row(requirement, &context);
tally(&mut totals, row.outcome);
rows.push(row);
}
Report {
revision: registry.revision().to_string(),
revision_mismatch: crate::declared::mismatch(registry.revision(), events),
totals,
requirements: rows,
}
}
fn build_row(requirement: &Requirement, context: &TraceContext<'_>) -> RequirementReport {
let mut row = RequirementReport {
id: requirement.id.to_string(),
level: requirement.level.keyword().to_owned(),
outcome: Outcome::Unsupported,
findings: vec![],
exclusion: None,
missing_checks: vec![],
capability: None,
};
match &requirement.verification {
Verification::Excluded { exclusion } => {
row.outcome = Outcome::Excluded;
row.exclusion = Some(exclusion.clone());
}
Verification::Checks { checks: check_ids } => {
let mut resolved = Vec::with_capacity(check_ids.len());
for check_id in check_ids {
match checks::find(check_id) {
Some(check) => resolved.push(check),
None => row.missing_checks.push(check_id.clone()),
}
}
if !row.missing_checks.is_empty() {
row.outcome = Outcome::Unsupported;
} else if let Some(gate) = undeclared_gate(requirement, context) {
row.outcome = Outcome::NotApplicable;
row.capability = Some(gate.as_str().to_owned());
} else {
let mut observed = false;
for check in resolved {
let outcome = check.run(context);
observed |= outcome.subjects > 0;
row.findings.extend(outcome.findings);
}
row.outcome = if row.findings.is_empty() && !observed {
Outcome::NotObserved
} else {
classify_outcome(requirement.level.is_error(), row.findings.is_empty())
};
}
}
_ => {}
}
row
}
fn undeclared_gate<'r>(
requirement: &'r Requirement,
context: &TraceContext<'_>,
) -> Option<&'r CapabilityGate> {
let gate = requirement.capability.as_ref()?;
let capabilities = match gate.party() {
CapabilityParty::Server => context.server_capabilities(),
CapabilityParty::Client => context.client_capabilities(),
};
if gate.is_declared(capabilities) {
None
} else {
Some(gate)
}
}
const fn tally(totals: &mut Totals, outcome: Outcome) {
match outcome {
Outcome::Pass => totals.pass += 1,
Outcome::Fail => totals.fail += 1,
Outcome::Warn => totals.warn += 1,
Outcome::Excluded => totals.excluded += 1,
Outcome::Unsupported => totals.unsupported += 1,
Outcome::NotApplicable => totals.not_applicable += 1,
Outcome::NotObserved => totals.not_observed += 1,
}
}
const fn classify_outcome(is_error_level: bool, clean: bool) -> Outcome {
if clean {
Outcome::Pass
} else if is_error_level {
Outcome::Fail
} else {
Outcome::Warn
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::reader::{Limits, parse_trace};
use mcp_conformance_core::requirement::Registry;
const HAPPY: &str = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"lifecycle","event":"transport-open"}
{"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"}}}}
{"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"}}}}
{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/initialized"}}"#;
#[test]
fn happy_path_passes_every_checked_requirement() {
use mcp_conformance_core::requirement::Verification;
let registry = Registry::builtin_2025_11_25().unwrap();
let events = parse_trace(HAPPY, &Limits::default()).unwrap();
let report = validate(®istry, &events);
assert!(!report.has_errors(), "{}", report.render_human());
assert!(!report.has_warnings(), "{}", report.render_human());
let documented_exclusions = registry
.requirements()
.iter()
.filter(|requirement| matches!(requirement.verification, Verification::Excluded { .. }))
.count();
assert_eq!(
usize::try_from(report.totals.excluded).unwrap(),
documented_exclusions,
"every documented exclusion reports as excluded, regardless of trace"
);
let gated = registry
.requirements()
.iter()
.filter(|requirement| {
requirement.capability.is_some()
&& matches!(requirement.verification, Verification::Checks { .. })
})
.count();
assert_eq!(
usize::try_from(report.totals.not_applicable).unwrap(),
gated,
"{}",
report.render_human()
);
assert_eq!(report.totals.unsupported, 0);
let pagination = report
.requirements
.iter()
.find(|row| row.id == "PAGE-002")
.expect("the 2025-11-25 registry carries PAGE-002");
assert_eq!(
pagination.outcome,
Outcome::NotObserved,
"{}",
report.render_human()
);
assert_eq!(
usize::try_from(
report.totals.pass
+ report.totals.fail
+ report.totals.warn
+ report.totals.excluded
+ report.totals.unsupported
+ report.totals.not_applicable
+ report.totals.not_observed
)
.unwrap(),
registry.requirements().len(),
"every requirement is accounted for exactly once"
);
}
const GATED_REGISTRY: &str = r#"{
"revision": "2025-11-25",
"requirements": [
{"id": "TOOL-001", "level": "MUST", "actor": "server",
"capability": "server.tools",
"source": {"section": "server/tools#x", "quote": "MUST t"},
"checks": ["base.jsonrpc-version"]}
]
}"#;
fn handshake(server_capabilities: &str) -> String {
format!(
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"}}}}}}}}
{{"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"}}}}}}}}
{{"seq":3,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{{"jsonrpc":"2.0","method":"notifications/initialized"}}}}"#
)
}
#[test]
fn undeclared_capability_reports_not_applicable_not_pass() {
let registry = Registry::from_json(GATED_REGISTRY).unwrap();
let trace = handshake(r#"{"prompts":{}}"#);
let events = parse_trace(&trace, &Limits::default()).unwrap();
let report = validate(®istry, &events);
assert_eq!(report.totals.not_applicable, 1);
assert_eq!(report.totals.pass, 0);
assert_eq!(
report.requirements[0].outcome,
crate::report::Outcome::NotApplicable
);
assert_eq!(
report.requirements[0].capability.as_deref(),
Some("server.tools")
);
assert_eq!(report.verdict(), crate::report::Verdict::Pass);
}
#[test]
fn declared_capability_runs_the_gated_checks() {
let registry = Registry::from_json(GATED_REGISTRY).unwrap();
let trace = handshake(r#"{"tools":{"listChanged":true}}"#);
let events = parse_trace(&trace, &Limits::default()).unwrap();
let report = validate(®istry, &events);
assert_eq!(report.totals.not_applicable, 0);
assert_eq!(report.totals.pass, 1);
assert!(report.requirements[0].capability.is_none());
}
#[test]
fn missing_checks_outrank_the_capability_gate() {
let registry_json = r#"{
"revision": "2025-11-25",
"requirements": [
{"id": "TOOL-001", "level": "MUST", "actor": "server",
"capability": "server.tools",
"source": {"section": "server/tools#x", "quote": "MUST t"},
"checks": ["future.not-built-yet"]}
]
}"#;
let registry = Registry::from_json(registry_json).unwrap();
let report = validate(®istry, &[]);
assert_eq!(report.totals.unsupported, 1);
assert_eq!(report.totals.not_applicable, 0);
}
#[test]
fn unknown_check_reports_unsupported_not_silence() {
let registry_json = r#"{
"revision": "2025-11-25",
"requirements": [
{"id": "FUTR-001", "level": "MUST", "actor": "both",
"source": {"section": "future#x", "quote": "MUST do future things"},
"checks": ["future.not-built-yet"]}
]
}"#;
let registry = Registry::from_json(registry_json).unwrap();
let report = validate(®istry, &[]);
assert_eq!(report.totals.unsupported, 1);
assert!(report.has_unsupported());
assert_eq!(
report.requirements[0].missing_checks,
["future.not-built-yet"]
);
}
#[test]
fn empty_trace_passes_vacuously_with_gates_not_applicable() {
let registry = Registry::builtin_2025_11_25().unwrap();
let report = validate(®istry, &[]);
assert_eq!(report.verdict(), crate::report::Verdict::Pass);
assert_eq!(report.totals.fail, 0);
assert_eq!(report.totals.warn, 0);
assert_eq!(report.totals.unsupported, 0);
let gated = registry
.requirements()
.iter()
.filter(|requirement| {
requirement.capability.is_some()
&& matches!(
requirement.verification,
mcp_conformance_core::requirement::Verification::Checks { .. }
)
})
.count();
assert_eq!(
usize::try_from(report.totals.not_applicable).unwrap(),
gated
);
}
#[test]
fn report_is_deterministic_across_runs() {
let registry = Registry::builtin_2025_11_25().unwrap();
let events = parse_trace(HAPPY, &Limits::default()).unwrap();
let a = serde_json::to_string(&validate(®istry, &events)).unwrap();
let b = serde_json::to_string(&validate(®istry, &events)).unwrap();
assert_eq!(a, b);
}
}