feat(mcp): support compact single execute replies (#591)

MCP execute accepts verbose: false too, using the same compact reply as batch (#599), and the MCP docs gain a section on compact replies.

Co-authored-by: lillith <[email protected]>
This commit is contained in:
selune authored and GitHub committed 2026-10-13 05:36:49 -04:00
1 parent f0d80a1d7e
commit 8d58e3b9f9
4 files changed
+107 -3

No files matched your search

+4
View File
@@ -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.
+81
View File
@@ -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<Value> = 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<Value, String> {
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"]}));
}
+4 -2
View File
@@ -68,6 +68,7 @@ pub fn tool_definitions() -> Vec<Value> {
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<String, Value>) -> Result<V
}
"execute" => {
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<String, Value>) -> 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 {
+18 -1
View File
@@ -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