From 867a89e112dcb64ea69d890d3219951157ebab2e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 22:28:38 +0000 Subject: [PATCH] address review: charge the escaped batch reply against the budget --- crates/mcp/src/tools.rs | 6 ++++-- crates/mcp/tests/it/batch_budget.rs | 13 +++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 3bae69d25..b5a05493e 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -369,12 +369,14 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { let id = c.get("command").and_then(Value::as_str).unwrap_or(""); match exec(b, id, c.get("params").cloned().unwrap_or(json!({}))) { Ok(v) => { - used = used.saturating_add(v.to_string().len()); + let entry = json!({"command": id, "ok": true, "result": v}); + // What the reply carries: the entry as text, escaped again inside the JSON-RPC reply. + used = used.saturating_add(json!(entry.to_string()).to_string().len()); if used > MAX_BATCH_BYTES { out.push(json!({"command": id, "ok": false, "error": "the batch's results passed 64 MiB; stopped here"})); return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true }; } - out.push(json!({"command": id, "ok": true, "result": v})); + out.push(entry); } Err(e) => { out.push(json!({"command": id, "ok": false, "error": e})); diff --git a/crates/mcp/tests/it/batch_budget.rs b/crates/mcp/tests/it/batch_budget.rs index 3794c1551..7172ddca0 100644 --- a/crates/mcp/tests/it/batch_budget.rs +++ b/crates/mcp/tests/it/batch_budget.rs @@ -43,3 +43,16 @@ fn the_batch_tool_is_bounded_in_commands_and_result_bytes() { assert!(err && text.contains("64 MiB"), "{}", &text[text.len().saturating_sub(200)..]); assert!(text.len() < (64 << 20) + (4 << 20)); } + +#[test] +fn the_batch_tool_charges_the_escaped_reply() { + // Quotes escape twice on the way out (`\\\"`), so 30 results of ~2 MiB serialize to ~120 MiB. + let mut server = Server::new(Box::new(Headless::with_document())); + let notes = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "slide.notes", "params": {"text": "\"".repeat(1 << 20)}}}}); + server.handle_line(¬es.to_string()).unwrap(); + let commands = vec![json!({"command": "slide.inspect"}); 30]; + let line = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "batch", "arguments": {"commands": commands, "keepGoing": true}}}); + let reply = server.handle_line(&line.to_string()).unwrap(); + assert!(reply.len() < (64 << 20) + (4 << 20), "{} bytes", reply.len()); + assert!(reply.contains("64 MiB")); +}