diff --git a/ROADMAP.md b/ROADMAP.md index d80e6725..0c432c68 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -61,6 +61,10 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. ## Dimensioning and error reporting +- MCP `execute` accepts `verbose: false`, sharing the compact reply helper used by + `batch`: body changes and created sketch IDs stay, while repeated lists are omitted. + Both tools retain full replies by default. + - Sketch Create groups drawing variants under Line, Rectangle, Circle, Arc, Slot, Polygon and Spline. Each open part remembers its last interactive choice per family across sketch sessions and document-tab switches; closing the part resets these UI choices. diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 2f8cae85..fb6bdd46 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -540,3 +540,84 @@ fn hidden_body_stays_hidden_after_save_and_reopen() { assert_eq!(base, true); assert_eq!(variant, false, "the hidden body is visible again after reopening"); } + +/// Single execute calls share batch compaction without changing geometry or inspection. +#[test] +fn compact_execute_keeps_changes_ids_and_default_replies() { + let mut s = server(); + let commands = json!([ + {"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "Base"}}, + {"command": "solid.box", "params": {"length": 4, "width": 4, "height": 10, "corner": [3, 3, 5], "operation": "cut", "targets": ["Base"]}}, + {"command": "sketch.create", "params": {"plane": "XY"}}, + {"command": "sketch.rectangle.two_point", "params": {"p0": [20, 0], "p1": [30, 10]}} + ]); + let full: Vec = commands.as_array().unwrap().iter().map(|a| tool(&mut s, "execute", a.clone())).collect(); + assert_eq!(full[0]["bodies"], json!(["Base"])); + assert_eq!(full[3]["curves"], full[3]["created"]["curves"]); + let expected_volume = volume(&mut s); + for verbose in [true, false] { + tool(&mut s, "new_design", json!({})); + for (a, expected) in commands.as_array().unwrap().iter().zip(&full) { + let mut a = a.clone(); + a["verbose"] = json!(verbose); + let mut actual = tool(&mut s, "execute", a); + let mut expected = expected.clone(); + // Independent executions have different timings; the field must still be present. + if expected.get("ms").is_some() { + assert!(actual["ms"].is_number()); + actual.as_object_mut().unwrap().remove("ms"); + expected.as_object_mut().unwrap().remove("ms"); + } + if !verbose { + // Compare against the full result with only the documented repeated fields removed. + let o = expected.as_object_mut().unwrap(); + if o.contains_key("created_bodies") { + o.remove("bodies"); + } + for key in ["points", "curves", "constraints", "dimensions"] { + if o.get(key).is_some_and(|v| o.get("created").and_then(|c| c.get(key)) == Some(v)) { + o.remove(key); + } + } + } + assert_eq!(actual, expected); + } + assert!((volume(&mut s) - expected_volume).abs() < 1e-9); + let inspect = tool(&mut s, "execute", json!({"command": "sketch.inspect", "verbose": false})); + assert_eq!(inspect["curves"].as_array().map(Vec::len), Some(4)); + let invalid = raw_tool(&mut s, "execute", json!({"command": "solid.box", "verbose": "false"})); + assert_eq!(invalid["error"]["code"], -32602); + assert!((volume(&mut s) - expected_volume).abs() < 1e-9); + let error = raw_tool(&mut s, "execute", json!({"command": "not.a.command", "verbose": false})); + assert_eq!(error["result"]["isError"], true); + } +} + +/// A connected app sees exactly one engine call; no inventory inspection is needed. +#[test] +fn compact_execute_uses_one_backend_call_and_keeps_removed_bodies() { + struct ReplyOnly { + calls: usize, + } + impl crate::backend::Backend for ReplyOnly { + fn call(&mut self, method: &str, params: Value) -> Result { + assert_eq!(method, "engine.execute"); + assert_eq!(params, json!({"command": "test.feature", "params": {}})); + self.calls += 1; + Ok( + json!({"feature": 7, "name": "Combine1", "bodies": ["Base"], "created_bodies": [], "changed_bodies": ["Base"], "removed_bodies": ["Tool"]}), + ) + } + fn has_ui(&self) -> bool { + true + } + fn describe(&self) -> String { + "test".into() + } + } + let mut b = ReplyOnly { calls: 0 }; + let r = crate::tools::call_tool(&mut b, "execute", &json!({"command": "test.feature", "verbose": false})); + let v: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(b.calls, 1); + assert_eq!(v, json!({"feature": 7, "name": "Combine1", "created_bodies": [], "changed_bodies": ["Base"], "removed_bodies": ["Tool"]})); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 37f9c643..6d849019 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -68,6 +68,7 @@ pub fn tool_definitions() -> Vec { json!({ "command": string("Command id (see list_commands)"), "params": {"type": "object", "description": "The command's parameters"}, + "verbose": {"type": "boolean", "description": "false: omit the full feature body list and sketch ids repeated outside `created`; keep created_bodies, changed_bodies and removed_bodies (default true)"}, }), &["command"], ), @@ -344,7 +345,8 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map) -> Result { let id = str_arg(a, "command").ok_or("`command` is required")?; - exec(b, id, a.get("params").cloned().filter(Value::is_object).unwrap_or(json!({}))) + let v = exec(b, id, a.get("params").cloned().filter(Value::is_object).unwrap_or(json!({})))?; + Ok(if a.get("verbose").and_then(Value::as_bool).unwrap_or(true) { v } else { compact(v) }) } "inspect_design" => { let measure = a.get("measure").and_then(Value::as_bool).unwrap_or(true); @@ -484,7 +486,7 @@ fn batch(b: &mut dyn Backend, a: &Map) -> Value { text_result(&json!({"ok": true, "completed": results.len(), "results": results}), false) } -/// A step's result without what the design already had (`batch` with `verbose: false`): a +/// A command's result without what the design already had (`execute` or `batch` with `verbose: false`): a /// feature's full body list (its created, changed and removed bodies stay), and the sketch ids /// a sketch command repeats outside `created`. fn compact(mut v: Value) -> Value { diff --git a/docs/mcp.md b/docs/mcp.md index f2673117..ace021ed 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -50,7 +50,7 @@ Claude Desktop (`claude_desktop_config.json`), bridged to a running app: | Tool | Arguments | What it does | |---|---|---| | `list_commands` | `filter?` | The command catalog: id, label, tab/panel, shortcut, parameter docs, enabled now. `filter` matches id, label, tab and panel. | -| `execute` | `command`, `params?` | Run any command with JSON parameters (`list_commands` shows them). Failures leave the design unchanged. A feature command replies with the feature (`feature`, `name`), every body (`bodies`) and the bodies it created, changed and removed (`created_bodies`, `changed_bodies`, `removed_bodies`). | +| `execute` | `command`, `params?`, `verbose?` (default true) | Run any command with JSON parameters (`list_commands` shows them). Failures leave the design unchanged. A feature command replies with the feature (`feature`, `name`), every body (`bodies`) and the bodies it created, changed and removed (`created_bodies`, `changed_bodies`, `removed_bodies`). `verbose: false` uses the same compact reply as `batch`. | | `batch` | `commands: [{command, params}]`, `rollback?` (default true), `verbose?` (default true) | Run steps in order, stop at the first error and undo the completed steps. The reply names the failing step (`failed.index`, `failed.command`, `failed.error`) and every result before it. `verbose: false` keeps the reply small in large designs: feature steps leave out the full body list (`created_bodies`, `changed_bodies`, `removed_bodies` stay) and sketch steps the ids they repeat outside `created`. | | `inspect_design` | `measure?` (default true), `sketches?` (default true) | Parameters, timeline (type, suppressed, rolled back, error, warning, timing), bodies (volume, area, centre of mass, bbox, face/edge/vertex counts, visible), sketches (curves, points, constraints, profiles, DOF, solve status, visible), selection, undo depth. Hide helper sketches, bodies or construction planes with `execute` `browser.visibility` (`{items, visible}` or `{folder, visible}`). | | `measure` | `body?` | Volume (mm³), area (mm²), centre of mass, bbox and topology counts per body, with totals. | @@ -82,6 +82,23 @@ however long the history is. Batches that call `file.new` or `doc.open` get the not its file path. (An older app without checkpoints falls back to undoing as many steps as the batch added to the undo history.) +## Compact replies + +`execute` and `batch` default to `verbose: true`, preserving the full command results. +For long modelling sessions, set `verbose: false` at the tool-argument level: + +```json +{"name": "execute", "arguments": {"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}, "verbose": false}} +``` + +Feature replies omit the full `bodies` list while keeping `feature`, `name`, +`created_bodies`, `changed_bodies` and `removed_bodies`. A cut can therefore report a +changed body even when no body name was added or removed. Sketch replies keep new +IDs under `created` and omit only identical top-level `points`, `curves`, +`constraints` and `dimensions` lists. Other fields and errors are unchanged; +inspection commands still return their complete results. Compaction uses the +engine's reply directly, without an additional design-inspection request. + ## Demo: an enclosure built over MCP `examples/demo/enclosure_mcp.py` is a small MCP client. It drives a running app (`solidcraft