From 2ba45cdd4c2947b090ad5b4039ed0e10767b7013 Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:58:45 -0500 Subject: [PATCH] dc5-10: bound MCP stdio frames --- crates/mcp/src/server.rs | 52 ++++++++++++++++++++++++++++++++++++---- crates/mcp/src/tests.rs | 28 ++++++++++++++++++++++ docs/mcp.md | 4 +++- 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index 710067cd..747df015 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -1,6 +1,6 @@ //! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources methods. -use std::io::{BufRead, Write}; +use std::io::{self, BufRead, Write}; use serde_json::{Value, json}; @@ -17,6 +17,8 @@ const METHOD_NOT_FOUND: i64 = -32601; const INVALID_PARAMS: i64 = -32602; const INTERNAL_ERROR: i64 = -32603; const RESOURCE_NOT_FOUND: i64 = -32002; +/// Keep request frames in step with the connected backend's reply limit. +const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024; const INSTRUCTIONS: &str = "DeckCraft is a presentation app (a PowerPoint clone). Coordinates are points on the \ current slide (y down; the default 16:9 slide is 960×540). Every action is a command: find ids and parameters with \ @@ -62,9 +64,16 @@ impl Server { /// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is /// the protocol stream). pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> { - for line in input.lines() { - let line = line?; - if let Some(reply) = self.handle_line(&line) { + let mut input = input; + while let Some(frame) = read_frame(&mut input)? { + let reply = match frame { + Frame::Complete(bytes) => match std::str::from_utf8(&bytes) { + Ok(line) => self.handle_line(line), + Err(_) => Some(error(Value::Null, PARSE_ERROR, "request frame is not valid UTF-8").to_string()), + }, + Frame::TooLarge => Some(error(Value::Null, INVALID_REQUEST, "request frame exceeds 16 MiB").to_string()), + }; + if let Some(reply) = reply { output.write_all(reply.as_bytes())?; output.write_all(b"\n")?; output.flush()?; @@ -172,3 +181,38 @@ impl Server { } } } + +enum Frame { + Complete(Vec), + TooLarge, +} + +/// Read through one newline while retaining no more than the documented frame budget. +fn read_frame(input: &mut impl BufRead) -> io::Result> { + let mut bytes = Vec::new(); + let mut too_large = false; + loop { + let available = input.fill_buf()?; + if available.is_empty() { + if bytes.is_empty() && !too_large { + return Ok(None); + } + return Ok(Some(if too_large { Frame::TooLarge } else { Frame::Complete(bytes) })); + } + let count = available.iter().position(|byte| *byte == b'\n').map_or(available.len(), |i| i + 1); + if !too_large { + let remaining = MAX_FRAME_BYTES.saturating_sub(bytes.len()); + if count > remaining { + too_large = true; + bytes.clear(); + } else { + bytes.extend_from_slice(&available[..count]); + } + } + let ended = available[count - 1] == b'\n'; + input.consume(count); + if ended { + return Ok(Some(if too_large { Frame::TooLarge } else { Frame::Complete(bytes) })); + } + } +} diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index f34ee867..bfffa83d 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -10,6 +10,34 @@ fn call(s: &mut Server, id: u64, method: &str, params: Value) -> Value { serde_json::from_str(&out).expect("json") } +#[test] +fn oversized_stdio_frame_is_drained_before_the_next_request() { + let mut s = Server::new(Box::new(Headless::new())); + let mut input = vec![b'x'; 16 * 1024 * 1024 + 1]; + input.extend_from_slice(b"\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}\n"); + let mut output = Vec::new(); + s.serve(std::io::Cursor::new(input), &mut output).unwrap(); + let replies: Vec = std::str::from_utf8(&output).unwrap().lines().map(|line| serde_json::from_str(line).unwrap()).collect(); + assert_eq!(replies.len(), 2); + assert_eq!(replies[0]["error"]["code"], -32600); + assert!(replies[0]["error"]["message"].as_str().unwrap().contains("16 MiB")); + assert_eq!(replies[1]["id"], 1); + assert_eq!(replies[1]["result"]["serverInfo"]["name"], "deckcraft"); +} + +#[test] +fn invalid_utf8_stdio_frame_gets_a_parse_error_and_does_not_stop_serving() { + let mut s = Server::new(Box::new(Headless::new())); + let mut input = b"\xff\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}\n".to_vec(); + let mut output = Vec::new(); + s.serve(std::io::Cursor::new(&mut input), &mut output).unwrap(); + let replies: Vec = std::str::from_utf8(&output).unwrap().lines().map(|line| serde_json::from_str(line).unwrap()).collect(); + assert_eq!(replies.len(), 2); + assert_eq!(replies[0]["error"]["code"], -32700); + assert!(replies[0]["error"]["message"].as_str().unwrap().contains("UTF-8")); + assert_eq!(replies[1]["result"]["serverInfo"]["name"], "deckcraft"); +} + fn tool(s: &mut Server, name: &str, args: Value) -> Value { let r = call(s, 9, "tools/call", json!({"name": name, "arguments": args})); let res = &r["result"]; diff --git a/docs/mcp.md b/docs/mcp.md index 0b3b3a3d..5f660f2b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3,7 +3,9 @@ `deckcraft-cli mcp` runs a Model Context Protocol server on stdio. By default it drives its own headless engine (no window); with `--connect PORT` it drives a running DeckCraft started with `--control PORT`, so an agent and a person can work on the same deck. `--sample` opens the sample -deck. +deck. Stdio request frames are limited to 16 MiB including the newline; oversized frames receive a +JSON-RPC error and are drained so the next request can still be handled. Invalid UTF-8 frames receive +a parse error. Connected mode accepts only newline-terminated replies of at most 16 MiB (including the newline), with the current request's numeric `id`, a boolean `ok`, and either `result` on success or a string