Files
lightcraft/crates/mcp/src/tests.rs
T
Nathaniel LedfordandClaude Opus 5.5 1029e63bde LR-BEHAV-TEXTFIELD: agents cut, copy and paste in text fields
`ui.clipboard {action, text?}` does what the system does for ⌘X / ⌘C /
⌘V in the focused text field (egui-winit sends clipboard events, not
key presses, so `ui.key` with ⌘C could not), and `ui.inspect` reports
`copied`, the text the UI last put on the clipboard. MCP has the same
as the `clipboard` tool when the app is attached.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:23:09 -04:00

402 lines
20 KiB
Rust

use serde_json::{Value, json};
use crate::{Backend, Headless, PROTOCOL_VERSION, Server, call_tool, command_tool_name};
fn server() -> Server {
Server::new(Box::new(Headless::demo()))
}
fn rpc(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 reply = s.handle_line(&line).expect("reply");
let v: Value = serde_json::from_str(&reply).unwrap();
assert_eq!(v["jsonrpc"], "2.0");
assert_eq!(v["id"], id);
v
}
#[test]
fn initialize_negotiates_version() {
let mut s = server();
let r = rpc(&mut s, 1, "initialize", json!({"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}}));
assert_eq!(r["result"]["protocolVersion"], "2025-03-26");
assert_eq!(r["result"]["serverInfo"]["name"], "lightcraft");
assert!(r["result"]["capabilities"]["tools"].is_object());
let r = rpc(&mut s, 2, "initialize", json!({"protocolVersion": "1999-01-01"}));
assert_eq!(r["result"]["protocolVersion"], PROTOCOL_VERSION);
assert!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#).is_none());
assert!(s.is_initialized());
}
#[test]
fn errors() {
let mut s = server();
let v: Value = serde_json::from_str(&s.handle_line("{not json").unwrap()).unwrap();
assert_eq!(v["error"]["code"], -32700);
assert_eq!(rpc(&mut s, 1, "nope", json!({}))["error"]["code"], -32601);
assert_eq!(rpc(&mut s, 2, "tools/call", json!({}))["error"]["code"], -32602);
assert!(s.handle_line(" ").is_none());
// Unknown tools and failing commands are tool errors, not protocol errors.
let r = rpc(&mut s, 3, "tools/call", json!({"name": "nope", "arguments": {}}));
assert_eq!(r["result"]["isError"], true);
let r = rpc(&mut s, 4, "tools/call", json!({"name": "run_command", "arguments": {"command": "no.such"}}));
assert_eq!(r["result"]["isError"], true);
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("unknown command"));
}
#[test]
fn tools_list_has_helpers_and_every_command() {
let mut s = server();
let r = rpc(&mut s, 1, "tools/list", json!({}));
let tools = r["result"]["tools"].as_array().unwrap();
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
for n in ["list_commands", "run_command", "import", "set_develop", "render_photo", "export"] {
assert!(names.contains(&n), "{n}");
}
// Headless: no UI tools.
assert!(!names.contains(&"screenshot"));
let hl = Headless::demo();
for c in hl.session.commands() {
assert!(!c.id.contains('_'), "command ids must not contain `_` ({})", c.id);
let n = command_tool_name(c.id);
assert!(names.contains(&n.as_str()), "{n}");
assert!(n.len() <= 64 && n.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_'), "{n}");
}
assert!(names.contains(&"cmd_app_export"));
for t in tools {
assert_eq!(t["inputSchema"]["type"], "object");
}
// Compact mode keeps only the helpers.
let mut s = Server::new(Box::new(Headless::demo())).with_command_tools(false);
let r = rpc(&mut s, 1, "tools/list", json!({}));
assert!(r["result"]["tools"].as_array().unwrap().iter().all(|t| !t["name"].as_str().unwrap().starts_with("cmd_")));
}
/// The `import` helper passes the copy options through: a folder template files the copy.
#[test]
fn import_tool_copies_with_a_folder_template() {
let base = std::env::temp_dir().join(format!("lc-mcp-import-tpl-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let (src, dest) = (base.join("card"), base.join("out"));
std::fs::create_dir_all(&src).unwrap();
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[20, 3, 9, 255]; 64] };
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(src.join("a.png"), png).unwrap();
let mut b = Headless::demo();
b.session.clock = Box::new(|| "2026-01-14T05:58:48".to_string());
let args =
json!({"paths": [src.to_string_lossy()], "mode": "copy", "destination": dest.to_string_lossy(), "organize": "{date:%Y}/{date:%Y%m%d}"});
let r = call_tool(&mut b, "import", &args);
assert!(!r.is_error, "{r:?}");
assert!(dest.join("2026").join("20260114").join("a.png").is_file());
let _ = std::fs::remove_dir_all(&base);
}
/// Issue #93: `render_photo`, `ui.render` and `app.export` with an exact `path` must not replace
/// a photo's original.
#[test]
fn path_writes_never_replace_an_original() {
let base = std::env::temp_dir().join(format!("lc-mcp-guard-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 30, 9, 255]; 64] };
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
let orig = base.join("a.png");
std::fs::write(&orig, &png).unwrap();
let mut b = Headless::demo();
let r = call_tool(&mut b, "import", &json!({"paths": [orig.to_string_lossy()]}));
assert!(!r.is_error, "{r:?}");
let id = b.session.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id.0;
let path = orig.to_string_lossy().to_string();
let r = call_tool(&mut b, "render_photo", &json!({"id": id, "path": path}));
assert!(r.is_error, "{r:?}");
assert!(b.call("ui.render", json!({"id": id, "path": path})).unwrap_err().contains("original"));
let e = b.call("app.export", json!({"ids": [id], "path": path})).unwrap_err();
assert!(e.contains("never writes over an original"), "{e}");
assert_eq!(std::fs::read(&orig).unwrap(), png, "the original is untouched");
// another path still works
let r = call_tool(&mut b, "render_photo", &json!({"id": id, "path": base.join("render.png").to_string_lossy()}));
assert!(!r.is_error, "{r:?}");
let _ = std::fs::remove_dir_all(&base);
}
/// Issue #181: a misspelled or unreadable `app.export` param is an error through the headless
/// backend (what `lightcraft-cli run` and the MCP `export` tool use), not a silent default.
#[test]
fn export_refuses_unknown_params_and_bad_values() {
let mut b = Headless::demo();
let out = "/nonexistent-lc-test/x.jpg";
let e = b.call("app.export", json!({"path": out, "longEdgee": 400})).unwrap_err();
assert!(e.contains("unknown parameter `longEdgee` (did you mean `longEdge`?)"), "{e}");
let e = b.call("app.export", json!({"path": out, "longEdge": "banana"})).unwrap_err();
assert!(e.contains("`longEdge` must be a number"), "{e}");
let e = b.call("app.export", json!({"path": out, "watermark": {"text": "x", "size": 3}})).unwrap_err();
assert!(e.contains("`watermark.size` must be a number 0.005..0.5"), "{e}");
// `export` tool and preset expansion go through the same check
let r = call_tool(&mut b, "export", &json!({"path": out, "quality": 101}));
assert!(r.is_error, "{r:?}");
let e = b.session.execute("export.savePreset", &json!({"name": "Typo", "params": {"qualty": 5}})).unwrap_err().to_string();
assert!(e.contains("did you mean `quality`"), "{e}");
}
/// Every option the `export` tool advertises reaches `app.export` (they were accepted, then dropped).
#[test]
fn export_tool_forwards_its_advertised_options() {
let mut b = Headless::demo();
let base = std::env::temp_dir().join(format!("lc-mcp-export-fwd-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let r = call_tool(&mut b, "export", &json!({"dir": base.to_string_lossy(), "subfolder": "sub", "longEdge": 64, "format": "png"}));
assert!(!r.is_error, "{r:?}");
let files: Vec<_> = std::fs::read_dir(base.join("sub")).unwrap().collect();
assert_eq!(files.len(), 1, "written into the subfolder");
let _ = std::fs::remove_dir_all(&base);
}
/// The `import` helper moves: renamed into the folder template, the source removed.
#[test]
fn import_tool_moves_with_a_folder_template() {
let base = std::env::temp_dir().join(format!("lc-mcp-import-move-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let (src, dest) = (base.join("card"), base.join("out"));
std::fs::create_dir_all(&src).unwrap();
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[21, 3, 9, 255]; 64] };
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
std::fs::write(src.join("sample.png"), png).unwrap();
let mut b = Headless::demo();
b.session.clock = Box::new(|| "2026-01-14T05:58:48".to_string());
let args = json!({"paths": [src.to_string_lossy()], "mode": "move", "destination": dest.to_string_lossy(),
"organize": "{date:%Y}/{date:%Y%m%d}", "rename": "{date:%Y%m%d}_{seq:3}"});
let r = call_tool(&mut b, "import", &args);
assert!(!r.is_error, "{r:?}");
assert!(dest.join("2026").join("20260114").join("20260114_001.png").is_file());
assert!(!src.join("sample.png").exists(), "moved");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn command_tools_run_commands() {
let mut b = Headless::demo();
let first = b.session.visible_cloned()[0];
let r = call_tool(&mut b, "cmd_library_select", &json!({"ids": [first.0]}));
assert!(!r.is_error, "{r:?}");
let r = call_tool(&mut b, "cmd_photo_rate", &json!({"rating": 4}));
assert!(!r.is_error, "{r:?}");
assert_eq!(b.session.catalog.photo(first).unwrap().rating, 4);
let r = call_tool(&mut b, "cmd_edit_undo", &json!({}));
assert!(!r.is_error);
assert_eq!(b.session.catalog.photo(first).unwrap().rating, 0);
}
#[test]
fn set_develop_values_and_settings() {
let mut b = Headless::demo();
let first = b.session.visible_cloned()[0];
let r = call_tool(&mut b, "set_develop", &json!({"id": first.0, "values": {"light.exposure": 1.25}}));
assert!(!r.is_error, "{r:?}");
assert_eq!(r.structured.as_ref().unwrap()["controls"][0]["value"], 1.25);
assert_eq!(b.session.develop_of(first).unwrap().light.exposure, 1.25);
let r = call_tool(&mut b, "set_develop", &json!({"settings": {"light": {"contrast": 30.0}}}));
assert!(!r.is_error, "{r:?}");
let d = b.session.develop_of(first).unwrap();
assert_eq!((d.light.exposure, d.light.contrast), (1.25, 30.0));
assert!(call_tool(&mut b, "set_develop", &json!({})).is_error);
assert!(call_tool(&mut b, "set_develop", &json!({"values": {"no.such": 1}})).is_error);
}
#[test]
fn crop_needs_a_rect_angle_or_reset() {
let mut b = Headless::demo();
let first = b.session.visible_cloned()[0];
// A guessed parameter must not succeed silently.
let r = call_tool(&mut b, "crop", &json!({"id": first.0, "aspect": "1:1"}));
assert!(r.is_error, "{r:?}");
assert!(call_tool(&mut b, "crop", &json!({"id": first.0, "reset": false})).is_error);
assert!(!call_tool(&mut b, "crop", &json!({"id": first.0, "rect": [0.1, 0.0, 0.9, 1.0]})).is_error);
assert!(!call_tool(&mut b, "crop", &json!({"id": first.0, "reset": true})).is_error);
}
#[test]
fn ui_tools_need_the_app() {
let mut b = Headless::demo();
let r = call_tool(&mut b, "screenshot", &json!({}));
assert!(r.is_error);
assert!(r.content[0]["text"].as_str().unwrap().contains("--connect"));
}
#[test]
fn resources() {
let mut s = server();
let r = rpc(&mut s, 1, "resources/list", json!({}));
let list = r["result"]["resources"].as_array().unwrap();
assert_eq!(list.len(), 7);
for (i, res) in list.iter().enumerate() {
let uri = res["uri"].as_str().unwrap();
let r = rpc(&mut s, 10 + i as u64, "resources/read", json!({"uri": uri}));
let text = r["result"]["contents"][0]["text"].as_str().unwrap_or_else(|| panic!("{uri}: {r}"));
serde_json::from_str::<Value>(text).unwrap();
}
assert_eq!(rpc(&mut s, 99, "resources/read", json!({"uri": "lightcraft://nope"}))["error"]["code"], -32002);
}
/// `select_photos` with an id that is not in the library is a tool error, and the photo that was
/// active stays active (#182).
#[test]
fn select_photos_rejects_unknown_ids_and_keeps_the_active_photo() {
let mut b = Headless::demo();
let first = b.session.visible_cloned()[0];
let r = call_tool(&mut b, "select_photos", &json!({"ids": [first.0]}));
assert!(!r.is_error, "{r:?}");
let r = call_tool(&mut b, "select_photos", &json!({"ids": [9999]}));
assert!(r.is_error, "{r:?}");
assert!(r.content[0]["text"].as_str().unwrap().contains("no such photo 9999"), "{r:?}");
assert_eq!(b.session.active(), Some(first));
let r = call_tool(&mut b, "get_develop", &json!({}));
assert!(!r.is_error, "{r:?}");
let r = call_tool(&mut b, "get_develop", &json!({"id": 9999}));
assert!(r.is_error, "{r:?}");
let r = call_tool(&mut b, "set_develop", &json!({"id": 9999, "values": {"light.exposure": 1.0}}));
assert!(r.is_error, "{r:?}");
assert_eq!(b.session.active(), Some(first));
}
#[test]
fn core_tools_have_hints_and_reject_unknown_keys() {
for compact in [false, true] {
let mut s = server().with_command_tools(!compact);
let list = rpc(&mut s, 1, "tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap();
for name in ["command_list", "command_run", "command_batch", "doc_inspect", "render_preview", "list_commands", "run_command", "render_photo"]
{
assert!(tools.iter().any(|t| t["name"] == name), "missing {name}");
}
for t in tools {
assert!(!t["title"].as_str().unwrap().is_empty());
for hint in ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] {
assert!(t["annotations"][hint].is_boolean(), "{t}");
}
}
for name in ["command_list", "command_run", "command_batch", "doc_inspect", "render_preview", "query_photos", "run_command"] {
let r = rpc(&mut s, 2, "tools/call", json!({"name":name,"arguments":{"misspelled":true}}));
assert_eq!(r["error"]["code"], -32602, "{r}");
assert!(r["error"]["message"].as_str().unwrap().contains("misspelled"), "{r}");
}
let preview = tools.iter().find(|t| t["name"] == "render_preview").unwrap();
assert_eq!(preview["annotations"]["readOnlyHint"], true);
assert!(preview["inputSchema"]["properties"].get("path").is_none());
let writer = tools.iter().find(|t| t["name"] == "render_photo").unwrap();
assert_eq!(writer["annotations"]["readOnlyHint"], false);
let export = tools.iter().find(|t| t["name"] == "export").unwrap();
for key in lightcraft_engine::export::OPTION_PARAMS.iter().chain(lightcraft_engine::export::TARGET_PARAMS) {
assert!(export["inputSchema"]["properties"].get(*key).is_some(), "missing export argument {key}");
}
let before = rpc(&mut s, 2, "resources/read", json!({"uri":"lightcraft://library"}));
let image = rpc(&mut s, 2, "tools/call", json!({"name":"render_preview","arguments":{"max_side":64}}));
assert_eq!(image["result"]["isError"], false, "{image}");
assert!(image["result"]["content"].as_array().unwrap().iter().any(|c| c["type"] == "image"));
assert_eq!(before, rpc(&mut s, 2, "resources/read", json!({"uri":"lightcraft://library"})));
let batch = json!({"steps":[{"id":"catalog.stats"},{"id":"no.such"},{"id":"catalog.stats"}],"stop_on_error":false});
let r = rpc(&mut s, 3, "tools/call", json!({"name":"command_batch","arguments":batch}));
assert_eq!(r["result"]["isError"], true);
assert_eq!(r["result"]["structuredContent"]["completed"], 2);
assert_eq!(r["result"]["structuredContent"]["failed"], 1);
let r = rpc(&mut s, 4, "tools/call", json!({"name":"command_batch","arguments":{"steps":[{"id":"no.such"},{"id":"catalog.stats"}]}}));
assert_eq!(r["result"]["structuredContent"]["completed"], 0);
assert_eq!(r["result"]["structuredContent"]["results"].as_array().unwrap().len(), 1);
}
}
#[test]
fn core_resources_match_tools_after_parse_error() {
let mut s = server();
let bad: Value = serde_json::from_str(&s.handle_line("{bad").unwrap()).unwrap();
assert_eq!(bad["error"]["code"], -32700);
assert!(bad["id"].is_null());
let list = rpc(&mut s, 1, "resources/list", json!({}));
for (uri, tool) in [("lightcraft://document", "doc_inspect"), ("lightcraft://commands", "command_list")] {
assert!(list["result"]["resources"].as_array().unwrap().iter().any(|r| r["uri"] == uri));
let resource = rpc(&mut s, 2, "resources/read", json!({"uri":uri}));
let call = rpc(&mut s, 3, "tools/call", json!({"name":tool,"arguments":{}}));
let a: Value = serde_json::from_str(resource["result"]["contents"][0]["text"].as_str().unwrap()).unwrap();
let b: Value = serde_json::from_str(call["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(a, b);
}
let old = rpc(&mut s, 4, "resources/read", json!({"uri":"lightcraft://library"}));
assert!(old["result"]["contents"].is_array(), "{old}");
}
#[test]
fn escaped_tool_panic_is_an_error_and_session_survives() {
struct Faulty {
inner: Headless,
fail: bool,
}
impl Backend for Faulty {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
if method == "engine.execute" && params["command"] == "catalog.stats" && std::mem::take(&mut self.fail) {
panic!("synthetic backend panic");
}
self.inner.call(method, params)
}
fn has_ui(&self) -> bool {
false
}
fn describe(&self) -> String {
"test".into()
}
}
let mut s = Server::new(Box::new(Faulty { inner: Headless::demo(), fail: true }));
let params = json!({"name":"run_command","arguments":{"command":"catalog.stats"}});
let r = rpc(&mut s, 1, "tools/call", params.clone());
assert_eq!(r["result"]["isError"], true, "{r}");
assert!(r.to_string().contains("synthetic backend panic"));
assert_eq!(rpc(&mut s, 2, "tools/call", params)["result"]["isError"], false);
assert!(rpc(&mut s, 3, "ping", json!({}))["result"].is_object());
}
#[test]
fn modern_requests_receive_result_and_cache_fields() {
let mut s = server();
let meta = json!({"io.modelcontextprotocol/protocolVersion":"2026-07-28"});
for (method, mut params) in [
("tools/list", json!({})),
("resources/list", json!({})),
("resources/templates/list", json!({})),
("resources/read", json!({"uri":"lightcraft://library"})),
] {
params["_meta"] = meta.clone();
let r = rpc(&mut s, 1, method, params);
assert_eq!(r["result"]["resultType"], "complete", "{r}");
assert_eq!(r["result"]["cacheScope"], "private");
assert_eq!(r["result"]["ttlMs"], if method == "resources/read" { 0 } else { 600_000 });
}
assert!(rpc(&mut s, 2, "tools/list", json!({}))["result"].get("resultType").is_none());
}
/// A running app's control channel, as far as the MCP server can tell: it records each call.
struct Recorder(Vec<(String, Value)>);
impl Backend for Recorder {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
self.0.push((method.to_string(), params));
Ok(Value::Null)
}
fn has_ui(&self) -> bool {
true
}
fn describe(&self) -> String {
"recorder".into()
}
}
/// With the app attached, agents cut, copy and paste in its text fields (`clipboard`).
#[test]
fn the_clipboard_tool_cuts_copies_and_pastes_in_the_app() {
let names: Vec<String> = crate::tools::helper_tools(true).iter().filter_map(|t| t["name"].as_str().map(str::to_string)).collect();
assert!(names.iter().any(|n| n == "clipboard"), "{names:?}");
assert!(!crate::tools::helper_tools(false).iter().any(|t| t["name"] == "clipboard"), "not without the app");
let mut b = Recorder(vec![]);
let r = call_tool(&mut b, "clipboard", &json!({"action": "paste", "text": "travel"}));
assert!(!r.is_error, "{r:?}");
assert_eq!(b.0, vec![("ui.clipboard".to_string(), json!({"action": "paste", "text": "travel"}))]);
}