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.
This commit is contained in:
Fahri Meral 2026-10-08 04:33:25 +03:00 committed by bflatastic
parent a5963b7c76
commit e278ccf44d
10 changed files with 170 additions and 7 deletions

View File

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

View File

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

View File

@ -24,8 +24,9 @@ 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.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<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 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<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) -> Result<inspect::SummaryOpts> {
fn slice_opts(p: &Value, cmd: &str) -> Result<inspect::SummaryOpts> {
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<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") };
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<Value> {
let u = str_param(p, "units").and_then(Unit::named).ok_or_else(|| bad("document.setUnits", "unknown units"))?;

View File

@ -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::<Vec<_>>(),
// Top of the stack first, like the Layers panel.
"layers": d.layers.iter().rev().map(|l| node_summary(l)).collect::<Vec<_>>(),
"layers": d.layers.iter().rev().map(|l| node_summary_opts(l, opts)).collect::<Vec<_>>(),
"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::<Vec<_>>(),
@ -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<String>,
pub kind: Option<String>,
pub text: Option<String>,
}
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<Value>, u64) {
use std::sync::Arc;
let mut out = Vec::new();
let mut total = 0u64;
let mut ancestors: Vec<u64> = Vec::new();
fn walk(nodes: &[Arc<Node>], ancestors: &mut Vec<u64>, filter: &FindFilter, limit: u64, out: &mut Vec<Value>, 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.

View File

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

View File

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

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -85,6 +85,7 @@
Clear 消去
Find and Replace… 検索と置換…
Find Next 次を検索
Find Objects オブジェクトを検索
Spelling スペルチェック
Auto Spell Check 自動スペルチェック
Check Spelling… スペルチェック…

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -906,6 +906,7 @@
Find Font 查找字体
Find Font… 查找字体…
Find Next 查找下一个
Find Objects 查找对象
First Page 第一页
First-line Left Indent 首行左缩进
Fisheye… 鱼眼…

Can't render this file because it contains an unexpected character in line 2 and column 73.

View File

@ -928,6 +928,7 @@
Find Font 尋找字體
Find Font… 尋找字體…
Find Next 尋找下一個
Find Objects 尋找物件
First Page 第一頁
First-line Left Indent 首行左縮排
Fisheye… 魚眼…

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

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