forked from 1b-agent/fieldcraft-upstream
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:
29 files changed
+2405
-49
No files matched your search
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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, ¶ms).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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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, ¶ms) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
Reference in new issue
Block a user