From 03ab7bb805163b7717ae2f1e68fdb80c7abd21c7 Mon Sep 17 00:00:00 2001 From: acosimati <288045379+acosimati@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:22:41 +0000 Subject: [PATCH] MCP: doc_inspect reports opacityPct in the percent scale the drawing tools take draw_path, draw_shape and object.setProps take opacity in percent (0-100), but the document.inspect / document.node summary behind doc_inspect, inspect_document and vectorcraft://object/{id} reported the stored 0-1 fraction under the same name. An agent that read 0.55 and wrote it back got a nearly invisible object. The summary now adds opacityPct (0-100, rounded to 1/100 of a percent) beside the unchanged opacity, and the tool, resource and command descriptions say which scale each field uses. Round-trip test in the mcp crate: draw at 55 %, read 55 back from doc_inspect, inspect_document and the object resource, write it back unchanged. --- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/inspect.rs | 4 ++++ crates/mcp/src/resources.rs | 2 +- crates/mcp/src/tests_drawopts.rs | 34 ++++++++++++++++++++++++++++++++ crates/mcp/src/tools.rs | 6 +++--- docs/mcp.md | 6 ++++-- 6 files changed, 47 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index aadd456ef..fa4878d2c 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, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect), + cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_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, within?: [x, y, width, height] (an object's geometric bounds inside this rectangle, edges included; document points as document.inspect's bounds; layers never match this or the size filters), intersects?: [x, y, width, height] (bounds touching or overlapping it), minWidth?, maxWidth?, minHeight?, maxHeight?: pt (the bounds' size), minThickness?, maxThickness?: pt (the smaller of the bounds' width and height), minArea?, maxArea?: pt² (the bounds' width × height), minAnchors?, maxAnchors?: anchor points of a path (a compound path's members together; other objects never match), select?: bool (also select the matches, leaving out hidden and locked ones, those inside another match, and a compound path's members unless the compound path is isolated; Select > Reselect repeats it), limit?: max matches listed (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total, selected (with select: the count selected)}; at least one filter, all of them matching", 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))), diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 45dcaa7a4..14df8529d 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -45,6 +45,10 @@ pub fn node_summary_opts(n: &Node, opts: SummaryOpts, symbols: &SymbolBoxes) -> }); if n.opacity < 1.0 { v["opacity"] = json!(n.opacity); + // The same value in the percent scale `object.setProps`, `transparency.set` and the MCP + // drawing tools take, so a reader can pass it back as it is. Rounded to 1/100 of a + // percent so a 55 written as an f32 reads back as 55, not 55.000001. + v["opacityPct"] = json!((f64::from(n.opacity) * 10_000.0).round() / 100.0); } if n.blend != BlendMode::Normal { v["blend"] = json!(n.blend.label()); diff --git a/crates/mcp/src/resources.rs b/crates/mcp/src/resources.rs index d11d69b4b..f22b45959 100644 --- a/crates/mcp/src/resources.rs +++ b/crates/mcp/src/resources.rs @@ -47,7 +47,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.node {summary: true}). Large containers can be sliced with run_command document.node {id, summary, depth, childLimit}: truncated levels report childCount", + description: "The layer or object with this id, with its children, bounds and paint (document.node {summary: true}; `opacity` is a 0–1 fraction, `opacityPct` the 0–100 % the drawing tools take). Large containers can be sliced with run_command document.node {id, summary, depth, childLimit}: truncated levels report childCount", mime: "application/json", live: Live::Objects, }, diff --git a/crates/mcp/src/tests_drawopts.rs b/crates/mcp/src/tests_drawopts.rs index bef4e59b8..2229fbeaf 100644 --- a/crates/mcp/src/tests_drawopts.rs +++ b/crates/mcp/src/tests_drawopts.rs @@ -180,3 +180,37 @@ fn a_bad_paint_draws_nothing() { assert_eq!(history.last().unwrap().label, label); } } + +/// The opacity `draw_path` takes in percent reads back in the same scale: `doc_inspect` (and +/// the `vectorcraft://object/{id}` resource, the same summary) report `opacityPct` beside the +/// stored 0–1 `opacity`, so an agent that reuses what it read does not turn 55 % into 0.55 %. +#[test] +fn inspect_reports_opacity_in_the_percent_scale_the_drawing_tools_take() { + let mut h = headless(); + let (id, reply) = draw(&mut h, "draw_path", json!({"d": "M10 10 L90 90", "opacity": 55})); + assert_eq!(reply["opacity"], json!(55)); + let summary = |h: &mut Headless, id: NodeId| -> Value { + let r = call_tool(h, "doc_inspect", &json!({"ids": [id.0]})); + assert!(!r.is_error, "{r:?}"); + let v: Value = serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap(); + v["nodes"].as_array().unwrap().iter().find(|n| n["id"] == id.0).cloned().expect("the node") + }; + let n = summary(&mut h, id); + assert_eq!(n["opacityPct"], json!(55.0), "{n}"); + assert!((n["opacity"].as_f64().unwrap() - 0.55).abs() < 1e-6, "{n}"); + // The whole tree and the object resource say the same. + let r = call_tool(&mut h, "inspect_document", &json!({})); + let tree: Value = serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap(); + let in_tree = tree["layers"][0]["children"].as_array().unwrap().iter().find(|c| c["id"] == id.0).expect("the path in its layer"); + assert_eq!(in_tree["opacityPct"], json!(55.0), "{in_tree}"); + let res = crate::resources::read(&mut h, &format!("vectorcraft://object/{}", id.0)).unwrap(); + assert_eq!((res["opacityPct"].clone(), res["opacity"].as_f64().map(|o| (o * 100.0).round())), (json!(55.0), Some(55.0)), "{res}"); + // Writing `opacityPct` back, as the descriptions say, keeps the object as it was. + let r = call_tool(&mut h, "run_command", &json!({"command": "object.setProps", "params": {"id": id.0, "opacity": n["opacityPct"]}})); + assert!(!r.is_error, "{r:?}"); + assert!((node(&h, id).opacity - 0.55).abs() < 1e-6); + // A fully opaque object reports neither field, so the two never disagree. + let (full, _) = draw(&mut h, "draw_path", json!({"d": "M0 0 L5 5"})); + let n = summary(&mut h, full); + assert!(n.get("opacity").is_none() && n.get("opacityPct").is_none(), "{n}"); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 2b4c81640..efedb7cfb 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -64,7 +64,7 @@ fn with_draw_options(mut props: Value) -> Value { "dash": {"description": "Dash and gap lengths in pt ([d, g, …]), or null for a solid stroke (stroke.set `dash`)", "anyOf": [{"type": "array", "items": {"type": "number"}}, {"type": "null"}]}, "cap": {"type": "string", "enum": ["butt", "round", "square", "projecting"], "description": "Stroke cap"}, "join": {"type": "string", "enum": ["miter", "round", "bevel"], "description": "Stroke corner join"}, - "opacity": {"type": "number", "minimum": 0, "maximum": 100, "description": "Opacity in % (object.setProps `opacity`)"}, + "opacity": {"type": "number", "minimum": 0, "maximum": 100, "description": "Opacity in % (object.setProps `opacity`); doc_inspect reads it back as `opacityPct` (its `opacity` is a 0–1 fraction)"}, "name": string("Name in the Layers panel"), "effects": { "type": "array", @@ -145,7 +145,7 @@ pub fn tool_definitions() -> Vec { tool( "doc_inspect", "Inspect document", - "Summary of the active document, including artboards, layer tree, selection and history. On a large document scope it: depth 0 for the layer skeleton, ids for just those nodes, kind for every node of one kind, limit/offset to page. inspect_document returns the same summary.", + "Summary of the active document, including artboards, layer tree, selection and history. A node below full opacity reports `opacity` as a 0–1 fraction and `opacityPct` in the 0–100 % scale the drawing tools and object.setProps take: pass `opacityPct` back, never `opacity`. On a large document scope it: depth 0 for the layer skeleton, ids for just those nodes, kind for every node of one kind, limit/offset to page. inspect_document returns the same summary.", obj(inspect_props(), &[]), true, ), @@ -196,7 +196,7 @@ 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. 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. doc_inspect returns the same summary.", + "Summary of the active document: artboards, layer tree (ids, names, kinds, bounds, fill/stroke; `opacity` as a 0–1 fraction and `opacityPct` in the 0–100 % scale the drawing tools take), 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. doc_inspect returns the same summary.", obj(inspect_props(), &[]), true, ), diff --git a/docs/mcp.md b/docs/mcp.md index 148131127..e24878b6e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -233,8 +233,10 @@ nothing truncated the reply is the plain summary. Both must be non-negative inte In the summary an object reports its paint as `fill`, `stroke` (labels) and `strokeWidth`. So does a compound path, whose paint is its own: its member paths report `compoundMember: true` instead of a paint they don't have. Every -node (layers and groups too) reports `opacity` when it is below 1 and `blend` when it isn't Normal, as the label -`object.setProps {blend}` takes (`"Multiply"`, `"Color Burn"`). +node (layers and groups too) reports its opacity when it is below 1, in both scales: `opacity` is the 0–1 fraction +the document stores and `opacityPct` the 0–100 % that `object.setProps`, `transparency.set`, `draw_path` and +`draw_shape` take (so an agent passes `opacityPct` back, never `opacity`); and `blend` when it isn't Normal, as +the label `object.setProps {blend}` takes (`"Multiply"`, `"Color Burn"`). 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