forked from 1b-agent/fieldcraft-upstream
* io: read and write roots that confine an automation client's file access Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * engine: every file a command reads or writes is checked against the session's roots Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * engine: headless pictures of a session (model, geometry, deformed shape and contours) Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * mcp: MCP server over stdio with headless and connected backends, roots and caps Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * cli: modelcraft-cli mcp [--connect ADDR | --headless] with automation read and write roots Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * mcp: end-to-end analysis of a corpus part over MCP (open, tet mesh, define, solve, probe, render) Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: MCP server (docs/mcp.md), Claude Code setup, roots; architecture, README, roadmap, attribution Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm --------- Co-authored-by: jyviko <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
270 lines
14 KiB
Rust
270 lines
14 KiB
Rust
//! The protocol and the tools driven through JSON-RPC lines, as a client sends them, with
|
|
//! hostile input: malformed messages, huge parameters, paths outside the roots, unknown tools.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use modelcraft_io::roots::Roots;
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::{Headless, MAX_LINE, Options, Server};
|
|
|
|
fn server() -> Server {
|
|
Server::new(Box::new(Headless::default()))
|
|
}
|
|
|
|
fn confined(read: &Path, write: &Path) -> Server {
|
|
Server::new(Box::new(Headless::new(Roots::new(Some(read), Some(write)).unwrap())))
|
|
}
|
|
|
|
fn send(s: &mut Server, msg: Value) -> Value {
|
|
serde_json::from_str(&s.handle_line(&msg.to_string()).unwrap()).unwrap()
|
|
}
|
|
|
|
/// A tool call: (text of the first content block, is_error).
|
|
fn call(s: &mut Server, name: &str, args: Value) -> (String, bool) {
|
|
let r = send(s, json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": args}}));
|
|
let result = &r["result"];
|
|
assert!(result.is_object(), "{name}: {r}");
|
|
let text = result["content"].as_array().unwrap().iter().find(|c| c["type"] == "text").map(|c| c["text"].as_str().unwrap().to_string());
|
|
(text.unwrap_or_default(), result["isError"] == json!(true))
|
|
}
|
|
|
|
fn ok(s: &mut Server, name: &str, args: Value) -> Value {
|
|
let (text, err) = call(s, name, args.clone());
|
|
assert!(!err, "{name} {args}: {text}");
|
|
serde_json::from_str(&text).unwrap_or(Value::String(text))
|
|
}
|
|
|
|
struct Dir(PathBuf);
|
|
impl Dir {
|
|
fn new(tag: &str) -> Dir {
|
|
let d = std::env::temp_dir().join(format!("modelcraft-mcp-{tag}-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&d);
|
|
std::fs::create_dir_all(&d).unwrap();
|
|
Dir(std::fs::canonicalize(&d).unwrap())
|
|
}
|
|
}
|
|
impl Drop for Dir {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn initialize_lists_tools_and_resources() {
|
|
let mut s = server();
|
|
let r = send(&mut s, json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-03-26", "capabilities": {}}}));
|
|
assert_eq!(r["result"]["protocolVersion"], "2025-03-26");
|
|
assert_eq!(r["result"]["serverInfo"]["name"], "modelcraft");
|
|
assert!(r["result"]["instructions"].as_str().unwrap().contains("headless"));
|
|
let r = send(&mut s, json!({"jsonrpc": "2.0", "id": 2, "method": "initialize", "params": {"protocolVersion": "1999-01-01"}}));
|
|
assert_eq!(r["result"]["protocolVersion"], crate::PROTOCOL_VERSION);
|
|
assert!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#).is_none());
|
|
assert!(s.is_initialized());
|
|
let tools = send(&mut s, json!({"jsonrpc": "2.0", "id": 3, "method": "tools/list"}));
|
|
let names: Vec<&str> = tools["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect();
|
|
for t in [
|
|
"list_commands",
|
|
"execute",
|
|
"batch",
|
|
"inspect_model",
|
|
"check_model",
|
|
"open",
|
|
"save",
|
|
"write_deck",
|
|
"mesh",
|
|
"solve",
|
|
"show_results",
|
|
"probe",
|
|
"reactions",
|
|
"screenshot",
|
|
"undo",
|
|
"redo",
|
|
] {
|
|
assert!(names.contains(&t), "{t}");
|
|
}
|
|
let r = send(&mut s, json!({"jsonrpc": "2.0", "id": 4, "method": "resources/read", "params": {"uri": "modelcraft://model"}}));
|
|
assert!(r["result"]["contents"][0]["text"].as_str().unwrap().contains("nodes"));
|
|
let r = send(&mut s, json!({"jsonrpc": "2.0", "id": 5, "method": "resources/read", "params": {"uri": "modelcraft://commands"}}));
|
|
assert!(r["result"]["contents"][0]["text"].as_str().unwrap().contains("solve.static"));
|
|
assert_eq!(send(&mut s, json!({"jsonrpc": "2.0", "id": 6, "method": "ping"}))["result"], json!({}));
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_messages_get_json_rpc_errors() {
|
|
let mut s = server();
|
|
let code = |s: &mut Server, line: &str| -> i64 {
|
|
let r: Value = serde_json::from_str(&s.handle_line(line).unwrap()).unwrap();
|
|
r["error"]["code"].as_i64().unwrap()
|
|
};
|
|
assert_eq!(code(&mut s, "{not json"), -32700);
|
|
assert_eq!(code(&mut s, "\"just a string\""), -32600);
|
|
assert_eq!(code(&mut s, "42"), -32600);
|
|
assert_eq!(code(&mut s, "[]"), -32600);
|
|
assert_eq!(code(&mut s, &format!("[{}]", vec!["{}"; 1000].join(","))), -32600);
|
|
assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":1}"#), -32600);
|
|
assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":{"a":1},"method":"ping"}"#), -32600);
|
|
assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"nope"}"#), -32601);
|
|
assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{}}"#), -32602);
|
|
assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"rm_rf"}}"#), -32602);
|
|
assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute","arguments":[]}}"#), -32602);
|
|
assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute","arguments":{}}}"#), -32602);
|
|
assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute","arguments":{"command":7}}}"#), -32602);
|
|
assert_eq!(
|
|
code(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"execute","arguments":{"command":"x","extra":1}}}"#),
|
|
-32602
|
|
);
|
|
assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"mesh","arguments":{"method":"voodoo"}}}"#), -32602);
|
|
assert_eq!(code(&mut s, r#"{"jsonrpc":"2.0","id":1,"method":"resources/read","params":{"uri":"file:///etc/passwd"}}"#), -32002);
|
|
// Deeply nested JSON is refused by the parser, not by a stack overflow.
|
|
assert_eq!(code(&mut s, &format!("{}{}", "[".repeat(100_000), "]".repeat(100_000))), -32700);
|
|
// Notifications and responses get no reply; a batch replies to its requests only.
|
|
assert!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}"#).is_none());
|
|
assert!(s.handle_line(r#"{"jsonrpc":"2.0","id":9,"result":{}}"#).is_none());
|
|
let r: Value =
|
|
serde_json::from_str(&s.handle_line(r#"[{"jsonrpc":"2.0","id":1,"method":"ping"},{"jsonrpc":"2.0","method":"x"}]"#).unwrap()).unwrap();
|
|
assert_eq!(r.as_array().unwrap().len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn serve_survives_huge_lines_and_bad_bytes() {
|
|
let mut s = server();
|
|
let mut input = Vec::new();
|
|
input.extend(br#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#);
|
|
input.push(b'\n');
|
|
input.extend(br#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"execute","arguments":{"command":"model.info","params":{"x":""#);
|
|
input.extend(vec![b'a'; MAX_LINE + 10]);
|
|
input.extend(b"\"}}}}\n");
|
|
input.extend([0xff, 0xfe, b'\n']);
|
|
input.extend(br#"{"jsonrpc":"2.0","id":3,"method":"ping"}"#);
|
|
input.push(b'\n');
|
|
let mut out = Vec::new();
|
|
s.serve(input.as_slice(), &mut out).unwrap();
|
|
let replies: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
|
assert_eq!(replies.len(), 4, "{replies:?}");
|
|
assert_eq!(replies[0]["id"], 1);
|
|
assert!(replies[1]["error"]["message"].as_str().unwrap().contains("too large"));
|
|
assert_eq!(replies[2]["error"]["code"], -32700);
|
|
assert_eq!(replies[3]["id"], 3, "the server keeps serving");
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_tool_arguments_are_tool_errors() {
|
|
let mut s = server();
|
|
let huge = "x".repeat(1 << 20);
|
|
for (tool, args) in [
|
|
("execute", json!({"command": "nope.nothing"})),
|
|
("execute", json!({"command": huge})),
|
|
("execute", json!({"command": "mesh.node.add", "params": {"label": -1, "xyz": [1e308, null, "a"]}})),
|
|
("execute", json!({"command": "mesh.element.add", "params": {"label": 1, "kind": "hex20", "nodes": vec![u32::MAX; 20]}})),
|
|
("execute", json!({"command": "file.open", "params": {"path": "\u{0}"}})),
|
|
("open", json!({"path": "/etc/passwd"})),
|
|
("open", json!({"path": "/no/such/model.modelcraft"})),
|
|
("open", json!({"path": huge.clone() + ".solidcraft"})),
|
|
("save", json!({})),
|
|
("write_deck", json!({"format": "abaqus", "path": "/no/such/dir/x.inp"})),
|
|
("mesh", json!({"method": "tet", "params": {"body": "Nope", "tolerance": 0.5}})),
|
|
("mesh", json!({"method": "tet", "params": {"surface": 1e300}})),
|
|
("solve", json!({"analysis": "static"})),
|
|
("solve", json!({"analysis": "frequency", "step": "Nope", "options": {"max_dofs": -1}})),
|
|
("show_results", json!({})),
|
|
("show_results", json!({"bands": 5})),
|
|
("show_results", json!({"field": "von_mises"})),
|
|
("probe", json!({"nodes": [1, 2, 3]})),
|
|
("reactions", json!({"sets": ["nope"], "about": [1e308, 0, 0]})),
|
|
("screenshot", json!({"view": "iso", "path": "/no/such/dir/x.png"})),
|
|
("batch", json!({"commands": [{"command": "model.info"}, {"params": {}}, {"command": "model.info"}]})),
|
|
("batch", json!({"commands": vec![json!({"command": "model.info"}); crate::tools::MAX_STEPS + 1]})),
|
|
("undo", json!({})),
|
|
("redo", json!({})),
|
|
] {
|
|
let (text, err) = call(&mut s, tool, args.clone());
|
|
assert!(err, "{tool} should fail: {text}");
|
|
assert!(text.len() < 100_000, "{tool}: errors stay short");
|
|
}
|
|
// Nothing changed on the way.
|
|
assert_eq!(ok(&mut s, "inspect_model", json!({}))["model"]["nodes"], 0);
|
|
// A huge but valid image size is clamped, not refused or allocated.
|
|
let r = send(
|
|
&mut s,
|
|
json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "screenshot", "arguments": {"width": u64::MAX, "height": 16, "source": "model"}}}),
|
|
);
|
|
assert_eq!(r["result"]["content"][0]["type"], "image", "{r}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_batch_stops_at_the_first_error() {
|
|
let mut s = server();
|
|
let r = call(
|
|
&mut s,
|
|
"batch",
|
|
json!({"commands": [
|
|
{"command": "mesh.node.add", "params": {"label": 1, "xyz": [0, 0, 0]}},
|
|
{"command": "mesh.node.add", "params": {"label": 0, "xyz": [0, 0, 0]}},
|
|
{"command": "mesh.node.add", "params": {"label": 2, "xyz": [0, 0, 0]}},
|
|
]}),
|
|
);
|
|
assert!(r.1);
|
|
let v: Value = serde_json::from_str(&r.0).unwrap();
|
|
assert_eq!((v["completed"].clone(), v["failed"]["index"].clone()), (json!(1), json!(1)));
|
|
assert_eq!(ok(&mut s, "inspect_model", json!({}))["model"]["nodes"], 1);
|
|
ok(&mut s, "undo", json!({}));
|
|
assert_eq!(ok(&mut s, "inspect_model", json!({}))["model"]["nodes"], 0);
|
|
let listed = ok(&mut s, "list_commands", json!({"filter": "SOLVE."}));
|
|
assert!(listed.as_array().unwrap().iter().all(|c| c["id"].as_str().unwrap().starts_with("solve.")));
|
|
}
|
|
|
|
#[test]
|
|
fn file_access_stays_inside_the_roots() {
|
|
let d = Dir::new("roots");
|
|
let (input, output, outside) = (d.0.join("in"), d.0.join("out"), d.0.join("outside"));
|
|
for f in [&input, &output, &outside] {
|
|
std::fs::create_dir_all(f).unwrap();
|
|
}
|
|
let design = modelcraft_testkit::corpus::Analytic::Cylinder { radius: 5.0, height: 10.0 }.design().unwrap();
|
|
std::fs::write(outside.join("c.solidcraft"), &design).unwrap();
|
|
std::fs::write(input.join("c.solidcraft"), &design).unwrap();
|
|
#[cfg(unix)]
|
|
std::os::unix::fs::symlink(outside.join("c.solidcraft"), input.join("link.solidcraft")).unwrap();
|
|
let mut s = confined(&input, &output);
|
|
let refused = |s: &mut Server, tool: &str, args: Value| {
|
|
let (text, err) = call(s, tool, args.clone());
|
|
assert!(err && text.contains("automation"), "{tool} {args}: {text}");
|
|
};
|
|
refused(&mut s, "open", json!({"path": outside.join("c.solidcraft")}));
|
|
#[cfg(unix)]
|
|
refused(&mut s, "open", json!({"path": input.join("link.solidcraft")}));
|
|
refused(&mut s, "execute", json!({"command": "geometry.open", "params": {"path": input.join("../outside/c.solidcraft")}}));
|
|
ok(&mut s, "open", json!({"path": input.join("c.solidcraft")}));
|
|
refused(&mut s, "save", json!({"path": outside.join("m.modelcraft")}));
|
|
refused(&mut s, "save", json!({"path": input.join("m.modelcraft")}));
|
|
refused(&mut s, "execute", json!({"command": "file.save_as", "params": {"path": outside.join("m.modelcraft")}}));
|
|
refused(&mut s, "write_deck", json!({"format": "abaqus", "path": outside.join("m.inp")}));
|
|
refused(&mut s, "screenshot", json!({"path": outside.join("x.png")}));
|
|
refused(&mut s, "screenshot", json!({"path": input.join("x.png")}));
|
|
refused(&mut s, "execute", json!({"command": "file.recovery.list", "params": {"dir": outside}}));
|
|
ok(&mut s, "save", json!({"path": output.join("m.modelcraft")}));
|
|
ok(&mut s, "screenshot", json!({"path": output.join("x.png")}));
|
|
assert!(output.join("m.modelcraft").is_file() && output.join("x.png").is_file());
|
|
assert_eq!(std::fs::read_dir(&outside).unwrap().count(), 1, "nothing written outside");
|
|
}
|
|
|
|
#[test]
|
|
fn roots_imply_headless() {
|
|
let d = Dir::new("connect");
|
|
let options = Options { connect: Some("127.0.0.1:1".into()), roots: Roots::new(Some(&d.0), None).unwrap() };
|
|
assert!(crate::backend(options).err().unwrap().contains("cannot be combined"));
|
|
// An app that cannot be reached: headless, with a note.
|
|
let (b, note) = crate::backend(Options { connect: Some("127.0.0.1:1".into()), roots: Roots::default() }).unwrap();
|
|
assert!(note.unwrap().contains("running headless"));
|
|
assert!(b.describe().contains("headless"));
|
|
}
|
|
|
|
#[test]
|
|
fn base64_matches_the_rfc_vectors() {
|
|
use crate::tools::base64;
|
|
for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")] {
|
|
assert_eq!(base64(i.as_bytes()), o);
|
|
}
|
|
}
|