Skip to main content

mcp_trace_validator/
junit.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! `JUnit` XML rendering of validation reports, for CI systems that ingest test
5//! result files.
6//!
7//! Mapping (documented because `JUnit` has no native concept of a warning):
8//!
9//! | Outcome | `JUnit` representation |
10//! |---------|----------------------|
11//! | `pass` | passing `<testcase>` |
12//! | `fail` | `<failure>` per requirement, findings in the body |
13//! | `warn` | passing `<testcase>` with findings in `<system-out>` — SHOULD-level findings do not fail CI unless promoted by `--strict`, and that promotion is an exit-code concern, not a report concern |
14//! | `excluded` / `unsupported` / `not-applicable` | `<skipped>` with the reason as its message |
15//!
16//! The output is deterministic (registry order, no timestamps, no hostnames) for the
17//! same reason every other report format is: reports are artifacts.
18
19use core::fmt::Write as _;
20
21use crate::report::{Outcome, Report};
22
23/// Renders the report as a single-suite `JUnit` XML document.
24///
25/// ```
26/// use mcp_conformance_core::requirement::Registry;
27/// use mcp_trace_validator::{engine, junit};
28///
29/// let registry = Registry::builtin_2025_11_25()?;
30/// let xml = junit::render(&engine::validate(&registry, &[]));
31/// assert!(xml.starts_with(r#"<?xml version="1.0" encoding="UTF-8"?>"#));
32/// # Ok::<(), Box<dyn core::error::Error>>(())
33/// ```
34#[must_use]
35pub fn render(report: &Report) -> String {
36    let totals = report.totals;
37    let failures = totals.fail;
38    let skipped = totals.excluded + totals.unsupported + totals.not_applicable;
39    let tests = totals.pass + totals.fail + totals.warn + skipped;
40
41    let mut out = String::new();
42    out.push_str(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
43    out.push('\n');
44    let _ = writeln!(
45        out,
46        r#"<testsuites tests="{tests}" failures="{failures}" skipped="{skipped}">"#
47    );
48    let _ = writeln!(
49        out,
50        r#"  <testsuite name="mcp-trace-validator ({})" tests="{tests}" failures="{failures}" skipped="{skipped}">"#,
51        escape(&report.revision)
52    );
53
54    for row in &report.requirements {
55        render_row(&mut out, report, row);
56    }
57
58    out.push_str("  </testsuite>\n</testsuites>\n");
59    out
60}
61
62fn render_row(out: &mut String, report: &Report, row: &crate::report::RequirementReport) {
63    let name = escape(&format!("{} ({})", row.id, row.level));
64    let classname = escape(&format!("mcp.{}", report.revision));
65    match row.outcome {
66        Outcome::Pass => {
67            let _ = writeln!(
68                out,
69                r#"    <testcase classname="{classname}" name="{name}"/>"#
70            );
71        }
72        Outcome::Fail => {
73            let _ = writeln!(
74                out,
75                r#"    <testcase classname="{classname}" name="{name}">"#
76            );
77            for finding in &row.findings {
78                let _ = writeln!(
79                    out,
80                    r#"      <failure message="{}">{}</failure>"#,
81                    escape(&finding.detail),
82                    escape(&location(finding.seq, &finding.check)),
83                );
84            }
85            out.push_str("    </testcase>\n");
86        }
87        Outcome::Warn => {
88            let _ = writeln!(
89                out,
90                r#"    <testcase classname="{classname}" name="{name}">"#
91            );
92            out.push_str("      <system-out>");
93            for finding in &row.findings {
94                let _ = writeln!(
95                    out,
96                    "{}: {}",
97                    escape(&location(finding.seq, &finding.check)),
98                    escape(&finding.detail)
99                );
100            }
101            out.push_str("</system-out>\n    </testcase>\n");
102        }
103        Outcome::Excluded | Outcome::Unsupported | Outcome::NotApplicable => {
104            let _ = writeln!(
105                out,
106                r#"    <testcase classname="{classname}" name="{name}"><skipped message="{}"/></testcase>"#,
107                escape(&skip_reason(row))
108            );
109        }
110    }
111}
112
113/// The `<skipped>` message for the three non-judged outcomes.
114fn skip_reason(row: &crate::report::RequirementReport) -> String {
115    match row.outcome {
116        Outcome::Excluded => row
117            .exclusion
118            .clone()
119            .unwrap_or_else(|| "excluded".to_owned()),
120        Outcome::NotApplicable => format!(
121            "not applicable: capability {} was not declared in this session",
122            row.capability.as_deref().unwrap_or("(unknown)")
123        ),
124        _ => format!(
125            "registry references checks this build does not implement: {}",
126            row.missing_checks.join(", ")
127        ),
128    }
129}
130
131fn location(seq: Option<u64>, check: &str) -> String {
132    seq.map_or_else(
133        || format!("[{check}]"),
134        |seq| format!("[{check}] at seq {seq}"),
135    )
136}
137
138/// XML escaping for text and attribute content (we always double-quote
139/// attributes, so escaping `"` but not `'` is sufficient).
140///
141/// Findings quote trace strings — method names, ids — that come from untrusted
142/// input and may carry characters XML 1.0 forbids entirely. C0 control
143/// characters other than tab/LF/CR cannot appear in an XML 1.0 document even as
144/// numeric references (XML 1.0 §2.2), so a trace whose method name contains,
145/// say, U+0001 would otherwise produce a document a strict CI parser rejects.
146/// Those characters are replaced with U+FFFD (the Unicode replacement
147/// character) so the output is always well-formed.
148fn escape(text: &str) -> String {
149    let mut out = String::with_capacity(text.len());
150    for ch in text.chars() {
151        match ch {
152            '&' => out.push_str("&amp;"),
153            '<' => out.push_str("&lt;"),
154            '>' => out.push_str("&gt;"),
155            '"' => out.push_str("&quot;"),
156            '\t' | '\n' | '\r' => out.push(ch),
157            // C0 controls (except the three above) are not representable in
158            // XML 1.0 at all; substitute rather than emit an invalid document.
159            c if (c as u32) < 0x20 => out.push('\u{FFFD}'),
160            _ => out.push(ch),
161        }
162    }
163    out
164}
165
166#[cfg(test)]
167#[allow(clippy::unwrap_used)]
168mod tests {
169    use super::*;
170    use crate::reader::{Limits, parse_trace};
171    use mcp_conformance_core::requirement::Registry;
172
173    fn report_for(trace: &str) -> Report {
174        let registry = Registry::builtin_2025_11_25().unwrap();
175        let events = parse_trace(trace, &Limits::default()).unwrap();
176        crate::engine::validate(&registry, &events)
177    }
178
179    const VIOLATION: &str = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","id":1,"method":"tools/list"}}"#;
180
181    #[test]
182    fn renders_well_formed_suite_with_failure_and_skips() {
183        let total = Registry::builtin_2025_11_25().unwrap().requirements().len();
184        let xml = render(&report_for(VIOLATION));
185        assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
186        // One testcase per registry requirement; counts reconcile with the totals.
187        assert!(
188            xml.contains(&format!(r#"<testsuites tests="{total}""#)),
189            "{xml}"
190        );
191        assert!(xml.contains(r#"name="LIFE-001 (MUST)""#), "{xml}");
192        assert!(xml.contains("<failure message="), "{xml}");
193        assert!(xml.contains("<skipped message="), "{xml}");
194        // The LIFE-004 warning must NOT be a failure; its findings live in system-out.
195        assert!(xml.contains("<system-out>"), "{xml}");
196        // Balanced tags, exactly once each.
197        assert_eq!(xml.matches("<testsuites").count(), 1);
198        assert_eq!(xml.matches("</testsuites>").count(), 1);
199        assert_eq!(xml.matches("<testsuite ").count(), 1);
200        assert_eq!(xml.matches("</testsuite>").count(), 1);
201        assert_eq!(xml.matches("<testcase").count(), total);
202    }
203
204    #[test]
205    fn escapes_xml_metacharacters_in_details() {
206        // Finding details quote method names: "tools/list" arrives inside XML
207        // attributes, and quotes/angles must be escaped, never raw.
208        let xml = render(&report_for(VIOLATION));
209        assert!(xml.contains("&quot;tools/list&quot;"), "{xml}");
210        assert!(
211            !xml.contains(r#"message="first message is a "tools"#),
212            "{xml}"
213        );
214        assert_eq!(escape(r#"<a & "b">"#), "&lt;a &amp; &quot;b&quot;&gt;");
215    }
216
217    #[test]
218    fn escape_substitutes_xml_illegal_control_characters() {
219        // C0 controls other than tab/LF/CR cannot appear in XML 1.0 even as
220        // numeric references (XML 1.0 §2.2), so escape() substitutes them with
221        // U+FFFD; tab/LF/CR pass through. This is defense in depth: today's
222        // findings format trace strings with `{:?}`, which already renders a
223        // control char as printable `\u{1}` before it reaches escape(), so the
224        // hazard is not reachable through a current check — but escape()'s
225        // contract is "always emit a well-formed document," independent of how
226        // any caller built its string, and a future Display-formatted finding
227        // must not be able to void that.
228        assert_eq!(escape("a\u{0001}b\u{001F}c"), "a\u{FFFD}b\u{FFFD}c");
229        assert_eq!(escape("a\tb\nc\rd"), "a\tb\nc\rd");
230        // The boundary: U+001F substitutes, U+0020 (space) passes.
231        assert_eq!(escape("\u{001F}\u{0020}"), "\u{FFFD} ");
232    }
233
234    #[test]
235    fn passing_reports_have_zero_failures_and_self_closing_cases() {
236        let xml = render(&report_for(
237            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"}}}}
238{"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"}}}}
239{"seq":2,"direction":"client-to-server","transport":"stdio","kind":"message","payload":{"jsonrpc":"2.0","method":"notifications/initialized"}}"#,
240        ));
241        assert!(xml.contains(r#"failures="0""#), "{xml}");
242        assert!(xml.contains(r#"name="BASE-001 (MUST)"/>"#), "{xml}");
243    }
244
245    fn bare_row(id: &str, outcome: Outcome) -> crate::report::RequirementReport {
246        crate::report::RequirementReport {
247            id: id.to_owned(),
248            level: "MUST".to_owned(),
249            outcome,
250            findings: vec![],
251            exclusion: None,
252            missing_checks: vec![],
253            capability: None,
254        }
255    }
256
257    #[test]
258    fn skip_accounting_and_location_text_are_exact() {
259        use crate::report::{Finding, Totals};
260        // Hand-built report with excluded, unsupported, AND not-applicable rows:
261        // pins the skipped sum (excluded + unsupported + not_applicable), the
262        // per-variant skip messages, and the failure-body location text.
263        let mut failed = bare_row("AAAA-001", Outcome::Fail);
264        failed.findings = vec![Finding {
265            check: "area.some-check".to_owned(),
266            seq: Some(7),
267            detail: "it went wrong".to_owned(),
268        }];
269        let mut excluded_a = bare_row("AAAA-002", Outcome::Excluded);
270        excluded_a.exclusion = Some("not judgeable from traces".to_owned());
271        let mut excluded_b = bare_row("AAAA-003", Outcome::Excluded);
272        excluded_b.exclusion = Some("also excluded".to_owned());
273        let mut unsupported = bare_row("AAAA-004", Outcome::Unsupported);
274        unsupported.missing_checks = vec!["future.check".to_owned()];
275        let mut not_applicable = bare_row("AAAA-005", Outcome::NotApplicable);
276        not_applicable.capability = Some("server.tools".to_owned());
277        let report = Report {
278            revision: "2025-11-25".to_owned(),
279            totals: Totals {
280                pass: 0,
281                fail: 1,
282                warn: 0,
283                excluded: 2,
284                unsupported: 1,
285                not_applicable: 1,
286            },
287            requirements: vec![failed, excluded_a, excluded_b, unsupported, not_applicable],
288        };
289        let xml = render(&report);
290        // skipped = excluded + unsupported + not_applicable, no other arithmetic.
291        assert!(
292            xml.contains(r#"<testsuites tests="5" failures="1" skipped="4">"#),
293            "{xml}"
294        );
295        assert!(
296            xml.contains(
297                r#"<skipped message="not applicable: capability server.tools was not declared in this session"/>"#
298            ),
299            "{xml}"
300        );
301        // The two skip variants carry their own distinct messages.
302        assert!(
303            xml.contains(r#"<skipped message="not judgeable from traces"/>"#),
304            "{xml}"
305        );
306        assert!(
307            xml.contains(r#"<skipped message="registry references checks this build does not implement: future.check"/>"#),
308            "{xml}"
309        );
310        // Failure bodies carry the check-and-seq location, verbatim.
311        assert!(
312            xml.contains(">[area.some-check] at seq 7</failure>"),
313            "{xml}"
314        );
315        assert_eq!(location(None, "x.y"), "[x.y]");
316        assert_eq!(location(Some(3), "x.y"), "[x.y] at seq 3");
317    }
318}