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