MCP: reject document.node slices without summary, document and sweep them (review of #405)

`depth` / `childLimit` without `summary: true` used to return the whole
full-JSON subtree silently; it is now an invalid-params error. docs/mcp.md
describes the slice parameters and childCount, the engine test covers
fractional values, unsliced reads and limits past the tree, and the CLI
stdio sweep reads a sliced group through run_command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-08 07:28:24 +07:00
parent 81ab5cb3b0
commit 526fc2e157
4 changed files with 44 additions and 3 deletions

View File

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

View File

@ -105,8 +105,15 @@ 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 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.

View File

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

View File

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