From 9a5429a325e48dbd878e08f4e4af5677d67327b7 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 09:09:29 +0700 Subject: [PATCH] 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 57ae2538..323b611c 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 4adcdcfa..800a15b3 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 ba9fd2ae..c4480abf 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 9750d13d..71e9ad1f 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 a1671047..cab692bf 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 e4d19cf5..8acd207b 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 b5c2e6c3..caec6281 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 55f71965..4bf77f5c 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 c40da3a8..cacf91f2 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. |