1use super::era::{conclude, conclude_request, RequestAssessment, ResultConclusion};
11use super::parser::{parse_mcp_transcript_detailed_with_depth, EmbeddedJsonDepthExceeded};
12use super::{McpEvent, McpInputFormat};
13use assay_common::limits::{LimitExceeded, LimitKind, LimitReader};
14use serde::de::{DeserializeSeed, Deserializer, IgnoredAny, MapAccess, SeqAccess, Visitor};
15use std::io::Read;
16
17#[derive(Debug, Clone, Copy)]
19#[non_exhaustive]
20pub struct McpTranscriptLimits {
21 pub max_source_bytes: u64,
23 pub max_line_bytes: u64,
25 pub max_events: usize,
27 pub max_json_depth: usize,
29}
30
31impl Default for McpTranscriptLimits {
32 fn default() -> Self {
33 Self {
34 max_source_bytes: 16 * 1024 * 1024,
35 max_line_bytes: 1024 * 1024,
36 max_events: 100_000,
37 max_json_depth: 64,
38 }
39 }
40}
41
42#[derive(Debug, thiserror::Error)]
44#[non_exhaustive]
45pub enum McpTranscriptIngestError {
46 #[error("MCP transcript exceeded source-byte limit of {limit}")]
47 SourceBytes { limit: u64 },
48 #[error("MCP transcript JSONL line exceeded byte limit of {limit}")]
49 LineBytes { limit: u64 },
50 #[error("MCP transcript event count exceeded limit of {limit}")]
51 Events { limit: usize },
52 #[error("MCP transcript JSON nesting exceeded depth limit of {limit}")]
53 JsonDepth { limit: usize },
54 #[error("MCP transcript is not valid UTF-8")]
55 InvalidUtf8,
56 #[error("MCP transcript could not be read")]
57 ReadFailed,
58 #[error("MCP transcript is invalid")]
59 InvalidTranscript,
60 #[error("MCP transcript conclusion is incomplete")]
62 ConclusionIncomplete,
63 #[error("MCP transcript conclusion is invalid")]
65 ConclusionInvalid,
66}
67
68pub fn parse_mcp_transcript_bounded<R: Read>(
75 reader: R,
76 format: McpInputFormat,
77 limits: McpTranscriptLimits,
78) -> Result<Vec<McpEvent>, McpTranscriptIngestError> {
79 let mut reader = LimitReader::new(reader, limits.max_source_bytes, LimitKind::SourceBytes);
80 let mut bytes = Vec::new();
81 if let Err(error) = reader.read_to_end(&mut bytes) {
82 if let Some(LimitExceeded { limit, .. }) = LimitExceeded::from_io(&error) {
83 return Err(McpTranscriptIngestError::SourceBytes { limit });
84 }
85 return Err(McpTranscriptIngestError::ReadFailed);
86 }
87 check_json_depth(&bytes, format, limits.max_json_depth)?;
88 if format == McpInputFormat::JsonRpc {
89 check_jsonl_lines(&bytes, limits.max_line_bytes)?;
90 }
91 let text = String::from_utf8(bytes).map_err(|_| McpTranscriptIngestError::InvalidUtf8)?;
92 check_event_count(text.as_bytes(), format, limits.max_events)?;
93 let parsed = parse_mcp_transcript_detailed_with_depth(&text, format, limits.max_json_depth)
94 .map_err(|error| {
95 if error.downcast_ref::<EmbeddedJsonDepthExceeded>().is_some() {
96 McpTranscriptIngestError::JsonDepth {
97 limit: limits.max_json_depth,
98 }
99 } else {
100 McpTranscriptIngestError::InvalidTranscript
101 }
102 })?;
103 for entry in &parsed {
104 if entry.is_error_response {
105 continue;
109 }
110 if let Some(metadata) = &entry.context.request_metadata {
111 match conclude_request(
112 &entry.context.era,
113 metadata,
114 entry.context.capability_observation.as_ref(),
115 ) {
116 RequestAssessment::Valid => {}
117 RequestAssessment::Incomplete(_) => {
118 return Err(McpTranscriptIngestError::ConclusionIncomplete)
119 }
120 RequestAssessment::Invalid(_) => {
121 return Err(McpTranscriptIngestError::ConclusionInvalid)
122 }
123 }
124 }
125 if let Some(result) = &entry.context.result_observation {
126 match conclude(
127 &entry.context.era,
128 result,
129 entry.context.capability_observation.as_ref(),
130 ) {
131 ResultConclusion::Terminal | ResultConclusion::NonTerminal => {}
132 ResultConclusion::Incomplete(_) => {
133 return Err(McpTranscriptIngestError::ConclusionIncomplete)
134 }
135 ResultConclusion::Invalid(_) => {
136 return Err(McpTranscriptIngestError::ConclusionInvalid)
137 }
138 }
139 }
140 }
141 Ok(parsed.into_iter().map(|entry| entry.event).collect())
142}
143
144fn check_jsonl_lines(bytes: &[u8], max_line_bytes: u64) -> Result<(), McpTranscriptIngestError> {
145 for line in bytes.split(|byte| *byte == b'\n') {
146 if line.len() as u64 > max_line_bytes {
147 return Err(McpTranscriptIngestError::LineBytes {
148 limit: max_line_bytes,
149 });
150 }
151 }
152 Ok(())
153}
154
155fn check_json_depth(
156 bytes: &[u8],
157 format: McpInputFormat,
158 max_json_depth: usize,
159) -> Result<(), McpTranscriptIngestError> {
160 if super::json_depth::exceeds_limit(bytes, max_json_depth, format == McpInputFormat::JsonRpc) {
161 return Err(McpTranscriptIngestError::JsonDepth {
162 limit: max_json_depth,
163 });
164 }
165 Ok(())
166}
167
168fn check_event_count(
169 bytes: &[u8],
170 format: McpInputFormat,
171 max_events: usize,
172) -> Result<(), McpTranscriptIngestError> {
173 let count = match format {
174 McpInputFormat::JsonRpc => {
175 let count = bytes
176 .split(|byte| *byte == b'\n')
177 .filter(|line| !line.iter().all(u8::is_ascii_whitespace))
178 .count();
179 if count > max_events {
180 CountOutcome::OverLimit
181 } else {
182 CountOutcome::WithinLimit
183 }
184 }
185 McpInputFormat::Inspector => count_container_array(bytes, "events", true, max_events)?,
186 McpInputFormat::StreamableHttp | McpInputFormat::HttpSse => {
187 count_container_array(bytes, "entries", false, max_events)?
188 }
189 };
190 match count {
191 CountOutcome::WithinLimit => Ok(()),
192 CountOutcome::OverLimit => Err(McpTranscriptIngestError::Events { limit: max_events }),
193 }
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197enum CountOutcome {
198 WithinLimit,
199 OverLimit,
200}
201
202fn count_container_array(
203 bytes: &[u8],
204 array_key: &'static str,
205 root_array_is_target: bool,
206 max_events: usize,
207) -> Result<CountOutcome, McpTranscriptIngestError> {
208 let mut deserializer = serde_json::Deserializer::from_slice(bytes);
209 deserializer
210 .deserialize_any(ContainerVisitor {
211 array_key,
212 root_array_is_target,
213 max_events,
214 })
215 .map_err(|_| McpTranscriptIngestError::InvalidTranscript)
216}
217
218struct ContainerVisitor {
219 array_key: &'static str,
220 root_array_is_target: bool,
221 max_events: usize,
222}
223
224impl<'de> Visitor<'de> for ContainerVisitor {
225 type Value = CountOutcome;
226
227 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
228 formatter.write_str("an MCP transcript container")
229 }
230
231 fn visit_seq<A: SeqAccess<'de>>(self, sequence: A) -> Result<Self::Value, A::Error> {
232 if self.root_array_is_target {
233 CountSeed {
234 max_events: self.max_events,
235 }
236 .count(sequence)
237 } else {
238 drain_sequence(sequence)?;
239 Ok(CountOutcome::WithinLimit)
240 }
241 }
242
243 fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
244 let mut outcome = CountOutcome::WithinLimit;
245 while let Some(key) = map.next_key::<String>()? {
246 if key == self.array_key {
247 let observed = map.next_value_seed(CountSeed {
248 max_events: self.max_events,
249 })?;
250 if observed == CountOutcome::OverLimit {
251 return Ok(observed);
252 }
253 outcome = observed;
254 } else {
255 map.next_value::<IgnoredAny>()?;
256 }
257 }
258 Ok(outcome)
259 }
260
261 fn visit_bool<E: serde::de::Error>(self, _value: bool) -> Result<Self::Value, E> {
262 Ok(CountOutcome::WithinLimit)
263 }
264
265 fn visit_i64<E: serde::de::Error>(self, _value: i64) -> Result<Self::Value, E> {
266 Ok(CountOutcome::WithinLimit)
267 }
268
269 fn visit_u64<E: serde::de::Error>(self, _value: u64) -> Result<Self::Value, E> {
270 Ok(CountOutcome::WithinLimit)
271 }
272
273 fn visit_f64<E: serde::de::Error>(self, _value: f64) -> Result<Self::Value, E> {
274 Ok(CountOutcome::WithinLimit)
275 }
276
277 fn visit_str<E: serde::de::Error>(self, _value: &str) -> Result<Self::Value, E> {
278 Ok(CountOutcome::WithinLimit)
279 }
280
281 fn visit_string<E: serde::de::Error>(self, _value: String) -> Result<Self::Value, E> {
282 Ok(CountOutcome::WithinLimit)
283 }
284
285 fn visit_none<E: serde::de::Error>(self) -> Result<Self::Value, E> {
286 Ok(CountOutcome::WithinLimit)
287 }
288
289 fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
290 Ok(CountOutcome::WithinLimit)
291 }
292}
293
294struct CountSeed {
295 max_events: usize,
296}
297
298impl CountSeed {
299 fn count<'de, A: SeqAccess<'de>>(&self, mut sequence: A) -> Result<CountOutcome, A::Error> {
300 let mut count = 0usize;
301 while sequence.next_element::<IgnoredAny>()?.is_some() {
302 count += 1;
303 if count > self.max_events {
304 return Ok(CountOutcome::OverLimit);
305 }
306 }
307 Ok(CountOutcome::WithinLimit)
308 }
309}
310
311impl<'de> DeserializeSeed<'de> for CountSeed {
312 type Value = CountOutcome;
313
314 fn deserialize<D: Deserializer<'de>>(self, deserializer: D) -> Result<Self::Value, D::Error> {
315 deserializer.deserialize_any(CountValueVisitor {
316 max_events: self.max_events,
317 })
318 }
319}
320
321struct CountValueVisitor {
322 max_events: usize,
323}
324
325impl<'de> Visitor<'de> for CountValueVisitor {
326 type Value = CountOutcome;
327
328 fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
329 formatter.write_str("an event array")
330 }
331
332 fn visit_seq<A: SeqAccess<'de>>(self, sequence: A) -> Result<Self::Value, A::Error> {
333 CountSeed {
334 max_events: self.max_events,
335 }
336 .count(sequence)
337 }
338
339 fn visit_map<A: MapAccess<'de>>(self, map: A) -> Result<Self::Value, A::Error> {
340 drain_map(map)?;
341 Ok(CountOutcome::WithinLimit)
342 }
343
344 fn visit_bool<E: serde::de::Error>(self, _value: bool) -> Result<Self::Value, E> {
345 Ok(CountOutcome::WithinLimit)
346 }
347
348 fn visit_i64<E: serde::de::Error>(self, _value: i64) -> Result<Self::Value, E> {
349 Ok(CountOutcome::WithinLimit)
350 }
351
352 fn visit_u64<E: serde::de::Error>(self, _value: u64) -> Result<Self::Value, E> {
353 Ok(CountOutcome::WithinLimit)
354 }
355
356 fn visit_f64<E: serde::de::Error>(self, _value: f64) -> Result<Self::Value, E> {
357 Ok(CountOutcome::WithinLimit)
358 }
359
360 fn visit_str<E: serde::de::Error>(self, _value: &str) -> Result<Self::Value, E> {
361 Ok(CountOutcome::WithinLimit)
362 }
363
364 fn visit_string<E: serde::de::Error>(self, _value: String) -> Result<Self::Value, E> {
365 Ok(CountOutcome::WithinLimit)
366 }
367
368 fn visit_none<E: serde::de::Error>(self) -> Result<Self::Value, E> {
369 Ok(CountOutcome::WithinLimit)
370 }
371
372 fn visit_unit<E: serde::de::Error>(self) -> Result<Self::Value, E> {
373 Ok(CountOutcome::WithinLimit)
374 }
375}
376
377fn drain_sequence<'de, A: SeqAccess<'de>>(mut sequence: A) -> Result<(), A::Error> {
378 while sequence.next_element::<IgnoredAny>()?.is_some() {}
379 Ok(())
380}
381
382fn drain_map<'de, A: MapAccess<'de>>(mut map: A) -> Result<(), A::Error> {
383 while map.next_entry::<IgnoredAny, IgnoredAny>()?.is_some() {}
384 Ok(())
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390 use crate::mcp::parser::parse_mcp_transcript_detailed;
391 use std::io::{Cursor, Read};
392
393 const NOTIFICATION: &str = r#"{"jsonrpc":"2.0","method":"notifications/progress","params":{}}"#;
394
395 fn modern_transport(result: &str) -> String {
396 format!(
397 r#"{{"transport":"streamable-http","transport_context":{{"headers":{{"MCP-Protocol-Version":"2026-07-28"}}}},"entries":[{{"request":{{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{{"name":"example.tool","arguments":{{}},"_meta":{{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{{}}}}}}}}}},{{"response":{{"jsonrpc":"2.0","id":1,"result":{result}}}}}]}}"#
398 )
399 }
400
401 fn modern_transport_with_error(error: &str) -> String {
402 format!(
403 r#"{{"transport":"streamable-http","transport_context":{{"headers":{{"MCP-Protocol-Version":"2026-07-28"}}}},"entries":[{{"request":{{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{{"name":"example.tool","arguments":{{}},"_meta":{{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{{}}}}}}}}}},{{"response":{{"jsonrpc":"2.0","id":1,"error":{error}}}}}]}}"#
404 )
405 }
406
407 fn modern_transport_error() -> String {
408 modern_transport_with_error(r#"{"code":-32603,"message":"ATTACKER_SENTINEL"}"#)
409 }
410
411 fn limits() -> McpTranscriptLimits {
412 McpTranscriptLimits {
413 max_source_bytes: 4096,
414 max_line_bytes: 4096,
415 max_events: 8,
416 max_json_depth: 16,
417 }
418 }
419
420 fn assert_source_limit(error: McpTranscriptIngestError, limit: u64) {
421 assert!(
422 matches!(error, McpTranscriptIngestError::SourceBytes { limit: got } if got == limit),
423 "expected source-byte refusal at {limit}, got {error:?}"
424 );
425 }
426
427 #[test]
428 fn source_limit_is_inclusive_and_one_more_byte_is_refused() {
429 let bytes = NOTIFICATION.as_bytes();
430 let mut exact = limits();
431 exact.max_source_bytes = bytes.len() as u64;
432 assert_eq!(
433 parse_mcp_transcript_bounded(Cursor::new(bytes), McpInputFormat::JsonRpc, exact)
434 .unwrap()
435 .len(),
436 1
437 );
438
439 let mut over = exact;
440 over.max_source_bytes -= 1;
441 let error = parse_mcp_transcript_bounded(Cursor::new(bytes), McpInputFormat::JsonRpc, over)
442 .unwrap_err();
443 assert_source_limit(error, over.max_source_bytes);
444 }
445
446 #[test]
447 fn short_non_seekable_reads_cannot_walk_past_the_source_limit() {
448 struct OneByteAtATime(Cursor<Vec<u8>>);
449 impl Read for OneByteAtATime {
450 fn read(&mut self, output: &mut [u8]) -> std::io::Result<usize> {
451 if output.is_empty() {
452 return Ok(0);
453 }
454 self.0.read(&mut output[..1])
455 }
456 }
457
458 let bytes = NOTIFICATION.as_bytes().to_vec();
459 let mut exact = limits();
460 exact.max_source_bytes = bytes.len() as u64;
461 assert!(parse_mcp_transcript_bounded(
462 OneByteAtATime(Cursor::new(bytes.clone())),
463 McpInputFormat::JsonRpc,
464 exact,
465 )
466 .is_ok());
467
468 let mut over = exact;
469 over.max_source_bytes -= 1;
470 let error = parse_mcp_transcript_bounded(
471 OneByteAtATime(Cursor::new(bytes)),
472 McpInputFormat::JsonRpc,
473 over,
474 )
475 .unwrap_err();
476 assert_source_limit(error, over.max_source_bytes);
477 }
478
479 #[test]
480 fn source_limit_fires_before_invalid_utf8_is_materialized() {
481 let input = [b'{', b'}', 0xff];
482 let mut bounded = limits();
483 bounded.max_source_bytes = 2;
484 let error =
485 parse_mcp_transcript_bounded(Cursor::new(input), McpInputFormat::Inspector, bounded)
486 .unwrap_err();
487 assert_source_limit(error, 2);
488 }
489
490 #[test]
491 fn jsonl_line_limit_is_inclusive_and_one_more_byte_is_refused() {
492 let mut exact = limits();
493 exact.max_line_bytes = NOTIFICATION.len() as u64;
494 assert!(parse_mcp_transcript_bounded(
495 Cursor::new(NOTIFICATION),
496 McpInputFormat::JsonRpc,
497 exact,
498 )
499 .is_ok());
500
501 let mut over = exact;
502 over.max_line_bytes -= 1;
503 let error =
504 parse_mcp_transcript_bounded(Cursor::new(NOTIFICATION), McpInputFormat::JsonRpc, over)
505 .unwrap_err();
506 assert!(
507 matches!(error, McpTranscriptIngestError::LineBytes { limit } if limit == over.max_line_bytes)
508 );
509 }
510
511 #[test]
512 fn jsonl_line_limit_fires_before_invalid_json_is_parsed() {
513 let mut bounded = limits();
514 bounded.max_line_bytes = 3;
515 let error =
516 parse_mcp_transcript_bounded(Cursor::new("not-json"), McpInputFormat::JsonRpc, bounded)
517 .unwrap_err();
518 assert!(matches!(
519 error,
520 McpTranscriptIngestError::LineBytes { limit: 3 }
521 ));
522 }
523
524 #[test]
525 fn jsonrpc_event_limit_is_applied_before_the_parser_builds_the_event_vector() {
526 let transcript = format!("{NOTIFICATION}\n{NOTIFICATION}");
527 let mut exact = limits();
528 exact.max_events = 2;
529 assert_eq!(
530 parse_mcp_transcript_bounded(Cursor::new(&transcript), McpInputFormat::JsonRpc, exact,)
531 .unwrap()
532 .len(),
533 2
534 );
535
536 let mut over = exact;
537 over.max_events = 1;
538 let error =
539 parse_mcp_transcript_bounded(Cursor::new(&transcript), McpInputFormat::JsonRpc, over)
540 .unwrap_err();
541 assert!(matches!(
542 error,
543 McpTranscriptIngestError::Events { limit: 1 }
544 ));
545
546 let malformed_second = format!("{NOTIFICATION}\nnot-json");
547 let error = parse_mcp_transcript_bounded(
548 Cursor::new(malformed_second),
549 McpInputFormat::JsonRpc,
550 over,
551 )
552 .unwrap_err();
553 assert!(
554 matches!(error, McpTranscriptIngestError::Events { limit: 1 }),
555 "event ceiling must decide before a later row reaches the semantic parser"
556 );
557 }
558
559 #[test]
560 fn inspector_event_limit_is_applied_to_the_events_array() {
561 let transcript = format!(r#"{{"events":[{NOTIFICATION},{NOTIFICATION}]}}"#);
562 let mut bounded = limits();
563 bounded.max_events = 1;
564 let error = parse_mcp_transcript_bounded(
565 Cursor::new(transcript),
566 McpInputFormat::Inspector,
567 bounded,
568 )
569 .unwrap_err();
570 assert!(matches!(
571 error,
572 McpTranscriptIngestError::Events { limit: 1 }
573 ));
574 }
575
576 #[test]
577 fn transport_event_limit_is_applied_to_the_entries_array() {
578 let entry = format!(r#"{{"request":{NOTIFICATION}}}"#);
579 let transcript =
580 format!(r#"{{"transport":"streamable-http","entries":[{entry},{entry}]}}"#);
581 let mut bounded = limits();
582 bounded.max_events = 1;
583 let error = parse_mcp_transcript_bounded(
584 Cursor::new(transcript),
585 McpInputFormat::StreamableHttp,
586 bounded,
587 )
588 .unwrap_err();
589 assert!(matches!(
590 error,
591 McpTranscriptIngestError::Events { limit: 1 }
592 ));
593 }
594
595 #[test]
596 fn json_depth_limit_is_inclusive_and_one_more_level_is_refused() {
597 let transcript =
598 r#"{"jsonrpc":"2.0","method":"notifications/progress","params":{"outer":{"leaf":1}}}"#;
599 let mut exact = limits();
600 exact.max_json_depth = 3;
601 assert!(parse_mcp_transcript_bounded(
602 Cursor::new(transcript),
603 McpInputFormat::JsonRpc,
604 exact,
605 )
606 .is_ok());
607
608 let mut over = exact;
609 over.max_json_depth = 2;
610 let error =
611 parse_mcp_transcript_bounded(Cursor::new(transcript), McpInputFormat::JsonRpc, over)
612 .unwrap_err();
613 assert!(matches!(
614 error,
615 McpTranscriptIngestError::JsonDepth { limit: 2 }
616 ));
617 }
618
619 #[test]
620 fn sse_string_payload_obeys_the_same_json_depth_limit() {
621 let request = serde_json::json!({
622 "jsonrpc": "2.0",
623 "id": 1,
624 "method": "tools/call",
625 "params": {
626 "name": "example.tool",
627 "arguments": {"one": {"two": {"three": true}}},
628 "_meta": {
629 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
630 "io.modelcontextprotocol/clientCapabilities": {}
631 }
632 }
633 });
634 let response = serde_json::json!({
635 "jsonrpc": "2.0",
636 "id": 1,
637 "result": {"content": [], "resultType": "complete"}
638 });
639 let transcript = serde_json::json!({
640 "transport": "http-sse",
641 "transport_context": {
642 "headers": {"MCP-Protocol-Version": "2026-07-28"}
643 },
644 "entries": [
645 {"sse": {"event": "message", "data": request.to_string()}},
646 {"sse": {"event": "message", "data": response.to_string()}}
647 ]
648 })
649 .to_string();
650 let mut exact = limits();
651 exact.max_json_depth = 5;
652 assert!(parse_mcp_transcript_bounded(
653 Cursor::new(transcript.as_bytes()),
654 McpInputFormat::HttpSse,
655 exact,
656 )
657 .is_ok());
658
659 let mut over = exact;
660 over.max_json_depth = 4;
661
662 let error =
663 parse_mcp_transcript_bounded(Cursor::new(transcript), McpInputFormat::HttpSse, over)
664 .unwrap_err();
665 assert!(matches!(
666 error,
667 McpTranscriptIngestError::JsonDepth { limit: 4 }
668 ));
669 }
670
671 #[test]
672 fn framed_json_depth_accumulates_across_newlines() {
673 let transcript = r#"{
674 "transport": "streamable-http",
675 "transport_context": {
676 "headers": {
677 "MCP-Protocol-Version": "2026-07-28"
678 }
679 },
680 "entries": [
681 {
682 "request": {
683 "jsonrpc": "2.0",
684 "id": 1,
685 "method": "tools/call",
686 "params": {
687 "name": "example.tool",
688 "arguments": {},
689 "_meta": {
690 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
691 "io.modelcontextprotocol/clientCapabilities": {}
692 }
693 }
694 }
695 }
696 ]
697}"#;
698 let mut bounded = limits();
699 bounded.max_json_depth = 1;
700
701 let error = parse_mcp_transcript_bounded(
702 Cursor::new(transcript),
703 McpInputFormat::StreamableHttp,
704 bounded,
705 )
706 .unwrap_err();
707 assert!(matches!(
708 error,
709 McpTranscriptIngestError::JsonDepth { limit: 1 }
710 ));
711 }
712
713 #[test]
714 fn every_jsonrpc_message_shape_requires_the_2_0_marker() {
715 let request = serde_json::json!({
716 "jsonrpc": "2.0",
717 "id": 1,
718 "method": "tools/call",
719 "params": {
720 "name": "example.tool",
721 "arguments": {},
722 "_meta": {
723 "io.modelcontextprotocol/protocolVersion": "2026-07-28",
724 "io.modelcontextprotocol/clientCapabilities": {}
725 }
726 }
727 });
728 let shapes = [
729 ("request", request.clone(), false),
730 (
731 "notification",
732 serde_json::json!({
733 "jsonrpc": "2.0",
734 "method": "notifications/progress",
735 "params": {}
736 }),
737 false,
738 ),
739 (
740 "success response",
741 serde_json::json!({
742 "jsonrpc": "2.0",
743 "id": 1,
744 "result": {"content": [], "resultType": "complete"}
745 }),
746 true,
747 ),
748 (
749 "error response",
750 serde_json::json!({
751 "jsonrpc": "2.0",
752 "id": 1,
753 "error": {"code": -32603, "message": "refused"}
754 }),
755 true,
756 ),
757 ];
758 let markers = [
759 ("missing", None),
760 ("non-string", Some(serde_json::json!(7))),
761 ("wrong", Some(serde_json::json!("1.0"))),
762 ];
763
764 for (shape_name, shape, needs_request) in shapes {
765 for (marker_name, marker) in &markers {
766 let mut malformed = shape.clone();
767 let object = malformed.as_object_mut().unwrap();
768 match marker {
769 Some(value) => {
770 object.insert("jsonrpc".to_string(), value.clone());
771 }
772 None => {
773 object.remove("jsonrpc");
774 }
775 }
776 let transcript = if needs_request {
777 format!("{request}\n{malformed}\n")
778 } else {
779 format!("{malformed}\n")
780 };
781 let error = parse_mcp_transcript_bounded(
782 Cursor::new(transcript),
783 McpInputFormat::JsonRpc,
784 limits(),
785 )
786 .unwrap_err();
787 assert!(
788 matches!(error, McpTranscriptIngestError::InvalidTranscript),
789 "{shape_name} with {marker_name} marker returned {error:?}"
790 );
791 }
792 }
793 }
794
795 #[test]
796 fn braces_inside_strings_do_not_consume_json_depth_budget() {
797 let transcript =
798 r#"{"jsonrpc":"2.0","method":"notifications/progress","params":{"text":"{{[[}}]]"}}"#;
799 let mut bounded = limits();
800 bounded.max_json_depth = 2;
801 assert!(parse_mcp_transcript_bounded(
802 Cursor::new(transcript),
803 McpInputFormat::JsonRpc,
804 bounded,
805 )
806 .is_ok());
807 }
808
809 #[test]
810 fn unterminated_jsonl_string_cannot_mask_depth_on_the_next_line() {
811 let transcript = "{\"text\":\"unterminated\n[[[]]]";
812 let mut bounded = limits();
813 bounded.max_json_depth = 2;
814 let error =
815 parse_mcp_transcript_bounded(Cursor::new(transcript), McpInputFormat::JsonRpc, bounded)
816 .unwrap_err();
817 assert!(matches!(
818 error,
819 McpTranscriptIngestError::JsonDepth { limit: 2 }
820 ));
821 }
822
823 #[test]
824 fn unbalanced_jsonl_depth_does_not_carry_into_the_next_line() {
825 let transcript = format!("{{\"open\":{{\n{NOTIFICATION}");
826 let mut bounded = limits();
827 bounded.max_json_depth = 2;
828 let error =
829 parse_mcp_transcript_bounded(Cursor::new(transcript), McpInputFormat::JsonRpc, bounded)
830 .unwrap_err();
831 assert!(matches!(error, McpTranscriptIngestError::InvalidTranscript));
832 }
833
834 #[test]
835 fn diagnostics_do_not_echo_attacker_controlled_input() {
836 let input = br#"{"ATTACKER_SENTINEL":"#;
837 let error =
838 parse_mcp_transcript_bounded(Cursor::new(input), McpInputFormat::Inspector, limits())
839 .unwrap_err();
840 assert!(matches!(error, McpTranscriptIngestError::InvalidTranscript));
841 assert!(!error.to_string().contains("ATTACKER_SENTINEL"));
842 assert!(!format!("{error:?}").contains("ATTACKER_SENTINEL"));
843 }
844
845 #[test]
846 fn unresolved_request_era_is_refused_after_bounded_parsing() {
847 let request = r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"example.tool","arguments":{}}}"#;
848 let error =
849 parse_mcp_transcript_bounded(Cursor::new(request), McpInputFormat::JsonRpc, limits())
850 .unwrap_err();
851 assert_eq!(error.to_string(), "MCP transcript conclusion is incomplete");
852 }
853
854 #[test]
855 fn modern_result_without_result_type_is_refused_after_bounded_parsing() {
856 let transcript = modern_transport(r#"{"content":[]}"#);
857 let error = parse_mcp_transcript_bounded(
858 Cursor::new(transcript),
859 McpInputFormat::StreamableHttp,
860 limits(),
861 )
862 .unwrap_err();
863 assert_eq!(error.to_string(), "MCP transcript conclusion is invalid");
864 }
865
866 #[test]
867 fn unrecognized_modern_result_is_not_promoted_to_a_clean_reading() {
868 let transcript = modern_transport(r#"{"content":[],"resultType":"future-state"}"#);
869 let error = parse_mcp_transcript_bounded(
870 Cursor::new(transcript),
871 McpInputFormat::StreamableHttp,
872 limits(),
873 )
874 .unwrap_err();
875 assert_eq!(error.to_string(), "MCP transcript conclusion is incomplete");
876 assert!(!format!("{error:?}").contains("future-state"));
877 }
878
879 #[test]
880 fn modern_error_response_is_observed_without_becoming_a_result_conclusion() {
881 let transcript = modern_transport_error();
882 let parsed =
883 parse_mcp_transcript_detailed(&transcript, McpInputFormat::StreamableHttp).unwrap();
884 let error_entry = parsed
885 .iter()
886 .find(|entry| entry.is_error_response)
887 .expect("the parser must observe the JSON-RPC error response");
888 assert!(
889 error_entry.context.result_observation.is_none(),
890 "an error response must not acquire an MCP result conclusion"
891 );
892 assert!(parse_mcp_transcript_bounded(
893 Cursor::new(transcript),
894 McpInputFormat::StreamableHttp,
895 limits(),
896 )
897 .is_ok());
898 }
899
900 #[test]
901 fn malformed_error_members_are_refused_before_error_acceptance() {
902 for malformed in [
903 "null",
904 "7",
905 r#""denied""#,
906 r#"{"message":"missing code"}"#,
907 r#"{"code":-32603}"#,
908 r#"{"code":1.0,"message":"decimal code"}"#,
909 r#"{"code":1.5,"message":"float code"}"#,
910 r#"{"code":-32603,"message":7}"#,
911 ] {
912 let error = parse_mcp_transcript_bounded(
913 Cursor::new(modern_transport_with_error(malformed)),
914 McpInputFormat::StreamableHttp,
915 limits(),
916 )
917 .unwrap_err();
918 assert!(
919 matches!(error, McpTranscriptIngestError::InvalidTranscript),
920 "malformed error member must be a transcript refusal, got {error:?}"
921 );
922 }
923 }
924
925 #[test]
926 fn valid_modern_input_required_with_continuation_is_accepted() {
927 let transcript =
928 modern_transport(r#"{"resultType":"input_required","requestState":"opaque"}"#);
929 assert!(parse_mcp_transcript_bounded(
930 Cursor::new(transcript),
931 McpInputFormat::StreamableHttp,
932 limits(),
933 )
934 .is_ok());
935 }
936}