Files
OneCAD/crates/mcp/src/tests.rs
T
selune 8d58e3b9f9 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]>
2026-10-13 05:36:49 -04:00

624 lines
31 KiB
Rust

//! An agent builds parts through MCP only and verifies them through MCP only.
use std::f64::consts::PI;
use serde_json::{Value, json};
use crate::{Headless, PROTOCOL_VERSION, Server, tool_definitions};
fn server() -> Server {
Server::new(Box::new(Headless::default()))
}
fn call(s: &mut Server, id: u64, method: &str, params: Value) -> Value {
let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
let r = s.handle_line(&line).expect("reply");
serde_json::from_str(&r).unwrap()
}
fn raw_tool(s: &mut Server, name: &str, args: Value) -> Value {
call(s, 99, "tools/call", json!({"name": name, "arguments": args}))
}
/// Call a tool that must succeed; returns its JSON text (or the first content block).
fn tool(s: &mut Server, name: &str, args: Value) -> Value {
let r = raw_tool(s, name, args);
let c = &r["result"]["content"][0];
assert_ne!(r["result"]["isError"], json!(true), "{name} failed: {c}");
assert!(r.get("error").is_none(), "{name}: {r}");
if c["type"] == "text" { serde_json::from_str(c["text"].as_str().unwrap()).unwrap_or(Value::Null) } else { c.clone() }
}
#[test]
fn crash_reports_are_listed_and_read_without_changing_the_design() {
let mut s = server();
let list = tool(&mut s, "crash_reports", json!({"limit": 3}));
assert!(list["reports"].as_array().is_some_and(|r| r.len() <= 3), "{list}");
assert!(list["dir"].is_string(), "{list}");
let r = raw_tool(&mut s, "crash_reports", json!({"id": "../../etc"}));
assert_eq!(r["result"]["isError"], json!(true), "{r}");
let r = raw_tool(&mut s, "crash_reports", json!({"id": 5}));
assert!(r.get("error").is_some() || r["result"]["isError"] == json!(true), "{r}");
}
fn volume(s: &mut Server) -> f64 {
tool(s, "measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap()
}
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-12)
}
#[test]
fn handshake_and_tools_list() {
let mut s = server();
let init =
call(&mut s, 1, "initialize", json!({"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "t", "version": "0"}}));
assert_eq!(init["result"]["protocolVersion"], "2025-03-26");
assert_eq!(init["result"]["serverInfo"]["name"], "solidcraft");
assert!(init["result"]["capabilities"]["tools"].is_object());
assert!(init["result"]["instructions"].as_str().unwrap().contains("headless"));
// An unknown version gets ours.
let init = call(&mut s, 2, "initialize", json!({"protocolVersion": "1999-01-01"}));
assert_eq!(init["result"]["protocolVersion"], PROTOCOL_VERSION);
assert!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#).is_none());
assert!(s.is_initialized());
assert_eq!(call(&mut s, 3, "ping", json!({}))["result"], json!({}));
let tools = call(&mut s, 4, "tools/list", json!({}));
let names: Vec<&str> = tools["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect();
for want in [
"list_commands",
"execute",
"batch",
"inspect_design",
"measure",
"body_topology",
"set_parameter",
"screenshot",
"export",
"undo",
"redo",
"crash_reports",
"new_design",
"open",
"save",
] {
assert!(names.contains(&want), "{want} missing from {names:?}");
}
for t in tools["result"]["tools"].as_array().unwrap() {
assert_eq!(t["inputSchema"]["type"], "object", "{t}");
assert!(t["description"].as_str().unwrap().len() > 20, "{t}");
}
let cmds = tool(&mut s, "list_commands", json!({"filter": "fillet"}));
assert!(cmds.as_array().unwrap().iter().any(|c| c["id"] == "solid.fillet"), "{cmds}");
assert!(cmds.as_array().unwrap().len() < 5);
let res = call(&mut s, 5, "resources/list", json!({}));
assert_eq!(res["result"]["resources"].as_array().unwrap().len(), 2);
let d = call(&mut s, 6, "resources/read", json!({"uri": "solidcraft://design"}));
assert!(d["result"]["contents"][0]["text"].as_str().unwrap().contains("timeline"));
assert_eq!(call(&mut s, 7, "resources/read", json!({"uri": "solidcraft://nope"}))["error"]["code"], -32002);
assert_eq!(call(&mut s, 8, "nope/nope", json!({}))["error"]["code"], -32601);
}
/// Box → fillet → hole in one batch, then verify with measure / inspect_design and look at it.
#[test]
fn agent_builds_box_fillet_hole() {
let mut s = server();
let r = tool(
&mut s,
"batch",
json!({"commands": [
{"command": "sketch.create", "params": {"plane": "XY"}},
{"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 30]}},
{"command": "sketch.finish"},
{"command": "solid.extrude", "params": {"distance": 20}},
{"command": "solid.fillet", "params": {"edges": [[0, 0, 10]], "radius": 3}},
{"command": "sketch.create", "params": {"plane": "XY"}},
{"command": "sketch.circle.center", "params": {"center": [20, 15], "radius": 5}},
{"command": "solid.extrude", "params": {"distance": 20, "operation": "cut"}},
]}),
);
assert_eq!(r["completed"], 8, "{r}");
let expect = 24000.0 - (9.0 - PI * 9.0 / 4.0) * 20.0 - PI * 25.0 * 20.0;
let v = volume(&mut s);
assert!(rel(v, expect) < 5e-4, "{v} vs {expect}");
let m = tool(&mut s, "measure", json!({"body": "Body1"}));
// 6 box faces + fillet + hole wall (the kernel may keep the wall as two halves).
assert!((8..=9).contains(&m["bodies"][0]["faces"].as_u64().unwrap()), "{m}");
assert!(rel(m["bodies"][0]["bbox"]["max"][0].as_f64().unwrap(), 40.0) < 1e-6, "{m}");
let d = tool(&mut s, "inspect_design", json!({}));
assert_eq!(d["timeline"].as_array().unwrap().len(), 5, "{d}");
assert!(d["timeline"].as_array().unwrap().iter().all(|f| f["error"].is_null()), "{d}");
assert_eq!(d["sketches"][0]["curves"].as_array().unwrap().len(), 4, "{d}");
assert!(d["sketches"][0]["status"].is_string(), "{d}");
assert!(d["bodies"][0]["volume_mm3"].as_f64().unwrap() > 0.0);
let topo = tool(&mut s, "body_topology", json!({"body": "Body1"}));
assert!(topo["edges"].as_array().unwrap().len() >= 12, "{topo}");
assert!(topo["faces"].as_array().unwrap().len() >= 8, "{topo}");
// Undo the hole, redo it.
tool(&mut s, "undo", json!({}));
assert!(rel(volume(&mut s), 24000.0 - (9.0 - PI * 9.0 / 4.0) * 20.0) < 5e-4);
tool(&mut s, "redo", json!({}));
assert!(rel(volume(&mut s), expect) < 5e-4);
// Look at it from the top: a real PNG comes back as an image block.
let img = tool(&mut s, "screenshot", json!({"view": "top", "width": 320, "height": 200}));
assert_eq!(img["type"], "image");
assert_eq!(img["mimeType"], "image/png");
assert!(img["data"].as_str().unwrap().starts_with("iVBORw0KGgo"), "not a PNG");
// Export, save, start over, open.
let dir = std::env::temp_dir().join(format!("solidcraft-mcp-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let step = dir.join("part.step");
tool(&mut s, "export", json!({"path": step.to_string_lossy()}));
assert!(std::fs::read_to_string(&step).unwrap().contains("ISO-10303-21"));
let stl = dir.join("part.stl");
tool(&mut s, "export", json!({"path": stl.to_string_lossy(), "format": "stl"}));
assert!(std::fs::metadata(&stl).unwrap().len() > 1000);
let shot = dir.join("shot.png");
tool(&mut s, "screenshot", json!({"path": shot.to_string_lossy(), "width": 64, "height": 64}));
assert!(std::fs::metadata(&shot).unwrap().len() > 100);
let design = dir.join("part.solidcraft");
tool(&mut s, "save", json!({"path": design.to_string_lossy()}));
tool(&mut s, "new_design", json!({"name": "Empty"}));
assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 0);
tool(&mut s, "open", json!({"path": design.to_string_lossy()}));
assert!(rel(volume(&mut s), expect) < 5e-4);
// A STEP file opens as a new design with the same body.
let r = tool(&mut s, "open", json!({"path": step.to_string_lossy()}));
assert_eq!(r["features"], 1, "{r}");
assert!(rel(volume(&mut s), expect) < 5e-4);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_selects_container_or_json_and_reopens_by_contents() {
let mut s = server();
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 20, "height": 30}}));
let dir = std::env::temp_dir().join(format!("solidcraft-mcp-encodings-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
for (name, format, is_json) in [
("default.solidcraft", None, false),
("explicit.solidcraft", Some("json"), true),
("legacy.solvecraft", None, false),
("inferred.json", None, true),
("container.json", Some("container"), false),
] {
let path = dir.join(name);
let mut args = json!({"path": path.to_string_lossy()});
if let Some(format) = format {
args["format"] = json!(format);
}
tool(&mut s, "save", args);
let bytes = std::fs::read(&path).unwrap();
if is_json {
assert!(serde_json::from_slice::<Value>(&bytes).unwrap().is_object());
} else {
assert!(bytes.starts_with(b"PK\x03\x04"));
}
tool(&mut s, "new_design", json!({}));
tool(&mut s, "open", json!({"path": path.to_string_lossy()}));
assert!(rel(volume(&mut s), 6000.0) < 1e-9);
}
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn parameters_drive_the_design() {
let mut s = server();
tool(&mut s, "set_parameter", json!({"name": "len", "value": "40 mm"}));
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": "len", "width": 30, "height": "len / 2", "corner": [0, 0, 0]}}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 20.0) < 1e-9);
let r = tool(&mut s, "set_parameter", json!({"name": "len", "value": 50}));
assert_eq!(r["value"], 50.0, "{r}");
assert_eq!(r["errors"].as_array().unwrap().len(), 0, "{r}");
assert!(rel(volume(&mut s), 50.0 * 30.0 * 25.0) < 1e-9);
let d = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false}));
assert!(d["params"].as_array().unwrap().iter().any(|p| p["name"] == "len" && p["value"] == 50.0), "{d}");
}
#[test]
fn batch_stops_at_first_error_and_rolls_back() {
let mut s = server();
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}}));
let r = raw_tool(
&mut s,
"batch",
json!({"commands": [
{"command": "solid.sphere", "params": {"radius": 3, "center": [50, 0, 0]}},
{"command": "solid.extrude", "params": {"distance": "bogus +"}},
{"command": "solid.sphere", "params": {"radius": 3, "center": [80, 0, 0]}},
]}),
);
assert_eq!(r["result"]["isError"], true, "{r}");
let body: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(body["failed"]["index"], 1, "{body}");
assert_eq!(body["failed"]["command"], "solid.extrude");
assert_eq!(body["undone"], 1, "{body}");
assert_eq!(body["results"].as_array().unwrap().len(), 1);
// Only the first box is left, and its undo step is intact.
assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 1);
assert_eq!(tool(&mut s, "inspect_design", json!({"measure": false}))["undo"], 1);
// Without rollback the completed steps stay.
let r = raw_tool(
&mut s,
"batch",
json!({"rollback": false, "commands": [{"command": "solid.sphere", "params": {"radius": 3, "center": [50, 0, 0]}}, {"command": "NoSuchCommand"}]}),
);
assert_eq!(r["result"]["isError"], true);
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("NoSuchCommand"));
assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 2);
}
/// Regression: with a full undo history a failed batch reported `undone: 0` and left its
/// sketches behind (an empty "Sketch1", two sketches named "TapHole").
#[test]
fn batch_rolls_back_with_a_full_undo_history() {
let mut s = server();
let fill: Vec<Value> =
(0..230).map(|i| json!({"command": "parameters.add", "params": {"name": format!("p{i}"), "expression": "1 mm"}})).collect();
tool(&mut s, "batch", json!({"commands": fill}));
let undo = tool(&mut s, "inspect_design", json!({"measure": false}))["undo"].clone();
for _ in 0..2 {
let r = raw_tool(
&mut s,
"batch",
json!({"commands": [
{"command": "sketch.create", "params": {"plane": "XY", "name": "TapHole"}},
{"command": "sketch.circle.center", "params": {"center": [0, 0], "radius": 2}},
{"command": "sketch.dimension", "params": {"entities": ["nope"], "value": 3}},
]}),
);
let body: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(body["failed"]["index"], 2, "{body}");
assert_eq!(body["undone"], 2, "{body}");
}
let d = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false}));
assert_eq!(d["sketches"].as_array().map(Vec::len), Some(0), "{d}");
assert!(d["active_sketch"].is_null(), "{d}");
assert_eq!(d["undo"], undo);
}
#[test]
fn helper_sketches_can_be_hidden() {
let mut s = server();
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 40, "width": 40, "height": 2}}));
let sk = tool(&mut s, "execute", json!({"command": "sketch.create", "params": {"plane": "XY"}}))["sketch"].clone();
tool(&mut s, "execute", json!({"command": "sketch.line", "params": {"points": [[0, 0], [40, 0]], "construction": true}}));
tool(&mut s, "execute", json!({"command": "sketch.finish"}));
let visible = |s: &mut Server| tool(s, "inspect_design", json!({"measure": false}))["sketches"][0]["visible"].clone();
assert_eq!(visible(&mut s), true);
tool(&mut s, "execute", json!({"command": "browser.visibility", "params": {"items": [sk], "visible": false}}));
assert_eq!(visible(&mut s), false);
let r = raw_tool(&mut s, "execute", json!({"command": "browser.visibility", "params": {"items": ["Nope"], "visible": false}}));
assert_eq!(r["result"]["isError"], true);
tool(&mut s, "execute", json!({"command": "browser.visibility", "params": {"folder": "sketches", "visible": true}}));
assert_eq!(visible(&mut s), true);
}
#[test]
fn unknown_tools_and_bad_arguments_are_clean_errors() {
let mut s = server();
let r = raw_tool(&mut s, "make_coffee", json!({}));
assert_eq!(r["error"]["code"], -32602, "{r}");
assert!(r["error"]["message"].as_str().unwrap().contains("make_coffee"));
assert_eq!(raw_tool(&mut s, "execute", json!({}))["error"]["code"], -32602);
assert_eq!(raw_tool(&mut s, "execute", json!({"command": 5}))["error"]["code"], -32602);
assert_eq!(raw_tool(&mut s, "measure", json!({"bogus": 1}))["error"]["code"], -32602);
assert_eq!(raw_tool(&mut s, "screenshot", json!({"source": "sideways"}))["error"]["code"], -32602);
// `view` also takes saved named views, so an unknown name is a tool error naming it.
let r = raw_tool(&mut s, "screenshot", json!({"view": "sideways"}));
assert_eq!(r["result"]["isError"], true, "{r}");
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("sideways"), "{r}");
assert_eq!(call(&mut s, 1, "tools/call", json!({"arguments": {}}))["error"]["code"], -32602);
// Engine errors are tool errors the model can read.
let r = raw_tool(&mut s, "execute", json!({"command": "solid.extrude", "params": {"distance": 10}}));
assert_eq!(r["result"]["isError"], true, "{r}");
let r = raw_tool(&mut s, "execute", json!({"command": "NoSuchCommand"}));
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("unknown command"));
// Framing errors.
assert!(s.handle_line("{not json").unwrap().contains("-32700"));
assert!(s.handle_line("[]").unwrap().contains("-32600"));
assert!(s.handle_line("42").unwrap().contains("-32600"));
assert!(s.handle_line(" ").is_none());
assert!(s.handle_line(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#).is_none());
let both = s.handle_line(r#"[{"jsonrpc":"2.0","id":1,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/x"}]"#).unwrap();
assert_eq!(serde_json::from_str::<Value>(&both).unwrap().as_array().unwrap().len(), 1);
}
fn hostile_values() -> Vec<Value> {
vec![
Value::Null,
json!(true),
json!(-1),
json!(0),
json!(1e308),
json!(-1e308),
json!(""),
json!("nope"),
json!("../../../../../../nonexistent/dir/x.step"),
json!("1/0"),
json!([]),
json!([1e308, -1e308]),
json!([{"command": "solid.extrude", "params": {"distance": 1e308}}]),
json!([{"command": 7}, null, "x"]),
json!({}),
json!({"x": 1}),
]
}
/// Every tool, every argument it documents, every hostile value: replies are well-formed
/// JSON-RPC and nothing panics.
#[test]
fn hostile_arguments_never_panic() {
let mut s = server();
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}}));
let defs = tool_definitions();
let mut n = 0;
for def in &defs {
let name = def["name"].as_str().unwrap();
// Hostile strings are relative paths: don't let the sweep write files into the crate.
let writes = matches!(name, "save" | "screenshot" | "export");
let keys: Vec<String> = def["inputSchema"]["properties"].as_object().unwrap().keys().filter(|k| !(writes && *k == "path")).cloned().collect();
for k in &keys {
for v in hostile_values() {
let mut args = json!({});
// Fill the other required arguments with something plausible.
for r in def["inputSchema"]["required"].as_array().into_iter().flatten() {
args[r.as_str().unwrap()] = json!("Body1");
}
args[k.as_str()] = v;
if name == "screenshot" && (k == "width" || k == "height") {
args["view"] = json!("front");
}
let r = raw_tool(&mut s, name, args.clone());
assert!(r.get("result").is_some() || r["error"]["code"].is_i64(), "{name} {args}: {r}");
n += 1;
}
}
}
assert!(n > 100);
// Execute every command with hostile params through MCP too.
let cmds = tool(&mut s, "list_commands", json!({}));
for c in cmds.as_array().unwrap() {
let id = c["id"].as_str().unwrap();
if matches!(id, "doc.open" | "file.save" | "file.save_as" | "file.export" | "file.save_mesh") {
continue;
}
for v in hostile_values() {
let r = raw_tool(&mut s, "execute", json!({"command": id, "params": {"distance": v.clone(), "edges": v.clone(), "plane": v}}));
assert!(r.get("result").is_some(), "{id}: {r}");
}
}
// Arguments that are not an object.
for v in hostile_values().into_iter().filter(|v| !v.is_object() && !v.is_null()) {
let r = call(&mut s, 1, "tools/call", json!({"name": "measure", "arguments": v}));
assert_eq!(r["error"]["code"], -32602, "{r}");
}
}
#[test]
fn base64_matches_rfc4648() {
use crate::tools::base64;
for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")] {
assert_eq!(base64(i.as_bytes()), o);
}
}
/// Filtering by a command id returns that command, not every command whose docs mention it (#269).
#[test]
fn list_commands_filter_by_a_command_id_matches_ids() {
let mut s = server();
let cmds = tool(&mut s, "list_commands", json!({"filter": "sketch.delete"}));
let ids: Vec<&str> = cmds.as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect();
assert!(ids.contains(&"sketch.delete"), "{ids:?}");
assert!(ids.iter().all(|id| id.contains("sketch.delete")), "{} commands matched: {ids:?}", ids.len());
}
/// Compatibility shims (docs/compatibility.md): a design saved before the rename (legacy
/// extension and format tag) opens through MCP, and the legacy resource URIs still read.
#[test]
fn legacy_design_files_and_resource_uris() {
let mut s = server();
let saved = String::from_utf8(solidcraft_engine::io::write_design(&solidcraft_engine::doc::Document::default())).unwrap();
assert!(saved.contains("\"solidcraft/"), "{saved}");
let legacy = saved.replace("\"solidcraft/", "\"solvecraft/");
let path = std::env::temp_dir().join(format!("solidcraft-mcp-legacy-{}.solvecraft", std::process::id()));
std::fs::write(&path, legacy).unwrap();
tool(&mut s, "open", json!({"path": path.to_string_lossy()}));
let _ = std::fs::remove_file(&path);
let d = call(&mut s, 1, "resources/read", json!({"uri": "solvecraft://design"}));
assert!(d["result"]["contents"][0]["text"].as_str().unwrap().contains("timeline"));
}
/// #528: an agent saves the angle of a product photo as a named view once, then renders the
/// model from that view after each change. The render must use the saved camera, not a
/// standard view.
#[test]
fn screenshot_renders_a_saved_named_view() {
let mut s = server();
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 40, "width": 20, "height": 10}}));
let camera = json!({"target": [20, 10, 5], "yaw": 0.5, "pitch": -0.6, "distance": 150});
tool(&mut s, "execute", json!({"command": "view.save", "params": {"name": "Photo", "camera": camera.clone()}}));
let shot = |s: &mut Server, view: &str| {
let img = tool(s, "screenshot", json!({"view": view, "width": 96, "height": 64}));
img["data"].as_str().unwrap_or_default().to_string()
};
let iso = shot(&mut s, "iso");
let photo = shot(&mut s, "Photo");
assert!(photo.starts_with("iVBORw0KGgo"), "not a PNG");
assert_ne!(photo, iso, "the named view rendered like the standard iso view");
// The same camera given directly renders the same picture; a standard view turns back.
let direct = tool(&mut s, "screenshot", json!({"camera": camera, "width": 96, "height": 64}));
assert_eq!(direct["data"].as_str().unwrap_or_default(), photo);
assert_eq!(shot(&mut s, "iso"), iso);
}
/// #530: in a design with many bodies every feature result lists all of them, so a batch reply
/// is mostly the same body list once per step. A compact batch reply names only what each step
/// added or changed.
#[test]
fn compact_batch_reply_does_not_repeat_the_body_list() {
let mut s = server();
let cube =
|x: i64, y: i64| json!({"command": "solid.box", "params": {"length": 1, "width": 1, "height": 1, "corner": [x, y, 0], "operation": "new"}});
let existing: Vec<Value> = (0..50).map(|i| cube(2 * i, 0)).collect();
tool(&mut s, "batch", json!({"commands": existing}));
let more: Vec<Value> = (0..3).map(|i| cube(2 * i, 5)).collect();
let r = raw_tool(&mut s, "batch", json!({"commands": more, "verbose": false}));
assert!(r.get("error").is_none() && r["result"]["isError"] != json!(true), "{r}");
let reply = r["result"]["content"][0]["text"].as_str().unwrap_or_default();
assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 53);
// The new bodies are named; the 50 untouched ones are not repeated.
for new in ["Body51", "Body52", "Body53"] {
assert!(reply.contains(&format!("\"{new}\"")), "{new} missing from the compact reply: {reply}");
}
for old in ["Body1", "Body25", "Body50"] {
assert!(!reply.contains(&format!("\"{old}\"")), "untouched {old} repeated in the compact reply ({} bytes): {reply}", reply.len());
}
}
/// A compact batch reply keeps what each step did: the body a cut changed, and a sketch
/// command's new ids once (under `created`); the default reply is unchanged.
#[test]
fn compact_batch_reply_keeps_changed_bodies_and_created_ids() {
let mut s = server();
let steps = 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 = tool(&mut s, "batch", json!({"commands": steps}));
assert_eq!(full["results"][1]["bodies"], json!(["Base"]), "{full}");
assert_eq!(full["results"][3]["curves"], full["results"][3]["created"]["curves"], "{full}");
tool(&mut s, "new_design", json!({}));
let r = tool(&mut s, "batch", json!({"commands": steps, "verbose": false}));
let cut = &r["results"][1];
assert!(cut.get("bodies").is_none(), "{r}");
assert_eq!(cut["changed_bodies"], json!(["Base"]), "{r}");
let rect = &r["results"][3];
assert!(rect.get("curves").is_none() && rect.get("constraints").is_none(), "{r}");
assert_eq!(rect["created"]["curves"].as_array().map(Vec::len), Some(4), "{r}");
assert!(rect["sides"].is_object(), "results that are not repeated stay: {r}");
}
/// #531: a product model keeps an optional variant hidden in its default presentation. Hiding
/// it must survive saving and reopening the design (suppressing it would change the design).
#[test]
fn hidden_body_stays_hidden_after_save_and_reopen() {
let mut s = server();
tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "Base"}}));
tool(
&mut s,
"execute",
json!({"command": "solid.box", "params": {"length": 5, "width": 5, "height": 5, "corner": [20, 0, 0], "operation": "new", "body_name": "Variant"}}),
);
tool(&mut s, "execute", json!({"command": "browser.visibility", "params": {"items": ["Variant"], "visible": false}}));
let visible = |s: &mut Server, body: &str| {
let d = tool(s, "inspect_design", json!({"measure": false}));
d["bodies"].as_array().into_iter().flatten().find(|b| b["name"] == body).map(|b| b["visible"].clone()).unwrap_or(Value::Null)
};
assert_eq!(visible(&mut s, "Variant"), false);
let dir = std::env::temp_dir().join(format!("solidcraft-mcp-visibility-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let design = dir.join("variants.solidcraft");
tool(&mut s, "save", json!({"path": design.to_string_lossy()}));
tool(&mut s, "new_design", json!({"name": "Empty"}));
tool(&mut s, "open", json!({"path": design.to_string_lossy()}));
let (base, variant) = (visible(&mut s, "Base"), visible(&mut s, "Variant"));
let _ = std::fs::remove_dir_all(&dir);
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"]}));
}