Skip to content

Commit f1fa6ca

Browse files
committed
agentic-contract-mcp: restore content-length framed stdio transport
1 parent 56c080c commit f1fa6ca

1 file changed

Lines changed: 72 additions & 34 deletions

File tree

crates/agentic-contract-mcp/src/stdio.rs

Lines changed: 72 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1-
//! Hardened stdio transport — newline-delimited JSON-RPC 2.0 (MCP standard).
1+
//! Hardened stdio transport — Content-Length framed JSON-RPC 2.0 (MCP standard).
22
33
use std::io::{BufRead, BufReader, Read, Write};
44

5-
/// Maximum message size: 8 MiB.
6-
pub const MAX_MESSAGE_BYTES: usize = 8 * 1024 * 1024;
5+
/// Maximum frame size: 8 MiB.
6+
pub const MAX_CONTENT_LENGTH_BYTES: usize = 8 * 1024 * 1024;
7+
/// JSON-RPC stdio framing header marker.
8+
pub const CONTENT_LENGTH_HEADER: &str = "content-length:";
9+
/// Backward-compatible alias for existing transport checks.
10+
pub const MAX_MESSAGE_BYTES: usize = MAX_CONTENT_LENGTH_BYTES;
711

812
/// Transport errors.
913
#[derive(Debug, thiserror::Error)]
@@ -29,7 +33,7 @@ pub enum TransportError {
2933
Json(#[from] serde_json::Error),
3034
}
3135

32-
/// Hardened stdio transport using newline-delimited JSON.
36+
/// Hardened stdio transport using Content-Length framing.
3337
pub struct StdioTransport<R: Read, W: Write> {
3438
reader: BufReader<R>,
3539
writer: W,
@@ -44,37 +48,62 @@ impl<R: Read, W: Write> StdioTransport<R, W> {
4448
}
4549
}
4650

47-
/// Read a single newline-delimited JSON message.
51+
/// Read a single Content-Length framed JSON message.
4852
pub fn read_message(&mut self) -> Result<String, TransportError> {
49-
let mut line = String::new();
50-
let bytes_read = self.reader.read_line(&mut line)?;
51-
if bytes_read == 0 {
52-
return Err(TransportError::Io(std::io::Error::new(
53-
std::io::ErrorKind::UnexpectedEof,
54-
"EOF on stdin",
55-
)));
53+
let mut content_length: Option<usize> = None;
54+
55+
// Parse headers until the terminating empty line.
56+
loop {
57+
let mut line = String::new();
58+
let bytes_read = self.reader.read_line(&mut line)?;
59+
if bytes_read == 0 {
60+
return Err(TransportError::Io(std::io::Error::new(
61+
std::io::ErrorKind::UnexpectedEof,
62+
"EOF while reading headers",
63+
)));
64+
}
65+
66+
let trimmed = line.trim_end_matches(['\r', '\n']);
67+
if trimmed.is_empty() {
68+
break;
69+
}
70+
71+
if let Some((name, value)) = trimmed.split_once(':') {
72+
let header_name = CONTENT_LENGTH_HEADER.trim_end_matches(':');
73+
if name.trim().eq_ignore_ascii_case(header_name) {
74+
let parsed = value.trim().parse::<usize>().map_err(|_| {
75+
TransportError::Io(std::io::Error::new(
76+
std::io::ErrorKind::InvalidData,
77+
"invalid Content-Length value",
78+
))
79+
})?;
80+
content_length = Some(parsed);
81+
}
82+
}
5683
}
5784

58-
let trimmed = line.trim().to_string();
59-
if trimmed.is_empty() {
60-
// Skip blank lines and read the next one
61-
return self.read_message();
62-
}
85+
let len = content_length.ok_or_else(|| {
86+
TransportError::Io(std::io::Error::new(
87+
std::io::ErrorKind::InvalidData,
88+
"missing Content-Length header",
89+
))
90+
})?;
6391

64-
if trimmed.len() > MAX_MESSAGE_BYTES {
65-
return Err(TransportError::MessageTooLarge(
66-
trimmed.len(),
67-
MAX_MESSAGE_BYTES,
68-
));
92+
if len > MAX_MESSAGE_BYTES {
93+
return Err(TransportError::MessageTooLarge(len, MAX_MESSAGE_BYTES));
6994
}
7095

71-
Ok(trimmed)
96+
let mut body = vec![0u8; len];
97+
self.reader.read_exact(&mut body)?;
98+
let message = String::from_utf8(body).map_err(|_| TransportError::InvalidUtf8)?;
99+
Ok(message)
72100
}
73101

74-
/// Write a JSON message followed by a newline.
102+
/// Write a JSON message with MCP Content-Length framing.
75103
pub fn write_message(&mut self, content: &str) -> Result<(), TransportError> {
104+
let header = format!("Content-Length: {}\r\n\r\n", content.len());
105+
self.writer.write_all(header.as_bytes())?;
76106
self.writer.write_all(content.as_bytes())?;
77-
self.writer.write_all(b"\n")?;
78107
self.writer.flush()?;
79108
Ok(())
80109
}
@@ -95,24 +124,33 @@ mod tests {
95124

96125
#[test]
97126
fn test_read_write_message() {
98-
let input = "{\"test\":true}\n";
127+
let input = b"Content-Length: 13\r\n\r\n{\"test\":true}";
99128
let mut output = Vec::new();
100129

101-
let mut transport =
102-
StdioTransport::new(std::io::Cursor::new(input.as_bytes().to_vec()), &mut output);
130+
let mut transport = StdioTransport::new(std::io::Cursor::new(input.to_vec()), &mut output);
103131
let msg = transport.read_message().unwrap();
104132
assert_eq!(msg, "{\"test\":true}");
133+
134+
transport.write_message("hello").unwrap();
135+
let written = String::from_utf8(output).unwrap();
136+
assert_eq!(written, "Content-Length: 5\r\n\r\nhello");
105137
}
106138

107139
#[test]
108-
fn test_skip_blank_lines() {
109-
let input = "\n\n{\"test\":true}\n";
140+
fn test_case_insensitive_content_length() {
141+
let input = b"content-length: 4\r\n\r\ntest";
110142
let mut output = Vec::new();
111-
112-
let mut transport =
113-
StdioTransport::new(std::io::Cursor::new(input.as_bytes().to_vec()), &mut output);
143+
let mut transport = StdioTransport::new(std::io::Cursor::new(input.to_vec()), &mut output);
114144
let msg = transport.read_message().unwrap();
115-
assert_eq!(msg, "{\"test\":true}");
145+
assert_eq!(msg, "test");
146+
}
147+
148+
#[test]
149+
fn test_missing_content_length_fails() {
150+
let input = b"No-Header: value\r\n\r\n{}";
151+
let mut output = Vec::new();
152+
let mut transport = StdioTransport::new(std::io::Cursor::new(input.to_vec()), &mut output);
153+
assert!(transport.read_message().is_err());
116154
}
117155

118156
#[test]

0 commit comments

Comments
 (0)