mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-31 07:38:16 +00:00
commit
58abe9ddb7
3 files changed
+79
-5
No files matched your search
@@ -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) }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user