From d1ff23990e95909b836b1b3c6bc138326678f536 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 7 Oct 2026 19:22:58 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- Cargo.lock | 9 + Cargo.toml | 1 + crates/mcp/Cargo.toml | 13 ++ crates/mcp/src/backend.rs | 173 ++++++++++++++ crates/mcp/src/lib.rs | 21 ++ crates/mcp/src/server.rs | 170 ++++++++++++++ crates/mcp/src/tests.rs | 315 +++++++++++++++++++++++++ crates/mcp/src/tools.rs | 481 ++++++++++++++++++++++++++++++++++++++ xtask/src/layers.rs | 1 + 9 files changed, 1184 insertions(+) create mode 100644 crates/mcp/Cargo.toml create mode 100644 crates/mcp/src/backend.rs create mode 100644 crates/mcp/src/lib.rs create mode 100644 crates/mcp/src/server.rs create mode 100644 crates/mcp/src/tests.rs create mode 100644 crates/mcp/src/tools.rs diff --git a/Cargo.lock b/Cargo.lock index cec49f72..ac5a619b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 0defcac1..5b4a6bba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml new file mode 100644 index 00000000..bfaaf378 --- /dev/null +++ b/crates/mcp/Cargo.toml @@ -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 } diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs new file mode 100644 index 00000000..98c67401 --- /dev/null +++ b/crates/mcp/src/backend.rs @@ -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; + /// 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)>, + next_id: u64, +} + +impl Remote { + pub fn connect(addr: &str) -> std::io::Result { + 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 { + 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 { + 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 { + 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, ¶ms).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() + } +} diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs new file mode 100644 index 00000000..3fdc4740 --- /dev/null +++ b/crates/mcp/src/lib.rs @@ -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; diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs new file mode 100644 index 00000000..c96defdb --- /dev/null +++ b/crates/mcp/src/server.rs @@ -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, + 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) -> Value { + json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}}) +} + +impl Server { + pub fn new(backend: Box) -> 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 { + 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::(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 = 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 { + 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, ¶ms) { + Ok(r) => response(id, r), + Err((code, m)) => error(id, code, m), + }) + } + + fn request(&mut self, method: &str, params: &Value) -> Result { + 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"))), + } + } +} diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs new file mode 100644 index 00000000..76798e31 --- /dev/null +++ b/crates/mcp/src/tests.rs @@ -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::(&both).unwrap().as_array().unwrap().len(), 1); +} + +fn hostile_values() -> Vec { + 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 = 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); + } +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs new file mode 100644 index 00000000..365ee14e --- /dev/null +++ b/crates/mcp/src/tools.rs @@ -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 { + 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 { + b.call("engine.execute", json!({"command": command, "params": params})) +} + +fn pick(a: &Map, 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, k: &str) -> Option<&'a str> { + a.get(k).and_then(Value::as_str) +} + +fn dispatch(b: &mut dyn Backend, name: &str, a: &Map) -> Result { + 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 = d["sketches"].as_array().map(|v| v.iter().map(|s| s["id"].clone()).collect()).unwrap_or_default(); + let full: Vec = 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 { + 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) -> 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) -> 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) -> Result<(Vec, 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 +} diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 7d1252d4..c381629e 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -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),