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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
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

+267
View File
@@ -0,0 +1,267 @@
//! Where tool calls end up: an in-process headless session, or a running app's control channel
//! (docs/control-protocol.md: `engine.execute`, `engine.commands`, `ui.view`, `ui.render`,
//! `ui.capture`).
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use modelcraft_engine::Session;
use modelcraft_engine::view::{Palette, render_png};
use modelcraft_io::roots::Roots;
use modelcraft_render::StandardView;
use serde_json::{Value, json};
/// What a picture shows.
#[derive(Debug, Clone, PartialEq)]
pub struct PictureRequest {
/// A standard view to turn to first (`iso`, `front`…; `home` and `fit` frame the model).
pub view: Option<String>,
/// The whole app window (`true`) or the model only.
pub window: bool,
/// Image size in pixels (a window capture: points); `None` keeps the default.
pub width: Option<u32>,
pub height: Option<u32>,
}
/// A PNG and what it shows.
#[derive(Debug, Clone, PartialEq)]
pub struct Picture {
pub png: Vec<u8>,
pub info: Value,
}
/// Something that runs ModelCraft commands and draws pictures.
pub trait Backend {
/// Run a command (`engine.execute`).
fn execute(&mut self, command: &str, params: Value) -> Result<Value, String>;
/// Every command with its parameter docs (`engine.commands`).
fn commands(&mut self) -> Result<Value, String>;
/// A PNG of the model, or of the app's window when one is attached.
fn picture(&mut self, request: &PictureRequest) -> Result<Picture, String>;
/// The folders file access is confined to (checked by the server for the files it writes
/// itself).
fn roots(&self) -> &Roots;
/// For the client's instructions.
fn describe(&self) -> String;
}
/// Default size of a model picture.
pub(crate) const DEFAULT_SIZE: (u32, u32) = (960, 600);
/// Largest picture side the server asks for, in pixels.
pub(crate) const MAX_SIDE: u32 = 4096;
/// Largest image read back from the app.
const MAX_IMAGE_BYTES: u64 = 64 << 20;
/// Longest reply line accepted from the app.
const MAX_REPLY: u64 = 256 << 20;
fn standard_view(v: &str) -> Result<StandardView, String> {
match v {
"home" | "fit" => Ok(StandardView::Iso),
_ => StandardView::parse(v).ok_or_else(|| format!("unknown view `{}`", v.chars().take(40).collect::<String>())),
}
}
/// An in-process headless session; pictures come from the CPU rasterizer.
pub struct Headless {
pub session: Session,
/// The orientation of pictures; each one frames everything drawn.
pub view: StandardView,
roots: Roots,
}
impl Default for Headless {
fn default() -> Self {
Headless::new(Roots::default())
}
}
impl Headless {
/// An empty session whose commands read and write files only as `roots` allow.
pub fn new(roots: Roots) -> Self {
let mut session = Session::default();
session.file_access = roots.clone();
Headless { session, view: StandardView::Iso, roots }
}
}
impl Backend for Headless {
fn execute(&mut self, command: &str, params: Value) -> Result<Value, String> {
self.session.execute(command, &params).map_err(|e| e.to_string())
}
fn commands(&mut self) -> Result<Value, String> {
Ok(modelcraft_engine::commands().iter().map(|c| json!({"id": c.id, "label": c.label, "params": c.params})).collect())
}
fn picture(&mut self, r: &PictureRequest) -> Result<Picture, String> {
if let Some(v) = &r.view {
self.view = standard_view(v)?;
}
let w = r.width.unwrap_or(DEFAULT_SIZE.0).clamp(16, MAX_SIDE);
let h = r.height.unwrap_or(DEFAULT_SIZE.1).clamp(16, MAX_SIDE);
let (png, shown) = render_png(&self.session, self.view, w as usize, h as usize, &Palette::LIGHT).map_err(|e| e.to_string())?;
let contour = shown
.contour
.map(|(field, min, max)| json!({"field": field, "min": {"node": min.0, "value": min.1}, "max": {"node": max.0, "value": max.1}}));
let info = json!({
"source": "model",
"view": self.view,
"width": w,
"height": h,
"results": shown.step.map(|step| json!({"step": step, "scale": shown.scale, "contour": contour})),
"undrawn_bodies": shown.failed,
});
Ok(Picture { png, info })
}
fn roots(&self) -> &Roots {
&self.roots
}
fn describe(&self) -> String {
"a headless in-process ModelCraft session (no window; screenshots are CPU renders)".into()
}
}
/// A running ModelCraft app reached through its loopback control channel
/// (`modelcraft --control PORT`).
pub struct Remote {
addr: String,
conn: Option<(BufReader<TcpStream>, TcpStream)>,
next_id: u64,
roots: Roots,
}
static SHOT: AtomicU64 = AtomicU64::new(0);
impl Remote {
pub fn connect(addr: &str) -> std::io::Result<Self> {
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, roots: Roots::default() };
r.reconnect()?;
Ok(r)
}
fn reconnect(&mut self) -> std::io::Result<()> {
self.conn = None;
let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr));
for sa in self.addr.to_socket_addrs()? {
match TcpStream::connect_timeout(&sa, Duration::from_millis(800)) {
Ok(s) => {
s.set_nodelay(true).ok();
// Meshing and solving big models take a while; the app answers a request it
// has not finished within 60 s with a timeout of its own.
s.set_read_timeout(Some(Duration::from_secs(600))).ok();
let read = s.try_clone()?;
self.conn = Some((BufReader::new(read), s));
return Ok(());
}
Err(e) => last = e,
}
}
Err(last)
}
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
if self.conn.is_none() {
self.reconnect()?;
}
let Some((reader, writer)) = self.conn.as_mut() else { return Err(std::io::Error::other("not connected")) };
let sent = writer.write_all(line.as_bytes()).and_then(|()| writer.write_all(b"\n")).and_then(|()| writer.flush());
if let Err(e) = sent {
self.conn = None;
return Err(e);
}
let mut reply = String::new();
let n = match reader.by_ref().take(MAX_REPLY).read_line(&mut reply) {
Ok(n) => n,
Err(e) => {
// A timed-out read leaves a late reply on the stream: start afresh next time.
self.conn = None;
return Err(e);
}
};
if n == 0 || !reply.ends_with('\n') {
self.conn = None;
return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "connection closed (or reply too large)"));
}
Ok(reply)
}
/// One control-channel request.
pub fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
let id = self.next_id;
self.next_id += 1;
let line = json!({"id": id, "method": method, "params": params}).to_string();
let reply = match self.roundtrip(&line) {
Ok(r) => r,
// One retry on a fresh connection (the app may have restarted).
Err(_) => self.roundtrip(&line).map_err(|e| format!("ModelCraft at {}: {e}", self.addr))?,
};
let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply from the app: {e}"))?;
if v.get("ok").and_then(Value::as_bool) == Some(true) {
Ok(v.get("result").cloned().unwrap_or(Value::Null))
} else {
Err(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string())
}
}
}
/// A temporary PNG path the app writes and the server reads back (both on this computer).
fn temp_png() -> PathBuf {
let n = SHOT.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("modelcraft-mcp-{}-{n}.png", std::process::id()))
}
impl Backend for Remote {
fn execute(&mut self, command: &str, params: Value) -> Result<Value, String> {
self.call("engine.execute", json!({"command": command, "params": params}))
}
fn commands(&mut self) -> Result<Value, String> {
self.call("engine.commands", json!({}))
}
fn picture(&mut self, r: &PictureRequest) -> Result<Picture, String> {
if let Some(v) = &r.view {
standard_view(v)?;
self.call("ui.view", json!({"view": v}))?;
}
let path = temp_png();
let shown = path.to_string_lossy().into_owned();
let mut params = json!({"path": shown});
let method = if r.window {
for (k, v) in [("width", r.width), ("height", r.height)] {
if let Some(v) = v {
params[k] = json!(v.clamp(16, MAX_SIDE));
}
}
"ui.capture"
} else {
params["width"] = json!(r.width.unwrap_or(DEFAULT_SIZE.0).clamp(16, MAX_SIDE));
params["height"] = json!(r.height.unwrap_or(DEFAULT_SIZE.1).clamp(16, MAX_SIDE));
"ui.render"
};
let result = self.call(method, params);
let read = result.and_then(|info| {
let len = std::fs::metadata(&path).map_err(|e| format!("{shown}: {e} (is the app on another computer?)"))?.len();
if len > MAX_IMAGE_BYTES {
return Err(format!("{shown}: image too large ({len} bytes)"));
}
let png = std::fs::read(&path).map_err(|e| format!("{shown}: {e}"))?;
Ok(Picture { png, info: json!({"source": if r.window { "window" } else { "model" }, "app": info}) })
});
let _ = std::fs::remove_file(&path);
read
}
fn roots(&self) -> &Roots {
&self.roots
}
fn describe(&self) -> String {
format!("connected to the ModelCraft app at {} (the user watches its window)", self.addr)
}
}
+56
View File
@@ -0,0 +1,56 @@
//! ModelCraft's MCP server (docs/mcp.md).
//!
//! JSON-RPC 2.0 over stdio, one message per line, implementing the Model Context Protocol's
//! lifecycle, tools and resources. Every tool runs engine commands (the same ones the UI,
//! scripts, the CLI and the control channel run) and never opens a dialog. A tool call is served
//! by a [`Backend`]: an in-process headless session ([`Headless`], pictures drawn with the CPU
//! rasterizer) or a running app reached through its control channel ([`Remote`],
//! docs/control-protocol.md), so a person can watch the agent work.
//!
//! The server treats its input as hostile: a message line is capped at [`MAX_LINE`] bytes and
//! answered with an error past it, a JSON-RPC batch at [`MAX_BATCH_MESSAGES`] messages, a
//! `batch` tool call at [`tools::MAX_STEPS`] commands, and tool arguments are checked against
//! the tool's schema before anything runs. Read and write roots ([`modelcraft_io::roots`])
//! confine every file a tool touches.
//!
//! Adapted from SolidCraft's `crates/mcp` (MIT OR Apache-2.0), see ATTRIBUTION.md.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod backend;
mod server;
pub mod tools;
pub use backend::{Backend, Headless, Picture, PictureRequest, Remote};
pub use server::{MAX_BATCH_MESSAGES, MAX_LINE, PROTOCOL_VERSION, SUPPORTED_VERSIONS, Server};
use modelcraft_io::roots::Roots;
/// How the server is started (`modelcraft-cli mcp`).
#[derive(Debug, Clone, Default)]
pub struct Options {
/// The control channel of a running app (`127.0.0.1:7070`); `None` runs headless.
pub connect: Option<String>,
/// Folders file access is confined to; unconfined by default.
pub roots: Roots,
}
/// The backend `options` ask for, and a note for stderr when the server falls back to headless
/// because the app cannot be reached. Confined roots with `connect` are refused: the app would
/// read and write files the roots do not cover.
pub fn backend(options: Options) -> Result<(Box<dyn Backend>, Option<String>), String> {
let headless = |roots: Roots| -> Box<dyn Backend> { Box::new(Headless::new(roots)) };
match options.connect {
Some(addr) if options.roots.is_confined() => Err(format!(
"--connect {addr} cannot be combined with --automation-read-root or --automation-write-root: the app is not confined by them (they imply --headless)"
)),
Some(addr) => match Remote::connect(&addr) {
Ok(r) => Ok((Box::new(r), None)),
Err(e) => Ok((headless(options.roots), Some(format!("ModelCraft at {addr} cannot be reached ({e}); running headless")))),
},
None => Ok((headless(options.roots), None)),
}
}
#[cfg(test)]
mod tests;
+237
View File
@@ -0,0 +1,237 @@
//! JSON-RPC 2.0 framing and the MCP lifecycle, tools and resources methods.
use std::io::{BufRead, Write};
use serde_json::{Value, json};
use crate::backend::Backend;
use crate::tools::{call_tool, check_arguments, tool_definitions};
/// The newest protocol revision spoken; offered when the client asks for one not known.
pub const PROTOCOL_VERSION: &str = "2025-06-18";
pub const SUPPORTED_VERSIONS: &[&str] = &[PROTOCOL_VERSION, "2025-03-26", "2024-11-05"];
/// Longest message line accepted (the control channel's limit too).
pub const MAX_LINE: usize = 16 << 20;
/// Most messages in one JSON-RPC batch (protocol revisions before 2025-06-18).
pub const MAX_BATCH_MESSAGES: usize = 100;
const PARSE_ERROR: i64 = -32700;
const INVALID_REQUEST: i64 = -32600;
const METHOD_NOT_FOUND: i64 = -32601;
const INVALID_PARAMS: i64 = -32602;
const INTERNAL_ERROR: i64 = -32603;
const RESOURCE_NOT_FOUND: i64 = -32002;
const INSTRUCTIONS: &str = "ModelCraft turns geometry into finite element models and solves them. The model is \
unit-consistent and never converts (SolidCraft geometry is in mm, so use mm-t-s-N-MPa: E in MPa, density in t/mm³, \
forces in N); Z is up. A typical analysis: `open` a .solidcraft design (or a .modelcraft model, or a face-tagged \
.3mf); `inspect_model` for its bodies and their persistent face names; `mesh` (method tet: {body, tolerance}); \
then `execute` the model definition commands (model.set.create {name, geometry: [{body, face}]}, \
model.material.create, model.section.create, model.boundary_condition.create, model.load.create, model.step.create \
— list_commands shows each one's parameters); `check_model`; `solve` (static, frequency, explicit, fluid); \
`show_results` (field: displacement, von_mises…; its reply has the field's range) and `probe` / `reactions` for \
numbers; `screenshot` to look; `save` or `write_deck` (abaqus, nastran, lsdyna). Failures leave the model unchanged \
and name what to fix.";
pub struct Server {
backend: Box<dyn Backend>,
initialized: bool,
}
fn response(id: Value, result: Value) -> Value {
json!({"jsonrpc": "2.0", "id": id, "result": result})
}
fn error(id: Value, code: i64, message: impl Into<String>) -> Value {
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}})
}
/// One line of at most `max` bytes (without the newline). A longer line is read to its end and
/// dropped, so the stream stays in step: `Err(InvalidData)` reports it.
fn read_line(r: &mut impl BufRead, max: usize) -> std::io::Result<Option<Vec<u8>>> {
let mut line = Vec::new();
let mut too_long = false;
loop {
let (done, used) = {
let available = r.fill_buf()?;
if available.is_empty() {
if too_long {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "request too large"));
}
return Ok((!line.is_empty()).then_some(line));
}
let (part, done, used) = match available.iter().position(|b| *b == b'\n') {
Some(i) => (available.get(..i).unwrap_or(&[]), true, i + 1),
None => (available, false, available.len()),
};
if !too_long {
line.extend_from_slice(part);
if line.len() > max {
too_long = true;
line = Vec::new();
}
}
(done, used)
};
r.consume(used);
if done {
if too_long {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "request too large"));
}
return Ok(Some(line));
}
}
}
impl Server {
pub fn new(backend: Box<dyn Backend>) -> Self {
Self { backend, initialized: false }
}
pub fn backend(&mut self) -> &mut dyn Backend {
self.backend.as_mut()
}
/// Whether the client sent `notifications/initialized`.
pub fn is_initialized(&self) -> bool {
self.initialized
}
/// Serve newline-delimited JSON-RPC until `input` closes. Only protocol messages go to
/// `output`; diagnostics belong on stderr.
pub fn serve(&mut self, mut input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
loop {
let reply = match read_line(&mut input, MAX_LINE) {
Ok(None) => return Ok(()),
Ok(Some(bytes)) => match String::from_utf8(bytes) {
Ok(line) => self.handle_line(&line),
Err(_) => Some(error(Value::Null, PARSE_ERROR, "parse error: input is not UTF-8").to_string()),
},
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
Some(error(Value::Null, INVALID_REQUEST, format!("request too large (at most {MAX_LINE} bytes a line)")).to_string())
}
Err(e) => return Err(e),
};
if let Some(reply) = reply {
output.write_all(reply.as_bytes())?;
output.write_all(b"\n")?;
output.flush()?;
}
}
}
/// One line in, at most one line out (notifications get no reply).
pub fn handle_line(&mut self, line: &str) -> Option<String> {
let line = line.trim();
if line.is_empty() {
return None;
}
if line.len() > MAX_LINE {
return Some(error(Value::Null, INVALID_REQUEST, "request too large").to_string());
}
let reply = match serde_json::from_str::<Value>(line) {
Ok(Value::Array(batch)) if batch.is_empty() => Some(error(Value::Null, INVALID_REQUEST, "empty batch")),
Ok(Value::Array(batch)) if batch.len() > MAX_BATCH_MESSAGES => {
Some(error(Value::Null, INVALID_REQUEST, format!("batch too large (at most {MAX_BATCH_MESSAGES} messages)")))
}
Ok(Value::Array(batch)) => {
let replies: Vec<Value> = batch.into_iter().filter_map(|m| self.handle(m)).collect();
(!replies.is_empty()).then_some(Value::Array(replies))
}
Ok(msg) => self.handle(msg),
Err(e) => Some(error(Value::Null, PARSE_ERROR, format!("parse error: {e}"))),
};
reply.map(|r| r.to_string())
}
/// One message in, its reply out (none for notifications and responses).
pub fn handle(&mut self, msg: Value) -> Option<Value> {
let Value::Object(o) = &msg else { return Some(error(Value::Null, INVALID_REQUEST, "a message must be an object")) };
let id = o.get("id").cloned();
if let Some(id) = &id
&& !(id.is_string() || id.is_i64() || id.is_u64())
{
return Some(error(Value::Null, INVALID_REQUEST, "`id` must be a string or an integer"));
}
let Some(method) = o.get("method").and_then(Value::as_str) else {
// A response to a request of ours (none are sent) or junk.
if o.contains_key("result") || o.contains_key("error") {
return None;
}
return Some(error(id.unwrap_or(Value::Null), INVALID_REQUEST, "missing `method`"));
};
let params = o.get("params").cloned().unwrap_or(Value::Null);
let Some(id) = id else {
if method == "notifications/initialized" {
self.initialized = true;
}
return None;
};
Some(match self.request(method, &params) {
Ok(r) => response(id, r),
Err((code, m)) => error(id, code, m),
})
}
fn request(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
match method {
"initialize" => {
let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL_VERSION);
let version = if SUPPORTED_VERSIONS.contains(&asked) { asked } else { PROTOCOL_VERSION };
Ok(json!({
"protocolVersion": version,
"capabilities": {"tools": {"listChanged": false}, "resources": {"listChanged": false}},
"serverInfo": {"name": "modelcraft", "title": "ModelCraft", "version": env!("CARGO_PKG_VERSION")},
"instructions": format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe()),
}))
}
"ping" => Ok(json!({})),
"tools/list" => Ok(json!({"tools": tool_definitions()})),
"tools/call" => {
let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?;
let args = match params.get("arguments") {
None | Some(Value::Null) => json!({}),
Some(a) => a.clone(),
};
check_arguments(name, &args).map_err(|e| (INVALID_PARAMS, e))?;
Ok(call_tool(self.backend.as_mut(), name, &args))
}
"resources/list" => Ok(json!({"resources": [
{"uri": "modelcraft://model", "name": "Model", "description": "model.info: node and element counts, units, definitions", "mimeType": "application/json"},
{"uri": "modelcraft://commands", "name": "Command catalog", "description": "every command with its parameter docs", "mimeType": "application/json"},
]})),
"resources/templates/list" => Ok(json!({"resourceTemplates": []})),
"prompts/list" => Ok(json!({"prompts": []})),
"resources/read" => {
let uri = params.get("uri").and_then(Value::as_str).unwrap_or("");
let v = match uri {
"modelcraft://model" => self.backend.execute("model.info", json!({})),
"modelcraft://commands" => self.backend.commands(),
_ => return Err((RESOURCE_NOT_FOUND, format!("unknown resource `{}`", uri.chars().take(200).collect::<String>()))),
}
.map_err(|e| (INTERNAL_ERROR, e))?;
Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": v.to_string()}]}))
}
_ => Err((METHOD_NOT_FOUND, format!("method `{}` not found", method.chars().take(200).collect::<String>()))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lines_are_capped_and_the_stream_stays_in_step() {
let mut input = Vec::new();
input.extend(vec![b'x'; 100]);
input.extend(b"\nshort\n");
input.extend(vec![b'y'; 30]);
let mut r = std::io::BufReader::with_capacity(8, input.as_slice());
assert_eq!(read_line(&mut r, 10).unwrap_err().kind(), std::io::ErrorKind::InvalidData);
assert_eq!(read_line(&mut r, 10).unwrap().unwrap(), b"short");
assert!(read_line(&mut r, 10).is_err(), "a last line without newline that is too long");
assert!(read_line(&mut r, 10).unwrap().is_none());
}
}
+269
View File
@@ -0,0 +1,269 @@
//! The protocol and the tools driven through JSON-RPC lines, as a client sends them, with
//! hostile input: malformed messages, huge parameters, paths outside the roots, unknown tools.
use std::path::{Path, PathBuf};
use modelcraft_io::roots::Roots;
use serde_json::{Value, json};
use crate::{Headless, MAX_LINE, Options, Server};
fn server() -> Server {
Server::new(Box::new(Headless::default()))
}
fn confined(read: &Path, write: &Path) -> Server {
Server::new(Box::new(Headless::new(Roots::new(Some(read), Some(write)).unwrap())))
}
fn send(s: &mut Server, msg: Value) -> Value {
serde_json::from_str(&s.handle_line(&msg.to_string()).unwrap()).unwrap()
}
/// A tool call: (text of the first content block, is_error).
fn call(s: &mut Server, name: &str, args: Value) -> (String, bool) {
let r = send(s, json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": args}}));
let result = &r["result"];
assert!(result.is_object(), "{name}: {r}");
let text = result["content"].as_array().unwrap().iter().find(|c| c["type"] == "text").map(|c| c["text"].as_str().unwrap().to_string());
(text.unwrap_or_default(), result["isError"] == json!(true))
}
fn ok(s: &mut Server, name: &str, args: Value) -> Value {
let (text, err) = call(s, name, args.clone());
assert!(!err, "{name} {args}: {text}");
serde_json::from_str(&text).unwrap_or(Value::String(text))
}
struct Dir(PathBuf);
impl Dir {
fn new(tag: &str) -> Dir {
let d = std::env::temp_dir().join(format!("modelcraft-mcp-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
Dir(std::fs::canonicalize(&d).unwrap())
}
}
impl Drop for Dir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn initialize_lists_tools_and_resources() {
let mut s = server();
let r = send(&mut s, json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-03-26", "capabilities": {}}}));
assert_eq!(r["result"]["protocolVersion"], "2025-03-26");
assert_eq!(r["result"]["serverInfo"]["name"], "modelcraft");
assert!(r["result"]["instructions"].as_str().unwrap().contains("headless"));
let r = send(&mut s, json!({"jsonrpc": "2.0", "id": 2, "method": "initialize", "params": {"protocolVersion": "1999-01-01"}}));
assert_eq!(r["result"]["protocolVersion"], crate::PROTOCOL_VERSION);
assert!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#).is_none());
assert!(s.is_initialized());
let tools = send(&mut s, json!({"jsonrpc": "2.0", "id": 3, "method": "tools/list"}));
let names: Vec<&str> = tools["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect();
for t in [
"list_commands",
"execute",
"batch",
"inspect_model",
"check_model",
"open",
"save",
"write_deck",
"mesh",
"solve",
"show_results",
"probe",
"reactions",
"screenshot",
"undo",
"redo",
] {
assert!(names.contains(&t), "{t}");
}
let r = send(&mut s, json!({"jsonrpc": "2.0", "id": 4, "method": "resources/read", "params": {"uri": "modelcraft://model"}}));
assert!(r["result"]["contents"][0]["text"].as_str().unwrap().contains("nodes"));
let r = send(&mut s, json!({"jsonrpc": "2.0", "id": 5, "method": "resources/read", "params": {"uri": "modelcraft://commands"}}));
assert!(r["result"]["contents"][0]["text"].as_str().unwrap().contains("solve.static"));
assert_eq!(send(&mut s, json!({"jsonrpc": "2.0", "id": 6, "method": "ping"}))["result"], json!({}));
}
#[test]
fn malformed_messages_get_json_rpc_errors() {
let mut s = server();
let code = |s: &mut Server, line: &str| -> i64 {
let r: Value = serde_json::from_str(&s.handle_line(line).unwrap()).unwrap();
r["error"]["code"].as_i64().unwrap()
};
assert_eq!(code(&mut s, "{not json"), -32700);
assert_eq!(code(&mut s, "\"just a string\""), -32600);
assert_eq!(code(&mut s, "42"), -32600);
assert_eq!(code(&mut s, "[]"), -32600);
assert_eq!(code(&mut s, &format!("[{}]", vec!["{}"; 1000].join(","))), -32600);
assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":1}"#), -32600);
assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":{"a":1},"method":"ping"}"#), -32600);
assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"nope"}"#), -32601);
assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{}}"#), -32602);
assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"rm_rf"}}"#), -32602);
assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute","arguments":[]}}"#), -32602);
assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute","arguments":{}}}"#), -32602);
assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute","arguments":{"command":7}}}"#), -32602);
assert_eq!(
code(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute","arguments":{"command":"x","extra":1}}}"#),
-32602
);
assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"mesh","arguments":{"method":"voodoo"}}}"#), -32602);
assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"resources/read","params":{"uri":"file:///etc/passwd"}}"#), -32002);
// Deeply nested JSON is refused by the parser, not by a stack overflow.
assert_eq!(code(&mut s, &format!("{}{}", "[".repeat(100_000), "]".repeat(100_000))), -32700);
// Notifications and responses get no reply; a batch replies to its requests only.
assert!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}"#).is_none());
assert!(s.handle_line(r#"{"jsonrpc":"2.0","id":9,"result":{}}"#).is_none());
let r: Value =
serde_json::from_str(&s.handle_line(r#"[{"jsonrpc":"2.0","id":1,"method":"ping"},{"jsonrpc":"2.0","method":"x"}]"#).unwrap()).unwrap();
assert_eq!(r.as_array().unwrap().len(), 1);
}
#[test]
fn serve_survives_huge_lines_and_bad_bytes() {
let mut s = server();
let mut input = Vec::new();
input.extend(br#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#);
input.push(b'\n');
input.extend(br#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"execute","arguments":{"command":"model.info","params":{"x":""#);
input.extend(vec![b'a'; MAX_LINE + 10]);
input.extend(b"\"}}}}\n");
input.extend([0xff, 0xfe, b'\n']);
input.extend(br#"{"jsonrpc":"2.0","id":3,"method":"ping"}"#);
input.push(b'\n');
let mut out = Vec::new();
s.serve(input.as_slice(), &mut out).unwrap();
let replies: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(replies.len(), 4, "{replies:?}");
assert_eq!(replies[0]["id"], 1);
assert!(replies[1]["error"]["message"].as_str().unwrap().contains("too large"));
assert_eq!(replies[2]["error"]["code"], -32700);
assert_eq!(replies[3]["id"], 3, "the server keeps serving");
}
#[test]
fn hostile_tool_arguments_are_tool_errors() {
let mut s = server();
let huge = "x".repeat(1 << 20);
for (tool, args) in [
("execute", json!({"command": "nope.nothing"})),
("execute", json!({"command": huge})),
("execute", json!({"command": "mesh.node.add", "params": {"label": -1, "xyz": [1e308, null, "a"]}})),
("execute", json!({"command": "mesh.element.add", "params": {"label": 1, "kind": "hex20", "nodes": vec![u32::MAX; 20]}})),
("execute", json!({"command": "file.open", "params": {"path": "\u{0}"}})),
("open", json!({"path": "/etc/passwd"})),
("open", json!({"path": "/no/such/model.modelcraft"})),
("open", json!({"path": huge.clone() + ".solidcraft"})),
("save", json!({})),
("write_deck", json!({"format": "abaqus", "path": "/no/such/dir/x.inp"})),
("mesh", json!({"method": "tet", "params": {"body": "Nope", "tolerance": 0.5}})),
("mesh", json!({"method": "tet", "params": {"surface": 1e300}})),
("solve", json!({"analysis": "static"})),
("solve", json!({"analysis": "frequency", "step": "Nope", "options": {"max_dofs": -1}})),
("show_results", json!({})),
("show_results", json!({"bands": 5})),
("show_results", json!({"field": "von_mises"})),
("probe", json!({"nodes": [1, 2, 3]})),
("reactions", json!({"sets": ["nope"], "about": [1e308, 0, 0]})),
("screenshot", json!({"view": "iso", "path": "/no/such/dir/x.png"})),
("batch", json!({"commands": [{"command": "model.info"}, {"params": {}}, {"command": "model.info"}]})),
("batch", json!({"commands": vec![json!({"command": "model.info"}); crate::tools::MAX_STEPS + 1]})),
("undo", json!({})),
("redo", json!({})),
] {
let (text, err) = call(&mut s, tool, args.clone());
assert!(err, "{tool} should fail: {text}");
assert!(text.len() < 100_000, "{tool}: errors stay short");
}
// Nothing changed on the way.
assert_eq!(ok(&mut s, "inspect_model", json!({}))["model"]["nodes"], 0);
// A huge but valid image size is clamped, not refused or allocated.
let r = send(
&mut s,
json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "screenshot", "arguments": {"width": u64::MAX, "height": 16, "source": "model"}}}),
);
assert_eq!(r["result"]["content"][0]["type"], "image", "{r}");
}
#[test]
fn a_batch_stops_at_the_first_error() {
let mut s = server();
let r = call(
&mut s,
"batch",
json!({"commands": [
{"command": "mesh.node.add", "params": {"label": 1, "xyz": [0, 0, 0]}},
{"command": "mesh.node.add", "params": {"label": 0, "xyz": [0, 0, 0]}},
{"command": "mesh.node.add", "params": {"label": 2, "xyz": [0, 0, 0]}},
]}),
);
assert!(r.1);
let v: Value = serde_json::from_str(&r.0).unwrap();
assert_eq!((v["completed"].clone(), v["failed"]["index"].clone()), (json!(1), json!(1)));
assert_eq!(ok(&mut s, "inspect_model", json!({}))["model"]["nodes"], 1);
ok(&mut s, "undo", json!({}));
assert_eq!(ok(&mut s, "inspect_model", json!({}))["model"]["nodes"], 0);
let listed = ok(&mut s, "list_commands", json!({"filter": "SOLVE."}));
assert!(listed.as_array().unwrap().iter().all(|c| c["id"].as_str().unwrap().starts_with("solve.")));
}
#[test]
fn file_access_stays_inside_the_roots() {
let d = Dir::new("roots");
let (input, output, outside) = (d.0.join("in"), d.0.join("out"), d.0.join("outside"));
for f in [&input, &output, &outside] {
std::fs::create_dir_all(f).unwrap();
}
let design = modelcraft_testkit::corpus::Analytic::Cylinder { radius: 5.0, height: 10.0 }.design().unwrap();
std::fs::write(outside.join("c.solidcraft"), &design).unwrap();
std::fs::write(input.join("c.solidcraft"), &design).unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink(outside.join("c.solidcraft"), input.join("link.solidcraft")).unwrap();
let mut s = confined(&input, &output);
let refused = |s: &mut Server, tool: &str, args: Value| {
let (text, err) = call(s, tool, args.clone());
assert!(err && text.contains("automation"), "{tool} {args}: {text}");
};
refused(&mut s, "open", json!({"path": outside.join("c.solidcraft")}));
#[cfg(unix)]
refused(&mut s, "open", json!({"path": input.join("link.solidcraft")}));
refused(&mut s, "execute", json!({"command": "geometry.open", "params": {"path": input.join("../outside/c.solidcraft")}}));
ok(&mut s, "open", json!({"path": input.join("c.solidcraft")}));
refused(&mut s, "save", json!({"path": outside.join("m.modelcraft")}));
refused(&mut s, "save", json!({"path": input.join("m.modelcraft")}));
refused(&mut s, "execute", json!({"command": "file.save_as", "params": {"path": outside.join("m.modelcraft")}}));
refused(&mut s, "write_deck", json!({"format": "abaqus", "path": outside.join("m.inp")}));
refused(&mut s, "screenshot", json!({"path": outside.join("x.png")}));
refused(&mut s, "screenshot", json!({"path": input.join("x.png")}));
refused(&mut s, "execute", json!({"command": "file.recovery.list", "params": {"dir": outside}}));
ok(&mut s, "save", json!({"path": output.join("m.modelcraft")}));
ok(&mut s, "screenshot", json!({"path": output.join("x.png")}));
assert!(output.join("m.modelcraft").is_file() && output.join("x.png").is_file());
assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "nothing written outside");
}
#[test]
fn roots_imply_headless() {
let d = Dir::new("connect");
let options = Options { connect: Some("127.0.0.1:1".into()), roots: Roots::new(Some(&d.0), None).unwrap() };
assert!(crate::backend(options).err().unwrap().contains("cannot be combined"));
// An app that cannot be reached: headless, with a note.
let (b, note) = crate::backend(Options { connect: Some("127.0.0.1:1".into()), roots: Roots::default() }).unwrap();
assert!(note.unwrap().contains("running headless"));
assert!(b.describe().contains("headless"));
}
#[test]
fn base64_matches_the_rfc_vectors() {
use crate::tools::base64;
for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")] {
assert_eq!(base64(i.as_bytes()), o);
}
}
+557
View File
@@ -0,0 +1,557 @@
//! The tools: their definitions (JSON schemas) and what they run. Every tool is a thin wrapper
//! over engine commands; the same list serves the headless and the connected backend.
use std::path::Path;
use modelcraft_io::roots::Access;
use serde_json::{Map, Value, json};
use crate::backend::{Backend, PictureRequest};
/// Most commands in one `batch` call.
pub const MAX_STEPS: usize = 10_000;
/// The meshers the `mesh` tool runs, by method; a method whose command this build lacks is
/// left out.
const MESHERS: &[(&str, &str, &str)] = &[
("tet", "mesh.tet.generate", "constrained Delaunay tets of a body {body, tolerance} or an imported surface {surface}"),
("hex", "mesh.hex.generate", "all-hex mesh of a body"),
("hex_sweep", "mesh.hex.sweep", "swept all-hex mesh"),
("hex_mapped", "mesh.hex.mapped", "mapped all-hex mesh"),
("block", "mesh.block", "structured hex block"),
("planar", "mesh.planar", "2D elements of a polygon or a planar face"),
];
/// The analyses the `solve` tool runs; one whose command this build lacks is left out.
const ANALYSES: &[(&str, &str)] =
&[("static", "solve.static"), ("frequency", "solve.frequency"), ("explicit", "solve.explicit"), ("fluid", "solve.fluid"), ("heat", "solve.heat")];
const VIEWS: &[&str] = &["iso", "home", "fit", "front", "back", "top", "bottom", "left", "right"];
fn available<T: Copy>(table: &'static [T], command: fn(T) -> &'static str) -> impl Iterator<Item = T> {
table.iter().copied().filter(move |t| modelcraft_engine::find_command(command(*t)).is_some())
}
fn obj(props: Value, required: &[&str]) -> Value {
let mut o = json!({"type": "object", "properties": props, "additionalProperties": false});
if !required.is_empty() {
o["required"] = json!(required);
}
o
}
fn tool(name: &str, title: &str, desc: &str, schema: Value, read_only: bool, destructive: bool) -> Value {
json!({
"name": name,
"title": title,
"description": desc,
"inputSchema": schema,
"annotations": {"title": title, "readOnlyHint": read_only, "destructiveHint": destructive, "idempotentHint": read_only, "openWorldHint": false},
})
}
fn string(desc: &str) -> Value {
json!({"type": "string", "description": desc})
}
fn integer(desc: &str) -> Value {
json!({"type": "integer", "description": desc})
}
/// Every tool, with its JSON schema.
pub fn tool_definitions() -> Vec<Value> {
let methods: Vec<&str> = available(MESHERS, |m| m.1).map(|m| m.0).collect();
let method_docs: Vec<String> = available(MESHERS, |m| m.1).map(|(m, c, d)| format!("{m} ({c}: {d})")).collect();
let analyses: Vec<&str> = available(ANALYSES, |a| a.1).map(|a| a.0).collect();
vec![
tool(
"list_commands",
"List commands",
"Every command with its label and parameter documentation. Ids are dotted and lower-case (geometry.open, \
mesh.tet.generate, model.material.create, model.step.create, solve.static, results.contour). Optional \
case-insensitive substring `filter` over id and label.",
obj(json!({"filter": string("Substring to match, e.g. \"model.load\", \"mesh\", \"results\"")}), &[]),
true,
false,
),
tool(
"execute",
"Execute a command",
"Run one command with JSON parameters, exactly as the UI, scripts and the CLI do; it never opens a dialog. \
Examples: model.units {system: \"mm-t-s-N-MPa\"}; model.set.create {name: \"root\", geometry: [{body: \"Body1\", \
face: \"F1:start\"}]}; model.material.create {name: \"Steel\", elastic: {type: \"isotropic\", young: 210000, \
poisson: 0.3}, density: 7.85e-9}; model.section.create {name: \"solid\", set: \"all\", material: \"Steel\", \
kind: {type: \"solid\"}}; model.boundary_condition.create {name: \"fix\", type: \"fixed\", target: {set: \
\"root\"}}; model.load.create {name: \"push\", type: \"force\", target: {set: \"tip\"}, vector: [0, 0, -100]}; \
model.step.create {name: \"Load\", procedure: {type: \"static\"}, boundary_conditions: [\"fix\"], loads: \
[\"push\"]}. Returns the command's result; a failure leaves the model unchanged and says why.",
obj(
json!({
"command": string("Command id (see list_commands)"),
"params": {"type": "object", "description": "The command's parameters"},
}),
&["command"],
),
false,
false,
),
tool(
"batch",
"Run several commands",
"Run commands in order, stopping at the first error. Each step is {command, params}. The reply has every \
result and, on an error, the failing step (index from 0) and its error; the steps before it stay applied \
(`undo` takes them back one by one).",
obj(
json!({
"commands": {
"type": "array",
"description": "Steps: [{\"command\": id, \"params\": {...}}, ...]",
"items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}, "required": ["command"]},
},
}),
&["commands"],
),
false,
false,
),
tool(
"inspect_model",
"Inspect the model",
"The model: node and element counts, the unit system and the definitions by kind (model.info); the step \
table (which boundary conditions and loads each step has, model.activity); the open design's bodies with \
their measures and persistent face names (geometry.bodies), which sets name as {body, face}.",
obj(json!({}), &[]),
true,
false,
),
tool(
"check_model",
"Check the model",
"The model check (model.check): every error and warning, each naming its item (a set member not on the \
mesh, an element with no section, a missing density for a frequency step, implausible units…). A model \
with errors does not solve or write.",
obj(json!({}), &[]),
true,
false,
),
tool(
"open",
"Open a file",
"Open by extension: a .modelcraft model (file.open: replaces the model; its design and meshes open again), a \
SolidCraft design .solidcraft (geometry.open: its bodies become the geometry) or a face-tagged .3mf mesh \
(geometry.import.mesh: added as a surface).",
obj(json!({"path": string("Path to a .modelcraft, .solidcraft or .3mf file")}), &["path"]),
false,
true,
),
tool(
"save",
"Save the model",
"Save the model as a .modelcraft file (file.save): to `path`, or to the file it came from.",
obj(json!({"path": string("Path to save to (default: the model's file)")}), &[]),
false,
false,
),
tool(
"write_deck",
"Write a solver deck",
"Write the model as a solver input deck (deck.write): Abaqus input (abaqus), Nastran bulk data (nastran) or \
an LS-DYNA keyword deck (lsdyna). Fails naming every model check error and everything the format cannot \
express.",
obj(
json!({
"format": {"type": "string", "enum": ["abaqus", "nastran", "lsdyna"]},
"path": string("Output file"),
"steps": {"type": "array", "items": {"type": "string"}, "description": "Only these steps (a Nastran run is static or frequency steps)"},
}),
&["format", "path"],
),
false,
false,
),
tool(
"mesh",
"Mesh",
&format!(
"Mesh the geometry with the chosen method and parameters (list_commands shows each mesher's options), then \
report the mesh, the model's counts and the mesh quality (mesh.quality: certified validity, volume, metric \
extremes). Methods: {}.",
method_docs.join("; ")
),
obj(
json!({
"method": {"type": "string", "enum": methods},
"params": {"type": "object", "description": "The mesher's parameters, e.g. {\"body\": \"Body1\", \"tolerance\": 0.5}"},
}),
&["method"],
),
false,
false,
),
tool(
"solve",
"Solve a step",
"Solve a step of the model and keep its result: static (linear static), frequency (modes), explicit \
(explicit dynamics), fluid (lattice Boltzmann), heat when available. `step` may be left out when the model \
has one step of that kind; `options` takes the solver's caps and settings (list_commands). The results \
display then shows the step (show_results changes it).",
obj(
json!({
"analysis": {"type": "string", "enum": analyses},
"step": string("Step name"),
"options": {"type": "object", "description": "More solve parameters, e.g. {\"max_dofs\": 1000000} or {\"mass\": \"lumped\"}"},
}),
&["analysis"],
),
false,
false,
),
tool(
"show_results",
"Show results",
"Choose what the results display shows and read it back: the step, the deformed shape (`deformed`, `scale`: \
a factor or \"auto\"), contours of a nodal field (`field`: displacement, displacement_x/y/z, von_mises, \
max/mid/min_principal, stress_xx/yy/zz/xy/xz/yz, or none; `bands` 2–24), a frequency step's mode or an \
explicit step's frame. The reply has the contour's range: its smallest and largest value and their nodes \
(field displacement gives the largest displacement). Only `step`: show that step.",
obj(
json!({
"step": string("Solved step (default: the one shown)"),
"field": string("Contour field, or \"none\""),
"bands": integer("Contour bands, 2 to 24"),
"deformed": {"type": "boolean", "description": "Draw the deformed shape"},
"scale": {"type": ["number", "string"], "description": "Deformation scale factor or \"auto\""},
"mode": integer("Mode of a frequency step, from 1"),
"frame": integer("Field frame of an explicit step, from 0"),
"time": {"type": "number", "description": "Show the explicit step's frame nearest this time"},
}),
&[],
),
false,
false,
),
tool(
"probe",
"Probe results",
"Stored results at nodes (solve.probe): displacement, reaction, stress and von Mises (static), the shape of \
mode `mode` (frequency), displacement and velocity in field frame `frame` (explicit) and, with `elements`, \
explicit element values; for a fluid step, the flow at `points` and the force histories of `forces`.",
obj(
json!({
"step": string("Solved step"),
"nodes": {"type": "array", "items": {"type": "integer"}, "description": "Node labels"},
"elements": {"type": "array", "items": {"type": "integer"}, "description": "Element labels (explicit)"},
"points": {"type": "array", "items": {"type": "array"}, "description": "Points [x, y, z] (fluid)"},
"forces": {"type": "array", "items": {"type": "string"}, "description": "Walls or faces whose force histories to return (fluid)"},
"mode": integer("Mode, from 1 (frequency)"),
"frame": integer("Field frame, from 0 (explicit, fluid)"),
}),
&[],
),
true,
false,
),
tool(
"reactions",
"Reaction sums",
"Reaction force and moment (about `about`) summed over each node set and over the whole model \
(results.reactions); `sets` defaults to the sets the step's boundary conditions hold.",
obj(
json!({
"step": string("Solved static step"),
"sets": {"type": "array", "items": {"type": "string"}, "description": "Node sets"},
"about": {"type": "array", "items": {"type": "number"}, "description": "Moment point [x, y, z] (default origin)"},
}),
&[],
),
true,
false,
),
tool(
"screenshot",
"Look at the model",
"A PNG of the model and the results display. Headless: a CPU render framing everything drawn. Connected to \
the app: the whole window drawn offscreen (source \"window\", the default; ui.capture) or the model with the \
app's camera (source \"model\"; ui.render). `view` turns the camera to a standard view first.",
obj(
json!({
"view": {"type": "string", "enum": VIEWS, "description": "Standard view (default: keep the current one; headless starts at iso)"},
"source": {"type": "string", "enum": ["window", "model"], "description": "window = the app's window, model = a model render"},
"width": integer("Width in pixels (window: points), 16 to 4096"),
"height": integer("Height in pixels (window: points), 16 to 4096"),
"path": string("Also save the PNG here"),
}),
&[],
),
true,
false,
),
tool("undo", "Undo", "Take back the last command that changed the model.", obj(json!({}), &[]), false, false),
tool("redo", "Redo", "Run the last undone change again.", obj(json!({}), &[]), false, false),
]
}
fn type_matches(t: &str, v: &Value) -> bool {
match t {
"string" => v.is_string(),
"number" => v.is_number(),
"integer" => v.is_i64() || v.is_u64(),
"boolean" => v.is_boolean(),
"array" => v.is_array(),
"object" => v.is_object(),
"null" => v.is_null(),
_ => true,
}
}
/// Checks `args` against the tool's schema (top level): unknown tool, missing required
/// arguments, unknown arguments, type and enum mismatches.
pub(crate) fn check_arguments(name: &str, args: &Value) -> Result<(), String> {
let defs = tool_definitions();
let shown: String = name.chars().take(100).collect();
let def = defs.iter().find(|d| d["name"] == name).ok_or_else(|| format!("unknown tool `{shown}`"))?;
let schema = &def["inputSchema"];
let Some(a) = args.as_object() else { return Err(format!("{name}: arguments must be an object")) };
for r in schema["required"].as_array().into_iter().flatten().filter_map(Value::as_str) {
if a.get(r).is_none_or(Value::is_null) {
return Err(format!("{name}: missing required argument `{r}`"));
}
}
let props = schema["properties"].as_object();
for (k, v) in a {
let Some(p) = props.and_then(|p| p.get(k)) else {
return Err(format!("{name}: unknown argument `{}`", k.chars().take(100).collect::<String>()));
};
if v.is_null() {
continue;
}
let ok = match &p["type"] {
Value::String(t) => type_matches(t, v),
Value::Array(ts) => ts.iter().filter_map(Value::as_str).any(|t| type_matches(t, v)),
_ => true,
};
if !ok {
return Err(format!("{name}: argument `{k}` must be of type {}", p["type"]));
}
if let Some(allowed) = p["enum"].as_array()
&& !allowed.contains(v)
{
return Err(format!("{name}: `{k}` must be one of {}", Value::Array(allowed.clone())));
}
}
Ok(())
}
/// A tool result: one text block and the error flag.
fn text_result(v: &Value, is_error: bool) -> Value {
let text = match v {
Value::String(s) => s.clone(),
v => serde_json::to_string_pretty(v).unwrap_or_default(),
};
let mut r = json!({"content": [{"type": "text", "text": text}]});
if is_error {
r["isError"] = json!(true);
}
r
}
/// Run a tool. A failure is a tool result with `isError: true` (the model reads the message and
/// corrects); nothing here panics on hostile arguments.
pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> Value {
let empty = Map::new();
let a = args.as_object().unwrap_or(&empty);
let r = match name {
"screenshot" => return screenshot(b, a),
"batch" => return batch(b, a),
_ => dispatch(b, name, a),
};
match r {
Ok(v) => text_result(&v, false),
Err(e) => text_result(&Value::String(e), true),
}
}
fn pick(a: &Map<String, Value>, keys: &[&str]) -> Value {
Value::Object(keys.iter().filter_map(|k| a.get(*k).filter(|v| !v.is_null()).map(|v| (k.to_string(), v.clone()))).collect())
}
fn str_arg<'a>(a: &'a Map<String, Value>, k: &str) -> Option<&'a str> {
a.get(k).and_then(Value::as_str)
}
/// An object argument (absent: empty).
fn object_arg(a: &Map<String, Value>, k: &str) -> Value {
a.get(k).cloned().filter(Value::is_object).unwrap_or_else(|| json!({}))
}
fn dispatch(b: &mut dyn Backend, name: &str, a: &Map<String, Value>) -> Result<Value, String> {
match name {
"list_commands" => {
let all = b.commands()?;
let Some(f) = str_arg(a, "filter").map(str::to_lowercase).filter(|f| !f.is_empty()) else { return Ok(all) };
let hit = |c: &Value| ["id", "label"].iter().any(|k| c.get(*k).and_then(Value::as_str).is_some_and(|s| s.to_lowercase().contains(&f)));
Ok(Value::Array(all.as_array().map(|v| v.iter().filter(|c| hit(c)).cloned().collect()).unwrap_or_default()))
}
"execute" => {
let id = str_arg(a, "command").ok_or("`command` is required")?;
b.execute(id, object_arg(a, "params"))
}
"inspect_model" => Ok(json!({
"model": b.execute("model.info", json!({}))?,
"steps": b.execute("model.activity", json!({}))?,
"bodies": b.execute("geometry.bodies", json!({}))?,
})),
"check_model" => b.execute("model.check", json!({})),
"open" => {
let path = str_arg(a, "path").ok_or("`path` is required")?;
let ext = Path::new(path).extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase);
let command = match ext.as_deref() {
Some("modelcraft") => "file.open",
Some("solidcraft") => "geometry.open",
Some("3mf") => "geometry.import.mesh",
_ => return Err("open: a .modelcraft model, a .solidcraft design or a .3mf mesh".into()),
};
let r = b.execute(command, json!({"path": path}))?;
Ok(json!({"command": command, "result": r}))
}
"save" => b.execute("file.save", pick(a, &["path"])),
"write_deck" => b.execute("deck.write", pick(a, &["format", "path", "steps"])),
"mesh" => {
let method = str_arg(a, "method").ok_or("`method` is required")?;
let (_, command, _) = available(MESHERS, |m| m.1).find(|m| m.0 == method).ok_or_else(|| format!("unknown mesh method `{method}`"))?;
let mesh = b.execute(command, object_arg(a, "params"))?;
let model = b.execute("model.info", json!({}))?;
let quality = b.execute("mesh.quality", json!({})).unwrap_or_else(|e| json!({"error": e}));
Ok(json!({"command": command, "mesh": mesh, "model": model, "quality": quality}))
}
"solve" => {
let analysis = str_arg(a, "analysis").ok_or("`analysis` is required")?;
let (_, command) = available(ANALYSES, |x| x.1).find(|x| x.0 == analysis).ok_or_else(|| format!("unknown analysis `{analysis}`"))?;
let mut params = object_arg(a, "options");
if let Some(step) = a.get("step").filter(|s| !s.is_null()) {
params["step"] = step.clone();
}
b.execute(command, params)
}
"show_results" => show_results(b, a),
"probe" => b.execute("solve.probe", pick(a, &["step", "nodes", "elements", "points", "forces", "mode", "frame"])),
"reactions" => b.execute("results.reactions", pick(a, &["step", "sets", "about"])),
"undo" => b.execute("edit.undo", json!({})),
"redo" => b.execute("edit.redo", json!({})),
other => Err(format!("unknown tool `{}`", other.chars().take(100).collect::<String>())),
}
}
/// `show_results`: the `results.*` commands its arguments ask for, in a fixed order; the reply of
/// the last one (each replies with the whole display).
fn show_results(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<Value, String> {
let step = pick(a, &["step"]);
let with_step = |mut p: Value| {
if let (Some(o), Some(s)) = (p.as_object_mut(), step.get("step")) {
o.insert("step".into(), s.clone());
}
p
};
let mut last = None;
if let Some(mode) = a.get("mode").filter(|v| !v.is_null()) {
last = Some(b.execute("results.modes", with_step(json!({"mode": mode})))?);
}
if a.get("frame").is_some_and(|v| !v.is_null()) || a.get("time").is_some_and(|v| !v.is_null()) {
last = Some(b.execute("results.frame", with_step(pick(a, &["frame", "time"])))?);
}
if a.get("deformed").is_some_and(|v| !v.is_null()) || a.get("scale").is_some_and(|v| !v.is_null()) {
let mut p = pick(a, &["scale"]);
if let Some(show) = a.get("deformed").filter(|v| !v.is_null()) {
p["show"] = show.clone();
}
last = Some(b.execute("results.deformed", with_step(p))?);
}
if a.get("field").is_some_and(|v| !v.is_null()) {
last = Some(b.execute("results.contour", with_step(pick(a, &["field", "bands"])))?);
} else if a.get("bands").is_some_and(|v| !v.is_null()) {
return Err("show_results: `bands` goes with `field`".into());
}
match last {
Some(v) => Ok(v),
// Only a step: show it.
None if step.get("step").is_some() => b.execute("results.deformed", step),
None => Err("show_results: give `step`, `field`, `deformed`, `scale`, `mode`, `frame` or `time`".into()),
}
}
/// `batch`: run steps in order, stopping at the first failure.
fn batch(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
let Some(steps) = a.get("commands").and_then(Value::as_array) else {
return text_result(&json!("`commands` must be an array of {command, params}"), true);
};
if steps.len() > MAX_STEPS {
return text_result(&json!(format!("batch too long ({} steps, at most {MAX_STEPS})", steps.len())), true);
}
let mut results = Vec::with_capacity(steps.len());
for (i, step) in steps.iter().enumerate() {
let command = step.get("command").and_then(Value::as_str);
let r = match command {
Some(id) => b.execute(id, step.get("params").cloned().filter(Value::is_object).unwrap_or_else(|| json!({}))),
None => Err("missing `command`".to_string()),
};
match r {
Ok(v) => results.push(v),
Err(e) => {
return text_result(
&json!({"ok": false, "completed": i, "failed": {"index": i, "command": command, "error": e}, "results": results}),
true,
);
}
}
}
text_result(&json!({"ok": true, "completed": results.len(), "results": results}), false)
}
/// `screenshot`: a PNG as an MCP image content block, with what it shows as text.
fn screenshot(b: &mut dyn Backend, a: &Map<String, Value>) -> Value {
match capture(b, a) {
Ok((png, info)) => json!({"content": [
{"type": "image", "data": base64(&png), "mimeType": "image/png"},
{"type": "text", "text": info.to_string()},
]}),
Err(e) => text_result(&Value::String(e), true),
}
}
fn capture(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<(Vec<u8>, Value), String> {
// The file is checked before anything is drawn.
let keep = match str_arg(a, "path").filter(|p| !p.trim().is_empty()) {
Some(p) => Some(b.roots().check(Path::new(p), Access::Write).map_err(|e| e.to_string())?),
None => None,
};
let size = |k: &str| a.get(k).and_then(Value::as_u64).map(|v| u32::try_from(v).unwrap_or(u32::MAX));
let request = PictureRequest {
view: str_arg(a, "view").map(str::to_string),
window: str_arg(a, "source") != Some("model"),
width: size("width"),
height: size("height"),
};
let picture = b.picture(&request)?;
let mut info = picture.info;
if let Some(path) = &keep {
modelcraft_io::fs::write_atomic(path, &picture.png, false).map_err(|e| e.to_string())?;
info["path"] = json!(path.to_string_lossy());
}
info["bytes"] = json!(picture.png.len());
Ok((picture.png, info))
}
/// Standard base64 with padding (RFC 4648).
pub(crate) fn base64(data: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)];
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
for (i, shift) in [18u32, 12, 6, 0].into_iter().enumerate() {
let digit = T.get(((n >> shift) & 63) as usize).copied().unwrap_or(b'=');
out.push(char::from(if i <= chunk.len() { digit } else { b'=' }));
}
}
out
}