Merge pull request #1513

Bound MCP stdio request frames
This commit is contained in:
Brandon Thomas committed 2026-10-30 15:58:54 -04:00
commit 58abe9ddb7
3 files changed
+79 -5

No files matched your search

+48 -4
View File
@@ -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<u8>),
TooLarge,
}
/// Read through one newline while retaining no more than the documented frame budget.
fn read_frame(input: &mut impl BufRead) -> io::Result<Option<Frame>> {
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) }));
}
}
}
+28
View File
@@ -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<Value> = 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<Value> = 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"];
+3 -1
View File
@@ -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