MCP: server and tools

crates/mcp (L5, engine only): JSON-RPC 2.0 over stdio with protocol version negotiation, a
headless Session backend and a bridge to the app's control port, 14 tools (list_commands,
execute, batch with rollback, inspect_design, measure, body_topology, set_parameter,
screenshot as an image block, export, undo/redo, new_design/open/save) and tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-07 19:25:27 +00:00
1 parent 646b9a7226
commit d1ff23990e
9 files changed
+1184

No files matched your search

Generated
+9
View File
@@ -3070,6 +3070,7 @@ version = "0.1.0"
dependencies = [
"serde_json",
"solvecraft-engine",
"solvecraft-mcp",
]
[[package]]
@@ -3134,6 +3135,14 @@ dependencies = [
"truck-topology",
]
[[package]]
name = "solvecraft-mcp"
version = "0.1.0"
dependencies = [
"serde_json",
"solvecraft-engine",
]
[[package]]
name = "solvecraft-render"
version = "0.1.0"
+1
View File
@@ -20,6 +20,7 @@ solvecraft-doc = { path = "crates/doc" }
solvecraft-io = { path = "crates/io" }
solvecraft-engine = { path = "crates/engine" }
solvecraft-ui-egui = { path = "crates/ui-egui" }
solvecraft-mcp = { path = "crates/mcp" }
# foundation
thiserror = "2"
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "solvecraft-mcp"
description = "SolveCraft 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]
solvecraft-engine = { workspace = true }
serde_json = { workspace = true }
+173
View File
@@ -0,0 +1,173 @@
//! Where MCP tool calls end up: a control-channel method call (`docs/control-protocol.md`).
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpStream, ToSocketAddrs};
use std::time::Duration;
use serde_json::{Value, json};
use solvecraft_engine::Session;
use solvecraft_engine::render::{StandardView, render_png};
/// Something that answers control-channel methods: `engine.execute`, `engine.commands`,
/// `document.inspect`, `ui.view`, `ui.render`, `ui.screenshot`.
pub trait Backend {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String>;
/// True when a real window is attached (`ui.screenshot` can capture it).
fn has_ui(&self) -> bool;
fn describe(&self) -> String;
}
/// Longest reply line accepted from the app.
const MAX_REPLY: u64 = 64 << 20;
/// A running SolveCraft app reached through its loopback control port
/// (`solvecraft --control PORT`).
pub struct Remote {
addr: String,
conn: Option<(BufReader<TcpStream>, TcpStream)>,
next_id: u64,
}
impl Remote {
pub fn connect(addr: &str) -> std::io::Result<Self> {
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1 };
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();
// Kernel operations (booleans, fillets) on big parts can take a while.
s.set_read_timeout(Some(Duration::from_secs(300))).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"));
}
Ok(reply)
}
}
impl Backend for Remote {
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!("SolveCraft 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())
}
}
fn has_ui(&self) -> bool {
true
}
fn describe(&self) -> String {
format!("connected to the SolveCraft app at {} (the user watches the viewport)", self.addr)
}
}
/// An in-process headless session (no window); renders with the CPU rasterizer.
pub struct Headless {
pub session: Session,
/// Orientation for renders; the camera is refitted to the model every time.
pub view: StandardView,
}
impl Default for Headless {
fn default() -> Self {
Headless::new(Session::default())
}
}
impl Headless {
pub fn new(session: Session) -> Self {
Headless { session, view: StandardView::Iso }
}
}
fn dim(p: &Value, k: &str, default: f64) -> usize {
p.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(default).clamp(16.0, 4096.0) as usize
}
impl Backend for Headless {
fn call(&mut self, method: &str, p: Value) -> Result<Value, String> {
let s = &mut self.session;
let str_p = |k: &str| p.get(k).and_then(Value::as_str);
match method {
"engine.execute" | "command" => {
let id = str_p("command").ok_or("missing `command`")?;
let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}));
s.execute(id, &params).map_err(|e| e.to_string())
}
"engine.script" => s.run_script(&p).map(Value::from).map_err(|e| e.to_string()),
"engine.commands" => s.execute("engine.commands", &json!({})).map_err(|e| e.to_string()),
"document.inspect" => s.execute("document.inspect", &p).map_err(|e| e.to_string()),
"ui.view" => {
let v = str_p("view").unwrap_or("home");
self.view = match v {
"fit" => self.view,
_ => StandardView::parse(v).ok_or_else(|| format!("unknown view `{v}`"))?,
};
Ok(json!({"view": self.view}))
}
"ui.render" => {
let (w, h) = (dim(&p, "width", 1280.0), dim(&p, "height", 800.0));
let mut cam = solvecraft_engine::view::home_camera(s);
cam.set_view(self.view);
let scene = solvecraft_engine::view::scene(s, &cam);
let png = render_png(&scene, &cam, w, h).ok_or("render failed")?;
let path = str_p("path").ok_or("missing `path`")?;
std::fs::write(path, &png).map_err(|e| format!("{path}: {e}"))?;
Ok(json!({"path": path, "bytes": png.len(), "width": w, "height": h}))
}
"ui.screenshot" => Err("no window in headless mode; use ui.render".into()),
other => Err(format!("unknown method `{other}`")),
}
}
fn has_ui(&self) -> bool {
false
}
fn describe(&self) -> String {
"headless in-process SolveCraft session".into()
}
}
+21
View File
@@ -0,0 +1,21 @@
//! SolveCraft MCP server.
//!
//! JSON-RPC 2.0 over stdio (newline-delimited), implementing the MCP lifecycle, tools and
//! resources. Tools map onto the control-channel methods (`engine.execute`, `document.inspect`,
//! `ui.render`…, see `docs/control-protocol.md`), served either by an in-process headless
//! [`Headless`] session or by a running app through its loopback control port ([`Remote`]), so a
//! person can watch the agent model. Every modelling step is an engine command; tools never open
//! dialogs.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod backend;
mod server;
mod tools;
pub use backend::{Backend, Headless, Remote};
pub use server::{PROTOCOL_VERSION, SUPPORTED_VERSIONS, Server};
pub use tools::{call_tool, tool_definitions};
#[cfg(test)]
mod tests;
+170
View File
@@ -0,0 +1,170 @@
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / 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 we speak; offered when the client asks for one we don't know.
pub const PROTOCOL_VERSION: &str = "2025-06-18";
pub const SUPPORTED_VERSIONS: &[&str] = &[PROTOCOL_VERSION, "2025-03-26", "2024-11-05"];
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;
/// Longest accepted request line (a guard against runaway input).
const MAX_LINE: usize = 16 << 20;
/// Most messages in one JSON-RPC batch.
const MAX_BATCH: usize = 1000;
const INSTRUCTIONS: &str = "SolveCraft is a parametric 3D CAD app in the style of Autodesk Fusion. Units are millimetres \
and degrees in expressions (internally radians), Z is up. Model like in Fusion: `SketchCreate {plane: XY}`, draw \
(`ShapeRectangleTwoPoint {p0, p1}`, `CircleCenterRadius {center, radius}`, `DrawPolyline {points, closed}`), constrain \
and dimension (`SketchDimension {entities, value}`; values may name parameters), `SketchStop`, then features \
(`Extrude {distance, operation?: new|join|cut|intersect}`, `Revolve`, `FusionFilletEdgesCommand {edges: [[x,y,z] point on \
edge], radius}`, `FusionChamferCommand`, patterns, mirror, primitives). Use `batch` for several steps at once, \
`list_commands` for ids and parameter docs, `inspect_design` / `measure` to verify, `list_edges` to find edge points, \
`set_parameter` to drive the design and `screenshot` to look at it.";
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()}})
}
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()
}
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, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
for line in input.lines() {
let line = match line {
Ok(l) => l,
// Not UTF-8: answer with a parse error and keep serving.
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
let reply = error(Value::Null, PARSE_ERROR, "parse error: input is not UTF-8").to_string();
output.write_all(reply.as_bytes())?;
output.write_all(b"\n")?;
output.flush()?;
continue;
}
Err(e) => return Err(e),
};
if let Some(reply) = self.handle_line(&line) {
output.write_all(reply.as_bytes())?;
output.write_all(b"\n")?;
output.flush()?;
}
}
Ok(())
}
/// 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 => Some(error(Value::Null, INVALID_REQUEST, "batch too large")),
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())
}
pub fn handle(&mut self, msg: Value) -> Option<Value> {
let Value::Object(o) = &msg else { return Some(error(Value::Null, INVALID_REQUEST, "message must be an object")) };
let id = o.get("id").cloned();
let Some(method) = o.get("method").and_then(Value::as_str) else {
// A response to a request of ours (we send none) 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": "solvecraft", "title": "SolveCraft", "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": "solvecraft://design", "name": "Active design", "description": "document.inspect with measurements", "mimeType": "application/json"},
{"uri": "solvecraft://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 {
"solvecraft://design" => self.backend.call("document.inspect", json!({"measure": true})),
"solvecraft://commands" => self.backend.call("engine.commands", json!({})),
_ => return Err((RESOURCE_NOT_FOUND, format!("unknown resource `{uri}`"))),
}
.map_err(|e| (INTERNAL_ERROR, e))?;
Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": v.to_string()}]}))
}
_ => Err((METHOD_NOT_FOUND, format!("method `{method}` not found"))),
}
}
}
+315
View File
@@ -0,0 +1,315 @@
//! An agent builds parts through MCP only and verifies them through MCP only.
use std::f64::consts::PI;
use serde_json::{Value, json};
use crate::{Headless, PROTOCOL_VERSION, Server, tool_definitions};
fn server() -> Server {
Server::new(Box::new(Headless::default()))
}
fn call(s: &mut Server, id: u64, method: &str, params: Value) -> Value {
let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
let r = s.handle_line(&line).expect("reply");
serde_json::from_str(&r).unwrap()
}
fn raw_tool(s: &mut Server, name: &str, args: Value) -> Value {
call(s, 99, "tools/call", json!({"name": name, "arguments": args}))
}
/// Call a tool that must succeed; returns its JSON text (or the first content block).
fn tool(s: &mut Server, name: &str, args: Value) -> Value {
let r = raw_tool(s, name, args);
let c = &r["result"]["content"][0];
assert_ne!(r["result"]["isError"], json!(true), "{name} failed: {c}");
assert!(r.get("error").is_none(), "{name}: {r}");
if c["type"] == "text" { serde_json::from_str(c["text"].as_str().unwrap()).unwrap_or(Value::Null) } else { c.clone() }
}
fn volume(s: &mut Server) -> f64 {
tool(s, "measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap()
}
fn rel(a: f64, b: f64) -> f64 {
(a - b).abs() / b.abs().max(1e-12)
}
#[test]
fn handshake_and_tools_list() {
let mut s = server();
let init =
call(&mut s, 1, "initialize", json!({"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "t", "version": "0"}}));
assert_eq!(init["result"]["protocolVersion"], "2025-03-26");
assert_eq!(init["result"]["serverInfo"]["name"], "solvecraft");
assert!(init["result"]["capabilities"]["tools"].is_object());
assert!(init["result"]["instructions"].as_str().unwrap().contains("headless"));
// An unknown version gets ours.
let init = call(&mut s, 2, "initialize", json!({"protocolVersion": "1999-01-01"}));
assert_eq!(init["result"]["protocolVersion"], PROTOCOL_VERSION);
assert!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#).is_none());
assert!(s.is_initialized());
assert_eq!(call(&mut s, 3, "ping", json!({}))["result"], json!({}));
let tools = call(&mut s, 4, "tools/list", json!({}));
let names: Vec<&str> = tools["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect();
for want in [
"list_commands",
"execute",
"batch",
"inspect_design",
"measure",
"body_topology",
"set_parameter",
"screenshot",
"export",
"undo",
"redo",
"new_design",
"open",
"save",
] {
assert!(names.contains(&want), "{want} missing from {names:?}");
}
for t in tools["result"]["tools"].as_array().unwrap() {
assert_eq!(t["inputSchema"]["type"], "object", "{t}");
assert!(t["description"].as_str().unwrap().len() > 20, "{t}");
}
let cmds = tool(&mut s, "list_commands", json!({"filter": "fillet"}));
assert!(cmds.as_array().unwrap().iter().any(|c| c["id"] == "FusionFilletEdgesCommand"), "{cmds}");
assert!(cmds.as_array().unwrap().len() < 5);
let res = call(&mut s, 5, "resources/list", json!({}));
assert_eq!(res["result"]["resources"].as_array().unwrap().len(), 2);
let d = call(&mut s, 6, "resources/read", json!({"uri": "solvecraft://design"}));
assert!(d["result"]["contents"][0]["text"].as_str().unwrap().contains("timeline"));
assert_eq!(call(&mut s, 7, "resources/read", json!({"uri": "solvecraft://nope"}))["error"]["code"], -32002);
assert_eq!(call(&mut s, 8, "nope/nope", json!({}))["error"]["code"], -32601);
}
/// Box → fillet → hole in one batch, then verify with measure / inspect_design and look at it.
#[test]
fn agent_builds_box_fillet_hole() {
let mut s = server();
let r = tool(
&mut s,
"batch",
json!({"commands": [
{"command": "SketchCreate", "params": {"plane": "XY"}},
{"command": "ShapeRectangleTwoPoint", "params": {"p0": [0, 0], "p1": [40, 30]}},
{"command": "SketchStop"},
{"command": "Extrude", "params": {"distance": 20}},
{"command": "FusionFilletEdgesCommand", "params": {"edges": [[0, 0, 10]], "radius": 3}},
{"command": "SketchCreate", "params": {"plane": "XY"}},
{"command": "CircleCenterRadius", "params": {"center": [20, 15], "radius": 5}},
{"command": "Extrude", "params": {"distance": 20, "operation": "cut"}},
]}),
);
assert_eq!(r["completed"], 8, "{r}");
let expect = 24000.0 - (9.0 - PI * 9.0 / 4.0) * 20.0 - PI * 25.0 * 20.0;
let v = volume(&mut s);
assert!(rel(v, expect) < 5e-4, "{v} vs {expect}");
let m = tool(&mut s, "measure", json!({"body": "Body1"}));
// 6 box faces + fillet + hole wall (the kernel may keep the wall as two halves).
assert!((8..=9).contains(&m["bodies"][0]["faces"].as_u64().unwrap()), "{m}");
assert!(rel(m["bodies"][0]["bbox"]["max"][0].as_f64().unwrap(), 40.0) < 1e-6, "{m}");
let d = tool(&mut s, "inspect_design", json!({}));
assert_eq!(d["timeline"].as_array().unwrap().len(), 5, "{d}");
assert!(d["timeline"].as_array().unwrap().iter().all(|f| f["error"].is_null()), "{d}");
assert_eq!(d["sketches"][0]["curves"].as_array().unwrap().len(), 4, "{d}");
assert!(d["sketches"][0]["status"].is_string(), "{d}");
assert!(d["bodies"][0]["volume_mm3"].as_f64().unwrap() > 0.0);
let topo = tool(&mut s, "body_topology", json!({"body": "Body1"}));
assert!(topo["edges"].as_array().unwrap().len() >= 12, "{topo}");
assert!(topo["faces"].as_array().unwrap().len() >= 8, "{topo}");
// Undo the hole, redo it.
tool(&mut s, "undo", json!({}));
assert!(rel(volume(&mut s), 24000.0 - (9.0 - PI * 9.0 / 4.0) * 20.0) < 5e-4);
tool(&mut s, "redo", json!({}));
assert!(rel(volume(&mut s), expect) < 5e-4);
// Look at it from the top: a real PNG comes back as an image block.
let img = tool(&mut s, "screenshot", json!({"view": "top", "width": 320, "height": 200}));
assert_eq!(img["type"], "image");
assert_eq!(img["mimeType"], "image/png");
assert!(img["data"].as_str().unwrap().starts_with("iVBORw0KGgo"), "not a PNG");
// Export, save, start over, open.
let dir = std::env::temp_dir().join(format!("solvecraft-mcp-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let step = dir.join("part.step");
tool(&mut s, "export", json!({"path": step.to_string_lossy()}));
assert!(std::fs::read_to_string(&step).unwrap().contains("ISO-10303-21"));
let stl = dir.join("part.stl");
tool(&mut s, "export", json!({"path": stl.to_string_lossy(), "format": "stl"}));
assert!(std::fs::metadata(&stl).unwrap().len() > 1000);
let shot = dir.join("shot.png");
tool(&mut s, "screenshot", json!({"path": shot.to_string_lossy(), "width": 64, "height": 64}));
assert!(std::fs::metadata(&shot).unwrap().len() > 100);
let design = dir.join("part.solvecraft");
tool(&mut s, "save", json!({"path": design.to_string_lossy()}));
tool(&mut s, "new_design", json!({"name": "Empty"}));
assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 0);
tool(&mut s, "open", json!({"path": design.to_string_lossy()}));
assert!(rel(volume(&mut s), expect) < 5e-4);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn parameters_drive_the_design() {
let mut s = server();
tool(&mut s, "set_parameter", json!({"name": "len", "value": "40 mm"}));
tool(&mut s, "execute", json!({"command": "PrimitiveBox", "params": {"length": "len", "width": 30, "height": "len / 2", "corner": [0, 0, 0]}}));
assert!(rel(volume(&mut s), 40.0 * 30.0 * 20.0) < 1e-9);
let r = tool(&mut s, "set_parameter", json!({"name": "len", "value": 50}));
assert_eq!(r["value"], 50.0, "{r}");
assert_eq!(r["errors"].as_array().unwrap().len(), 0, "{r}");
assert!(rel(volume(&mut s), 50.0 * 30.0 * 25.0) < 1e-9);
let d = tool(&mut s, "inspect_design", json!({"measure": false, "sketches": false}));
assert!(d["params"].as_array().unwrap().iter().any(|p| p["name"] == "len" && p["value"] == 50.0), "{d}");
}
#[test]
fn batch_stops_at_first_error_and_rolls_back() {
let mut s = server();
tool(&mut s, "execute", json!({"command": "PrimitiveBox", "params": {"length": 10, "width": 10, "height": 10}}));
let r = raw_tool(
&mut s,
"batch",
json!({"commands": [
{"command": "PrimitiveSphere", "params": {"radius": 3, "center": [50, 0, 0]}},
{"command": "Extrude", "params": {"distance": "bogus +"}},
{"command": "PrimitiveSphere", "params": {"radius": 3, "center": [80, 0, 0]}},
]}),
);
assert_eq!(r["result"]["isError"], true, "{r}");
let body: Value = serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(body["failed"]["index"], 1, "{body}");
assert_eq!(body["failed"]["command"], "Extrude");
assert_eq!(body["undone"], 1, "{body}");
assert_eq!(body["results"].as_array().unwrap().len(), 1);
// Only the first box is left, and its undo step is intact.
assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 1);
assert_eq!(tool(&mut s, "inspect_design", json!({"measure": false}))["undo"], 1);
// Without rollback the completed steps stay.
let r = raw_tool(
&mut s,
"batch",
json!({"rollback": false, "commands": [{"command": "PrimitiveSphere", "params": {"radius": 3, "center": [50, 0, 0]}}, {"command": "NoSuchCommand"}]}),
);
assert_eq!(r["result"]["isError"], true);
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("NoSuchCommand"));
assert_eq!(tool(&mut s, "measure", json!({}))["body_count"], 2);
}
#[test]
fn unknown_tools_and_bad_arguments_are_clean_errors() {
let mut s = server();
let r = raw_tool(&mut s, "make_coffee", json!({}));
assert_eq!(r["error"]["code"], -32602, "{r}");
assert!(r["error"]["message"].as_str().unwrap().contains("make_coffee"));
assert_eq!(raw_tool(&mut s, "execute", json!({}))["error"]["code"], -32602);
assert_eq!(raw_tool(&mut s, "execute", json!({"command": 5}))["error"]["code"], -32602);
assert_eq!(raw_tool(&mut s, "measure", json!({"bogus": 1}))["error"]["code"], -32602);
assert_eq!(raw_tool(&mut s, "screenshot", json!({"view": "sideways"}))["error"]["code"], -32602);
assert_eq!(call(&mut s, 1, "tools/call", json!({"arguments": {}}))["error"]["code"], -32602);
// Engine errors are tool errors the model can read.
let r = raw_tool(&mut s, "execute", json!({"command": "Extrude", "params": {"distance": 10}}));
assert_eq!(r["result"]["isError"], true, "{r}");
let r = raw_tool(&mut s, "execute", json!({"command": "NoSuchCommand"}));
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("unknown command"));
// Framing errors.
assert!(s.handle_line("{not json").unwrap().contains("-32700"));
assert!(s.handle_line("[]").unwrap().contains("-32600"));
assert!(s.handle_line("42").unwrap().contains("-32600"));
assert!(s.handle_line(" ").is_none());
assert!(s.handle_line(r#"{"jsonrpc":"2.0","id":1,"result":{}}"#).is_none());
let both = s.handle_line(r#"[{"jsonrpc":"2.0","id":1,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/x"}]"#).unwrap();
assert_eq!(serde_json::from_str::<Value>(&both).unwrap().as_array().unwrap().len(), 1);
}
fn hostile_values() -> Vec<Value> {
vec![
Value::Null,
json!(true),
json!(-1),
json!(0),
json!(1e308),
json!(-1e308),
json!(""),
json!("nope"),
json!("../../../../../../nonexistent/dir/x.step"),
json!("1/0"),
json!([]),
json!([1e308, -1e308]),
json!([{"command": "Extrude", "params": {"distance": 1e308}}]),
json!([{"command": 7}, null, "x"]),
json!({}),
json!({"x": 1}),
]
}
/// Every tool, every argument it documents, every hostile value: replies are well-formed
/// JSON-RPC and nothing panics.
#[test]
fn hostile_arguments_never_panic() {
let mut s = server();
tool(&mut s, "execute", json!({"command": "PrimitiveBox", "params": {"length": 10, "width": 10, "height": 10}}));
let defs = tool_definitions();
let mut n = 0;
for def in &defs {
let name = def["name"].as_str().unwrap();
// Hostile strings are relative paths: don't let the sweep write files into the crate.
let writes = matches!(name, "save" | "screenshot" | "export");
let keys: Vec<String> = def["inputSchema"]["properties"].as_object().unwrap().keys().filter(|k| !(writes && *k == "path")).cloned().collect();
for k in &keys {
for v in hostile_values() {
let mut args = json!({});
// Fill the other required arguments with something plausible.
for r in def["inputSchema"]["required"].as_array().into_iter().flatten() {
args[r.as_str().unwrap()] = json!("Body1");
}
args[k.as_str()] = v;
if name == "screenshot" && (k == "width" || k == "height") {
args["view"] = json!("front");
}
let r = raw_tool(&mut s, name, args.clone());
assert!(r.get("result").is_some() || r["error"]["code"].is_i64(), "{name} {args}: {r}");
n += 1;
}
}
}
assert!(n > 100);
// Execute every command with hostile params through MCP too.
let cmds = tool(&mut s, "list_commands", json!({}));
for c in cmds.as_array().unwrap() {
let id = c["id"].as_str().unwrap();
if matches!(id, "doc.open" | "SaveDocumentCommand" | "SaveDocumentAsCommand" | "ExportCommand" | "FusionSaveAsSTLCommand") {
continue;
}
for v in hostile_values() {
let r = raw_tool(&mut s, "execute", json!({"command": id, "params": {"distance": v.clone(), "edges": v.clone(), "plane": v}}));
assert!(r.get("result").is_some(), "{id}: {r}");
}
}
// Arguments that are not an object.
for v in hostile_values().into_iter().filter(|v| !v.is_object() && !v.is_null()) {
let r = call(&mut s, 1, "tools/call", json!({"name": "measure", "arguments": v}));
assert_eq!(r["error"]["code"], -32602, "{r}");
}
}
#[test]
fn base64_matches_rfc4648() {
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);
}
}
+481
View File
@@ -0,0 +1,481 @@
//! MCP tool definitions and dispatch. Every tool is a thin wrapper over engine commands
//! (`engine.execute`) and the inspection / rendering control methods.
use std::sync::atomic::{AtomicU64, Ordering};
use serde_json::{Map, Value, json};
use crate::backend::Backend;
/// Most commands in one `batch` call.
const MAX_BATCH: usize = 10_000;
/// Largest image returned by `screenshot`.
const MAX_IMAGE_BYTES: u64 = 32 << 20;
const VIEWS: &[&str] = &["iso", "home", "front", "back", "top", "bottom", "left", "right", "fit"];
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})
}
/// Every tool, with its JSON schema. The same list serves the headless and the bridged backend.
pub fn tool_definitions() -> Vec<Value> {
vec![
tool(
"list_commands",
"List commands",
"The command catalog: id, label, toolbar tab/panel, shortcut, one-line parameter docs and whether the command is \
enabled right now. Ids are Fusion's command ids where Fusion has the command (Extrude, SketchCreate, \
FusionFilletEdgesCommand…), dotted ids otherwise (sketch.inspect, timeline.rollback). Optional case-insensitive \
substring `filter` over id, label, panel and params.",
obj(json!({"filter": string("Substring to match, e.g. \"sketch\", \"fillet\", \"pattern\"")}), &[]),
true,
false,
),
tool(
"execute",
"Execute a command",
"Run one command with JSON parameters, exactly as the toolbar, palette and scripts do. Never opens a dialog. \
Examples: SketchCreate {plane: \"XY\"}; ShapeRectangleTwoPoint {p0: [0,0], p1: [40,30]}; CircleCenterRadius \
{center: [20,15], radius: 5}; SketchDimension {entities: [curve id], value: \"width\"}; SketchStop {}; Extrude \
{distance: 20, operation: \"cut\"}; FusionFilletEdgesCommand {edges: [[0,0,10]], radius: 3}; PrimitiveBox {length, \
width, height}. Lengths are mm; expressions may use parameters and units (\"width / 2\", \"30 deg\"). Returns \
the command's result; failures leave the design unchanged.",
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}. On an error the steps \
already run are undone (unless rollback: false), so the batch is all-or-nothing; the reply names the failing \
step (index from 0), its error, and every result before it. The usual way to build a part: sketch, draw, \
dimension, finish, extrude, fillet… in one call.",
obj(
json!({
"commands": {
"type": "array",
"description": "Steps: [{\"command\": id, \"params\": {...}}, ...]",
"items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}, "required": ["command"]},
},
"rollback": {"type": "boolean", "description": "Undo completed steps when one fails (default true)"},
}),
&["commands"],
),
false,
false,
),
tool(
"inspect_design",
"Inspect the design",
"The whole design: user parameters (expression, value, errors), the timeline (features with type, suppressed, \
rolled back, error, warning, timing), bodies (volume, area, centre of mass, bounding box, face/edge/vertex \
counts), sketches (curves with ids and coordinates, points, constraints, profiles, degrees of freedom, solve \
status), the active sketch, selection and undo depth.",
obj(
json!({
"measure": {"type": "boolean", "description": "Measure bodies (default true)"},
"sketches": {"type": "boolean", "description": "Full sketch contents (default true; false = summary only)"},
}),
&[],
),
true,
false,
),
tool(
"measure",
"Measure bodies",
"Volume (mm³), surface area (mm²), centre of mass, bounding box and face/edge/vertex counts of one body or all \
bodies, with totals.",
obj(json!({"body": string("Body name (default: all bodies)")}), &[]),
true,
false,
),
tool(
"body_topology",
"List edges and faces",
"The edges (index, midpoint, length, endpoints) and faces (index, area, centroid, plane normal) of a body. Use \
an edge midpoint as the [x,y,z] point that picks the edge for fillet and chamfer.",
obj(json!({"body": string("Body name, e.g. \"Body1\"")}), &["body"]),
true,
false,
),
tool(
"set_parameter",
"Set a parameter",
"Create or change a user parameter and re-evaluate the timeline. `value` is a number (mm) or an expression \
(\"40 mm\", \"width * 2\", \"30 deg\"). Returns the new value, the features recomputed and any features that now fail.",
obj(
json!({
"name": string("Parameter name"),
"value": {"type": ["number", "string"], "description": "Number or expression"},
"unit": string("mm, deg or \"\" (unitless); default from the expression"),
"comment": string("Comment shown in the parameters table"),
}),
&["name", "value"],
),
false,
false,
),
tool(
"screenshot",
"Look at the design",
"Render the design and return it as a PNG image. Headless: a CPU render of the model, fitted to the view. \
Connected to the app: a screenshot of the window (source \"window\", the default) or a render of the model \
with the app's camera (source \"model\"). `view` first turns the camera to a standard view.",
obj(
json!({
"view": {"type": "string", "enum": VIEWS, "description": "Standard view (default: keep the current one; headless starts at iso)"},
"path": string("Also keep the PNG at this path"),
"width": {"type": "integer", "description": "Render width in pixels (model renders; default 960)"},
"height": {"type": "integer", "description": "Render height in pixels (model renders; default 600)"},
"source": {"type": "string", "enum": ["window", "model"], "description": "window = app screenshot, model = model render"},
}),
&[],
),
true,
false,
),
tool(
"export",
"Export",
"Export bodies to STEP (.step/.stp), binary STL (.stl), ASCII STL (format stla) or OBJ. The format comes from \
the extension unless given.",
obj(
json!({
"path": string("Output file"),
"format": {"type": "string", "enum": ["step", "stl", "stla", "obj"]},
"bodies": {"type": "array", "items": {"type": "string"}, "description": "Body names (default: all)"},
}),
&["path"],
),
false,
false,
),
tool("undo", "Undo", "Undo the last change to the design.", obj(json!({}), &[]), false, false),
tool("redo", "Redo", "Redo the last undone change.", obj(json!({}), &[]), false, false),
tool(
"new_design",
"New design",
"Start a new, empty design (unsaved changes to the current one are discarded).",
obj(json!({"name": string("Design name (default Untitled)")}), &[]),
false,
true,
),
tool(
"open",
"Open a design",
"Open a .solvecraft design file (replaces the current design).",
obj(json!({"path": string("Path to a .solvecraft file")}), &["path"]),
false,
true,
),
tool(
"save",
"Save the design",
"Save the design as a .solvecraft file (to `path`, or to the file it came from).",
obj(json!({"path": string("Path to save to (default: the current file)")}), &[]),
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,
}
}
/// Validates `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 def = defs.iter().find(|d| d["name"] == name).ok_or_else(|| format!("unknown tool `{name}`"))?;
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}`"));
};
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: content blocks plus 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. Failures are tool results with `isError: true` (the model sees the message);
/// 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 exec(b: &mut dyn Backend, command: &str, params: Value) -> Result<Value, String> {
b.call("engine.execute", json!({"command": command, "params": params}))
}
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)
}
fn dispatch(b: &mut dyn Backend, name: &str, a: &Map<String, Value>) -> Result<Value, String> {
match name {
"list_commands" => {
let all = b.call("engine.commands", json!({}))?;
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", "panel", "tab", "params"]
.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")?;
exec(b, id, a.get("params").cloned().filter(Value::is_object).unwrap_or(json!({})))
}
"inspect_design" => {
let measure = a.get("measure").and_then(Value::as_bool).unwrap_or(true);
let mut d = b.call("document.inspect", json!({"measure": measure}))?;
if a.get("sketches").and_then(Value::as_bool).unwrap_or(true) {
let ids: Vec<Value> = d["sketches"].as_array().map(|v| v.iter().map(|s| s["id"].clone()).collect()).unwrap_or_default();
let full: Vec<Value> = ids
.into_iter()
.map(|id| exec(b, "sketch.inspect", json!({"sketch": id.clone()})).unwrap_or_else(|e| json!({"id": id, "error": e})))
.collect();
d["sketches"] = Value::Array(full);
}
Ok(d)
}
"measure" => {
let p = match str_arg(a, "body") {
Some(body) => json!({"bodies": [body]}),
None => json!({}),
};
exec(b, "MeasureCommand", p)
}
"body_topology" => {
let p = pick(a, &["body"]);
let edges = exec(b, "model.edges", p.clone())?;
let faces = exec(b, "model.faces", p)?;
Ok(json!({"body": a.get("body"), "edges": edges["edges"], "faces": faces["faces"]}))
}
"set_parameter" => {
let mut p = pick(a, &["name", "unit", "comment"]);
p["expression"] = a.get("value").cloned().unwrap_or(Value::Null);
exec(b, "ChangeParameterCommand", p)
}
"export" => exec(b, "ExportCommand", pick(a, &["path", "format", "bodies"])),
"undo" => exec(b, "UndoCommand", json!({})),
"redo" => exec(b, "RedoCommand", json!({})),
"new_design" => exec(b, "NewDocumentCommand", pick(a, &["name"])),
"open" => exec(b, "doc.open", pick(a, &["path"])),
"save" => exec(b, "SaveDocumentCommand", pick(a, &["path"])),
other => Err(format!("unknown tool `{other}`")),
}
}
fn undo_depth(b: &mut dyn Backend) -> Option<u64> {
b.call("document.inspect", json!({})).ok().and_then(|d| d["undo"].as_u64())
}
/// `batch`: run steps in order, stop at the first failure and (by default) undo what ran.
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_BATCH {
return text_result(&json!(format!("batch too long ({} steps, at most {MAX_BATCH})", steps.len())), true);
}
let rollback = a.get("rollback").and_then(Value::as_bool).unwrap_or(true);
let before = if rollback { undo_depth(b) } else { None };
let mut results = Vec::with_capacity(steps.len());
for (i, step) in steps.iter().enumerate() {
let command = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str);
let r = match command {
Some(id) => exec(b, id, step.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({}))),
None => Err("missing `command`".to_string()),
};
match r {
Ok(v) => results.push(v),
Err(e) => {
let mut undone = 0u64;
if let (Some(before), Some(after)) = (before, rollback.then(|| undo_depth(b)).flatten()) {
for _ in before..after {
if exec(b, "UndoCommand", json!({})).is_err() {
break;
}
undone += 1;
}
}
return text_result(
&json!({
"ok": false,
"completed": i,
"failed": {"index": i, "command": command, "error": e},
"rolled_back": rollback,
"undone": undone,
"results": results,
}),
true,
);
}
}
}
text_result(&json!({"ok": true, "completed": results.len(), "results": results}), false)
}
static SHOT: AtomicU64 = AtomicU64::new(0);
/// `screenshot`: render or capture to a file, return it as an MCP image content block.
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> {
if let Some(v) = str_arg(a, "view") {
b.call("ui.view", json!({"view": v}))?;
// Turning to a standard view also frames the model (like a view cube click).
if v != "fit" && v != "home" {
b.call("ui.view", json!({"view": "fit"}))?;
}
}
let keep = str_arg(a, "path").filter(|p| !p.trim().is_empty()).map(str::to_string);
let path = keep.clone().unwrap_or_else(|| {
let n = SHOT.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!("solvecraft-mcp-{}-{n}.png", std::process::id())).to_string_lossy().to_string()
});
let window = match str_arg(a, "source") {
Some("model") => false,
Some("window") => true,
_ => b.has_ui(),
};
let size = |k: &str, d: u64| a.get(k).and_then(Value::as_u64).unwrap_or(d).clamp(16, 4096);
let render = |b: &mut dyn Backend| b.call("ui.render", json!({"path": path, "width": size("width", 960), "height": size("height", 600)}));
let (info, source) = if window {
match b.call("ui.screenshot", json!({"path": path})) {
Ok(v) => (v, "window"),
// No presented frame (minimised or hidden window): fall back to a model render.
Err(_) => (render(b)?, "model"),
}
} else {
(render(b)?, "model")
};
let read = std::fs::metadata(&path)
.map_err(|e| format!("{path}: {e}"))
.and_then(|m| if m.len() > MAX_IMAGE_BYTES { Err(format!("{path}: image too large")) } else { Ok(()) })
.and_then(|()| std::fs::read(&path).map_err(|e| format!("{path}: {e} (is the app on another machine?)")));
if keep.is_none() {
let _ = std::fs::remove_file(&path);
}
let png = read?;
let (w, h) = match source {
"model" => (json!(size("width", 960)), json!(size("height", 600))),
_ => (info.get("width").cloned().unwrap_or(Value::Null), info.get("height").cloned().unwrap_or(Value::Null)),
};
let bytes = png.len();
Ok((png, json!({"source": source, "path": keep, "width": w, "height": h, "bytes": bytes})))
}
/// 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() {
if i <= chunk.len() {
out.push(char::from(T[((n >> shift) & 63) as usize]));
} else {
out.push('=');
}
}
}
out
}
+1
View File
@@ -43,6 +43,7 @@ pub const TABLE: &[(&str, Class)] = &[
("io", Class::Layer(3)),
("engine", Class::Layer(4)),
("ui-egui", Class::Layer(5)),
("mcp", Class::Layer(5)),
("testkit", Class::Testkit),
// apps and tooling
("solvecraft", Class::Exempt),