From 873e9f5c9936b5520e557404affa0214573f7274 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 30 Sep 2026 06:29:25 -0400 Subject: [PATCH] M1.9: MCP server (stdio JSON-RPC, remote + headless backends) and drawcraft-cli Co-Authored-By: Claude Opus 5.5 --- apps/drawcraft-cli/Cargo.toml | 3 + apps/drawcraft-cli/src/main.rs | 153 ++++++++- apps/drawcraft-cli/tests/cli.rs | 82 +++++ crates/mcp/src/headless.rs | 321 ++++++++++++++++++ crates/mcp/src/server.rs | 165 +++++++++ crates/mcp/src/tests.rs | 384 +++++++++++++++++++++ crates/mcp/src/tools.rs | 575 ++++++++++++++++++++++++++++++++ 7 files changed, 1682 insertions(+), 1 deletion(-) create mode 100644 apps/drawcraft-cli/tests/cli.rs create mode 100644 crates/mcp/src/headless.rs create mode 100644 crates/mcp/src/server.rs create mode 100644 crates/mcp/src/tests.rs create mode 100644 crates/mcp/src/tools.rs diff --git a/apps/drawcraft-cli/Cargo.toml b/apps/drawcraft-cli/Cargo.toml index 70c358e1..674965f5 100644 --- a/apps/drawcraft-cli/Cargo.toml +++ b/apps/drawcraft-cli/Cargo.toml @@ -16,3 +16,6 @@ workspace = true [dependencies] drawcraft-mcp = { workspace = true } serde_json = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/apps/drawcraft-cli/src/main.rs b/apps/drawcraft-cli/src/main.rs index f328e4d9..ac36bfcf 100644 --- a/apps/drawcraft-cli/src/main.rs +++ b/apps/drawcraft-cli/src/main.rs @@ -1 +1,152 @@ -fn main() {} +//! `drawcraft-cli`: DrawCraft from the command line. +//! +//! ```text +//! drawcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] +//! drawcraft-cli run [--in file.drawcraft|file.svg] [--cmd id [--params '{json}']]... [--export out.svg|.png|.drawcraft]... [--scale 2] +//! drawcraft-cli commands +//! ``` +#![forbid(unsafe_code)] + +use std::io::Write; +use std::process::ExitCode; + +use drawcraft_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server}; +use serde_json::{Value, json}; + +const USAGE: &str = "\ +drawcraft-cli — DrawCraft automation + +USAGE: + drawcraft-cli mcp [--connect ADDR | --headless] + Run the MCP server on stdio. Default: connect to a running app at 127.0.0.1:7979 + (drawcraft --control 7979), falling back to a headless in-process session. + + drawcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N] + Headless batch: open FILE (.drawcraft/.svg) or start a new document, run commands in + order, export (.svg, .png, .drawcraft by extension). Prints one JSON result per step. + + drawcraft-cli commands + Print the command catalogue as JSON. +"; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let r = match args.first().map(String::as_str) { + Some("mcp") => mcp(&args[1..]), + Some("run") => run(&args[1..]), + Some("commands") => commands(), + Some("-h" | "--help" | "help") | None => { + print!("{USAGE}"); + Ok(()) + } + Some("-V" | "--version") => { + println!("drawcraft-cli {}", env!("CARGO_PKG_VERSION")); + Ok(()) + } + Some(other) => Err(format!("unknown subcommand `{other}`\n\n{USAGE}")), + }; + match r { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("drawcraft-cli: {e}"); + ExitCode::FAILURE + } + } +} + +fn mcp(args: &[String]) -> Result<(), String> { + let mut connect: Option = None; + let mut headless = 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 an address")?), + "--headless" => headless = true, + other => return Err(format!("unknown mcp option `{other}`")), + } + } + if headless && connect.is_some() { + return Err("use either --connect or --headless".into()); + } + let backend: Box = if headless { + Box::new(Headless::with_document()) + } else if let Some(addr) = connect { + // Explicit address: fail loudly if the app isn't there. + Box::new(Remote::connect(&addr).map_err(|e| format!("cannot connect to {addr}: {e}"))?) + } else { + match Remote::connect(DEFAULT_ADDR) { + Ok(r) => Box::new(r), + Err(_) => Box::new(Headless::with_document()), + } + }; + eprintln!("drawcraft-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 commands() -> Result<(), String> { + let mut h = Headless::new(); + let v = h.call("engine.commands", json!({}))?; + println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); + Ok(()) +} + +enum Step { + Cmd(String, Value), + Export(String), +} + +fn run(args: &[String]) -> Result<(), String> { + let mut input: Option = None; + let mut steps: Vec = vec![]; + let mut scale = 1.0; + let mut it = args.iter(); + while let Some(a) = it.next() { + let mut val = |what: &str| it.next().cloned().ok_or_else(|| format!("{a} needs {what}")); + match a.as_str() { + "--in" | "-i" => input = Some(val("a file")?), + "--cmd" | "-c" => steps.push(Step::Cmd(val("a command id")?, json!({}))), + "--params" | "-p" => { + let raw = val("JSON")?; + let p: Value = serde_json::from_str(&raw).map_err(|e| format!("--params {raw}: {e}"))?; + if !p.is_object() { + return Err(format!("--params must be a JSON object, got {raw}")); + } + match steps.last_mut() { + Some(Step::Cmd(_, params)) => *params = p, + _ => return Err("--params must follow a --cmd".into()), + } + } + "--export" | "-o" => steps.push(Step::Export(val("a file")?)), + "--scale" | "-s" => { + let raw = val("a number")?; + scale = raw.parse::().map_err(|_| format!("--scale {raw}: not a number"))?; + } + other => return Err(format!("unknown run option `{other}`")), + } + } + + let mut h = Headless::new(); + let mut out = std::io::stdout().lock(); + let mut emit = |v: Value| writeln!(out, "{v}").map_err(|e| e.to_string()); + if let Some(path) = &input { + let r = h.call("app.open", json!({"path": path})).map_err(|e| format!("open {path}: {e}"))?; + emit(json!({"step": "open", "path": path, "result": r}))?; + } else if !matches!(steps.first(), Some(Step::Cmd(id, _)) if id == "file.new") { + h.ensure_document(); + } + for step in steps { + match step { + Step::Cmd(id, params) => { + let r = h.call("engine.execute", json!({"command": id, "params": params})).map_err(|e| format!("{id}: {e}"))?; + emit(json!({"step": "cmd", "command": id, "result": r}))?; + } + Step::Export(path) => { + let r = h.call("app.export", json!({"path": path, "scale": scale})).map_err(|e| format!("export {path}: {e}"))?; + emit(json!({"step": "export", "result": r}))?; + } + } + } + Ok(()) +} diff --git a/apps/drawcraft-cli/tests/cli.rs b/apps/drawcraft-cli/tests/cli.rs new file mode 100644 index 00000000..7d8d32a4 --- /dev/null +++ b/apps/drawcraft-cli/tests/cli.rs @@ -0,0 +1,82 @@ +use std::io::Write; +use std::process::{Command, Stdio}; + +use serde_json::{Value, json}; + +const BIN: &str = env!("CARGO_BIN_EXE_drawcraft-cli"); + +fn tmp(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("drawcraft-cli-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join(name) +} + +#[test] +fn commands_prints_catalogue() { + let out = Command::new(BIN).arg("commands").output().unwrap(); + assert!(out.status.success()); + let v: Value = serde_json::from_slice(&out.stdout).unwrap(); + let ids: Vec<&str> = v.as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect(); + assert!(ids.contains(&"shape.rectangle") && ids.contains(&"object.group") && ids.contains(&"file.export")); +} + +#[test] +fn run_batch_exports() { + let svg = tmp("batch.svg"); + let png = tmp("batch.png"); + let dc = tmp("batch.drawcraft"); + let out = Command::new(BIN) + .args(["run", "--cmd", "file.new", "--params", r#"{"width":200,"height":100}"#]) + .args(["--cmd", "shape.ellipse", "--params", r#"{"x":10,"y":10,"width":80,"height":60}"#]) + .args(["--cmd", "paint.setFill", "--params", r##"{"color":"#3366ff"}"##]) + .args(["--export", svg.to_str().unwrap(), "--export", png.to_str().unwrap(), "--export", dc.to_str().unwrap(), "--scale", "2"]) + .output() + .unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let lines: Vec = String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(lines.len(), 6); + assert!(std::fs::read_to_string(&svg).unwrap().contains("3366ff")); + let png_bytes = std::fs::read(&png).unwrap(); + assert_eq!(&png_bytes[..4], b"\x89PNG"); + // 200×100 pt at scale 2 → 400×200 px (IHDR width/height). + assert_eq!(u32::from_be_bytes(png_bytes[16..20].try_into().unwrap()), 400); + + // Re-open the native file and inspect. + let out = Command::new(BIN).args(["run", "--in", dc.to_str().unwrap(), "--cmd", "document.inspect"]).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let last: Value = serde_json::from_str(String::from_utf8(out.stdout).unwrap().lines().last().unwrap()).unwrap(); + assert_eq!(last["result"]["artboards"][0]["width"], 200.0); +} + +#[test] +fn run_reports_errors() { + let out = Command::new(BIN).args(["run", "--cmd", "nope.nothing"]).output().unwrap(); + assert!(!out.status.success()); + assert!(String::from_utf8_lossy(&out.stderr).contains("nope.nothing")); + let out = Command::new(BIN).args(["run", "--params", "{}"]).output().unwrap(); + assert!(!out.status.success()); +} + +#[test] +fn mcp_headless_over_stdio() { + let mut child = Command::new(BIN).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); + { + let mut stdin = child.stdin.take().unwrap(); + for m in [ + json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","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":"draw_shape","arguments":{"shape":"polygon","cx":100,"cy":100,"radius":50,"sides":5,"fill":"#00aa00"}}}), + json!({"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"screenshot","arguments":{"scale":0.25}}}), + ] { + writeln!(stdin, "{m}").unwrap(); + } + } + let out = child.wait_with_output().unwrap(); + assert!(out.status.success()); + let replies: Vec = String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(replies.iter().map(|r| r["id"].as_u64().unwrap()).collect::>(), [1, 2, 3, 4]); + assert_eq!(replies[0]["result"]["serverInfo"]["name"], "drawcraft"); + assert_eq!(replies[2]["result"]["isError"], false); + assert_eq!(replies[3]["result"]["content"][0]["type"], "image"); +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs new file mode 100644 index 00000000..8720650a --- /dev/null +++ b/crates/mcp/src/headless.rs @@ -0,0 +1,321 @@ +//! An in-process engine session that answers the control-channel methods itself. + +use drawcraft_engine::{Session, UiRequest, ViewInfo}; +use drawcraft_geom::Point; +use drawcraft_render::Renderer; +use drawcraft_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 screenshots and PNG export. +pub struct Headless { + pub session: Session, + pub renderer: Renderer, + /// View state handed to tools (zoom 1, smart guides on). + pub view: ViewInfo, +} + +impl Default for Headless { + fn default() -> Self { + Self::new() + } +} + +/// Commands the desktop app adds on top of the engine; the headless backend implements them too so +/// `run_command` behaves the same in both modes. +const HOST_COMMANDS: &[(&str, &str, &str)] = &[ + ("file.open", "Open…", "{path} open a .drawcraft or .svg file as a new document"), + ("file.save", "Save", "{path?} save as .drawcraft (default: the document's path)"), + ("file.saveAs", "Save As…", "{path}"), + ("file.export", "Export…", "{format?: svg|png|drawcraft, path, scale?, artboard?}"), + ("tool.select", "Select Tool", "{tool} e.g. selection, directSelection, pen, rectangle, ellipse, polygon, star, lineSegment"), +]; + +fn s<'a>(p: &'a Value, k: &str) -> Option<&'a str> { + p.get(k).and_then(Value::as_str) +} + +fn ext_of(path: &str) -> String { + std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default() +} + +fn file_name(path: &str) -> String { + std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()) +} + +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, + "[" | "bracketleft" | "openbracket" => ToolKey::BracketLeft, + "]" | "bracketright" | "closebracket" => ToolKey::BracketRight, + "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(); + 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 +} + +impl Headless { + /// A session with no document (the first `file.new` / `app.open` creates one). + pub fn new() -> Self { + Self { session: Session::new(), renderer: Renderer::new(), view: ViewInfo::default() } + } + + /// A session with a fresh default (Letter) document, ready to draw into. + pub fn with_document() -> Self { + let mut h = Self::new(); + h.ensure_document(); + h + } + + /// Create a default document when none is open. + pub fn ensure_document(&mut self) { + if self.session.active().is_none() + && let Err(e) = self.session.execute("file.new", &json!({})) + { + log::error!("file.new failed: {e}"); + } + } + + fn exec(&mut self, id: &str, params: &Value) -> Result { + match id { + "file.open" => self.open(params), + "file.save" | "file.saveAs" => self.save(params), + "file.export" => self.export(params), + "tool.select" => self.select_tool(params), + _ => self.session.execute(id, params).map_err(|e| e.to_string()), + } + } + + fn commands(&self) -> Value { + let mut v: Vec = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); + for (id, label, params) in HOST_COMMANDS { + v.push(json!({"id": id, "label": label, "menu": [], "shortcut": null, "params": params, "enabled": true, "host": true})); + } + Value::Array(v) + } + + fn select_tool(&mut self, p: &Value) -> Result { + let t = s(p, "tool").ok_or("missing `tool`")?; + if drawcraft_tools::tool_info(t).is_none() { + return Err(format!("unknown tool `{t}` (see ui.tool.list)")); + } + self.session.select_tool(t, self.view).map_err(|e| e.to_string())?; + Ok(json!({"tool": self.session.tool_id()})) + } + + fn apply_ui_requests(&mut self, reqs: Vec, out: &mut Vec) -> Result<(), String> { + for r in reqs { + match r { + UiRequest::SwitchTool(t) => { + self.session.select_tool(&t, self.view).map_err(|e| e.to_string())?; + } + UiRequest::Dialog(k, p) => out.push(json!({"dialog": k, "params": p})), + } + } + Ok(()) + } + + 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(); + self.ensure_document(); + let mut requests = vec![]; + 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("screen-space pointer events need the desktop app; use document coordinates".into()); + } + 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); + let ev = PointerEvent { kind, pos: Point::new(x, y), mods, pressure: 1.0 }; + let reqs = self.session.pointer(&ev, self.view).map_err(|e| e.to_string())?; + self.apply_ui_requests(reqs, &mut requests)?; + } + let sel = self.session.active().map(|d| d.selection.objects.iter().map(|i| i.0).collect::>()).unwrap_or_default(); + Ok(json!({"selection": sel, "tool": self.session.tool_id(), "requests": requests})) + } + + fn key(&mut self, p: &Value) -> Result { + let key = s(p, "key").ok_or("missing `key`")?; + let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false); + let mods = Mods { shift: b("shift"), alt: b("alt"), cmd: b("cmd"), ctrl: b("ctrl"), ..Mods::default() }; + let tk = tool_key(key); + // A busy tool (pen path in progress, shape being dragged) gets its keys first. + if let Some(k) = tk + && self.session.tool_busy() + { + let mut out = vec![]; + let reqs = self.session.tool_key(k, mods, self.view).map_err(|e| e.to_string())?; + self.apply_ui_requests(reqs, &mut out)?; + return Ok(json!({"handledBy": "tool", "requests": out})); + } + if let Some(c) = drawcraft_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 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.select_tool(t.id, self.view).map_err(|e| e.to_string())?; + return Ok(json!({"handledBy": "tool.select", "tool": t.id})); + } + if let Some(k) = tk { + let mut out = vec![]; + let reqs = self.session.tool_key(k, mods, self.view).map_err(|e| e.to_string())?; + self.apply_ui_requests(reqs, &mut out)?; + return Ok(json!({"handledBy": "tool", "requests": out})); + } + Err(format!("no headless binding for key `{key}`")) + } + + fn render(&mut self, p: &Value) -> Result { + let st = self.session.active().ok_or("no document")?; + let doc = st.doc.clone(); + let idx = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize; + let r = doc.artboards.get(idx).map(|a| a.rect).ok_or("no such artboard")?; + let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.01, 16.0); + let img = self.renderer.render_region(&doc, r, scale, true); + 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": drawcraft_format::base64_encode(&png)})), + } + } + + /// `app.open {path}`: `.drawcraft` or `.svg` as a new active document. + pub fn open(&mut self, p: &Value) -> Result { + let path = s(p, "path").ok_or("missing `path`")?; + let bytes = std::fs::read(path).map_err(|e| format!("read {path}: {e}"))?; + let name = file_name(path); + let e = ext_of(path); + let mut warnings = vec![]; + let (doc, keep_path) = if e == "drawcraft" || drawcraft_format::sniff(&bytes) { + let mut d = drawcraft_format::load(&bytes).map_err(|e| e.to_string())?; + if d.title.is_empty() { + d.title = name.clone(); + } + (d, true) + } else if e == "svg" || bytes.starts_with(b" Result { + let st = self.session.active().ok_or("no document")?; + let path = s(p, "path").map(str::to_string).or_else(|| st.path.clone()).ok_or("missing `path` (document was never saved)")?; + let bytes = drawcraft_format::save(&st.doc, true); + std::fs::write(&path, bytes).map_err(|e| format!("write {path}: {e}"))?; + if let Some(st) = self.session.active_mut() { + st.path = Some(path.clone()); + st.saved_revision = st.revision; + } + Ok(json!({"path": path})) + } + + /// `app.export {format?, path, scale?, artboard?}`: svg | png | drawcraft (format defaults to the extension). + pub fn export(&mut self, p: &Value) -> Result { + let path = s(p, "path").ok_or("missing `path`")?; + let fmt = s(p, "format").map(str::to_ascii_lowercase).unwrap_or_else(|| ext_of(path)); + let st = self.session.active().ok_or("no document")?; + let doc = st.doc.clone(); + let bytes = match fmt.as_str() { + "svg" => { + let ab = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize; + drawcraft_svg::export(&doc, &drawcraft_svg::ExportOptions { artboard: Some(ab), ..Default::default() }).into_bytes() + } + "png" => { + let idx = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize; + let r = doc.artboards.get(idx).map(|a| a.rect).ok_or("no such artboard")?; + let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.01, 16.0); + self.renderer.render_region(&doc, r, scale, false).to_png() + } + "drawcraft" => drawcraft_format::save(&doc, true), + other => return Err(format!("unknown export format `{other}` (svg, png, drawcraft)")), + }; + std::fs::write(path, &bytes).map_err(|e| format!("write {path}: {e}"))?; + Ok(json!({"path": path, "format": fmt, "bytes": bytes.len()})) + } +} + +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(self.commands()), + "document.inspect" => self.exec("document.inspect", &json!({})), + "document.node" => self.exec("document.node", p), + "document.json" => self.exec("document.json", &json!({})), + "ui.tool.select" => self.select_tool(p), + "ui.tool.list" => Ok(serde_json::to_value(TOOL_GROUPS).unwrap_or_default()), + "ui.pointer" => self.pointer(p), + "ui.key" => self.key(p), + "ui.render" | "ui.screenshot" => self.render(p), + "app.open" => self.open(p), + "app.save" => self.save(p), + "app.export" => self.export(p), + "app.quit" => Ok(Value::Null), + m if m.starts_with("ui.") => { + Err(format!("`{m}` needs the desktop app (start `drawcraft --control 7979` and use `drawcraft-cli mcp --connect`)")) + } + other => Err(format!("unknown method `{other}`")), + } + } + + fn has_ui(&self) -> bool { + false + } + + fn describe(&self) -> String { + "headless".into() + } +} diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs new file mode 100644 index 00000000..3cae76cc --- /dev/null +++ b/crates/mcp/src/server.rs @@ -0,0 +1,165 @@ +//! 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 = "DrawCraft is a vector illustration app (an Illustrator clone). Coordinates are points in \ +document space (y down, origin at the first artboard's top-left; a new document is 612×792). Draw with draw_shape / \ +draw_path, change colours with set_paint, look with screenshot and inspect_document. Every menu action is a command: \ +find it with list_commands and run it with run_command. New objects become the selection, and most commands act on the \ +selection (or on explicit `ids`)."; + +/// Resource URIs. +pub const DOC_URI: &str = "drawcraft://document"; +pub const DOC_JSON_URI: &str = "drawcraft://document/json"; + +/// 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, ¶ms); + 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, _params: &Value) { + 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": "drawcraft", "title": "DrawCraft", "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 (summary)", "description": "Layer tree, artboards, selection and history of the active document (document.inspect)", "mimeType": "application/json"}, + {"uri": DOC_JSON_URI, "name": "document-json", "title": "Active document (full model)", "description": "The complete document model as JSON (document.json)", "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!({})), + DOC_JSON_URI => self.backend.call("engine.execute", json!({"command": "document.json", "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()}]})) + } + 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..f34921ea --- /dev/null +++ b/crates/mcp/src/tests.rs @@ -0,0 +1,384 @@ +use std::io::{BufRead, BufReader, Write}; + +use serde_json::{Value, json}; + +use crate::{Backend, Headless, PROTOCOL_VERSION, Remote, Server, 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, id: u64, name: &str, args: Value) -> Value { + let v = rpc(s, id, "tools/call", json!({"name": name, "arguments": args})); + assert!(v.get("error").is_none(), "{v}"); + v["result"].clone() +} + +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 tmp(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("drawcraft-mcp-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir.join(name) +} + +// ---------- framing ---------- + +#[test] +fn framing_basics() { + let mut s = server(); + // Blank lines and notifications produce no output. + assert_eq!(s.handle_line(" "), None); + assert_eq!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#), None); + assert!(s.is_initialized()); + // Parse error → -32700 with null id. + 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); + // Unknown method → -32601, id echoed (string ids too). + 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"); + // Missing method → invalid request. + let v: Value = serde_json::from_str(&s.handle_line(r#"{"jsonrpc":"2.0","id":3}"#).unwrap()).unwrap(); + assert_eq!(v["error"]["code"], -32600); + // Responses from the client are ignored. + assert_eq!(s.handle_line(r#"{"jsonrpc":"2.0","id":9,"result":{}}"#), None); + // ping + assert_eq!(rpc(&mut s, 4, "ping", json!({}))["result"], json!({})); + // Batch (legacy clients). + 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); +} + +#[test] +fn serve_loop_writes_one_line_per_request() { + let mut s = server(); + let input = [ + json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":PROTOCOL_VERSION,"capabilities":{},"clientInfo":{"name":"t","version":"0"}}}).to_string(), + json!({"jsonrpc":"2.0","method":"notifications/initialized"}).to_string(), + json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}).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(), 2); + assert_eq!(lines[0]["result"]["protocolVersion"], PROTOCOL_VERSION); + assert_eq!(lines[0]["result"]["serverInfo"]["name"], "drawcraft"); + assert!(lines[0]["result"]["capabilities"]["tools"].is_object()); + assert!(lines[0]["result"]["capabilities"]["resources"].is_object()); + assert!(lines[1]["result"]["tools"].as_array().unwrap().len() >= 19); +} + +#[test] +fn initialize_negotiates_version() { + let mut s = server(); + let v = rpc(&mut s, 1, "initialize", json!({"protocolVersion": "2025-03-26"})); + assert_eq!(v["result"]["protocolVersion"], "2025-03-26"); + let v = rpc(&mut s, 2, "initialize", json!({"protocolVersion": "1999-01-01"})); + assert_eq!(v["result"]["protocolVersion"], PROTOCOL_VERSION); +} + +// ---------- tools/list ---------- + +#[test] +fn tool_schemas_are_valid() { + let tools = tool_definitions(); + let mut names = std::collections::HashSet::new(); + for t in &tools { + let name = t["name"].as_str().expect("name"); + assert!(!name.is_empty() && name.len() <= 64 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'), "{name}"); + assert!(names.insert(name), "duplicate tool {name}"); + assert!(t["description"].as_str().is_some_and(|d| d.len() > 10), "{name} description"); + let schema = &t["inputSchema"]; + assert_eq!(schema["type"], "object", "{name}"); + let props = schema["properties"].as_object().unwrap_or_else(|| panic!("{name} properties")); + for r in schema.get("required").and_then(Value::as_array).into_iter().flatten() { + assert!(props.contains_key(r.as_str().unwrap()), "{name}: required {r} not in properties"); + } + for (k, p) in props { + assert!(p.is_object(), "{name}.{k}"); + assert!(p.get("type").is_some() || p.get("anyOf").is_some(), "{name}.{k} has no type"); + } + } + for want in [ + "list_commands", + "run_command", + "inspect_document", + "inspect_ui", + "select_tool", + "pointer_gesture", + "draw_path", + "draw_shape", + "set_paint", + "press_key", + "invoke_menu", + "open_panel", + "screenshot", + "open_file", + "save_file", + "export", + "undo", + "redo", + ] { + assert!(names.contains(want), "missing tool {want}"); + } +} + +// ---------- headless end-to-end ---------- + +#[test] +fn headless_end_to_end() { + let mut s = server(); + rpc(&mut s, 1, "initialize", json!({"protocolVersion": PROTOCOL_VERSION})); + s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#); + + let r = call( + &mut s, + 2, + "draw_shape", + json!({"shape": "rectangle", "x": 100, "y": 100, "width": 200, "height": 120, "fill": "#ff0000", "stroke": "none"}), + ); + assert_eq!(r["isError"], false, "{r}"); + let created: Value = serde_json::from_str(&text_of(&r)).unwrap(); + let id = created["id"].as_u64().expect("id"); + + let r = call( + &mut s, + 3, + "draw_shape", + json!({"shape": "star", "cx": 400, "cy": 400, "radius1": 80, "radius2": 40, "fill": [0, 0, 1], "strokeWidth": 3}), + ); + assert_eq!(r["isError"], false, "{r}"); + + let r = call(&mut s, 4, "inspect_document", json!({})); + let doc: Value = serde_json::from_str(&text_of(&r)).unwrap(); + assert!(doc["objects"].as_u64().unwrap() >= 3, "{doc}"); + let layer = &doc["layers"][0]; + let rect = layer["children"].as_array().unwrap().iter().find(|c| c["id"] == id).expect("rect in layer"); + assert_eq!(rect["bounds"]["width"], 200.0); + assert_eq!(rect["fill"], "#ff0000", "{rect}"); + assert_eq!(rect["stroke"], "None"); + + // Screenshot returns image content (base64 PNG) and text. + let shot_path = tmp("shot.png"); + let r = call(&mut s, 5, "screenshot", json!({"path": shot_path.to_str().unwrap()})); + assert_eq!(r["isError"], false, "{r}"); + let img = r["content"].as_array().unwrap().iter().find(|c| c["type"] == "image").expect("image content"); + assert_eq!(img["mimeType"], "image/png"); + let png = drawcraft_format::base64_decode(img["data"].as_str().unwrap()).unwrap(); + assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n"); + assert_eq!(std::fs::read(&shot_path).unwrap(), png); + + // Export SVG. + let svg_path = tmp("out.svg"); + let r = call(&mut s, 6, "export", json!({"path": svg_path.to_str().unwrap()})); + assert_eq!(r["isError"], false, "{r}"); + let svg = std::fs::read_to_string(&svg_path).unwrap(); + assert!(svg.contains("(text).unwrap()["layers"].is_array()); + let v = rpc(&mut s, 12, "resources/read", json!({"uri": "drawcraft://document/json"})); + assert!(v["result"]["contents"][0]["text"].as_str().unwrap().len() > 10); + let v = rpc(&mut s, 13, "resources/read", json!({"uri": "drawcraft://nope"})); + assert_eq!(v["error"]["code"], -32002); +} + +#[test] +fn headless_path_gesture_undo() { + let mut s = server(); + let count = |s: &mut Server| -> u64 { + let r = call(s, 90, "inspect_document", json!({})); + serde_json::from_str::(&text_of(&r)).unwrap()["objects"].as_u64().unwrap() + }; + let base = count(&mut s); + let r = call(&mut s, 1, "draw_path", json!({"points": [[10, 10], [100, 10], [50, 90]], "closed": true, "fill": "#00ff00"})); + assert_eq!(r["isError"], false, "{r}"); + let r = call(&mut s, 2, "draw_path", json!({"d": "M0 0 C 10 10 20 10 30 0", "stroke": "#000000", "strokeWidth": 2})); + assert_eq!(r["isError"], false, "{r}"); + assert_eq!(count(&mut s), base + 2); + + // Drag out an ellipse with the ellipse tool. + let r = call( + &mut s, + 3, + "pointer_gesture", + json!({"tool": "ellipse", "events": [{"kind": "down", "x": 200, "y": 200}, {"kind": "drag", "x": 260, "y": 240}, {"kind": "up", "x": 300, "y": 280}]}), + ); + assert_eq!(r["isError"], false, "{r}"); + assert_eq!(count(&mut s), base + 3); + + assert_eq!(call(&mut s, 4, "undo", json!({}))["isError"], false); + assert_eq!(count(&mut s), base + 2); + assert_eq!(call(&mut s, 5, "redo", json!({}))["isError"], false); + assert_eq!(count(&mut s), base + 3); + + // Keyboard shortcut headless: Cmd+Z undoes. + let r = call(&mut s, 6, "press_key", json!({"key": "Z", "mods": {"cmd": true}})); + assert_eq!(r["isError"], false, "{r}"); + assert_eq!(count(&mut s), base + 2); + + // Long tail through run_command + list_commands filter. + let r = call(&mut s, 7, "run_command", json!({"command": "select.all"})); + assert_eq!(r["isError"], false, "{r}"); + let r = call(&mut s, 8, "run_command", json!({"command": "object.group"})); + assert_eq!(r["isError"], false, "{r}"); + let r = call(&mut s, 9, "list_commands", json!({"filter": "group"})); + let v: Value = serde_json::from_str(&text_of(&r)).unwrap(); + assert!(v["commands"].as_array().unwrap().iter().any(|c| c["id"] == "object.group")); + assert!( + v["commands"] + .as_array() + .unwrap() + .iter() + .all(|c| c["id"].as_str().unwrap().contains("group") || c["label"].as_str().unwrap().to_lowercase().contains("group")) + ); + + // set_paint on the selection. + let r = call(&mut s, 10, "set_paint", json!({"fill": "#123456", "strokeWidth": 4})); + assert_eq!(r["isError"], false, "{r}"); + let r = call(&mut s, 11, "select_tool", json!({"tool": "pen"})); + assert_eq!(r["isError"], false, "{r}"); + let r = call(&mut s, 12, "export", json!({"path": tmp("x.png").to_str().unwrap(), "scale": 0.5})); + assert_eq!(r["isError"], false, "{r}"); + assert_eq!(&std::fs::read(tmp("x.png")).unwrap()[..4], b"\x89PNG"); +} + +#[test] +fn errors_are_tool_results_not_crashes() { + let mut s = server(); + let cases = [ + ("no_such_tool", json!({})), + ("draw_shape", json!({"shape": "hexagon"})), + ("draw_shape", json!({"shape": "rectangle", "x": "left"})), + ("draw_shape", json!({})), + ("draw_path", json!({"points": [[1]]})), + ("draw_path", json!({})), + ("run_command", json!({"command": "does.not.exist"})), + ("run_command", json!({"command": "object.move", "params": 5})), + ("run_command", json!({})), + ("pointer_gesture", json!({"events": [{"kind": "wiggle", "x": 0, "y": 0}]})), + ("pointer_gesture", json!({"events": []})), + ("select_tool", json!({"tool": "laser"})), + ("set_paint", json!({})), + ("set_paint", json!({"fill": "#zzzzzz"})), + ("inspect_ui", json!({})), + ("open_panel", json!({"panel": "Layers"})), + ("type_text", json!({"text": "hi"})), + ("screenshot", json!({"window": true})), + ("open_file", json!({"path": "/definitely/not/here.svg"})), + ("export", json!({"path": "/tmp/out.bmp"})), + ("redo", json!({})), + ("press_key", json!({"key": "F13"})), + ]; + for (i, (name, args)) in cases.iter().enumerate() { + let r = call(&mut s, i as u64, name, args.clone()); + assert_eq!(r["isError"], true, "{name} {args} → {r}"); + assert!(!text_of(&r).is_empty()); + } + // Arguments that aren't an object. + let v = rpc(&mut s, 100, "tools/call", json!({"name": "undo", "arguments": [1, 2]})); + assert_eq!(v["result"]["isError"], true); + // Missing name is a protocol error. + let v = rpc(&mut s, 101, "tools/call", json!({})); + assert_eq!(v["error"]["code"], -32602); + // The server still works afterwards. + let r = call(&mut s, 102, "inspect_document", json!({})); + assert_eq!(r["isError"], false); +} + +// ---------- remote ---------- + +/// A fake control server: answers `document.inspect`, echoes `engine.execute`, errors otherwise. +fn fake_app() -> (String, std::thread::JoinHandle>) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let h = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + let mut out = stream.try_clone().unwrap(); + let mut seen = vec![]; + for line in BufReader::new(stream).lines() { + let msg: Value = serde_json::from_str(&line.unwrap()).unwrap(); + let reply = match msg["method"].as_str().unwrap() { + "document.inspect" => json!({"ok": true, "result": {"title": "Remote", "layers": []}}), + "engine.execute" => json!({"ok": true, "result": {"echo": msg["params"]}}), + "ui.inspect" => json!({"ok": true, "result": {"tool": "selection"}}), + _ => json!({"ok": false, "error": "nope"}), + }; + let mut reply = reply; + reply["id"] = msg["id"].clone(); + writeln!(out, "{reply}").unwrap(); + seen.push(msg); + } + seen + }); + (addr, h) +} + +#[test] +fn remote_forwards_methods() { + let (addr, h) = fake_app(); + let mut s = Server::new(Box::new(Remote::connect(&addr).unwrap())); + let r = call(&mut s, 1, "inspect_document", json!({})); + assert!(text_of(&r).contains("Remote")); + let r = call(&mut s, 2, "run_command", json!({"command": "object.group"})); + assert!(text_of(&r).contains("object.group"), "{r}"); + let r = call(&mut s, 3, "inspect_ui", json!({})); + assert_eq!(r["isError"], false, "{r}"); + let r = call(&mut s, 4, "open_panel", json!({"panel": "Layers"})); + assert_eq!(r["isError"], true); + assert!(text_of(&r).contains("nope")); + drop(s); + let seen = h.join().unwrap(); + let methods: Vec<&str> = seen.iter().map(|m| m["method"].as_str().unwrap()).collect(); + assert_eq!(methods, ["document.inspect", "engine.execute", "ui.inspect", "ui.set"]); + assert_eq!(seen[1]["params"], json!({"command": "object.group", "params": {}})); +} + +#[test] +fn remote_connect_fails_fast() { + // Bind then drop to get a port that's (almost certainly) closed. + let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); + assert!(Remote::connect(&format!("127.0.0.1:{port}")).is_err()); +} + +#[test] +fn headless_backend_methods() { + let mut h = Headless::new(); + assert!(h.call("document.inspect", json!({})).is_err(), "no document yet"); + h.call("engine.execute", json!({"command": "file.new", "params": {"width": 300, "height": 200}})).unwrap(); + let r = h.call("ui.render", json!({"scale": 2})).unwrap(); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(600), Some(400))); + let cmds = h.call("engine.commands", json!({})).unwrap(); + assert!(cmds.as_array().unwrap().iter().any(|c| c["id"] == "file.export")); + assert!(h.call("ui.tool.list", json!({})).unwrap().is_array()); + assert!(h.call("ui.resize", json!({})).is_err()); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs new file mode 100644 index 00000000..7fa51dda --- /dev/null +++ b/crates/mcp/src/tools.rs @@ -0,0 +1,575 @@ +//! 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; + +/// 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 } + } + 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 string(desc: &str) -> Value { + json!({"type": "string", "description": desc}) +} +fn paint_schema(what: &str) -> Value { + json!({ + "description": format!("{what}: \"#rrggbb\", \"none\", [r,g,b] (0..1), {{\"c\",\"m\",\"y\",\"k\"}}, {{\"gray\"}}, or a full paint.setFill params object ({{\"gradient\":…}} / {{\"swatch\":name}})"), + "anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "number"}}, {"type": "object"}] + }) +} +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}, + }) +} + +/// All tools, in `tools/list` order. +pub fn tool_definitions() -> Vec { + let empty = || obj(json!({}), &[]); + vec![ + tool( + "list_commands", + "List commands", + "List DrawCraft commands (id, label, menu path, shortcut, parameter description, enabled state). Every editing action is a command; run any of them with run_command. Coordinates are points in document space, y down, origin at the first artboard's top-left.", + obj( + json!({"filter": string("Case-insensitive substring matched against id, label and menu path (e.g. \"align\", \"Object\")"), "enabledOnly": {"type": "boolean", "description": "Only commands that can run right now"}}), + &[], + ), + true, + ), + tool( + "run_command", + "Run command", + "Execute any DrawCraft command by id with JSON params (see list_commands for ids and params). Examples: {\"command\":\"object.group\"}, {\"command\":\"object.align\",\"params\":{\"align\":\"left\"}}, {\"command\":\"file.new\",\"params\":{\"width\":800,\"height\":600}}. Returns the command's result (e.g. {id} for creation commands).", + obj( + json!({"command": string("Command id, e.g. shape.rectangle, object.group, paint.setFill"), "params": {"type": "object", "description": "Command parameters"}}), + &["command"], + ), + false, + ), + tool( + "inspect_document", + "Inspect document", + "Summary of the active document: artboards, layer tree (ids, names, kinds, bounds, fill/stroke), selection, undo history, current tool and default paint.", + empty(), + true, + ), + tool( + "inspect_ui", + "Inspect UI", + "UI state of the running desktop app: tool and options, panels, dialogs, view (zoom/center), window, documents, perf. Desktop app only.", + empty(), + true, + ), + tool( + "select_tool", + "Select tool", + "Switch the active tool (as clicking it in the Tools panel). Ids: selection, directSelection, groupSelection, pen, rectangle, roundedRectangle, ellipse, polygon, star, lineSegment, …", + obj(json!({"tool": string("Tool id")}), &["tool"]), + false, + ), + tool( + "pointer_gesture", + "Pointer gesture", + "Drive the active tool with mouse events in document coordinates, exactly like the mouse would (e.g. a rectangle drag: down at (100,100), drag to (200,150), up at (200,150)). Optionally selects `tool` first.", + 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("Document x (pt)"), + "y": num("Document y (pt)"), + "mods": mods_schema(), + }), &["kind", "x", "y"]), + }, + "mods": mods_schema(), + }), + &["events"], + ), + false, + ), + tool( + "draw_path", + "Draw path", + "Create a path from points ([[x,y],…] or [{x,y,in?:[x,y],out?:[x,y]},…] with Bézier handles) or SVG path data `d`, then apply optional paint. Returns the new object id.", + obj( + json!({ + "points": {"type": "array", "description": "Anchors: [x,y] pairs or {x, y, in?, out?, smooth?} objects", "items": {"anyOf": [{"type": "array", "items": {"type": "number"}, "minItems": 2}, {"type": "object"}]}}, + "d": string("SVG path data (alternative to points), e.g. \"M0 0 L100 0 L50 80 Z\""), + "closed": {"type": "boolean", "description": "Close the path (points only)"}, + "fill": paint_schema("Fill"), + "stroke": paint_schema("Stroke"), + "strokeWidth": num("Stroke weight in pt"), + }), + &[], + ), + false, + ), + tool( + "draw_shape", + "Draw shape", + "Create a live shape and apply optional paint. rectangle/ellipse: x, y, width, height (rectangle: radius = corner radius). polygon: cx, cy, radius, sides. star: cx, cy, radius1, radius2, points. line: x1, y1, x2, y2. Returns the new object id.", + obj( + json!({ + "shape": {"type": "string", "enum": ["rectangle", "ellipse", "polygon", "star", "line"]}, + "x": num("Left (rectangle/ellipse)"), + "y": num("Top (rectangle/ellipse)"), + "width": num("Width (rectangle/ellipse)"), + "height": num("Height (rectangle/ellipse)"), + "radius": num("Corner radius (rectangle) or radius (polygon)"), + "cx": num("Centre x (polygon/star)"), + "cy": num("Centre y (polygon/star)"), + "sides": {"type": "integer", "minimum": 3, "description": "Polygon sides (default 6)"}, + "radius1": num("Star outer radius"), + "radius2": num("Star inner radius"), + "points": {"type": "integer", "minimum": 3, "description": "Star points (default 5)"}, + "rotation": num("Rotation in degrees (polygon/star)"), + "x1": num("Line start x"), + "y1": num("Line start y"), + "x2": num("Line end x"), + "y2": num("Line end y"), + "fill": paint_schema("Fill"), + "stroke": paint_schema("Stroke"), + "strokeWidth": num("Stroke weight in pt"), + }), + &["shape"], + ), + false, + ), + tool( + "set_paint", + "Set paint", + "Set fill, stroke and/or stroke weight on the selection (or `ids`) and as the default for new art.", + obj( + json!({ + "fill": paint_schema("Fill"), + "stroke": paint_schema("Stroke"), + "strokeWidth": num("Stroke weight in pt"), + "ids": {"type": "array", "items": {"type": "integer"}, "description": "Object ids (default: the selection)"}, + }), + &[], + ), + false, + ), + tool( + "press_key", + "Press key", + "Send a key press with modifiers, e.g. {key:\"Z\", mods:{cmd:true}} (undo), {key:\"Escape\"}, {key:\"Enter\"}. In the desktop app it goes through the real keyboard path; headless, it runs the command or tool bound to that shortcut.", + obj( + json!({"key": string("Key name: A–Z, 0–9, Enter, Escape, Delete, Backspace, Tab, Space, Left/Right/Up/Down, [ ]…"), "mods": mods_schema()}), + &["key"], + ), + false, + ), + tool( + "type_text", + "Type text", + "Type text into the focused widget (text tool, dialog field). Desktop app only.", + obj(json!({"text": string("Text to type")}), &["text"]), + false, + ), + tool( + "invoke_menu", + "Invoke menu", + "Invoke a menu item by its command id (same ids as list_commands, including UI-only commands like view.zoomIn or window.* in the desktop app).", + obj(json!({"command": string("Menu command id"), "params": {"type": "object"}}), &["command"]), + false, + ), + tool( + "open_panel", + "Open panel", + "Open a panel in the desktop app's dock (e.g. Layers, Swatches, Stroke, Align, Pathfinder, Transform). Desktop app only.", + obj(json!({"panel": string("Panel name")}), &["panel"]), + false, + ), + tool( + "screenshot", + "Screenshot", + "Render the active artboard to PNG and return it as an image (optionally also saving it to `path`). With window:true (desktop app only) captures the whole app window instead.", + obj( + json!({ + "path": string("Also write the PNG here"), + "scale": num("Pixels per point (default 1)"), + "artboard": {"type": "integer", "minimum": 0, "description": "Artboard index (default 0)"}, + "window": {"type": "boolean", "description": "Capture the app window (desktop app only)"}, + }), + &[], + ), + true, + ), + tool( + "open_file", + "Open file", + "Open a .drawcraft or .svg file as a new, active document.", + obj(json!({"path": string("File path")}), &["path"]), + false, + ), + tool( + "save_file", + "Save file", + "Save the active document in the native .drawcraft format.", + obj(json!({"path": string("Destination (default: the document's current path)")}), &[]), + false, + ), + tool( + "export", + "Export", + "Export the active document: svg (artboard viewBox), png (rendered artboard) or drawcraft (native).", + obj( + json!({ + "format": {"type": "string", "enum": ["svg", "png", "drawcraft"], "description": "Default: from the path's extension"}, + "path": string("Destination file"), + "scale": num("PNG pixels per point (default 1)"), + }), + &["path"], + ), + false, + ), + tool("undo", "Undo", "Undo the last change (Edit → Undo).", empty(), false), + tool("redo", "Redo", "Redo the last undone change (Edit → Redo).", 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}` needs the desktop app: run `drawcraft --control 7979` and start the server with `drawcraft-cli mcp --connect 127.0.0.1:7979`" + )) + } +} + +/// Paint argument → `paint.setFill` / `paint.setStroke` params. +fn paint_params(v: &Value) -> Result { + Ok(match v { + Value::Null => json!({"none": true}), + Value::String(s) if s.eq_ignore_ascii_case("none") => json!({"none": true}), + Value::String(_) | Value::Array(_) => json!({"color": v}), + Value::Object(o) if ["color", "none", "swatch", "gradient"].iter().any(|k| o.contains_key(*k)) => v.clone(), + Value::Object(_) => json!({"color": v}), + other => return Err(format!("unsupported paint value {other}")), + }) +} + +fn apply_paint(b: &mut dyn Backend, a: &Args, ids: Option) -> Result { + let with_ids = |mut p: Value| { + if let Some(ids) = &ids { + p["ids"] = ids.clone(); + } + p + }; + let mut applied = Map::new(); + if let Some(f) = a.get("fill") { + exec(b, "paint.setFill", with_ids(paint_params(f)?))?; + applied.insert("fill".into(), f.clone()); + } + if let Some(s) = a.get("stroke") { + exec(b, "paint.setStroke", with_ids(paint_params(s)?))?; + applied.insert("stroke".into(), s.clone()); + } + if let Some(w) = a.get("strokeWidth") { + let w = w.as_f64().ok_or("`strokeWidth` must be a number")?; + exec(b, "stroke.set", with_ids(json!({"weight": w})))?; + applied.insert("strokeWidth".into(), json!(w)); + } + Ok(Value::Object(applied)) +} + +fn created_id(r: &Value) -> Option { + r.get("id").filter(|v| v.is_u64()).map(|id| json!([id])) +} + +fn draw_path(b: &mut dyn Backend, a: &Args) -> Result { + let mut p = json!({}); + if let Some(d) = a.get("d").and_then(Value::as_str) { + p["d"] = json!(d); + } else { + let pts = a.get("points").and_then(Value::as_array).ok_or("give `points` or `d`")?; + let mut anchors = vec![]; + for (i, pt) in pts.iter().enumerate() { + let anchor = match pt { + Value::Array(xy) if xy.len() >= 2 && xy[0].is_number() && xy[1].is_number() => json!({"x": xy[0], "y": xy[1]}), + Value::Object(o) if o.get("x").is_some_and(Value::is_number) && o.get("y").is_some_and(Value::is_number) => pt.clone(), + _ => return Err(format!("point {i} must be [x,y] or {{x,y,…}}")), + }; + anchors.push(anchor); + } + if anchors.is_empty() { + return Err("`points` is empty".into()); + } + p["anchors"] = Value::Array(anchors); + p["closed"] = json!(a.get("closed").and_then(Value::as_bool).unwrap_or(false)); + } + let r = exec(b, "path.create", p)?; + let paint = apply_paint(b, a, created_id(&r))?; + Ok(json!({"id": r.get("id"), "paint": paint})) +} + +fn draw_shape(b: &mut dyn Backend, a: &Args) -> Result { + let shape = req_str(a, "shape")?; + let cmd = match shape { + "rectangle" | "rect" => "shape.rectangle", + "ellipse" | "circle" => "shape.ellipse", + "polygon" => "shape.polygon", + "star" => "shape.star", + "line" => "shape.line", + other => return Err(format!("unknown shape `{other}` (rectangle, ellipse, polygon, star, line)")), + }; + let mut p = a.clone(); + for k in ["shape", "fill", "stroke", "strokeWidth"] { + p.remove(k); + } + let r = exec(b, cmd, Value::Object(p))?; + let paint = apply_paint(b, a, created_id(&r))?; + Ok(json!({"id": r.get("id"), "shape": shape, "paint": paint})) +} + +fn screenshot(b: &mut dyn Backend, a: &Args) -> Result { + let path = a.get("path").and_then(Value::as_str); + if a.get("window").and_then(Value::as_bool) == Some(true) { + need_ui(b, "screenshot {window:true}")?; + // The app writes the capture to disk (loopback: same machine), then we read it back. + let tmp; + let target = match path { + Some(p) => p, + None => { + tmp = std::env::temp_dir().join(format!("drawcraft-window-{}.png", std::process::id())).to_string_lossy().to_string(); + &tmp + } + }; + let r = b.call("ui.screenshot", json!({"path": target}))?; + let png = std::fs::read(target).map_err(|e| format!("read {target}: {e}"))?; + if path.is_none() { + std::fs::remove_file(target).ok(); + } + return Ok(image_result(&png, json!({"window": true, "width": r.get("width"), "height": r.get("height"), "path": path}))); + } + let mut p = json!({}); + for k in ["scale", "artboard"] { + if let Some(v) = a.get(k) { + p[k] = v.clone(); + } + } + let r = b.call("ui.render", p)?; + let b64 = r.get("pngBase64").and_then(Value::as_str).ok_or("renderer returned no image")?; + if let Some(path) = path { + let png = drawcraft_format::base64_decode(b64).ok_or("renderer returned bad base64")?; + std::fs::write(path, png).map_err(|e| format!("write {path}: {e}"))?; + } + Ok(ToolResult { + content: vec![ + json!({"type": "image", "data": b64, "mimeType": "image/png"}), + json!({"type": "text", "text": json!({"width": r.get("width"), "height": r.get("height"), "path": path}).to_string()}), + ], + is_error: false, + }) +} + +fn image_result(png: &[u8], info: Value) -> ToolResult { + ToolResult { + content: vec![ + json!({"type": "image", "data": drawcraft_format::base64_encode(png), "mimeType": "image/png"}), + json!({"type": "text", "text": info.to_string()}), + ], + is_error: false, + } +} + +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 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})) + } + "run_command" | "invoke_menu" => { + let cmd = req_str(a, "command")?; + let params = match a.get("params") { + None | Some(Value::Null) => json!({}), + Some(v @ Value::Object(_)) => v.clone(), + Some(_) => return Err("`params` must be an object".into()), + }; + let method = if name == "invoke_menu" && b.has_ui() { "ui.menu.invoke" } else { "engine.execute" }; + j(b.call(method, json!({"command": cmd, "params": params}))?) + } + "inspect_document" => j(b.call("document.inspect", json!({}))?), + "inspect_ui" => { + need_ui(b, name)?; + j(b.call("ui.inspect", json!({}))?) + } + "select_tool" => j(b.call("ui.tool.select", json!({"tool": req_str(a, "tool")?}))?), + "pointer_gesture" => { + 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}))?; + } + let events: Vec = events + .iter() + .map(|e| { + let mut e = e.clone(); + e["space"] = json!("doc"); + e + }) + .collect(); + let mut p = json!({"events": events}); + if let Some(m) = a.get("mods") { + p["mods"] = m.clone(); + } + j(b.call("ui.pointer", p)?) + } + "draw_path" => j(draw_path(b, a)?), + "draw_shape" => j(draw_shape(b, a)?), + "set_paint" => { + if !["fill", "stroke", "strokeWidth"].iter().any(|k| a.contains_key(*k)) { + return Err("give at least one of `fill`, `stroke`, `strokeWidth`".into()); + } + let ids = a.get("ids").cloned(); + j(json!({"applied": apply_paint(b, a, ids)?})) + } + "press_key" => { + let mut p = json!({"key": req_str(a, "key")?}); + if let Some(Value::Object(m)) = a.get("mods") { + for (k, v) in m { + p[k.as_str()] = v.clone(); + } + } + j(b.call("ui.key", p)?) + } + "type_text" => { + need_ui(b, name)?; + j(b.call("ui.text", json!({"text": req_str(a, "text")?}))?) + } + "open_panel" => { + need_ui(b, name)?; + j(b.call("ui.set", json!({"panel": req_str(a, "panel")?}))?) + } + "screenshot" => screenshot(b, a), + "open_file" => j(b.call("app.open", json!({"path": req_str(a, "path")?}))?), + "save_file" => j(b.call("app.save", json!({"path": a.get("path").and_then(Value::as_str)}))?), + "export" => { + let path = req_str(a, "path")?; + let fmt = a + .get("format") + .and_then(Value::as_str) + .map(str::to_ascii_lowercase) + .unwrap_or_else(|| std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default()); + if !["svg", "png", "drawcraft"].contains(&fmt.as_str()) { + return Err(format!("unknown export format `{fmt}` (svg, png, drawcraft)")); + } + let scale = a.get("scale").and_then(Value::as_f64).unwrap_or(1.0); + // The desktop app exports native files through Save (which also sets the document path). + if fmt == "drawcraft" && b.has_ui() { + return j(b.call("app.save", json!({"path": path}))?); + } + j(b.call("app.export", json!({"format": fmt, "path": path, "scale": scale}))?) + } + "undo" => j(exec(b, "edit.undo", json!({}))?), + "redo" => j(exec(b, "edit.redo", 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}")), + } +}