From 1eb4d0afe0780d6ae2deb4545332c52569ee7ffc Mon Sep 17 00:00:00 2001 From: Rares Cristea Date: Wed, 4 Nov 2026 14:29:24 +0200 Subject: [PATCH] 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. --- crates/platform/src/process.rs | 27 +++++++++++++++++++++++++-- docs/pitfalls.md | 8 ++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/platform/src/process.rs b/crates/platform/src/process.rs index 3d6076a1..70501180 100644 --- a/crates/platform/src/process.rs +++ b/crates/platform/src/process.rs @@ -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 = 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(); diff --git a/docs/pitfalls.md b/docs/pitfalls.md index 396924ea..6bb29639 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -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.