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 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-08 09:09:29 +07:00
parent e278ccf44d
commit 9a5429a325
9 changed files with 56 additions and 25 deletions

View File

@ -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:

View File

@ -126,26 +126,31 @@ fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
}
/// `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<inspect::SummaryOpts> {
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<Option<u64>> {
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<Value> {
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}))
}

View File

@ -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);
}

View File

@ -271,7 +271,7 @@ impl Backend for Headless {
self.exec(&id, &params)
}
"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),

View File

@ -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()}));

View File

@ -100,8 +100,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.",
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<ToolResult, Str
let method = if name == "invoke_menu" && b.has_ui() { "ui.menu.invoke" } else { "engine.execute" };
j(b.call(method, json!({"command": cmd, "params": params}))?)
}
"inspect_document" => j(b.call("document.inspect", json!({}))?),
"inspect_document" => {
// Only the slicing options reach the command, which validates them.
let slice: Map<String, Value> =
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!({}))?)

View File

@ -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()),

View File

@ -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 |

View File

@ -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. |