mirror of
https://github.com/storytold/craft-anything.git
synced 2026-11-05 20:47:45 +01:00
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:
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user