Merge pull request #328 from fahrim/mcp-small-wins

MCP small wins: panel ids, object reads via document.node, stdio sweep
This commit is contained in:
Brandon Thomas 2026-10-07 17:02:30 -04:00
commit 33a91ad140
7 changed files with 223 additions and 39 deletions

View File

@ -89,7 +89,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
- Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command.
- JSON-lines control channel with real egui pointer and keyboard injection.
- MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings.
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
- MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (identical reply bytes with one lookup instead of a full-tree serialize per read — ~437x faster leaf reads on a 2,000-object document, measured release-vs-release), and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads).
- Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`).
- Actions panel that records and plays back commands.
- **UI:** Illustrator 2026 layout restyled to measured values:

View File

@ -83,6 +83,119 @@ fn mcp_headless_over_stdio() {
assert_eq!(replies[3]["result"]["content"][0]["type"], "image");
}
/// End-to-end protocol sweep over stdio against the compiled binary. The in-process
/// tests drive `Server::handle_line` directly, so the binary's own wiring (argument
/// parsing, logger install, stdout pollution, notification order) is untested: this
/// drives the real `mcp --headless` process through prompts, completions, resource
/// templates, logging and the error taxonomy instead.
#[test]
fn mcp_protocol_over_stdio() {
use std::io::{BufRead, BufReader};
let mut child =
Command::new(BIN).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
let mut stdin = child.stdin.take().unwrap();
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
let mut notes = vec![];
fn send(stdin: &mut std::process::ChildStdin, v: Value) {
writeln!(stdin, "{v}").unwrap();
}
// Next reply with this id; notifications (no id) are stashed for later.
fn recv(lines: &mut std::io::Lines<BufReader<std::process::ChildStdout>>, notes: &mut Vec<Value>, id: u64) -> Value {
loop {
let line = lines.next().unwrap().unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!(v["jsonrpc"], "2.0", "{v}");
if v.get("id").and_then(|i| i.as_u64()) == Some(id) {
return v;
}
notes.push(v);
}
}
send(
&mut stdin,
json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}),
);
let r = recv(&mut lines, &mut notes, 1);
assert_eq!(r["result"]["serverInfo"]["name"], "vectorcraft");
assert!(r["result"]["capabilities"]["prompts"].is_object(), "{r}");
send(&mut stdin, json!({"jsonrpc":"2.0","method":"notifications/initialized"}));
send(&mut stdin, json!({"jsonrpc":"2.0","id":2,"method":"prompts/list","params":{}}));
let r = recv(&mut lines, &mut notes, 2);
assert!(r["result"]["prompts"].as_array().is_some_and(|p| !p.is_empty()), "{r}");
send(&mut stdin, json!({"jsonrpc":"2.0","id":3,"method":"prompts/get","params":{"name":"poster","arguments":{"brief":"a jazz festival"}}}));
let r = recv(&mut lines, &mut notes, 3);
assert!(r["result"]["messages"][0]["content"]["text"].as_str().is_some_and(|t| t.contains("a jazz festival")), "{r}");
send(
&mut stdin,
json!({"jsonrpc":"2.0","id":4,"method":"completion/complete","params":{"ref":{"type":"ref/prompt","name":"export-set"},"argument":{"name":"formats","value":"sv"}}}),
);
let r = recv(&mut lines, &mut notes, 4);
assert!(r["result"]["completion"]["values"].as_array().is_some_and(|v| v.contains(&json!("svg"))), "{r}");
send(&mut stdin, json!({"jsonrpc":"2.0","id":5,"method":"resources/list","params":{}}));
assert!(recv(&mut lines, &mut notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 2));
send(&mut stdin, json!({"jsonrpc":"2.0","id":6,"method":"resources/templates/list","params":{}}));
assert!(recv(&mut lines, &mut notes, 6)["result"]["resourceTemplates"].as_array().is_some_and(|v| v.len() >= 4));
send(
&mut stdin,
json!({"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"draw_shape","arguments":{"shape":"rectangle","x":10,"y":10,"width":60,"height":40}}}),
);
let r = recv(&mut lines, &mut notes, 7);
assert_eq!(r["result"]["isError"], false, "{r}");
let id: i64 = serde_json::from_str::<Value>(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap()["id"].as_i64().unwrap();
send(&mut stdin, json!({"jsonrpc":"2.0","id":8,"method":"resources/read","params":{"uri": format!("vectorcraft://object/{id}")}}));
let r = recv(&mut lines, &mut notes, 8);
let body: Value = serde_json::from_str(r["result"]["contents"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(body["id"].as_i64(), Some(id), "{body}");
// Unknown resources (-32002) and bad template values (-32602) stay apart.
send(&mut stdin, json!({"jsonrpc":"2.0","id":9,"method":"resources/read","params":{"uri":"vectorcraft://nope" }}));
assert_eq!(recv(&mut lines, &mut notes, 9)["error"]["code"], -32002);
send(&mut stdin, json!({"jsonrpc":"2.0","id":10,"method":"resources/read","params":{"uri":"vectorcraft://object/"}}));
assert_eq!(recv(&mut lines, &mut notes, 10)["error"]["code"], -32602);
// The binary installed its logger: records arrive as notifications/message, and only
// after the client asked for them.
send(&mut stdin, json!({"jsonrpc":"2.0","id":11,"method":"logging/setLevel","params":{"level":"debug"}}));
assert!(recv(&mut lines, &mut notes, 11).get("error").is_none());
send(&mut stdin, json!({"jsonrpc":"2.0","id":12,"method":"tools/list","params":{}}));
assert!(recv(&mut lines, &mut notes, 12)["result"]["tools"].as_array().is_some_and(|v| !v.is_empty()));
// Malformed input is a parse error, and the server keeps serving afterwards.
stdin.write_all(b"{not json\n").unwrap();
let parse_err: Value = loop {
let line = lines.next().unwrap().unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
if v.get("error").is_some() {
break v;
}
notes.push(v);
};
assert_eq!(parse_err["error"]["code"], -32700);
assert!(parse_err["id"].is_null(), "{parse_err}");
send(&mut stdin, json!({"jsonrpc":"2.0","id":13,"method":"ping","params":{}}));
assert!(recv(&mut lines, &mut notes, 13).get("error").is_none());
drop(stdin);
// Drain whatever the debug window queued; every line off the protocol stream parses.
for line in lines.by_ref() {
let line = line.unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!(v["jsonrpc"], "2.0", "{v}");
notes.push(v);
}
let out = child.wait_with_output().unwrap();
assert!(out.status.success());
assert!(!String::from_utf8_lossy(&out.stderr).contains("panicked"));
let messages = notes.iter().filter(|n| n["method"] == "notifications/message").count();
assert!(messages > 0, "no log records after logging/setLevel: {notes:?}");
}
/// `vectorcraft-cli commands | head -1` panicked with "failed printing to
/// stdout: Broken pipe (os error 32)" and exit status 101.
#[test]

View File

@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, file_close),
cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate),
cmd!(query "document.inspect", "Inspect Document", [], None, "{} → layer tree, artboards, selection, history", has_doc, |s, _| Ok(inspect::document(s))),
cmd!(query "document.node", "Inspect Object", [], None, "{id} → full object JSON", has_doc, doc_node),
cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: the compact document.inspect-style summary instead of the full object JSON} → one object", has_doc, doc_node),
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
cmd!(
"document.setUnits",
@ -106,7 +106,7 @@ fn doc_activate(s: &mut Session, p: &Value) -> Result<Value> {
fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("document.node", "missing id"))?;
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
Ok(serde_json::to_value(n).unwrap_or(Value::Null))
if bool_or(p, "summary", false) { Ok(inspect::node_summary(n)) } else { Ok(serde_json::to_value(n).unwrap_or(Value::Null)) }
}
/// Document Setup's units (also `document.setup {units}`).

View File

@ -312,6 +312,21 @@ fn inspect_lists_everything() {
assert!(s.commands().len() > 100);
}
/// `document.node {summary: true}` answers the `document.inspect` shape for one node:
/// the same fields the full-tree summary carries, without serializing the whole tree.
#[test]
fn document_node_summary_matches_inspect() {
let mut s = session();
let id = rect(&mut s, 0.0, 0.0, 10.0, 10.0).0;
let full = s.execute("document.node", &json!({"id": id})).unwrap();
assert!(full.get("bounds").is_none(), "full object JSON carries geometry, not bounds: {full}");
let summary = s.execute("document.node", &json!({"id": id, "summary": true})).unwrap();
let v = s.execute("document.inspect", &json!({})).unwrap();
let node = v["layers"][0]["children"].as_array().unwrap().iter().find(|n| n["id"] == id).cloned().unwrap();
assert_eq!(summary, node, "{summary} vs {node}");
assert!(s.execute("document.node", &json!({"id": 999999, "summary": true})).is_err());
}
/// Type reports the paint its characters show, and its own object-level paint apart from it.
#[test]
fn inspect_reports_the_paint_of_types_characters() {

View File

@ -44,7 +44,7 @@ pub static TEMPLATES: &[Template] = &[
uri: "vectorcraft://object/{id}",
name: "object",
title: "One layer or object",
description: "The layer or object with this id, with its children, bounds and paint (document.inspect)",
description: "The layer or object with this id, with its children, bounds and paint (document.node {summary: true})",
mime: "application/json",
live: Live::Objects,
},
@ -118,7 +118,9 @@ pub fn read(b: &mut dyn Backend, uri: &str) -> Result<Value, ReadError> {
return Err(ReadError::Invalid(format!("{} needs a value for `{}`", template.uri, variable_name(template.uri))));
}
let found = match template.name {
"object" => find_object(b, &var).map_err(ReadError::Backend)?,
// One direct `document.node` lookup through `engine.execute` (which reaches both
// backends), instead of pulling the whole layer tree out of `document.inspect`.
"object" => read_object(b, &var).map_err(ReadError::Backend)?,
// The command catalogue is a control method; the rest are engine commands.
"command" => find_in(b.call("engine.commands", json!({})).map_err(ReadError::Backend)?, None, "id", &var),
"effect" => find_in(exec(b, "effect.list").map_err(ReadError::Backend)?, Some("catalog"), "id", &var),
@ -171,30 +173,23 @@ fn decode(s: &str) -> String {
String::from_utf8_lossy(&out).into_owned()
}
/// The layer or object with this id in the active document, with its subtree.
fn find_object(b: &mut dyn Backend, id: &str) -> Result<Option<Value>, String> {
let doc = exec(b, "document.inspect")?;
Ok(find_node(doc.get("layers"), id))
}
/// Depth-first search of the layer tree for a node whose `id` matches.
fn find_node(node: Option<&Value>, id: &str) -> Option<Value> {
let nodes = node?.as_array()?;
let mut stack: Vec<&Value> = nodes.iter().rev().collect();
while let Some(n) = stack.pop() {
let hit = match n.get("id") {
Some(Value::String(s)) => s == id,
Some(Value::Number(number)) => number.to_string() == id,
_ => false,
};
if hit {
return Some(n.clone());
}
if let Some(kids) = n.get("children").and_then(Value::as_array) {
stack.extend(kids.iter().rev());
}
/// The layer or object with this id in the active document, as its compact summary
/// (bounds, paint labels, subtree — the `document.inspect` shape for one node).
///
/// One `document.node {summary: true}` lookup through `engine.execute` (which reaches
/// both backends), instead of pulling the whole layer tree out of `document.inspect`
/// and scanning it: the reply matches the old read field for field, without the
/// O(document) serialize-and-summarize on every call.
///
/// A non-numeric id, or one that names nothing, is "not found" (the caller turns it into
/// `-32602`); backend failures are swallowed the same way, matching how [`crate::prompts`]
/// treats an unreachable catalogue (empty rather than an error).
fn read_object(b: &mut dyn Backend, id: &str) -> Result<Option<Value>, String> {
let Ok(num) = id.parse::<u64>() else { return Ok(None) };
match b.call("engine.execute", json!({"command": "document.node", "params": {"id": num, "summary": true}})) {
Ok(v) => Ok(Some(v)),
Err(_) => Ok(None),
}
None
}
/// The item in a query command's array whose `field` equals `value`.
@ -261,5 +256,11 @@ mod tests {
let v = read(b.as_mut(), &format!("vectorcraft://object/{id}")).unwrap();
assert_eq!(v["id"].to_string(), id);
assert!(v["bounds"].is_object(), "{v}");
// Layers resolve too, not just drawn objects.
let inspect = b.call("document.inspect", json!({})).unwrap();
let layer = inspect["layers"].as_array().and_then(|l| l.first()).expect("a layer");
let lid = layer["id"].to_string();
let v = read(b.as_mut(), &format!("vectorcraft://object/{lid}")).unwrap();
assert_eq!(v["id"].to_string(), lid);
}
}

View File

@ -233,8 +233,8 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"open_panel",
"Open panel",
"Open a panel in the desktop app's dock (e.g. Layers, Swatches, Stroke, Align, Pathfinder, Transform). Desktop app only.",
obj(json!({"panel": string("Panel name")}), &["panel"]),
"Open a panel in the desktop app's dock by id (layers, swatches, stroke, align, pathfinder, transform, …; case-insensitive, display labels like \"Layers\" work too). Desktop app only.",
obj(json!({"panel": string("Panel id or display label")}), &["panel"]),
false,
),
tool(

View File

@ -171,7 +171,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("window.toolbarAdvanced", "Toolbar: Advanced / Basic", "", "{}"),
("window.taskBar", "Contextual Task Bar", "", "{}"),
("window.dock", "Panels", "Tab", "{} show/hide all panels"),
("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke"),
("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too)"),
(
"window.collapseDock",
"Collapse Panels to Icons",
@ -711,6 +711,28 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
),
];
/// Canonical panel id for `window.panel`: the dock tabs (`properties`, `layers`, `libraries`)
/// and every `ICON_PANELS` id, matched case-insensitively. Each icon panel's display label
/// is accepted too, so `"Layers"` and `"Swatches"` work the way agents write them.
fn normalize_panel(input: &str) -> Option<&'static str> {
let name = input.trim();
if name.eq_ignore_ascii_case("properties") {
return Some("properties");
}
if name.eq_ignore_ascii_case("layers") {
return Some("layers");
}
if name.eq_ignore_ascii_case("libraries") {
return Some("libraries");
}
for &(id, label, _) in ICON_PANELS.iter() {
if name.eq_ignore_ascii_case(id) || name.eq_ignore_ascii_case(label) {
return Some(id);
}
}
None
}
/// Handle a UI command. `None` = not a UI command (the engine handles it).
pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
if id == "app.language" {
@ -935,25 +957,29 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
Ok(json!(collapsed))
}
"window.panel" => {
let panel = s("panel").unwrap_or_default();
match (panel.as_str(), DockTab::from_id(&panel)) {
let raw = s("panel").unwrap_or_default();
// Canonical id (case-insensitive; display labels work too), then the
// dock tab it names, if any.
let canonical = normalize_panel(&raw);
let tab = canonical.and_then(DockTab::from_id);
match (canonical, tab) {
// A collapsed dock pops the panel out of its icon, like the icon panels.
(p, Some(tab)) if app.ui.dock_collapsed => {
(Some(p), Some(tab)) if app.ui.dock_collapsed => {
app.ui.dock_tab = tab;
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(p) { None } else { Some(p.to_string()) };
app.ui.dock = true;
Ok(json!({"open": app.ui.open_panel}))
}
(_, Some(tab)) => {
(Some(_), Some(tab)) => {
app.ui.dock_tab = tab;
Ok(Value::Null)
}
(p, None) if ICON_PANELS.iter().any(|(id, _, _)| *id == p) => {
(Some(p), None) => {
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(p) { None } else { Some(p.to_string()) };
app.ui.dock = true;
Ok(json!({"open": app.ui.open_panel}))
}
(other, None) => Err(format!("unknown panel `{other}`")),
(None, _) => Err(format!("unknown panel `{raw}`")),
}
}
"window.brightness" => match s("brightness").as_deref().and_then(Brightness::parse) {
@ -1237,9 +1263,10 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
"window.taskBar" => app.ui.task_bar,
"window.panel" => {
let panel = p.get("panel").and_then(Value::as_str).unwrap_or("");
match DockTab::from_id(panel) {
let canonical = normalize_panel(panel);
match canonical.and_then(DockTab::from_id) {
Some(tab) if !app.ui.dock_collapsed => app.ui.dock_tab == tab,
_ => app.ui.open_panel.as_deref() == Some(panel),
_ => canonical.is_some_and(|id| app.ui.open_panel.as_deref() == Some(id)),
}
}
"window.collapseDock" => app.ui.dock_collapsed,
@ -3167,6 +3194,34 @@ mod tests {
}
}
#[test]
fn panel_ids_are_case_insensitive_and_labels_work() {
assert_eq!(normalize_panel("layers"), Some("layers"));
assert_eq!(normalize_panel("Layers"), Some("layers"));
assert_eq!(normalize_panel("LAYERS"), Some("layers"));
assert_eq!(normalize_panel("Swatches"), Some("swatches"));
assert_eq!(normalize_panel("swatches"), Some("swatches"));
assert_eq!(normalize_panel("Color Guide"), Some("colorGuide"));
assert_eq!(normalize_panel("colorguide"), Some("colorGuide"));
assert_eq!(normalize_panel(" Stroke "), Some("stroke"));
assert_eq!(normalize_panel("nope"), None);
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
app.run("window.panel", json!({"panel": "Layers"})).unwrap();
assert_eq!(app.ui.dock_tab, crate::state::DockTab::Layers);
app.run("window.panel", json!({"panel": "Swatches"})).unwrap();
assert_eq!(app.ui.open_panel.as_deref(), Some("swatches"));
assert!(checked(&app, "window.panel", &json!({"panel": "Swatches"})).unwrap());
assert!(app.run("window.panel", json!({"panel": "nope"})).is_err());
// A collapsed dock pops the panel out of its icon, by canonical id.
app.ui.dock_collapsed = true;
app.run("window.panel", json!({"panel": "Layers"})).unwrap();
assert_eq!(app.ui.open_panel.as_deref(), Some("layers"));
assert!(checked(&app, "window.panel", &json!({"panel": "Layers"})).unwrap());
app.ui.dock_collapsed = false;
}
#[test]
fn saved_views_store_and_restore_the_view() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());