From e5e09d5d485f3d4ed8fde0c23afb8561aa2a666f Mon Sep 17 00:00:00 2001 From: Fahri Meral Date: Wed, 7 Oct 2026 16:39:06 +0300 Subject: [PATCH] mcp: read object template via document.node summary flag --- crates/engine/src/cmd/edit.rs | 4 +-- crates/engine/src/tests.rs | 15 +++++++++++ crates/mcp/src/resources.rs | 51 ++++++++++++++++++----------------- 3 files changed, 43 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3c070f6c..00e025cd 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -25,7 +25,7 @@ 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.node", "Inspect Object", [], None, "{id} → full object JSON", has_doc, doc_node), + cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: the compact document.inspect-style summary instead of the full object JSON} → one object", has_doc, doc_node), cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))), cmd!( "document.setUnits", @@ -106,7 +106,7 @@ fn doc_activate(s: &mut Session, p: &Value) -> Result { fn doc_node(s: &mut Session, p: &Value) -> Result { let id = id_param(p, "id").ok_or_else(|| bad("document.node", "missing id"))?; let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?; - Ok(serde_json::to_value(n).unwrap_or(Value::Null)) + if bool_or(p, "summary", false) { Ok(inspect::node_summary(n)) } else { Ok(serde_json::to_value(n).unwrap_or(Value::Null)) } } /// Document Setup's units (also `document.setup {units}`). diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7dbe71c1..8b455d85 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -285,6 +285,21 @@ fn inspect_lists_everything() { assert!(s.commands().len() > 100); } +/// `document.node {summary: true}` answers the `document.inspect` shape for one node: +/// the same fields the full-tree summary carries, without serializing the whole tree. +#[test] +fn document_node_summary_matches_inspect() { + let mut s = session(); + let id = rect(&mut s, 0.0, 0.0, 10.0, 10.0).0; + let full = s.execute("document.node", &json!({"id": id})).unwrap(); + assert!(full.get("bounds").is_none(), "full object JSON carries geometry, not bounds: {full}"); + let summary = s.execute("document.node", &json!({"id": id, "summary": true})).unwrap(); + let v = s.execute("document.inspect", &json!({})).unwrap(); + let node = v["layers"][0]["children"].as_array().unwrap().iter().find(|n| n["id"] == id).cloned().unwrap(); + assert_eq!(summary, node, "{summary} vs {node}"); + assert!(s.execute("document.node", &json!({"id": 999999, "summary": true})).is_err()); +} + /// Type reports the paint its characters show, and its own object-level paint apart from it. #[test] fn inspect_reports_the_paint_of_types_characters() { diff --git a/crates/mcp/src/resources.rs b/crates/mcp/src/resources.rs index a834185b..4db1d6dd 100644 --- a/crates/mcp/src/resources.rs +++ b/crates/mcp/src/resources.rs @@ -44,7 +44,7 @@ pub static TEMPLATES: &[Template] = &[ uri: "vectorcraft://object/{id}", name: "object", title: "One layer or object", - description: "The layer or object with this id, with its children, bounds and paint (document.inspect)", + description: "The layer or object with this id, with its children, bounds and paint (document.node {summary: true})", mime: "application/json", live: Live::Objects, }, @@ -118,7 +118,9 @@ pub fn read(b: &mut dyn Backend, uri: &str) -> Result { return Err(ReadError::Invalid(format!("{} needs a value for `{}`", template.uri, variable_name(template.uri)))); } let found = match template.name { - "object" => find_object(b, &var).map_err(ReadError::Backend)?, + // One direct `document.node` lookup through `engine.execute` (which reaches both + // backends), instead of pulling the whole layer tree out of `document.inspect`. + "object" => read_object(b, &var).map_err(ReadError::Backend)?, // The command catalogue is a control method; the rest are engine commands. "command" => find_in(b.call("engine.commands", json!({})).map_err(ReadError::Backend)?, None, "id", &var), "effect" => find_in(exec(b, "effect.list").map_err(ReadError::Backend)?, Some("catalog"), "id", &var), @@ -171,30 +173,23 @@ fn decode(s: &str) -> String { String::from_utf8_lossy(&out).into_owned() } -/// The layer or object with this id in the active document, with its subtree. -fn find_object(b: &mut dyn Backend, id: &str) -> Result, String> { - let doc = exec(b, "document.inspect")?; - Ok(find_node(doc.get("layers"), id)) -} - -/// Depth-first search of the layer tree for a node whose `id` matches. -fn find_node(node: Option<&Value>, id: &str) -> Option { - let nodes = node?.as_array()?; - let mut stack: Vec<&Value> = nodes.iter().rev().collect(); - while let Some(n) = stack.pop() { - let hit = match n.get("id") { - Some(Value::String(s)) => s == id, - Some(Value::Number(number)) => number.to_string() == id, - _ => false, - }; - if hit { - return Some(n.clone()); - } - if let Some(kids) = n.get("children").and_then(Value::as_array) { - stack.extend(kids.iter().rev()); - } +/// The layer or object with this id in the active document, as its compact summary +/// (bounds, paint labels, subtree — the `document.inspect` shape for one node). +/// +/// One `document.node {summary: true}` lookup through `engine.execute` (which reaches +/// both backends), instead of pulling the whole layer tree out of `document.inspect` +/// and scanning it: the reply matches the old read field for field, without the +/// O(document) serialize-and-summarize on every call. +/// +/// A non-numeric id, or one that names nothing, is "not found" (the caller turns it into +/// `-32602`); backend failures are swallowed the same way, matching how [`crate::prompts`] +/// treats an unreachable catalogue (empty rather than an error). +fn read_object(b: &mut dyn Backend, id: &str) -> Result, String> { + let Ok(num) = id.parse::() else { return Ok(None) }; + match b.call("engine.execute", json!({"command": "document.node", "params": {"id": num, "summary": true}})) { + Ok(v) => Ok(Some(v)), + Err(_) => Ok(None), } - None } /// The item in a query command's array whose `field` equals `value`. @@ -261,5 +256,11 @@ mod tests { let v = read(b.as_mut(), &format!("vectorcraft://object/{id}")).unwrap(); assert_eq!(v["id"].to_string(), id); assert!(v["bounds"].is_object(), "{v}"); + // Layers resolve too, not just drawn objects. + let inspect = b.call("document.inspect", json!({})).unwrap(); + let layer = inspect["layers"].as_array().and_then(|l| l.first()).expect("a layer"); + let lid = layer["id"].to_string(); + let v = read(b.as_mut(), &format!("vectorcraft://object/{lid}")).unwrap(); + assert_eq!(v["id"].to_string(), lid); } }