Files
OneCAD-Simulation/crates/mcp/tests/workflow.rs
T
0899587127 MCP server: modelcraft-cli mcp (headless or bridged), read/write roots, end-to-end analysis over MCP (#48)
* io: read and write roots that confine an automation client's file access

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* engine: every file a command reads or writes is checked against the session's roots

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* engine: headless pictures of a session (model, geometry, deformed shape and contours)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* mcp: MCP server over stdio with headless and connected backends, roots and caps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* cli: modelcraft-cli mcp [--connect ADDR | --headless] with automation read and write roots

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* mcp: end-to-end analysis of a corpus part over MCP (open, tet mesh, define, solve, probe, render)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* docs: MCP server (docs/mcp.md), Claude Code setup, roots; architecture, README, roadmap, attribution

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:18:08 -04:00

142 lines
7.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! A whole analysis driven only through MCP messages on a headless, confined server: open a
//! SolidCraft design built from a corpus recipe, mesh it, define sets, material, section,
//! boundary condition, load and step, solve, read the largest displacement and render a PNG.
use std::path::PathBuf;
use modelcraft_io::roots::Roots;
use modelcraft_mcp::{Headless, Server};
use serde_json::{Value, json};
struct Client {
server: Server,
next: u64,
}
impl Client {
fn request(&mut self, method: &str, params: Value) -> Value {
self.next += 1;
let line = json!({"jsonrpc": "2.0", "id": self.next, "method": method, "params": params}).to_string();
let reply: Value = serde_json::from_str(&self.server.handle_line(&line).unwrap()).unwrap();
assert_eq!(reply["id"], self.next);
assert!(reply.get("error").is_none(), "{method}: {reply}");
reply["result"].clone()
}
/// A tool call that must succeed: its content blocks.
fn tool(&mut self, name: &str, arguments: Value) -> Vec<Value> {
let r = self.request("tools/call", json!({"name": name, "arguments": arguments}));
let content = r["content"].as_array().unwrap().clone();
assert!(r["isError"] != json!(true), "{name}: {}", content[0]["text"]);
content
}
/// A tool call's JSON reply.
fn json(&mut self, name: &str, arguments: Value) -> Value {
let content = self.tool(name, arguments);
serde_json::from_str(content[0]["text"].as_str().unwrap()).unwrap()
}
}
fn base64_decode(s: &str) -> Vec<u8> {
let value = |c: u8| match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'+' => 62,
_ => 63,
};
let bytes: Vec<u8> = s.bytes().filter(|c| *c != b'=').collect();
let mut out = Vec::new();
for chunk in bytes.chunks(4) {
let n = chunk.iter().enumerate().fold(0u32, |n, (i, c)| n | (u32::from(value(*c)) << (18 - 6 * i)));
out.extend(n.to_be_bytes().into_iter().skip(1).take(chunk.len() - 1));
}
out
}
#[test]
fn a_corpus_part_is_meshed_loaded_solved_and_rendered_over_mcp() {
let dir: PathBuf = std::env::temp_dir().join(format!("modelcraft-mcp-workflow-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let dir = std::fs::canonicalize(&dir).unwrap();
let part = modelcraft_testkit::corpus::read_recipe(&modelcraft_testkit::corpus::corpus_dir().join("stepped-block")).unwrap();
let design = dir.join("steps.solidcraft");
std::fs::write(&design, modelcraft_testkit::corpus::design_bytes(&part).unwrap()).unwrap();
let roots = Roots::new(Some(&dir), Some(&dir)).unwrap();
let mut c = Client { server: Server::new(Box::new(Headless::new(roots))), next: 0 };
let init = c.request("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "1"}}));
assert_eq!(init["serverInfo"]["name"], "modelcraft");
assert!(c.server.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#).is_none());
// Open the design and look at its faces.
let opened = c.json("open", json!({"path": design}));
assert_eq!(opened["command"], "geometry.open");
let model = c.json("inspect_model", json!({}));
let body = &model["bodies"][0];
let faces: Vec<&str> = body["faces"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
// The union's x = 0 end (one face, from the first box) and the bottom step's x = 60 end.
assert!(faces.contains(&"F1:-x") && faces.contains(&"F1:+x"), "{faces:?}");
// Mesh: constrained Delaunay tets, every boundary face tagged with its B-rep face.
let meshed = c.json("mesh", json!({"method": "tet", "params": {"body": "Steps", "tolerance": 0.5}}));
let tets = meshed["model"]["elements"]["tet4"].as_u64().unwrap();
assert!(tets > 10, "{meshed}");
assert_eq!((meshed["quality"]["valid"].as_u64(), meshed["quality"]["invalid"]["count"].as_u64()), (Some(tets), Some(0)), "{}", meshed["quality"]);
// Steel in mm-t-s-N-MPa, clamped at x = 0, 1 kN down on the x = 60 end, one static step.
let script = json!([
{"command": "model.units", "params": {"system": "mm-t-s-N-MPa"}},
{"command": "model.set.create", "params": {"name": "all", "geometry": [{"body": "Steps"}]}},
{"command": "model.set.create", "params": {"name": "root", "geometry": [{"body": "Steps", "face": "F1:-x"}]}},
{"command": "model.set.create", "params": {"name": "tip", "geometry": [{"body": "Steps", "face": "F1:+x"}]}},
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}, "density": 7.85e-9}},
{"command": "model.section.create", "params": {"name": "solid", "set": "all", "material": "Steel", "kind": {"type": "solid"}}},
{"command": "model.boundary_condition.create", "params": {"name": "clamp", "type": "fixed", "target": {"set": "root"}}},
{"command": "model.load.create", "params": {"name": "push", "type": "force", "target": {"set": "tip"}, "vector": [0, 0, -1000]}},
{"command": "model.step.create", "params": {"name": "Load", "procedure": {"type": "static"}, "boundary_conditions": ["clamp"], "loads": ["push"]}},
]);
let batch = c.json("batch", json!({"commands": script}));
assert_eq!(batch["completed"], 9, "{batch}");
let check = c.json("check_model", json!({}));
assert_eq!(check["errors"], 0, "{check}");
// Solve, then read the largest displacement from the contour range and the reactions.
let solved = c.json("solve", json!({"analysis": "static", "step": "Load"}));
assert!(solved.is_object(), "{solved}");
let shown = c.json("show_results", json!({"step": "Load", "field": "displacement", "deformed": true}));
let max = &shown["contour"]["range"]["max"];
let largest = max["value"].as_f64().unwrap();
// Order of magnitude: a 60 mm steel cantilever, 40 × 10 at the tip end, loaded with 1 kN,
// bends a few micrometres (a coarse linear tet mesh is stiff, so a little less).
assert!(largest > 1e-4 && largest < 0.1, "{shown}");
let node = max["node"].as_u64().unwrap();
let probe = c.json("probe", json!({"step": "Load", "nodes": [node]}));
let u = probe["nodes"][0]["displacement"].as_array().unwrap();
let norm = u.iter().map(|v| v.as_f64().unwrap().powi(2)).sum::<f64>().sqrt();
assert!((norm - largest).abs() <= 1e-9 * largest, "{probe}");
assert!(u[2].as_f64().unwrap() < 0.0, "the tip goes down: {probe}");
let reactions = c.json("reactions", json!({"step": "Load"}));
let fz = reactions["total"]["force"][2].as_f64().unwrap();
assert!((fz - 1000.0).abs() < 1e-6, "the clamp carries the load: {reactions}");
// Look at it: a PNG image block, also saved inside the write root.
let picture = dir.join("load.png");
let content = c.tool("screenshot", json!({"view": "iso", "width": 480, "height": 320, "path": picture}));
assert_eq!(content[0]["type"], "image");
assert_eq!(content[0]["mimeType"], "image/png");
let png = base64_decode(content[0]["data"].as_str().unwrap());
assert!(png.starts_with(&[0x89, b'P', b'N', b'G']));
assert_eq!(std::fs::read(&picture).unwrap(), png);
let info: Value = serde_json::from_str(content[1]["text"].as_str().unwrap()).unwrap();
assert_eq!(info["results"]["step"], "Load", "{info}");
// Save the model and write a deck, both inside the root.
c.json("save", json!({"path": dir.join("steps.modelcraft")}));
c.json("write_deck", json!({"format": "abaqus", "path": dir.join("steps.inp")}));
assert!(dir.join("steps.modelcraft").is_file() && dir.join("steps.inp").is_file());
let _ = std::fs::remove_dir_all(&dir);
}