Skip to main content

mcp_trace_validator/
reader.rs

1// SPDX-License-Identifier: MIT
2// Copyright 2026 Tom F. (https://github.com/tomtom215)
3
4//! Trace parsing with hard resource limits.
5//!
6//! Traces are untrusted input by design — they arrive from arbitrary implementations
7//! and arbitrary capture tooling. The reader therefore enforces explicit caps (line
8//! length, event count) and produces typed, line-addressed errors instead of panics.
9//! It performs no I/O itself: callers hand it the document text.
10
11use core::fmt;
12
13use mcp_conformance_core::trace::TraceEvent;
14
15/// Resource limits applied while parsing a trace document.
16#[derive(Debug, Clone, PartialEq, Eq)]
17#[non_exhaustive]
18pub struct Limits {
19    /// Maximum number of events accepted in one trace.
20    pub max_events: usize,
21    /// Maximum length in bytes of a single JSON Lines record.
22    pub max_line_bytes: usize,
23}
24
25impl Default for Limits {
26    fn default() -> Self {
27        Self {
28            // Generous for real sessions (the everything-server suites produce
29            // hundreds of events) while bounding adversarial inputs.
30            max_events: 100_000,
31            max_line_bytes: 1024 * 1024,
32        }
33    }
34}
35
36/// Why a trace document was rejected. Every variant that addresses one record
37/// carries its 1-based line number; the two that describe the document as a
38/// whole do not.
39#[derive(Debug)]
40#[non_exhaustive]
41pub enum TraceParseError {
42    /// A line exceeded [`Limits::max_line_bytes`].
43    LineTooLong {
44        /// 1-based line number.
45        line: usize,
46        /// Observed length in bytes.
47        length: usize,
48        /// The configured cap.
49        limit: usize,
50    },
51    /// The document contains more than [`Limits::max_events`] events.
52    TooManyEvents {
53        /// The configured cap.
54        limit: usize,
55    },
56    /// A line was empty (JSON Lines forbids blank records; a single trailing newline
57    /// is fine).
58    BlankLine {
59        /// 1-based line number.
60        line: usize,
61    },
62    /// A line was not a valid [`TraceEvent`] object.
63    Malformed {
64        /// 1-based line number.
65        line: usize,
66        /// The underlying JSON error.
67        source: serde_json::Error,
68    },
69    /// The document begins with a UTF-8 byte-order mark.
70    ///
71    /// Its own variant because serde reports it as `expected value at line 1
72    /// column 1`, which is true and tells the reader nothing: the offending
73    /// bytes are invisible in every editor that wrote them. A BOM is the
74    /// commonest way a trace produced on Windows fails to parse — `Out-File`
75    /// and `Set-Content` have both emitted one by default — and
76    /// `jq`, `python -m json.tool` and every other tool the reader might reach
77    /// for will insist the file is fine.
78    ByteOrderMark,
79    /// The whole document is a single JSON value: it is JSON, not JSON Lines.
80    ///
81    /// Checked only after a line has already failed, so a valid document never
82    /// reaches it and the cost is paid once, on the error path. Pretty-printing
83    /// is the other half of the same mistake as the BOM — a file that is
84    /// obviously well-formed JSON, rejected with a message about column 1.
85    NotJsonLines {
86        /// Whether that value is an array, so the message can name the fix.
87        array: bool,
88    },
89    /// Event `seq` values must be strictly increasing in document order.
90    NonMonotonicSeq {
91        /// 1-based line number.
92        line: usize,
93        /// The `seq` on this line.
94        seq: u64,
95        /// The `seq` on the previous event line.
96        previous: u64,
97    },
98}
99
100impl fmt::Display for TraceParseError {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        match self {
103            Self::LineTooLong {
104                line,
105                length,
106                limit,
107            } => write!(
108                f,
109                "line {line}: record is {length} bytes, exceeding the {limit}-byte limit"
110            ),
111            Self::TooManyEvents { limit } => {
112                write!(f, "trace exceeds the {limit}-event limit")
113            }
114            Self::BlankLine { line } => {
115                write!(
116                    f,
117                    "line {line}: blank line (JSON Lines forbids blank records)"
118                )
119            }
120            Self::Malformed { line, source } => {
121                write!(f, "line {line}: not a valid trace event: {source}")
122            }
123            Self::ByteOrderMark => write!(
124                f,
125                "the document begins with a UTF-8 byte-order mark (EF BB BF), which JSON \
126                 Lines does not permit; strip those three bytes and re-run"
127            ),
128            Self::NotJsonLines { array } => {
129                let fix = if *array {
130                    "it is a JSON array — one event per element, so `jq -c '.[]' <file>` converts it"
131                } else {
132                    "it is one pretty-printed JSON object — `jq -c . <file>` puts it on one line"
133                };
134                write!(
135                    f,
136                    "the document is a single JSON value, not JSON Lines (one event per line): {fix}"
137                )
138            }
139            Self::NonMonotonicSeq {
140                line,
141                seq,
142                previous,
143            } => write!(
144                f,
145                "line {line}: seq {seq} is not greater than the previous event's seq {previous}"
146            ),
147        }
148    }
149}
150
151impl core::error::Error for TraceParseError {
152    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
153        match self {
154            Self::Malformed { source, .. } => Some(source),
155            _ => None,
156        }
157    }
158}
159
160/// Parses a JSON Lines trace document into events, enforcing [`Limits`].
161///
162/// # Errors
163///
164/// Returns the first [`TraceParseError`] encountered, addressed by 1-based line
165/// number. An empty document yields an empty event list (validating an empty trace is
166/// the engine's question, not the parser's).
167///
168/// ```
169/// use mcp_trace_validator::reader::{Limits, parse_trace};
170///
171/// let line = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"lifecycle","event":"transport-open"}"#;
172/// assert_eq!(parse_trace(line, &Limits::default())?.len(), 1);
173/// assert!(parse_trace("not json", &Limits::default()).is_err());
174/// # Ok::<(), mcp_trace_validator::reader::TraceParseError>(())
175/// ```
176pub fn parse_trace(input: &str, limits: &Limits) -> Result<Vec<TraceEvent>, TraceParseError> {
177    // Before anything is addressed by line: a leading BOM makes line 1 fail with
178    // a message about a column, and the bytes it names cannot be seen.
179    if input.starts_with('\u{feff}') {
180        return Err(TraceParseError::ByteOrderMark);
181    }
182    let mut events = Vec::new();
183    let mut previous_seq: Option<u64> = None;
184    for (index, line) in input.lines().enumerate() {
185        let line_number = index + 1;
186        if line.len() > limits.max_line_bytes {
187            return Err(TraceParseError::LineTooLong {
188                line: line_number,
189                length: line.len(),
190                limit: limits.max_line_bytes,
191            });
192        }
193        if line.trim().is_empty() {
194            // A pretty-printed document with an internal blank line reaches
195            // here before any line has failed to parse, so ask the same
196            // question the parse-failure path asks.
197            return Err(whole_document_is_json(input)
198                .unwrap_or(TraceParseError::BlankLine { line: line_number }));
199        }
200        if events.len() >= limits.max_events {
201            return Err(TraceParseError::TooManyEvents {
202                limit: limits.max_events,
203            });
204        }
205        let event: TraceEvent = serde_json::from_str(line).map_err(|source| {
206            whole_document_is_json(input).unwrap_or(TraceParseError::Malformed {
207                line: line_number,
208                source,
209            })
210        })?;
211        if let Some(previous) = previous_seq
212            && event.seq <= previous
213        {
214            return Err(TraceParseError::NonMonotonicSeq {
215                line: line_number,
216                seq: event.seq,
217                previous,
218            });
219        }
220        previous_seq = Some(event.seq);
221        events.push(event);
222    }
223    Ok(events)
224}
225
226/// [`TraceParseError::NotJsonLines`] when `input` is a JSON *document* rather
227/// than JSON Lines.
228///
229/// Parsing end to end is not enough on its own, and the test that says so was
230/// already here: one valid record followed by a stray blank line also parses as
231/// a single value, because JSON permits trailing whitespace — reporting that as
232/// "this is JSON, not JSON Lines" would replace a true diagnosis with a false
233/// one. So the value must also be shaped like a document a person pretty-printed
234/// or wrapped: an array (the whole trace as one value, however it is spaced), or
235/// a value spread across more than one line. A one-line object that simply is
236/// not a trace event falls through to serde's message, which names the field it
237/// was missing.
238fn whole_document_is_json(input: &str) -> Option<TraceParseError> {
239    let value: serde_json::Value = serde_json::from_str(input).ok()?;
240    let array = value.is_array();
241    let spans_lines = input.lines().filter(|line| !line.trim().is_empty()).count() > 1;
242    (array || spans_lines).then_some(TraceParseError::NotJsonLines { array })
243}
244
245#[cfg(test)]
246#[allow(clippy::unwrap_used)]
247mod tests {
248    use super::*;
249
250    const VALID_EVENT: &str = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"lifecycle","event":"transport-open"}"#;
251
252    /// The two ways a first trace fails to parse for a reason serde cannot
253    /// name, and the message each must produce.
254    #[test]
255    fn a_leading_byte_order_mark_is_named_rather_than_pointed_at() {
256        let document = format!("\u{feff}{VALID_EVENT}");
257        let error = parse_trace(&document, &Limits::default()).unwrap_err();
258        assert!(matches!(error, TraceParseError::ByteOrderMark), "{error:?}");
259        let message = error.to_string();
260        assert!(message.contains("byte-order mark"), "{message}");
261        assert!(message.contains("EF BB BF"), "{message}");
262        // The same bytes anywhere but the start are ordinary content, and the
263        // line that carries them is what the reader is told about.
264        let inside = format!("{VALID_EVENT}\n\u{feff}{VALID_EVENT}");
265        assert!(matches!(
266            parse_trace(&inside, &Limits::default()).unwrap_err(),
267            TraceParseError::Malformed { line: 2, .. }
268        ));
269    }
270
271    #[test]
272    fn a_json_document_is_told_apart_from_json_lines() {
273        // A pretty-printed object: serde says `EOF while parsing an object at
274        // line 1 column 1`, which describes a fragment rather than the file.
275        let pretty = "{\n  \"seq\": 0,\n  \"direction\": \"client-to-server\",\n  \"transport\": \"stdio\",\n  \"kind\": \"lifecycle\",\n  \"event\": \"transport-open\"\n}";
276        let error = parse_trace(pretty, &Limits::default()).unwrap_err();
277        assert!(
278            matches!(error, TraceParseError::NotJsonLines { array: false }),
279            "{error:?}"
280        );
281        assert!(error.to_string().contains("jq -c ."), "{error}");
282
283        // The same events as a JSON array, pretty or compact.
284        for document in [format!("[{VALID_EVENT}]"), format!("[\n  {VALID_EVENT}\n]")] {
285            let error = parse_trace(&document, &Limits::default()).unwrap_err();
286            assert!(
287                matches!(error, TraceParseError::NotJsonLines { array: true }),
288                "{error:?}"
289            );
290            assert!(error.to_string().contains("jq -c '.[]'"), "{error}");
291        }
292    }
293
294    #[test]
295    fn one_record_and_a_stray_newline_is_still_a_blank_line() {
296        // JSON permits trailing whitespace, so this parses as a single value —
297        // and calling it "a JSON document, not JSON Lines" would be a confident
298        // wrong answer where the true one is one word away.
299        let document = format!("{VALID_EVENT}\n\n");
300        assert!(matches!(
301            parse_trace(&document, &Limits::default()).unwrap_err(),
302            TraceParseError::BlankLine { line: 2 }
303        ));
304    }
305
306    #[test]
307    fn a_one_line_object_that_is_not_an_event_keeps_serdes_message() {
308        // Also one JSON value, also not JSON Lines by shape — but the useful
309        // answer names the field, not the file format.
310        let error = parse_trace(r#"{"hello":"world"}"#, &Limits::default()).unwrap_err();
311        assert!(
312            matches!(error, TraceParseError::Malformed { line: 1, .. }),
313            "{error:?}"
314        );
315        assert!(error.to_string().contains("missing field `seq`"), "{error}");
316    }
317
318    #[test]
319    fn a_genuinely_broken_line_still_gets_its_line_number() {
320        // The check must not swallow the ordinary case: a document that is not
321        // one JSON value keeps serde's message and the line it happened on.
322        let document = format!("{VALID_EVENT}\nnot json\n");
323        let error = parse_trace(&document, &Limits::default()).unwrap_err();
324        assert!(
325            matches!(error, TraceParseError::Malformed { line: 2, .. }),
326            "{error:?}"
327        );
328
329        // And a stray blank line between real records is still a blank line.
330        let gapped = format!("{VALID_EVENT}\n\n{VALID_EVENT}\n");
331        assert!(matches!(
332            parse_trace(&gapped, &Limits::default()).unwrap_err(),
333            TraceParseError::BlankLine { line: 2 }
334        ));
335    }
336
337    #[test]
338    fn parses_valid_lines_and_empty_documents() {
339        assert!(parse_trace("", &Limits::default()).unwrap().is_empty());
340        let one = parse_trace(VALID_EVENT, &Limits::default()).unwrap();
341        assert_eq!(one.len(), 1);
342        // Trailing newline is fine.
343        let with_newline = format!("{VALID_EVENT}\n");
344        assert_eq!(
345            parse_trace(&with_newline, &Limits::default())
346                .unwrap()
347                .len(),
348            1
349        );
350    }
351
352    #[test]
353    fn rejects_blank_interior_lines() {
354        let doc = format!("{VALID_EVENT}\n\n");
355        assert!(matches!(
356            parse_trace(&doc, &Limits::default()),
357            Err(TraceParseError::BlankLine { line: 2 })
358        ));
359    }
360
361    #[test]
362    fn rejects_oversized_lines() {
363        let limits = Limits {
364            max_line_bytes: 16,
365            ..Limits::default()
366        };
367        assert!(matches!(
368            parse_trace(VALID_EVENT, &limits),
369            Err(TraceParseError::LineTooLong { line: 1, .. })
370        ));
371    }
372
373    #[test]
374    fn rejects_too_many_events() {
375        let limits = Limits {
376            max_events: 1,
377            ..Limits::default()
378        };
379        let second = VALID_EVENT.replace("\"seq\":0", "\"seq\":1");
380        let doc = format!("{VALID_EVENT}\n{second}");
381        assert!(matches!(
382            parse_trace(&doc, &limits),
383            Err(TraceParseError::TooManyEvents { limit: 1 })
384        ));
385    }
386
387    #[test]
388    fn rejects_malformed_records_with_line_numbers() {
389        let doc = format!("{VALID_EVENT}\n{{\"seq\":1}}");
390        match parse_trace(&doc, &Limits::default()) {
391            Err(TraceParseError::Malformed { line, .. }) => assert_eq!(line, 2),
392            other => panic!("expected malformed at line 2, got {other:?}"),
393        }
394    }
395
396    #[test]
397    fn rejects_non_monotonic_seq() {
398        let duplicate = format!("{VALID_EVENT}\n{VALID_EVENT}");
399        assert!(matches!(
400            parse_trace(&duplicate, &Limits::default()),
401            Err(TraceParseError::NonMonotonicSeq {
402                line: 2,
403                seq: 0,
404                previous: 0
405            })
406        ));
407    }
408
409    #[test]
410    fn error_messages_are_line_addressed() {
411        let doc = format!("{VALID_EVENT}\nnot json");
412        let error = parse_trace(&doc, &Limits::default()).unwrap_err();
413        assert!(error.to_string().starts_with("line 2:"), "{error}");
414    }
415
416    #[test]
417    fn line_exactly_at_the_byte_limit_is_accepted() {
418        // Boundary pinning: the limit is inclusive (> rejects, == passes).
419        let limits = Limits {
420            max_line_bytes: VALID_EVENT.len(),
421            ..Limits::default()
422        };
423        assert_eq!(parse_trace(VALID_EVENT, &limits).unwrap().len(), 1);
424    }
425
426    #[test]
427    fn error_source_is_exposed_for_malformed_records_only() {
428        use core::error::Error as _;
429        let malformed = parse_trace("nope", &Limits::default()).unwrap_err();
430        assert!(malformed.source().is_some());
431        let blank = parse_trace(" \n", &Limits::default()).unwrap_err();
432        assert!(blank.source().is_none());
433    }
434}