From e278ccf44df29485c39f010fe708c360584cf267 Mon Sep 17 00:00:00 2001 From: Fahri Meral Date: Thu, 8 Oct 2026 04:33:25 +0300 Subject: [PATCH 1/2] MCP: skeleton reads and find for large documents document.inspect takes the same {depth?, childLimit?} slicing as document.node (a depth-0 read is the layer skeleton), and document.find {name?|kind?|text?, limit?} locates nodes across the tree with their ancestor paths and a total. docs/mcp.md documents the drill-down recipe; the CLI stdio sweep covers both. --- ROADMAP.md | 2 +- apps/vectorcraft-cli/tests/cli.rs | 15 ++++++ crates/engine/src/cmd/edit.rs | 23 +++++++-- crates/engine/src/inspect.rs | 73 ++++++++++++++++++++++++++++- crates/engine/src/tests.rs | 54 +++++++++++++++++++++ crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + docs/mcp.md | 6 +++ 10 files changed, 170 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 55076a1ba..57ae25385 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -90,7 +90,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`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), 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). + - 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}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` slices the same way so a depth-0 read is the layer skeleton, `document.find` locates nodes by name/kind/text with ancestor paths, 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: diff --git a/apps/vectorcraft-cli/tests/cli.rs b/apps/vectorcraft-cli/tests/cli.rs index 02bc6acc6..cb49b31be 100644 --- a/apps/vectorcraft-cli/tests/cli.rs +++ b/apps/vectorcraft-cli/tests/cli.rs @@ -181,6 +181,21 @@ fn mcp_protocol_over_stdio() { send(&mut stdin, run(24, "document.node", json!({"id": group, "summary": true, "depth": -1}))); assert_eq!(recv(&mut lines, &mut notes, 24)["result"]["isError"], true); + // Skeleton then drill: a depth-0 inspect is the layer skeleton with counts, and + // document.find locates nodes without dumping the tree. + send(&mut stdin, run(25, "document.inspect", json!({"depth": 0}))); + let r = recv(&mut lines, &mut notes, 25); + let skel: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); + assert!(skel["layers"][0].get("children").is_none(), "{skel}"); + assert!(skel["artboards"].as_array().is_some_and(|a| !a.is_empty()), "{skel}"); + send(&mut stdin, run(26, "document.find", json!({"kind": "ellipse"}))); + let r = recv(&mut lines, &mut notes, 26); + let found: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(found["total"], 1, "{found}"); + assert!(found["matches"][0]["path"].as_array().is_some_and(|p| !p.is_empty()), "{found}"); + send(&mut stdin, run(27, "document.find", json!({}))); + assert_eq!(recv(&mut lines, &mut notes, 27)["result"]["isError"], true); + // 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"}})); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 7868dc4ef..4adcdcfa0 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -24,8 +24,9 @@ pub fn specs() -> Vec { ), 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.inspect", "Inspect Document", [], None, "{depth?, childLimit?} → layer tree, artboards, selection, history (a sliced tree reports childCount)", has_doc, |s, p| Ok(inspect::document_opts(s, slice_opts(p, "document.inspect")?))), cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node), + cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group, Path, Type or Image (exact), text?: substring of type content, limit?: max matches (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total}", has_doc, doc_find), 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", @@ -113,7 +114,7 @@ fn doc_activate(s: &mut Session, p: &Value) -> Result { fn doc_node(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id").ok_or_else(|| bad("document.node", "missing id"))?; - let opts = slice_opts(p)?; + let opts = slice_opts(p, "document.node")?; let summary = bool_or(p, "summary", false); if !summary && (opts.depth.is_some() || opts.child_limit.is_some()) { // The full object JSON is never truncated: a slice without `summary` would @@ -127,14 +128,28 @@ fn doc_node(s: &mut Session, p: &Value) -> Result { /// `depth` / `childLimit` for a summary read: absent (or null) means no limit. /// A present value that is not a non-negative integer is rejected rather than /// silently reinterpreted. -fn slice_opts(p: &Value) -> Result { +fn slice_opts(p: &Value, cmd: &str) -> Result { let opt = |key: &str| match p.get(key) { None | Some(Value::Null) => Ok(None), - Some(v) => v.as_u64().map(Some).ok_or_else(|| bad("document.node", format!("`{key}` must be a non-negative integer"))), + Some(v) => v.as_u64().map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a non-negative integer"))), }; Ok(inspect::SummaryOpts { depth: opt("depth")?, child_limit: opt("childLimit")? }) } +fn doc_find(s: &mut Session, p: &Value) -> Result { + let substring = |key: &str| str_param(p, key).filter(|v| !v.is_empty()).map(|v| v.to_lowercase()); + let filter = inspect::FindFilter { name: substring("name"), kind: substring("kind"), text: substring("text") }; + if filter.is_empty() { + return Err(bad("document.find", "give at least one of `name`, `kind`, `text`")); + } + let limit = match p.get("limit") { + None | Some(Value::Null) => 100, + Some(v) => v.as_u64().ok_or_else(|| bad("document.find", "`limit` must be a non-negative integer"))?, + }; + let (matches, total) = inspect::find_nodes(&s.doc()?.doc, &filter, limit); + Ok(json!({"matches": matches, "total": total})) +} + /// Document Setup's units (also `document.setup {units}`). fn set_units(s: &mut Session, p: &Value) -> Result { let u = str_param(p, "units").and_then(Unit::named).ok_or_else(|| bad("document.setUnits", "unknown units"))?; diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index f9c9a7f2a..89f9fe12f 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -1,7 +1,7 @@ //! Agent-friendly document summaries and view-models for panels. use serde_json::{Value, json}; -use vectorcraft_doc::{ArrowAlign, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, WidthProfile}; +use vectorcraft_doc::{ArrowAlign, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, WidthProfile}; use crate::Session; @@ -98,6 +98,12 @@ pub fn node_summary_opts(n: &Node, opts: SummaryOpts) -> Value { } pub fn document(s: &Session) -> Value { + document_opts(s, SummaryOpts::default()) +} + +/// The same summary with [`SummaryOpts`] applied to the layer tree (artboards and +/// the rest always come whole): `depth: 0` is the skeleton — top layers with counts. +pub fn document_opts(s: &Session, opts: SummaryOpts) -> Value { let Some(st) = s.active() else { return Value::Null }; let d = &st.doc; json!({ @@ -109,7 +115,7 @@ pub fn document(s: &Session) -> Value { "colorMode": format!("{:?}", d.color_mode), "artboards": d.artboards.iter().map(|a| json!({"name": a.name, "x": a.rect.x0, "y": a.rect.y0, "width": a.rect.width(), "height": a.rect.height()})).collect::>(), // Top of the stack first, like the Layers panel. - "layers": d.layers.iter().rev().map(|l| node_summary(l)).collect::>(), + "layers": d.layers.iter().rev().map(|l| node_summary_opts(l, opts)).collect::>(), "currentLayer": st.active_layer.map(|l| l.0), // The rows highlighted in the Layers panel (`layer.setCurrent`, `layer.highlight`). "layerRows": st.highlighted_rows().iter().map(|i| i.0).collect::>(), @@ -129,6 +135,69 @@ pub fn document(s: &Session) -> Value { }) } +/// What `document.find` matches: every given filter must hit (AND). All matching is +/// case-insensitive; `name` and `text` are substrings, `kind` is the exact panel label +/// (`Layer`, `Group`, `Path`, `Type`, `Image`, ...). +pub struct FindFilter { + pub name: Option, + pub kind: Option, + pub text: Option, +} + +impl FindFilter { + /// True when no effective filter was given (empty strings count as absent). + pub fn is_empty(&self) -> bool { + [self.name.as_ref(), self.kind.as_ref(), self.text.as_ref()].iter().all(|f| f.is_none()) + } + + fn hits(&self, n: &Node) -> bool { + if let Some(needle) = &self.name + && !n.display_name().to_lowercase().contains(needle) + { + return false; + } + if let Some(kind) = &self.kind + && !n.kind_label().eq_ignore_ascii_case(kind) + { + return false; + } + if let Some(needle) = &self.text { + let NodeKind::Text(t) = &n.kind else { return false }; + if !t.plain_text().to_lowercase().contains(needle) { + return false; + } + } + true + } +} + +/// Search the whole layer tree, top of the stack first like the Layers panel. +/// Returns at most `limit` hits as `{id, name, kind, path}` (`path` is the ancestor +/// chain, layer first) plus the total hit count, so a capped reply stays explicit. +pub fn find_nodes(doc: &Document, filter: &FindFilter, limit: u64) -> (Vec, u64) { + use std::sync::Arc; + let mut out = Vec::new(); + let mut total = 0u64; + let mut ancestors: Vec = Vec::new(); + fn walk(nodes: &[Arc], ancestors: &mut Vec, filter: &FindFilter, limit: u64, out: &mut Vec, total: &mut u64) { + for n in nodes.iter().rev() { + if filter.hits(n) { + *total += 1; + if (out.len() as u64) < limit { + out.push(json!({"id": n.id.0, "name": n.display_name(), "kind": n.kind_label(), "path": ancestors})); + } + } + if let Some(ch) = n.children() { + ancestors.push(n.id.0); + walk(ch, ancestors, filter, limit, out, total); + ancestors.pop(); + } + } + } + walk(&doc.layers, &mut ancestors, filter, limit, &mut out, &mut total); + (out, total) +} + /// Cap names as `stroke.set` takes them. pub const CAPS: [(LineCap, &str); 3] = [(LineCap::Butt, "butt"), (LineCap::Round, "round"), (LineCap::Square, "square")]; /// Join names as `stroke.set` takes them. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e134571e0..ba9fd2ae7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -414,6 +414,60 @@ fn document_node_summary_slices_with_depth_and_limit() { } } +/// `document.inspect` slices the layer tree with the same options; artboards and the +/// rest always come whole, and the default output is unchanged. +#[test] +fn document_inspect_slices_the_layer_tree() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 10.0, 10.0); + rect(&mut s, 20.0, 0.0, 10.0, 10.0); + s.execute("select.all", &json!({})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + + let full = s.execute("document.inspect", &json!({})).unwrap(); + assert!(full["layers"][0].get("childCount").is_none(), "{full}"); + let flat = s.execute("document.inspect", &json!({"depth": 0})).unwrap(); + assert!(flat["layers"][0].get("children").is_none(), "{flat}"); + assert_eq!(flat["layers"][0]["childCount"], 1); + assert_eq!(flat["artboards"], full["artboards"]); + assert_eq!(flat["objects"], full["objects"]); + let one = s.execute("document.inspect", &json!({"childLimit": 0})).unwrap(); + assert_eq!(one["layers"][0]["children"], json!([])); + assert_eq!(one["layers"][0]["childCount"], 1); + assert!(s.execute("document.inspect", &json!({"depth": "deep"})).is_err()); +} + +/// `document.find` searches names, kinds and type content across the whole tree and +/// answers ids with ancestor paths plus the total, so capped replies stay explicit. +#[test] +fn document_find_searches_names_kinds_and_text() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0).0; + s.execute("object.setProps", &json!({"ids": [a], "name": "Hero Banner"})).unwrap(); + rect(&mut s, 20.0, 0.0, 10.0, 10.0); + s.execute("select.all", &json!({})).unwrap(); + let group = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + let mut find = |p: Value| s.execute("document.find", &p).unwrap(); + + let v = find(json!({"name": "hero"})); + assert_eq!(v["total"], 1); + assert_eq!(v["matches"][0]["id"], a); + assert_eq!(v["matches"][0]["path"], json!([1, group])); + + let v = find(json!({"kind": "group"})); + assert_eq!(v["total"], 1); + assert_eq!(v["matches"][0]["id"], group); + + // `limit: 0` counts without listing; a missing filter is rejected, not a full dump. + let v = find(json!({"kind": "rectangle", "limit": 0})); + assert_eq!(v["matches"].as_array().map(Vec::len), Some(0)); + assert_eq!(v["total"], 2); + assert!(s.execute("document.find", &json!({})).is_err()); + assert!(s.execute("document.find", &json!({"name": ""})).is_err()); + assert!(s.execute("document.find", &json!({"name": "hero", "limit": "many"})).is_err()); + assert_eq!(s.execute("document.find", &json!({"kind": "nope"})).unwrap()["total"], 0); +} + /// 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() { diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2fff78176..837781e7b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -912,6 +912,7 @@ Find Font Buscar fuente Find Font… Buscar fuente… Find Next Buscar siguiente + Find Objects Buscar objetos First Page Primera página First-line Left Indent Sangría izquierda en primera línea Fisheye… Ojo de pez… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 38b44df17..426cb3d88 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -85,6 +85,7 @@ Clear 消去 Find and Replace… 検索と置換… Find Next 次を検索 + Find Objects オブジェクトを検索 Spelling スペルチェック Auto Spell Check 自動スペルチェック Check Spelling… スペルチェック… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 1e57a8f08..56a524bb4 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -906,6 +906,7 @@ Find Font 查找字体 Find Font… 查找字体… Find Next 查找下一个 + Find Objects 查找对象 First Page 第一页 First-line Left Indent 首行左缩进 Fisheye… 鱼眼… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index bfdaf422d..710c33ec7 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -928,6 +928,7 @@ Find Font 尋找字體 Find Font… 尋找字體… Find Next 尋找下一個 + Find Objects 尋找物件 First Page 第一頁 First-line Left Indent 首行左縮排 Fisheye… 魚眼… diff --git a/docs/mcp.md b/docs/mcp.md index c6a016140..c40da3a87 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -91,6 +91,12 @@ that shows fewer children than it has reports their total as `childCount`, so tr nothing truncated the reply is the plain summary. Both must be non-negative integers and need `summary: true` (the full object JSON is never truncated); anything else is an error. +On a large document, drill instead of dumping: `run_command document.inspect {depth: 0}` is the skeleton +(artboards with top layers and counts), `run_command document.find {name?|kind?|text?, limit?}` locates nodes +across the whole tree (at least one filter is required; answers `{matches: [{id, name, kind, path}], total}`), +and `document.node` reads the interesting ones sliced. `document.json` stays whole: it is the fidelity path, +not the way to look around. + ### Completions `completion/complete` answers for a prompt argument (`ref/prompt`) or a template variable From 9a5429a325e48dbd878e08f4e4af5677d67327b7 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 09:09:29 +0700 Subject: [PATCH 2/2] MCP: inspect_document slices too, stricter find filters (review of #416) - The inspect_document tool (and the control channel's document.inspect) take {depth?, childLimit?}, so MCP clients get the skeleton without going through run_command; the tool description points at the drill-down path. - document.find rejects a filter of the wrong type instead of dropping it (which widened the search); one opt_u64 helper parses limit, depth and childLimit. - docs/mcp.md spells out find's matching rules; control-protocol.md and ROADMAP updated; MCP and engine tests cover the new paths. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 ++-- crates/engine/src/cmd/edit.rs | 27 ++++++++++++++++----------- crates/engine/src/tests.rs | 2 ++ crates/mcp/src/headless.rs | 2 +- crates/mcp/src/tests.rs | 9 +++++++++ crates/mcp/src/tools.rs | 17 ++++++++++++++--- crates/ui-egui/src/control.rs | 2 +- docs/control-protocol.md | 2 +- docs/mcp.md | 16 ++++++++++------ 9 files changed, 56 insertions(+), 25 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 57ae25385..323b611cd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adob This file tracks **how far we are and what's left**. Time estimates are wall-clock hours of continuous Claude Opus 5.5 agent work (including builds and the CI gate), given both for **one agent** and for **4–6 parallel agents** on disjoint crates. They are counted from the remaining work (see [Parity estimate](#parity-estimate)), calibrated against measured throughput, and updated as work lands. -_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414) and the first Preferences options that were stored but unused (#394); see [Honest assessment](#honest-assessment-2026-10-05))._ +_Last updated: 2026-10-08 (after M4.14–M4.98, M8.1–M8.20, M14.4–M14.7, the MCP prompts, resource templates, completions and logging, the 2026-10-07 issue fixes, the Layers panel rework and the 2026-10-08 community contributions: Pen and shape-tool modifiers, path editing, Layers, Artboards, Swatches/Brushes and dialogs, Japanese composition and PDF text, Hebrew/Arabic type, interface languages (Spanish among them), the font menu, saved selections, MCP and EPS, the missing-font export warning (#319), open paths in the Shape Builder (#374), PDF/`.ai` import keeping strokes live and hidden and nested layers (#372), per-corner Live Corners (#381), SVG type opening at the size it draws at (#396), the Wayland clipboard with copied-file paste (#398), ruler guides that can be selected, moved and deleted (#414) and the first Preferences options that were stored but unused (#394) and skeleton reads with `document.find` for large documents over MCP (#416); see [Honest assessment](#honest-assessment-2026-10-05))._ ## Where we are @@ -90,7 +90,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`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` slices the same way so a depth-0 read is the layer skeleton, `document.find` locates nodes by name/kind/text with ancestor paths, 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). + - 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}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, 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: diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 4adcdcfa0..800a15b34 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -126,26 +126,31 @@ fn doc_node(s: &mut Session, p: &Value) -> Result { } /// `depth` / `childLimit` for a summary read: absent (or null) means no limit. -/// A present value that is not a non-negative integer is rejected rather than -/// silently reinterpreted. fn slice_opts(p: &Value, cmd: &str) -> Result { - let opt = |key: &str| match p.get(key) { + Ok(inspect::SummaryOpts { depth: opt_u64(p, cmd, "depth")?, child_limit: opt_u64(p, cmd, "childLimit")? }) +} + +/// An optional count: absent (or null) is `None`; a present value that is not a +/// non-negative integer is rejected rather than silently reinterpreted. +fn opt_u64(p: &Value, cmd: &str, key: &str) -> Result> { + match p.get(key) { None | Some(Value::Null) => Ok(None), Some(v) => v.as_u64().map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a non-negative integer"))), - }; - Ok(inspect::SummaryOpts { depth: opt("depth")?, child_limit: opt("childLimit")? }) + } } fn doc_find(s: &mut Session, p: &Value) -> Result { - let substring = |key: &str| str_param(p, key).filter(|v| !v.is_empty()).map(|v| v.to_lowercase()); - let filter = inspect::FindFilter { name: substring("name"), kind: substring("kind"), text: substring("text") }; + // A filter of the wrong type is an error, not dropped: dropping it would widen the search. + let needle = |key: &str| match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(v)) => Ok(Some(v.to_lowercase()).filter(|v| !v.is_empty())), + Some(_) => Err(bad("document.find", format!("`{key}` must be a string"))), + }; + let filter = inspect::FindFilter { name: needle("name")?, kind: needle("kind")?, text: needle("text")? }; if filter.is_empty() { return Err(bad("document.find", "give at least one of `name`, `kind`, `text`")); } - let limit = match p.get("limit") { - None | Some(Value::Null) => 100, - Some(v) => v.as_u64().ok_or_else(|| bad("document.find", "`limit` must be a non-negative integer"))?, - }; + let limit = opt_u64(p, "document.find", "limit")?.unwrap_or(100); let (matches, total) = inspect::find_nodes(&s.doc()?.doc, &filter, limit); Ok(json!({"matches": matches, "total": total})) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ba9fd2ae7..c4480abf2 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -465,6 +465,8 @@ fn document_find_searches_names_kinds_and_text() { assert!(s.execute("document.find", &json!({})).is_err()); assert!(s.execute("document.find", &json!({"name": ""})).is_err()); assert!(s.execute("document.find", &json!({"name": "hero", "limit": "many"})).is_err()); + // A filter of the wrong type is rejected, not dropped (dropping it would widen the search). + assert!(s.execute("document.find", &json!({"kind": "group", "name": 5})).is_err()); assert_eq!(s.execute("document.find", &json!({"kind": "nope"})).unwrap()["total"], 0); } diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 9750d13d6..71e9ad1f4 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -271,7 +271,7 @@ impl Backend for Headless { self.exec(&id, ¶ms) } "engine.commands" => Ok(self.commands()), - "document.inspect" => self.exec("document.inspect", &json!({})), + "document.inspect" => self.exec("document.inspect", p), "document.node" => self.exec("document.node", p), "document.json" => self.exec("document.json", &json!({})), "ui.tool.select" => self.select_tool(p), diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index a16710476..cab692bfb 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -175,6 +175,15 @@ fn headless_end_to_end() { assert_eq!(rect["fill"], "#ff0000", "{rect}"); assert_eq!(rect["stroke"], "None"); + // A depth-0 inspect is the skeleton: top layers with counts, nothing below them; + // a bad slice is a tool error, not a full dump. + let r = call(&mut s, 41, "inspect_document", json!({"depth": 0})); + let skel: Value = serde_json::from_str(&text_of(&r)).unwrap(); + assert!(skel["layers"][0].get("children").is_none(), "{skel}"); + assert_eq!(skel["layers"][0]["childCount"], layer["children"].as_array().unwrap().len()); + assert_eq!(skel["artboards"], doc["artboards"]); + assert_eq!(call(&mut s, 42, "inspect_document", json!({"depth": -1}))["isError"], true); + // Screenshot returns image content (base64 PNG) and text. let shot_path = tmp("shot.png"); let r = call(&mut s, 5, "screenshot", json!({"path": shot_path.to_str().unwrap()})); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index e4d19cf58..8acd207bf 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -100,8 +100,14 @@ pub fn tool_definitions() -> Vec { tool( "inspect_document", "Inspect document", - "Summary of the active document: artboards, layer tree (ids, names, kinds, bounds, fill/stroke), selection, undo history, current tool and default paint.", - empty(), + "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.", + obj( + json!({ + "depth": {"type": "integer", "minimum": 0, "description": "Child levels of the layer tree to include (default all; 0 = top layers with counts)"}, + "childLimit": {"type": "integer", "minimum": 0, "description": "Children shown per node (default all; a level that shows fewer reports childCount)"} + }), + &[], + ), true, ), tool( @@ -683,7 +689,12 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result j(b.call("document.inspect", json!({}))?), + "inspect_document" => { + // Only the slicing options reach the command, which validates them. + let slice: Map = + a.iter().filter(|(k, _)| matches!(k.as_str(), "depth" | "childLimit")).map(|(k, v)| (k.clone(), v.clone())).collect(); + j(b.call("document.inspect", Value::Object(slice))?) + } "inspect_ui" => { need_ui(b, name)?; j(b.call("ui.inspect", json!({}))?) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index b5c2e6c32..caec6281f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -123,7 +123,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques wrap(app.run(id, params)) } "engine.commands" => ok(all_commands(app)), - "document.inspect" => wrap(app.run("document.inspect", json!({}))), + "document.inspect" => wrap(app.run("document.inspect", p.clone())), "ui.inspect" => ok(inspect(app, ctx)), "ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()), "ui.contextMenu.list" => ok(serde_json::to_value(crate::menus::context_entries(app)).unwrap_or_default()), diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 55f71965d..4bf77f5c3 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -17,7 +17,7 @@ so only enable it while you use it. Transport: `apps/vectorcraft/src/control_ser |---|---|---| | `engine.execute` | `{command, params}` | run any engine or UI command (see `engine.commands`) | | `engine.commands` | | every command with label, shortcut, params doc, enablement | -| `document.inspect` | | layer tree, selection, history, paint defaults | +| `document.inspect` | `{depth?, childLimit?}` | layer tree, selection, history, paint defaults; the options slice the layer tree as `document.node {summary: true}` does | | `ui.inspect` | | tool, UI state, view, canvas rect, window size, perf, background saves and exports still running | | `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item | | `ui.contextMenu.list` | | the canvas context menu for the current selection, flattened like `ui.menu.list` (`path` holds its submenus). `ui.click {x, y, button: "right"}` on the canvas opens it, after selecting the object there unless it is already selected | diff --git a/docs/mcp.md b/docs/mcp.md index c40da3a87..cacf91f2b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -91,11 +91,15 @@ that shows fewer children than it has reports their total as `childCount`, so tr nothing truncated the reply is the plain summary. Both must be non-negative integers and need `summary: true` (the full object JSON is never truncated); anything else is an error. -On a large document, drill instead of dumping: `run_command document.inspect {depth: 0}` is the skeleton -(artboards with top layers and counts), `run_command document.find {name?|kind?|text?, limit?}` locates nodes -across the whole tree (at least one filter is required; answers `{matches: [{id, name, kind, path}], total}`), -and `document.node` reads the interesting ones sliced. `document.json` stays whole: it is the fidelity path, -not the way to look around. +On a large document, drill instead of dumping: `inspect_document {depth: 0}` (or `run_command document.inspect +{depth?, childLimit?}`, same rules) is the skeleton (artboards with top layers and counts), `run_command +document.find {name?, kind?, text?, limit?}` locates nodes across the whole tree, and `document.node` reads the +interesting ones sliced. `document.find` needs at least one filter and all given filters must match; matching +ignores case, `name` is a substring of the Layers panel name, `text` a substring of type content and `kind` the +exact panel label (`Layer`, `Group`, `Clip Group`, `Path`, `Compound Path`, `Type`, `Image`, `Rectangle`, ...). +It answers `{matches: [{id, name, kind, path}], total}` top of the stack first, where `path` is the ancestor +chain (layer first) and `limit` caps `matches` (default 100; `0` only counts). `document.json` stays whole: it is +the fidelity path, not the way to look around. ### Completions @@ -162,7 +166,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr |---|---|---| | `list_commands` | `{filter?, enabledOnly?}` | The command catalogue: id, label, menu, shortcut, params doc, enablement. | | `run_command` | `{command, params?}` | Runs any command. Use it for everything without a dedicated tool. | -| `inspect_document` | `{}` | Artboards, layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. Paint comes as a label (`"#ff0000"`, `"None"`, `"G1 40% (#ff9999)"` for a swatch at a tint); `paint.proxies` gives it as an object. | +| `inspect_document` | `{depth?, childLimit?}` | Artboards, layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. Paint comes as a label (`"#ff0000"`, `"None"`, `"G1 40% (#ff9999)"` for a swatch at a tint); `paint.proxies` gives it as an object. | | `inspect_ui` | `{}` | UI state. Remote mode only. | | `select_tool` | `{tool}` | `selection`, `directSelection`, `pen`, `rectangle`, `ellipse`, `polygon`, `star`, `lineSegment`, … | | `pointer_gesture` | `{events:[{kind,x,y,mods?}], tool?, mods?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. Events go through the same path as the mouse. |