M1.9: MCP server (stdio JSON-RPC, remote + headless backends) and drawcraft-cli
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
874c0e6ca2
commit
873e9f5c99
|
|
@ -16,3 +16,6 @@ workspace = true
|
|||
[dependencies]
|
||||
drawcraft-mcp = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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<String> = 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<String> = 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<dyn Backend> = 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<String> = None;
|
||||
let mut steps: Vec<Step> = 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::<f64>().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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Value> = 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<Value> = 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::<Vec<_>>(), [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");
|
||||
}
|
||||
|
|
@ -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<PointerKind> {
|
||||
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<ToolKey> {
|
||||
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<Value, String> {
|
||||
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<Value> = 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<Value, String> {
|
||||
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<UiRequest>, out: &mut Vec<Value>) -> 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<Value, String> {
|
||||
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::<Vec<_>>()).unwrap_or_default();
|
||||
Ok(json!({"selection": sel, "tool": self.session.tool_id(), "requests": requests}))
|
||||
}
|
||||
|
||||
fn key(&mut self, p: &Value) -> Result<Value, String> {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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"<?xml") || bytes.starts_with(b"<svg") {
|
||||
let text = std::str::from_utf8(&bytes).map_err(|_| "SVG is not UTF-8".to_string())?;
|
||||
let (mut d, w) = drawcraft_svg::import_with_report(text).map_err(|e| e.to_string())?;
|
||||
d.title = name.clone();
|
||||
warnings = w;
|
||||
(d, false)
|
||||
} else {
|
||||
return Err(format!("headless mode can open .drawcraft and .svg files, not .{e}"));
|
||||
};
|
||||
let index = self.session.add_document(doc, keep_path.then(|| path.to_string()));
|
||||
Ok(json!({"index": index, "title": name, "warnings": warnings}))
|
||||
}
|
||||
|
||||
/// `app.save {path?}`: native format; remembers the path and clears the dirty flag.
|
||||
pub fn save(&mut self, p: &Value) -> Result<Value, String> {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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<dyn Backend>,
|
||||
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<String>) -> Value {
|
||||
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}})
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn new(backend: Box<dyn Backend>) -> Self {
|
||||
Self { backend, initialized: false }
|
||||
}
|
||||
|
||||
pub fn backend(&mut self) -> &mut dyn Backend {
|
||||
self.backend.as_mut()
|
||||
}
|
||||
|
||||
/// Whether the client has sent `notifications/initialized`.
|
||||
pub fn is_initialized(&self) -> bool {
|
||||
self.initialized
|
||||
}
|
||||
|
||||
/// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is
|
||||
/// the protocol stream).
|
||||
pub fn serve(&mut self, 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<String> {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(line) {
|
||||
Ok(Value::Array(batch)) => {
|
||||
// Batches were removed in 2025-06-18; still answer older clients sensibly.
|
||||
if batch.is_empty() {
|
||||
Some(error(Value::Null, INVALID_REQUEST, "empty batch"))
|
||||
} else {
|
||||
let replies: Vec<Value> = batch.into_iter().filter_map(|m| self.handle(m)).collect();
|
||||
(!replies.is_empty()).then_some(Value::Array(replies))
|
||||
}
|
||||
}
|
||||
Ok(msg) => self.handle(msg),
|
||||
Err(e) => Some(error(Value::Null, PARSE_ERROR, format!("parse error: {e}"))),
|
||||
};
|
||||
reply.map(|r| r.to_string())
|
||||
}
|
||||
|
||||
/// Handle one JSON-RPC message; `None` for notifications and responses.
|
||||
pub fn handle(&mut self, msg: Value) -> Option<Value> {
|
||||
let Value::Object(o) = &msg else { return Some(error(Value::Null, INVALID_REQUEST, "message must be an object")) };
|
||||
let id = o.get("id").cloned();
|
||||
let Some(method) = o.get("method").and_then(Value::as_str) else {
|
||||
// A response to a server→client request (we send none) — ignore; anything else is invalid.
|
||||
if o.contains_key("result") || o.contains_key("error") {
|
||||
return None;
|
||||
}
|
||||
return Some(error(id.unwrap_or(Value::Null), INVALID_REQUEST, "missing `method`"));
|
||||
};
|
||||
let params = o.get("params").cloned().unwrap_or(Value::Null);
|
||||
let Some(id) = id else {
|
||||
self.notification(method, ¶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<Value, (i64, String)> {
|
||||
match method {
|
||||
"initialize" => {
|
||||
let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION);
|
||||
let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION };
|
||||
Ok(json!({
|
||||
"protocolVersion": version,
|
||||
"capabilities": {"tools": {}, "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}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Value> = 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("<svg") && (svg.contains("<rect") || svg.contains("<path")), "{svg}");
|
||||
|
||||
// Save native, open it back.
|
||||
let dc = tmp("out.drawcraft");
|
||||
assert_eq!(call(&mut s, 7, "save_file", json!({"path": dc.to_str().unwrap()}))["isError"], false);
|
||||
let r = call(&mut s, 8, "open_file", json!({"path": dc.to_str().unwrap()}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
let r = call(&mut s, 9, "open_file", json!({"path": svg_path.to_str().unwrap()}));
|
||||
assert_eq!(r["isError"], false, "{r}");
|
||||
|
||||
// Resources.
|
||||
let v = rpc(&mut s, 10, "resources/list", json!({}));
|
||||
assert_eq!(v["result"]["resources"].as_array().unwrap().len(), 2);
|
||||
let v = rpc(&mut s, 11, "resources/read", json!({"uri": "drawcraft://document"}));
|
||||
let text = v["result"]["contents"][0]["text"].as_str().unwrap();
|
||||
assert!(serde_json::from_str::<Value>(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::<Value>(&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<Vec<Value>>) {
|
||||
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());
|
||||
}
|
||||
|
|
@ -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<Value>,
|
||||
pub is_error: bool,
|
||||
}
|
||||
|
||||
impl ToolResult {
|
||||
pub fn text(t: impl Into<String>) -> 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<String>) -> 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<Value> {
|
||||
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<String, Value>;
|
||||
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<Value>) -> Result<Value, String> {
|
||||
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<Value> {
|
||||
r.get("id").filter(|v| v.is_u64()).map(|id| json!([id]))
|
||||
}
|
||||
|
||||
fn draw_path(b: &mut dyn Backend, a: &Args) -> Result<Value, String> {
|
||||
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<Value, String> {
|
||||
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<ToolResult, String> {
|
||||
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::<Vec<_>>().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<ToolResult, String> {
|
||||
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<Value> = 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}")),
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue