mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
M1.9: MCP server (designcraft-mcp) + designcraft-cli mcp
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
91ae2b3cc9
commit
044534503c
Generated
+14
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 = ¶ms;
|
||||
match method {
|
||||
"engine.execute" | "ui.menu.invoke" | "command" => {
|
||||
let id = s(p, "command").or(s(p, "id")).ok_or("missing `command`")?.to_string();
|
||||
let params = match p.get("params") {
|
||||
None | Some(Value::Null) => json!({}),
|
||||
Some(v) => v.clone(),
|
||||
};
|
||||
self.exec(&id, ¶ms)
|
||||
}
|
||||
"engine.commands" => Ok(serde_json::to_value(self.session.commands()).unwrap_or_default()),
|
||||
"document.inspect" => self.exec("document.inspect", &json!({})),
|
||||
"ui.tool.select" => self.exec("tool.select", &json!({"tool": s(p, "tool").unwrap_or("")})),
|
||||
"ui.tool.list" => Ok(serde_json::to_value(TOOL_GROUPS).unwrap_or_default()),
|
||||
"ui.pointer" => self.pointer(p),
|
||||
"ui.key" => self.key(p),
|
||||
"ui.text" => self.text(p),
|
||||
"ui.render" => self.render(p),
|
||||
"app.open" => self.exec("file.open", &json!({"path": s(p, "path")})),
|
||||
"app.save" => self.exec("file.save", &json!({"path": s(p, "path")})),
|
||||
"app.export" => self.export(p),
|
||||
"app.quit" => Ok(Value::Null),
|
||||
m if m.starts_with("ui.") => Err(format!("`{m}` is not available headless; {NEEDS_APP}")),
|
||||
other => Err(format!("unknown method `{other}`")),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_ui(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn describe(&self) -> String {
|
||||
"headless (in-process engine, no window)".into()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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, ¶ms) {
|
||||
Ok(r) => response(id, r),
|
||||
Err((code, m)) => error(id, code, m),
|
||||
})
|
||||
}
|
||||
|
||||
fn notification(&mut self, method: &str) {
|
||||
match method {
|
||||
"notifications/initialized" => self.initialized = true,
|
||||
"notifications/cancelled" | "notifications/progress" | "notifications/roots/list_changed" => {}
|
||||
other => log::debug!("ignoring notification {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn request(&mut self, method: &str, params: &Value) -> Result<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}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
@@ -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"}
|
||||
```
|
||||
Reference in New Issue
Block a user