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:
parent
81ab5cb3b0
commit
526fc2e157
|
|
@ -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"}}));
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue