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 carries the 1-based line number.
37#[derive(Debug)]
38#[non_exhaustive]
39pub enum TraceParseError {
40    /// A line exceeded [`Limits::max_line_bytes`].
41    LineTooLong {
42        /// 1-based line number.
43        line: usize,
44        /// Observed length in bytes.
45        length: usize,
46        /// The configured cap.
47        limit: usize,
48    },
49    /// The document contains more than [`Limits::max_events`] events.
50    TooManyEvents {
51        /// The configured cap.
52        limit: usize,
53    },
54    /// A line was empty (JSON Lines forbids blank records; a single trailing newline
55    /// is fine).
56    BlankLine {
57        /// 1-based line number.
58        line: usize,
59    },
60    /// A line was not a valid [`TraceEvent`] object.
61    Malformed {
62        /// 1-based line number.
63        line: usize,
64        /// The underlying JSON error.
65        source: serde_json::Error,
66    },
67    /// Event `seq` values must be strictly increasing in document order.
68    NonMonotonicSeq {
69        /// 1-based line number.
70        line: usize,
71        /// The `seq` on this line.
72        seq: u64,
73        /// The `seq` on the previous event line.
74        previous: u64,
75    },
76}
77
78impl fmt::Display for TraceParseError {
79    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80        match self {
81            Self::LineTooLong {
82                line,
83                length,
84                limit,
85            } => write!(
86                f,
87                "line {line}: record is {length} bytes, exceeding the {limit}-byte limit"
88            ),
89            Self::TooManyEvents { limit } => {
90                write!(f, "trace exceeds the {limit}-event limit")
91            }
92            Self::BlankLine { line } => {
93                write!(
94                    f,
95                    "line {line}: blank line (JSON Lines forbids blank records)"
96                )
97            }
98            Self::Malformed { line, source } => {
99                write!(f, "line {line}: not a valid trace event: {source}")
100            }
101            Self::NonMonotonicSeq {
102                line,
103                seq,
104                previous,
105            } => write!(
106                f,
107                "line {line}: seq {seq} is not greater than the previous event's seq {previous}"
108            ),
109        }
110    }
111}
112
113impl core::error::Error for TraceParseError {
114    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
115        match self {
116            Self::Malformed { source, .. } => Some(source),
117            _ => None,
118        }
119    }
120}
121
122/// Parses a JSON Lines trace document into events, enforcing [`Limits`].
123///
124/// # Errors
125///
126/// Returns the first [`TraceParseError`] encountered, addressed by 1-based line
127/// number. An empty document yields an empty event list (validating an empty trace is
128/// the engine's question, not the parser's).
129///
130/// ```
131/// use mcp_trace_validator::reader::{Limits, parse_trace};
132///
133/// let line = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"lifecycle","event":"transport-open"}"#;
134/// assert_eq!(parse_trace(line, &Limits::default())?.len(), 1);
135/// assert!(parse_trace("not json", &Limits::default()).is_err());
136/// # Ok::<(), mcp_trace_validator::reader::TraceParseError>(())
137/// ```
138pub fn parse_trace(input: &str, limits: &Limits) -> Result<Vec<TraceEvent>, TraceParseError> {
139    let mut events = Vec::new();
140    let mut previous_seq: Option<u64> = None;
141    for (index, line) in input.lines().enumerate() {
142        let line_number = index + 1;
143        if line.len() > limits.max_line_bytes {
144            return Err(TraceParseError::LineTooLong {
145                line: line_number,
146                length: line.len(),
147                limit: limits.max_line_bytes,
148            });
149        }
150        if line.trim().is_empty() {
151            return Err(TraceParseError::BlankLine { line: line_number });
152        }
153        if events.len() >= limits.max_events {
154            return Err(TraceParseError::TooManyEvents {
155                limit: limits.max_events,
156            });
157        }
158        let event: TraceEvent =
159            serde_json::from_str(line).map_err(|source| TraceParseError::Malformed {
160                line: line_number,
161                source,
162            })?;
163        if let Some(previous) = previous_seq
164            && event.seq <= previous
165        {
166            return Err(TraceParseError::NonMonotonicSeq {
167                line: line_number,
168                seq: event.seq,
169                previous,
170            });
171        }
172        previous_seq = Some(event.seq);
173        events.push(event);
174    }
175    Ok(events)
176}
177
178#[cfg(test)]
179#[allow(clippy::unwrap_used)]
180mod tests {
181    use super::*;
182
183    const VALID_EVENT: &str = r#"{"seq":0,"direction":"client-to-server","transport":"stdio","kind":"lifecycle","event":"transport-open"}"#;
184
185    #[test]
186    fn parses_valid_lines_and_empty_documents() {
187        assert!(parse_trace("", &Limits::default()).unwrap().is_empty());
188        let one = parse_trace(VALID_EVENT, &Limits::default()).unwrap();
189        assert_eq!(one.len(), 1);
190        // Trailing newline is fine.
191        let with_newline = format!("{VALID_EVENT}\n");
192        assert_eq!(
193            parse_trace(&with_newline, &Limits::default())
194                .unwrap()
195                .len(),
196            1
197        );
198    }
199
200    #[test]
201    fn rejects_blank_interior_lines() {
202        let doc = format!("{VALID_EVENT}\n\n");
203        assert!(matches!(
204            parse_trace(&doc, &Limits::default()),
205            Err(TraceParseError::BlankLine { line: 2 })
206        ));
207    }
208
209    #[test]
210    fn rejects_oversized_lines() {
211        let limits = Limits {
212            max_line_bytes: 16,
213            ..Limits::default()
214        };
215        assert!(matches!(
216            parse_trace(VALID_EVENT, &limits),
217            Err(TraceParseError::LineTooLong { line: 1, .. })
218        ));
219    }
220
221    #[test]
222    fn rejects_too_many_events() {
223        let limits = Limits {
224            max_events: 1,
225            ..Limits::default()
226        };
227        let second = VALID_EVENT.replace("\"seq\":0", "\"seq\":1");
228        let doc = format!("{VALID_EVENT}\n{second}");
229        assert!(matches!(
230            parse_trace(&doc, &limits),
231            Err(TraceParseError::TooManyEvents { limit: 1 })
232        ));
233    }
234
235    #[test]
236    fn rejects_malformed_records_with_line_numbers() {
237        let doc = format!("{VALID_EVENT}\n{{\"seq\":1}}");
238        match parse_trace(&doc, &Limits::default()) {
239            Err(TraceParseError::Malformed { line, .. }) => assert_eq!(line, 2),
240            other => panic!("expected malformed at line 2, got {other:?}"),
241        }
242    }
243
244    #[test]
245    fn rejects_non_monotonic_seq() {
246        let duplicate = format!("{VALID_EVENT}\n{VALID_EVENT}");
247        assert!(matches!(
248            parse_trace(&duplicate, &Limits::default()),
249            Err(TraceParseError::NonMonotonicSeq {
250                line: 2,
251                seq: 0,
252                previous: 0
253            })
254        ));
255    }
256
257    #[test]
258    fn error_messages_are_line_addressed() {
259        let doc = format!("{VALID_EVENT}\nnot json");
260        let error = parse_trace(&doc, &Limits::default()).unwrap_err();
261        assert!(error.to_string().starts_with("line 2:"), "{error}");
262    }
263
264    #[test]
265    fn line_exactly_at_the_byte_limit_is_accepted() {
266        // Boundary pinning: the limit is inclusive (> rejects, == passes).
267        let limits = Limits {
268            max_line_bytes: VALID_EVENT.len(),
269            ..Limits::default()
270        };
271        assert_eq!(parse_trace(VALID_EVENT, &limits).unwrap().len(), 1);
272    }
273
274    #[test]
275    fn error_source_is_exposed_for_malformed_records_only() {
276        use core::error::Error as _;
277        let malformed = parse_trace("nope", &Limits::default()).unwrap_err();
278        assert!(malformed.source().is_some());
279        let blank = parse_trace(" \n", &Limits::default()).unwrap_err();
280        assert!(blank.source().is_none());
281    }
282}