diff --git a/Cargo.lock b/Cargo.lock index d373ce94..0d70b5ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -758,6 +758,7 @@ version = "0.1.0" dependencies = [ "designcraft-doc", "designcraft-engine", + "designcraft-mcp", "designcraft-render", "serde_json", ] @@ -836,6 +837,19 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "designcraft-mcp" +version = "0.1.0" +dependencies = [ + "designcraft-engine", + "designcraft-geom", + "designcraft-render", + "designcraft-tools", + "image", + "log", + "serde_json", +] + [[package]] name = "designcraft-render" version = "0.1.0" diff --git a/ROADMAP.md b/ROADMAP.md index 14206957..91fa7dd2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -11,8 +11,9 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document dialog, ⌘K palette, 4 brightness themes. - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection (basic), Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - ~100 commands, all reachable through the JSON control channel; headless CLI rendering to PNG. +- MCP server (`designcraft-cli mcp [--connect PORT]`, docs/mcp.md): headless engine or the running app; commands, batch, document/story inspection, page renders as images, window screenshots, pointer/keyboard/dialog input. -**Next (in order):** MCP server · PDF export (krilla, PDF/X-4) · IDML import/export · native format as zip · web build · Pen tool & Direct Selection anchors · Story Editor · Find/Change (+GREP) · tables · Liang hyphenation · smart guides · Links panel + relink · Preflight · TOC/index/footnotes · EPUB. +**Next (in order):** PDF export (krilla, PDF/X-4) · IDML import/export · native format as zip · web build · Pen tool & Direct Selection anchors · Story Editor · Find/Change (+GREP) · tables · Liang hyphenation · smart guides · Links panel + relink · Preflight · TOC/index/footnotes · EPUB. ## Milestones diff --git a/apps/designcraft-cli/Cargo.toml b/apps/designcraft-cli/Cargo.toml index 51b78bb2..e354a1b8 100644 --- a/apps/designcraft-cli/Cargo.toml +++ b/apps/designcraft-cli/Cargo.toml @@ -16,4 +16,5 @@ path = "src/main.rs" designcraft-engine = { workspace = true } designcraft-render = { workspace = true } designcraft-doc = { workspace = true } +designcraft-mcp = { workspace = true } serde_json = { workspace = true } diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs index 03977eed..7f09bf54 100644 --- a/apps/designcraft-cli/src/main.rs +++ b/apps/designcraft-cli/src/main.rs @@ -3,7 +3,7 @@ //! ```text //! designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--export OUT.png|.jpg|.designcraft] [--all-pages DIR] //! designcraft-cli commands # list every command (JSON) -//! designcraft-cli mcp [--connect PORT] # MCP server over stdio +//! designcraft-cli mcp [--connect PORT] [--sample] # MCP server over stdio (docs/mcp.md) //! ``` use std::process::ExitCode; @@ -22,7 +22,7 @@ fn main() -> ExitCode { Some("mcp") => report(mcp(&args[1..])), _ => { eprintln!( - "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--export OUT] [--all-pages DIR]\n designcraft-cli commands\n designcraft-cli mcp [--connect PORT]" + "usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--export OUT] [--all-pages DIR]\n designcraft-cli commands\n designcraft-cli mcp [--connect PORT] [--sample]" ); ExitCode::FAILURE } @@ -39,8 +39,40 @@ fn report(r: Result<(), String>) -> ExitCode { } } -fn mcp(_args: &[String]) -> Result<(), String> { - Err("the MCP server lands with the designcraft-mcp crate".into()) +/// `mcp` (headless, in-process engine) or `mcp --connect PORT|HOST:PORT` (drive a running app +/// started with `designcraft --control PORT`). JSON-RPC on stdin/stdout; logs on stderr. +fn mcp(args: &[String]) -> Result<(), String> { + use designcraft_mcp::{Backend, Headless, Remote, Server, control_addr}; + let mut connect: Option = None; + let mut sample = false; + let mut it = args.iter(); + while let Some(a) = it.next() { + match a.as_str() { + "--connect" => connect = Some(it.next().cloned().ok_or("--connect needs a port or host:port")?), + "--sample" => sample = true, + other => return Err(format!("unknown mcp option `{other}` (usage: designcraft-cli mcp [--connect PORT] [--sample])")), + } + } + let backend: Box = match connect { + Some(c) => { + let addr = control_addr(&c); + Box::new( + Remote::connect(&addr) + .map_err(|e| format!("cannot connect to the DesignCraft app at {addr}: {e} (start it with `designcraft --control PORT`)"))?, + ) + } + None => { + let mut h = Headless::with_document(); + if sample { + h.session.execute("file.newSample", &json!({})).map_err(|e| e.to_string())?; + } + Box::new(h) + } + }; + eprintln!("designcraft-cli: MCP server on stdio ({})", backend.describe()); + let stdin = std::io::stdin(); + let stdout = std::io::stdout(); + Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string()) } fn run(args: &[String]) -> Result<(), String> { diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml new file mode 100644 index 00000000..fa863aa9 --- /dev/null +++ b/crates/mcp/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "designcraft-mcp" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "DesignCraft MCP server: JSON-RPC 2.0 over stdio, driving a running app or a headless session" + +[lints] +workspace = true + +[dependencies] +designcraft-engine = { workspace = true } +designcraft-render = { workspace = true } +designcraft-tools = { workspace = true } +designcraft-geom = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } + +[dev-dependencies] +image = { workspace = true } diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs new file mode 100644 index 00000000..0b940aa3 --- /dev/null +++ b/crates/mcp/src/backend.rs @@ -0,0 +1,107 @@ +//! Where MCP tool calls end up: a control-channel method call. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::Duration; + +use serde_json::{Value, json}; + +/// Something that answers control-channel methods (`engine.execute`, `document.inspect`, +/// `ui.pointer`, `ui.render`, …). See `designcraft_ui_egui::control` for the full list. +pub trait Backend { + /// Call one method. `Ok` carries the `result`, `Err` the error message. + fn call(&mut self, method: &str, params: Value) -> Result; + /// True when a real UI is attached (`ui.screenshot`, `ui.click`, dialogs… work). + fn has_ui(&self) -> bool; + /// Short human description ("headless", "connected to 127.0.0.1:7979"). + fn describe(&self) -> String; +} + +/// A running DesignCraft app, reached through its loopback control port. +pub struct Remote { + addr: String, + conn: Option<(BufReader, TcpStream)>, + next_id: u64, +} + +impl Remote { + /// Connect to `addr` (`127.0.0.1:7979`), failing fast when nothing is listening. + pub fn connect(addr: &str) -> std::io::Result { + let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1 }; + r.reconnect()?; + Ok(r) + } + + pub fn addr(&self) -> &str { + &self.addr + } + + fn reconnect(&mut self) -> std::io::Result<()> { + self.conn = None; + let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr)); + for sa in self.addr.to_socket_addrs()? { + match TcpStream::connect_timeout(&sa, Duration::from_millis(800)) { + Ok(s) => { + s.set_nodelay(true).ok(); + // The app answers within 60 s (its own timeout); leave headroom. + s.set_read_timeout(Some(Duration::from_secs(90))).ok(); + let read = s.try_clone()?; + self.conn = Some((BufReader::new(read), s)); + return Ok(()); + } + Err(e) => last = e, + } + } + Err(last) + } + + fn roundtrip(&mut self, line: &str) -> std::io::Result { + if self.conn.is_none() { + self.reconnect()?; + } + let Some((reader, writer)) = self.conn.as_mut() else { + return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected")); + }; + writer.write_all(line.as_bytes())?; + writer.write_all(b"\n")?; + writer.flush()?; + let mut reply = String::new(); + if reader.read_line(&mut reply)? == 0 { + return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed")); + } + Ok(reply) + } +} + +impl Backend for Remote { + fn call(&mut self, method: &str, params: Value) -> Result { + let id = self.next_id; + self.next_id += 1; + let line = json!({"id": id, "method": method, "params": params}).to_string(); + // One retry with a fresh connection (the app may have restarted). + let reply = match self.roundtrip(&line) { + Ok(r) => r, + Err(_) => { + self.conn = None; + self.roundtrip(&line).map_err(|e| { + self.conn = None; + format!("DesignCraft app at {} is not reachable: {e}", self.addr) + })? + } + }; + let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from app: {e}"))?; + if v.get("ok").and_then(Value::as_bool) == Some(true) { + Ok(v.get("result").cloned().unwrap_or(Value::Null)) + } else { + Err(v.get("error").and_then(Value::as_str).unwrap_or("unknown error").to_string()) + } + } + + fn has_ui(&self) -> bool { + true + } + + fn describe(&self) -> String { + format!("connected to the DesignCraft app at {}", self.addr) + } +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs new file mode 100644 index 00000000..87bec33c --- /dev/null +++ b/crates/mcp/src/headless.rs @@ -0,0 +1,265 @@ +//! An in-process engine session that answers the engine-level control-channel methods itself. + +use designcraft_engine::{Session, ViewInfo}; +use designcraft_geom::Point; +use designcraft_render::{RenderOptions, Renderer}; +use designcraft_tools::{Mods, PointerEvent, PointerKind, TOOL_GROUPS, ToolKey}; +use serde_json::{Value, json}; + +use crate::backend::Backend; + +/// Headless backend: a [`Session`] plus a CPU [`Renderer`] for page renders and PNG export. +pub struct Headless { + pub session: Session, + pub renderer: Renderer, +} + +impl Default for Headless { + fn default() -> Self { + Self::new() + } +} + +/// Hint appended to errors for methods that need a window. +pub(crate) const NEEDS_APP: &str = "it needs the desktop app: start `designcraft --control 7979` and run the MCP server \ +with `designcraft-cli mcp --connect 7979`"; + +const VIEW: ViewInfo = ViewInfo { zoom: 1.0 }; + +fn s<'a>(p: &'a Value, k: &str) -> Option<&'a str> { + p.get(k).and_then(Value::as_str) +} + +pub(crate) fn pointer_kind(k: &str) -> Option { + Some(match k { + "down" => PointerKind::Down, + "drag" => PointerKind::Drag, + "up" => PointerKind::Up, + "move" => PointerKind::Move, + "doubleclick" | "dblclick" => PointerKind::DoubleClick, + _ => return None, + }) +} + +fn tool_key(name: &str) -> Option { + Some(match name.to_ascii_lowercase().as_str() { + "enter" | "return" => ToolKey::Enter, + "esc" | "escape" => ToolKey::Escape, + "backspace" => ToolKey::Backspace, + "delete" => ToolKey::Delete, + "up" | "arrowup" => ToolKey::Up, + "down" | "arrowdown" => ToolKey::Down, + "left" | "arrowleft" => ToolKey::Left, + "right" | "arrowright" => ToolKey::Right, + "home" => ToolKey::Home, + "end" => ToolKey::End, + "pageup" => ToolKey::PageUp, + "pagedown" => ToolKey::PageDown, + "tab" => ToolKey::Tab, + _ => return None, + }) +} + +/// Does a shortcut like `Cmd+Shift+]` match `key` + `mods`? +fn shortcut_matches(shortcut: &str, key: &str, mods: Mods) -> bool { + let parts: Vec<&str> = shortcut.split('+').collect(); + // "Cmd+=": the last part is the key even if it's punctuation; "Cmd++" would split oddly but + // no shortcut uses it. + let Some((k, ms)) = parts.split_last() else { return false }; + let has = |m: &str| ms.iter().any(|x| x.eq_ignore_ascii_case(m)); + k.eq_ignore_ascii_case(key) + && has("cmd") == mods.cmd + && has("shift") == mods.shift + && (has("alt") || has("option")) == mods.alt + && has("ctrl") == mods.ctrl +} + +fn mods_from(p: &Value) -> Mods { + let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false); + Mods { shift: b("shift"), alt: b("alt"), cmd: b("cmd"), ctrl: b("ctrl"), space: false } +} + +impl Headless { + /// A session with no document (the first `file.new` / `file.open` creates one). + pub fn new() -> Self { + Self { session: Session::new(), renderer: Renderer::new() } + } + + /// A session with a fresh default (Letter) document, ready to lay out. + pub fn with_document() -> Self { + let mut h = Self::new(); + if let Err(e) = h.session.execute("file.new", &json!({})) { + log::error!("file.new failed: {e}"); + } + h + } + + fn exec(&mut self, id: &str, params: &Value) -> Result { + let r = self.session.execute(id, params).map_err(|e| { + let ui_only = ["app.", "view.", "window."].iter().any(|p| id.starts_with(p)); + if ui_only && designcraft_engine::find_command(id).is_none() { format!("`{id}` is a UI command; {NEEDS_APP}") } else { e.to_string() } + }); + // Requests for a UI (file pickers, dialogs) have nobody to serve them here. + self.session.ui_requests.clear(); + r + } + + fn selection(&self) -> Value { + self.session.active().map(|d| serde_json::to_value(&d.selection).unwrap_or_default()).unwrap_or(Value::Null) + } + + fn pointer(&mut self, p: &Value) -> Result { + let events = p.get("events").and_then(Value::as_array).ok_or("missing `events`")?; + let base: Mods = p.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or_default(); + if self.session.active().is_none() { + return Err("no document open (use new_document first)".into()); + } + for e in events { + let kind = s(e, "kind").unwrap_or(""); + let kind = pointer_kind(kind).ok_or_else(|| format!("unknown pointer kind `{kind}` (down|drag|up|move|doubleclick)"))?; + if s(e, "space") == Some("screen") { + return Err(format!("screen-space pointer events: {NEEDS_APP}. Use spread coordinates instead")); + } + let x = e.get("x").and_then(Value::as_f64).ok_or("pointer event needs numeric `x`")?; + let y = e.get("y").and_then(Value::as_f64).ok_or("pointer event needs numeric `y`")?; + let mods = e.get("mods").and_then(|m| serde_json::from_value(m.clone()).ok()).unwrap_or(base); + self.session.pointer(&PointerEvent { kind, pos: Point::new(x, y), mods }, VIEW).map_err(|e| e.to_string())?; + } + let requests: Vec = self.session.ui_requests.drain(..).map(|r| serde_json::to_value(r).unwrap_or_default()).collect(); + Ok(json!({"selection": self.selection(), "tool": self.session.tool_id(), "requests": requests})) + } + + /// Keyboard input: the active tool (caret movement, Enter/Tab/Backspace while typing), then + /// command shortcuts, then tool shortcuts, then plain text while typing. + fn key(&mut self, p: &Value) -> Result { + let key = s(p, "key").ok_or("missing `key`")?; + let mods = mods_from(p); + let typing = self.session.wants_text(); + let tk = tool_key(key); + if let Some(k) = tk + && (typing || self.session.tool_busy()) + && self.session.tool_key(k, mods, VIEW).map_err(|e| e.to_string())? + { + self.session.ui_requests.clear(); + return Ok(json!({"handledBy": "tool", "tool": self.session.tool_id(), "selection": self.selection()})); + } + // While typing, plain keys are text, not shortcuts. + let plain = !mods.cmd && !mods.ctrl && !mods.alt; + if !(typing && plain) + && let Some(c) = designcraft_engine::command_specs().iter().find(|c| c.shortcut.is_some_and(|sc| shortcut_matches(sc, key, mods))) + { + let r = self.exec(c.id, &json!({}))?; + return Ok(json!({"handledBy": "command", "command": c.id, "result": r})); + } + if !typing && let Some(t) = TOOL_GROUPS.iter().flat_map(|g| g.iter()).find(|t| t.shortcut.is_some_and(|sc| shortcut_matches(sc, key, mods))) { + self.session.set_tool(t.id); + return Ok(json!({"handledBy": "tool.select", "tool": t.id})); + } + if let Some(k) = tk + && self.session.tool_key(k, mods, VIEW).map_err(|e| e.to_string())? + { + self.session.ui_requests.clear(); + return Ok(json!({"handledBy": "tool", "tool": self.session.tool_id(), "selection": self.selection()})); + } + if typing && plain { + let text = s(p, "text").map(str::to_string).or_else(|| { + let mut c = key.chars(); + match (c.next(), c.next()) { + (Some(ch), None) => Some(if mods.shift { ch.to_uppercase().collect() } else { ch.to_lowercase().collect() }), + _ if key.eq_ignore_ascii_case("space") => Some(" ".into()), + _ => None, + } + }); + if let Some(t) = text { + self.exec("text.insert", &json!({"text": t}))?; + return Ok(json!({"handledBy": "text", "text": t})); + } + } + Err(format!("key `{key}` does nothing here (no tool, command or tool shortcut bound to it)")) + } + + fn text(&mut self, p: &Value) -> Result { + let t = s(p, "text").ok_or("missing `text`")?; + if !self.session.wants_text() { + return Err("no text insertion point: click into a text frame with the type tool first (pointer with tool \"type\"), \ + or select text with execute text.select, or use set_story_text" + .into()); + } + self.exec("text.insert", &json!({"text": t}))?; + Ok(json!({"inserted": t.chars().count()})) + } + + fn render(&mut self, p: &Value) -> Result { + let st = self.session.active().ok_or("no document open")?; + let page = p.get("page").and_then(Value::as_u64).unwrap_or(0) as usize; + let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.05, 16.0); + let bleed = p.get("bleed").and_then(Value::as_bool).unwrap_or(false); + let img = self + .renderer + .render_page(&st.doc, &self.session.cache, page, scale, bleed, &RenderOptions { printing_only: true, ..Default::default() }) + .ok_or_else(|| format!("no page {page} (the document has {} pages, indices are 0-based)", st.doc.page_count()))?; + let png = img.to_png(); + match s(p, "path") { + Some(path) => { + std::fs::write(path, &png).map_err(|e| format!("write {path}: {e}"))?; + Ok(json!({"path": path, "width": img.width, "height": img.height})) + } + None => Ok(json!({"width": img.width, "height": img.height, "pngBase64": designcraft_engine::cmd::base64_encode(&png)})), + } + } + + /// `app.export {path, page?, scale?}`: PNG (or JPEG by extension) of one page, like the + /// app's Export Page as PNG. + fn export(&mut self, p: &Value) -> Result { + let path = s(p, "path").ok_or("missing `path`")?; + let st = self.session.active().ok_or("no document open")?; + let page = p.get("page").and_then(Value::as_u64).unwrap_or(0) as usize; + let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(2.0).clamp(0.05, 16.0); + let img = self + .renderer + .render_page(&st.doc, &self.session.cache, page, scale, false, &RenderOptions { printing_only: true, ..Default::default() }) + .ok_or_else(|| format!("no page {page}"))?; + let lower = path.to_ascii_lowercase(); + let bytes = if lower.ends_with(".jpg") || lower.ends_with(".jpeg") { img.to_jpeg(90) } else { img.to_png() }; + std::fs::write(path, &bytes).map_err(|e| format!("write {path}: {e}"))?; + Ok(json!({"path": path, "width": img.width, "height": img.height})) + } +} + +impl Backend for Headless { + fn call(&mut self, method: &str, params: Value) -> Result { + let p = ¶ms; + match method { + "engine.execute" | "ui.menu.invoke" | "command" => { + let id = s(p, "command").or(s(p, "id")).ok_or("missing `command`")?.to_string(); + let params = match p.get("params") { + None | Some(Value::Null) => json!({}), + Some(v) => v.clone(), + }; + self.exec(&id, ¶ms) + } + "engine.commands" => Ok(serde_json::to_value(self.session.commands()).unwrap_or_default()), + "document.inspect" => self.exec("document.inspect", &json!({})), + "ui.tool.select" => self.exec("tool.select", &json!({"tool": s(p, "tool").unwrap_or("")})), + "ui.tool.list" => Ok(serde_json::to_value(TOOL_GROUPS).unwrap_or_default()), + "ui.pointer" => self.pointer(p), + "ui.key" => self.key(p), + "ui.text" => self.text(p), + "ui.render" => self.render(p), + "app.open" => self.exec("file.open", &json!({"path": s(p, "path")})), + "app.save" => self.exec("file.save", &json!({"path": s(p, "path")})), + "app.export" => self.export(p), + "app.quit" => Ok(Value::Null), + m if m.starts_with("ui.") => Err(format!("`{m}` is not available headless; {NEEDS_APP}")), + other => Err(format!("unknown method `{other}`")), + } + } + + fn has_ui(&self) -> bool { + false + } + + fn describe(&self) -> String { + "headless (in-process engine, no window)".into() + } +} diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs new file mode 100644 index 00000000..01cfed73 --- /dev/null +++ b/crates/mcp/src/lib.rs @@ -0,0 +1,36 @@ +//! DesignCraft's MCP server. +//! +//! [Model Context Protocol](https://modelcontextprotocol.io) over stdio: newline-delimited +//! JSON-RPC 2.0, hand-written (no async runtime). The server exposes DesignCraft as a set of MCP +//! tools and resources and forwards everything to a [`Backend`]: +//! +//! - [`Remote`] talks to a running desktop app through its loopback JSON-lines control channel +//! (`designcraft --control 7979`): one `{"id","method","params"}` line in, one +//! `{"id","ok","result"|"error"}` line out (see `docs/control-protocol.md`). +//! - [`Headless`] hosts an in-process [`designcraft_engine::Session`] and implements the +//! engine-level control-channel methods itself (rendering pages with `designcraft-render`), so +//! agents can build layouts and look at them without a window. +//! +//! Entry points: [`Server::serve`] (stdio loop) and [`Server::handle_line`] (one message). +#![forbid(unsafe_code)] + +mod backend; +mod headless; +mod server; +mod tools; + +pub use backend::{Backend, Remote}; +pub use headless::Headless; +pub use server::{PROTOCOL_VERSION, Server}; +pub use tools::{ToolResult, call_tool, tool_definitions}; + +/// Default control-channel port of the desktop app. +pub const DEFAULT_PORT: u16 = 7979; + +/// `"7979"` → `"127.0.0.1:7979"`; `"host:port"` is kept as is. +pub fn control_addr(s: &str) -> String { + if s.parse::().is_ok() { format!("127.0.0.1:{s}") } else { s.to_string() } +} + +#[cfg(test)] +mod tests; diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs new file mode 100644 index 00000000..9505732a --- /dev/null +++ b/crates/mcp/src/server.rs @@ -0,0 +1,166 @@ +//! 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")}, + "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 = params.get("arguments").cloned().unwrap_or(Value::Null); + 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}"))), + } + } +} diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs new file mode 100644 index 00000000..63ab741f --- /dev/null +++ b/crates/mcp/src/tests.rs @@ -0,0 +1,393 @@ +use std::io::{BufRead, BufReader, Write}; + +use designcraft_engine::cmd::{base64_decode, base64_encode}; +use serde_json::{Value, json}; + +use crate::{Backend, Headless, PROTOCOL_VERSION, Remote, Server, control_addr, tool_definitions}; + +fn server() -> Server { + Server::new(Box::new(Headless::with_document())) +} + +fn rpc(s: &mut Server, id: u64, method: &str, params: Value) -> Value { + let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string(); + let reply = s.handle_line(&line).expect("reply"); + let v: Value = serde_json::from_str(&reply).expect("reply is JSON"); + assert_eq!(v["jsonrpc"], "2.0"); + assert_eq!(v["id"], id); + v +} + +fn call(s: &mut Server, name: &str, args: Value) -> Value { + let v = rpc(s, 99, "tools/call", json!({"name": name, "arguments": args})); + assert!(v.get("error").is_none(), "{v}"); + v["result"].clone() +} + +/// Call a tool that must succeed; returns its JSON text payload. +fn ok(s: &mut Server, name: &str, args: Value) -> Value { + let r = call(s, name, args); + assert_eq!(r["isError"], false, "{name}: {r}"); + serde_json::from_str(&text_of(&r)).unwrap_or(Value::Null) +} + +fn text_of(result: &Value) -> String { + result["content"].as_array().unwrap().iter().filter(|c| c["type"] == "text").map(|c| c["text"].as_str().unwrap().to_string()).collect() +} + +fn image_of(result: &Value) -> Vec { + let img = result["content"].as_array().unwrap().iter().find(|c| c["type"] == "image").expect("image block"); + assert_eq!(img["mimeType"], "image/png"); + base64_decode(img["data"].as_str().unwrap()) +} + +fn tmp(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("designcraft-mcp-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join(name) +} + +const PNG_MAGIC: &[u8] = b"\x89PNG\r\n\x1a\n"; + +// ---------- protocol ---------- + +#[test] +fn framing_basics() { + let mut s = server(); + assert_eq!(s.handle_line(" "), None); + assert_eq!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#), None); + assert!(s.is_initialized()); + let v: Value = serde_json::from_str(&s.handle_line("{not json").unwrap()).unwrap(); + assert_eq!(v["error"]["code"], -32700); + assert_eq!(v["id"], Value::Null); + let v: Value = serde_json::from_str(&s.handle_line(r#"{"jsonrpc":"2.0","id":"abc","method":"nope"}"#).unwrap()).unwrap(); + assert_eq!(v["error"]["code"], -32601); + assert_eq!(v["id"], "abc"); + let v: Value = serde_json::from_str(&s.handle_line(r#"{"jsonrpc":"2.0","id":3}"#).unwrap()).unwrap(); + assert_eq!(v["error"]["code"], -32600); + assert_eq!(s.handle_line(r#"{"jsonrpc":"2.0","id":9,"result":{}}"#), None); + assert_eq!(rpc(&mut s, 4, "ping", json!({}))["result"], json!({})); + let v: Value = serde_json::from_str( + &s.handle_line(r#"[{"jsonrpc":"2.0","id":1,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/initialized"}]"#).unwrap(), + ) + .unwrap(); + assert_eq!(v.as_array().unwrap().len(), 1); + // tools/call without a name is a protocol error, not a tool error. + assert_eq!(rpc(&mut s, 5, "tools/call", json!({}))["error"]["code"], -32602); +} + +#[test] +fn protocol_round_trip_over_serve() { + let mut s = server(); + let input = [ + json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":PROTOCOL_VERSION,"capabilities":{},"clientInfo":{"name":"t","version":"0"}}}), + json!({"jsonrpc":"2.0","method":"notifications/initialized"}), + json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}), + json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_commands","arguments":{"filter":"frame.create"}}}), + json!({"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"execute","arguments":{"command":"frame.create","params":{"rect":[36,36,300,200],"content":"text","text":"Hi"}}}}), + json!({"jsonrpc":"2.0","id":5,"method":"resources/read","params":{"uri":"designcraft://document"}}), + ] + .map(|v| v.to_string()) + .join("\n"); + let mut out = Vec::new(); + s.serve(input.as_bytes(), &mut out).unwrap(); + let lines: Vec = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(lines.len(), 5, "one reply per request, none for the notification"); + assert_eq!(lines[0]["result"]["protocolVersion"], PROTOCOL_VERSION); + assert_eq!(lines[0]["result"]["serverInfo"]["name"], "designcraft"); + assert!(lines[0]["result"]["capabilities"]["tools"].is_object()); + assert!(lines[0]["result"]["instructions"].as_str().unwrap().contains("headless")); + let tools = lines[1]["result"]["tools"].as_array().unwrap(); + assert_eq!(tools.len(), tool_definitions().len()); + let listed: Value = serde_json::from_str(&text_of(&lines[2]["result"])).unwrap(); + assert_eq!(listed["count"], 1); + assert_eq!(listed["commands"][0]["id"], "frame.create"); + let created: Value = serde_json::from_str(&text_of(&lines[3]["result"])).unwrap(); + assert!(created["id"].is_u64() && created["story"].is_u64(), "{created}"); + let doc: Value = serde_json::from_str(lines[4]["result"]["contents"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(doc["stories"][0]["preview"], "Hi"); +} + +#[test] +fn initialize_negotiates_version() { + let mut s = server(); + let v = rpc(&mut s, 1, "initialize", json!({"protocolVersion": "2024-11-05"})); + assert_eq!(v["result"]["protocolVersion"], "2024-11-05"); + let v = rpc(&mut s, 2, "initialize", json!({"protocolVersion": "1999-01-01"})); + assert_eq!(v["result"]["protocolVersion"], PROTOCOL_VERSION); +} + +#[test] +fn tool_definitions_are_well_formed() { + let defs = tool_definitions(); + let mut names = std::collections::HashSet::new(); + for t in &defs { + let name = t["name"].as_str().unwrap(); + assert!(names.insert(name), "duplicate tool {name}"); + assert!(name.chars().all(|c| c.is_ascii_lowercase() || c == '_'), "{name}"); + assert!(t["description"].as_str().unwrap().len() > 20, "{name} needs a description"); + assert_eq!(t["inputSchema"]["type"], "object", "{name}"); + for r in t["inputSchema"]["required"].as_array().into_iter().flatten() { + assert!(t["inputSchema"]["properties"].get(r.as_str().unwrap()).is_some(), "{name}: required `{r}` not in properties"); + } + } + for want in [ + "list_commands", + "execute", + "batch", + "inspect_document", + "get_story", + "set_story_text", + "new_document", + "open_document", + "save_document", + "render_page", + "export_png", + "screenshot", + "pointer", + "key", + "type_text", + "click", + "drag", + "select_tool", + "menu_list", + "ui_inspect", + "dialog_set", + "dialog_confirm", + "dialog_cancel", + "place_image", + ] { + assert!(names.contains(want), "missing tool {want}"); + } + // Every tool is dispatched (no "unknown tool" for listed names). + let mut s = server(); + for name in names { + let r = call(&mut s, name, json!({})); + assert!(!text_of(&r).contains("unknown tool"), "{name} not dispatched"); + } +} + +// ---------- layout through the tools ---------- + +#[test] +fn builds_a_small_layout_and_renders_it() { + let mut s = server(); + let d = ok(&mut s, "new_document", json!({"width": 400, "height": 300, "margins": 24, "title": "Flyer"})); + assert_eq!(d["index"], 1); + let f = ok(&mut s, "execute", json!({"command": "frame.create", "params": {"rect": [24, 24, 376, 150], "content": "text"}})); + let (frame, story) = (f["id"].as_u64().unwrap(), f["story"].as_u64().unwrap()); + let st = ok(&mut s, "set_story_text", json!({"frame": frame, "text": "Summer Fair\nSaturday in the park"})); + assert_eq!(st["id"], story); + assert_eq!(st["paragraphs"], 2); + ok(&mut s, "execute", json!({"command": "selection.set", "params": {"ids": [frame]}})); + ok(&mut s, "execute", json!({"command": "type.char", "params": {"size": 28}})); + let got = ok(&mut s, "get_story", json!({"story": story})); + assert_eq!(got["text"], "Summer Fair\nSaturday in the park"); + assert!(got["lines"].as_u64().unwrap() >= 2); + + let r = call(&mut s, "render_page", json!({"page": 0, "scale": 1})); + assert_eq!(r["isError"], false, "{r}"); + let png = image_of(&r); + assert!(png.starts_with(PNG_MAGIC)); + let info: Value = serde_json::from_str(&text_of(&r)).unwrap(); + assert_eq!((info["width"].as_u64(), info["height"].as_u64()), (Some(400), Some(300))); + // The page isn't blank: dark text pixels on white paper. + let img = image::load_from_memory(&png).unwrap().to_rgba8(); + assert_eq!(img.dimensions(), (400, 300)); + assert!(img.pixels().any(|p| p.0[0] < 128 && p.0[3] > 200), "no text was drawn"); + + let doc = ok(&mut s, "inspect_document", json!({})); + assert_eq!(doc["title"], "Flyer"); + assert_eq!(doc["spreads"][0]["items"][0]["id"], frame); +} + +#[test] +fn type_tool_gesture_then_typing() { + let mut s = server(); + let r = ok( + &mut s, + "pointer", + json!({"tool": "type", "events": [{"kind": "down", "x": 72, "y": 72}, {"kind": "drag", "x": 300, "y": 200}, {"kind": "up", "x": 300, "y": 200}]}), + ); + assert_eq!(r["tool"], "type"); + ok(&mut s, "type_text", json!({"text": "Hello"})); + ok(&mut s, "key", json!({"key": "Enter"})); + ok(&mut s, "type_text", json!({"text": "World"})); + let doc = ok(&mut s, "inspect_document", json!({})); + let story = doc["stories"][0]["id"].as_u64().unwrap(); + assert_eq!(ok(&mut s, "get_story", json!({"story": story}))["text"], "Hello\nWorld"); + // Escape leaves the text and selects the frame; Cmd+Z undoes through the shortcut. + let k = ok(&mut s, "key", json!({"key": "Escape"})); + assert_eq!(k["handledBy"], "tool"); + let k = ok(&mut s, "key", json!({"key": "Z", "cmd": true})); + assert_eq!(k["command"], "edit.undo"); + // Tool shortcut when not typing. + assert_eq!(ok(&mut s, "key", json!({"key": "V"}))["tool"], "selection"); +} + +#[test] +fn batch_stops_on_first_error() { + let mut s = server(); + let r = call( + &mut s, + "batch", + json!({"commands": [ + {"command": "frame.create", "params": {"rect": [0, 0, 100, 100]}}, + {"command": "no.such.command"}, + {"command": "frame.create", "params": {"rect": [0, 0, 50, 50]}}, + ]}), + ); + assert_eq!(r["isError"], true); + let v: Value = serde_json::from_str(&text_of(&r)).unwrap(); + assert_eq!(v["failedIndex"], 1); + assert_eq!(v["completed"], 1); + assert!(v["error"].as_str().unwrap().contains("no.such.command")); + let doc = ok(&mut s, "inspect_document", json!({})); + assert_eq!(doc["spreads"][0]["items"].as_array().unwrap().len(), 1, "the third command must not run"); + + let v = ok(&mut s, "batch", json!({"commands": [{"command": "edit.undo"}, {"command": "document.history"}]})); + assert_eq!(v["completed"], 2); +} + +#[test] +fn ui_only_tools_explain_how_to_connect() { + let mut s = server(); + for (name, args) in [ + ("screenshot", json!({})), + ("click", json!({"x": 1, "y": 1})), + ("drag", json!({"x": 1, "y": 1, "toX": 5, "toY": 5})), + ("menu_list", json!({})), + ("ui_inspect", json!({})), + ("ui_set", json!({"zoom": 2})), + ("dialog_set", json!({"field": "width", "value": 10})), + ("dialog_confirm", json!({})), + ("dialog_cancel", json!({})), + ] { + let r = call(&mut s, name, args); + assert_eq!(r["isError"], true, "{name}"); + assert!(text_of(&r).contains("--connect"), "{name}: {}", text_of(&r)); + } + let r = call(&mut s, "execute", json!({"command": "view.zoomIn"})); + assert_eq!(r["isError"], true); + assert!(text_of(&r).contains("UI command")); + // Typing without a caret explains what to do. + let r = call(&mut s, "type_text", json!({"text": "x"})); + assert_eq!(r["isError"], true); + assert!(text_of(&r).contains("insertion point")); +} + +#[test] +fn files_place_and_export() { + let mut s = server(); + // A 4×2 red PNG made by the renderer's encoder. + let px = designcraft_render::Rendered { width: 4, height: 2, pixels: [255, 0, 0, 255].repeat(8) }; + let png = px.to_png(); + let placed = ok(&mut s, "place_image", json!({"base64": base64_encode(&png), "name": "red.png", "x": 50, "y": 60, "width": 100})); + assert!(placed["id"].is_u64() && placed["asset"].is_u64(), "{placed}"); + let img_path = tmp("red.png"); + std::fs::write(&img_path, &png).unwrap(); + // With a graphic frame selected, Place replaces its content; deselect for a new frame. + ok(&mut s, "execute", json!({"command": "edit.deselectAll"})); + assert!(ok(&mut s, "place_image", json!({"path": img_path}))["id"].is_u64()); + assert_eq!(call(&mut s, "place_image", json!({}))["isError"], true); + + let out = tmp("page.png"); + let e = ok(&mut s, "export_png", json!({"path": out, "scale": 0.5})); + assert_eq!(e["width"], 306); + assert!(std::fs::read(&out).unwrap().starts_with(PNG_MAGIC)); + let r = call(&mut s, "render_page", json!({"path": tmp("render.png")})); + assert_eq!(r["isError"], false); + assert!(std::fs::read(tmp("render.png")).unwrap().starts_with(PNG_MAGIC)); + + let doc_path = tmp("saved.designcraft"); + let saved = ok(&mut s, "save_document", json!({"path": doc_path})); + assert_eq!(saved["path"], doc_path.to_string_lossy().as_ref()); + let opened = ok(&mut s, "open_document", json!({"path": doc_path})); + assert!(opened["index"].is_u64()); + let doc = ok(&mut s, "inspect_document", json!({})); + assert_eq!(doc["spreads"][0]["items"].as_array().unwrap().len(), 2); + assert_eq!(doc["dirty"], false); + + let sample = ok(&mut s, "new_document", json!({"sample": true})); + assert!(sample["index"].is_u64()); + assert!(ok(&mut s, "inspect_document", json!({}))["pageCount"].as_u64().unwrap() > 1); + let r = call(&mut s, "render_page", json!({"page": 999})); + assert_eq!(r["isError"], true); +} + +#[test] +fn list_commands_filters() { + let mut s = server(); + let all = ok(&mut s, "list_commands", json!({})); + let n = all["count"].as_u64().unwrap(); + assert!(n > 50, "{n}"); + let some = ok(&mut s, "list_commands", json!({"filter": "Arrange"})); + assert!(some["count"].as_u64().unwrap() >= 4); + let enabled = ok(&mut s, "list_commands", json!({"enabledOnly": true})); + assert!(enabled["count"].as_u64().unwrap() < n, "nothing is selected, so some commands are disabled"); + let r = call(&mut s, "nope", json!({})); + assert_eq!(r["isError"], true); + assert_eq!(call(&mut s, "execute", json!([1]))["isError"], true); +} + +// ---------- connect mode ---------- + +/// A fake app on a loopback port answering a few control methods, recording what it got. +fn fake_app() -> (String, std::sync::mpsc::Receiver) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let Ok((stream, _)) = listener.accept() else { return }; + let mut out = stream.try_clone().unwrap(); + for line in BufReader::new(stream).lines() { + let Ok(line) = line else { break }; + let req: Value = serde_json::from_str(&line).unwrap(); + let p = &req["params"]; + let reply = match req["method"].as_str().unwrap() { + "ui.screenshot" => { + let px = designcraft_render::Rendered { width: 3, height: 3, pixels: vec![0; 36] }; + std::fs::write(p["path"].as_str().unwrap(), px.to_png()).unwrap(); + json!({"ok": true, "result": {"path": p["path"], "width": 3, "height": 3}}) + } + "ui.click" => json!({"ok": true, "result": null}), + "ui.inspect" => json!({"ok": true, "result": {"tool": "selection"}}), + _ => json!({"ok": false, "error": "nope"}), + }; + tx.send(req.clone()).unwrap(); + let mut reply = reply; + reply["id"] = req["id"].clone(); + writeln!(out, "{reply}").unwrap(); + } + }); + (addr, rx) +} + +#[test] +fn connect_mode_forwards_to_the_app() { + let (addr, seen) = fake_app(); + let remote = Remote::connect(&addr).unwrap(); + assert!(remote.has_ui()); + let mut s = Server::new(Box::new(remote)); + let r = call(&mut s, "screenshot", json!({})); + assert_eq!(r["isError"], false, "{r}"); + assert!(image_of(&r).starts_with(PNG_MAGIC)); + let got = seen.recv().unwrap(); + assert_eq!(got["method"], "ui.screenshot"); + assert!(!std::path::Path::new(got["params"]["path"].as_str().unwrap()).exists(), "temp screenshot is removed"); + + ok(&mut s, "click", json!({"x": 10, "y": 20, "count": 2})); + let got = seen.recv().unwrap(); + assert_eq!((got["method"].as_str(), got["params"]["x"].as_i64(), got["params"]["count"].as_i64()), (Some("ui.click"), Some(10), Some(2))); + assert_eq!(ok(&mut s, "ui_inspect", json!({}))["tool"], "selection"); + let r = call(&mut s, "menu_list", json!({})); + assert_eq!(r["isError"], true); + assert!(text_of(&r).contains("nope"), "app errors are passed through"); +} + +#[test] +fn control_addr_accepts_port_or_address() { + assert_eq!(control_addr("7979"), "127.0.0.1:7979"); + assert_eq!(control_addr("localhost:8000"), "localhost:8000"); + assert!(Remote::connect("127.0.0.1:1").is_err()); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs new file mode 100644 index 00000000..8f407508 --- /dev/null +++ b/crates/mcp/src/tools.rs @@ -0,0 +1,607 @@ +//! MCP tool definitions (names, descriptions, JSON Schemas) and their implementations on top of a +//! [`Backend`]. + +use serde_json::{Map, Value, json}; + +use crate::backend::Backend; +use crate::headless::NEEDS_APP; + +/// The result of `tools/call`: MCP content blocks plus the `isError` flag. +#[derive(Clone, Debug, PartialEq)] +pub struct ToolResult { + pub content: Vec, + pub is_error: bool, +} + +impl ToolResult { + pub fn text(t: impl Into) -> Self { + Self { content: vec![json!({"type": "text", "text": t.into()})], is_error: false } + } + pub fn json(v: &Value) -> Self { + Self::text(serde_json::to_string_pretty(v).unwrap_or_default()) + } + pub fn error(t: impl Into) -> Self { + Self { content: vec![json!({"type": "text", "text": t.into()})], is_error: true } + } + /// A PNG image block followed by a JSON text block with details. + pub fn image(png_base64: String, info: &Value) -> Self { + Self { + content: vec![json!({"type": "image", "data": png_base64, "mimeType": "image/png"}), json!({"type": "text", "text": info.to_string()})], + is_error: false, + } + } + pub fn to_value(&self) -> Value { + json!({"content": self.content, "isError": self.is_error}) + } +} + +// ---------- schemas ---------- + +fn num(desc: &str) -> Value { + json!({"type": "number", "description": desc}) +} +fn int(desc: &str) -> Value { + json!({"type": "integer", "minimum": 0, "description": desc}) +} +fn string(desc: &str) -> Value { + json!({"type": "string", "description": desc}) +} +fn boolean(desc: &str) -> Value { + json!({"type": "boolean", "description": desc}) +} +fn params_schema() -> Value { + json!({"type": "object", "description": "Command parameters (see the `params` field of list_commands)"}) +} +fn mods_schema() -> Value { + json!({ + "type": "object", + "description": "Modifier keys held (cmd = Command on macOS / Ctrl elsewhere; alt = Option)", + "properties": {"shift": {"type": "boolean"}, "alt": {"type": "boolean"}, "cmd": {"type": "boolean"}, "ctrl": {"type": "boolean"}}, + "additionalProperties": false + }) +} +fn obj(props: Value, required: &[&str]) -> Value { + let mut o = json!({"type": "object", "properties": props}); + if !required.is_empty() { + o["required"] = json!(required); + } + o +} +fn tool(name: &str, title: &str, desc: &str, schema: Value, read_only: bool) -> Value { + json!({ + "name": name, + "title": title, + "description": desc, + "inputSchema": schema, + "annotations": {"title": title, "readOnlyHint": read_only, "openWorldHint": false}, + }) +} + +const APP_ONLY: &str = " Desktop app only (`designcraft-cli mcp --connect PORT`)."; + +/// All tools, in `tools/list` order. +pub fn tool_definitions() -> Vec { + let empty = || obj(json!({}), &[]); + let app_only = |d: &str| format!("{d}{APP_ONLY}"); + vec![ + // ----- commands ----- + tool( + "list_commands", + "List commands", + "List DesignCraft commands: id, label, menu path, shortcut, parameter description and whether it can run right now. \ + Every user-visible action is a command (frame.create, text.insert, type.char, style.paragraph.apply, layout.pages.insert, \ + object.fill, transform.move, edit.undo…); run them with execute or batch.", + obj( + json!({ + "filter": string("Case-insensitive substring matched against id, label and menu path (e.g. \"frame\", \"style\", \"Layout\")"), + "enabledOnly": boolean("Only commands that can run right now"), + }), + &[], + ), + true, + ), + tool( + "execute", + "Execute command", + "Run any DesignCraft command by id with JSON params (ids and params from list_commands). Examples: \ + {\"command\":\"frame.create\",\"params\":{\"rect\":[36,36,576,300],\"content\":\"text\",\"text\":\"Hello\"}} → {id, story}; \ + {\"command\":\"type.char\",\"params\":{\"size\":24}}; {\"command\":\"edit.undo\"}. Coordinates are points in spread space. \ + In the desktop app UI-only commands (view.*, window.*, app.*) work too.", + obj(json!({"command": string("Command id, e.g. frame.create"), "params": params_schema()}), &["command"]), + false, + ), + tool( + "batch", + "Execute commands", + "Run several commands in order, stopping at the first error. Returns each result; on failure also the failing index \ + and message (earlier commands stay applied; use edit.undo to revert them).", + obj( + json!({"commands": { + "type": "array", + "minItems": 1, + "description": "Commands to run in order", + "items": obj(json!({"command": string("Command id"), "params": params_schema()}), &["command"]), + }}), + &["commands"], + ), + false, + ), + // ----- document ----- + tool( + "inspect_document", + "Inspect document", + "Summary of the active document: settings, pages (index, name, bounds, margins, columns), spreads with items (id, kind, \ + bounds, fill, stroke, story), stories (id, frames, length, overset, preview), layers, paragraph/character styles, \ + swatches, selection, active tool.", + empty(), + true, + ), + tool( + "get_story", + "Get story", + "Full text of a story plus its frames, paragraph count, line count and overset position. Identify it by `story` id or \ + by a text `frame` id (default: the selection).", + obj(json!({"story": int("Story id"), "frame": int("Text frame id")}), &[]), + true, + ), + tool( + "set_story_text", + "Set story text", + "Replace the whole text of a story (keeps the first paragraph's and character's formatting). Use \\n for paragraph breaks. \ + Identify the story by `story` id or by a text `frame` id.", + obj(json!({"story": int("Story id"), "frame": int("Text frame id"), "text": string("New text")}), &["text"]), + false, + ), + tool( + "new_document", + "New document", + "Create a new document and make it active. Sizes are in points (72 pt = 1 in). sample:true opens the multi-page \ + magazine sample instead.", + obj( + json!({ + "preset": string("Page size preset, e.g. \"Letter\", \"A4\", \"Tabloid\""), + "width": num("Page width (pt)"), + "height": num("Page height (pt)"), + "pages": {"type": "integer", "minimum": 1, "description": "Number of pages"}, + "facingPages": boolean("Facing pages (spreads)"), + "columns": {"type": "integer", "minimum": 1, "description": "Column count"}, + "gutter": num("Column gutter (pt)"), + "margins": {"description": "Uniform margin (pt) or {top, bottom, inside, outside}", "anyOf": [{"type": "number"}, {"type": "object"}]}, + "bleed": num("Bleed on all sides (pt)"), + "title": string("Document title"), + "sample": boolean("Open the sample magazine instead"), + }), + &[], + ), + false, + ), + tool( + "open_document", + "Open document", + "Open a .designcraft file as a new, active document.", + obj(json!({"path": string("File path")}), &["path"]), + false, + ), + tool( + "save_document", + "Save document", + "Save the active document in the native .designcraft format.", + obj(json!({"path": string("Destination (default: the document's current path)")}), &[]), + false, + ), + tool( + "place_image", + "Place image", + "Place an image (PNG, JPEG, WebP, GIF, TIFF) from a file `path` or `base64` data. It goes into `frame` (or the selected \ + empty/graphic frame), otherwise into a new frame at x, y (default: the page's top-left margin) with an optional width.", + obj( + json!({ + "path": string("Image file path"), + "base64": string("Image bytes, base64 (alternative to path)"), + "name": string("Name for base64 images"), + "frame": int("Frame id to place into"), + "spread": int("Spread index for a new frame (default 0)"), + "x": num("Left of the new frame (spread pt)"), + "y": num("Top of the new frame (spread pt)"), + "width": num("Width of the new frame (pt); height follows the aspect ratio"), + }), + &[], + ), + false, + ), + // ----- looking ----- + tool( + "render_page", + "Render page", + "Render one page of the active document (printing items only, like Preview) and return it as a PNG image. Optionally \ + also write it to `path`.", + obj( + json!({ + "page": int("Page index, 0-based (default 0; in the desktop app default = the current page)"), + "scale": num("Pixels per point (default 1 = 72 ppi)"), + "bleed": boolean("Include the bleed area"), + "path": string("Also write the PNG here"), + }), + &[], + ), + true, + ), + tool( + "export_png", + "Export PNG", + "Export one page as a PNG (or JPEG if the path ends in .jpg) file. Returns the path and pixel size.", + obj( + json!({"path": string("Destination file"), "page": int("Page index, 0-based (default 0)"), "scale": num("Pixels per point (default 2)")}), + &["path"], + ), + false, + ), + tool( + "screenshot", + "Screenshot", + &app_only("Capture the whole app window (menus, panels, canvas) and return it as a PNG image. Optionally also keep it at `path`."), + obj(json!({"path": string("Also write the PNG here")}), &[]), + true, + ), + // ----- input ----- + tool( + "select_tool", + "Select tool", + "Switch the active tool (as clicking it in the Tools panel). Ids: selection, directSelection, type, rectangleFrame, \ + ellipseFrame, polygonFrame, rectangle, ellipse, polygon, line, hand, zoom, …", + obj(json!({"tool": string("Tool id")}), &["tool"]), + false, + ), + tool( + "pointer", + "Pointer gesture", + "Drive the active tool with mouse events, exactly like the mouse would. Coordinates are spread points (space \"doc\", \ + default) or, in the desktop app, screen points (space \"screen\"). Example: a text frame with the type tool: \ + {tool:\"type\", events:[{kind:\"down\",x:72,y:72},{kind:\"drag\",x:300,y:200},{kind:\"up\",x:300,y:200}]} leaves a caret \ + in the new frame, ready for type_text. Returns the selection and the active tool.", + obj( + json!({ + "tool": string("Optional tool to select before the gesture"), + "events": { + "type": "array", + "minItems": 1, + "items": obj(json!({ + "kind": {"type": "string", "enum": ["down", "drag", "up", "move", "doubleclick"]}, + "x": num("x (pt)"), + "y": num("y (pt)"), + "space": {"type": "string", "enum": ["doc", "screen"], "description": "Coordinate space (default doc)"}, + "mods": mods_schema(), + }), &["kind", "x", "y"]), + }, + "mods": mods_schema(), + }), + &["events"], + ), + false, + ), + tool( + "key", + "Press key", + "Press a key with modifiers, e.g. {key:\"Z\", cmd:true} (undo), {key:\"Escape\"}, {key:\"Enter\"}, {key:\"Left\", shift:true}. \ + In the desktop app it goes through the real keyboard path; headless it goes to the active tool (caret keys while typing), \ + then command shortcuts, then tool shortcuts.", + obj( + json!({ + "key": string("Key name: A–Z, 0–9, Enter, Escape, Delete, Backspace, Tab, Space, Left/Right/Up/Down, Home, End, [ ] …"), + "shift": {"type": "boolean"}, "alt": {"type": "boolean"}, "cmd": {"type": "boolean"}, "ctrl": {"type": "boolean"}, + }), + &["key"], + ), + false, + ), + tool( + "type_text", + "Type text", + "Type text at the text insertion point (as the keyboard would with the type tool), or into the focused dialog field in \ + the desktop app. Put a caret first with pointer (tool \"type\") or execute text.placeCaret / text.select.", + obj(json!({"text": string("Text to type (\\n = new paragraph)")}), &["text"]), + false, + ), + tool( + "click", + "Click", + &app_only( + "Click at a screen point (egui points, top-left of the window = 0,0) through the real pointer path: reaches menus, \ + panels, flyouts and dialogs. Find coordinates with ui_inspect and screenshot.", + ), + obj( + json!({ + "x": num("Screen x"), "y": num("Screen y"), + "button": {"type": "string", "enum": ["left", "right"], "description": "Default left"}, + "count": {"type": "integer", "minimum": 1, "description": "2 = double-click"}, + "shift": {"type": "boolean"}, "alt": {"type": "boolean"}, "cmd": {"type": "boolean"}, "ctrl": {"type": "boolean"}, + }), + &["x", "y"], + ), + false, + ), + tool( + "drag", + "Drag", + &app_only("Press at (x, y), move to (toX, toY) in `steps` moves and release, in screen points through the real pointer path."), + obj( + json!({ + "x": num("Start x"), "y": num("Start y"), "toX": num("End x"), "toY": num("End y"), + "steps": {"type": "integer", "minimum": 1, "description": "Intermediate moves (default 8)"}, + "shift": {"type": "boolean"}, "alt": {"type": "boolean"}, "cmd": {"type": "boolean"}, + }), + &["x", "y", "toX", "toY"], + ), + false, + ), + // ----- UI ----- + tool( + "menu_list", + "List menus", + &app_only("The menu bar tree (menus, entries as cmd:/ui: command ids, separators, submenus)."), + empty(), + true, + ), + tool( + "ui_inspect", + "Inspect UI", + &app_only("UI state: active tool, panels, view (zoom, origin), canvas rect in screen points, window size, documents, open dialog, perf."), + empty(), + true, + ), + tool( + "ui_set", + "Set UI state", + &app_only( + "Change UI state: panel to open, brightness, rulers/guides/frameEdges/baselineGrid/textThreads, screenMode, zoom, page (1-based), fit (page|spread).", + ), + obj( + json!({ + "panel": string("Panel: properties, pages, layers, swatches, paragraphStyles, characterStyles, stroke, character, paragraph, textWrap, links"), + "brightness": {"type": "string", "enum": ["dark", "mediumDark", "mediumLight", "light"]}, + "rulers": {"type": "boolean"}, "guides": {"type": "boolean"}, "frameEdges": {"type": "boolean"}, + "baselineGrid": {"type": "boolean"}, "textThreads": {"type": "boolean"}, + "screenMode": {"type": "string", "enum": ["normal", "preview", "bleed", "slug", "presentation"]}, + "zoom": num("1.0 = 100%"), + "page": {"type": "integer", "minimum": 1, "description": "Go to page (1-based)"}, + "fit": {"type": "string", "enum": ["page", "spread"]}, + }), + &[], + ), + false, + ), + tool( + "dialog_set", + "Set dialog field", + &app_only("Set a field of the open dialog (see ui_inspect → dialog for its fields)."), + obj(json!({"field": string("Field name"), "value": {"description": "New value"}}), &["field", "value"]), + false, + ), + tool("dialog_confirm", "Confirm dialog", &app_only("Press OK in the open dialog."), empty(), false), + tool("dialog_cancel", "Cancel dialog", &app_only("Press Cancel in the open dialog."), empty(), false), + ] +} + +// ---------- implementation ---------- + +type Args = Map; + +fn req_str<'a>(a: &'a Args, k: &str) -> Result<&'a str, String> { + a.get(k).and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or_else(|| format!("missing string argument `{k}`")) +} + +fn exec(b: &mut dyn Backend, cmd: &str, params: Value) -> Result { + b.call("engine.execute", json!({"command": cmd, "params": params})) +} + +fn need_ui(b: &dyn Backend, tool: &str) -> Result<(), String> { + if b.has_ui() { Ok(()) } else { Err(format!("`{tool}` is not available headless; {NEEDS_APP}")) } +} + +/// The given keys of `a` that are present, as a JSON object. +fn pick(a: &Args, keys: &[&str]) -> Value { + Value::Object(keys.iter().filter_map(|k| a.get(*k).filter(|v| !v.is_null()).map(|v| (k.to_string(), v.clone()))).collect()) +} + +fn command_params(v: Option<&Value>) -> Result { + match v { + None | Some(Value::Null) => Ok(json!({})), + Some(v @ Value::Object(_)) => Ok(v.clone()), + Some(_) => Err("`params` must be an object".into()), + } +} + +fn filter_commands(all: Value, a: &Args) -> Value { + let needle = a.get("filter").and_then(Value::as_str).map(str::to_lowercase).filter(|s| !s.is_empty()); + let enabled_only = a.get("enabledOnly").and_then(Value::as_bool).unwrap_or(false); + let Value::Array(list) = all else { return all }; + let hit = |c: &Value| { + if enabled_only && c.get("enabled").and_then(Value::as_bool) == Some(false) { + return false; + } + let Some(n) = &needle else { return true }; + let field = |k: &str| c.get(k).and_then(Value::as_str).unwrap_or("").to_lowercase(); + let menu = c + .get("menu") + .and_then(Value::as_array) + .map(|m| m.iter().filter_map(Value::as_str).collect::>().join(" ")) + .unwrap_or_default() + .to_lowercase(); + field("id").contains(n) || field("label").contains(n) || menu.contains(n) + }; + Value::Array(list.into_iter().filter(hit).collect()) +} + +fn batch(b: &mut dyn Backend, a: &Args) -> Result { + let cmds = a.get("commands").and_then(Value::as_array).filter(|c| !c.is_empty()).ok_or("`commands` must be a non-empty array")?; + let mut results = vec![]; + for (i, c) in cmds.iter().enumerate() { + let step = (|| { + let id = c.get("command").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or("missing `command`")?; + exec(b, id, command_params(c.get("params"))?) + })(); + match step { + Ok(r) => results.push(r), + Err(e) => { + let v = json!({"completed": i, "results": results, "failedIndex": i, "failedCommand": c.get("command"), "error": e}); + return Ok(ToolResult { is_error: true, ..ToolResult::json(&v) }); + } + } + } + Ok(ToolResult::json(&json!({"completed": results.len(), "results": results}))) +} + +/// The story id from `story` or the text frame `frame` (else the selection). +fn story_id(b: &mut dyn Backend, a: &Args) -> Result { + if let Some(s) = a.get("story").filter(|v| v.is_u64()) { + return Ok(s.clone()); + } + let r = exec(b, "story.get", pick(a, &["frame"]))?; + r.get("id").cloned().ok_or_else(|| "no story found (give `story` or a text `frame`)".into()) +} + +fn render_page(b: &mut dyn Backend, a: &Args) -> Result { + let r = b.call("ui.render", pick(a, &["page", "scale", "bleed"]))?; + let b64 = r.get("pngBase64").and_then(Value::as_str).ok_or("renderer returned no image")?.to_string(); + let path = a.get("path").and_then(Value::as_str); + if let Some(path) = path { + let png = designcraft_engine::cmd::base64_decode(&b64); + std::fs::write(path, png).map_err(|e| format!("write {path}: {e}"))?; + } + Ok(ToolResult::image(b64, &json!({"width": r.get("width"), "height": r.get("height"), "path": path}))) +} + +fn screenshot(b: &mut dyn Backend, a: &Args) -> Result { + need_ui(b, "screenshot")?; + // The app writes the capture to disk (loopback: same machine), then we read it back. + let keep = a.get("path").and_then(Value::as_str); + let target = match keep { + Some(p) => p.to_string(), + None => std::env::temp_dir() + .join(format!("designcraft-mcp-{}-{}.png", std::process::id(), std::time::SystemTime::UNIX_EPOCH.elapsed().map_or(0, |d| d.as_millis()))) + .to_string_lossy() + .to_string(), + }; + let r = b.call("ui.screenshot", json!({"path": target}))?; + let png = std::fs::read(&target).map_err(|e| format!("read {target}: {e}"))?; + if keep.is_none() { + std::fs::remove_file(&target).ok(); + } + Ok(ToolResult::image(designcraft_engine::cmd::base64_encode(&png), &json!({"width": r.get("width"), "height": r.get("height"), "path": keep}))) +} + +fn pointer(b: &mut dyn Backend, a: &Args) -> Result { + let events = a.get("events").and_then(Value::as_array).filter(|e| !e.is_empty()).ok_or("`events` must be a non-empty array")?; + for (i, e) in events.iter().enumerate() { + let kind = e.get("kind").and_then(Value::as_str).unwrap_or(""); + if crate::headless::pointer_kind(kind).is_none() { + return Err(format!("event {i}: unknown kind `{kind}` (down, drag, up, move, doubleclick)")); + } + if !e.get("x").is_some_and(Value::is_number) || !e.get("y").is_some_and(Value::is_number) { + return Err(format!("event {i}: numeric `x` and `y` required")); + } + } + if let Some(t) = a.get("tool").and_then(Value::as_str) { + b.call("ui.tool.select", json!({"tool": t}))?; + } + b.call("ui.pointer", pick(a, &["events", "mods"])) +} + +fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result { + let j = |v: Value| Ok(ToolResult::json(&v)); + match name { + "list_commands" => { + let all = b.call("engine.commands", json!({}))?; + let list = filter_commands(all, a); + j(json!({"count": list.as_array().map_or(0, Vec::len), "commands": list})) + } + "execute" => { + let cmd = req_str(a, "command")?; + let params = command_params(a.get("params"))?; + j(exec(b, cmd, params)?) + } + "batch" => batch(b, a), + "inspect_document" => j(b.call("document.inspect", json!({}))?), + "get_story" => j(exec(b, "story.get", pick(a, &["story", "frame"]))?), + "set_story_text" => { + let text = a.get("text").and_then(Value::as_str).ok_or("missing string argument `text`")?.to_string(); + let sid = story_id(b, a)?; + exec(b, "story.setText", json!({"story": sid, "text": text}))?; + j(exec(b, "story.get", json!({"story": sid}))?) + } + "new_document" => { + if a.get("sample").and_then(Value::as_bool) == Some(true) { + return j(exec(b, "file.newSample", json!({}))?); + } + let mut p = a.clone(); + p.remove("sample"); + j(exec(b, "file.new", Value::Object(p))?) + } + "open_document" => j(b.call("app.open", json!({"path": req_str(a, "path")?}))?), + "save_document" => j(exec(b, "file.save", pick(a, &["path"]))?), + "place_image" => { + if !a.contains_key("path") && !a.contains_key("base64") { + return Err("give `path` or `base64`".into()); + } + j(exec(b, "file.place", pick(a, &["path", "base64", "name", "frame", "spread", "x", "y", "width"]))?) + } + "render_page" => render_page(b, a), + "export_png" => { + req_str(a, "path")?; + j(b.call("app.export", pick(a, &["path", "page", "scale"]))?) + } + "screenshot" => screenshot(b, a), + "select_tool" => j(b.call("ui.tool.select", json!({"tool": req_str(a, "tool")?}))?), + "pointer" => j(pointer(b, a)?), + "key" => { + req_str(a, "key")?; + j(b.call("ui.key", pick(a, &["key", "shift", "alt", "cmd", "ctrl"]))?) + } + "type_text" => j(b.call("ui.text", json!({"text": req_str(a, "text")?}))?), + "click" | "drag" => { + need_ui(b, name)?; + let keys: &[&str] = &["x", "y", "toX", "toY", "steps", "button", "count", "shift", "alt", "cmd", "ctrl"]; + j(b.call(if name == "click" { "ui.click" } else { "ui.drag" }, pick(a, keys))?) + } + "menu_list" => { + need_ui(b, name)?; + j(b.call("ui.menu.list", json!({}))?) + } + "ui_inspect" => { + need_ui(b, name)?; + j(b.call("ui.inspect", json!({}))?) + } + "ui_set" => { + need_ui(b, name)?; + j(b.call("ui.set", Value::Object(a.clone()))?) + } + "dialog_set" => { + need_ui(b, name)?; + j(b.call("ui.dialog.set", json!({"field": req_str(a, "field")?, "value": a.get("value")}))?) + } + "dialog_confirm" => { + need_ui(b, name)?; + j(b.call("ui.dialog.confirm", json!({}))?) + } + "dialog_cancel" => { + need_ui(b, name)?; + j(b.call("ui.dialog.cancel", json!({}))?) + } + other => Err(format!("unknown tool `{other}` (see tools/list)")), + } +} + +/// Run one tool. Failures (unknown tool, bad arguments, command errors) come back as an +/// `isError` result so the model can read and correct them. +pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { + let empty = Map::new(); + let a = match args { + Value::Object(o) => o, + Value::Null => &empty, + _ => return ToolResult::error("tool arguments must be a JSON object"), + }; + match dispatch(b, name, a) { + Ok(r) => r, + Err(e) => ToolResult::error(format!("{name}: {e}")), + } +} diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 00000000..4ed2a3e8 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,92 @@ +# MCP server + +`designcraft-cli mcp` is a [Model Context Protocol](https://modelcontextprotocol.io) server that lets Claude and +other agents drive DesignCraft: create documents, lay out frames and text, apply styles, place images, and look at +the result as PNG images. It speaks newline-delimited JSON-RPC 2.0 on stdin/stdout (protocol `2025-06-18`, also +`2025-03-26` and `2024-11-05`); logs go to stderr. The implementation is the `designcraft-mcp` crate. + +## Register it + +Build the CLI (`cargo build --release -p designcraft-cli`), then: + +```sh +# Headless: an in-process engine, no window +claude mcp add designcraft -- /path/to/target/release/designcraft-cli mcp + +# Drive the running desktop app (start it first with a control port) +designcraft --control 7979 +claude mcp add designcraft-app -- /path/to/target/release/designcraft-cli mcp --connect 7979 +``` + +Other MCP clients use the same command line, e.g. in a JSON config: + +```json +{"mcpServers": {"designcraft": {"command": "/path/to/designcraft-cli", "args": ["mcp"]}}} +``` + +## Modes + +| | Headless (`mcp`) | Connected (`mcp --connect PORT` or `HOST:PORT`) | +|---|---|---| +| Engine | In-process `designcraft_engine::Session`, starts with an empty Letter document (`--sample` opens the sample magazine) | The app's session, through its loopback control channel ([control-protocol.md](control-protocol.md)) | +| Rendering | `designcraft-render` (CPU) | The app's renderer | +| Window tools (`screenshot`, `click`, `drag`, `menu_list`, `ui_inspect`, `ui_set`, `dialog_*`) | Return an error explaining how to connect | Work | +| UI-only commands (`view.*`, `window.*`, `app.*`) via `execute` | Error | Work | + +In connected mode every tool maps to a control-channel method (`engine.execute`, `ui.render`, `ui.pointer`, …), so +what the agent does shows up live in the window. The connection is re-established once if the app restarts. + +## Coordinates + +Points (1/72 in), y down, in **spread** space: on a single-page spread the page's top-left is (0, 0); on a facing +spread the right page starts at x = page width. `inspect_document` lists page bounds per spread. Page indices in +`render_page` / `export_png` are 0-based. `pointer` events in connected mode may use `"space": "screen"` (egui points, +see `ui_inspect` → `canvasRect`). + +## Tools + +| Tool | What it does | +|---|---| +| `list_commands` | Every command (id, label, menu path, shortcut, params, enabled), with `filter` / `enabledOnly` | +| `execute` | Run any command: `{command, params}` | +| `batch` | Run `commands: [{command, params}]` in order, stopping at the first error (reports `failedIndex`) | +| `inspect_document` | Pages, spreads, items (ids, bounds, fill, story), stories (overset), layers, styles, swatches, selection | +| `get_story` | Text, frames, paragraphs, lines, overset of a story (`story` or text `frame`) | +| `set_story_text` | Replace a story's text (`\n` = new paragraph) | +| `new_document` | `file.new` (preset, width, height, pages, facingPages, columns, gutter, margins, bleed, title) or `sample: true` | +| `open_document` / `save_document` | Native `.designcraft` files | +| `place_image` | Image from `path` or `base64` into `frame`, the selected frame, or a new frame at x, y, width | +| `render_page` | Render a page → MCP image content (PNG, base64); optional `path` to also save it | +| `export_png` | Write a page as PNG (or JPEG by extension) | +| `screenshot` | Whole app window → image content (connected only; captured to a temp file and read back) | +| `select_tool` | Activate a tool (`selection`, `type`, `rectangleFrame`, `line`, …) | +| `pointer` | Mouse gesture events (`down`/`drag`/`up`/`move`/`doubleclick`) for the active tool, optional `tool` | +| `key` | Key press with modifiers (headless: active tool → command shortcuts → tool shortcuts) | +| `type_text` | Type at the text insertion point (or into the focused dialog field in the app) | +| `click` / `drag` | Real pointer input at screen points (connected only) | +| `menu_list`, `ui_inspect`, `ui_set` | Menu tree, UI state, change UI state (connected only) | +| `dialog_set` / `dialog_confirm` / `dialog_cancel` | Fill in and close the open dialog (connected only) | + +Tool failures come back as results with `isError: true` and a message the model can act on; protocol errors use +JSON-RPC error codes. Resources: `designcraft://document` (document.inspect) and `designcraft://commands`. + +## Example + +```text +new_document {"preset": "A4", "margins": 36} +execute {"command": "frame.create", "params": {"rect": [36, 36, 559, 200], "content": "text", "text": "Hello"}} + → {"id": 8, "story": 9} +set_story_text {"story": 9, "text": "Summer Fair\nSaturday in the park"} +execute {"command": "type.char", "params": {"size": 28}} +place_image {"path": "/tmp/photo.jpg", "x": 36, "y": 220, "width": 523} +render_page {"page": 0} → PNG image +``` + +Or with the Type tool, the way a user would: + +```text +pointer {"tool": "type", "events": [{"kind": "down", "x": 72, "y": 72}, {"kind": "drag", "x": 300, "y": 200}, {"kind": "up", "x": 300, "y": 200}]} +type_text {"text": "Hello"} +key {"key": "Enter"} +key {"key": "Escape"} +```