//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources methods. use std::io::{BufRead, Write}; use serde_json::{Value, json}; use crate::backend::Backend; use crate::tools::{call_tool, tool_definitions}; /// The MCP revision we implement. pub const PROTOCOL_VERSION: &str = "2025-06-18"; 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; const INSTRUCTIONS: &str = "DesignCraft is a page-layout app (an InDesign clone). Coordinates are points in spread \ space (y down; on a single-page spread the page's top-left is (0,0); a default Letter page is 612×792). Every action \ is a command: find ids and parameters with list_commands and run them with execute (or several with batch). Typical \ flow: new_document → execute frame.create {rect:[x0,y0,x1,y1], content:\"text\", text:\"…\"} → set_story_text / \ execute type.char / style.paragraph.apply → render_page to look at the result. inspect_document lists pages, items \ (ids, bounds), stories (overset) and styles. place_image puts a picture on the page."; /// Resource URIs. pub const DOC_URI: &str = "designcraft://document"; pub const COMMANDS_URI: &str = "designcraft://commands"; /// An MCP server bound to one backend. pub struct Server { backend: Box, initialized: bool, } fn response(id: Value, result: Value) -> Value { json!({"jsonrpc": "2.0", "id": id, "result": result}) } fn error(id: Value, code: i64, message: impl Into) -> Value { json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}}) } impl Server { pub fn new(backend: Box) -> 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, 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) { output.write_all(reply.as_bytes())?; output.write_all(b"\n")?; output.flush()?; } } Ok(()) } /// Handle one line; returns the reply line (None for notifications and blank lines). pub fn handle_line(&mut self, line: &str) -> Option { let line = line.trim(); if line.is_empty() { return None; } let reply = match serde_json::from_str::(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 = 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 { 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")); } 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 { 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": {}, "resources": {}}, "serverInfo": {"name": "designcraft", "title": "DesignCraft", "version": env!("CARGO_PKG_VERSION"), "websiteUrl": designcraft_engine::links::APP_PAGE}, "instructions": format!( "{INSTRUCTIONS} Backend: {}. Community: {} (Discord), {} (app page), {} (source). The `app.links` command returns these links.", self.backend.describe(), designcraft_engine::links::DISCORD, designcraft_engine::links::APP_PAGE, designcraft_engine::links::GITHUB ), })) } "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 = params.get("arguments").cloned().unwrap_or(Value::Null); check_arguments(name, &args).map_err(|m| (INVALID_PARAMS, m))?; Ok(call_tool(self.backend.as_mut(), name, &args).to_value()) } "resources/list" => Ok(json!({"resources": [ {"uri": DOC_URI, "name": "document", "title": "Active document", "description": "Pages, spreads, items, stories, styles, swatches and selection of the active document (document.inspect)", "mimeType": "application/json"}, {"uri": COMMANDS_URI, "name": "commands", "title": "Commands", "description": "Every command: id, label, menu path, shortcut, params, enabled (engine.commands)", "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 { DOC_URI => self.backend.call("document.inspect", json!({})), COMMANDS_URI => self.backend.call("engine.commands", json!({})), _ => 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()}]})) } other => Err((METHOD_NOT_FOUND, format!("method not found: {other}"))), } } } /// Arguments a tool doesn't take, and command params a command doesn't take, are invalid params /// (#776): a misspelt key is refused instead of being ignored. `execute` and `batch` are checked /// against the params descriptions of the commands they run, every step before the first runs. fn check_arguments(name: &str, args: &Value) -> Result<(), String> { let Some(arguments) = args.as_object() else { return Ok(()) }; if let Some(def) = tool_definitions().into_iter().find(|t| t.get("name").and_then(Value::as_str) == Some(name)) && let Some(properties) = def.get("inputSchema").and_then(|s| s.get("properties")).and_then(Value::as_object) && let Some(key) = arguments.keys().find(|k| !properties.contains_key(*k)) { return Err(format!("unknown argument `{key}` for `{name}`")); } let command = |step: &Value| -> Result<(), String> { match (step.get("command").and_then(Value::as_str), step.get("params")) { (Some(id), Some(params)) => designcraft_engine::check_params(id, params).map_err(|e| e.to_string()), _ => Ok(()), } }; match name { "execute" => command(args), "batch" => { let steps = match (arguments.get("commands").and_then(Value::as_array), arguments.get("script").and_then(Value::as_str)) { (Some(steps), _) => steps.clone(), // A script that doesn't parse is the tool's to report. (None, Some(text)) => match designcraft_engine::script::parse(text) { Ok(steps) => steps.into_iter().map(|s| json!({"command": s.command, "params": s.params})).collect(), Err(_) => return Ok(()), }, (None, None) => return Ok(()), }; for (i, step) in steps.iter().enumerate() { command(step).map_err(|e| format!("step {i}: {e}"))?; } Ok(()) } _ => Ok(()), } }