Merge pull request #4022 from dotCooCoo/feat/mcp-alias-tools-and-window-max-side

MCP: each alias tool names its pair, and ui_screenshot takes max_side
This commit is contained in:
BFlat
2026-10-24 20:56:36 +07:00
committed by GitHub
3 changed files with 161 additions and 32 deletions
+78
View File
@@ -210,6 +210,84 @@ impl Backend for Panics {
"test".into()
}
}
/// An app whose window capture is a 1200 × 800 PNG.
struct Window(Vec<u8>);
impl Backend for Window {
fn call(&mut self, method: &str, _: Value) -> Result<Value, String> {
match method {
"ui.screenshot" => Ok(json!({"pngBase64": vectorcraft_format::base64_encode(&self.0), "width": 1200, "height": 800})),
_ => Err(format!("unexpected {method}")),
}
}
fn has_ui(&self) -> bool {
true
}
fn describe(&self) -> String {
"window".into()
}
}
/// `ui_screenshot` and `screenshot {window: true}` take `max_side`, which scales the returned
/// image down to it and reports the capture's own size; without it the capture comes back as sent.
#[test]
fn window_captures_take_max_side() {
let mut capture = std::io::Cursor::new(Vec::new());
image::RgbaImage::from_fn(1200, 800, |x, y| image::Rgba([(x % 256) as u8, (y % 256) as u8, 90, 255]))
.write_to(&mut capture, image::ImageFormat::Png)
.unwrap();
let capture = capture.into_inner();
let mut s = Server::new(Box::new(Window(capture.clone())));
let image_of = |r: &Value| vectorcraft_format::base64_decode(r["content"][0]["data"].as_str().unwrap()).unwrap();
let info = |r: &Value| -> Value { serde_json::from_str(r["content"][1]["text"].as_str().unwrap()).unwrap() };
for (name, mut args) in [("ui_screenshot", json!({})), ("screenshot", json!({"window": true}))] {
let r = call(&mut s, name, args.clone());
assert_eq!(r["isError"], false, "{name}: {r}");
assert_eq!(image_of(&r), capture, "{name}: the capture as sent");
assert!(info(&r).get("capturedWidth").is_none(), "{name}: {r}");
args["max_side"] = json!(300);
let r = call(&mut s, name, args.clone());
assert_eq!(r["isError"], false, "{name}: {r}");
let img = image::load_from_memory(&image_of(&r)).unwrap();
assert_eq!((img.width(), img.height()), (300, 200), "{name}");
let i = info(&r);
assert_eq!((i["width"].clone(), i["height"].clone()), (json!(300), json!(200)), "{name}: {i}");
assert_eq!((i["capturedWidth"].clone(), i["capturedHeight"].clone()), (json!(1200), json!(800)), "{name}: {i}");
// A capture that already fits comes back as sent.
args["max_side"] = json!(4096);
let r = call(&mut s, name, args.clone());
assert_eq!(image_of(&r), capture, "{name}");
assert_eq!(info(&r)["capturedWidth"], 1200, "{name}");
for bad in [json!(0), json!(4097), json!("300"), json!(1.5)] {
args["max_side"] = bad.clone();
assert_eq!(call(&mut s, name, args.clone())["isError"], true, "{name}: {bad}");
}
}
// An artboard render is sized with `scale`.
let r = call(&mut s, "screenshot", json!({"max_side": 300}));
assert_eq!(r["isError"], true, "{r}");
assert!(r["content"][0]["text"].as_str().unwrap().contains("`scale`"), "{r}");
}
/// Each tool with another name for the same call names it in its description.
#[test]
fn alias_tools_name_their_pair() {
let tools = tool_definitions();
let description = |name: &str| tools.iter().find(|t| t["name"] == name).unwrap()["description"].as_str().unwrap().to_string();
let names = |text: &str, name: &str| text.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_')).any(|word| word == name);
for (a, b) in [
("command_list", "list_commands"),
("command_run", "run_command"),
("doc_inspect", "inspect_document"),
("ui_inspect", "inspect_ui"),
("ui_screenshot", "screenshot"),
("undo", "redo"),
] {
assert!(names(&description(a), b), "{a} names {b}: {}", description(a));
assert!(names(&description(b), a), "{b} names {a}: {}", description(b));
}
assert!(description("ui_screenshot").contains("window:true"));
}
#[test]
fn conventions_tool_panics_are_tool_errors() {
let mut s = Server::new(Box::new(Panics));
+77 -28
View File
@@ -116,7 +116,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"command_list",
"List commands",
"Discover command ids, labels, parameter descriptions and enabled state. Returns an array. The whole catalogue is large: narrow it with filter (or query), menu, and brief.",
"Discover command ids, labels, parameter descriptions and enabled state. Returns an array. The whole catalogue is large: narrow it with filter (or query), menu, and brief. list_commands lists the same commands; it takes enabledOnly instead of enabled_only and returns {count, commands}.",
obj(
json!({"filter": string("Case-insensitive id, label or menu substring"), "query": string("Same as filter"), "menu": string(MENU_FILTER), "enabled_only": {"type":"boolean"}, "brief": {"type":"boolean", "description": BRIEF}}),
&[],
@@ -126,7 +126,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"command_run",
"Run a command",
"Run an engine command by id with its JSON params. Command parameters follow the registry's descriptions.",
"Run an engine command by id with its JSON params. Command parameters follow the registry's descriptions. run_command runs it the same way; it takes the id as command instead of id.",
obj(json!({"id": string("Command id"), "params": {"type":"object"}}), &["id"]),
false,
),
@@ -143,7 +143,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"doc_inspect",
"Inspect document",
"Summary of the active document, including artboards, layer tree, selection and history. On a large document scope it: depth 0 for the layer skeleton, ids for just those nodes, kind for every node of one kind, limit/offset to page.",
"Summary of the active document, including artboards, layer tree, selection and history. On a large document scope it: depth 0 for the layer skeleton, ids for just those nodes, kind for every node of one kind, limit/offset to page. inspect_document returns the same summary.",
obj(inspect_props(), &[]),
true,
),
@@ -157,18 +157,24 @@ pub fn tool_definitions() -> Vec<Value> {
),
true,
),
tool("ui_inspect", "Inspect the interface", "Inspect the connected desktop interface; returns a tool error in headless mode.", empty(), true),
tool(
"ui_inspect",
"Inspect the interface",
"Inspect the connected desktop interface; returns a tool error in headless mode. The same call as inspect_ui.",
empty(),
true,
),
tool(
"ui_screenshot",
"Capture the interface",
"Capture the connected desktop window as inline PNG; requires a running desktop app.",
empty(),
"Capture the connected desktop window as inline PNG; requires a running desktop app. The same call as screenshot with window:true. max_side scales the returned image down to at most that many pixels on its longer side (1..4096; default: the full capture).",
obj(json!({"max_side": {"type":"integer","minimum":1,"maximum":4096,"description":MAX_SIDE_WINDOW}}), &[]),
true,
),
tool(
"list_commands",
"List commands",
"List VectorCraft commands (id, label, menu path, shortcut, parameter description, enabled state). Every editing action is a command; run any of them with run_command. Coordinates are points in document space, y down, origin at the first artboard's top-left.",
"List VectorCraft commands (id, label, menu path, shortcut, parameter description, enabled state). Every editing action is a command; run any of them with run_command. Coordinates are points in document space, y down, origin at the first artboard's top-left. command_list lists the same commands as a bare array and takes enabled_only.",
obj(
json!({"filter": string("Case-insensitive substring matched against id, label and menu path (e.g. \"align\", \"Object\")"), "query": string("Same as filter"), "menu": string(MENU_FILTER), "enabledOnly": {"type": "boolean", "description": "Only commands that can run right now"}, "brief": {"type": "boolean", "description": BRIEF}}),
&[],
@@ -178,7 +184,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"run_command",
"Run command",
"Execute any VectorCraft command by id with JSON params (see list_commands for ids and params). Examples: {\"command\":\"object.group\"}, {\"command\":\"object.align\",\"params\":{\"horizontal\":\"left\"}}, {\"command\":\"file.new\",\"params\":{\"width\":800,\"height\":600}}. Returns the command's result (e.g. {id} for creation commands).",
"Execute any VectorCraft command by id with JSON params (see list_commands for ids and params). Examples: {\"command\":\"object.group\"}, {\"command\":\"object.align\",\"params\":{\"horizontal\":\"left\"}}, {\"command\":\"file.new\",\"params\":{\"width\":800,\"height\":600}}. Returns the command's result (e.g. {id} for creation commands). command_run runs it the same way and takes the id as id.",
obj(
json!({"command": string("Command id, e.g. shape.rectangle, object.group, paint.setFill"), "params": {"type": "object", "description": "Command parameters"}}),
&["command"],
@@ -188,14 +194,14 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"inspect_document",
"Inspect document",
"Summary of the active document: artboards, layer tree (ids, names, kinds, bounds, fill/stroke), selection, undo history, current tool and default paint. On a large document pass depth: 0 for the layer skeleton (truncated levels report childCount), then locate objects with run_command document.find and read them with document.node.",
"Summary of the active document: artboards, layer tree (ids, names, kinds, bounds, fill/stroke), selection, undo history, current tool and default paint. On a large document pass depth: 0 for the layer skeleton (truncated levels report childCount), then locate objects with run_command document.find and read them with document.node. doc_inspect returns the same summary.",
obj(inspect_props(), &[]),
true,
),
tool(
"inspect_ui",
"Inspect UI",
"UI state of the running desktop app: tool and options, panels, dialogs, view (zoom/center), window, documents, perf. Desktop app only.",
"UI state of the running desktop app: tool and options, panels, dialogs, view (zoom/center), window, documents, perf. Desktop app only. The same call as ui_inspect.",
empty(),
true,
),
@@ -328,13 +334,14 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"screenshot",
"Screenshot",
"Render the active artboard to PNG and return it as an image (optionally also saving it to `path`). With window:true (desktop app only) captures the whole app window instead.",
"Render the active artboard to PNG and return it as an image (optionally also saving it to `path`). With window:true (desktop app only) captures the whole app window instead, as ui_screenshot does; max_side then scales the returned image down (a file written to `path` keeps the full capture).",
obj(
json!({
"path": string("Also write the PNG here"),
"scale": num("Pixels per point (default 1)"),
"artboard": {"type": "integer", "minimum": 0, "description": "Artboard index (default 0)"},
"window": {"type": "boolean", "description": "Capture the app window (desktop app only)"},
"max_side": {"type": "integer", "minimum": 1, "maximum": 4096, "description": MAX_SIDE_WINDOW},
}),
&[],
),
@@ -489,8 +496,8 @@ pub fn tool_definitions() -> Vec<Value> {
),
false,
),
tool("undo", "Undo", "Undo the last change (Edit → Undo).", empty(), false),
tool("redo", "Redo", "Redo the last undone change (Edit → Redo).", empty(), false),
tool("undo", "Undo", "Undo the last change (Edit → Undo). redo reapplies it.", empty(), false),
tool("redo", "Redo", "Redo the last undone change (Edit → Redo): the change the last undo took back.", empty(), false),
]
}
@@ -823,6 +830,10 @@ fn screenshot(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
let path = a.get("path").and_then(Value::as_str).map(absolute_path).transpose()?;
let path = path.as_deref();
let window = a.get("window").and_then(Value::as_bool) == Some(true);
let max = max_side(a)?;
if max.is_some() && !window {
return Err("`max_side` applies to a window capture (`window: true`); size an artboard render with `scale`".into());
}
// The backend writes the file, where its automation roots allow, and sends the image back too
// (`data`): this process writes nothing.
let mut p = json!({"data": true});
@@ -846,26 +857,60 @@ fn screenshot(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
// (loopback: the same machine).
None => match path {
Some(path) => std::fs::read(path).map_err(|e| format!("read {path}: {e}"))?,
None if window => return old_window_capture(b),
None if window => return old_window_capture(b, max),
None => return Err("the renderer returned no image".into()),
},
};
let info = if window {
json!({"window": true, "width": r.get("width"), "height": r.get("height"), "path": path})
if window {
let mut info = json!({"window": true, "width": r.get("width"), "height": r.get("height"), "path": path});
let png = fit_capture(png, max, &mut info)?;
Ok(image_result(&png, info))
} else {
json!({"width": r.get("width"), "height": r.get("height"), "path": path})
};
Ok(image_result(&png, info))
Ok(image_result(&png, json!({"width": r.get("width"), "height": r.get("height"), "path": path})))
}
}
/// The window of an app from before `ui.screenshot {data}`, captured through a temporary file.
fn old_window_capture(b: &mut dyn Backend) -> Result<ToolResult, String> {
fn old_window_capture(b: &mut dyn Backend, max: Option<u32>) -> Result<ToolResult, String> {
let tmp = std::env::temp_dir().join(format!("vectorcraft-window-{}.png", std::process::id())).to_string_lossy().to_string();
let r = b.call("ui.screenshot", json!({"path": tmp}))?;
let png = std::fs::read(&tmp).map_err(|e| format!("read {tmp}: {e}"))?;
// Best effort: a temporary file left behind is harmless.
std::fs::remove_file(&tmp).ok();
Ok(image_result(&png, json!({"window": true, "width": r.get("width"), "height": r.get("height"), "path": Value::Null})))
let mut info = json!({"window": true, "width": r.get("width"), "height": r.get("height"), "path": Value::Null});
let png = fit_capture(png, max, &mut info)?;
Ok(image_result(&png, info))
}
/// `max_side`: absent, or an integer in 1..4096.
fn max_side(a: &Args) -> Result<Option<u32>, String> {
match a.get("max_side") {
None => Ok(None),
Some(v) => Ok(Some(
v.as_u64().filter(|v| (1..=4096).contains(v)).and_then(|v| u32::try_from(v).ok()).ok_or("`max_side` must be an integer in 1..4096")?,
)),
}
}
/// A window capture with `max_side`: scaled down when its longer side is larger, with its own size
/// in `capturedWidth` / `capturedHeight` and the returned image's in `width` / `height`. Without
/// `max_side` the capture and `info` stay as they are.
fn fit_capture(png: Vec<u8>, max: Option<u32>, info: &mut Value) -> Result<Vec<u8>, String> {
let Some(max) = max else { return Ok(png) };
let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| format!("read capture: {e}"))?;
info["capturedWidth"] = json!(img.width());
info["capturedHeight"] = json!(img.height());
if img.width().max(img.height()) <= max {
info["width"] = json!(img.width());
info["height"] = json!(img.height());
return Ok(png);
}
let img = img.thumbnail(max, max);
info["width"] = json!(img.width());
info["height"] = json!(img.height());
let mut out = std::io::Cursor::new(Vec::new());
img.write_to(&mut out, image::ImageFormat::Png).map_err(|e| format!("encode capture: {e}"))?;
Ok(out.into_inner())
}
fn image_result(png: &[u8], info: Value) -> ToolResult {
@@ -882,6 +927,9 @@ fn image_result(png: &[u8], info: Value) -> ToolResult {
const MENU_FILTER: &str = "Case-insensitive substring of the menu path, its levels joined with \" > \" (e.g. \"Object\", \"Object > Path\")";
/// The `brief` argument of the command listings.
const BRIEF: &str = "Leave out each command's params description (the bulk of the catalogue); run command_list with the id as filter to read it";
/// The `max_side` argument of the window captures.
const MAX_SIDE_WINDOW: &str =
"Window capture only: longest side of the returned image in pixels; a larger capture is scaled down to it (default: the full capture)";
/// The command catalogue narrowed by the listing tools' arguments: `filter` (or `query`) on the
/// id, label and menu path, `menu` on the menu path, `enabledOnly`, and `brief` (no `params`
@@ -939,7 +987,11 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
}
"command_batch" => command_batch(b, a),
"render_preview" => render_preview(b, a),
"ui_screenshot" => screenshot(b, &Map::from_iter([("window".into(), json!(true))])),
"ui_screenshot" => {
let mut args = a.clone();
args.insert("window".into(), json!(true));
screenshot(b, &args)
}
"list_commands" => {
let all = b.call("engine.commands", json!({}))?;
let list = filter_commands(all, a);
@@ -1232,10 +1284,7 @@ fn command_batch(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
}
fn render_preview(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
let max = match a.get("max_side") {
None => 1024,
Some(v) => v.as_u64().filter(|v| (1..=4096).contains(v)).ok_or("`max_side` must be an integer in 1..4096")?,
};
let max = max_side(a)?.unwrap_or(1024);
let index = match a.get("artboard") {
None => 0,
Some(v) => v.as_u64().ok_or("`artboard` must be a 0-based artboard index")?,
@@ -1250,7 +1299,7 @@ fn render_preview(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
}
// The headless renderer clamps scale at 0.01. Bound its allocation even for
// a huge artboard, then downsize to the requested dimensions.
let scale = (max as f64 / width.max(height)).clamp(0.01, 1.0);
let scale = (f64::from(max) / width.max(height)).clamp(0.01, 1.0);
if width.max(height) * scale > 4096.0 {
return Err("artboard is too large for a bounded preview".into());
}
@@ -1258,7 +1307,7 @@ fn render_preview(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
let encoded = r.get("pngBase64").and_then(Value::as_str).ok_or("renderer returned no image")?;
let png = vectorcraft_format::base64_decode(encoded).ok_or("renderer returned bad base64")?;
let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).map_err(|e| format!("read preview: {e}"))?;
let img = if img.width() > max as u32 || img.height() > max as u32 { img.thumbnail(max as u32, max as u32) } else { img };
let img = if img.width() > max || img.height() > max { img.thumbnail(max, max) } else { img };
let mut out = std::io::Cursor::new(Vec::new());
img.write_to(&mut out, image::ImageFormat::Png).map_err(|e| format!("encode preview: {e}"))?;
Ok(image_result(out.get_ref(), json!({"width":img.width(),"height":img.height()})))
+6 -4
View File
@@ -9,7 +9,7 @@ It has two backends:
| Mode | How | What works |
|---|---|---|
| **Remote** | `vectorcraft-cli mcp --connect 127.0.0.1:7979` (the app must run with `vectorcraft --control 7979`) | Everything. Tool calls are forwarded over the [control protocol](control-protocol.md), so you watch the app change live. |
| **Headless** | `vectorcraft-cli mcp --headless` | An in-process engine session with a CPU renderer. Everything except the UI-only tools (`inspect_ui`, `type_text`, `open_panel`, `screenshot {window:true}`). |
| **Headless** | `vectorcraft-cli mcp --headless` | An in-process engine session with a CPU renderer. Everything except the UI-only tools (`inspect_ui` or `ui_inspect`, `type_text`, `open_panel`, `screenshot {window:true}` or `ui_screenshot`). |
With no flag, the server tries `127.0.0.1:7979` and falls back to headless. Logs go to stderr; stdout carries
only protocol messages.
@@ -347,12 +347,14 @@ The older names below remain listed because existing workflows use them.
| Tool | Arguments and result |
|---|---|
| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text); returns the command array. `list_commands` lists the same commands; it takes `enabledOnly` and returns `{count, commands}` |
| `command_run` | `id`, optional `params`; runs the command through the existing backend. An unknown key in `params` is an error that names it and the nearest key the command takes (`invalid parameters for `file.new`: unknown parameter `widht`; did you mean `width`?`), so a misspelt key no longer runs the command with its default (see [Parameter keys](#parameter-keys)). `run_command` runs a command the same way and takes its id as `command` |
| `command_batch` | `steps: [{id, params?}]` (at most 256), optional `stop_on_error` (default true), `atomic` and `label`; returns `completed`, `failed`, `results: [{ok, result\|error}]` and `created` (the ids the `draw_path` / `draw_shape` steps made, in order). A step's `id` is a command id, or `draw_path` / `draw_shape` with that tool's arguments as `params` (checked as `tools/call` checks them; the step's result is the tool's, so `"$N.id"` is the new object). Each edit has its own undo step, or with `atomic: true` the batch's edits are one undo step named `label` (else after its first edit; `edit.undoGroup.begin`/`end` underneath), and the edits made in a document that a step opens or creates (`document.open`, `file.new`) are one undo step there, named the same, also when no document was open before the batch. In a document that was open before the batch and that a step switches to (`document.activate`), each edit stays its own undo step; failures set `isError`. A param string `"$N.path"` or `"$last.path"` takes a value from an earlier step's result (see `run` below). Kept step results total at most 8 MiB; past that the batch stops with the reason. This is the MCP tool, not the engine command `command.batch` (that one takes `commands: [{command, params}]` and is what the Actions panel plays); use `command_run` with `command.batch` when replaying a recorded action |
| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text); returns the command array |
| `command_run` | `id`, optional `params`; runs the command through the existing backend. An unknown key in `params` is an error that names it and the nearest key the command takes (`invalid parameters for `file.new`: unknown parameter `widht`; did you mean `width`?`), so a misspelt key no longer runs the command with its default (see [Parameter keys](#parameter-keys)) |
| `command_batch` | `steps: [{id, params?}]` (at most 256), optional `stop_on_error` (default true), `atomic` and `label`; returns `completed`, `failed`, `results: [{ok, result\|error}]` and `created` (the ids the `draw_path` / `draw_shape` steps made, in order). A step's `id` is a command id, or `draw_path` / `draw_shape` with that tool's arguments as `params` (checked as `tools/call` checks them; the step's result is the tool's, so `"$N.id"` is the new object). Each edit has its own undo step, or with `atomic: true` the batch's edits are one undo step named `label` (else after its first edit; `edit.undoGroup.begin`/`end` underneath), and the edits made in a document that a step opens or creates (`document.open`, `file.new`) are one undo step there, named the same, also when no document was open before the batch. In a document that was open before the batch and that a step switches to (`document.activate`), each edit stays its own undo step; failures set `isError`. A param string `"$N.path"` or `"$last.path"` takes a value from an earlier step's result (see `run` below). Kept step results total at most 8 MiB; past that the batch stops with the reason. This is the MCP tool, not the engine command `command.batch` (that one takes `commands: [{command, params}]` and is what the Actions panel plays); use `command_run` with `command.batch` when replaying a recorded action |
| `doc_inspect` | Optional `depth`, `childLimit`, `ids`, `kind`, `limit`, `offset`; same summary as `inspect_document` |
| `render_preview` | Optional `max_side` (1–4096, default 1024) and `artboard` (0-based, default 0); inline PNG of that artboard without editing it, drawn as `export` draws a PNG with `background: "white"` at the scale that fits `max_side` (at most 1). Artboards too large to render within the allocation bound return a tool error |
| `ui_inspect`, `ui_screenshot` | Connected desktop state/window capture; tool errors in headless mode |
| `ui_inspect`, `ui_screenshot` | Connected desktop state/window capture; tool errors in headless mode. `ui_inspect` is the same call as `inspect_ui`, and `ui_screenshot` is `screenshot` with `window: true`. `ui_screenshot` takes an optional `max_side` (1–4096), as `screenshot {window: true}` does: a capture whose longer side is larger is scaled down to `max_side` pixels on that side, and the reply's text gives the returned size as `width`/`height` and the capture's as `capturedWidth`/`capturedHeight`. Without `max_side` the full capture comes back |
Every tool declares read-only, destructive, idempotent and open-world hints. File-writing
`screenshot`, `save_file` and `export` are conservatively marked as mutations. Unknown top-level
@@ -413,7 +415,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
| `type_text` | `{text}` | Remote only. |
| `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. |
| `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. |
| `screenshot` | `{path?, scale?, artboard?, window?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `window:true` captures the app window (remote only). With `path` the backend (the headless session or the app) writes the PNG there too. |
| `screenshot` | `{path?, scale?, artboard?, window?, max_side?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. |
| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`/`.epsf`, `.ps`, `.dxf`, `.emf`, `.wmf`, PNG, JPEG, GIF, WebP, TIFF, BMP, Targa (`.tga`/`.vda`/`.icb`/`.vst`), PSD/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). `run_command document.formats` lists the formats. |
| `save_file` | `{path?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). The app is sent `wait: true`: the call returns once the file is written, or with the error (such as a path outside the write root), even with Background Save on. When the save asks first (the question before replacing a file that opened with losses, or on macOS and Linux the file dialog for an untitled document without `path`), the reply is `{pending}` or `"cancelled"` as before, and the save that runs after the answer uses the Background Save preference. |
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `ai` (the PDF-compatible `.ai` that Save writes, which carries the document when every artboard is written in order), `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF and `.ai` write one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based). SVG, SVGZ and EPS write one file per chosen artboard. Rasters, DXF, EMF and WMF write one artboard, or one file per chosen artboard with `options: {"useArtboards": true}`. Rasters also take `options: {"rect": [x, y, width, height]}` (page points, y down): that area of the page at the given `ppi`/`scale`, without an artboard and without changing the document; it is refused with `artboard`, `range` or `useArtboards`, with a non-positive or non-finite size, and for non-raster formats. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |