mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-12 05:12:32 +00:00
`serve` read a whole line before checking it against `MAX_LINE`, so a client sending a newline-free payload grew the buffer without limit. The line is now read through `take`, as the control server does; the rest of an over-long line is read and dropped, the client gets the "message too large" error, and the next message is served (stdio has one client, so the stream stays open rather than closing as the control server's connections do). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
236 lines
11 KiB
Rust
236 lines
11 KiB
Rust
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources / prompts methods.
|
|
|
|
use std::io::{BufRead, Read, Write};
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::backend::Backend;
|
|
use crate::tools::{call_tool, check_arguments, tool_definitions};
|
|
|
|
/// The MCP revision we implement.
|
|
pub const PROTOCOL_VERSION: &str = "2025-06-18";
|
|
/// Revisions we can speak; an older client gets its own version echoed back.
|
|
pub const SUPPORTED_VERSIONS: &[&str] = &[PROTOCOL_VERSION, "2025-03-26", "2024-11-05"];
|
|
|
|
const PARSE_ERROR: i64 = -32700;
|
|
const INVALID_REQUEST: i64 = -32600;
|
|
const METHOD_NOT_FOUND: i64 = -32601;
|
|
const INVALID_PARAMS: i64 = -32602;
|
|
const INTERNAL_ERROR: i64 = -32603;
|
|
const RESOURCE_NOT_FOUND: i64 = -32002;
|
|
|
|
/// Longest accepted message line (16 MB): bigger payloads belong in files.
|
|
const MAX_LINE: usize = 16 * 1024 * 1024;
|
|
|
|
const INSTRUCTIONS: &str = "GridCraft is a spreadsheet app (an Excel clone). Cells use A1 notation (\"B2\", \"A1:D10\", \
|
|
\"Sheet2!A1:B3\"). Every action is a command: find ids and parameters with list_commands and run any of them with \
|
|
execute_command. Convenience tools cover the common flow: write_range / set_cell to enter values and formulas (strings \
|
|
starting with = are formulas), read_range / get_cell to read computed values back, format_range, create_table, \
|
|
insert_chart, sort_range, filter, evaluate_formula (try a formula without changing the sheet), undo / redo, \
|
|
open_workbook / save_workbook. inspect_workbook summarizes sheets, used ranges, tables, charts and names. \
|
|
list_functions lists the worksheet functions.";
|
|
|
|
/// Resource URIs.
|
|
pub const WORKBOOK_URI: &str = "gridcraft://workbook";
|
|
pub const COMMANDS_URI: &str = "gridcraft://commands";
|
|
pub const FUNCTIONS_URI: &str = "gridcraft://functions";
|
|
|
|
/// An MCP server bound to one backend.
|
|
pub struct Server {
|
|
backend: Box<dyn Backend>,
|
|
initialized: bool,
|
|
}
|
|
|
|
fn response(id: Value, result: Value) -> Value {
|
|
json!({"jsonrpc": "2.0", "id": id, "result": result})
|
|
}
|
|
|
|
/// Reads the next newline-delimited message into `buf`, keeping at most `MAX_LINE` bytes of it:
|
|
/// a client sending no newline can't make the server allocate more (#2164). `None` at the end of
|
|
/// `input`; `Some(false)` for a line over the limit, whose rest is read and dropped so the next
|
|
/// message is read whole.
|
|
pub(crate) fn read_message(input: &mut impl BufRead, buf: &mut Vec<u8>) -> std::io::Result<Option<bool>> {
|
|
buf.clear();
|
|
// One byte past the limit tells an over-long line from one of exactly `MAX_LINE` bytes.
|
|
let limit = u64::try_from(MAX_LINE).unwrap_or(u64::MAX).saturating_add(1);
|
|
if input.take(limit).read_until(b'\n', buf)? == 0 {
|
|
return Ok(None);
|
|
}
|
|
if buf.len() > MAX_LINE && buf.last() != Some(&b'\n') {
|
|
skip_line(input)?;
|
|
buf.clear();
|
|
return Ok(Some(false));
|
|
}
|
|
Ok(Some(true))
|
|
}
|
|
|
|
/// Reads up to and including the next newline (or to the end of `input`) without keeping it.
|
|
fn skip_line(input: &mut impl BufRead) -> std::io::Result<()> {
|
|
loop {
|
|
let chunk = input.fill_buf()?;
|
|
if chunk.is_empty() {
|
|
return Ok(());
|
|
}
|
|
match chunk.iter().position(|&b| b == b'\n') {
|
|
Some(i) => {
|
|
input.consume(i + 1);
|
|
return Ok(());
|
|
}
|
|
None => {
|
|
let n = chunk.len();
|
|
input.consume(n);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn error(id: Value, code: i64, message: impl Into<String>) -> Value {
|
|
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}})
|
|
}
|
|
|
|
impl Server {
|
|
pub fn new(backend: Box<dyn Backend>) -> Self {
|
|
Self { backend, initialized: false }
|
|
}
|
|
|
|
pub fn backend(&mut self) -> &mut dyn Backend {
|
|
self.backend.as_mut()
|
|
}
|
|
|
|
/// Whether the client has sent `notifications/initialized`.
|
|
pub fn is_initialized(&self) -> bool {
|
|
self.initialized
|
|
}
|
|
|
|
/// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is
|
|
/// the protocol stream).
|
|
pub fn serve(&mut self, mut input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
|
|
let mut buf = Vec::new();
|
|
loop {
|
|
let reply = match read_message(&mut input, &mut buf)? {
|
|
None => return Ok(()),
|
|
Some(false) => Some(error(Value::Null, INVALID_REQUEST, "message too large (16 MB max)").to_string()),
|
|
Some(true) => match std::str::from_utf8(&buf) {
|
|
Ok(line) => self.handle_line(line),
|
|
Err(_) => Some(error(Value::Null, PARSE_ERROR, "message is not valid UTF-8").to_string()),
|
|
},
|
|
};
|
|
if let Some(reply) = reply {
|
|
output.write_all(reply.as_bytes())?;
|
|
output.write_all(b"\n")?;
|
|
output.flush()?;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handle one line; returns the reply line (None for notifications and blank lines).
|
|
pub fn handle_line(&mut self, line: &str) -> Option<String> {
|
|
let line = line.trim();
|
|
if line.is_empty() {
|
|
return None;
|
|
}
|
|
if line.len() > MAX_LINE {
|
|
return Some(error(Value::Null, INVALID_REQUEST, "message too large (16 MB max)").to_string());
|
|
}
|
|
let reply = match serde_json::from_str::<Value>(line) {
|
|
Ok(Value::Array(batch)) => {
|
|
// Batches were removed in 2025-06-18; still answer older clients sensibly.
|
|
if batch.is_empty() {
|
|
Some(error(Value::Null, INVALID_REQUEST, "empty batch"))
|
|
} else {
|
|
let replies: Vec<Value> = batch.into_iter().filter_map(|m| self.handle(m)).collect();
|
|
(!replies.is_empty()).then_some(Value::Array(replies))
|
|
}
|
|
}
|
|
Ok(msg) => self.handle(msg),
|
|
Err(e) => Some(error(Value::Null, PARSE_ERROR, format!("parse error: {e}"))),
|
|
};
|
|
reply.map(|r| r.to_string())
|
|
}
|
|
|
|
/// Handle one JSON-RPC message; `None` for notifications and responses.
|
|
pub fn handle(&mut self, msg: Value) -> Option<Value> {
|
|
let Value::Object(o) = &msg else { return Some(error(Value::Null, INVALID_REQUEST, "message must be an object")) };
|
|
let id = o.get("id").cloned();
|
|
let Some(method) = o.get("method").and_then(Value::as_str) else {
|
|
// A response to a server→client request (we send none): ignore. Anything else is invalid.
|
|
if o.contains_key("result") || o.contains_key("error") {
|
|
return None;
|
|
}
|
|
return Some(error(id.unwrap_or(Value::Null), INVALID_REQUEST, "missing `method`"));
|
|
};
|
|
let params = o.get("params").cloned().unwrap_or(Value::Null);
|
|
let Some(id) = id else {
|
|
self.notification(method);
|
|
return None;
|
|
};
|
|
if !(id.is_string() || id.is_number()) {
|
|
return Some(error(Value::Null, INVALID_REQUEST, "`id` must be a string or number"));
|
|
}
|
|
if !(params.is_object() || params.is_null()) {
|
|
return Some(error(id, INVALID_PARAMS, "`params` must be an object"));
|
|
}
|
|
Some(match self.request(method, ¶ms) {
|
|
Ok(r) => response(id, r),
|
|
Err((code, m)) => error(id, code, m),
|
|
})
|
|
}
|
|
|
|
fn notification(&mut self, method: &str) {
|
|
match method {
|
|
"notifications/initialized" => self.initialized = true,
|
|
"notifications/cancelled" | "notifications/progress" | "notifications/roots/list_changed" => {}
|
|
other => log::debug!("ignoring notification {other}"),
|
|
}
|
|
}
|
|
|
|
fn request(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
|
|
match method {
|
|
"initialize" => {
|
|
let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION);
|
|
let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION };
|
|
Ok(json!({
|
|
"protocolVersion": version,
|
|
"capabilities": {"tools": {"listChanged": false}, "resources": {"listChanged": false, "subscribe": false}, "prompts": {"listChanged": false}},
|
|
"serverInfo": {"name": "gridcraft", "title": "GridCraft", "version": env!("CARGO_PKG_VERSION")},
|
|
"instructions": format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe()),
|
|
}))
|
|
}
|
|
"ping" => Ok(json!({})),
|
|
"tools/list" => Ok(json!({"tools": tool_definitions()})),
|
|
"tools/call" => {
|
|
let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?;
|
|
let args = match params.get("arguments") {
|
|
None | Some(Value::Null) => json!({}),
|
|
Some(v @ Value::Object(_)) => v.clone(),
|
|
Some(_) => return Err((INVALID_PARAMS, "tool `arguments` must be an object".to_string())),
|
|
};
|
|
check_arguments(name, &args).map_err(|e| (INVALID_PARAMS, e))?;
|
|
Ok(call_tool(self.backend.as_mut(), name, &args).to_value())
|
|
}
|
|
"resources/list" => Ok(json!({"resources": [
|
|
{"uri": WORKBOOK_URI, "name": "workbook", "title": "Active workbook", "description": "Sheets, used ranges, tables, charts, names, selection and undo history of the active workbook (document.inspect)", "mimeType": "application/json"},
|
|
{"uri": COMMANDS_URI, "name": "commands", "title": "Commands", "description": "Every command: id, label, ribbon path, shortcut, params, enabled (engine.commands)", "mimeType": "application/json"},
|
|
{"uri": FUNCTIONS_URI, "name": "functions", "title": "Worksheet functions", "description": "Every worksheet function: name, category, signature, description (formulas.functions)", "mimeType": "application/json"},
|
|
]})),
|
|
"resources/templates/list" => Ok(json!({"resourceTemplates": []})),
|
|
"resources/read" => {
|
|
let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri`".to_string()))?;
|
|
let v = match uri {
|
|
WORKBOOK_URI => self.backend.call("document.inspect", json!({})),
|
|
COMMANDS_URI => self.backend.call("engine.commands", json!({})),
|
|
FUNCTIONS_URI => self.backend.call("engine.execute", json!({"command": "formulas.functions", "params": {}})),
|
|
_ => return Err((RESOURCE_NOT_FOUND, format!("resource not found: {uri}"))),
|
|
}
|
|
.map_err(|e| (INTERNAL_ERROR, e))?;
|
|
Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": serde_json::to_string_pretty(&v).unwrap_or_default()}]}))
|
|
}
|
|
"prompts/list" => Ok(json!({"prompts": []})),
|
|
"prompts/get" => Err((INVALID_PARAMS, "no prompts are defined".to_string())),
|
|
"logging/setLevel" => Ok(json!({})),
|
|
"completion/complete" => Ok(json!({"completion": {"values": [], "hasMore": false}})),
|
|
other => Err((METHOD_NOT_FOUND, format!("method not found: {other}"))),
|
|
}
|
|
}
|
|
}
|