//! M0.9 acceptance: drive LightCraft over MCP (stdio framing), set exposure and render an image. //! Runs against the headless backend directly and through the TCP control-channel transport //! (`Remote`) to a stand-in control server. use std::io::{BufRead, BufReader, Cursor, Write}; use std::net::TcpListener; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; use lightcraft_mcp::{Backend, Headless, ProgressHook, Remote, Server, base64_decode}; use serde_json::{Value, json}; /// Feed a whole session through `Server::serve` and return the replies by id. fn session(server: &mut Server, msgs: &[Value]) -> Vec { let input: String = msgs.iter().map(|m| format!("{m}\n")).collect(); let mut out = Vec::new(); server.serve(Cursor::new(input), &mut out).unwrap(); String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect() } fn call(id: u64, name: &str, args: Value) -> Value { json!({"jsonrpc": "2.0", "id": id, "method": "tools/call", "params": {"name": name, "arguments": args}}) } /// Mean sRGB value of the PNG in an MCP image result. fn mean_of(result: &Value) -> (f64, u32, u32) { let img = result["content"].as_array().unwrap().iter().find(|c| c["type"] == "image").unwrap_or_else(|| panic!("no image in {result}")); assert_eq!(img["mimeType"], "image/png"); let png = base64_decode(img["data"].as_str().unwrap()).unwrap(); assert_eq!(&png[1..4], b"PNG"); let d = lightcraft_codecs::decode(&png, Default::default()).unwrap(); let rgba = d.to_srgb8(); let sum: u64 = rgba.data.iter().map(|p| p[0] as u64 + p[1] as u64 + p[2] as u64).sum(); (sum as f64 / (3 * rgba.data.len()) as f64, d.width, d.height) } fn exposure_roundtrip(server: &mut Server) { let replies = session( server, &[ json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "e2e", "version": "0"}}}), json!({"jsonrpc": "2.0", "method": "notifications/initialized"}), json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}), call(3, "query_photos", json!({"limit": 1})), ], ); assert_eq!(replies.len(), 3, "notifications get no reply"); assert_eq!(replies[0]["result"]["serverInfo"]["name"], "lightcraft"); let tools: Vec<&str> = replies[1]["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect(); assert!(tools.contains(&"set_develop") && tools.contains(&"render_photo") && tools.contains(&"cmd_develop_set")); let id = replies[2]["result"]["structuredContent"]["photos"][0]["id"].as_u64().expect("a photo"); let replies = session( server, &[ call(10, "render_photo", json!({"id": id, "size": 256})), call(11, "set_develop", json!({"id": id, "values": {"light.exposure": 1.5}})), call(12, "get_develop", json!({"id": id})), call(13, "render_photo", json!({"id": id, "size": 256})), // The generated per-command tool works too: exposure -1.5 via `cmd_develop_set`. call(14, "cmd_develop_set", json!({"control": "light.exposure", "value": -1.5})), call(15, "render_photo", json!({"size": 256})), ], ); for r in &replies { assert!(r["error"].is_null() && r["result"]["isError"] == false, "{r}"); } assert_eq!(replies[1]["result"]["structuredContent"]["controls"][0]["value"], 1.5); assert_eq!(replies[2]["result"]["structuredContent"]["light"]["exposure"], 1.5); let (before, w, h) = mean_of(&replies[0]["result"]); let (brighter, w2, h2) = mean_of(&replies[3]["result"]); let (darker, _, _) = mean_of(&replies[5]["result"]); assert_eq!((w, h), (w2, h2)); assert!(w.max(h) <= 256 && w.max(h) >= 200, "{w}x{h}"); println!("mean sRGB: exposure 0 → {before:.1}, +1.5 → {brighter:.1}, -1.5 → {darker:.1} ({w}×{h})"); assert!(brighter > before + 15.0, "exposure +1.5 should brighten: {before} → {brighter}"); assert!(darker < before - 15.0, "exposure -1.5 should darken: {before} → {darker}"); } #[test] fn file_writers_advertise_destructive_effects() { for with_commands in [false, true] { let mut server = Server::new(Box::new(Headless::demo())).with_command_tools(with_commands); let replies = session(&mut server, &[json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})]); let tools = replies[0]["result"]["tools"].as_array().unwrap(); for name in ["export", "render_photo"] { let tool = tools.iter().find(|t| t["name"] == name).unwrap(); assert_eq!(tool["annotations"]["readOnlyHint"], false, "{name}"); assert_eq!(tool["annotations"]["destructiveHint"], true, "{name} can replace a file"); } let preview = tools.iter().find(|t| t["name"] == "render_preview").unwrap(); assert_eq!(preview["annotations"]["readOnlyHint"], true); assert_eq!(preview["annotations"]["destructiveHint"], false); } let addr = fake_app(); let mut server = Server::new(Box::new(Remote::connect(&addr).unwrap())); let replies = session(&mut server, &[json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"})]); let tools = replies[0]["result"]["tools"].as_array().unwrap(); let screenshot = tools.iter().find(|t| t["name"] == "screenshot").unwrap(); assert_eq!(screenshot["annotations"]["readOnlyHint"], false); assert_eq!(screenshot["annotations"]["destructiveHint"], true); let image_only = tools.iter().find(|t| t["name"] == "ui_screenshot").unwrap(); assert_eq!(image_only["annotations"]["readOnlyHint"], true); assert_eq!(image_only["annotations"]["destructiveHint"], false); } #[test] fn headless_set_exposure_and_render() { exposure_roundtrip(&mut Server::new(Box::new(Headless::demo()))); } #[test] fn import_render_export_real_file() { let dir = std::env::temp_dir().join(format!("lightcraft-mcp-e2e-{}", std::process::id())); std::fs::create_dir_all(dir.join("sub")).unwrap(); // A synthetic gradient PNG to import. let img = lightcraft_raster::Rgba8::from_fn(96, 64, |x, y| [(x * 2) as u8, (y * 3) as u8, 128, 255]); lightcraft_mcp::write_image(&dir.join("sub/gradient.png"), &img, 90).unwrap(); let out = dir.join("out.jpg"); let mut server = Server::new(Box::new(Headless::default())); let replies = session( &mut server, &[ call(1, "import", json!({"paths": [dir.to_string_lossy()]})), call(2, "set_develop", json!({"values": {"light.exposure": 0.5}})), call(3, "render_photo", json!({"format": "jpeg", "size": 64})), call(4, "export", json!({"path": out.to_string_lossy(), "longEdge": 48})), ], ); for r in &replies { assert!(r["result"]["isError"] == false, "{r}"); } assert_eq!(replies[0]["result"]["structuredContent"]["imported"].as_array().unwrap().len(), 1); assert_eq!(replies[2]["result"]["content"][0]["mimeType"], "image/jpeg"); let jpg = std::fs::read(&out).unwrap(); assert_eq!(&jpg[..2], &[0xff, 0xd8]); let d = lightcraft_codecs::decode(&jpg, Default::default()).unwrap(); assert_eq!((d.width, d.height), (48, 32)); let _ = std::fs::remove_dir_all(&dir); } /// A stand-in for the desktop app's control server: JSON lines over TCP, answered by a headless /// backend (the real app answers the same methods from its UI thread). fn fake_app() -> String { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap().to_string(); std::thread::spawn(move || { let mut backend = Headless::demo(); for stream in listener.incoming().flatten() { let mut out = stream.try_clone().unwrap(); for line in BufReader::new(stream).lines() { let Ok(line) = line else { break }; let msg: Value = serde_json::from_str(&line).unwrap(); let reply = match backend.call(msg["method"].as_str().unwrap(), msg["params"].clone()) { Ok(v) => json!({"id": msg["id"], "ok": true, "result": v}), Err(e) => json!({"id": msg["id"], "ok": false, "error": e}), }; if writeln!(out, "{reply}").is_err() { break; } } } }); addr } #[test] fn remote_set_exposure_and_render() { let addr = fake_app(); let remote = Remote::connect(&addr).unwrap(); assert!(remote.has_ui()); let mut server = Server::new(Box::new(remote)); exposure_roundtrip(&mut server); } #[test] fn remote_unreachable_is_a_tool_error() { // Bind and drop to get a port with (almost certainly) nothing listening. let addr = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().to_string(); let mut server = Server::new(Box::new(Remote::lazy(&addr))); let r = session(&mut server, &[call(1, "query_photos", json!({}))]); assert_eq!(r[0]["result"]["isError"], true); assert!(r[0]["result"]["content"][0]["text"].as_str().unwrap().contains("not reachable")); } /// [`Headless`] whose batch export, while `hold` is set, waits once its first photo is written: /// it keeps asking the server's progress hook at that photo boundary (which answers pings and /// reads cancels meanwhile) until the request is cancelled or the test clears `hold`. A ping or /// cancel sent after the first progress then lands mid-batch, however fast the machine exports. struct HoldAfterFirst { inner: Headless, hold: Arc, } impl Backend for HoldAfterFirst { fn call(&mut self, method: &str, params: Value) -> Result { self.inner.call(method, params) } fn has_ui(&self) -> bool { self.inner.has_ui() } fn describe(&self) -> String { self.inner.describe() } fn set_progress(&mut self, hook: Option) { let hold = self.hold.clone(); self.inner.set_progress(hook.map(|mut hook| -> ProgressHook { Box::new(move |done, total, name| { // bounded, so a cancel that is never read fails the test instead of hanging it let until = Instant::now() + Duration::from_secs(20); while done == 1 && done < total && hold.load(Ordering::SeqCst) && Instant::now() < until { if !hook(done, total, name) { return false; } std::thread::sleep(Duration::from_millis(5)); } hook(done, total, name) }) })); } } /// Real stdio framing and real atomic exports: progress, ping during a batch, cancellation /// between complete photos, unrelated output preservation, and a usable session afterwards. /// Both exports wait after their first photo ([`HoldAfterFirst`]) until the ping is answered or /// the cancel is read, so neither can finish first on a fast machine. #[test] fn export_progress_and_cancel() { let dir = std::env::temp_dir().join(format!("lc-mcp-progress-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let (input, mut send) = std::io::pipe().unwrap(); let (output, write) = std::io::pipe().unwrap(); let hold = Arc::new(AtomicBool::new(true)); let backend = HoldAfterFirst { inner: Headless::demo(), hold: hold.clone() }; let server = std::thread::spawn(move || Server::new(Box::new(backend)).serve(BufReader::new(input), write).unwrap()); let (tx, rx) = std::sync::mpsc::channel(); let reader = std::thread::spawn(move || { for line in BufReader::new(output).lines() { let value: Value = serde_json::from_str(&line.unwrap()).unwrap(); if tx.send(value).is_err() { break; } } }); let next = || rx.recv_timeout(Duration::from_secs(30)).expect("MCP reply/progress"); writeln!(send, "{}", call(1, "query_photos", json!({"limit":8}))).unwrap(); let query = next(); let ids: Vec<_> = query["result"]["structuredContent"]["photos"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect(); assert_eq!(ids.len(), 8); let mut request = call(2, "export", json!({"ids":ids,"dir":dir.join("complete"),"longEdge":1200,"format":"png"})); request["params"]["_meta"] = json!({"progressToken":"photos","io.modelcontextprotocol/protocolVersion":"2026-07-28"}); writeln!(send, "{request}").unwrap(); let (mut last, mut notes, mut asked, mut answered) = (-1.0, 0, false, false); loop { let value = next(); if value["method"] == "notifications/progress" { assert_eq!(value["params"]["progressToken"], "photos"); let progress = value["params"]["progress"].as_f64().unwrap(); assert!(progress > last && progress <= 8.0, "{value}"); assert_eq!(value["params"]["total"], 8); last = progress; notes += 1; if !asked { writeln!(send, "{}", json!({"jsonrpc":"2.0","id":3,"method":"ping"})).unwrap(); asked = true; } } else if value["id"] == 3 { answered = true; // the ping was answered mid-batch: let the export run to the end hold.store(false, Ordering::SeqCst); } else { assert_eq!(value["id"], 2, "{value}"); assert_eq!(value["result"]["isError"], false, "{value}"); assert_eq!(value["result"]["resultType"], "complete"); break; } } assert!(notes >= 2 && answered, "{notes} progress messages; ping answered {answered}"); assert_eq!(last, 8.0); let out = dir.join("cancelled"); std::fs::create_dir_all(&out).unwrap(); let decoy = out.join("photo999.png"); std::fs::write(&decoy, b"unrelated existing output").unwrap(); // this export waits after its first photo until the cancel below is read hold.store(true, Ordering::SeqCst); let mut request = call(4, "command_run", json!({"id":"app.export","params":{"ids":ids,"dir":out,"longEdge":1200,"format":"png"}})); request["params"]["_meta"] = json!({"progressToken":44}); writeln!(send, "{request}").unwrap(); loop { let value = next(); assert_ne!(value["id"], 4, "must be cancellable before completion: {value}"); assert_eq!(value["params"]["progressToken"], 44, "{value}"); if value["params"]["progress"].as_f64().unwrap() > 0.0 { break; } } writeln!(send, "{}", json!({"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":4}})).unwrap(); writeln!(send, "{}", call(5, "doc_inspect", json!({}))).unwrap(); loop { let value = next(); if value["method"] == "notifications/progress" { continue; } assert_eq!(value["id"], 5, "cancelled request must not reply: {value}"); assert_eq!(value["result"]["isError"], false); break; } assert_eq!(std::fs::read(&decoy).unwrap(), b"unrelated existing output"); let photos: Vec<_> = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().path()).filter(|p| *p != decoy).collect(); assert_eq!(photos.len(), 1, "only the photo completed before the cancel remains: {photos:?}"); for path in &photos { lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).expect("complete image, no partial/temp file"); } assert_eq!(std::fs::read_dir(dir.join("complete")).unwrap().count(), 8, "earlier export retained"); drop(send); server.join().unwrap(); reader.join().unwrap(); std::fs::remove_dir_all(dir).unwrap(); } #[test] fn export_without_token_finishes_after_eof() { let dir = std::env::temp_dir().join(format!("lc-mcp-eof-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let mut headless = Headless::demo(); let ids: Vec<_> = headless.session.visible_cloned().iter().take(2).map(|id| id.0).collect(); let request = call(1, "cmd_app_export", json!({"ids":ids,"dir":dir,"longEdge":64,"format":"png"})); // Headless sessions remain movable to a worker after installing progress support. let replies = std::thread::spawn(move || { let mut s = Server::new(Box::new(headless)); session(&mut s, &[request, call(2, "doc_inspect", json!({}))]) }) .join() .unwrap(); assert_eq!(replies.len(), 2, "no token means no notifications"); assert_eq!(replies[0]["result"]["isError"], false, "{replies:?}"); assert_eq!(replies[1]["id"], 2, "queued request survives EOF"); assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2); std::fs::remove_dir_all(dir).unwrap(); }