diff --git a/apps/vectorcraft-cli/tests/cli.rs b/apps/vectorcraft-cli/tests/cli.rs index 0a44fcab..02bc6acc 100644 --- a/apps/vectorcraft-cli/tests/cli.rs +++ b/apps/vectorcraft-cli/tests/cli.rs @@ -160,6 +160,27 @@ fn mcp_protocol_over_stdio() { send(&mut stdin, json!({"jsonrpc":"2.0","id":10,"method":"resources/read","params":{"uri":"vectorcraft://object/"}})); assert_eq!(recv(&mut lines, &mut notes, 10)["error"]["code"], -32602); + // Large containers slice through run_command document.node: a truncated level reports + // childCount, and a bad limit is a tool error, not a crash or a full read. + send( + &mut stdin, + json!({"jsonrpc":"2.0","id":20,"method":"tools/call","params":{"name":"draw_shape","arguments":{"shape":"ellipse","x":90,"y":10,"width":30,"height":30}}}), + ); + assert_eq!(recv(&mut lines, &mut notes, 20)["result"]["isError"], false); + let run = |id: u64, command: &str, params: Value| json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":"run_command","arguments":{"command":command,"params":params}}}); + send(&mut stdin, run(21, "select.all", json!({}))); + recv(&mut lines, &mut notes, 21); + send(&mut stdin, run(22, "object.group", json!({}))); + let r = recv(&mut lines, &mut notes, 22); + let group = serde_json::from_str::(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap()["id"].clone(); + send(&mut stdin, run(23, "document.node", json!({"id": group, "summary": true, "childLimit": 1}))); + let r = recv(&mut lines, &mut notes, 23); + let sliced: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(sliced["children"].as_array().map(Vec::len), Some(1), "{sliced}"); + assert_eq!(sliced["childCount"], 2, "{sliced}"); + 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); + // 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"}})); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 72766988..3ab5748c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -105,8 +105,15 @@ 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 opts = slice_opts(p)?; + 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 + // otherwise silently return the whole subtree. + return Err(bad("document.node", "`depth` and `childLimit` slice the summary: pass `summary: true`")); + } let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?; - if bool_or(p, "summary", false) { Ok(inspect::node_summary_opts(n, slice_opts(p)?)) } else { Ok(serde_json::to_value(n).unwrap_or(Value::Null)) } + if summary { Ok(inspect::node_summary_opts(n, opts)) } else { Ok(serde_json::to_value(n).unwrap_or(Value::Null)) } } /// `depth` / `childLimit` for a summary read: absent (or null) means no limit. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8db40bf1..e134571e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -381,6 +381,8 @@ fn document_node_summary_slices_with_depth_and_limit() { let g = read(json!({"id": outer, "summary": true})); assert_eq!(g["children"].as_array().map(Vec::len), Some(2)); assert!(g.get("childCount").is_none(), "nothing truncated: {g}"); + // Limits beyond the tree are the whole tree, byte for byte. + assert_eq!(read(json!({"id": outer, "summary": true, "depth": u64::MAX, "childLimit": u64::MAX})), g); let one = read(json!({"id": outer, "summary": true, "childLimit": 1})); assert_eq!(one["children"].as_array().map(Vec::len), Some(1)); @@ -400,7 +402,14 @@ fn document_node_summary_slices_with_depth_and_limit() { assert!(nested.get("children").is_none(), "{nested}"); assert_eq!(nested["childCount"], 2); - for bad in [json!({"id": outer, "summary": true, "depth": -1}), json!({"id": outer, "summary": true, "childLimit": "many"})] { + for bad in [ + json!({"id": outer, "summary": true, "depth": -1}), + json!({"id": outer, "summary": true, "depth": 1.5}), + json!({"id": outer, "summary": true, "childLimit": "many"}), + // A slice of the full object JSON would silently be the whole subtree. + json!({"id": outer, "depth": 0}), + json!({"id": outer, "summary": false, "childLimit": 1}), + ] { assert!(s.execute("document.node", &bad).is_err(), "{bad}"); } } diff --git a/docs/mcp.md b/docs/mcp.md index ca4c6017..a3609dc5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -85,7 +85,11 @@ An unknown URI is `-32002`; a template with no value, or a value that names noth The same reads work as query commands through `run_command`: `document.node {id}` returns the object's full model JSON (geometry, appearance, every attribute) and `document.node {id, summary: true}` the compact summary `document.inspect` gives for it (id, name, kind, bounds, paint labels, children), without summarizing the whole -document. +document. A summary read slices a large container: `depth` is how many child levels it includes (`0`: the node +alone) and `childLimit` how many children each node shows, top of the stack first (both default to all). A level +that shows fewer children than it has reports their total as `childCount`, so truncation is never silent; with +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. ### Completions