MCP server: modelcraft-cli mcp (headless or bridged), read/write roots, end-to-end analysis over MCP (#48)

* io: read and write roots that confine an automation client's file access

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-13 22:18:08 -04:00
1 parent e900f41ce3
commit 0899587127
29 files changed
+2405 -49

No files matched your search

+1
View File
@@ -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 }
+2 -1
View File
@@ -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 {
+3 -2
View File
@@ -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<Value> {
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<Vec<String>> = 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}))
}
+84 -22
View File
@@ -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<T>(
what: &str,
stored: &SourceFile,
base: Option<&Path>,
roots: &Roots,
max: usize,
open: impl FnOnce(&[u8]) -> modelcraft_geometry::Result<T>,
warnings: &mut Vec<String>,
) -> (Option<T>, 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<String>) {
/// (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<String>) {
let mut warnings = Vec::new();
let mut sources = Geometry::default();
let mut design: Option<Arc<Design>> = 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<String>, saved: bool) -> Result<Value> {
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<String>, saved: bool) -> Res
}
fn open(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
fn save(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<PathBuf> {
/// 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<PathBuf> {
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<Value> {
let entries = modelcraft_io::recovery::orphans(&recovery_dir(p)?);
fn recovery_list(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
let dir = recovery_dir(p)?;
fn recovery_discard(s: &mut Session, p: &Value) -> Result<Value> {
let dir = recovery_dir(s, p, Access::Write)?;
if p.get("all").and_then(Value::as_bool) == Some(true) {
let ids: Vec<String> = 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<Value>| {
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");
+7 -6
View File
@@ -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<Value> {
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<Value> {
}
fn import_mesh(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> = surfaces
+13 -3
View File
@@ -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<Vec<u32>> {
.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<String> {
s.checked_path(path_param(p, key)?, access)
}
/// An optional number parameter.
pub(crate) fn optional_number(p: &Value, key: &str, default: f64) -> Result<f64> {
match p.get(key) {
+15 -1
View File
@@ -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<results::ResultsView>,
/// 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<Value> {
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<String> {
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);
+211
View File
@@ -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<String>,
/// 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<String>,
}
/// The model's display data with the open design's bodies and the imported surfaces.
pub fn display(s: &Session) -> (DisplayMesh, Vec<String>) {
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<u8>, 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());
}
}
+6 -2
View File
@@ -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<T> = std::result::Result<T, Error>;
+234
View File
@@ -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<PathBuf>; 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<Roots> {
let resolve = |p: Option<&Path>, access: Access| -> Result<Option<PathBuf>> {
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<PathBuf> {
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<PathBuf, String> {
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"));
}
}
+18
View File
@@ -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 }
+267
View File
@@ -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<String>,
/// 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<u32>,
pub height: Option<u32>,
}
/// A PNG and what it shows.
#[derive(Debug, Clone, PartialEq)]
pub struct Picture {
pub png: Vec<u8>,
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<Value, String>;
/// Every command with its parameter docs (`engine.commands`).
fn commands(&mut self) -> Result<Value, String>;
/// A PNG of the model, or of the app's window when one is attached.
fn picture(&mut self, request: &PictureRequest) -> Result<Picture, String>;
/// 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<StandardView, String> {
match v {
"home" | "fit" => Ok(StandardView::Iso),
_ => StandardView::parse(v).ok_or_else(|| format!("unknown view `{}`", v.chars().take(40).collect::<String>())),
}
}
/// 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<Value, String> {
self.session.execute(command, &params).map_err(|e| e.to_string())
}
fn commands(&mut self) -> Result<Value, String> {
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<Picture, String> {
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>, TcpStream)>,
next_id: u64,
roots: Roots,
}
static SHOT: AtomicU64 = AtomicU64::new(0);
impl Remote {
pub fn connect(addr: &str) -> std::io::Result<Self> {
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<String> {
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<Value, String> {
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<Value, String> {
self.call("engine.execute", json!({"command": command, "params": params}))
}
fn commands(&mut self) -> Result<Value, String> {
self.call("engine.commands", json!({}))
}
fn picture(&mut self, r: &PictureRequest) -> Result<Picture, String> {
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)
}
}
+56
View File
@@ -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<String>,
/// 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<dyn Backend>, Option<String>), String> {
let headless = |roots: Roots| -> Box<dyn Backend> { 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;
+237
View File
@@ -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<dyn Backend>,
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<String>) -> 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<Option<Vec<u8>>> {
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<dyn Backend>) -> 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<String> {
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::<Value>(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<Value> = 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<Value> {
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, &params) {
Ok(r) => response(id, r),
Err((code, m)) => error(id, code, m),
})
}
fn request(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
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::<String>()))),
}
.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::<String>()))),
}
}
}
#[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());
}
}
+269
View File
@@ -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<Value> = 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);
}
}
+557
View File
@@ -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<T: Copy>(table: &'static [T], command: fn(T) -> &'static str) -> impl Iterator<Item = T> {
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<Value> {
let methods: Vec<&str> = available(MESHERS, |m| m.1).map(|m| m.0).collect();
let method_docs: Vec<String> = 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::<String>()));
};
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<String, Value>, 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<String, Value>, k: &str) -> Option<&'a str> {
a.get(k).and_then(Value::as_str)
}
/// An object argument (absent: empty).
fn object_arg(a: &Map<String, Value>, 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<String, Value>) -> Result<Value, String> {
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::<String>())),
}
}
/// `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<String, Value>) -> Result<Value, String> {
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<String, Value>) -> 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<String, Value>) -> 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<String, Value>) -> Result<(Vec<u8>, 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
}
+141
View File
@@ -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<Value> {
let r = self.request("tools/call", json!({"name": name, "arguments": arguments}));
let content = r["content"].as_array().unwrap().clone();
assert!(r["isError"] != json!(true), "{name}: {}", content[0]["text"]);
content
}
/// A tool call's JSON reply.
fn json(&mut self, name: &str, arguments: Value) -> Value {
let content = self.tool(name, arguments);
serde_json::from_str(content[0]["text"].as_str().unwrap()).unwrap()
}
}
fn base64_decode(s: &str) -> Vec<u8> {
let value = |c: u8| match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'+' => 62,
_ => 63,
};
let bytes: Vec<u8> = s.bytes().filter(|c| *c != b'=').collect();
let mut out = Vec::new();
for chunk in bytes.chunks(4) {
let n = chunk.iter().enumerate().fold(0u32, |n, (i, c)| n | (u32::from(value(*c)) << (18 - 6 * i)));
out.extend(n.to_be_bytes().into_iter().skip(1).take(chunk.len() - 1));
}
out
}
#[test]
fn a_corpus_part_is_meshed_loaded_solved_and_rendered_over_mcp() {
let dir: PathBuf = std::env::temp_dir().join(format!("modelcraft-mcp-workflow-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let dir = std::fs::canonicalize(&dir).unwrap();
let part = modelcraft_testkit::corpus::read_recipe(&modelcraft_testkit::corpus::corpus_dir().join("stepped-block")).unwrap();
let design = dir.join("steps.solidcraft");
std::fs::write(&design, modelcraft_testkit::corpus::design_bytes(&part).unwrap()).unwrap();
let roots = Roots::new(Some(&dir), Some(&dir)).unwrap();
let mut c = Client { server: Server::new(Box::new(Headless::new(roots))), next: 0 };
let init = c.request("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "test", "version": "1"}}));
assert_eq!(init["serverInfo"]["name"], "modelcraft");
assert!(c.server.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#).is_none());
// Open the design and look at its faces.
let opened = c.json("open", json!({"path": design}));
assert_eq!(opened["command"], "geometry.open");
let model = c.json("inspect_model", json!({}));
let body = &model["bodies"][0];
let faces: Vec<&str> = body["faces"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
// The union's x = 0 end (one face, from the first box) and the bottom step's x = 60 end.
assert!(faces.contains(&"F1:-x") && faces.contains(&"F1:+x"), "{faces:?}");
// Mesh: constrained Delaunay tets, every boundary face tagged with its B-rep face.
let meshed = c.json("mesh", json!({"method": "tet", "params": {"body": "Steps", "tolerance": 0.5}}));
let tets = meshed["model"]["elements"]["tet4"].as_u64().unwrap();
assert!(tets > 10, "{meshed}");
assert_eq!((meshed["quality"]["valid"].as_u64(), meshed["quality"]["invalid"]["count"].as_u64()), (Some(tets), Some(0)), "{}", meshed["quality"]);
// Steel in mm-t-s-N-MPa, clamped at x = 0, 1 kN down on the x = 60 end, one static step.
let script = json!([
{"command": "model.units", "params": {"system": "mm-t-s-N-MPa"}},
{"command": "model.set.create", "params": {"name": "all", "geometry": [{"body": "Steps"}]}},
{"command": "model.set.create", "params": {"name": "root", "geometry": [{"body": "Steps", "face": "F1:-x"}]}},
{"command": "model.set.create", "params": {"name": "tip", "geometry": [{"body": "Steps", "face": "F1:+x"}]}},
{"command": "model.material.create", "params": {"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}, "density": 7.85e-9}},
{"command": "model.section.create", "params": {"name": "solid", "set": "all", "material": "Steel", "kind": {"type": "solid"}}},
{"command": "model.boundary_condition.create", "params": {"name": "clamp", "type": "fixed", "target": {"set": "root"}}},
{"command": "model.load.create", "params": {"name": "push", "type": "force", "target": {"set": "tip"}, "vector": [0, 0, -1000]}},
{"command": "model.step.create", "params": {"name": "Load", "procedure": {"type": "static"}, "boundary_conditions": ["clamp"], "loads": ["push"]}},
]);
let batch = c.json("batch", json!({"commands": script}));
assert_eq!(batch["completed"], 9, "{batch}");
let check = c.json("check_model", json!({}));
assert_eq!(check["errors"], 0, "{check}");
// Solve, then read the largest displacement from the contour range and the reactions.
let solved = c.json("solve", json!({"analysis": "static", "step": "Load"}));
assert!(solved.is_object(), "{solved}");
let shown = c.json("show_results", json!({"step": "Load", "field": "displacement", "deformed": true}));
let max = &shown["contour"]["range"]["max"];
let largest = max["value"].as_f64().unwrap();
// Order of magnitude: a 60 mm steel cantilever, 40 × 10 at the tip end, loaded with 1 kN,
// bends a few micrometres (a coarse linear tet mesh is stiff, so a little less).
assert!(largest > 1e-4 && largest < 0.1, "{shown}");
let node = max["node"].as_u64().unwrap();
let probe = c.json("probe", json!({"step": "Load", "nodes": [node]}));
let u = probe["nodes"][0]["displacement"].as_array().unwrap();
let norm = u.iter().map(|v| v.as_f64().unwrap().powi(2)).sum::<f64>().sqrt();
assert!((norm - largest).abs() <= 1e-9 * largest, "{probe}");
assert!(u[2].as_f64().unwrap() < 0.0, "the tip goes down: {probe}");
let reactions = c.json("reactions", json!({"step": "Load"}));
let fz = reactions["total"]["force"][2].as_f64().unwrap();
assert!((fz - 1000.0).abs() < 1e-6, "the clamp carries the load: {reactions}");
// Look at it: a PNG image block, also saved inside the write root.
let picture = dir.join("load.png");
let content = c.tool("screenshot", json!({"view": "iso", "width": 480, "height": 320, "path": picture}));
assert_eq!(content[0]["type"], "image");
assert_eq!(content[0]["mimeType"], "image/png");
let png = base64_decode(content[0]["data"].as_str().unwrap());
assert!(png.starts_with(&[0x89, b'P', b'N', b'G']));
assert_eq!(std::fs::read(&picture).unwrap(), png);
let info: Value = serde_json::from_str(content[1]["text"].as_str().unwrap()).unwrap();
assert_eq!(info["results"]["step"], "Load", "{info}");
// Save the model and write a deck, both inside the root.
c.json("save", json!({"path": dir.join("steps.modelcraft")}));
c.json("write_deck", json!({"format": "abaqus", "path": dir.join("steps.inp")}));
assert!(dir.join("steps.modelcraft").is_file() && dir.join("steps.inp").is_file());
let _ = std::fs::remove_dir_all(&dir);
}
+9 -6
View File
@@ -224,15 +224,18 @@ pub fn parts() -> Result<Vec<Part>> {
Ok(parts)
}
/// Build a part: run its recipe (or generate the shape) and open the design.
pub fn load(part: &Part) -> Result<Design> {
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<Vec<u8>> {
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<Design> {
open_design(&design_bytes(part)?).map_err(|source| Error::Geometry { part: part.name.clone(), source })
}
#[cfg(test)]