Read a plugin's whole tools/list line, however long (#797)

An MCP plugin whose `tools/list` answer is one JSON-RPC line over 1 MiB never loaded: the process reader cut long lines into fragments that never parsed, so the plugin timed out. The partial-line cap is now the 16 MiB the MCP client already allows, with a regression test for a multi-megabyte line.
This commit is contained in:
Rares Cristea
2026-11-04 14:29:24 +02:00
committed by GitHub
parent a067ac6981
commit 1eb4d0afe0
2 changed files with 33 additions and 2 deletions
+25 -2
View File
@@ -59,8 +59,10 @@ pub const DEFAULT_STDERR_CAP: usize = 1024 * 1024;
/// How long to wait for the output threads after the child exits (an escaped grandchild may hold
/// the pipe open; we stop waiting rather than hang).
const DRAIN_GRACE: Duration = Duration::from_secs(2);
/// Longest partial line kept before it is delivered anyway.
const MAX_LINE: usize = 1024 * 1024;
/// Longest partial line kept before it is delivered anyway. MCP servers answer `tools/list` as
/// one JSON-RPC line that can be megabytes (a plugin with many richly described tools), so this
/// must cover such a whole line: a fragment is not JSON, and the answer would never parse.
const MAX_LINE: usize = 16 * 1024 * 1024;
/// A program to run, built fluently.
#[derive(Clone)]
@@ -1445,6 +1447,27 @@ mod tests {
assert!(err.message.contains("bad thing"), "{}", err.message);
}
/// An MCP plugin answers `tools/list` as one JSON-RPC line that can be megabytes (a server with
/// many richly described tools). A line past the old 1 MiB cap arrived in fragments, so the
/// answer never parsed and the plugin timed out: the whole line must arrive as one line.
#[cfg(unix)]
#[test]
fn a_multimegabyte_line_arrives_whole() {
let (tx, rx) = std::sync::mpsc::channel();
let wanted = 3 * 1024 * 1024 + 7; // past the old cap, and not a round number
let out = Process::new("sh")
.args(["-c", &format!("head -c {wanted} /dev/zero | tr '\\0' a; echo")])
.on_stdout_line(move |l| {
let _ = tx.send(l.len());
})
.timeout(Duration::from_secs(30))
.run()
.unwrap();
assert!(out.success);
let lines: Vec<usize> = rx.into_iter().collect();
assert_eq!(lines, vec![wanted], "one line of {wanted} bytes, not {} fragments of {:?}", lines.len(), lines);
}
#[test]
fn missing_program_is_a_clear_error() {
let err = Process::new("no-such-program-craft-xyz").run().unwrap_err();
+8
View File
@@ -734,3 +734,11 @@ sum the same payload repeatedly. Count checksum byte visits across the whole sca
cancellation callback inside long sums, and distinguish skipped verification from a mismatch.
Test overlapping candidates with correct checksums and a complete nested archive so a structural
trailer cannot turn unchecked data into a high-confidence result.
**A line cap that cut a JSON-RPC answer in pieces.** MCP over stdio answers one message per line,
and a server with many richly described tools answers `tools/list` as several megabytes on that
one line. The process reader capped a partial line at 1 MiB and delivered it anyway, so the answer
arrived as fragments that never parsed and the plugin failed as a `tools/list` timeout — while the
same server answered a direct probe in under a second. A cap protects memory, but a JSON-RPC line
must arrive whole to parse at all: keep the cap at the size the MCP client already allows, and test
with a multi-megabyte line, not a short one.