use core::fmt::Write as _;
use crate::report::{Outcome, Report};
#[must_use]
pub fn render(report: &Report) -> String {
let totals = report.totals;
let failures = totals.fail;
let skipped =
totals.excluded + totals.unsupported + totals.not_applicable + totals.not_observed;
let tests = totals.pass + totals.fail + totals.warn + skipped;
let mut out = String::new();
out.push_str(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
out.push('\n');
let _ = writeln!(
out,
r#"<testsuites tests="{tests}" failures="{failures}" skipped="{skipped}">"#
);
let _ = writeln!(
out,
r#" <testsuite name="mcp-trace-validator ({})" tests="{tests}" failures="{failures}" skipped="{skipped}">"#,
escape(&report.revision)
);
for row in &report.requirements {
render_row(&mut out, report, row);
}
out.push_str(" </testsuite>\n</testsuites>\n");
out
}
fn render_row(out: &mut String, report: &Report, row: &crate::report::RequirementReport) {
let name = escape(&format!("{} ({})", row.id, row.level));
let classname = escape(&format!("mcp.{}", report.revision));
match row.outcome {
Outcome::Pass => {
let _ = writeln!(
out,
r#" <testcase classname="{classname}" name="{name}"/>"#
);
}
Outcome::Fail => {
let _ = writeln!(
out,
r#" <testcase classname="{classname}" name="{name}">"#
);
for finding in &row.findings {
let _ = writeln!(
out,
r#" <failure message="{}">{}</failure>"#,
escape(&finding.detail),
escape(&location(finding.seq, &finding.check)),
);
}
out.push_str(" </testcase>\n");
}
Outcome::Warn => {
let _ = writeln!(
out,
r#" <testcase classname="{classname}" name="{name}">"#
);
out.push_str(" <system-out>");
for finding in &row.findings {
let _ = writeln!(
out,
"{}: {}",
escape(&location(finding.seq, &finding.check)),
escape(&finding.detail)
);
}
out.push_str("</system-out>\n </testcase>\n");
}
Outcome::Excluded
| Outcome::Unsupported
| Outcome::NotApplicable
| Outcome::NotObserved => {
let _ = writeln!(
out,
r#" <testcase classname="{classname}" name="{name}"><skipped message="{}"/></testcase>"#,
escape(&skip_reason(row))
);
}
}
}
fn skip_reason(row: &crate::report::RequirementReport) -> String {
match row.outcome {
Outcome::Excluded => row
.exclusion
.clone()
.unwrap_or_else(|| "excluded".to_owned()),
Outcome::NotObserved => {
"the session carried none of the traffic this clause binds to".to_owned()
}
Outcome::NotApplicable => format!(
"not applicable: capability {} was not declared in this session",
row.capability.as_deref().unwrap_or("(unknown)")
),
_ => format!(
"registry references checks this build does not implement: {}",
row.missing_checks.join(", ")
),
}
}
fn location(seq: Option<u64>, check: &str) -> String {
seq.map_or_else(
|| format!("[{check}]"),
|seq| format!("[{check}] at seq {seq}"),
)
}
fn escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\t' | '\n' | '\r' => out.push(ch),
c if (c as u32) < 0x20 => out.push('\u{FFFD}'),
_ => out.push(ch),
}
}
out
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::reader::{Limits, parse_trace};
use mcp_conformance_core::requirement::Registry;
fn report_for(trace: &str) -> Report {
let registry = Registry::builtin_2025_11_25().unwrap();
let events = parse_trace(trace, &Limits::default()).unwrap();
crate::engine::validate(®istry, &events)
}
const VIOLATION: &str = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"tools/list"}}"#;
#[test]
fn renders_well_formed_suite_with_failure_and_skips() {
let total = Registry::builtin_2025_11_25().unwrap().requirements().len();
let xml = render(&report_for(VIOLATION));
assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
assert!(
xml.contains(&format!(r#"<testsuites tests="{total}""#)),
"{xml}"
);
assert!(xml.contains(r#"name="LIFE-001 (MUST)""#), "{xml}");
assert!(xml.contains("<failure message="), "{xml}");
assert!(xml.contains("<skipped message="), "{xml}");
assert!(xml.contains("<system-out>"), "{xml}");
assert_eq!(xml.matches("<testsuites").count(), 1);
assert_eq!(xml.matches("</testsuites>").count(), 1);
assert_eq!(xml.matches("<testsuite ").count(), 1);
assert_eq!(xml.matches("</testsuite>").count(), 1);
assert_eq!(xml.matches("<testcase").count(), total);
}
#[test]
fn escapes_xml_metacharacters_in_details() {
let xml = render(&report_for(VIOLATION));
assert!(xml.contains(""tools/list""), "{xml}");
assert!(
!xml.contains(r#"message="first message is a "tools"#),
"{xml}"
);
assert_eq!(escape(r#"<a & "b">"#), "<a & "b">");
}
#[test]
fn escape_substitutes_xml_illegal_control_characters() {
assert_eq!(escape("a\u{0001}b\u{001F}c"), "a\u{FFFD}b\u{FFFD}c");
assert_eq!(escape("a\tb\nc\rd"), "a\tb\nc\rd");
assert_eq!(escape("\u{001F}\u{0020}"), "\u{FFFD} ");
}
#[test]
fn passing_reports_have_zero_failures_and_self_closing_cases() {
let xml = render(&report_for(
r#"{"seq":0,"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":1,"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":2,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/initialized"}}"#,
));
assert!(xml.contains(r#"failures="0""#), "{xml}");
assert!(xml.contains(r#"name="BASE-001 (MUST)"/>"#), "{xml}");
}
fn bare_row(id: &str, outcome: Outcome) -> crate::report::RequirementReport {
crate::report::RequirementReport {
id: id.to_owned(),
level: "MUST".to_owned(),
outcome,
findings: vec![],
exclusion: None,
missing_checks: vec![],
capability: None,
}
}
fn every_skip_variant() -> Report {
use crate::report::{Finding, Totals};
let mut failed = bare_row("AAAA-001", Outcome::Fail);
failed.findings = vec![Finding {
check: "area.some-check".to_owned(),
seq: Some(7),
detail: "it went wrong".to_owned(),
}];
let mut excluded_a = bare_row("AAAA-002", Outcome::Excluded);
excluded_a.exclusion = Some("not judgeable from traces".to_owned());
let mut excluded_b = bare_row("AAAA-003", Outcome::Excluded);
excluded_b.exclusion = Some("also excluded".to_owned());
let mut unsupported = bare_row("AAAA-004", Outcome::Unsupported);
unsupported.missing_checks = vec!["future.check".to_owned()];
let mut not_applicable = bare_row("AAAA-005", Outcome::NotApplicable);
not_applicable.capability = Some("server.tools".to_owned());
Report {
revision_mismatch: None,
revision: "2025-11-25".to_owned(),
totals: Totals {
pass: 0,
fail: 1,
warn: 0,
excluded: 2,
unsupported: 1,
not_applicable: 1,
not_observed: 1,
},
requirements: vec![
failed,
excluded_a,
excluded_b,
unsupported,
not_applicable,
bare_row("AAAA-006", Outcome::NotObserved),
],
}
}
#[test]
fn skip_accounting_and_location_text_are_exact() {
let xml = render(&every_skip_variant());
assert!(
xml.contains(r#"<testsuites tests="6" failures="1" skipped="5">"#),
"{xml}"
);
assert_eq!(xml.matches("<testcase").count(), 6, "{xml}");
assert!(
xml.contains(
r#"<skipped message="not applicable: capability server.tools was not declared in this session"/>"#
),
"{xml}"
);
assert!(
xml.contains(r#"<skipped message="not judgeable from traces"/>"#),
"{xml}"
);
assert!(
xml.contains(
r#"<skipped message="the session carried none of the traffic this clause binds to"/>"#
),
"{xml}"
);
assert!(
xml.contains(r#"<skipped message="registry references checks this build does not implement: future.check"/>"#),
"{xml}"
);
assert!(
xml.contains(">[area.some-check] at seq 7</failure>"),
"{xml}"
);
assert_eq!(location(None, "x.y"), "[x.y]");
assert_eq!(location(Some(3), "x.y"), "[x.y] at seq 3");
}
}