Files
vectorcraft/crates/mcp/src/tests_fileio.rs
T
dotCooCoo 538c7c2d07 MCP: headless file tools pass relative paths to the engine as given
Since #3971, open_file, save_file, export and screenshot made every path
absolute against the server's working directory, headless as well as
with --connect. Headless, the engine runs in the server's process and
resolves a relative path against the same directory, so the conversion
is only needed for a connected app. It also made a relative path fail
headless when the absolute path is not UTF-8.

The server now makes these paths absolute only for a connected app, as
docs/mcp.md describes. Headless, it sends them as given.
2026-10-24 20:51:25 -07:00

378 lines
18 KiB
Rust

//! File I/O through MCP: every readable format opens headless, `export` sends one
//! `document.export` call whatever the backend, and `save_file` one `app.save` call.
use std::io::{BufRead, BufReader, Write};
use serde_json::{Value, json};
use crate::{Backend, Headless, Remote, Server, call_tool, tool_definitions};
fn tmp(name: &str) -> String {
let dir = std::env::temp_dir().join(format!("vectorcraft-mcp-fileio-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join(name).to_string_lossy().to_string()
}
fn text(r: &crate::ToolResult) -> Value {
assert!(!r.is_error, "{r:?}");
serde_json::from_str(r.content[0]["text"].as_str().unwrap()).unwrap()
}
/// A headless session with two artboards of different widths (100 and 40 pt) and a rectangle.
fn two_boards() -> Headless {
let mut h = Headless::new();
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 100, "height": 50, "artboards": 2}})).unwrap();
h.call("engine.execute", json!({"command": "artboard.setProps", "params": {"index": 1, "width": 40}})).unwrap();
h.call("engine.execute", json!({"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 20, "height": 20}})).unwrap();
h
}
#[test]
fn headless_opens_pdf_ai_and_templates() {
let mut h = two_boards();
let pdf = h.call("engine.execute", json!({"command": "document.serialize", "params": {"format": "pdf"}})).unwrap();
let pdf = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap();
for name in ["x.pdf", "x.ai"] {
let path = tmp(name);
std::fs::write(&path, &pdf).unwrap();
let r = text(&call_tool(&mut h, "open_file", &json!({"path": path})));
assert_eq!(r["title"], name);
assert_eq!(h.session.doc().unwrap().doc.artboards.len(), 2, "one artboard per page");
}
let ait = tmp("x.ait");
std::fs::write(&ait, &pdf).unwrap();
let r = text(&call_tool(&mut h, "open_file", &json!({"path": ait})));
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
let saved = h.call("engine.execute", json!({"command": "file.saveAsTemplate", "params": {"path": tmp("t.vectorcraft")}})).unwrap();
let tpl = tmp("t.vctemplate");
assert_eq!(saved["path"], tpl.as_str(), "saved with the template's extension: {saved}");
let r = text(&call_tool(&mut h, "open_file", &json!({"path": tpl})));
assert!(r["title"].as_str().unwrap().starts_with("Untitled-"), "{r}");
assert_eq!(h.session.doc().unwrap().path, None, "Save asks for a new name");
}
#[test]
fn export_tool_lists_engine_formats_and_options() {
let tools = tool_definitions();
let export = tools.iter().find(|t| t["name"] == "export").unwrap();
let props = &export["inputSchema"]["properties"];
assert_eq!(
props["format"]["enum"],
json!([
"vectorcraft",
"svg",
"svgz",
"pdf",
"ai",
"png",
"jpg",
"gif",
"webp",
"tiff",
"bmp",
"template",
"png8",
"txt",
"dxf",
"eps",
"emf",
"wmf",
"tga",
"psd"
])
);
for k in ["artboard", "range", "options"] {
assert!(props.get(k).is_some(), "export takes {k}");
}
// The description names every format of the enum.
let words: Vec<&str> = export["description"].as_str().unwrap().split(|c: char| !c.is_ascii_alphanumeric()).collect();
for f in props["format"]["enum"].as_array().unwrap() {
assert!(words.contains(&f.as_str().unwrap()), "{f}");
}
let open = tools.iter().find(|t| t["name"] == "open_file").unwrap();
assert!(open["description"].as_str().unwrap().contains(".ait"));
assert!(open["description"].as_str().unwrap().contains(".eps, .epsf, .ps, "), "{open}");
assert!(open["description"].as_str().unwrap().contains(".bmp, .tga, .vda, .icb, .vst, "), "{open}");
}
/// A fake app that records `engine.execute` calls and answers each with an empty object.
fn recording_app() -> (String, std::thread::JoinHandle<Vec<Value>>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
let h = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut out = stream.try_clone().unwrap();
let mut seen = vec![];
for line in BufReader::new(stream).lines() {
let msg: Value = serde_json::from_str(&line.unwrap()).unwrap();
writeln!(out, "{}", json!({"id": msg["id"], "ok": true, "result": {}})).unwrap();
seen.push(msg);
}
seen
});
(addr, h)
}
#[test]
fn export_is_the_same_call_in_the_app_and_headless() {
let args = json!({"format": "png", "artboard": 1, "options": {"scale": 2, "artboard": 0, "range": "1-2"}});
let (addr, app) = recording_app();
let mut remote = Remote::connect(&addr).unwrap();
assert!(!call_tool(&mut remote, "export", &args).is_error);
drop(remote);
let seen = app.join().unwrap();
assert_eq!(seen[0]["method"], "engine.execute");
assert_eq!(seen[0]["params"], json!({"command": "document.export", "params": {"format": "png", "artboard": 1, "scale": 2}}));
// Headless runs that very call: artboard 1 (40 pt wide) at scale 2.
let mut h = two_boards();
let r = text(&call_tool(&mut h, "export", &args));
let png = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap();
assert_eq!(&png[16..20], 80u32.to_be_bytes(), "IHDR width");
let direct = h.call("engine.execute", seen[0]["params"].clone()).unwrap();
assert_eq!(direct["dataBase64"], r["dataBase64"]);
}
#[test]
fn the_raster_scale_leaves_other_formats_alone() {
// DXF's own `scale` (drawing units per mm) comes through `options`; the raster one would resize
// the drawing and leave its units unset.
let mut h = two_boards();
let dxf = |h: &mut Headless, args: Value| text(&call_tool(h, "export", &args))["dataBase64"].clone();
let plain = dxf(&mut h, json!({"format": "dxf"}));
assert!(dxf(&mut h, json!({"format": "dxf", "scale": 2})) == plain, "the raster scale changed the DXF");
assert!(dxf(&mut h, json!({"format": "dxf", "options": {"scale": 2}})) != plain, "the DXF scale was dropped");
}
#[test]
fn exporting_native_keeps_the_document_path() {
let mut h = two_boards();
let doc = tmp("keep.vectorcraft");
h.call("app.save", json!({"path": doc})).unwrap();
let copy = tmp("copy.vectorcraft");
let r = text(&call_tool(&mut h, "export", &json!({"path": copy})));
assert_eq!(r["format"], "vectorcraft");
assert_eq!(h.session.doc().unwrap().path.as_deref(), Some(doc.as_str()));
// PDF range through the tool: one page.
let pdf = text(&call_tool(&mut h, "export", &json!({"format": "pdf", "range": "2"})));
let pdf = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap();
assert_eq!(pdf_pages(&pdf), 1);
// An .ai export holds every artboard by default and one artboard with a range, and the document keeps its path.
for (args, pages) in [(json!({"format": "ai"}), 2), (json!({"format": "ai", "range": "2"}), 1)] {
let ai = text(&call_tool(&mut h, "export", &args));
assert_eq!(ai["format"], "ai");
assert_eq!(pdf_pages(&vectorcraft_format::base64_decode(ai["dataBase64"].as_str().unwrap()).unwrap()), pages, "{args}");
}
assert_eq!(h.session.doc().unwrap().path.as_deref(), Some(doc.as_str()));
}
/// The bytes `save_file` writes to `name` with `args`.
fn saved_with(h: &mut Headless, name: &str, mut args: Value) -> Vec<u8> {
let path = tmp(name);
args["path"] = json!(path);
let r = text(&call_tool(h, "save_file", &args));
assert_eq!(r["format"], "vectorcraft", "{r}");
std::fs::read(&path).unwrap()
}
#[test]
fn save_file_takes_the_native_save_options() {
let mut h = two_boards();
let gz = saved_with(&mut h, "gz.vectorcraft", json!({"compress": true}));
assert_eq!(gz[..2], [0x1f, 0x8b], "gzipped");
let plain = saved_with(&mut h, "plain.vectorcraft", json!({"compress": false}));
assert_eq!(plain[0], b'{', "plain JSON");
let nodes = h.session.doc().unwrap().doc.node_count();
for name in ["gz.vectorcraft", "plain.vectorcraft"] {
let mut back = Headless::new();
text(&call_tool(&mut back, "open_file", &json!({"path": tmp(name)})));
assert_eq!(back.session.doc().unwrap().doc.node_count(), nodes, "{name} reopens");
}
let opt = saved_with(&mut h, "opt.vectorcraft", json!({"options": {"compress": true}}));
assert_eq!(opt[..2], [0x1f, 0x8b], "`options` carries them too");
let v3: Value = serde_json::from_slice(&saved_with(&mut h, "v3.vectorcraft", json!({"version": 3, "preview": null}))).unwrap();
assert_eq!(v3["version"], 3);
let shown = saved_with(&mut h, "preview.vectorcraft", json!({"preview": true}));
assert!(vectorcraft_format::preview(&shown).is_some(), "a preview");
assert!(vectorcraft_format::preview(&plain).is_none(), "off by default");
// Version 2 files can't be compressed: the engine's error comes back.
let r = call_tool(&mut h, "save_file", &json!({"path": tmp("v2.vectorcraft"), "version": 2, "compress": true}));
assert!(r.is_error && r.content[0]["text"].as_str().unwrap().contains("can't be compressed"), "{r:?}");
// The options are in the schema; a misspelled one is still refused.
let mut s = Server::new(Box::new(Headless::with_document()));
let call = |s: &mut Server, args: Value| {
s.handle(json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "save_file", "arguments": args}})).unwrap()
};
let r = call(&mut s, json!({"path": tmp("ok.vectorcraft"), "compress": true}));
assert_eq!(r["result"]["isError"], false, "{r}");
let r = call(&mut s, json!({"path": tmp("typo.vectorcraft"), "compresss": true}));
assert_eq!(r["error"]["code"], -32602, "{r}");
assert!(r["error"]["message"].as_str().unwrap().contains("unknown argument `compresss`"), "{r}");
}
/// `warnings` name each native save option the written format doesn't read, and a top-level option
/// that disagrees with the same key in `options` is an error.
#[test]
fn save_file_warns_about_options_it_does_not_use() {
let mut h = two_boards();
let warnings = |r: &Value| -> Vec<String> { r["warnings"].as_array().unwrap().iter().map(|w| w.as_str().unwrap().to_string()).collect() };
let ignored = |w: &[String], key: &str| w.iter().any(|w| w.starts_with(&format!("`{key}` was not used")));
// SVG reads none of them, at the top level or in `options`.
let path = tmp("ignored.svg");
let r = text(&call_tool(&mut h, "save_file", &json!({"path": path, "compress": true, "version": 3, "options": {"preview": true}})));
assert_eq!(r["format"], "svg", "{r}");
let w = warnings(&r);
assert!(ignored(&w, "compress") && ignored(&w, "version") && ignored(&w, "options.preview"), "{w:?}");
assert_eq!(w.iter().filter(|w| w.starts_with("`compress`")).count(), 1, "said once: {w:?}");
assert!(w.iter().any(|w| w.contains("SVG")), "names the format: {w:?}");
assert!(std::fs::read(&path).unwrap().starts_with(b"<"), "plain SVG");
// A .ai file reads `compress` but not `version`.
let r = text(&call_tool(&mut h, "save_file", &json!({"path": tmp("ignored.ai"), "compress": false, "version": 3})));
let w = warnings(&r);
assert!(ignored(&w, "version") && !ignored(&w, "compress"), "{w:?}");
// A top-level value that disagrees with `options` is refused, and nothing is written.
let path = tmp("both.vectorcraft");
let _ = std::fs::remove_file(&path);
let r = call_tool(&mut h, "save_file", &json!({"path": path, "compress": true, "options": {"compress": false}}));
let e = r.content[0]["text"].as_str().unwrap();
assert!(r.is_error && e.contains("`compress: true` and `options.compress: false` disagree"), "{r:?}");
assert!(!std::path::Path::new(&path).exists(), "nothing is written");
// Nothing to say when they agree or when every option applies.
for mut args in [json!({"compress": true, "options": {"compress": true}}), json!({"compress": false, "version": 3, "preview": true})] {
args["path"] = json!(tmp("quiet.vectorcraft"));
let r = text(&call_tool(&mut h, "save_file", &args));
assert_eq!(r["warnings"], json!([]), "{args}: {r}");
}
// The app's save warns about the top-level options itself; the reply gets the notes on
// `options`, after the warnings the app sent.
struct App;
impl Backend for App {
fn call(&mut self, _: &str, _: Value) -> Result<Value, String> {
Ok(json!({"path": "/out/a.svg", "format": "svg", "warnings": ["the app's own warning"]}))
}
fn has_ui(&self) -> bool {
true
}
fn describe(&self) -> String {
"app".into()
}
}
let r = text(&call_tool(&mut App, "save_file", &json!({"path": "/out/a.svg", "compress": true, "options": {"preview": true}})));
let w = warnings(&r);
assert_eq!(w.len(), 2, "{w:?}");
assert!(w[0] == "the app's own warning" && ignored(&w[1..], "options.preview"), "{w:?}");
}
#[test]
fn save_file_is_the_same_call_in_the_app_and_headless() {
let path = tmp("app.vectorcraft");
let (addr, app) = recording_app();
let mut remote = Remote::connect(&addr).unwrap();
let args = json!({"path": path, "compress": true, "version": 3, "preview": null, "options": {"preview": true}});
assert!(!call_tool(&mut remote, "save_file", &args).is_error);
drop(remote);
let seen = app.join().unwrap();
assert_eq!(seen[0]["method"], "app.save");
assert_eq!(seen[0]["params"], json!({"path": path, "compress": true, "version": 3, "options": {"preview": true}, "wait": true}));
}
/// The app is asked to write the file before it replies (`wait`), even with Background Save on, so a
/// save that fails is the call's error; a headless session always does, and is sent no `wait`.
#[test]
fn save_file_waits_for_the_file_in_the_app() {
struct Recording {
ui: bool,
seen: Vec<(String, Value)>,
}
impl Backend for Recording {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
self.seen.push((method.to_string(), params));
if self.ui { Err("Saving a.vectorcraft failed: disk full".into()) } else { Ok(json!({})) }
}
fn has_ui(&self) -> bool {
self.ui
}
fn describe(&self) -> String {
"recording".into()
}
}
for ui in [true, false] {
let mut b = Recording { ui, seen: vec![] };
let r = call_tool(&mut b, "save_file", &json!({"path": "/out/a.vectorcraft"}));
assert_eq!(r.is_error, ui, "the app's error is the call's: {r:?}");
let [(method, params)] = &b.seen[..] else { panic!("one call: {:?}", b.seen) };
assert_eq!(method, "app.save");
// The tool sends the app the path made absolute (on Windows, against the current drive), and
// a headless session the path as given.
let path = if ui { std::path::absolute("/out/a.vectorcraft").unwrap().to_string_lossy().into_owned() } else { "/out/a.vectorcraft".into() };
let mut want = json!({"path": path});
if ui {
want["wait"] = json!(true);
}
assert_eq!(params, &want, "has_ui: {ui}");
}
}
fn pdf_pages(pdf: &[u8]) -> usize {
let mut h = Headless::new();
h.call("engine.execute", json!({"command": "document.open", "params": {"name": "p.pdf", "dataBase64": vectorcraft_format::base64_encode(pdf)}}))
.unwrap();
h.session.doc().unwrap().doc.artboards.len()
}
/// Issue #421 over MCP: RGB greys switched to CMYK with `grays: "black"` print on the black plate
/// only, in the document and in the exported PDF.
#[test]
fn document_color_mode_puts_rgb_greys_on_the_black_plate() {
let svg = tmp("greys.svg");
std::fs::write(
&svg,
r##"<svg xmlns="http://www.w3.org/2000/svg" width="200pt" height="50pt" viewBox="0 0 200 50">
<rect x="0" y="0" width="50" height="50" fill="#000000"/><rect x="50" y="0" width="50" height="50" fill="#333333"/>
<rect x="100" y="0" width="50" height="50" fill="#808080"/><rect x="150" y="0" width="50" height="50" fill="#e6e6e6"/>
</svg>"##,
)
.unwrap();
let want = [1.0, 0.8, 0.498, 0.098];
let k_only = |h: &Headless| -> Vec<f32> {
let mut ks = vec![];
for l in &h.session.doc().unwrap().doc.layers {
l.walk(&mut |n| {
if n.children().is_none()
&& let Some(c) = n.appearance.fill_paint().color()
{
let v = serde_json::to_value(c).unwrap();
assert!(v["model"] == "cmyk" && v["c"] == 0.0 && v["m"] == 0.0 && v["y"] == 0.0, "not K only: {v}");
ks.push(v["k"].as_f64().unwrap() as f32);
}
});
}
ks
};
let close = |ks: &[f32]| ks.len() == want.len() && ks.iter().zip(want).all(|(k, w)| (k - w).abs() < 0.005);
let mut h = Headless::new();
text(&call_tool(&mut h, "open_file", &json!({"path": svg})));
let r = text(&call_tool(&mut h, "run_command", &json!({"command": "file.documentColorMode", "params": {"mode": "cmyk", "grays": "black"}})));
assert_eq!(r["changed"], 4);
let ks = k_only(&h);
assert!(close(&ks), "{ks:?}");
// The PDF carries the same inks (DeviceCMYK opens as CMYK).
let pdf = text(&call_tool(&mut h, "export", &json!({"format": "pdf"})));
let mut back = Headless::new();
back.call("engine.execute", json!({"command": "document.open", "params": {"name": "greys.pdf", "dataBase64": pdf["dataBase64"]}})).unwrap();
let ks = k_only(&back);
assert!(close(&ks), "{ks:?}");
// document.open takes the same option.
let mut o = Headless::new();
let r = call_tool(&mut o, "run_command", &json!({"command": "document.open", "params": {"path": svg, "colorMode": "cmyk", "grays": "black"}}));
text(&r);
let ks = k_only(&o);
assert!(close(&ks), "{ks:?}");
let bad = call_tool(&mut o, "run_command", &json!({"command": "file.documentColorMode", "params": {"mode": "rgb", "grays": "rich"}}));
assert!(bad.is_error, "{bad:?}");
}