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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

No files matched your search

+3 -2
View File
@@ -2,6 +2,7 @@
use std::path::Path;
use modelcraft_io::roots::Access;
use serde_json::{Value, json};
use super::req;
@@ -22,10 +23,10 @@ fn write(s: &mut Session, p: &Value) -> Result<Value> {
let known: Vec<&str> = modelcraft_decks::FORMATS.iter().map(|f| f.name).collect();
EngineError::Params(format!("unknown deck format `{shown}`; known: {}", known.join(", ")))
})?;
let path = super::path_param(p, "path")?;
let path = super::file_param(s, p, "path", Access::Write)?;
let steps: Option<Vec<String>> = super::opt(p, "steps")?;
let deck = format.write_steps(&s.model, steps.as_deref())?;
modelcraft_io::fs::write_atomic(Path::new(path), deck.text.as_bytes(), false)?;
modelcraft_io::fs::write_atomic(Path::new(&path), deck.text.as_bytes(), false)?;
Ok(json!({"format": format.name, "path": path, "bytes": deck.text.len(), "warnings": deck.warnings}))
}
+84 -22
View File
@@ -14,7 +14,9 @@ use modelcraft_geometry::{Design, MAX_DESIGN_BYTES, open_design};
use modelcraft_io::{Geometry, ModelFile, SourceFile};
use serde_json::{Value, json};
use super::{path_param, req};
use modelcraft_io::roots::{Access, Roots};
use super::{file_param, req};
use crate::{CommandKind, CommandSpec, EngineError, Result, Session, next_revision};
pub const OPEN: CommandSpec = CommandSpec {
@@ -110,18 +112,27 @@ fn resolved_path(path: &str, base: Option<&Path>) -> String {
}
/// Open one geometry file again: what it gives, and the source as now read (or as stored, with
/// its path resolved, when it could not be).
/// its path resolved, when it could not be). A file outside the read root of `roots` is not
/// opened.
fn reopen<T>(
what: &str,
stored: &SourceFile,
base: Option<&Path>,
roots: &Roots,
max: usize,
open: impl FnOnce(&[u8]) -> modelcraft_geometry::Result<T>,
warnings: &mut Vec<String>,
) -> (Option<T>, SourceFile) {
let path = resolved_path(stored.path(), base);
let kept = stored.with_path(path.clone()).unwrap_or_else(|_| stored.clone());
let bytes = match modelcraft_geometry::read_file(&path, max) {
let checked = match roots.check(Path::new(&path), Access::Read) {
Ok(p) => p.to_string_lossy().into_owned(),
Err(e) => {
warnings.push(format!("{what} `{path}` was not opened ({e}); the model opens without it"));
return (None, kept);
}
};
let bytes = match modelcraft_geometry::read_file(&checked, max) {
Ok(b) => b,
Err(e) => {
warnings.push(format!("{what} `{path}` was not opened ({e}); the model opens without it"));
@@ -155,19 +166,20 @@ impl Session {
}
/// A session holding what a file holds, its geometry opened again from the paths it names
/// (relative ones against `base`); problems with the geometry come back as warnings.
pub fn from_file(file: ModelFile, base: Option<&Path>) -> (Session, Vec<String>) {
/// (relative ones against `base`) where `roots` lets it read them; problems with the
/// geometry come back as warnings. The session's own file access is unconfined.
pub fn from_file(file: ModelFile, base: Option<&Path>, roots: &Roots) -> (Session, Vec<String>) {
let mut warnings = Vec::new();
let mut sources = Geometry::default();
let mut design: Option<Arc<Design>> = None;
if let Some(stored) = &file.geometry.design {
let (opened, source) = reopen("design", stored, base, MAX_DESIGN_BYTES, open_design, &mut warnings);
let (opened, source) = reopen("design", stored, base, roots, MAX_DESIGN_BYTES, open_design, &mut warnings);
design = opened.map(Arc::new);
sources.design = Some(source);
}
let mut surfaces = Vec::new();
for stored in &file.geometry.meshes {
let (opened, source) = reopen("mesh", stored, base, tagged_mesh::MAX_FILE_BYTES, tagged_mesh::read_3mf, &mut warnings);
let (opened, source) = reopen("mesh", stored, base, roots, tagged_mesh::MAX_FILE_BYTES, tagged_mesh::read_3mf, &mut warnings);
surfaces.extend(opened.into_iter().flatten().map(Arc::new));
sources.meshes.push(source);
}
@@ -176,11 +188,12 @@ impl Session {
(session, warnings)
}
/// Replace this session with `opened`, keeping its undo memory limit and its work caps; the
/// history starts empty.
/// Replace this session with `opened`, keeping its undo memory limit, its work caps and its
/// file access; the history starts empty.
fn replace_with(&mut self, mut opened: Session) {
opened.history = std::mem::take(&mut self.history);
opened.limits = self.limits;
opened.file_access = std::mem::take(&mut self.file_access);
opened.clear_history();
*self = opened;
}
@@ -202,7 +215,7 @@ fn summary(s: &Session, warnings: &[String]) -> Value {
/// paths are relative to its folder), `saved` whether that file holds this content.
fn load(s: &mut Session, bytes: &[u8], path: Option<String>, saved: bool) -> Result<Value> {
let file = modelcraft_io::read(bytes)?;
let (mut opened, warnings) = Session::from_file(file, path.as_deref().and_then(|p| Path::new(p).parent()));
let (mut opened, warnings) = Session::from_file(file, path.as_deref().and_then(|p| Path::new(p).parent()), &s.file_access);
opened.path = path;
if saved {
opened.saved_revision = Some(opened.revision);
@@ -212,7 +225,7 @@ fn load(s: &mut Session, bytes: &[u8], path: Option<String>, saved: bool) -> Res
}
fn open(s: &mut Session, p: &Value) -> Result<Value> {
let path = absolute(path_param(p, "path")?);
let path = absolute(&file_param(s, p, "path", Access::Read)?);
let max = usize::try_from(modelcraft_io::MAX_FILE_BYTES).unwrap_or(usize::MAX);
let bytes = modelcraft_geometry::read_file(&path, max)?;
load(s, &bytes, Some(path.clone()), true).map_err(|e| EngineError::Params(format!("{path}: {e}")))
@@ -232,41 +245,45 @@ fn save_to(s: &mut Session, path: &str) -> Result<Value> {
fn save(s: &mut Session, p: &Value) -> Result<Value> {
let path = match p.get("path") {
Some(_) => path_param(p, "path")?.to_string(),
None => s.path.clone().ok_or_else(|| EngineError::Params("the model has no file yet: give `path` (or use file.save_as)".into()))?,
Some(_) => file_param(s, p, "path", Access::Write)?,
None => {
let own = s.path.as_deref().ok_or_else(|| EngineError::Params("the model has no file yet: give `path` (or use file.save_as)".into()))?;
s.checked_path(own, Access::Write)?
}
};
save_to(s, &path)
}
fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
let path = path_param(p, "path")?.to_string();
let path = file_param(s, p, "path", Access::Write)?;
save_to(s, &path)
}
/// The recovery folder: `dir`, else the default one.
fn recovery_dir(p: &Value) -> Result<PathBuf> {
/// The recovery folder: `dir` (checked for `access`), else the default one (the app's own folder,
/// which no client names, so it is not confined).
fn recovery_dir(s: &Session, p: &Value, access: Access) -> Result<PathBuf> {
match p.get("dir") {
Some(_) => Ok(PathBuf::from(path_param(p, "dir")?)),
Some(_) => Ok(PathBuf::from(file_param(s, p, "dir", access)?)),
None => {
crate::autosave::default_dir().ok_or_else(|| EngineError::Params("no recovery folder: set MODELCRAFT_RECOVERY_DIR or give `dir`".into()))
}
}
}
fn recovery_list(_: &mut Session, p: &Value) -> Result<Value> {
let entries = modelcraft_io::recovery::orphans(&recovery_dir(p)?);
fn recovery_list(s: &mut Session, p: &Value) -> Result<Value> {
let entries = modelcraft_io::recovery::orphans(&recovery_dir(s, p, Access::Read)?);
serde_json::to_value(entries).map_err(|e| EngineError::Params(e.to_string()))
}
fn recover(s: &mut Session, p: &Value) -> Result<Value> {
let dir = recovery_dir(p)?;
let dir = recovery_dir(s, p, Access::Read)?;
let (bytes, entry) = modelcraft_io::recovery::load(&dir, req::<&str>(p, "id")?)?;
// Recovered work is unsaved until it is saved. Autosaves keep geometry paths absolute.
load(s, &bytes, entry.path, false)
}
fn recovery_discard(_: &mut Session, p: &Value) -> Result<Value> {
let dir = recovery_dir(p)?;
fn recovery_discard(s: &mut Session, p: &Value) -> Result<Value> {
let dir = recovery_dir(s, p, Access::Write)?;
if p.get("all").and_then(Value::as_bool) == Some(true) {
let ids: Vec<String> = modelcraft_io::recovery::orphans(&dir).into_iter().map(|e| e.id).collect();
for id in &ids {
@@ -349,6 +366,51 @@ mod tests {
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_confined_session_reads_and_writes_only_inside_its_roots() {
let d = dir("roots");
let (input, output, outside) = (d.join("in"), d.join("out"), d.join("outside"));
for f in [&input, &output, &outside] {
std::fs::create_dir_all(f).unwrap();
}
let design = plate_design(&Plate::default()).unwrap();
std::fs::write(input.join("plate.solidcraft"), &design).unwrap();
std::fs::write(outside.join("plate.solidcraft"), &design).unwrap();
let mut s = Session { file_access: Roots::new(Some(&input), Some(&output)).unwrap(), ..Session::default() };
let refused = |r: Result<Value>| {
let e = r.unwrap_err().to_string();
assert!(e.starts_with("automation"), "{e}");
};
refused(s.execute("geometry.open", &json!({"path": outside.join("plate.solidcraft")})));
refused(s.execute("geometry.import.mesh", &json!({"path": outside.join("x.3mf")})));
refused(s.execute("file.open", &json!({"path": outside.join("x.modelcraft")})));
refused(s.execute("file.recovery.list", &json!({"dir": outside})));
refused(s.execute("file.recovery.discard", &json!({"all": true, "dir": input})));
s.execute("geometry.open", &json!({"path": input.join("plate.solidcraft")})).unwrap();
defined(&mut s);
// Writes go to the write root only: not the read root, not elsewhere.
refused(s.execute("file.save_as", &json!({"path": input.join("m.modelcraft")})));
refused(s.execute("file.save_as", &json!({"path": outside.join("m.modelcraft")})));
refused(s.execute("file.save_as", &json!({"path": output.join("../outside/m.modelcraft")})));
refused(s.execute("deck.write", &json!({"format": "abaqus", "path": outside.join("m.inp")})));
let saved = output.join("m.modelcraft");
s.execute("file.save_as", &json!({"path": saved})).unwrap();
assert!(saved.is_file());
assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "nothing written outside");
// The roots survive opening a file; a model whose design lies outside the read root
// opens without it.
let mut other = Session::default();
other.execute("geometry.open", &json!({"path": outside.join("plate.solidcraft")})).unwrap();
other.execute("file.save_as", &json!({"path": input.join("other.modelcraft")})).unwrap();
let r = s.execute("file.open", &json!({"path": input.join("other.modelcraft")})).unwrap();
assert!(r["warnings"][0].as_str().unwrap().contains("automation path rejected"), "{r}");
assert!(s.design.is_none());
assert!(s.file_access.is_confined());
refused(s.execute("file.save", &json!({})));
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn hostile_file_params_and_files_change_nothing() {
let d = dir("hostile");
+7 -6
View File
@@ -3,6 +3,7 @@ use std::sync::Arc;
use modelcraft_geometry::tagged_mesh;
use modelcraft_geometry::{MAX_DESIGN_BYTES, Shape, SurfaceMesh, open_design};
use modelcraft_io::SourceFile;
use modelcraft_io::roots::Access;
use serde_json::{Value, json};
use crate::{CommandKind, CommandSpec, EngineError, Result, Session};
@@ -40,9 +41,9 @@ pub const IMPORT_MESH: CommandSpec = CommandSpec {
};
fn open(s: &mut Session, p: &Value) -> Result<Value> {
let path = super::path_param(p, "path")?;
let bytes = modelcraft_geometry::read_file(path, MAX_DESIGN_BYTES)?;
let source = SourceFile::of(super::file::absolute(path), &bytes)?;
let path = super::file_param(s, p, "path", Access::Read)?;
let bytes = modelcraft_geometry::read_file(&path, MAX_DESIGN_BYTES)?;
let source = SourceFile::of(super::file::absolute(&path), &bytes)?;
let design = open_design(&bytes)?;
let out = json!({
"name": design.name,
@@ -96,12 +97,12 @@ fn tessellate(s: &mut Session, p: &Value) -> Result<Value> {
}
fn import_mesh(s: &mut Session, p: &Value) -> Result<Value> {
let path = super::path_param(p, "path")?;
let path = super::file_param(s, p, "path", Access::Read)?;
if s.sources.meshes.len() >= modelcraft_io::MAX_MESH_SOURCES {
return Err(EngineError::Params(format!("a model imports at most {} meshes", modelcraft_io::MAX_MESH_SOURCES)));
}
let bytes = modelcraft_geometry::read_file(path, tagged_mesh::MAX_FILE_BYTES)?;
let source = SourceFile::of(super::file::absolute(path), &bytes)?;
let bytes = modelcraft_geometry::read_file(&path, tagged_mesh::MAX_FILE_BYTES)?;
let source = SourceFile::of(super::file::absolute(&path), &bytes)?;
let surfaces = tagged_mesh::read_3mf(&bytes)?;
let first = s.surfaces.len();
let added: Vec<Value> = surfaces
+13 -3
View File
@@ -16,7 +16,9 @@ use modelcraft_model::MAX_LABEL;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{CommandSpec, EngineError, Result};
use modelcraft_io::roots::Access;
use crate::{CommandSpec, EngineError, Result, Session};
pub static ALL: &[CommandSpec] = &[
model::INFO,
@@ -158,14 +160,22 @@ pub(crate) fn labels(p: &Value, key: &str, max_len: usize) -> Result<Vec<u32>> {
.ok_or_else(|| EngineError::Params(format!("`{key}` must be labels in 1..={MAX_LABEL}")))
}
/// A required path parameter.
pub(crate) fn path_param<'a>(p: &'a Value, key: &str) -> Result<&'a str> {
/// A required path parameter, as given.
fn path_param<'a>(p: &'a Value, key: &str) -> Result<&'a str> {
p.get(key)
.and_then(Value::as_str)
.filter(|s| !s.trim().is_empty() && s.len() <= modelcraft_io::MAX_PATH_LEN && !s.contains('\0'))
.ok_or_else(|| EngineError::Params(format!("`{key}` must be a file path")))
}
/// A required path parameter naming a file (or folder) the command reads or writes: the path
/// to use, checked against the session's [`Session::file_access`] (as given while unconfined;
/// resolved, links followed, when confined). Every command that touches a file named by a
/// parameter takes its path from here.
pub(crate) fn file_param(s: &Session, p: &Value, key: &str, access: Access) -> Result<String> {
s.checked_path(path_param(p, key)?, access)
}
/// An optional number parameter.
pub(crate) fn optional_number(p: &Value, key: &str, default: f64) -> Result<f64> {
match p.get(key) {