diff --git a/AGENTS.md b/AGENTS.md index 04a51ec..d55d872 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,6 +122,8 @@ People trust ModelCraft with their models; a crash loses their work. **This outr look at the result: `ui.capture {path}` (the whole window drawn offscreen; works with the screen locked), `ui.screenshot {path}` (a presented frame) or `ui.render {path}` (the model only), then read the PNG. +- `cargo run -p modelcraft-cli -- mcp [--connect 127.0.0.1:PORT]` serves MCP on stdio (docs/mcp.md): + the same commands as tools, headless or bridged to the running app. - Build with `CARGO_TARGET_DIR=/target`. Parallel agents: separate target dirs, one worktree each. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index f009e5f..ed41d30 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -166,6 +166,8 @@ is committed and listed here with its exports. | crates/io/src/fs.rs (atomic writes) | SolidCraft `crates/io/src/safe_write.rs` | MIT OR Apache-2.0 | | crates/io/src/recovery.rs (recovery folder, entries, locks) | SolidCraft `crates/engine/src/recovery.rs` | MIT OR Apache-2.0 | | apps/modelcraft/src/control_server.rs | SolidCraft `apps/solidcraft/src/control_server.rs` | MIT OR Apache-2.0 | +| crates/mcp/src/ (JSON-RPC framing, MCP lifecycle, tool schema checks, the control-channel backend, base64) | SolidCraft `crates/mcp/src/` | MIT OR Apache-2.0 | +| crates/io/src/roots.rs (the rules of read and write roots) | VectorCraft's automation roots (docs/mcp.md there) | MIT OR Apache-2.0 | ## Test oracles diff --git a/Cargo.lock b/Cargo.lock index b12161b..f7a388c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1874,6 +1874,8 @@ name = "modelcraft-cli" version = "0.1.0" dependencies = [ "modelcraft-engine", + "modelcraft-io", + "modelcraft-mcp", "serde_json", ] @@ -1899,6 +1901,7 @@ dependencies = [ "modelcraft-lbm", "modelcraft-mesh", "modelcraft-model", + "modelcraft-render", "modelcraft-solver", "serde", "serde_json", @@ -1962,6 +1965,17 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "modelcraft-mcp" +version = "0.1.0" +dependencies = [ + "modelcraft-engine", + "modelcraft-io", + "modelcraft-render", + "modelcraft-testkit", + "serde_json", +] + [[package]] name = "modelcraft-mesh" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index c5df644..eba5118 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ modelcraft-engine = { path = "crates/engine" } modelcraft-testkit = { path = "crates/testkit" } modelcraft-render = { path = "crates/render" } modelcraft-ui-egui = { path = "crates/ui-egui" } +modelcraft-mcp = { path = "crates/mcp" } # foundation thiserror = "2" diff --git a/README.md b/README.md index e493e57..cb5bfe5 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ cargo xtask ci # fmt, assets, licences, layers, cargo xtask test --heavy # the heavy (ignored) tests and the doc tests cargo run -p modelcraft-cli -- commands # every command and its parameters cargo run -p modelcraft-cli -- run script.json # run a command script +cargo run -p modelcraft-cli -- mcp # MCP server on stdio for AI agents (docs/mcp.md) ``` Rust 1.95 or later, with the `wasm32-unknown-unknown` target for `cargo xtask ci`. @@ -24,6 +25,8 @@ Rust 1.95 or later, with the `wasm32-unknown-unknown` target for `cargo xtask ci - [Architecture](docs/architecture.md): crates, layers and the meshing pipeline - [Libraries and licences](docs/adr/0001-libraries.md) +- [AI agents (MCP)](docs/mcp.md): `claude mcp add modelcraft -- /path/to/modelcraft-cli mcp` +- [Control protocol](docs/control-protocol.md): driving the desktop app - [Contributing](CONTRIBUTING.md) and [agent instructions](AGENTS.md) ## License diff --git a/ROADMAP.md b/ROADMAP.md index 9ac48ea..ff7477f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -34,4 +34,4 @@ hex meshes today and it gives the best elements where stresses are read. | M13 Fluid–structure interaction | Immersed-boundary coupling of the lattice Boltzmann fluid with the explicit or implicit structure; interface force transfer; sub-cycling; Aitken then interface quasi-Newton coupling; free-surface flow | planned | | M14 Blast and impact fluids | EOS-driven fluids in the explicit solver: ALE multi-material or SPH, with fluid–structure contact | planned | | Continuous: verification and validation | Every capability checked against theory (closed forms), experiments (published measurements) and published benchmarks (NAFEMS, vendor benchmark and verification manuals such as Abaqus's), each case our own model in `tests/oracle/` with its cited reference and tolerance; `cargo xtask oracle` runs them and writes docs/oracle.md; once a case matches, our result is frozen as a fixture so fidelity cannot regress (docs/verification.md). Runner, fixtures and CI job: done; 26 theory cases (patch tests of every solid element, cantilevers, Lamé in 3D, plane strain and axisymmetric, Kirsch in 3D and plane stress, rotating disc and cylinder, beam frequencies, couplings, explicit waves, impact and J2 plasticity, CDT invariants), 9 published benchmarks (MacNeal–Harder solid tests, Scordelis–Lo with solids, NAFEMS LE1 in 3D and 2D, LE10, FV32) and 9 pending cases with citations (shells, thermal, static plasticity, Taylor bar, CFD); `mesh.block` mapped blocks and 2D patches build their meshes | started | -| Continuous: desktop and agents | Desktop app (viewport, model tree, commands, control channel, the open design drawn, `ui.capture` of the whole window offscreen: done; toolbar of every capability from the catalogue, ready or coming soon with its milestone, icons drawn with VectorCraft, docs/features.md: done; the Craft-family shell — title bar, workspaces and ribbon drop-downs, browser, context panel, command search, journal timeline, light and dark themes — native file pickers with background opening, drag and drop and recent files, samples and one-click Solve: done), MCP server, browser build, command schema as the public API | started | +| Continuous: desktop and agents | Desktop app (viewport, model tree, commands, control channel, the open design drawn, `ui.capture` of the whole window offscreen: done; toolbar of every capability from the catalogue, ready or coming soon with its milestone, icons drawn with VectorCraft, docs/features.md: done; the Craft-family shell — title bar, workspaces and ribbon drop-downs, browser, context panel, command search, journal timeline, light and dark themes — native file pickers with background opening, drag and drop and recent files, samples and one-click Solve: done; MCP server over stdio, headless or bridged to the app, with read and write roots, docs/mcp.md: done), browser build, command schema as the public API | started | diff --git a/apps/modelcraft-cli/Cargo.toml b/apps/modelcraft-cli/Cargo.toml index e3c1a00..d27aa98 100644 --- a/apps/modelcraft-cli/Cargo.toml +++ b/apps/modelcraft-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "modelcraft-cli" -description = "ModelCraft from the command line: list commands, run scripts" +description = "ModelCraft from the command line: list commands, run scripts, serve MCP" version.workspace = true edition.workspace = true license.workspace = true @@ -10,4 +10,6 @@ workspace = true [dependencies] modelcraft-engine = { workspace = true } +modelcraft-io = { workspace = true } +modelcraft-mcp = { workspace = true } serde_json = { workspace = true } diff --git a/apps/modelcraft-cli/src/main.rs b/apps/modelcraft-cli/src/main.rs index c2ca1f3..24159f5 100644 --- a/apps/modelcraft-cli/src/main.rs +++ b/apps/modelcraft-cli/src/main.rs @@ -1,9 +1,11 @@ //! modelcraft-cli commands //! modelcraft-cli run //! modelcraft-cli features +//! modelcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR] [--automation-write-root DIR] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +use std::path::Path; use std::process::ExitCode; use modelcraft_engine::Session; @@ -16,6 +18,14 @@ usage: and print the model summary modelcraft-cli features print docs/features.md (every capability, ready or coming soon) from the capability catalogue + modelcraft-cli mcp [options] serve the Model Context Protocol on stdin/stdout + (docs/mcp.md): + --headless an in-process session (the default) + --connect ADDR drive the app at ADDR (`modelcraft --control PORT`); + headless when it cannot be reached + --automation-read-root DIR files may be read only inside DIR + --automation-write-root DIR files may be written only inside DIR (with either root, + the access a root left out is not granted) "; /// Largest script accepted (bytes). @@ -34,6 +44,7 @@ fn main() -> ExitCode { print!("{}", modelcraft_engine::catalogue::features_page()); Ok(()) } + Some("mcp") => mcp(args.get(1..).unwrap_or(&[])), Some("run") => args.get(1).ok_or_else(|| format!("run needs a script\n\n{USAGE}")).and_then(|p| run(p)), Some("-h" | "--help" | "help") | None => { print!("{USAGE}"); @@ -50,6 +61,54 @@ fn main() -> ExitCode { } } +/// The `mcp` options: each given once, as `--flag value` or `--flag=value`. +fn mcp_options(args: &[String]) -> Result { + let mut connect = None; + let mut headless = false; + let mut read = None; + let mut write = None; + let mut it = args.iter(); + while let Some(arg) = it.next() { + let (flag, inline) = match arg.split_once('=') { + Some((f, v)) if f.starts_with("--") => (f, Some(v.to_string())), + _ => (arg.as_str(), None), + }; + let mut value = || inline.clone().or_else(|| it.next().cloned()).filter(|v| !v.is_empty()).ok_or_else(|| format!("{flag} needs a value")); + let slot = match flag { + "--headless" if inline.is_none() => { + if std::mem::replace(&mut headless, true) { + return Err("--headless is given twice".into()); + } + continue; + } + "--connect" => &mut connect, + "--automation-read-root" => &mut read, + "--automation-write-root" => &mut write, + other => return Err(format!("mcp: unknown option `{other}`\n\n{USAGE}")), + }; + if slot.replace(value()?).is_some() { + return Err(format!("{flag} is given twice")); + } + } + if headless && connect.is_some() { + return Err("--connect and --headless exclude each other".into()); + } + let roots = match (&read, &write) { + (None, None) => modelcraft_io::roots::Roots::default(), + _ => modelcraft_io::roots::Roots::new(read.as_deref().map(Path::new), write.as_deref().map(Path::new)).map_err(|e| e.to_string())?, + }; + Ok(modelcraft_mcp::Options { connect, roots }) +} + +fn mcp(args: &[String]) -> Result<(), String> { + let (backend, note) = modelcraft_mcp::backend(mcp_options(args)?)?; + if let Some(note) = note { + eprintln!("modelcraft-cli mcp: {note}"); + } + let mut server = modelcraft_mcp::Server::new(backend); + server.serve(std::io::stdin().lock(), std::io::stdout().lock()).map_err(|e| format!("mcp: {e}")) +} + fn run(path: &str) -> Result<(), String> { let size = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?.len(); if size > MAX_SCRIPT_BYTES { @@ -63,3 +122,35 @@ fn run(path: &str) -> Result<(), String> { println!("{}", serde_json::to_string_pretty(&info).map_err(|e| e.to_string())?); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn opts(args: &[&str]) -> Result { + mcp_options(&args.iter().map(|s| s.to_string()).collect::>()) + } + + #[test] + fn mcp_options_are_checked() { + let dir = std::env::temp_dir(); + let d = dir.to_string_lossy(); + assert!(opts(&[]).unwrap().connect.is_none()); + assert_eq!(opts(&["--connect", "127.0.0.1:7070"]).unwrap().connect.as_deref(), Some("127.0.0.1:7070")); + assert_eq!(opts(&["--connect=127.0.0.1:7070"]).unwrap().connect.as_deref(), Some("127.0.0.1:7070")); + assert!(opts(&["--automation-read-root", &d]).unwrap().roots.is_confined()); + assert!(opts(&[&format!("--automation-write-root={d}")]).unwrap().roots.is_confined()); + for bad in [ + vec!["--connect"], + vec!["--connect", "a", "--connect", "b"], + vec!["--headless", "--headless"], + vec!["--headless", "--connect", "a"], + vec!["--automation-read-root", "/no/such/folder"], + vec!["--automation-read-root="], + vec!["--nope"], + vec!["--headless=yes"], + ] { + assert!(opts(&bad).is_err(), "{bad:?}"); + } + } +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index dc95539..f402d6d 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -18,6 +18,7 @@ modelcraft-solver = { workspace = true } modelcraft-explicit = { workspace = true } modelcraft-fem = { workspace = true } modelcraft-lbm = { workspace = true } +modelcraft-render = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/engine/src/catalogue.rs b/crates/engine/src/catalogue.rs index 94f7c1e..c3dadb1 100644 --- a/crates/engine/src/catalogue.rs +++ b/crates/engine/src/catalogue.rs @@ -515,7 +515,8 @@ pub fn features_page() -> String { Generated from the capability catalogue (`crates/engine/src/catalogue.rs`) by\n\ `cargo run -p modelcraft-cli -- features > docs/features.md`; a test fails when it is out of date.\n\ Every entry is a toolbar button. **Ready** entries run their command; **coming soon** entries are\n\ - shown greyed with the roadmap milestone that brings them (ROADMAP.md).\n\n", + shown greyed with the roadmap milestone that brings them (ROADMAP.md). Scripts, the command line,\n\ + the control channel and AI agents over MCP (docs/mcp.md) run the same commands.\n\n", ); let _ = writeln!(md, "{ready} ready, {} coming soon.", CATALOGUE.len() - ready); for tab in Tab::ALL { diff --git a/crates/engine/src/cmd/deck.rs b/crates/engine/src/cmd/deck.rs index 206becc..134980c 100644 --- a/crates/engine/src/cmd/deck.rs +++ b/crates/engine/src/cmd/deck.rs @@ -2,6 +2,7 @@ use std::path::Path; +use modelcraft_io::roots::Access; use serde_json::{Value, json}; use super::req; @@ -22,10 +23,10 @@ fn write(s: &mut Session, p: &Value) -> Result { let known: Vec<&str> = modelcraft_decks::FORMATS.iter().map(|f| f.name).collect(); EngineError::Params(format!("unknown deck format `{shown}`; known: {}", known.join(", "))) })?; - let path = super::path_param(p, "path")?; + let path = super::file_param(s, p, "path", Access::Write)?; let steps: Option> = super::opt(p, "steps")?; let deck = format.write_steps(&s.model, steps.as_deref())?; - modelcraft_io::fs::write_atomic(Path::new(path), deck.text.as_bytes(), false)?; + modelcraft_io::fs::write_atomic(Path::new(&path), deck.text.as_bytes(), false)?; Ok(json!({"format": format.name, "path": path, "bytes": deck.text.len(), "warnings": deck.warnings})) } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 1b72a31..6696c7e 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -14,7 +14,9 @@ use modelcraft_geometry::{Design, MAX_DESIGN_BYTES, open_design}; use modelcraft_io::{Geometry, ModelFile, SourceFile}; use serde_json::{Value, json}; -use super::{path_param, req}; +use modelcraft_io::roots::{Access, Roots}; + +use super::{file_param, req}; use crate::{CommandKind, CommandSpec, EngineError, Result, Session, next_revision}; pub const OPEN: CommandSpec = CommandSpec { @@ -110,18 +112,27 @@ fn resolved_path(path: &str, base: Option<&Path>) -> String { } /// Open one geometry file again: what it gives, and the source as now read (or as stored, with -/// its path resolved, when it could not be). +/// its path resolved, when it could not be). A file outside the read root of `roots` is not +/// opened. fn reopen( what: &str, stored: &SourceFile, base: Option<&Path>, + roots: &Roots, max: usize, open: impl FnOnce(&[u8]) -> modelcraft_geometry::Result, warnings: &mut Vec, ) -> (Option, SourceFile) { let path = resolved_path(stored.path(), base); let kept = stored.with_path(path.clone()).unwrap_or_else(|_| stored.clone()); - let bytes = match modelcraft_geometry::read_file(&path, max) { + let checked = match roots.check(Path::new(&path), Access::Read) { + Ok(p) => p.to_string_lossy().into_owned(), + Err(e) => { + warnings.push(format!("{what} `{path}` was not opened ({e}); the model opens without it")); + return (None, kept); + } + }; + let bytes = match modelcraft_geometry::read_file(&checked, max) { Ok(b) => b, Err(e) => { warnings.push(format!("{what} `{path}` was not opened ({e}); the model opens without it")); @@ -155,19 +166,20 @@ impl Session { } /// A session holding what a file holds, its geometry opened again from the paths it names - /// (relative ones against `base`); problems with the geometry come back as warnings. - pub fn from_file(file: ModelFile, base: Option<&Path>) -> (Session, Vec) { + /// (relative ones against `base`) where `roots` lets it read them; problems with the + /// geometry come back as warnings. The session's own file access is unconfined. + pub fn from_file(file: ModelFile, base: Option<&Path>, roots: &Roots) -> (Session, Vec) { let mut warnings = Vec::new(); let mut sources = Geometry::default(); let mut design: Option> = None; if let Some(stored) = &file.geometry.design { - let (opened, source) = reopen("design", stored, base, MAX_DESIGN_BYTES, open_design, &mut warnings); + let (opened, source) = reopen("design", stored, base, roots, MAX_DESIGN_BYTES, open_design, &mut warnings); design = opened.map(Arc::new); sources.design = Some(source); } let mut surfaces = Vec::new(); for stored in &file.geometry.meshes { - let (opened, source) = reopen("mesh", stored, base, tagged_mesh::MAX_FILE_BYTES, tagged_mesh::read_3mf, &mut warnings); + let (opened, source) = reopen("mesh", stored, base, roots, tagged_mesh::MAX_FILE_BYTES, tagged_mesh::read_3mf, &mut warnings); surfaces.extend(opened.into_iter().flatten().map(Arc::new)); sources.meshes.push(source); } @@ -176,11 +188,12 @@ impl Session { (session, warnings) } - /// Replace this session with `opened`, keeping its undo memory limit and its work caps; the - /// history starts empty. + /// Replace this session with `opened`, keeping its undo memory limit, its work caps and its + /// file access; the history starts empty. fn replace_with(&mut self, mut opened: Session) { opened.history = std::mem::take(&mut self.history); opened.limits = self.limits; + opened.file_access = std::mem::take(&mut self.file_access); opened.clear_history(); *self = opened; } @@ -202,7 +215,7 @@ fn summary(s: &Session, warnings: &[String]) -> Value { /// paths are relative to its folder), `saved` whether that file holds this content. fn load(s: &mut Session, bytes: &[u8], path: Option, saved: bool) -> Result { let file = modelcraft_io::read(bytes)?; - let (mut opened, warnings) = Session::from_file(file, path.as_deref().and_then(|p| Path::new(p).parent())); + let (mut opened, warnings) = Session::from_file(file, path.as_deref().and_then(|p| Path::new(p).parent()), &s.file_access); opened.path = path; if saved { opened.saved_revision = Some(opened.revision); @@ -212,7 +225,7 @@ fn load(s: &mut Session, bytes: &[u8], path: Option, saved: bool) -> Res } fn open(s: &mut Session, p: &Value) -> Result { - let path = absolute(path_param(p, "path")?); + let path = absolute(&file_param(s, p, "path", Access::Read)?); let max = usize::try_from(modelcraft_io::MAX_FILE_BYTES).unwrap_or(usize::MAX); let bytes = modelcraft_geometry::read_file(&path, max)?; load(s, &bytes, Some(path.clone()), true).map_err(|e| EngineError::Params(format!("{path}: {e}"))) @@ -232,41 +245,45 @@ fn save_to(s: &mut Session, path: &str) -> Result { fn save(s: &mut Session, p: &Value) -> Result { let path = match p.get("path") { - Some(_) => path_param(p, "path")?.to_string(), - None => s.path.clone().ok_or_else(|| EngineError::Params("the model has no file yet: give `path` (or use file.save_as)".into()))?, + Some(_) => file_param(s, p, "path", Access::Write)?, + None => { + let own = s.path.as_deref().ok_or_else(|| EngineError::Params("the model has no file yet: give `path` (or use file.save_as)".into()))?; + s.checked_path(own, Access::Write)? + } }; save_to(s, &path) } fn save_as(s: &mut Session, p: &Value) -> Result { - let path = path_param(p, "path")?.to_string(); + let path = file_param(s, p, "path", Access::Write)?; save_to(s, &path) } -/// The recovery folder: `dir`, else the default one. -fn recovery_dir(p: &Value) -> Result { +/// The recovery folder: `dir` (checked for `access`), else the default one (the app's own folder, +/// which no client names, so it is not confined). +fn recovery_dir(s: &Session, p: &Value, access: Access) -> Result { match p.get("dir") { - Some(_) => Ok(PathBuf::from(path_param(p, "dir")?)), + Some(_) => Ok(PathBuf::from(file_param(s, p, "dir", access)?)), None => { crate::autosave::default_dir().ok_or_else(|| EngineError::Params("no recovery folder: set MODELCRAFT_RECOVERY_DIR or give `dir`".into())) } } } -fn recovery_list(_: &mut Session, p: &Value) -> Result { - let entries = modelcraft_io::recovery::orphans(&recovery_dir(p)?); +fn recovery_list(s: &mut Session, p: &Value) -> Result { + let entries = modelcraft_io::recovery::orphans(&recovery_dir(s, p, Access::Read)?); serde_json::to_value(entries).map_err(|e| EngineError::Params(e.to_string())) } fn recover(s: &mut Session, p: &Value) -> Result { - let dir = recovery_dir(p)?; + let dir = recovery_dir(s, p, Access::Read)?; let (bytes, entry) = modelcraft_io::recovery::load(&dir, req::<&str>(p, "id")?)?; // Recovered work is unsaved until it is saved. Autosaves keep geometry paths absolute. load(s, &bytes, entry.path, false) } -fn recovery_discard(_: &mut Session, p: &Value) -> Result { - let dir = recovery_dir(p)?; +fn recovery_discard(s: &mut Session, p: &Value) -> Result { + let dir = recovery_dir(s, p, Access::Write)?; if p.get("all").and_then(Value::as_bool) == Some(true) { let ids: Vec = modelcraft_io::recovery::orphans(&dir).into_iter().map(|e| e.id).collect(); for id in &ids { @@ -349,6 +366,51 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } + #[test] + fn a_confined_session_reads_and_writes_only_inside_its_roots() { + let d = dir("roots"); + let (input, output, outside) = (d.join("in"), d.join("out"), d.join("outside")); + for f in [&input, &output, &outside] { + std::fs::create_dir_all(f).unwrap(); + } + let design = plate_design(&Plate::default()).unwrap(); + std::fs::write(input.join("plate.solidcraft"), &design).unwrap(); + std::fs::write(outside.join("plate.solidcraft"), &design).unwrap(); + let mut s = Session { file_access: Roots::new(Some(&input), Some(&output)).unwrap(), ..Session::default() }; + let refused = |r: Result| { + let e = r.unwrap_err().to_string(); + assert!(e.starts_with("automation"), "{e}"); + }; + refused(s.execute("geometry.open", &json!({"path": outside.join("plate.solidcraft")}))); + refused(s.execute("geometry.import.mesh", &json!({"path": outside.join("x.3mf")}))); + refused(s.execute("file.open", &json!({"path": outside.join("x.modelcraft")}))); + refused(s.execute("file.recovery.list", &json!({"dir": outside}))); + refused(s.execute("file.recovery.discard", &json!({"all": true, "dir": input}))); + s.execute("geometry.open", &json!({"path": input.join("plate.solidcraft")})).unwrap(); + defined(&mut s); + // Writes go to the write root only: not the read root, not elsewhere. + refused(s.execute("file.save_as", &json!({"path": input.join("m.modelcraft")}))); + refused(s.execute("file.save_as", &json!({"path": outside.join("m.modelcraft")}))); + refused(s.execute("file.save_as", &json!({"path": output.join("../outside/m.modelcraft")}))); + refused(s.execute("deck.write", &json!({"format": "abaqus", "path": outside.join("m.inp")}))); + let saved = output.join("m.modelcraft"); + s.execute("file.save_as", &json!({"path": saved})).unwrap(); + assert!(saved.is_file()); + assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "nothing written outside"); + + // The roots survive opening a file; a model whose design lies outside the read root + // opens without it. + let mut other = Session::default(); + other.execute("geometry.open", &json!({"path": outside.join("plate.solidcraft")})).unwrap(); + other.execute("file.save_as", &json!({"path": input.join("other.modelcraft")})).unwrap(); + let r = s.execute("file.open", &json!({"path": input.join("other.modelcraft")})).unwrap(); + assert!(r["warnings"][0].as_str().unwrap().contains("automation path rejected"), "{r}"); + assert!(s.design.is_none()); + assert!(s.file_access.is_confined()); + refused(s.execute("file.save", &json!({}))); + let _ = std::fs::remove_dir_all(&d); + } + #[test] fn hostile_file_params_and_files_change_nothing() { let d = dir("hostile"); diff --git a/crates/engine/src/cmd/geometry.rs b/crates/engine/src/cmd/geometry.rs index 53e58b2..114c066 100644 --- a/crates/engine/src/cmd/geometry.rs +++ b/crates/engine/src/cmd/geometry.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use modelcraft_geometry::tagged_mesh; use modelcraft_geometry::{MAX_DESIGN_BYTES, Shape, SurfaceMesh, open_design}; use modelcraft_io::SourceFile; +use modelcraft_io::roots::Access; use serde_json::{Value, json}; use crate::{CommandKind, CommandSpec, EngineError, Result, Session}; @@ -40,9 +41,9 @@ pub const IMPORT_MESH: CommandSpec = CommandSpec { }; fn open(s: &mut Session, p: &Value) -> Result { - let path = super::path_param(p, "path")?; - let bytes = modelcraft_geometry::read_file(path, MAX_DESIGN_BYTES)?; - let source = SourceFile::of(super::file::absolute(path), &bytes)?; + let path = super::file_param(s, p, "path", Access::Read)?; + let bytes = modelcraft_geometry::read_file(&path, MAX_DESIGN_BYTES)?; + let source = SourceFile::of(super::file::absolute(&path), &bytes)?; let design = open_design(&bytes)?; let out = json!({ "name": design.name, @@ -96,12 +97,12 @@ fn tessellate(s: &mut Session, p: &Value) -> Result { } fn import_mesh(s: &mut Session, p: &Value) -> Result { - let path = super::path_param(p, "path")?; + let path = super::file_param(s, p, "path", Access::Read)?; if s.sources.meshes.len() >= modelcraft_io::MAX_MESH_SOURCES { return Err(EngineError::Params(format!("a model imports at most {} meshes", modelcraft_io::MAX_MESH_SOURCES))); } - let bytes = modelcraft_geometry::read_file(path, tagged_mesh::MAX_FILE_BYTES)?; - let source = SourceFile::of(super::file::absolute(path), &bytes)?; + let bytes = modelcraft_geometry::read_file(&path, tagged_mesh::MAX_FILE_BYTES)?; + let source = SourceFile::of(super::file::absolute(&path), &bytes)?; let surfaces = tagged_mesh::read_3mf(&bytes)?; let first = s.surfaces.len(); let added: Vec = surfaces diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4db1f74..ec6bcbd 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -16,7 +16,9 @@ use modelcraft_model::MAX_LABEL; use serde::Deserialize; use serde_json::{Value, json}; -use crate::{CommandSpec, EngineError, Result}; +use modelcraft_io::roots::Access; + +use crate::{CommandSpec, EngineError, Result, Session}; pub static ALL: &[CommandSpec] = &[ model::INFO, @@ -158,14 +160,22 @@ pub(crate) fn labels(p: &Value, key: &str, max_len: usize) -> Result> { .ok_or_else(|| EngineError::Params(format!("`{key}` must be labels in 1..={MAX_LABEL}"))) } -/// A required path parameter. -pub(crate) fn path_param<'a>(p: &'a Value, key: &str) -> Result<&'a str> { +/// A required path parameter, as given. +fn path_param<'a>(p: &'a Value, key: &str) -> Result<&'a str> { p.get(key) .and_then(Value::as_str) .filter(|s| !s.trim().is_empty() && s.len() <= modelcraft_io::MAX_PATH_LEN && !s.contains('\0')) .ok_or_else(|| EngineError::Params(format!("`{key}` must be a file path"))) } +/// A required path parameter naming a file (or folder) the command reads or writes: the path +/// to use, checked against the session's [`Session::file_access`] (as given while unconfined; +/// resolved, links followed, when confined). Every command that touches a file named by a +/// parameter takes its path from here. +pub(crate) fn file_param(s: &Session, p: &Value, key: &str, access: Access) -> Result { + s.checked_path(path_param(p, key)?, access) +} + /// An optional number parameter. pub(crate) fn optional_number(p: &Value, key: &str, default: f64) -> Result { match p.get(key) { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8e8f103..83071b8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -18,6 +18,7 @@ pub mod cmd; pub mod history; pub mod results; pub mod sample; +pub mod view; use std::collections::BTreeMap; use std::sync::Arc; @@ -174,13 +175,17 @@ pub struct Session { pub results_view: Option, /// The default work caps of commands; a file replacing the session keeps them. pub limits: Limits, + /// The folders commands may read and write files in (an automation client's roots, + /// docs/mcp.md); unconfined by default. A file replacing the session keeps them. + pub file_access: modelcraft_io::roots::Roots, } impl Session { /// Run a command. On an error or a panic the model, the design and the surfaces are left as /// they were. pub fn execute(&mut self, id: &str, params: &Value) -> Result { - let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; + // An id from hostile input is not echoed in full. + let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.chars().take(100).collect()))?; // Session commands (open, save, undo, redo) change the session only in their last step, // after everything that can fail, so they need no copy of the model to restore. let before = (spec.kind == CommandKind::Model).then(|| self.snapshot()); @@ -255,6 +260,15 @@ impl Session { self.results.insert(result.step().to_string(), (self.revision, Arc::new(result))); } + /// The path to use for `access` to the file at `path`, checked against [`Session::file_access`]. + pub fn checked_path(&self, path: &str, access: modelcraft_io::roots::Access) -> Result { + let checked = self.file_access.check(std::path::Path::new(path), access)?; + match checked.to_str() { + Some(p) => Ok(p.to_string()), + None => Err(EngineError::Params(format!("`{path}` resolves to a path that is not valid UTF-8"))), + } + } + /// The current state is what `path` now holds. pub(crate) fn mark_saved(&mut self, path: String) { self.path = Some(path); diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs new file mode 100644 index 0000000..f8dfcda --- /dev/null +++ b/crates/engine/src/view.rs @@ -0,0 +1,211 @@ +//! Headless pictures of a session: the model, its geometry and the results display drawn with +//! the CPU rasterizer of [`modelcraft_render`], with no window or GPU (the MCP server's +//! screenshots without an app, docs/mcp.md). +//! +//! The picture follows the desktop viewport's conventions: geometry draws as faces with their +//! boundaries while the model has no element faces, then as boundaries only; elements in their +//! kind's colour with their edges; a results view ([`crate::results::ResultsView`]) draws the +//! deformed shape (automatic or chosen scale) with its contour bands. Animation is not drawn: a +//! picture shows the full deformation (a mode at its positive shape). + +use modelcraft_model::ElementKind; +use modelcraft_render::contour::{Bands, band_color, split_triangle}; +use modelcraft_render::{Aabb3, Camera, DisplayMesh, Rgb, Scene, SceneLine, SceneTri, StandardView, Vec3}; + +use crate::results::{displacements, field_values, magnitude, range}; +use crate::{EngineError, Result, Session}; + +/// The colours of a picture. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Palette { + /// Background gradient, top to bottom. + pub background: (Rgb, Rgb), + pub hex: Rgb, + pub tet: Rgb, + pub wedge: Rgb, + pub shell: Rgb, + pub line_element: Rgb, + pub mesh_edge: Rgb, + pub geometry: Rgb, + pub geometry_edge: Rgb, + /// Faces of nodes without a value in a contour plot. + pub no_result: Rgb, + /// The contour colour scale, low to high. + pub contour: [Rgb; 5], +} + +impl Palette { + /// The desktop app's light theme. + pub const LIGHT: Palette = Palette { + background: (Rgb(0xf0, 0xf3, 0xf7), Rgb(0xc8, 0xd0, 0xdc)), + hex: Rgb(0x78, 0x9c, 0xc0), + tet: Rgb(0x80, 0xb4, 0x8a), + wedge: Rgb(0xc0, 0xaa, 0x78), + shell: Rgb(0xc0, 0x8c, 0x78), + line_element: Rgb(0xc8, 0x8c, 0x1e), + mesh_edge: Rgb(0x1e, 0x23, 0x2a), + geometry: Rgb(0xb4, 0xba, 0xc4), + geometry_edge: Rgb(0x2a, 0x30, 0x3a), + no_result: Rgb(0x9a, 0xa0, 0xaa), + contour: [Rgb(0x2b, 0x4f, 0xd8), Rgb(0x2f, 0xb8, 0xe0), Rgb(0x3c, 0xc8, 0x64), Rgb(0xf0, 0xd2, 0x3c), Rgb(0xe0, 0x40, 0x2e)], + }; + + fn element(&self, kind: ElementKind) -> Rgb { + match kind { + ElementKind::Hex8 | ElementKind::Hex20 => self.hex, + ElementKind::Tet4 | ElementKind::Tet10 => self.tet, + ElementKind::Wedge6 => self.wedge, + ElementKind::Tri3 | ElementKind::Tri6 | ElementKind::Quad4 | ElementKind::Quad8 => self.shell, + ElementKind::Rod2 | ElementKind::Rod3 | ElementKind::Beam2 => self.line_element, + } + } +} + +/// Mesh edge, geometry edge and line element widths, in output pixels. +const EDGE_WIDTH: f64 = 1.0; +const GEOMETRY_EDGE_WIDTH: f64 = 1.5; +const LINE_ELEMENT_WIDTH: f64 = 3.0; +/// The automatic scale draws the largest displacement this fraction of the model's size. +const AUTO_FRACTION: f64 = 0.1; + +/// The chordal tolerance geometry is tessellated with for display: a thousandth of its size. +fn display_tolerance(b: &Aabb3) -> f64 { + let d = b.diagonal(); + if d.is_finite() { (d * 1e-3).clamp(1e-3, 1.0) } else { 0.1 } +} + +/// What a picture shows besides the image. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Shown { + /// The solved step drawn, when the results display is on. + pub step: Option, + /// The deformation scale factor drawn (0: undeformed). + pub scale: f64, + /// The contour field drawn, with its smallest and largest value and their nodes. + pub contour: Option<(String, (u32, f64), (u32, f64))>, + /// Bodies that could not be tessellated for display. + pub failed: Vec, +} + +/// The model's display data with the open design's bodies and the imported surfaces. +pub fn display(s: &Session) -> (DisplayMesh, Vec) { + let mut d = DisplayMesh::from_model(&s.model); + let mut failed = Vec::new(); + for body in s.design.iter().flat_map(|d| &d.bodies) { + let mesh = body.measure().and_then(|m| body.tessellate(display_tolerance(&Aabb3 { min: m.bbox_min.into(), max: m.bbox_max.into() }))); + match mesh { + Ok(m) => d.add_surface(&m.positions, &m.triangles, &m.tri_face), + Err(e) => failed.push(format!("{}: {e}", body.name)), + } + } + for surface in &s.surfaces { + d.add_surface(&surface.mesh.positions, &surface.mesh.triangles, &surface.mesh.tri_face); + } + (d, failed) +} + +/// The model drawn as the viewport draws it (shaded with element edges). +fn model_scene(d: &DisplayMesh, p: &Palette) -> Scene { + let mut s = Scene { background: Some(p.background), ..Default::default() }; + s.tris = d.tris.iter().map(|t| SceneTri { p: t.p, normal: t.normal, color: p.element(t.kind) }).collect(); + let faces_drawn = d.tris.is_empty(); + if faces_drawn { + s.tris.extend(d.surface_tris.iter().map(|t| SceneTri { p: t.p, normal: t.normal, color: p.geometry })); + } + s.lines = d.edges.iter().map(|e| SceneLine { a: e.p[0], b: e.p[1], color: p.mesh_edge, width: EDGE_WIDTH }).collect(); + let edge = if faces_drawn { p.mesh_edge } else { p.geometry_edge }; + s.lines.extend(d.surface_edges.iter().map(|[a, b]| SceneLine { a: *a, b: *b, color: edge, width: GEOMETRY_EDGE_WIDTH })); + s.lines.extend(d.lines.iter().map(|l| SceneLine { a: l.a, b: l.b, color: p.element(l.kind), width: LINE_ELEMENT_WIDTH })); + s +} + +/// The scene of the session: the model, or the results view's plot, and what it shows. +pub fn scene(s: &Session, p: &Palette) -> (Scene, Aabb3, Shown) { + let (base, failed) = display(s); + let mut shown = Shown { failed, ..Shown::default() }; + let plot = s.results_view.as_ref().and_then(|v| s.latest_result(&v.step).map(|(r, _)| (v, r))); + let Some((view, result)) = plot else { + let bounds = base.bounds; + return (model_scene(&base, p), bounds, shown); + }; + shown.step = Some(view.step.clone()); + let pick = view.pick(); + let nodes = result.nodes(); + let u = displacements(result, pick).unwrap_or(&[]); + let scale = if !view.deformed { + 0.0 + } else if let Some(k) = view.scale { + k + } else { + let largest = u.iter().map(|v| magnitude(*v)).filter(|m| m.is_finite()).fold(0.0, f64::max); + let size = base.bounds.diagonal(); + if largest > 0.0 && size.is_finite() && size > 0.0 { AUTO_FRACTION * size / largest } else { 0.0 } + }; + shown.scale = scale; + let drawn = base.displaced(|label| { + let i = nodes.binary_search(&label).ok()?; + u.get(i).map(|d| Vec3::from(*d) * scale) + }); + let mut scene = model_scene(&drawn, p); + let contour = view.contour.and_then(|field| { + let values = field_values(&s.model, result, field, pick).ok()?; + let (min, max) = range(nodes, &values)?; + Some((field, min, max, values)) + }); + if let Some((field, min, max, values)) = contour { + shown.contour = Some((field.id().to_string(), min, max)); + let bands = Bands::new(min.1, max.1, view.bands); + let at = |label: u32| nodes.binary_search(&label).ok().and_then(|i| values.get(i).copied()).unwrap_or(f64::NAN); + scene.tris.clear(); + for tri in &drawn.tris { + split_triangle(tri.p, tri.nodes.map(at), &bands, &mut |piece, band| { + let color = band.map_or(p.no_result, |k| band_color(&p.contour, k, bands.count)); + for k in 1..piece.len().saturating_sub(1) { + if let (Some(&a), Some(&b), Some(&c)) = (piece.first(), piece.get(k), piece.get(k + 1)) { + scene.tris.push(SceneTri { p: [a, b, c], normal: tri.normal, color }); + } + } + }); + } + } + let mut bounds = base.bounds; + bounds.add(drawn.bounds.min); + bounds.add(drawn.bounds.max); + (scene, bounds, shown) +} + +/// A PNG of the session seen from `view`, framing everything drawn, `w` × `h` pixels. +pub fn render_png(s: &Session, view: StandardView, w: usize, h: usize, p: &Palette) -> Result<(Vec, Shown)> { + let (mut scene, bounds, shown) = scene(s, p); + let mut cam = Camera::default(); + cam.set_view(view); + cam.fit(&bounds); + scene.radius = if bounds.is_empty() { 1.0 } else { (cam.target.dist(bounds.center()) + bounds.diagonal() * 0.5).max(1e-3) }; + let png = modelcraft_render::render_png(&scene, &cam, w, h).map_err(|e| EngineError::Params(format!("render: {e}")))?; + Ok((png, shown)) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn renders_the_model_and_its_results() { + let mut s = Session::default(); + let empty = render_png(&s, StandardView::Iso, 32, 24, &Palette::LIGHT).unwrap(); + assert!(empty.0.starts_with(&[0x89, b'P', b'N', b'G'])); + s.run_script(&crate::sample::cantilever()).unwrap(); + let (model, shown) = render_png(&s, StandardView::Iso, 64, 48, &Palette::LIGHT).unwrap(); + assert_eq!(shown, Shown::default()); + s.execute("solve.static", &json!({})).unwrap(); + let (plot, shown) = render_png(&s, StandardView::Iso, 64, 48, &Palette::LIGHT).unwrap(); + assert_ne!(model, plot); + assert!(shown.scale > 0.0 && shown.step.is_some(), "{shown:?}"); + let (field, min, max) = shown.contour.unwrap(); + assert_eq!(field, "von_mises"); + assert!(max.1 > min.1); + assert!(render_png(&s, StandardView::Top, 0, 48, &Palette::LIGHT).is_err()); + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index a5c00ac..2604437 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -17,14 +17,15 @@ //! The same model always gives the same bytes. Reading treats the file as hostile: every size is //! capped before anything is allocated, every part's checksum is verified, and the model is //! rebuilt through the same checked methods commands use, so a file cannot hold what commands -//! would refuse. Reading and writing work on bytes (they build for the browser); only [`fs`] and -//! [`recovery`] touch the file system. +//! would refuse. Reading and writing work on bytes (they build for the browser); only [`fs`], +//! [`recovery`] and [`roots`] touch the file system. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] pub mod fs; pub mod mesh; pub mod recovery; +pub mod roots; use std::collections::BTreeMap; use std::io::{Cursor, Read, Write}; @@ -74,6 +75,9 @@ pub enum Error { /// The file system refused. #[error("{0}")] Io(String), + /// The path lies outside the folders file access is confined to ([`roots::Roots`]). + #[error("{0}")] + Refused(String), } pub type Result = std::result::Result; diff --git a/crates/io/src/roots.rs b/crates/io/src/roots.rs new file mode 100644 index 0000000..fb3a022 --- /dev/null +++ b/crates/io/src/roots.rs @@ -0,0 +1,234 @@ +//! Folders an automation client may read from and write to. +//! +//! An agent driving ModelCraft (the MCP server, docs/mcp.md) can be kept to the folders it was +//! given: every file a command reads must lie inside the read root, every file it writes inside +//! the write root. [`Roots::check`] decides, before the file is touched: +//! +//! - The path is made absolute against the working folder, then its links are followed: a file +//! (or folder) link that leads out of a root is outside it. For a file that does not exist yet, +//! its nearest existing folder's links are followed and the rest must be plain names, so `..` +//! below a folder that does not exist is refused, as is a link that leads nowhere (writing +//! through it would create its target). +//! - The result is compared with the root by whole folder names: `/work2/x` is not inside +//! `/work`. +//! - Read and write access are separate; a root left out grants none of its access. Without +//! either root ([`Roots::default`]) nothing is confined. +//! +//! A path is checked, then opened: another program that swaps a link in between can still win +//! that race, so the roots keep an agent to the files it was given rather than guarding against +//! other software on the computer. + +use std::path::{Component, Path, PathBuf}; + +use crate::{Error, Result}; + +/// What a command does with a file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Access { + Read, + Write, +} + +impl Access { + fn name(self) -> &'static str { + match self { + Access::Read => "read", + Access::Write => "write", + } + } +} + +/// The folders file access is confined to; the default confines nothing. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Roots { + /// `None` while unconfined; otherwise the resolved read and write roots (each `None` when + /// that access is not granted). + confined: Option<[Option; 2]>, +} + +impl Roots { + /// Nothing confined. + pub fn unconfined() -> Roots { + Roots::default() + } + + /// Confine reads to `read` and writes to `write`. A root left out grants none of its access. + /// Each root must be an existing folder; it is resolved (links followed) once, here. + pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result { + let resolve = |p: Option<&Path>, access: Access| -> Result> { + let Some(p) = p else { return Ok(None) }; + let shown = p.display(); + let dir = std::fs::canonicalize(p).map_err(|e| Error::Io(format!("the {} root `{shown}`: {e}", access.name())))?; + if !dir.is_dir() { + return Err(Error::Io(format!("the {} root `{shown}` is not a folder", access.name()))); + } + Ok(Some(dir)) + }; + Ok(Roots { confined: Some([resolve(read, Access::Read)?, resolve(write, Access::Write)?]) }) + } + + /// Whether any access is confined. + pub fn is_confined(&self) -> bool { + self.confined.is_some() + } + + /// The root of `access`, when confined and granted. + pub fn root(&self, access: Access) -> Option<&Path> { + let roots = self.confined.as_ref()?; + roots.get(access as usize).and_then(|r| r.as_deref()) + } + + /// The path to use for `access` to `path`: unchanged while unconfined, else the resolved path + /// (absolute, links followed), which lies inside the root. Refused with an error naming the + /// root and the path. + pub fn check(&self, path: &Path, access: Access) -> Result { + let Some(roots) = &self.confined else { return Ok(path.to_path_buf()) }; + let shown = path.display(); + let Some(root) = roots.get(access as usize).and_then(|r| r.as_deref()) else { + return Err(Error::Refused(format!("automation filesystem access is not granted: {} authority is absent: {shown}", access.name()))); + }; + let resolved = resolve(path).map_err(|why| Error::Refused(format!("automation path rejected: {why}: {shown}")))?; + if !resolved.starts_with(root) { + return Err(Error::Refused(format!("automation path rejected: outside the {} root {}: {shown}", access.name(), root.display()))); + } + Ok(resolved) + } +} + +/// `path` absolute, its links followed: wholly when it exists, else up to its nearest existing +/// folder, below which only plain names may follow. +fn resolve(path: &Path) -> std::result::Result { + if path.as_os_str().is_empty() { + return Err("empty path".into()); + } + #[cfg(windows)] + if let Some(name) = path.file_stem().and_then(|n| n.to_str()) { + const DEVICES: [&str; 22] = [ + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", + "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + ]; + if DEVICES.iter().any(|d| d.eq_ignore_ascii_case(name.trim_end())) { + return Err("a device name".into()); + } + } + let absolute = std::path::absolute(path).map_err(|e| e.to_string())?; + if let Ok(p) = std::fs::canonicalize(&absolute) { + return Ok(p); + } + // A link that leads nowhere: writing through it would create its target. + if std::fs::symlink_metadata(&absolute).is_ok() { + return Err("a link that leads nowhere".into()); + } + let mut rest = Vec::new(); + let mut base = absolute.as_path(); + loop { + let Some(parent) = base.parent() else { return Err("no existing folder above it".into()) }; + let Some(name) = base.file_name() else { return Err("not a plain file name".into()) }; + rest.push(name.to_os_string()); + base = parent; + if let Ok(dir) = std::fs::canonicalize(base) { + if !dir.is_dir() { + return Err(format!("{} is not a folder", base.display())); + } + let mut out = dir; + for name in rest.iter().rev() { + out.push(name); + } + return Ok(out); + } + if std::fs::symlink_metadata(base).is_ok() { + return Err("a link that leads nowhere".into()); + } + // `..` or `.` below a folder that does not exist cannot be resolved. + if !matches!(Path::new(base).components().next_back(), Some(Component::Normal(_))) { + return Err("`..` below a folder that does not exist".into()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Dir(PathBuf); + impl Dir { + fn new(tag: &str) -> Dir { + let d = std::env::temp_dir().join(format!("modelcraft-roots-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + Dir(std::fs::canonicalize(&d).unwrap()) + } + } + impl Drop for Dir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn unconfined_changes_nothing() { + let r = Roots::unconfined(); + assert!(!r.is_confined()); + assert_eq!(r.check(Path::new("/etc/passwd"), Access::Write).unwrap(), Path::new("/etc/passwd")); + } + + #[test] + fn reads_and_writes_stay_inside_their_roots() { + let d = Dir::new("inside"); + let (read, write) = (d.0.join("in"), d.0.join("in").join("out")); + std::fs::create_dir_all(&write).unwrap(); + std::fs::write(read.join("a.solidcraft"), b"x").unwrap(); + let r = Roots::new(Some(&read), Some(&write)).unwrap(); + assert_eq!(r.check(&read.join("a.solidcraft"), Access::Read).unwrap(), read.join("a.solidcraft")); + assert_eq!(r.check(&write.join("new.inp"), Access::Write).unwrap(), write.join("new.inp")); + // A new file in a new folder below the root: plain names only. + assert_eq!(r.check(&write.join("x/y/z.inp"), Access::Write).unwrap(), write.join("x/y/z.inp")); + assert!(r.check(&write.join("x/../../../z.inp"), Access::Write).is_err()); + // `..` through an existing folder resolves first, then is compared. + assert!(r.check(&write.join("../a.solidcraft"), Access::Write).is_err()); + assert!(r.check(&write.join("../a.solidcraft"), Access::Read).is_ok()); + let e = r.check(&read.join("x.png"), Access::Write).unwrap_err().to_string(); + assert!(e.contains("outside the write root"), "{e}"); + assert!(r.check(Path::new("/etc/passwd"), Access::Read).is_err()); + // Whole folder names: `in2` is not inside `in`. + std::fs::create_dir_all(d.0.join("in2")).unwrap(); + assert!(r.check(&d.0.join("in2/a"), Access::Read).is_err()); + assert!(r.check(Path::new(""), Access::Read).is_err()); + } + + #[test] + fn a_root_left_out_grants_nothing() { + let d = Dir::new("readonly"); + let r = Roots::new(Some(&d.0), None).unwrap(); + assert!(r.is_confined()); + let e = r.check(&d.0.join("x.inp"), Access::Write).unwrap_err().to_string(); + assert!(e.contains("write authority is absent"), "{e}"); + assert!(r.check(&d.0.join("x.inp"), Access::Read).is_ok()); + assert!(Roots::new(Some(&d.0.join("missing")), None).is_err()); + std::fs::write(d.0.join("file"), b"").unwrap(); + assert!(Roots::new(None, Some(&d.0.join("file"))).is_err(), "a root is a folder"); + } + + #[cfg(unix)] + #[test] + fn links_are_followed() { + let d = Dir::new("links"); + let (root, outside) = (d.0.join("root"), d.0.join("outside")); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + std::fs::write(outside.join("secret"), b"x").unwrap(); + std::os::unix::fs::symlink(outside.join("secret"), root.join("file-link")).unwrap(); + std::os::unix::fs::symlink(&outside, root.join("dir-link")).unwrap(); + std::os::unix::fs::symlink(outside.join("nowhere"), root.join("dangling")).unwrap(); + std::os::unix::fs::symlink(&root, outside.join("back")).unwrap(); + let r = Roots::new(Some(&root), Some(&root)).unwrap(); + for p in [root.join("file-link"), root.join("dir-link/secret"), root.join("dir-link/new.inp")] { + for a in [Access::Read, Access::Write] { + assert!(r.check(&p, a).is_err(), "{} {a:?}", p.display()); + } + } + assert!(r.check(&root.join("dangling"), Access::Write).unwrap_err().to_string().contains("leads nowhere")); + // A link from outside that leads in is inside. + assert_eq!(r.check(&outside.join("back/x.inp"), Access::Write).unwrap(), root.join("x.inp")); + } +} diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml new file mode 100644 index 0000000..3b86e15 --- /dev/null +++ b/crates/mcp/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "modelcraft-mcp" +description = "ModelCraft's MCP server: JSON-RPC 2.0 over stdio, driving a headless session or the running app" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dependencies] +modelcraft-engine = { workspace = true } +modelcraft-io = { workspace = true } +modelcraft-render = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +modelcraft-testkit = { workspace = true } diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs new file mode 100644 index 0000000..c607d97 --- /dev/null +++ b/crates/mcp/src/backend.rs @@ -0,0 +1,267 @@ +//! Where tool calls end up: an in-process headless session, or a running app's control channel +//! (docs/control-protocol.md: `engine.execute`, `engine.commands`, `ui.view`, `ui.render`, +//! `ui.capture`). + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use modelcraft_engine::Session; +use modelcraft_engine::view::{Palette, render_png}; +use modelcraft_io::roots::Roots; +use modelcraft_render::StandardView; +use serde_json::{Value, json}; + +/// What a picture shows. +#[derive(Debug, Clone, PartialEq)] +pub struct PictureRequest { + /// A standard view to turn to first (`iso`, `front`…; `home` and `fit` frame the model). + pub view: Option, + /// The whole app window (`true`) or the model only. + pub window: bool, + /// Image size in pixels (a window capture: points); `None` keeps the default. + pub width: Option, + pub height: Option, +} + +/// A PNG and what it shows. +#[derive(Debug, Clone, PartialEq)] +pub struct Picture { + pub png: Vec, + pub info: Value, +} + +/// Something that runs ModelCraft commands and draws pictures. +pub trait Backend { + /// Run a command (`engine.execute`). + fn execute(&mut self, command: &str, params: Value) -> Result; + /// Every command with its parameter docs (`engine.commands`). + fn commands(&mut self) -> Result; + /// A PNG of the model, or of the app's window when one is attached. + fn picture(&mut self, request: &PictureRequest) -> Result; + /// The folders file access is confined to (checked by the server for the files it writes + /// itself). + fn roots(&self) -> &Roots; + /// For the client's instructions. + fn describe(&self) -> String; +} + +/// Default size of a model picture. +pub(crate) const DEFAULT_SIZE: (u32, u32) = (960, 600); +/// Largest picture side the server asks for, in pixels. +pub(crate) const MAX_SIDE: u32 = 4096; +/// Largest image read back from the app. +const MAX_IMAGE_BYTES: u64 = 64 << 20; +/// Longest reply line accepted from the app. +const MAX_REPLY: u64 = 256 << 20; + +fn standard_view(v: &str) -> Result { + match v { + "home" | "fit" => Ok(StandardView::Iso), + _ => StandardView::parse(v).ok_or_else(|| format!("unknown view `{}`", v.chars().take(40).collect::())), + } +} + +/// An in-process headless session; pictures come from the CPU rasterizer. +pub struct Headless { + pub session: Session, + /// The orientation of pictures; each one frames everything drawn. + pub view: StandardView, + roots: Roots, +} + +impl Default for Headless { + fn default() -> Self { + Headless::new(Roots::default()) + } +} + +impl Headless { + /// An empty session whose commands read and write files only as `roots` allow. + pub fn new(roots: Roots) -> Self { + let mut session = Session::default(); + session.file_access = roots.clone(); + Headless { session, view: StandardView::Iso, roots } + } +} + +impl Backend for Headless { + fn execute(&mut self, command: &str, params: Value) -> Result { + self.session.execute(command, ¶ms).map_err(|e| e.to_string()) + } + + fn commands(&mut self) -> Result { + Ok(modelcraft_engine::commands().iter().map(|c| json!({"id": c.id, "label": c.label, "params": c.params})).collect()) + } + + fn picture(&mut self, r: &PictureRequest) -> Result { + if let Some(v) = &r.view { + self.view = standard_view(v)?; + } + let w = r.width.unwrap_or(DEFAULT_SIZE.0).clamp(16, MAX_SIDE); + let h = r.height.unwrap_or(DEFAULT_SIZE.1).clamp(16, MAX_SIDE); + let (png, shown) = render_png(&self.session, self.view, w as usize, h as usize, &Palette::LIGHT).map_err(|e| e.to_string())?; + let contour = shown + .contour + .map(|(field, min, max)| json!({"field": field, "min": {"node": min.0, "value": min.1}, "max": {"node": max.0, "value": max.1}})); + let info = json!({ + "source": "model", + "view": self.view, + "width": w, + "height": h, + "results": shown.step.map(|step| json!({"step": step, "scale": shown.scale, "contour": contour})), + "undrawn_bodies": shown.failed, + }); + Ok(Picture { png, info }) + } + + fn roots(&self) -> &Roots { + &self.roots + } + + fn describe(&self) -> String { + "a headless in-process ModelCraft session (no window; screenshots are CPU renders)".into() + } +} + +/// A running ModelCraft app reached through its loopback control channel +/// (`modelcraft --control PORT`). +pub struct Remote { + addr: String, + conn: Option<(BufReader, TcpStream)>, + next_id: u64, + roots: Roots, +} + +static SHOT: AtomicU64 = AtomicU64::new(0); + +impl Remote { + pub fn connect(addr: &str) -> std::io::Result { + let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, roots: Roots::default() }; + r.reconnect()?; + Ok(r) + } + + fn reconnect(&mut self) -> std::io::Result<()> { + self.conn = None; + let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr)); + for sa in self.addr.to_socket_addrs()? { + match TcpStream::connect_timeout(&sa, Duration::from_millis(800)) { + Ok(s) => { + s.set_nodelay(true).ok(); + // Meshing and solving big models take a while; the app answers a request it + // has not finished within 60 s with a timeout of its own. + s.set_read_timeout(Some(Duration::from_secs(600))).ok(); + let read = s.try_clone()?; + self.conn = Some((BufReader::new(read), s)); + return Ok(()); + } + Err(e) => last = e, + } + } + Err(last) + } + + fn roundtrip(&mut self, line: &str) -> std::io::Result { + if self.conn.is_none() { + self.reconnect()?; + } + let Some((reader, writer)) = self.conn.as_mut() else { return Err(std::io::Error::other("not connected")) }; + let sent = writer.write_all(line.as_bytes()).and_then(|()| writer.write_all(b"\n")).and_then(|()| writer.flush()); + if let Err(e) = sent { + self.conn = None; + return Err(e); + } + let mut reply = String::new(); + let n = match reader.by_ref().take(MAX_REPLY).read_line(&mut reply) { + Ok(n) => n, + Err(e) => { + // A timed-out read leaves a late reply on the stream: start afresh next time. + self.conn = None; + return Err(e); + } + }; + if n == 0 || !reply.ends_with('\n') { + self.conn = None; + return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "connection closed (or reply too large)")); + } + Ok(reply) + } + + /// One control-channel request. + pub fn call(&mut self, method: &str, params: Value) -> Result { + let id = self.next_id; + self.next_id += 1; + let line = json!({"id": id, "method": method, "params": params}).to_string(); + let reply = match self.roundtrip(&line) { + Ok(r) => r, + // One retry on a fresh connection (the app may have restarted). + Err(_) => self.roundtrip(&line).map_err(|e| format!("ModelCraft at {}: {e}", self.addr))?, + }; + let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply from the app: {e}"))?; + if v.get("ok").and_then(Value::as_bool) == Some(true) { + Ok(v.get("result").cloned().unwrap_or(Value::Null)) + } else { + Err(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string()) + } + } +} + +/// A temporary PNG path the app writes and the server reads back (both on this computer). +fn temp_png() -> PathBuf { + let n = SHOT.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!("modelcraft-mcp-{}-{n}.png", std::process::id())) +} + +impl Backend for Remote { + fn execute(&mut self, command: &str, params: Value) -> Result { + self.call("engine.execute", json!({"command": command, "params": params})) + } + + fn commands(&mut self) -> Result { + self.call("engine.commands", json!({})) + } + + fn picture(&mut self, r: &PictureRequest) -> Result { + if let Some(v) = &r.view { + standard_view(v)?; + self.call("ui.view", json!({"view": v}))?; + } + let path = temp_png(); + let shown = path.to_string_lossy().into_owned(); + let mut params = json!({"path": shown}); + let method = if r.window { + for (k, v) in [("width", r.width), ("height", r.height)] { + if let Some(v) = v { + params[k] = json!(v.clamp(16, MAX_SIDE)); + } + } + "ui.capture" + } else { + params["width"] = json!(r.width.unwrap_or(DEFAULT_SIZE.0).clamp(16, MAX_SIDE)); + params["height"] = json!(r.height.unwrap_or(DEFAULT_SIZE.1).clamp(16, MAX_SIDE)); + "ui.render" + }; + let result = self.call(method, params); + let read = result.and_then(|info| { + let len = std::fs::metadata(&path).map_err(|e| format!("{shown}: {e} (is the app on another computer?)"))?.len(); + if len > MAX_IMAGE_BYTES { + return Err(format!("{shown}: image too large ({len} bytes)")); + } + let png = std::fs::read(&path).map_err(|e| format!("{shown}: {e}"))?; + Ok(Picture { png, info: json!({"source": if r.window { "window" } else { "model" }, "app": info}) }) + }); + let _ = std::fs::remove_file(&path); + read + } + + fn roots(&self) -> &Roots { + &self.roots + } + + fn describe(&self) -> String { + format!("connected to the ModelCraft app at {} (the user watches its window)", self.addr) + } +} diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs new file mode 100644 index 0000000..2777e09 --- /dev/null +++ b/crates/mcp/src/lib.rs @@ -0,0 +1,56 @@ +//! ModelCraft's MCP server (docs/mcp.md). +//! +//! JSON-RPC 2.0 over stdio, one message per line, implementing the Model Context Protocol's +//! lifecycle, tools and resources. Every tool runs engine commands (the same ones the UI, +//! scripts, the CLI and the control channel run) and never opens a dialog. A tool call is served +//! by a [`Backend`]: an in-process headless session ([`Headless`], pictures drawn with the CPU +//! rasterizer) or a running app reached through its control channel ([`Remote`], +//! docs/control-protocol.md), so a person can watch the agent work. +//! +//! The server treats its input as hostile: a message line is capped at [`MAX_LINE`] bytes and +//! answered with an error past it, a JSON-RPC batch at [`MAX_BATCH_MESSAGES`] messages, a +//! `batch` tool call at [`tools::MAX_STEPS`] commands, and tool arguments are checked against +//! the tool's schema before anything runs. Read and write roots ([`modelcraft_io::roots`]) +//! confine every file a tool touches. +//! +//! Adapted from SolidCraft's `crates/mcp` (MIT OR Apache-2.0), see ATTRIBUTION.md. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +mod backend; +mod server; +pub mod tools; + +pub use backend::{Backend, Headless, Picture, PictureRequest, Remote}; +pub use server::{MAX_BATCH_MESSAGES, MAX_LINE, PROTOCOL_VERSION, SUPPORTED_VERSIONS, Server}; + +use modelcraft_io::roots::Roots; + +/// How the server is started (`modelcraft-cli mcp`). +#[derive(Debug, Clone, Default)] +pub struct Options { + /// The control channel of a running app (`127.0.0.1:7070`); `None` runs headless. + pub connect: Option, + /// Folders file access is confined to; unconfined by default. + pub roots: Roots, +} + +/// The backend `options` ask for, and a note for stderr when the server falls back to headless +/// because the app cannot be reached. Confined roots with `connect` are refused: the app would +/// read and write files the roots do not cover. +pub fn backend(options: Options) -> Result<(Box, Option), String> { + let headless = |roots: Roots| -> Box { Box::new(Headless::new(roots)) }; + match options.connect { + Some(addr) if options.roots.is_confined() => Err(format!( + "--connect {addr} cannot be combined with --automation-read-root or --automation-write-root: the app is not confined by them (they imply --headless)" + )), + Some(addr) => match Remote::connect(&addr) { + Ok(r) => Ok((Box::new(r), None)), + Err(e) => Ok((headless(options.roots), Some(format!("ModelCraft at {addr} cannot be reached ({e}); running headless")))), + }, + None => Ok((headless(options.roots), None)), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs new file mode 100644 index 0000000..e4bc4c8 --- /dev/null +++ b/crates/mcp/src/server.rs @@ -0,0 +1,237 @@ +//! JSON-RPC 2.0 framing and the MCP lifecycle, tools and resources methods. + +use std::io::{BufRead, Write}; + +use serde_json::{Value, json}; + +use crate::backend::Backend; +use crate::tools::{call_tool, check_arguments, tool_definitions}; + +/// The newest protocol revision spoken; offered when the client asks for one not known. +pub const PROTOCOL_VERSION: &str = "2025-06-18"; +pub const SUPPORTED_VERSIONS: &[&str] = &[PROTOCOL_VERSION, "2025-03-26", "2024-11-05"]; + +/// Longest message line accepted (the control channel's limit too). +pub const MAX_LINE: usize = 16 << 20; +/// Most messages in one JSON-RPC batch (protocol revisions before 2025-06-18). +pub const MAX_BATCH_MESSAGES: usize = 100; + +const PARSE_ERROR: i64 = -32700; +const INVALID_REQUEST: i64 = -32600; +const METHOD_NOT_FOUND: i64 = -32601; +const INVALID_PARAMS: i64 = -32602; +const INTERNAL_ERROR: i64 = -32603; +const RESOURCE_NOT_FOUND: i64 = -32002; + +const INSTRUCTIONS: &str = "ModelCraft turns geometry into finite element models and solves them. The model is \ +unit-consistent and never converts (SolidCraft geometry is in mm, so use mm-t-s-N-MPa: E in MPa, density in t/mm³, \ +forces in N); Z is up. A typical analysis: `open` a .solidcraft design (or a .modelcraft model, or a face-tagged \ +.3mf); `inspect_model` for its bodies and their persistent face names; `mesh` (method tet: {body, tolerance}); \ +then `execute` the model definition commands (model.set.create {name, geometry: [{body, face}]}, \ +model.material.create, model.section.create, model.boundary_condition.create, model.load.create, model.step.create \ +— list_commands shows each one's parameters); `check_model`; `solve` (static, frequency, explicit, fluid); \ +`show_results` (field: displacement, von_mises…; its reply has the field's range) and `probe` / `reactions` for \ +numbers; `screenshot` to look; `save` or `write_deck` (abaqus, nastran, lsdyna). Failures leave the model unchanged \ +and name what to fix."; + +pub struct Server { + backend: Box, + initialized: bool, +} + +fn response(id: Value, result: Value) -> Value { + json!({"jsonrpc": "2.0", "id": id, "result": result}) +} + +fn error(id: Value, code: i64, message: impl Into) -> Value { + json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}}) +} + +/// One line of at most `max` bytes (without the newline). A longer line is read to its end and +/// dropped, so the stream stays in step: `Err(InvalidData)` reports it. +fn read_line(r: &mut impl BufRead, max: usize) -> std::io::Result>> { + let mut line = Vec::new(); + let mut too_long = false; + loop { + let (done, used) = { + let available = r.fill_buf()?; + if available.is_empty() { + if too_long { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "request too large")); + } + return Ok((!line.is_empty()).then_some(line)); + } + let (part, done, used) = match available.iter().position(|b| *b == b'\n') { + Some(i) => (available.get(..i).unwrap_or(&[]), true, i + 1), + None => (available, false, available.len()), + }; + if !too_long { + line.extend_from_slice(part); + if line.len() > max { + too_long = true; + line = Vec::new(); + } + } + (done, used) + }; + r.consume(used); + if done { + if too_long { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "request too large")); + } + return Ok(Some(line)); + } + } +} + +impl Server { + pub fn new(backend: Box) -> Self { + Self { backend, initialized: false } + } + + pub fn backend(&mut self) -> &mut dyn Backend { + self.backend.as_mut() + } + + /// Whether the client sent `notifications/initialized`. + pub fn is_initialized(&self) -> bool { + self.initialized + } + + /// Serve newline-delimited JSON-RPC until `input` closes. Only protocol messages go to + /// `output`; diagnostics belong on stderr. + pub fn serve(&mut self, mut input: impl BufRead, mut output: impl Write) -> std::io::Result<()> { + loop { + let reply = match read_line(&mut input, MAX_LINE) { + Ok(None) => return Ok(()), + Ok(Some(bytes)) => match String::from_utf8(bytes) { + Ok(line) => self.handle_line(&line), + Err(_) => Some(error(Value::Null, PARSE_ERROR, "parse error: input is not UTF-8").to_string()), + }, + Err(e) if e.kind() == std::io::ErrorKind::InvalidData => { + Some(error(Value::Null, INVALID_REQUEST, format!("request too large (at most {MAX_LINE} bytes a line)")).to_string()) + } + Err(e) => return Err(e), + }; + if let Some(reply) = reply { + output.write_all(reply.as_bytes())?; + output.write_all(b"\n")?; + output.flush()?; + } + } + } + + /// One line in, at most one line out (notifications get no reply). + pub fn handle_line(&mut self, line: &str) -> Option { + let line = line.trim(); + if line.is_empty() { + return None; + } + if line.len() > MAX_LINE { + return Some(error(Value::Null, INVALID_REQUEST, "request too large").to_string()); + } + let reply = match serde_json::from_str::(line) { + Ok(Value::Array(batch)) if batch.is_empty() => Some(error(Value::Null, INVALID_REQUEST, "empty batch")), + Ok(Value::Array(batch)) if batch.len() > MAX_BATCH_MESSAGES => { + Some(error(Value::Null, INVALID_REQUEST, format!("batch too large (at most {MAX_BATCH_MESSAGES} messages)"))) + } + Ok(Value::Array(batch)) => { + let replies: Vec = batch.into_iter().filter_map(|m| self.handle(m)).collect(); + (!replies.is_empty()).then_some(Value::Array(replies)) + } + Ok(msg) => self.handle(msg), + Err(e) => Some(error(Value::Null, PARSE_ERROR, format!("parse error: {e}"))), + }; + reply.map(|r| r.to_string()) + } + + /// One message in, its reply out (none for notifications and responses). + pub fn handle(&mut self, msg: Value) -> Option { + let Value::Object(o) = &msg else { return Some(error(Value::Null, INVALID_REQUEST, "a message must be an object")) }; + let id = o.get("id").cloned(); + if let Some(id) = &id + && !(id.is_string() || id.is_i64() || id.is_u64()) + { + return Some(error(Value::Null, INVALID_REQUEST, "`id` must be a string or an integer")); + } + let Some(method) = o.get("method").and_then(Value::as_str) else { + // A response to a request of ours (none are sent) or junk. + if o.contains_key("result") || o.contains_key("error") { + return None; + } + return Some(error(id.unwrap_or(Value::Null), INVALID_REQUEST, "missing `method`")); + }; + let params = o.get("params").cloned().unwrap_or(Value::Null); + let Some(id) = id else { + if method == "notifications/initialized" { + self.initialized = true; + } + return None; + }; + Some(match self.request(method, ¶ms) { + Ok(r) => response(id, r), + Err((code, m)) => error(id, code, m), + }) + } + + fn request(&mut self, method: &str, params: &Value) -> Result { + match method { + "initialize" => { + let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION); + let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION }; + Ok(json!({ + "protocolVersion": version, + "capabilities": {"tools": {"listChanged": false}, "resources": {"listChanged": false}}, + "serverInfo": {"name": "modelcraft", "title": "ModelCraft", "version": env!("CARGO_PKG_VERSION")}, + "instructions": format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe()), + })) + } + "ping" => Ok(json!({})), + "tools/list" => Ok(json!({"tools": tool_definitions()})), + "tools/call" => { + let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?; + let args = match params.get("arguments") { + None | Some(Value::Null) => json!({}), + Some(a) => a.clone(), + }; + check_arguments(name, &args).map_err(|e| (INVALID_PARAMS, e))?; + Ok(call_tool(self.backend.as_mut(), name, &args)) + } + "resources/list" => Ok(json!({"resources": [ + {"uri": "modelcraft://model", "name": "Model", "description": "model.info: node and element counts, units, definitions", "mimeType": "application/json"}, + {"uri": "modelcraft://commands", "name": "Command catalog", "description": "every command with its parameter docs", "mimeType": "application/json"}, + ]})), + "resources/templates/list" => Ok(json!({"resourceTemplates": []})), + "prompts/list" => Ok(json!({"prompts": []})), + "resources/read" => { + let uri = params.get("uri").and_then(Value::as_str).unwrap_or(""); + let v = match uri { + "modelcraft://model" => self.backend.execute("model.info", json!({})), + "modelcraft://commands" => self.backend.commands(), + _ => return Err((RESOURCE_NOT_FOUND, format!("unknown resource `{}`", uri.chars().take(200).collect::()))), + } + .map_err(|e| (INTERNAL_ERROR, e))?; + Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": v.to_string()}]})) + } + _ => Err((METHOD_NOT_FOUND, format!("method `{}` not found", method.chars().take(200).collect::()))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lines_are_capped_and_the_stream_stays_in_step() { + let mut input = Vec::new(); + input.extend(vec![b'x'; 100]); + input.extend(b"\nshort\n"); + input.extend(vec![b'y'; 30]); + let mut r = std::io::BufReader::with_capacity(8, input.as_slice()); + assert_eq!(read_line(&mut r, 10).unwrap_err().kind(), std::io::ErrorKind::InvalidData); + assert_eq!(read_line(&mut r, 10).unwrap().unwrap(), b"short"); + assert!(read_line(&mut r, 10).is_err(), "a last line without newline that is too long"); + assert!(read_line(&mut r, 10).unwrap().is_none()); + } +} diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs new file mode 100644 index 0000000..2752d46 --- /dev/null +++ b/crates/mcp/src/tests.rs @@ -0,0 +1,269 @@ +//! The protocol and the tools driven through JSON-RPC lines, as a client sends them, with +//! hostile input: malformed messages, huge parameters, paths outside the roots, unknown tools. + +use std::path::{Path, PathBuf}; + +use modelcraft_io::roots::Roots; +use serde_json::{Value, json}; + +use crate::{Headless, MAX_LINE, Options, Server}; + +fn server() -> Server { + Server::new(Box::new(Headless::default())) +} + +fn confined(read: &Path, write: &Path) -> Server { + Server::new(Box::new(Headless::new(Roots::new(Some(read), Some(write)).unwrap()))) +} + +fn send(s: &mut Server, msg: Value) -> Value { + serde_json::from_str(&s.handle_line(&msg.to_string()).unwrap()).unwrap() +} + +/// A tool call: (text of the first content block, is_error). +fn call(s: &mut Server, name: &str, args: Value) -> (String, bool) { + let r = send(s, json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": args}})); + let result = &r["result"]; + assert!(result.is_object(), "{name}: {r}"); + let text = result["content"].as_array().unwrap().iter().find(|c| c["type"] == "text").map(|c| c["text"].as_str().unwrap().to_string()); + (text.unwrap_or_default(), result["isError"] == json!(true)) +} + +fn ok(s: &mut Server, name: &str, args: Value) -> Value { + let (text, err) = call(s, name, args.clone()); + assert!(!err, "{name} {args}: {text}"); + serde_json::from_str(&text).unwrap_or(Value::String(text)) +} + +struct Dir(PathBuf); +impl Dir { + fn new(tag: &str) -> Dir { + let d = std::env::temp_dir().join(format!("modelcraft-mcp-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + Dir(std::fs::canonicalize(&d).unwrap()) + } +} +impl Drop for Dir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[test] +fn initialize_lists_tools_and_resources() { + let mut s = server(); + let r = send(&mut s, json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-03-26", "capabilities": {}}})); + assert_eq!(r["result"]["protocolVersion"], "2025-03-26"); + assert_eq!(r["result"]["serverInfo"]["name"], "modelcraft"); + assert!(r["result"]["instructions"].as_str().unwrap().contains("headless")); + let r = send(&mut s, json!({"jsonrpc": "2.0", "id": 2, "method": "initialize", "params": {"protocolVersion": "1999-01-01"}})); + assert_eq!(r["result"]["protocolVersion"], crate::PROTOCOL_VERSION); + assert!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#).is_none()); + assert!(s.is_initialized()); + let tools = send(&mut s, json!({"jsonrpc": "2.0", "id": 3, "method": "tools/list"})); + let names: Vec<&str> = tools["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect(); + for t in [ + "list_commands", + "execute", + "batch", + "inspect_model", + "check_model", + "open", + "save", + "write_deck", + "mesh", + "solve", + "show_results", + "probe", + "reactions", + "screenshot", + "undo", + "redo", + ] { + assert!(names.contains(&t), "{t}"); + } + let r = send(&mut s, json!({"jsonrpc": "2.0", "id": 4, "method": "resources/read", "params": {"uri": "modelcraft://model"}})); + assert!(r["result"]["contents"][0]["text"].as_str().unwrap().contains("nodes")); + let r = send(&mut s, json!({"jsonrpc": "2.0", "id": 5, "method": "resources/read", "params": {"uri": "modelcraft://commands"}})); + assert!(r["result"]["contents"][0]["text"].as_str().unwrap().contains("solve.static")); + assert_eq!(send(&mut s, json!({"jsonrpc": "2.0", "id": 6, "method": "ping"}))["result"], json!({})); +} + +#[test] +fn malformed_messages_get_json_rpc_errors() { + let mut s = server(); + let code = |s: &mut Server, line: &str| -> i64 { + let r: Value = serde_json::from_str(&s.handle_line(line).unwrap()).unwrap(); + r["error"]["code"].as_i64().unwrap() + }; + assert_eq!(code(&mut s, "{not json"), -32700); + assert_eq!(code(&mut s, "\"just a string\""), -32600); + assert_eq!(code(&mut s, "42"), -32600); + assert_eq!(code(&mut s, "[]"), -32600); + assert_eq!(code(&mut s, &format!("[{}]", vec!["{}"; 1000].join(","))), -32600); + assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":1}"#), -32600); + assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":{"a":1},"method":"ping"}"#), -32600); + assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"nope"}"#), -32601); + assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{}}"#), -32602); + assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"rm_rf"}}"#), -32602); + assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute","arguments":[]}}"#), -32602); + assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute","arguments":{}}}"#), -32602); + assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute","arguments":{"command":7}}}"#), -32602); + assert_eq!( + code(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute","arguments":{"command":"x","extra":1}}}"#), + -32602 + ); + assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"mesh","arguments":{"method":"voodoo"}}}"#), -32602); + assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"resources/read","params":{"uri":"file:///etc/passwd"}}"#), -32002); + // Deeply nested JSON is refused by the parser, not by a stack overflow. + assert_eq!(code(&mut s, &format!("{}{}", "[".repeat(100_000), "]".repeat(100_000))), -32700); + // Notifications and responses get no reply; a batch replies to its requests only. + assert!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}"#).is_none()); + assert!(s.handle_line(r#"{"jsonrpc":"2.0","id":9,"result":{}}"#).is_none()); + let r: Value = + serde_json::from_str(&s.handle_line(r#"[{"jsonrpc":"2.0","id":1,"method":"ping"},{"jsonrpc":"2.0","method":"x"}]"#).unwrap()).unwrap(); + assert_eq!(r.as_array().unwrap().len(), 1); +} + +#[test] +fn serve_survives_huge_lines_and_bad_bytes() { + let mut s = server(); + let mut input = Vec::new(); + input.extend(br#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#); + input.push(b'\n'); + input.extend(br#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"execute","arguments":{"command":"model.info","params":{"x":""#); + input.extend(vec![b'a'; MAX_LINE + 10]); + input.extend(b"\"}}}}\n"); + input.extend([0xff, 0xfe, b'\n']); + input.extend(br#"{"jsonrpc":"2.0","id":3,"method":"ping"}"#); + input.push(b'\n'); + let mut out = Vec::new(); + s.serve(input.as_slice(), &mut out).unwrap(); + let replies: Vec = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(replies.len(), 4, "{replies:?}"); + assert_eq!(replies[0]["id"], 1); + assert!(replies[1]["error"]["message"].as_str().unwrap().contains("too large")); + assert_eq!(replies[2]["error"]["code"], -32700); + assert_eq!(replies[3]["id"], 3, "the server keeps serving"); +} + +#[test] +fn hostile_tool_arguments_are_tool_errors() { + let mut s = server(); + let huge = "x".repeat(1 << 20); + for (tool, args) in [ + ("execute", json!({"command": "nope.nothing"})), + ("execute", json!({"command": huge})), + ("execute", json!({"command": "mesh.node.add", "params": {"label": -1, "xyz": [1e308, null, "a"]}})), + ("execute", json!({"command": "mesh.element.add", "params": {"label": 1, "kind": "hex20", "nodes": vec![u32::MAX; 20]}})), + ("execute", json!({"command": "file.open", "params": {"path": "\u{0}"}})), + ("open", json!({"path": "/etc/passwd"})), + ("open", json!({"path": "/no/such/model.modelcraft"})), + ("open", json!({"path": huge.clone() + ".solidcraft"})), + ("save", json!({})), + ("write_deck", json!({"format": "abaqus", "path": "/no/such/dir/x.inp"})), + ("mesh", json!({"method": "tet", "params": {"body": "Nope", "tolerance": 0.5}})), + ("mesh", json!({"method": "tet", "params": {"surface": 1e300}})), + ("solve", json!({"analysis": "static"})), + ("solve", json!({"analysis": "frequency", "step": "Nope", "options": {"max_dofs": -1}})), + ("show_results", json!({})), + ("show_results", json!({"bands": 5})), + ("show_results", json!({"field": "von_mises"})), + ("probe", json!({"nodes": [1, 2, 3]})), + ("reactions", json!({"sets": ["nope"], "about": [1e308, 0, 0]})), + ("screenshot", json!({"view": "iso", "path": "/no/such/dir/x.png"})), + ("batch", json!({"commands": [{"command": "model.info"}, {"params": {}}, {"command": "model.info"}]})), + ("batch", json!({"commands": vec![json!({"command": "model.info"}); crate::tools::MAX_STEPS + 1]})), + ("undo", json!({})), + ("redo", json!({})), + ] { + let (text, err) = call(&mut s, tool, args.clone()); + assert!(err, "{tool} should fail: {text}"); + assert!(text.len() < 100_000, "{tool}: errors stay short"); + } + // Nothing changed on the way. + assert_eq!(ok(&mut s, "inspect_model", json!({}))["model"]["nodes"], 0); + // A huge but valid image size is clamped, not refused or allocated. + let r = send( + &mut s, + json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "screenshot", "arguments": {"width": u64::MAX, "height": 16, "source": "model"}}}), + ); + assert_eq!(r["result"]["content"][0]["type"], "image", "{r}"); +} + +#[test] +fn a_batch_stops_at_the_first_error() { + let mut s = server(); + let r = call( + &mut s, + "batch", + json!({"commands": [ + {"command": "mesh.node.add", "params": {"label": 1, "xyz": [0, 0, 0]}}, + {"command": "mesh.node.add", "params": {"label": 0, "xyz": [0, 0, 0]}}, + {"command": "mesh.node.add", "params": {"label": 2, "xyz": [0, 0, 0]}}, + ]}), + ); + assert!(r.1); + let v: Value = serde_json::from_str(&r.0).unwrap(); + assert_eq!((v["completed"].clone(), v["failed"]["index"].clone()), (json!(1), json!(1))); + assert_eq!(ok(&mut s, "inspect_model", json!({}))["model"]["nodes"], 1); + ok(&mut s, "undo", json!({})); + assert_eq!(ok(&mut s, "inspect_model", json!({}))["model"]["nodes"], 0); + let listed = ok(&mut s, "list_commands", json!({"filter": "SOLVE."})); + assert!(listed.as_array().unwrap().iter().all(|c| c["id"].as_str().unwrap().starts_with("solve."))); +} + +#[test] +fn file_access_stays_inside_the_roots() { + let d = Dir::new("roots"); + let (input, output, outside) = (d.0.join("in"), d.0.join("out"), d.0.join("outside")); + for f in [&input, &output, &outside] { + std::fs::create_dir_all(f).unwrap(); + } + let design = modelcraft_testkit::corpus::Analytic::Cylinder { radius: 5.0, height: 10.0 }.design().unwrap(); + std::fs::write(outside.join("c.solidcraft"), &design).unwrap(); + std::fs::write(input.join("c.solidcraft"), &design).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(outside.join("c.solidcraft"), input.join("link.solidcraft")).unwrap(); + let mut s = confined(&input, &output); + let refused = |s: &mut Server, tool: &str, args: Value| { + let (text, err) = call(s, tool, args.clone()); + assert!(err && text.contains("automation"), "{tool} {args}: {text}"); + }; + refused(&mut s, "open", json!({"path": outside.join("c.solidcraft")})); + #[cfg(unix)] + refused(&mut s, "open", json!({"path": input.join("link.solidcraft")})); + refused(&mut s, "execute", json!({"command": "geometry.open", "params": {"path": input.join("../outside/c.solidcraft")}})); + ok(&mut s, "open", json!({"path": input.join("c.solidcraft")})); + refused(&mut s, "save", json!({"path": outside.join("m.modelcraft")})); + refused(&mut s, "save", json!({"path": input.join("m.modelcraft")})); + refused(&mut s, "execute", json!({"command": "file.save_as", "params": {"path": outside.join("m.modelcraft")}})); + refused(&mut s, "write_deck", json!({"format": "abaqus", "path": outside.join("m.inp")})); + refused(&mut s, "screenshot", json!({"path": outside.join("x.png")})); + refused(&mut s, "screenshot", json!({"path": input.join("x.png")})); + refused(&mut s, "execute", json!({"command": "file.recovery.list", "params": {"dir": outside}})); + ok(&mut s, "save", json!({"path": output.join("m.modelcraft")})); + ok(&mut s, "screenshot", json!({"path": output.join("x.png")})); + assert!(output.join("m.modelcraft").is_file() && output.join("x.png").is_file()); + assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "nothing written outside"); +} + +#[test] +fn roots_imply_headless() { + let d = Dir::new("connect"); + let options = Options { connect: Some("127.0.0.1:1".into()), roots: Roots::new(Some(&d.0), None).unwrap() }; + assert!(crate::backend(options).err().unwrap().contains("cannot be combined")); + // An app that cannot be reached: headless, with a note. + let (b, note) = crate::backend(Options { connect: Some("127.0.0.1:1".into()), roots: Roots::default() }).unwrap(); + assert!(note.unwrap().contains("running headless")); + assert!(b.describe().contains("headless")); +} + +#[test] +fn base64_matches_the_rfc_vectors() { + 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); + } +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs new file mode 100644 index 0000000..82b171d --- /dev/null +++ b/crates/mcp/src/tools.rs @@ -0,0 +1,557 @@ +//! The tools: their definitions (JSON schemas) and what they run. Every tool is a thin wrapper +//! over engine commands; the same list serves the headless and the connected backend. + +use std::path::Path; + +use modelcraft_io::roots::Access; +use serde_json::{Map, Value, json}; + +use crate::backend::{Backend, PictureRequest}; + +/// Most commands in one `batch` call. +pub const MAX_STEPS: usize = 10_000; + +/// The meshers the `mesh` tool runs, by method; a method whose command this build lacks is +/// left out. +const MESHERS: &[(&str, &str, &str)] = &[ + ("tet", "mesh.tet.generate", "constrained Delaunay tets of a body {body, tolerance} or an imported surface {surface}"), + ("hex", "mesh.hex.generate", "all-hex mesh of a body"), + ("hex_sweep", "mesh.hex.sweep", "swept all-hex mesh"), + ("hex_mapped", "mesh.hex.mapped", "mapped all-hex mesh"), + ("block", "mesh.block", "structured hex block"), + ("planar", "mesh.planar", "2D elements of a polygon or a planar face"), +]; + +/// The analyses the `solve` tool runs; one whose command this build lacks is left out. +const ANALYSES: &[(&str, &str)] = + &[("static", "solve.static"), ("frequency", "solve.frequency"), ("explicit", "solve.explicit"), ("fluid", "solve.fluid"), ("heat", "solve.heat")]; + +const VIEWS: &[&str] = &["iso", "home", "fit", "front", "back", "top", "bottom", "left", "right"]; + +fn available(table: &'static [T], command: fn(T) -> &'static str) -> impl Iterator { + table.iter().copied().filter(move |t| modelcraft_engine::find_command(command(*t)).is_some()) +} + +fn obj(props: Value, required: &[&str]) -> Value { + let mut o = json!({"type": "object", "properties": props, "additionalProperties": false}); + if !required.is_empty() { + o["required"] = json!(required); + } + o +} + +fn tool(name: &str, title: &str, desc: &str, schema: Value, read_only: bool, destructive: bool) -> Value { + json!({ + "name": name, + "title": title, + "description": desc, + "inputSchema": schema, + "annotations": {"title": title, "readOnlyHint": read_only, "destructiveHint": destructive, "idempotentHint": read_only, "openWorldHint": false}, + }) +} + +fn string(desc: &str) -> Value { + json!({"type": "string", "description": desc}) +} + +fn integer(desc: &str) -> Value { + json!({"type": "integer", "description": desc}) +} + +/// Every tool, with its JSON schema. +pub fn tool_definitions() -> Vec { + let methods: Vec<&str> = available(MESHERS, |m| m.1).map(|m| m.0).collect(); + let method_docs: Vec = available(MESHERS, |m| m.1).map(|(m, c, d)| format!("{m} ({c}: {d})")).collect(); + let analyses: Vec<&str> = available(ANALYSES, |a| a.1).map(|a| a.0).collect(); + vec![ + tool( + "list_commands", + "List commands", + "Every command with its label and parameter documentation. Ids are dotted and lower-case (geometry.open, \ + mesh.tet.generate, model.material.create, model.step.create, solve.static, results.contour). Optional \ + case-insensitive substring `filter` over id and label.", + obj(json!({"filter": string("Substring to match, e.g. \"model.load\", \"mesh\", \"results\"")}), &[]), + true, + false, + ), + tool( + "execute", + "Execute a command", + "Run one command with JSON parameters, exactly as the UI, scripts and the CLI do; it never opens a dialog. \ + Examples: model.units {system: \"mm-t-s-N-MPa\"}; model.set.create {name: \"root\", geometry: [{body: \"Body1\", \ + face: \"F1:start\"}]}; model.material.create {name: \"Steel\", elastic: {type: \"isotropic\", young: 210000, \ + poisson: 0.3}, density: 7.85e-9}; model.section.create {name: \"solid\", set: \"all\", material: \"Steel\", \ + kind: {type: \"solid\"}}; model.boundary_condition.create {name: \"fix\", type: \"fixed\", target: {set: \ + \"root\"}}; model.load.create {name: \"push\", type: \"force\", target: {set: \"tip\"}, vector: [0, 0, -100]}; \ + model.step.create {name: \"Load\", procedure: {type: \"static\"}, boundary_conditions: [\"fix\"], loads: \ + [\"push\"]}. Returns the command's result; a failure leaves the model unchanged and says why.", + obj( + json!({ + "command": string("Command id (see list_commands)"), + "params": {"type": "object", "description": "The command's parameters"}, + }), + &["command"], + ), + false, + false, + ), + tool( + "batch", + "Run several commands", + "Run commands in order, stopping at the first error. Each step is {command, params}. The reply has every \ + result and, on an error, the failing step (index from 0) and its error; the steps before it stay applied \ + (`undo` takes them back one by one).", + obj( + json!({ + "commands": { + "type": "array", + "description": "Steps: [{\"command\": id, \"params\": {...}}, ...]", + "items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}, "required": ["command"]}, + }, + }), + &["commands"], + ), + false, + false, + ), + tool( + "inspect_model", + "Inspect the model", + "The model: node and element counts, the unit system and the definitions by kind (model.info); the step \ + table (which boundary conditions and loads each step has, model.activity); the open design's bodies with \ + their measures and persistent face names (geometry.bodies), which sets name as {body, face}.", + obj(json!({}), &[]), + true, + false, + ), + tool( + "check_model", + "Check the model", + "The model check (model.check): every error and warning, each naming its item (a set member not on the \ + mesh, an element with no section, a missing density for a frequency step, implausible units…). A model \ + with errors does not solve or write.", + obj(json!({}), &[]), + true, + false, + ), + tool( + "open", + "Open a file", + "Open by extension: a .modelcraft model (file.open: replaces the model; its design and meshes open again), a \ + SolidCraft design .solidcraft (geometry.open: its bodies become the geometry) or a face-tagged .3mf mesh \ + (geometry.import.mesh: added as a surface).", + obj(json!({"path": string("Path to a .modelcraft, .solidcraft or .3mf file")}), &["path"]), + false, + true, + ), + tool( + "save", + "Save the model", + "Save the model as a .modelcraft file (file.save): to `path`, or to the file it came from.", + obj(json!({"path": string("Path to save to (default: the model's file)")}), &[]), + false, + false, + ), + tool( + "write_deck", + "Write a solver deck", + "Write the model as a solver input deck (deck.write): Abaqus input (abaqus), Nastran bulk data (nastran) or \ + an LS-DYNA keyword deck (lsdyna). Fails naming every model check error and everything the format cannot \ + express.", + obj( + json!({ + "format": {"type": "string", "enum": ["abaqus", "nastran", "lsdyna"]}, + "path": string("Output file"), + "steps": {"type": "array", "items": {"type": "string"}, "description": "Only these steps (a Nastran run is static or frequency steps)"}, + }), + &["format", "path"], + ), + false, + false, + ), + tool( + "mesh", + "Mesh", + &format!( + "Mesh the geometry with the chosen method and parameters (list_commands shows each mesher's options), then \ + report the mesh, the model's counts and the mesh quality (mesh.quality: certified validity, volume, metric \ + extremes). Methods: {}.", + method_docs.join("; ") + ), + obj( + json!({ + "method": {"type": "string", "enum": methods}, + "params": {"type": "object", "description": "The mesher's parameters, e.g. {\"body\": \"Body1\", \"tolerance\": 0.5}"}, + }), + &["method"], + ), + false, + false, + ), + tool( + "solve", + "Solve a step", + "Solve a step of the model and keep its result: static (linear static), frequency (modes), explicit \ + (explicit dynamics), fluid (lattice Boltzmann), heat when available. `step` may be left out when the model \ + has one step of that kind; `options` takes the solver's caps and settings (list_commands). The results \ + display then shows the step (show_results changes it).", + obj( + json!({ + "analysis": {"type": "string", "enum": analyses}, + "step": string("Step name"), + "options": {"type": "object", "description": "More solve parameters, e.g. {\"max_dofs\": 1000000} or {\"mass\": \"lumped\"}"}, + }), + &["analysis"], + ), + false, + false, + ), + tool( + "show_results", + "Show results", + "Choose what the results display shows and read it back: the step, the deformed shape (`deformed`, `scale`: \ + a factor or \"auto\"), contours of a nodal field (`field`: displacement, displacement_x/y/z, von_mises, \ + max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz, or none; `bands` 2–24), a frequency step's mode or an \ + explicit step's frame. The reply has the contour's range: its smallest and largest value and their nodes \ + (field displacement gives the largest displacement). Only `step`: show that step.", + obj( + json!({ + "step": string("Solved step (default: the one shown)"), + "field": string("Contour field, or \"none\""), + "bands": integer("Contour bands, 2 to 24"), + "deformed": {"type": "boolean", "description": "Draw the deformed shape"}, + "scale": {"type": ["number", "string"], "description": "Deformation scale factor or \"auto\""}, + "mode": integer("Mode of a frequency step, from 1"), + "frame": integer("Field frame of an explicit step, from 0"), + "time": {"type": "number", "description": "Show the explicit step's frame nearest this time"}, + }), + &[], + ), + false, + false, + ), + tool( + "probe", + "Probe results", + "Stored results at nodes (solve.probe): displacement, reaction, stress and von Mises (static), the shape of \ + mode `mode` (frequency), displacement and velocity in field frame `frame` (explicit) and, with `elements`, \ + explicit element values; for a fluid step, the flow at `points` and the force histories of `forces`.", + obj( + json!({ + "step": string("Solved step"), + "nodes": {"type": "array", "items": {"type": "integer"}, "description": "Node labels"}, + "elements": {"type": "array", "items": {"type": "integer"}, "description": "Element labels (explicit)"}, + "points": {"type": "array", "items": {"type": "array"}, "description": "Points [x, y, z] (fluid)"}, + "forces": {"type": "array", "items": {"type": "string"}, "description": "Walls or faces whose force histories to return (fluid)"}, + "mode": integer("Mode, from 1 (frequency)"), + "frame": integer("Field frame, from 0 (explicit, fluid)"), + }), + &[], + ), + true, + false, + ), + tool( + "reactions", + "Reaction sums", + "Reaction force and moment (about `about`) summed over each node set and over the whole model \ + (results.reactions); `sets` defaults to the sets the step's boundary conditions hold.", + obj( + json!({ + "step": string("Solved static step"), + "sets": {"type": "array", "items": {"type": "string"}, "description": "Node sets"}, + "about": {"type": "array", "items": {"type": "number"}, "description": "Moment point [x, y, z] (default origin)"}, + }), + &[], + ), + true, + false, + ), + tool( + "screenshot", + "Look at the model", + "A PNG of the model and the results display. Headless: a CPU render framing everything drawn. Connected to \ + the app: the whole window drawn offscreen (source \"window\", the default; ui.capture) or the model with the \ + app's camera (source \"model\"; ui.render). `view` turns the camera to a standard view first.", + obj( + json!({ + "view": {"type": "string", "enum": VIEWS, "description": "Standard view (default: keep the current one; headless starts at iso)"}, + "source": {"type": "string", "enum": ["window", "model"], "description": "window = the app's window, model = a model render"}, + "width": integer("Width in pixels (window: points), 16 to 4096"), + "height": integer("Height in pixels (window: points), 16 to 4096"), + "path": string("Also save the PNG here"), + }), + &[], + ), + true, + false, + ), + tool("undo", "Undo", "Take back the last command that changed the model.", obj(json!({}), &[]), false, false), + tool("redo", "Redo", "Run the last undone change again.", obj(json!({}), &[]), false, false), + ] +} + +fn type_matches(t: &str, v: &Value) -> bool { + match t { + "string" => v.is_string(), + "number" => v.is_number(), + "integer" => v.is_i64() || v.is_u64(), + "boolean" => v.is_boolean(), + "array" => v.is_array(), + "object" => v.is_object(), + "null" => v.is_null(), + _ => true, + } +} + +/// Checks `args` against the tool's schema (top level): unknown tool, missing required +/// arguments, unknown arguments, type and enum mismatches. +pub(crate) fn check_arguments(name: &str, args: &Value) -> Result<(), String> { + let defs = tool_definitions(); + let shown: String = name.chars().take(100).collect(); + let def = defs.iter().find(|d| d["name"] == name).ok_or_else(|| format!("unknown tool `{shown}`"))?; + let schema = &def["inputSchema"]; + let Some(a) = args.as_object() else { return Err(format!("{name}: arguments must be an object")) }; + for r in schema["required"].as_array().into_iter().flatten().filter_map(Value::as_str) { + if a.get(r).is_none_or(Value::is_null) { + return Err(format!("{name}: missing required argument `{r}`")); + } + } + let props = schema["properties"].as_object(); + for (k, v) in a { + let Some(p) = props.and_then(|p| p.get(k)) else { + return Err(format!("{name}: unknown argument `{}`", k.chars().take(100).collect::())); + }; + if v.is_null() { + continue; + } + let ok = match &p["type"] { + Value::String(t) => type_matches(t, v), + Value::Array(ts) => ts.iter().filter_map(Value::as_str).any(|t| type_matches(t, v)), + _ => true, + }; + if !ok { + return Err(format!("{name}: argument `{k}` must be of type {}", p["type"])); + } + if let Some(allowed) = p["enum"].as_array() + && !allowed.contains(v) + { + return Err(format!("{name}: `{k}` must be one of {}", Value::Array(allowed.clone()))); + } + } + Ok(()) +} + +/// A tool result: one text block and the error flag. +fn text_result(v: &Value, is_error: bool) -> Value { + let text = match v { + Value::String(s) => s.clone(), + v => serde_json::to_string_pretty(v).unwrap_or_default(), + }; + let mut r = json!({"content": [{"type": "text", "text": text}]}); + if is_error { + r["isError"] = json!(true); + } + r +} + +/// Run a tool. A failure is a tool result with `isError: true` (the model reads the message and +/// corrects); nothing here panics on hostile arguments. +pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> Value { + let empty = Map::new(); + let a = args.as_object().unwrap_or(&empty); + let r = match name { + "screenshot" => return screenshot(b, a), + "batch" => return batch(b, a), + _ => dispatch(b, name, a), + }; + match r { + Ok(v) => text_result(&v, false), + Err(e) => text_result(&Value::String(e), true), + } +} + +fn pick(a: &Map, keys: &[&str]) -> Value { + Value::Object(keys.iter().filter_map(|k| a.get(*k).filter(|v| !v.is_null()).map(|v| (k.to_string(), v.clone()))).collect()) +} + +fn str_arg<'a>(a: &'a Map, k: &str) -> Option<&'a str> { + a.get(k).and_then(Value::as_str) +} + +/// An object argument (absent: empty). +fn object_arg(a: &Map, k: &str) -> Value { + a.get(k).cloned().filter(Value::is_object).unwrap_or_else(|| json!({})) +} + +fn dispatch(b: &mut dyn Backend, name: &str, a: &Map) -> Result { + match name { + "list_commands" => { + let all = b.commands()?; + let Some(f) = str_arg(a, "filter").map(str::to_lowercase).filter(|f| !f.is_empty()) else { return Ok(all) }; + let hit = |c: &Value| ["id", "label"].iter().any(|k| c.get(*k).and_then(Value::as_str).is_some_and(|s| s.to_lowercase().contains(&f))); + Ok(Value::Array(all.as_array().map(|v| v.iter().filter(|c| hit(c)).cloned().collect()).unwrap_or_default())) + } + "execute" => { + let id = str_arg(a, "command").ok_or("`command` is required")?; + b.execute(id, object_arg(a, "params")) + } + "inspect_model" => Ok(json!({ + "model": b.execute("model.info", json!({}))?, + "steps": b.execute("model.activity", json!({}))?, + "bodies": b.execute("geometry.bodies", json!({}))?, + })), + "check_model" => b.execute("model.check", json!({})), + "open" => { + let path = str_arg(a, "path").ok_or("`path` is required")?; + let ext = Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase); + let command = match ext.as_deref() { + Some("modelcraft") => "file.open", + Some("solidcraft") => "geometry.open", + Some("3mf") => "geometry.import.mesh", + _ => return Err("open: a .modelcraft model, a .solidcraft design or a .3mf mesh".into()), + }; + let r = b.execute(command, json!({"path": path}))?; + Ok(json!({"command": command, "result": r})) + } + "save" => b.execute("file.save", pick(a, &["path"])), + "write_deck" => b.execute("deck.write", pick(a, &["format", "path", "steps"])), + "mesh" => { + let method = str_arg(a, "method").ok_or("`method` is required")?; + let (_, command, _) = available(MESHERS, |m| m.1).find(|m| m.0 == method).ok_or_else(|| format!("unknown mesh method `{method}`"))?; + let mesh = b.execute(command, object_arg(a, "params"))?; + let model = b.execute("model.info", json!({}))?; + let quality = b.execute("mesh.quality", json!({})).unwrap_or_else(|e| json!({"error": e})); + Ok(json!({"command": command, "mesh": mesh, "model": model, "quality": quality})) + } + "solve" => { + let analysis = str_arg(a, "analysis").ok_or("`analysis` is required")?; + let (_, command) = available(ANALYSES, |x| x.1).find(|x| x.0 == analysis).ok_or_else(|| format!("unknown analysis `{analysis}`"))?; + let mut params = object_arg(a, "options"); + if let Some(step) = a.get("step").filter(|s| !s.is_null()) { + params["step"] = step.clone(); + } + b.execute(command, params) + } + "show_results" => show_results(b, a), + "probe" => b.execute("solve.probe", pick(a, &["step", "nodes", "elements", "points", "forces", "mode", "frame"])), + "reactions" => b.execute("results.reactions", pick(a, &["step", "sets", "about"])), + "undo" => b.execute("edit.undo", json!({})), + "redo" => b.execute("edit.redo", json!({})), + other => Err(format!("unknown tool `{}`", other.chars().take(100).collect::())), + } +} + +/// `show_results`: the `results.*` commands its arguments ask for, in a fixed order; the reply of +/// the last one (each replies with the whole display). +fn show_results(b: &mut dyn Backend, a: &Map) -> Result { + let step = pick(a, &["step"]); + let with_step = |mut p: Value| { + if let (Some(o), Some(s)) = (p.as_object_mut(), step.get("step")) { + o.insert("step".into(), s.clone()); + } + p + }; + let mut last = None; + if let Some(mode) = a.get("mode").filter(|v| !v.is_null()) { + last = Some(b.execute("results.modes", with_step(json!({"mode": mode})))?); + } + if a.get("frame").is_some_and(|v| !v.is_null()) || a.get("time").is_some_and(|v| !v.is_null()) { + last = Some(b.execute("results.frame", with_step(pick(a, &["frame", "time"])))?); + } + if a.get("deformed").is_some_and(|v| !v.is_null()) || a.get("scale").is_some_and(|v| !v.is_null()) { + let mut p = pick(a, &["scale"]); + if let Some(show) = a.get("deformed").filter(|v| !v.is_null()) { + p["show"] = show.clone(); + } + last = Some(b.execute("results.deformed", with_step(p))?); + } + if a.get("field").is_some_and(|v| !v.is_null()) { + last = Some(b.execute("results.contour", with_step(pick(a, &["field", "bands"])))?); + } else if a.get("bands").is_some_and(|v| !v.is_null()) { + return Err("show_results: `bands` goes with `field`".into()); + } + match last { + Some(v) => Ok(v), + // Only a step: show it. + None if step.get("step").is_some() => b.execute("results.deformed", step), + None => Err("show_results: give `step`, `field`, `deformed`, `scale`, `mode`, `frame` or `time`".into()), + } +} + +/// `batch`: run steps in order, stopping at the first failure. +fn batch(b: &mut dyn Backend, a: &Map) -> Value { + let Some(steps) = a.get("commands").and_then(Value::as_array) else { + return text_result(&json!("`commands` must be an array of {command, params}"), true); + }; + if steps.len() > MAX_STEPS { + return text_result(&json!(format!("batch too long ({} steps, at most {MAX_STEPS})", steps.len())), true); + } + let mut results = Vec::with_capacity(steps.len()); + for (i, step) in steps.iter().enumerate() { + let command = step.get("command").and_then(Value::as_str); + let r = match command { + Some(id) => b.execute(id, step.get("params").cloned().filter(Value::is_object).unwrap_or_else(|| json!({}))), + None => Err("missing `command`".to_string()), + }; + match r { + Ok(v) => results.push(v), + Err(e) => { + return text_result( + &json!({"ok": false, "completed": i, "failed": {"index": i, "command": command, "error": e}, "results": results}), + true, + ); + } + } + } + text_result(&json!({"ok": true, "completed": results.len(), "results": results}), false) +} + +/// `screenshot`: a PNG as an MCP image content block, with what it shows as text. +fn screenshot(b: &mut dyn Backend, a: &Map) -> Value { + match capture(b, a) { + Ok((png, info)) => json!({"content": [ + {"type": "image", "data": base64(&png), "mimeType": "image/png"}, + {"type": "text", "text": info.to_string()}, + ]}), + Err(e) => text_result(&Value::String(e), true), + } +} + +fn capture(b: &mut dyn Backend, a: &Map) -> Result<(Vec, Value), String> { + // The file is checked before anything is drawn. + let keep = match str_arg(a, "path").filter(|p| !p.trim().is_empty()) { + Some(p) => Some(b.roots().check(Path::new(p), Access::Write).map_err(|e| e.to_string())?), + None => None, + }; + let size = |k: &str| a.get(k).and_then(Value::as_u64).map(|v| u32::try_from(v).unwrap_or(u32::MAX)); + let request = PictureRequest { + view: str_arg(a, "view").map(str::to_string), + window: str_arg(a, "source") != Some("model"), + width: size("width"), + height: size("height"), + }; + let picture = b.picture(&request)?; + let mut info = picture.info; + if let Some(path) = &keep { + modelcraft_io::fs::write_atomic(path, &picture.png, false).map_err(|e| e.to_string())?; + info["path"] = json!(path.to_string_lossy()); + } + info["bytes"] = json!(picture.png.len()); + Ok((picture.png, info)) +} + +/// Standard base64 with padding (RFC 4648). +pub(crate) fn base64(data: &[u8]) -> String { + const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)]; + let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]); + for (i, shift) in [18u32, 12, 6, 0].into_iter().enumerate() { + let digit = T.get(((n >> shift) & 63) as usize).copied().unwrap_or(b'='); + out.push(char::from(if i <= chunk.len() { digit } else { b'=' })); + } + } + out +} diff --git a/crates/mcp/tests/workflow.rs b/crates/mcp/tests/workflow.rs new file mode 100644 index 0000000..e0835dc --- /dev/null +++ b/crates/mcp/tests/workflow.rs @@ -0,0 +1,141 @@ +//! 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 { + 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 { + 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 = 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::().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); +} diff --git a/crates/testkit/src/corpus.rs b/crates/testkit/src/corpus.rs index 427cc2a..afd7f13 100644 --- a/crates/testkit/src/corpus.rs +++ b/crates/testkit/src/corpus.rs @@ -224,15 +224,18 @@ pub fn parts() -> Result> { Ok(parts) } -/// Build a part: run its recipe (or generate the shape) and open the design. -pub fn load(part: &Part) -> Result { - let geometry = |source| Error::Geometry { part: part.name.clone(), source }; - let bytes = match &part.source { +/// A part's design as `.solidcraft` bytes: its recipe run (or its shape generated). +pub fn design_bytes(part: &Part) -> Result> { + match &part.source { Source::Recipe(script) => script_design(script), Source::Analytic(shape) => shape.design(), } - .map_err(geometry)?; - open_design(&bytes).map_err(geometry) + .map_err(|source| Error::Geometry { part: part.name.clone(), source }) +} + +/// Build a part: run its recipe (or generate the shape) and open the design. +pub fn load(part: &Part) -> Result { + open_design(&design_bytes(part)?).map_err(|source| Error::Geometry { part: part.name.clone(), source }) } #[cfg(test)] diff --git a/docs/architecture.md b/docs/architecture.md index 906c29c..f568fcf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,9 +29,9 @@ crates in strictly lower layers. | L2 | `modelcraft-lbm` | Lattice Boltzmann flow solver on the CPU: D3Q19 two-relaxation-time scheme, bounce-back and slip walls, Zou–He inlets and outlets, obstacles voxelised from the face-tagged surface with exact predicates, momentum-exchange forces per face. Uses `mesh` for the predicates and the boundary surface. | started | | L3 | `modelcraft-fsi` | Fluid–structure coupling between the L2 solvers (later). | planned | | L4 | `modelcraft-doc` | The document: geometry, mesh, model, results, history. | planned | -| L5 | `modelcraft-io` | `.modelcraft` files (docs/formats/modelcraft.md): text model definition, binary mesh, geometry references with content hashes, the journal; atomic saves; autosave storage for crash recovery. Bytes in, bytes out; the file system only in `fs` and `recovery`. Result files later. | started | -| L6 | `modelcraft-engine` | Commands (`CommandSpec`), `Session::execute` with rollback and the panic guard, scripts. | started | -| L7 | `modelcraft-ui-egui`, `modelcraft-mcp` | Desktop UI in the Craft family's layout (title bar with quick actions, workspace ribbon from the catalogue, browser, viewport with navigation bar and orientation gizmo, context panel, command search and command line, journal timeline; native file pickers as host services, background opening and solving; results display with legend and probe; offscreen capture of the whole window) and control channel requests (docs/control-protocol.md); MCP server. | ui started | +| L5 | `modelcraft-io` | `.modelcraft` files (docs/formats/modelcraft.md): text model definition, binary mesh, geometry references with content hashes, the journal; atomic saves; autosave storage for crash recovery; read and write roots that confine an automation client's file access (`roots`). Bytes in, bytes out; the file system only in `fs`, `recovery` and `roots`. Result files later. | started | +| L6 | `modelcraft-engine` | Commands (`CommandSpec`), `Session::execute` with rollback and the panic guard, scripts; every file a command names checked against the session's roots (`Session::file_access`); headless pictures of the session (`view`: model, geometry, deformed shape and contours on the CPU rasterizer). | started | +| L7 | `modelcraft-ui-egui`, `modelcraft-mcp` | Desktop UI in the Craft family's layout (title bar with quick actions, workspace ribbon from the catalogue, browser, viewport with navigation bar and orientation gizmo, context panel, command search and command line, journal timeline; native file pickers as host services, background opening and solving; results display with legend and probe; offscreen capture of the whole window) and control channel requests (docs/control-protocol.md); MCP server over stdio (docs/mcp.md): tools over engine commands, a headless session or the running app through its control channel, read and write roots. | ui, mcp started | | testkit | `modelcraft-testkit` | Test tooling, a dev-dependency only: the meshing test corpus (`tests/corpus/*/recipe.json`, analytic shapes) and its runner (`cargo xtask corpus`). | started | | apps | `modelcraft`, `modelcraft-cli`, `modelcraft-web` | Desktop, command line, browser. | desktop, cli started | diff --git a/docs/features.md b/docs/features.md index 520da15..2fce278 100644 --- a/docs/features.md +++ b/docs/features.md @@ -3,7 +3,8 @@ Generated from the capability catalogue (`crates/engine/src/catalogue.rs`) by `cargo run -p modelcraft-cli -- features > docs/features.md`; a test fails when it is out of date. Every entry is a toolbar button. **Ready** entries run their command; **coming soon** entries are -shown greyed with the roadmap milestone that brings them (ROADMAP.md). +shown greyed with the roadmap milestone that brings them (ROADMAP.md). Scripts, the command line, +the control channel and AI agents over MCP (docs/mcp.md) run the same commands. 52 ready, 51 coming soon. diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000..525f5bf --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,153 @@ +# ModelCraft and AI agents (MCP) + +ModelCraft speaks the [Model Context Protocol](https://modelcontextprotocol.io) over stdio +(JSON-RPC 2.0, one message per line; protocol revisions 2025-06-18, 2025-03-26 and 2024-11-05). +An agent builds and solves models the way a person does: every tool runs engine commands, the +same ones the UI, scripts, the command line and the control channel run, and never opens a +dialog or a file picker. The server is `crates/mcp` (`modelcraft-mcp`); `modelcraft-cli mcp` +runs it. + +## Start it + +```sh +# Headless (the default): an in-process session, no window; screenshots are CPU renders. +modelcraft-cli mcp +modelcraft-cli mcp --headless + +# Bridged to the running app, so you can watch the agent work: +modelcraft --control 7070 & +modelcraft-cli mcp --connect 127.0.0.1:7070 +``` + +With `--connect`, an app that cannot be reached when the server starts gives a headless session +instead (a note goes to stderr). stdout carries only protocol messages; errors go to stderr. +Bridged screenshots are written to a temporary file by the app and read back by the server, so +both must run on the same computer. + +### Claude Code + +```sh +claude mcp add modelcraft -- /path/to/modelcraft-cli mcp +# bridged to a running app: +claude mcp add modelcraft -- /path/to/modelcraft-cli mcp --connect 127.0.0.1:7070 +# kept to one project folder: +claude mcp add modelcraft -- /path/to/modelcraft-cli mcp --automation-read-root /work/bracket --automation-write-root /work/bracket/out +``` + +Build the binary first (`cargo build --release -p modelcraft-cli`; it is +`target/release/modelcraft-cli`). The same setup as a project `.mcp.json`, or in another client's +configuration (Claude Desktop's `claude_desktop_config.json`): + +```json +{ + "mcpServers": { + "modelcraft": { "command": "/path/to/modelcraft-cli", "args": ["mcp"] } + } +} +``` + +## Confining file access + +```sh +modelcraft-cli mcp --automation-read-root /work/bracket --automation-write-root /work/bracket/out +``` + +The two flags keep the agent to the folders you give it: + +- Every file a command reads must lie inside the **read root**: `open`, `file.open`, + `geometry.open`, `geometry.import.mesh`, the design and meshes a `.modelcraft` file opens + again, `file.recovery.list` and `file.recover` with a `dir`. +- Every file a command writes must lie inside the **write root**: `save`, `file.save` and + `file.save_as` (also a save to the model's own file), `write_deck` and `deck.write`, + `screenshot {path}`, `file.recovery.discard` with a `dir`. +- Read and write access are separate. Give the same folder twice for both; a root left out + grants none of its access (with only `--automation-read-root`, nothing can be written). Without + either flag nothing is confined. +- A path is made absolute against the server's working folder, then its links are followed: a + file (or folder) link that leads out of a root is outside it. For a file that does not exist + yet, its nearest existing folder's links are followed and the rest must be plain names, so + `..` below a folder that does not exist is refused, as is a link that leads nowhere (writing + through it would create its target). The result is compared with the root by whole folder + names: `/work2/x` is not inside `/work`. On Windows device names (`CON`, `NUL`, `COM1`…) are + refused. +- A refused path is an ordinary tool error the agent can read and correct, for example + `automation path rejected: outside the write root /work/bracket/out: /etc/x.inp` or + `automation filesystem access is not granted: write authority is absent: /work/bracket/out/x.inp`. + A design or mesh a model refers to outside the read root is not opened; the model opens + without it and says so in `warnings`. +- The app's own recovery folder (autosaves, `file.recovery.*` without `dir`) is not confined: + no agent names a path there. +- The roots must be existing folders; a missing one stops the server with an error. Each flag + is given once (`--flag ` or `--flag=`). + +The flags confine the headless server and imply `--headless`; `--connect` with them is an error, +since the server would otherwise drive an app that is not confined. + +A path is checked, then opened: another program that swaps a link in between can still win that +race, so the roots keep an agent to the files it was given rather than guarding against other +software on the computer. + +## Tools + +| Tool | Arguments | What it does | +|---|---|---| +| `list_commands` | `filter?` | Every command with its label and parameter documentation; `filter` matches id and label. | +| `execute` | `command`, `params?` | Run any command (`list_commands`). A failure leaves the model unchanged and names what to fix. | +| `batch` | `commands: [{command, params}]` | Run steps in order, stop at the first error; the reply has every result and the failing step (`failed.index`, `failed.command`, `failed.error`). Steps before it stay applied (`undo` takes them back). | +| `inspect_model` | — | `model.info` (counts, units, definitions), `model.activity` (the step table) and `geometry.bodies` (bodies with measures and persistent face names, which sets name as `{body, face}`). | +| `check_model` | — | `model.check`: errors and warnings, each naming its item. | +| `open` | `path` | By extension: `.modelcraft` (`file.open`), `.solidcraft` (`geometry.open`), `.3mf` (`geometry.import.mesh`). | +| `save` | `path?` | `file.save`: to `path` or the model's own file. | +| `write_deck` | `format` (abaqus, nastran, lsdyna), `path`, `steps?` | `deck.write`. | +| `mesh` | `method`, `params?` | Runs the mesher (`tet`: `mesh.tet.generate`; `planar`: `mesh.planar`; the hex and block meshers as they land) and replies with its result, `model.info` and `mesh.quality`. | +| `solve` | `analysis` (static, frequency, explicit, fluid; heat as it lands), `step?`, `options?` | `solve.` with the step and the solver's caps and settings. | +| `show_results` | `step?`, `field?`, `bands?`, `deformed?`, `scale?`, `mode?`, `frame?`, `time?` | The `results.*` commands its arguments ask for; the reply has the contour range (smallest and largest value and their nodes: `field: "displacement"` gives the largest displacement), modes or frames. | +| `probe` | `step?`, `nodes?`, `elements?`, `points?`, `forces?`, `mode?`, `frame?` | `solve.probe`: stored results at nodes, elements (explicit) or points (fluid). | +| `reactions` | `step?`, `sets?`, `about?` | `results.reactions`: reaction force and moment per node set and in total. | +| `screenshot` | `view?`, `source?` (window, model), `width?`, `height?`, `path?` | A PNG image content block and what it shows. Headless: a CPU render framing the model, the geometry and the results display (deformed shape, contour bands). Bridged: the whole app window drawn offscreen (`ui.capture`, the default) or a model render with the app's camera (`ui.render`); `view` turns the app's camera first. | +| `undo`, `redo` | — | `edit.undo`, `edit.redo`. | + +Resources: `modelcraft://model` (`model.info`) and `modelcraft://commands` (the command catalog). + +Errors: an unknown tool, or arguments that do not match the tool's schema (a missing or unknown +argument, a wrong type, a value outside an enum), are JSON-RPC `-32602` errors; an engine failure +is a tool result with `isError: true` and the engine's message. + +## Limits + +The server treats its input as hostile. A message line may be at most 16 MiB (the control +channel's limit); a longer one is answered with `request too large` and skipped, and the server +keeps serving. A JSON-RPC batch holds at most 100 messages, a `batch` call at most 10 000 +commands; JSON nested deeper than 128 levels is a parse error. Picture sides are clamped to +16–4096 pixels. Every command keeps its own caps (element counts, solver degrees of freedom and +factor size, iterations), and an engine failure or panic leaves the model as it was. + +## A typical session + +```text +→ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"me","version":"1"}}} +→ {"jsonrpc":"2.0","method":"notifications/initialized"} +→ {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"open","arguments":{"path":"/work/steps.solidcraft"}}} +→ {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"inspect_model","arguments":{}}} +← … "bodies": [{"name": "Steps", "faces": [{"name": "F1:-x"}, {"name": "F1:+x"}, …]}] … +→ {"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"mesh","arguments":{"method":"tet","params":{"body":"Steps","tolerance":0.5}}}} +→ {"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"batch","arguments":{"commands":[ + {"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"]}}]}}} +→ {"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"solve","arguments":{"analysis":"static","step":"Load"}}} +→ {"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"show_results","arguments":{"step":"Load","field":"displacement"}}} +← … "contour": {"field": "displacement", "range": {"max": {"node": 25, "value": 0.00485}, …}} … +→ {"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"screenshot","arguments":{"view":"iso"}}} +← {"jsonrpc":"2.0","id":8,"result":{"content":[{"type":"image","mimeType":"image/png","data":"iVBORw0…"},{"type":"text","text":"{\"source\":\"model\",…}"}]}} +``` + +`crates/mcp/tests/workflow.rs` runs this session on a part of the meshing corpus +(`tests/corpus/stepped-block`) and checks the results only through MCP; `crates/mcp/src/tests.rs` +sends malformed messages, huge parameters, paths outside the roots and unknown tools.