//! 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() } } 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"], "solvecraft"); 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", "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": "solvecraft://design"})); assert!(d["result"]["contents"][0]["text"].as_str().unwrap().contains("timeline")); assert_eq!(call(&mut s, 7, "resources/read", json!({"uri": "solvecraft://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!("solvecraft-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.solvecraft"); 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 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 = (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!({"view": "sideways"}))["error"]["code"], -32602); 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::(&both).unwrap().as_array().unwrap().len(), 1); } fn hostile_values() -> Vec { 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 = 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()); }