M1.9: MCP server (designcraft-mcp) + designcraft-cli mcp

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