diff --git a/Cargo.lock b/Cargo.lock index a1dd320c..aa997eba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1998,6 +1998,17 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "lightcraft-cli" +version = "0.1.0" +dependencies = [ + "lightcraft-codecs", + "lightcraft-engine", + "lightcraft-mcp", + "lightcraft-raster", + "serde_json", +] + [[package]] name = "lightcraft-codecs" version = "0.1.0" @@ -2071,6 +2082,17 @@ dependencies = [ "serde", ] +[[package]] +name = "lightcraft-mcp" +version = "0.1.0" +dependencies = [ + "lightcraft-codecs", + "lightcraft-engine", + "lightcraft-raster", + "log", + "serde_json", +] + [[package]] name = "lightcraft-meta" version = "0.1.0" diff --git a/apps/lightcraft-cli/Cargo.toml b/apps/lightcraft-cli/Cargo.toml new file mode 100644 index 00000000..6a733f31 --- /dev/null +++ b/apps/lightcraft-cli/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "lightcraft-cli" +version.workspace = true +edition.workspace = true +license.workspace = true +publish = false +description = "LightCraft command-line tool: headless render, command listing and the MCP server" + +[[bin]] +name = "lightcraft-cli" +path = "src/main.rs" + +[lints] +workspace = true + +[dependencies] +lightcraft-engine = { workspace = true } +lightcraft-mcp = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +lightcraft-codecs = { workspace = true } +lightcraft-raster = { workspace = true } diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs new file mode 100644 index 00000000..8bc2869c --- /dev/null +++ b/apps/lightcraft-cli/src/main.rs @@ -0,0 +1,232 @@ +//! `lightcraft-cli`: headless LightCraft. +//! +//! ```text +//! lightcraft-cli mcp [--connect [ADDR]] [--demo] [--compact] [FILES/FOLDERS…] +//! lightcraft-cli render -o [--set control=value]… [--settings FILE.json] [--preset ID] [--size N] [--quality Q] +//! lightcraft-cli commands [--json] +//! lightcraft-cli controls [--json] +//! ``` + +use std::io::{BufReader, Write}; +use std::path::Path; +use std::process::ExitCode; + +use lightcraft_engine::Session; +use lightcraft_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server, expand_paths, write_image}; +use serde_json::{Value, json}; + +const USAGE: &str = "\ +lightcraft-cli — headless LightCraft (photo library + raw developer) + +USAGE: + lightcraft-cli mcp [OPTIONS] [FILES/FOLDERS…] + MCP server (JSON-RPC 2.0 over stdio). Headless by default: an in-process session with the + given files imported. Options: + --connect [ADDR] drive a running app instead (`lightcraft --control 7980`; default 127.0.0.1:7980) + --demo headless: start with the procedurally generated demo library + --compact list only the helper tools (every command stays reachable via run_command) + lightcraft-cli render -o [OPTIONS] + Develop one file and write the result (.png, .jpg, .tif or .webp by extension). Options: + --set CONTROL=VALUE set a develop slider, repeatable (e.g. --set light.exposure=0.5) + --settings FILE merge a partial develop-settings JSON file + --preset ID apply a preset (see `commands`/presets.list) + --size N long edge in pixels (default: full size) + --quality Q JPEG quality 1..100 (default 92) + lightcraft-cli commands [--json] list every command id with its parameters + lightcraft-cli controls [--json] list every develop control id with its range + lightcraft-cli --version | --help +"; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let r = match args.first().map(String::as_str) { + Some("mcp") => mcp(&args[1..]), + Some("render") => render(&args[1..]), + Some("commands") => commands(&args[1..]), + Some("controls") => controls(&args[1..]), + Some("--version" | "-V" | "version") => { + println!("lightcraft-cli {}", env!("CARGO_PKG_VERSION")); + Ok(()) + } + Some("--help" | "-h" | "help") | None => { + print!("{USAGE}"); + Ok(()) + } + Some(other) => Err(format!("unknown subcommand `{other}`\n\n{USAGE}")), + }; + match r { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("lightcraft-cli: {e}"); + ExitCode::FAILURE + } + } +} + +fn mcp(args: &[String]) -> Result<(), String> { + let mut connect: Option = None; + let mut demo = false; + let mut compact = false; + let mut files = Vec::new(); + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--connect" => { + // Optional address argument. + match args.get(i + 1).filter(|a| !a.starts_with("--") && a.contains(':')) { + Some(a) => { + connect = Some(a.clone()); + i += 1; + } + None => connect = Some(DEFAULT_ADDR.to_string()), + } + } + a if a.starts_with("--connect=") => connect = Some(a["--connect=".len()..].to_string()), + "--headless" => connect = None, + "--demo" => demo = true, + "--compact" => compact = true, + a if a.starts_with("--") => return Err(format!("unknown option `{a}`")), + f => files.push(f.to_string()), + } + i += 1; + } + let backend: Box = match connect { + Some(addr) => { + if !files.is_empty() || demo { + return Err("FILES and --demo apply to headless mode only (import through the `import` tool instead)".into()); + } + match Remote::connect(&addr) { + Ok(r) => { + eprintln!("lightcraft-cli mcp: connected to LightCraft at {addr}"); + Box::new(r) + } + Err(e) => { + eprintln!("lightcraft-cli mcp: LightCraft is not reachable at {addr} yet ({e}); will retry on each call"); + Box::new(Remote::lazy(&addr)) + } + } + } + None => { + let mut h = if demo { Headless::demo() } else { Headless::default() }; + if !files.is_empty() { + let paths = expand_paths(&files); + let r = h.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?; + eprintln!("lightcraft-cli mcp: imported {} photo(s)", r["imported"].as_array().map_or(0, Vec::len)); + } + Box::new(h) + } + }; + eprintln!("lightcraft-cli mcp: serving MCP on stdio ({})", backend.describe()); + let mut server = Server::new(backend).with_command_tools(!compact); + let stdin = std::io::stdin(); + server.serve(BufReader::new(stdin.lock()), std::io::stdout().lock()).map_err(|e| e.to_string()) +} + +fn take_value<'a>(args: &'a [String], i: &mut usize, flag: &str) -> Result<&'a str, String> { + *i += 1; + args.get(*i).map(String::as_str).ok_or_else(|| format!("{flag} needs a value")) +} + +fn render(args: &[String]) -> Result<(), String> { + let mut input = None; + let mut output = None; + let mut values = serde_json::Map::new(); + let mut settings: Option = None; + let mut preset = None; + let mut size: Option = None; + let mut quality = 92u8; + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "-o" | "--output" => output = Some(take_value(args, &mut i, "-o")?.to_string()), + "--set" => { + let kv = take_value(args, &mut i, "--set")?; + let (k, v) = kv.split_once('=').ok_or_else(|| format!("--set expects control=value, got `{kv}`"))?; + let v: f64 = v.trim().parse().map_err(|_| format!("--set {k}: `{v}` is not a number"))?; + values.insert(k.trim().to_string(), json!(v)); + } + "--settings" => { + let p = take_value(args, &mut i, "--settings")?; + let text = std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}"))?; + settings = Some(serde_json::from_str(&text).map_err(|e| format!("{p}: {e}"))?); + } + "--preset" => preset = Some(take_value(args, &mut i, "--preset")?.to_string()), + "--size" => size = Some(take_value(args, &mut i, "--size")?.parse().map_err(|_| "--size expects a number")?), + "--quality" => quality = take_value(args, &mut i, "--quality")?.parse().map_err(|_| "--quality expects 1..100")?, + a if a.starts_with('-') => return Err(format!("unknown option `{a}`")), + f if input.is_none() => input = Some(f.to_string()), + f => return Err(format!("unexpected argument `{f}`")), + } + i += 1; + } + let input = input.ok_or("render: missing input file")?; + let output = output.ok_or("render: missing -o OUTPUT")?; + let mut s = Session::new().with_fs(); + let abs = expand_paths(std::slice::from_ref(&input)); + let r = s.execute("library.import", &json!({"paths": abs})).map_err(|e| e.to_string())?; + let id = r["imported"][0].as_u64().ok_or_else(|| format!("{input}: not a readable photo"))?; + let run = |s: &mut Session, cmd: &str, p: Value| s.execute(cmd, &p).map(|_| ()).map_err(|e| e.to_string()); + run(&mut s, "library.select", json!({"ids": [id], "active": id}))?; + if let Some(p) = preset { + run(&mut s, "preset.apply", json!({"id": p}))?; + } + if let Some(st) = settings { + run(&mut s, "develop.merge", json!({"settings": st}))?; + } + if !values.is_empty() { + run(&mut s, "develop.set", json!({"values": values}))?; + } + let photo = s.catalog.photo(lightcraft_engine::catalog::PhotoId(id)).ok_or("photo vanished")?; + let full = photo.width.max(photo.height).max(1) as u64; + let edge = size.unwrap_or(full) as usize; + let img = s.render_now(lightcraft_engine::catalog::PhotoId(id), edge, edge)?.image; + write_image(Path::new(&output), &img, quality)?; + eprintln!("lightcraft-cli: wrote {output} ({}×{})", img.width, img.height); + Ok(()) +} + +fn commands(args: &[String]) -> Result<(), String> { + let mut h = Headless::demo(); + let cmds = h.call("engine.commands", json!({}))?; + let mut out = std::io::stdout().lock(); + if args.iter().any(|a| a == "--json") { + writeln!(out, "{}", serde_json::to_string_pretty(&cmds).unwrap_or_default()).map_err(|e| e.to_string())?; + return Ok(()); + } + for c in cmds.as_array().into_iter().flatten() { + let sc = c["shortcut"].as_str().map(|s| format!(" [{s}]")).unwrap_or_default(); + writeln!( + out, + "{:<28} {}{sc}\n{:<28} params: {}", + c["id"].as_str().unwrap_or(""), + c["label"].as_str().unwrap_or(""), + "", + c["params"].as_str().unwrap_or("") + ) + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +fn controls(args: &[String]) -> Result<(), String> { + let mut s = Session::new(); + let v = s.execute("develop.controls", &json!({})).map_err(|e| e.to_string())?; + let mut out = std::io::stdout().lock(); + if args.iter().any(|a| a == "--json") { + writeln!(out, "{}", serde_json::to_string_pretty(&v).unwrap_or_default()).map_err(|e| e.to_string())?; + return Ok(()); + } + for c in v.as_array().into_iter().flatten() { + writeln!( + out, + "{:<28} {:<22} {} .. {} (default {})", + c["id"].as_str().unwrap_or(""), + c["label"].as_str().unwrap_or(""), + c["min"], + c["max"], + c["default"] + ) + .map_err(|e| e.to_string())?; + } + Ok(()) +} diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs new file mode 100644 index 00000000..bf26e8ff --- /dev/null +++ b/apps/lightcraft-cli/tests/cli.rs @@ -0,0 +1,100 @@ +//! End-to-end tests of the `lightcraft-cli` binary: `mcp` over real stdio pipes, `render`, `commands`. + +use std::io::{BufRead, BufReader, Write}; +use std::process::{Command, Stdio}; + +use serde_json::{Value, json}; + +const BIN: &str = env!("CARGO_BIN_EXE_lightcraft-cli"); + +fn tmp(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("lightcraft-cli-test-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d.join(name) +} + +fn gradient_png(path: &std::path::Path) { + let img = lightcraft_raster::Rgba8::from_fn(120, 80, |x, y| [(x * 2) as u8, (y * 3) as u8, 100, 255]); + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &Default::default()).unwrap(); + std::fs::write(path, png).unwrap(); +} + +fn mean(path: &std::path::Path) -> f64 { + let d = lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).unwrap(); + let img = d.to_srgb8(); + img.data.iter().map(|p| (p[0] as u32 + p[1] as u32 + p[2] as u32) as f64).sum::() / (3 * img.data.len()) as f64 +} + +#[test] +fn mcp_over_stdio_sets_exposure_and_renders() { + let input = tmp("in.png"); + gradient_png(&input); + let mut child = + Command::new(BIN).args(["mcp", input.to_str().unwrap()]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); + let mut stdin = child.stdin.take().unwrap(); + let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut rpc = |id: u64, method: &str, params: Value| -> Value { + writeln!(stdin, "{}", json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})).unwrap(); + stdin.flush().unwrap(); + let mut line = String::new(); + stdout.read_line(&mut line).unwrap(); + let v: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(v["id"], id); + v + }; + let init = rpc(1, "initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "cli-test", "version": "0"}})); + assert_eq!(init["result"]["protocolVersion"], "2025-06-18"); + let tools = rpc(2, "tools/list", json!({})); + assert!(tools["result"]["tools"].as_array().unwrap().len() > 50); + let base = tmp("base.png"); + let bright = tmp("bright.png"); + let r = rpc(3, "tools/call", json!({"name": "render_photo", "arguments": {"path": base.to_str().unwrap()}})); + assert_eq!(r["result"]["isError"], false, "{r}"); + let r = rpc(4, "tools/call", json!({"name": "set_develop", "arguments": {"values": {"light.exposure": 1.0}}})); + assert_eq!(r["result"]["isError"], false, "{r}"); + let r = rpc(5, "tools/call", json!({"name": "render_photo", "arguments": {"path": bright.to_str().unwrap()}})); + assert_eq!(r["result"]["isError"], false, "{r}"); + assert_eq!(r["result"]["content"][0]["type"], "image"); + drop(stdin); + assert!(child.wait().unwrap().success()); + let (a, b) = (mean(&base), mean(&bright)); + println!("cli mcp: mean sRGB exposure 0 → {a:.1}, +1 → {b:.1}"); + assert!(b > a + 10.0, "{a} → {b}"); +} + +#[test] +fn render_subcommand() { + let input = tmp("r-in.png"); + gradient_png(&input); + let plain = tmp("r-plain.png"); + let out = tmp("r-out.jpg"); + let st = Command::new(BIN).args(["render", input.to_str().unwrap(), "-o", plain.to_str().unwrap()]).status().unwrap(); + assert!(st.success()); + let st = Command::new(BIN) + .args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--set", "light.exposure=1", "--size", "60"]) + .status() + .unwrap(); + assert!(st.success()); + let d = lightcraft_codecs::decode(&std::fs::read(&out).unwrap(), Default::default()).unwrap(); + assert_eq!((d.width, d.height), (60, 40)); + let d = lightcraft_codecs::decode(&std::fs::read(&plain).unwrap(), Default::default()).unwrap(); + assert_eq!((d.width, d.height), (120, 80)); + assert!(mean(&out) > mean(&plain) + 10.0); + // Bad control id fails cleanly. + let o = Command::new(BIN).args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--set", "nope=1"]).output().unwrap(); + assert!(!o.status.success()); + assert!(String::from_utf8_lossy(&o.stderr).contains("unknown control")); +} + +#[test] +fn commands_subcommand_lists_registry() { + let o = Command::new(BIN).args(["commands", "--json"]).output().unwrap(); + assert!(o.status.success()); + let v: Value = serde_json::from_slice(&o.stdout).unwrap(); + let ids: Vec<&str> = v.as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect(); + for id in ["develop.set", "photo.rate", "library.import", "edit.undo", "app.export"] { + assert!(ids.contains(&id), "{id}"); + } + let o = Command::new(BIN).arg("controls").output().unwrap(); + assert!(String::from_utf8_lossy(&o.stdout).contains("light.exposure")); +} diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml new file mode 100644 index 00000000..b05dae56 --- /dev/null +++ b/crates/mcp/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "lightcraft-mcp" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "LightCraft MCP server (L5): JSON-RPC 2.0 over stdio, driving a running app or a headless session" + +[lints] +workspace = true + +[dependencies] +lightcraft-engine = { workspace = true } +lightcraft-codecs = { workspace = true } +lightcraft-raster = { workspace = true } +serde_json = { workspace = true } +log = { workspace = true } diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs new file mode 100644 index 00000000..5db2aaaa --- /dev/null +++ b/crates/mcp/src/backend.rs @@ -0,0 +1,112 @@ +//! Where MCP tool calls end up: a control-channel method call. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::Duration; + +use serde_json::{Value, json}; + +/// Something that answers control-channel methods (`engine.execute`, `engine.commands`, +/// `ui.render`, `ui.screenshot`, `ui.key`, …). See `docs/control-protocol.md`. +pub trait Backend { + /// Call one method. `Ok` carries the `result`, `Err` the error message. + fn call(&mut self, method: &str, params: Value) -> Result; + /// True when a real UI is attached (`ui.inspect`, `ui.clickWidget`, `ui.pointer` … work). + fn has_ui(&self) -> bool; + /// Short human description ("headless", "remote 127.0.0.1:7980"). + fn describe(&self) -> String; +} + +/// A running LightCraft app, reached through its loopback control port. +pub struct Remote { + addr: String, + conn: Option<(BufReader, TcpStream)>, + next_id: u64, +} + +impl Remote { + /// Connect to `addr` (e.g. `127.0.0.1:7980`), failing fast when nothing is listening. + 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) + } + + /// A remote that connects on first use (the app may start after the MCP server). + pub fn lazy(addr: &str) -> Self { + Self { addr: addr.to_string(), conn: None, next_id: 1 } + } + + pub fn addr(&self) -> &str { + &self.addr + } + + 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(); + // The app answers within 60 s (its own timeout); leave headroom. + s.set_read_timeout(Some(Duration::from_secs(90))).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::new(std::io::ErrorKind::NotConnected, "not connected")); + }; + writer.write_all(line.as_bytes())?; + writer.write_all(b"\n")?; + writer.flush()?; + let mut reply = String::new(); + if reader.read_line(&mut reply)? == 0 { + return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel 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(); + // One retry with a fresh connection (the app may have restarted). + let reply = match self.roundtrip(&line) { + Ok(r) => r, + Err(_) => { + self.conn = None; + self.roundtrip(&line).map_err(|e| { + self.conn = None; + format!("LightCraft app at {} is not reachable: {e} (start it with `lightcraft --control PORT`)", self.addr) + })? + } + }; + let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from 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("unknown error").to_string()) + } + } + + fn has_ui(&self) -> bool { + true + } + + fn describe(&self) -> String { + format!("remote {}", self.addr) + } +} diff --git a/crates/mcp/src/base64.rs b/crates/mcp/src/base64.rs new file mode 100644 index 00000000..9c02ac32 --- /dev/null +++ b/crates/mcp/src/base64.rs @@ -0,0 +1,60 @@ +//! Standard base64 (RFC 4648, padded) for MCP image content. + +const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/// Encode bytes as padded standard base64. +pub fn base64_encode(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)]; + let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32; + out.push(ALPHABET[(n >> 18) as usize & 63] as char); + out.push(ALPHABET[(n >> 12) as usize & 63] as char); + out.push(if chunk.len() > 1 { ALPHABET[(n >> 6) as usize & 63] as char } else { '=' }); + out.push(if chunk.len() > 2 { ALPHABET[n as usize & 63] as char } else { '=' }); + } + out +} + +/// Decode padded or unpadded standard base64 (whitespace ignored). `None` on invalid input. +pub fn base64_decode(text: &str) -> Option> { + let mut out = Vec::with_capacity(text.len() / 4 * 3); + let (mut acc, mut bits) = (0u32, 0u32); + for c in text.bytes().filter(|c| !c.is_ascii_whitespace()) { + if c == b'=' { + break; + } + let v = ALPHABET.iter().position(|&a| a == c)? as u32; + acc = (acc << 6) | v; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((acc >> bits) as u8); + acc &= (1 << bits) - 1; + } + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::{base64_decode, base64_encode}; + + #[test] + fn rfc4648_vectors() { + for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")] + { + assert_eq!(base64_encode(i.as_bytes()), o); + } + assert_eq!(base64_encode(&[0xff, 0xfe, 0xfd]), "//79"); + } + + #[test] + fn roundtrip() { + let data: Vec = (0..=255u8).chain((0..100).map(|i| (i * 37) as u8)).collect(); + for n in 0..data.len() { + assert_eq!(base64_decode(&base64_encode(&data[..n])).unwrap(), &data[..n]); + } + assert!(base64_decode("not base64!").is_none()); + } +} diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs new file mode 100644 index 00000000..b36930bc --- /dev/null +++ b/crates/mcp/src/headless.rs @@ -0,0 +1,162 @@ +//! An in-process [`Session`] that answers the control-channel methods the MCP tools use, so the +//! MCP server (and the CLI) work without a window. + +use std::path::Path; + +use lightcraft_engine::Session; +use lightcraft_engine::catalog::PhotoId; +use lightcraft_raster::Rgba8; +use serde_json::{Value, json}; + +use crate::backend::Backend; + +/// File extensions recognised as photos when expanding folders. +pub const PHOTO_EXTENSIONS: &[&str] = + &["jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "pef", "psd", "jxl", "gif", "bmp", "avif"]; + +/// Headless backend: a [`Session`] with filesystem hooks. +pub struct Headless { + pub session: Session, +} + +impl Default for Headless { + fn default() -> Self { + Self::new(Session::new().with_fs()) + } +} + +impl Headless { + pub fn new(session: Session) -> Self { + Self { session } + } + + /// A headless session with the procedurally generated demo library. + pub fn demo() -> Self { + Self::new(Session::with_demo().with_fs()) + } + + fn photo_or_active(&self, p: &Value) -> Result { + p.get("id").and_then(Value::as_u64).map(PhotoId).or(self.session.active()).ok_or_else(|| "no photo (import one or pass `id`)".to_string()) + } + + /// Render `id` (or the active photo) so its long edge is at most `size` pixels. + pub fn render(&mut self, p: &Value, default_size: u64) -> Result { + let id = self.photo_or_active(p)?; + let size = p.get("size").or(p.get("longEdge")).and_then(Value::as_u64).unwrap_or(default_size).clamp(16, 16384) as usize; + Ok(self.session.render_now(id, size, size)?.image) + } + + /// The UI command `app.export`, emulated: render and write PNG/JPEG/TIFF/WebP by extension. + fn export(&mut self, p: &Value) -> Result { + let id = self.photo_or_active(p)?; + let img = self.render(&json!({"id": id.0, "size": p.get("longEdge").and_then(Value::as_u64).unwrap_or(3000)}), 3000)?; + let path = match p.get("path").and_then(Value::as_str) { + Some(path) => path.to_string(), + None => { + let name = self.session.catalog.photo(id).map(|p| p.file_name.clone()).unwrap_or_else(|| "export".into()); + let stem = name.rsplit_once('.').map(|(a, _)| a.to_string()).unwrap_or(name); + format!("{stem}-lightcraft.png") + } + }; + let quality = p.get("quality").and_then(Value::as_u64).unwrap_or(90).clamp(1, 100) as u8; + write_image(Path::new(&path), &img, quality)?; + Ok(json!({"path": path, "width": img.width, "height": img.height})) + } +} + +impl Backend for Headless { + fn call(&mut self, method: &str, params: Value) -> Result { + let p = if params.is_null() { json!({}) } else { params }; + match method { + "engine.execute" | "ui.menu.invoke" | "command" => { + let id = p.get("command").or(p.get("id")).and_then(Value::as_str).ok_or("missing `command`")?; + let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({})); + if id == "app.export" { + return self.export(¶ms); + } + self.session.execute(id, ¶ms).map_err(|e| e.to_string()) + } + "engine.commands" => { + let mut v: Vec = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); + v.push(json!({"id": "app.export", "label": "Export Now", "menu": [], "shortcut": null, + "params": "{path?: output file (.png/.jpg/.tif/.webp), longEdge?: pixels (3000), quality?: 1..100 (JPEG)}", + "enabled": self.session.active().is_some()})); + Ok(Value::Array(v)) + } + "ui.render" => { + let img = self.render(&p, 1600)?; + match p.get("path").and_then(Value::as_str) { + Some(path) => { + write_image(Path::new(path), &img, 92)?; + Ok(json!({"path": path, "width": img.width, "height": img.height})) + } + None => Ok(json!({"width": img.width, "height": img.height})), + } + } + "app.export" => self.export(&p), + m if m.starts_with("ui.") || m == "app.quit" => { + Err(format!("`{m}` needs the desktop app: start `lightcraft --control 7980` and run the MCP server with `--connect`")) + } + other => Err(format!("unknown method `{other}`")), + } + } + + fn has_ui(&self) -> bool { + false + } + + fn describe(&self) -> String { + "headless".into() + } +} + +/// Expand folders (recursively, sorted) into photo files and make paths absolute. +pub fn expand_paths(paths: &[String]) -> Vec { + fn walk(p: &Path, out: &mut Vec) { + if p.is_dir() { + if let Ok(rd) = std::fs::read_dir(p) { + let mut v: Vec<_> = rd.flatten().map(|e| e.path()).collect(); + v.sort(); + for c in v { + if c.file_name().is_some_and(|n| !n.to_string_lossy().starts_with('.')) { + walk(&c, out); + } + } + } + } else if p.extension().is_some_and(|e| PHOTO_EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str())) { + out.push(std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf()).to_string_lossy().to_string()); + } + } + let mut out = Vec::new(); + for p in paths { + let path = Path::new(p); + if path.is_dir() { + walk(path, &mut out); + } else { + // Explicit files are kept even with unknown extensions (the probe decides). + out.push(std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()).to_string_lossy().to_string()); + } + } + out +} + +/// Encode by extension: `.png` (default), `.jpg`/`.jpeg` (quality), `.tif`/`.tiff`, `.webp` (lossless). +pub fn encode_image(ext: &str, img: &Rgba8, quality: u8) -> Result, String> { + use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, TiffCompression}; + let e = EncodeImage::rgba8(img); + let m = EncodeMeta::default(); + match ext.to_ascii_lowercase().as_str() { + "jpg" | "jpeg" => lightcraft_codecs::encode_jpeg(&e, quality, ChromaSubsampling::S444, &m), + "tif" | "tiff" => lightcraft_codecs::encode_tiff(&e, TiffCompression::Deflate, &m), + "webp" => lightcraft_codecs::encode_webp_lossless(&e, &m), + _ => lightcraft_codecs::encode_png(&e, &m), + } + .map_err(|e| e.to_string()) +} + +/// Encode by the path's extension and write the file. +pub fn write_image(path: &Path, img: &Rgba8, quality: u8) -> Result<(), String> { + let ext = path.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default(); + let bytes = encode_image(&ext, img, quality)?; + std::fs::write(path, bytes).map_err(|e| format!("{}: {e}", path.display())) +} diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs new file mode 100644 index 00000000..2b8fa10f --- /dev/null +++ b/crates/mcp/src/lib.rs @@ -0,0 +1,33 @@ +//! LightCraft's MCP server. +//! +//! [Model Context Protocol](https://modelcontextprotocol.io) over stdio: newline-delimited +//! JSON-RPC 2.0, hand-written (no async runtime). The server exposes LightCraft as MCP tools and +//! resources and forwards everything to a [`Backend`]: +//! +//! - [`Remote`] talks to a running desktop app through its loopback JSON-lines control channel +//! (`lightcraft --control 7980`): one `{"id","method","params"}` line in, one +//! `{"id","ok","result"|"error"}` line out (see `docs/control-protocol.md`). +//! - [`Headless`] hosts an in-process [`lightcraft_engine::Session`] and answers the same +//! control-channel method names itself (rendering with the engine's pipeline and encoding with +//! `lightcraft-codecs`), so agents can develop photos and look at the result without a window. +//! +//! Entry points: [`Server::serve`] (stdio loop) and [`Server::handle_line`] (one message). +#![forbid(unsafe_code)] + +mod backend; +mod base64; +mod headless; +mod server; +mod tools; + +pub use backend::{Backend, Remote}; +pub use base64::{base64_decode, base64_encode}; +pub use headless::{Headless, PHOTO_EXTENSIONS, encode_image, expand_paths, write_image}; +pub use server::{PROTOCOL_VERSION, Server}; +pub use tools::{COMMAND_TOOL_PREFIX, ToolResult, call_tool, command_tool_name, helper_tools, tool_definitions}; + +/// Default control-channel address of the desktop app (`lightcraft --control 7980`). +pub const DEFAULT_ADDR: &str = "127.0.0.1:7980"; + +#[cfg(test)] +mod tests; diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs new file mode 100644 index 00000000..c3371be5 --- /dev/null +++ b/crates/mcp/src/server.rs @@ -0,0 +1,191 @@ +//! 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, tool_definitions}; + +/// The MCP revision we implement. +pub const PROTOCOL_VERSION: &str = "2025-06-18"; +/// Revisions we can speak; anything else negotiates down to [`PROTOCOL_VERSION`]. +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; + +const INSTRUCTIONS: &str = "LightCraft is a non-destructive photo library and raw developer (a Lightroom alternative). \ +Photos have integer ids; most editing acts on the *active* photo (tools that take `id` make that photo active first). \ +Typical loop: query_photos → select_photos (or pass id) → list_controls → set_develop {values: {\"light.exposure\": 0.5}} → \ +render_photo to look at the result → export. Positions (crop rects, mask points, brush strokes) are normalized image \ +coordinates 0..1 with the origin at the top-left. Every action is a command: list_commands shows ids and parameter \ +docs; run_command runs any of them. Edits are undoable (undo / redo)."; + +/// Resource URIs. +pub const LIBRARY_URI: &str = "lightcraft://library"; +pub const PHOTOS_URI: &str = "lightcraft://photos"; +pub const PHOTO_URI: &str = "lightcraft://photo/active"; +pub const DEVELOP_URI: &str = "lightcraft://develop/active"; +pub const CONTROLS_URI: &str = "lightcraft://controls"; + +/// An MCP server bound to one backend. +pub struct Server { + backend: Box, + initialized: bool, + command_tools: 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()}}) +} + +fn resource(uri: &str, name: &str, title: &str, description: &str) -> Value { + json!({"uri": uri, "name": name, "title": title, "description": description, "mimeType": "application/json"}) +} + +impl Server { + pub fn new(backend: Box) -> Self { + Self { backend, initialized: false, command_tools: true } + } + + /// Whether `tools/list` includes one `cmd_*` tool per registered command (default true). + /// Off, every command stays reachable through `run_command`. + pub fn with_command_tools(mut self, on: bool) -> Self { + self.command_tools = on; + self + } + + pub fn backend(&mut self) -> &mut dyn Backend { + self.backend.as_mut() + } + + /// Whether the client has sent `notifications/initialized`. + pub fn is_initialized(&self) -> bool { + self.initialized + } + + /// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is + /// the protocol stream). + pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> { + for line in input.lines() { + let line = line?; + if let Some(reply) = self.handle_line(&line) { + output.write_all(reply.as_bytes())?; + output.write_all(b"\n")?; + output.flush()?; + } + } + Ok(()) + } + + /// Handle one line; returns the reply line (None for notifications and blank lines). + pub fn handle_line(&mut self, line: &str) -> Option { + let line = line.trim(); + if line.is_empty() { + return None; + } + let reply = match serde_json::from_str::(line) { + Ok(Value::Array(batch)) => { + // Batches were removed in 2025-06-18; still answer older clients sensibly. + if batch.is_empty() { + Some(error(Value::Null, INVALID_REQUEST, "empty batch")) + } else { + 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()) + } + + /// Handle one JSON-RPC message; `None` for notifications and responses. + 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 server→client request (we send none) — ignore; anything else is invalid. + if o.contains_key("result") || o.contains_key("error") { + return None; + } + return Some(error(id.filter(|i| i.is_string() || i.is_number()).unwrap_or(Value::Null), INVALID_REQUEST, "missing `method`")); + }; + let params = o.get("params").cloned().unwrap_or(Value::Null); + let Some(id) = id else { + self.notification(method, ¶ms); + return None; + }; + if !(id.is_string() || id.is_number()) { + return Some(error(Value::Null, INVALID_REQUEST, "`id` must be a string or number")); + } + if !(params.is_object() || params.is_null()) { + return Some(error(id, INVALID_PARAMS, "`params` must be an object")); + } + Some(match self.request(method, ¶ms) { + Ok(r) => response(id, r), + Err((code, m)) => error(id, code, m), + }) + } + + fn notification(&mut self, method: &str, _params: &Value) { + match method { + "notifications/initialized" => self.initialized = true, + "notifications/cancelled" | "notifications/progress" | "notifications/roots/list_changed" => {} + other => log::debug!("ignoring notification {other}"), + } + } + + 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": {"subscribe": false, "listChanged": false}}, + "serverInfo": {"name": "lightcraft", "title": "LightCraft", "version": env!("CARGO_PKG_VERSION")}, + "instructions": format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe()), + })) + } + "ping" => Ok(json!({})), + "tools/list" => Ok(json!({"tools": tool_definitions(self.backend.as_mut(), self.command_tools)})), + "tools/call" => { + let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?; + let args = params.get("arguments").cloned().unwrap_or(Value::Null); + Ok(call_tool(self.backend.as_mut(), name, &args).to_value()) + } + "resources/list" => Ok(json!({"resources": [ + resource(LIBRARY_URI, "library", "Library state", "Current source, filter, sort, selection, active photo/mask and undo/redo labels (library.state)"), + resource(PHOTOS_URI, "photos", "Photos in view", "The photos in the current view with ids, ratings, flags and metadata (catalog.query)"), + resource(PHOTO_URI, "active-photo", "Active photo", "Everything about the active photo: file, metadata, develop settings, albums, history (photo.inspect)"), + resource(DEVELOP_URI, "develop", "Develop settings", "The active photo's full develop settings JSON (develop.get)"), + resource(CONTROLS_URI, "controls", "Develop controls", "Every develop slider with id, range, default and current value (develop.controls)"), + ]})), + "resources/templates/list" => Ok(json!({"resourceTemplates": []})), + "resources/read" => { + let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri`".to_string()))?; + let cmd = match uri { + LIBRARY_URI => "library.state", + PHOTOS_URI => "catalog.query", + PHOTO_URI => "photo.inspect", + DEVELOP_URI => "develop.get", + CONTROLS_URI => "develop.controls", + _ => return Err((RESOURCE_NOT_FOUND, format!("resource not found: {uri}"))), + }; + let v = self.backend.call("engine.execute", json!({"command": cmd, "params": {}})).map_err(|e| (INTERNAL_ERROR, e))?; + Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": serde_json::to_string_pretty(&v).unwrap_or_default()}]})) + } + other => Err((METHOD_NOT_FOUND, format!("method not found: {other}"))), + } + } +} diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs new file mode 100644 index 00000000..b97e0497 --- /dev/null +++ b/crates/mcp/src/tests.rs @@ -0,0 +1,126 @@ +use serde_json::{Value, json}; + +use crate::{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_"))); +} + +#[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 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(), 5); + 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::(text).unwrap(); + } + assert_eq!(rpc(&mut s, 99, "resources/read", json!({"uri": "lightcraft://nope"}))["error"]["code"], -32002); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs new file mode 100644 index 00000000..e1e54874 --- /dev/null +++ b/crates/mcp/src/tools.rs @@ -0,0 +1,523 @@ +//! MCP tools: hand-written helpers (import, develop, render, screenshot, UI input…) plus one tool +//! per command in the engine's command registry (`cmd_`), all forwarded to a +//! [`Backend`] as control-channel methods. + +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde_json::{Map, Value, json}; + +use crate::backend::Backend; +use crate::base64::base64_encode; +use crate::headless::{encode_image, expand_paths}; + +/// Prefix of the tools generated from the command registry. +pub const COMMAND_TOOL_PREFIX: &str = "cmd_"; + +/// The result of `tools/call`: MCP content blocks plus the error flag. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ToolResult { + pub content: Vec, + pub is_error: bool, + pub structured: Option, +} + +impl ToolResult { + pub fn text(t: impl Into) -> Self { + Self { content: vec![json!({"type": "text", "text": t.into()})], ..Default::default() } + } + + /// A JSON result as pretty text plus `structuredContent` when it is an object. + pub fn json(v: Value) -> Self { + let text = if v.is_null() { "ok".to_string() } else { serde_json::to_string_pretty(&v).unwrap_or_default() }; + let mut r = Self::text(text); + if v.is_object() { + r.structured = Some(v); + } + r + } + + pub fn error(msg: impl Into) -> Self { + Self { is_error: true, ..Self::text(msg) } + } + + fn from(r: Result) -> Self { + match r { + Ok(v) => Self::json(v), + Err(e) => Self::error(e), + } + } + + pub fn to_value(&self) -> Value { + let mut v = json!({"content": self.content, "isError": self.is_error}); + if let Some(s) = &self.structured { + v["structuredContent"] = s.clone(); + } + v + } + + /// The first image block's base64 data and mime type. + pub fn image(&self) -> Option<(&str, &str)> { + self.content.iter().find(|c| c["type"] == "image").and_then(|c| Some((c["data"].as_str()?, c["mimeType"].as_str()?))) + } +} + +fn tool(name: &str, title: &str, description: &str, properties: Value, required: &[&str]) -> Value { + json!({ + "name": name, + "title": title, + "description": description, + "inputSchema": {"type": "object", "properties": properties, "required": required}, + }) +} + +fn ids_schema(what: &str) -> Value { + json!({"type": "array", "items": {"type": "integer"}, "description": what}) +} + +/// The hand-written helper tools. UI tools are included only when a desktop app is attached. +pub fn helper_tools(has_ui: bool) -> Vec { + let photo_id = json!({"type": "integer", "description": "Photo id (default: the active photo). Makes that photo active first."}); + let mut v = vec![ + tool( + "list_commands", + "List commands", + "Every command (engine + UI when connected) with id, label, menu, shortcut, parameter doc and whether it is enabled now. Each id is also callable as tool `cmd_` or via run_command.", + json!({"filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)"}}), + &[], + ), + tool( + "run_command", + "Run command", + "Run any LightCraft command by id with JSON params (see list_commands for ids and parameter docs), e.g. {command: \"photo.rate\", params: {rating: 4}}.", + json!({"command": {"type": "string"}, "params": {"type": "object", "description": "Command parameters", "additionalProperties": true}}), + &["command"], + ), + tool( + "import", + "Import photos", + "Add image/raw files to the library. Folders are scanned recursively for photos; relative paths resolve against the server's working directory. The first imported photo becomes active. Returns the new photo ids.", + json!({"paths": {"type": "array", "items": {"type": "string"}, "description": "Files and/or folders"}, "album": {"type": "integer", "description": "Also add to this album id"}}), + &["paths"], + ), + tool( + "query_photos", + "Query photos", + "List photos (id, file name, size, rating, flag, edited…). Without filter/sort: the current library view.", + json!({ + "filter": {"type": "object", "description": "Catalog Filter JSON (e.g. {\"minRating\": 3})", "additionalProperties": true}, + "sort": {"type": "object", "additionalProperties": true}, + "offset": {"type": "integer"}, + "limit": {"type": "integer", "description": "Default 200"} + }), + &[], + ), + tool( + "select_photos", + "Select photos", + "Set the selection; the active photo (the one develop tools edit) is `active` or the first id.", + json!({"ids": ids_schema("Photo ids"), "active": {"type": "integer"}, "mode": {"type": "string", "enum": ["replace", "add", "toggle", "range"]}}), + &["ids"], + ), + tool( + "list_controls", + "List develop controls", + "Every develop slider (id like `light.exposure`, label, section, min, max, default, current value) for the active photo.", + json!({"section": {"type": "string", "description": "Only this section (e.g. light, color, effects, detail)"}}), + &[], + ), + tool( + "get_develop", + "Get develop settings", + "The full develop settings JSON of a photo (white balance, light, color, curves, mixer, grading, effects, crop, masks, spots…).", + json!({"id": photo_id}), + &[], + ), + tool( + "set_develop", + "Set develop settings", + "Edit a photo (one undo step each for values and settings). `values` sets sliders by control id (see list_controls), e.g. {\"light.exposure\": 0.7, \"light.contrast\": 20}. `settings` deep-merges a partial develop-settings JSON (as returned by get_develop).", + json!({ + "id": photo_id, + "values": {"type": "object", "additionalProperties": {"type": "number"}, "description": "controlId → value"}, + "settings": {"type": "object", "additionalProperties": true, "description": "Partial DevelopSettings JSON to merge"}, + "label": {"type": "string", "description": "History label for `settings`"} + }), + &[], + ), + tool( + "apply_preset", + "Apply preset", + "Apply a develop preset (ids from cmd_presets_list) to the selection or `ids`, at `amount` percent (0..200, default 100).", + json!({"preset": {"type": "string", "description": "Preset id"}, "amount": {"type": "number"}, "ids": ids_schema("Photo ids (default: selection)")}), + &["preset"], + ), + tool( + "crop", + "Crop / straighten", + "Set the crop of the active photo: `rect` [x0,y0,x1,y1] in normalized coordinates (0..1, origin top-left) of the straightened frame, `angle` in degrees. `reset: true` clears it.", + json!({"id": photo_id, "rect": {"type": "array", "items": {"type": "number"}, "minItems": 4, "maxItems": 4}, "angle": {"type": "number"}, "reset": {"type": "boolean"}}), + &[], + ), + tool( + "render_photo", + "Render photo", + "Render a photo with its current develop settings (cropped) and return it as an image so you can look at the result.", + json!({ + "id": photo_id, + "size": {"type": "integer", "description": "Long edge in pixels (default 1024, max 4096)"}, + "format": {"type": "string", "enum": ["png", "jpeg"], "description": "Image encoding (default png; jpeg is smaller)"}, + "path": {"type": "string", "description": "Also save the rendered file here"} + }), + &[], + ), + tool( + "export", + "Export photo", + "Render a photo at full quality and write it to `path` (.png, .jpg, .tif or .webp; the desktop app writes PNG).", + json!({"id": photo_id, "path": {"type": "string"}, "longEdge": {"type": "integer", "description": "Default 3000"}, "quality": {"type": "integer", "description": "JPEG quality 1..100"}}), + &["path"], + ), + ]; + if has_ui { + v.extend([ + tool( + "screenshot", + "Screenshot", + "Capture the LightCraft window (after pending renders finish) and return it as an image.", + json!({"maxSize": {"type": "integer", "description": "Downscale so the long edge is at most this (default 1600)"}, "format": {"type": "string", "enum": ["png", "jpeg"]}, "path": {"type": "string", "description": "Also save the screenshot here"}}), + &[], + ), + tool("inspect_ui", "Inspect UI", "UI state: view, open panel, window and image rects, active photo, selection, active mask, status.", json!({}), &[]), + tool( + "set_ui", + "Set UI state", + "Merge UI state, e.g. {\"view\": \"detail\"} (photoGrid|squareGrid|detail|compare) (see inspect_ui for fields).", + json!({"state": {"type": "object", "additionalProperties": true}}), + &["state"], + ), + tool( + "list_widgets", + "List widgets", + "On-screen widgets by automation id with rects [x, y, w, h] in screen points.", + json!({"filter": {"type": "string"}}), + &[], + ), + tool( + "click", + "Click", + "Click a widget by automation id (`widget`, see list_widgets) or a screen point (`x`, `y`). `count: 2` double-clicks.", + json!({"widget": {"type": "string"}, "x": {"type": "number"}, "y": {"type": "number"}, "count": {"type": "integer"}, "button": {"type": "string", "enum": ["left", "right"]}}), + &[], + ), + tool( + "press_key", + "Press key", + "Press a key with optional modifiers (shortcuts), e.g. {key: \"Z\", cmd: true}. Names: A–Z, 0–9, Enter, Escape, Delete, Left/Right/Up/Down, Space, Tab, F1… .", + json!({"key": {"type": "string"}, "cmd": {"type": "boolean"}, "shift": {"type": "boolean"}, "alt": {"type": "boolean"}, "ctrl": {"type": "boolean"}}), + &["key"], + ), + tool("type_text", "Type text", "Type text into the focused field.", json!({"text": {"type": "string"}}), &["text"]), + tool( + "pointer_gesture", + "Pointer gesture", + "Replay a pointer gesture on the photo in normalized image coordinates (0..1, origin top-left): brush strokes, gradient drags, crop handles, heal spots. Needs the Detail view.", + json!({ + "events": {"type": "array", "items": {"type": "object", "properties": {"kind": {"type": "string", "enum": ["down", "drag", "move", "up"]}, "x": {"type": "number"}, "y": {"type": "number"}}, "required": ["kind", "x", "y"]}}, + "alt": {"type": "boolean"}, "shift": {"type": "boolean"}, "cmd": {"type": "boolean"} + }), + &["events"], + ), + ]); + } + v +} + +/// MCP tool name of a command id (`develop.set` → `cmd_develop_set`). +pub fn command_tool_name(id: &str) -> String { + format!("{COMMAND_TOOL_PREFIX}{}", id.replace(['.', ' '], "_")) +} + +/// One tool per command reported by the backend's `engine.commands`. +pub fn command_tools(backend: &mut dyn Backend) -> Vec { + let Ok(Value::Array(cmds)) = backend.call("engine.commands", json!({})) else { return Vec::new() }; + cmds.iter() + .filter_map(|c| { + let id = c["id"].as_str()?; + let label = c["label"].as_str().unwrap_or(id); + let mut d = format!("{label} (command `{id}`)."); + if let Some(p) = c["params"].as_str().filter(|p| !p.is_empty()) { + d.push_str(&format!(" Params: {p}.")); + } + if let Some(m) = c["menu"].as_array().filter(|m| m.iter().any(|x| x.as_str().is_some_and(|s| !s.is_empty()))) { + let m: Vec<&str> = m.iter().filter_map(Value::as_str).collect(); + d.push_str(&format!(" Menu: {}.", m.join(" › "))); + } + if let Some(s) = c["shortcut"].as_str() { + d.push_str(&format!(" Shortcut: {s}.")); + } + Some(json!({ + "name": command_tool_name(id), + "title": label, + "description": d, + "inputSchema": {"type": "object", "additionalProperties": true}, + })) + }) + .collect() +} + +/// All tools: helpers, plus the generated command tools when `with_commands`. +pub fn tool_definitions(backend: &mut dyn Backend, with_commands: bool) -> Vec { + let mut v = helper_tools(backend.has_ui()); + if with_commands { + v.extend(command_tools(backend)); + } + v +} + +fn exec(b: &mut dyn Backend, command: &str, params: Value) -> Result { + b.call("engine.execute", json!({"command": command, "params": params})) +} + +/// Make `args.id` (if any) the active photo. +fn activate(b: &mut dyn Backend, args: &Value) -> Result<(), String> { + if let Some(id) = args.get("id").and_then(Value::as_u64) { + exec(b, "library.select", json!({"ids": [id], "active": id}))?; + } + Ok(()) +} + +fn temp_path(tag: &str) -> std::path::PathBuf { + static N: AtomicU64 = AtomicU64::new(0); + std::env::temp_dir().join(format!("lightcraft-mcp-{}-{}-{tag}.png", std::process::id(), N.fetch_add(1, Ordering::Relaxed))) +} + +/// Read a PNG the backend wrote, optionally downscale / re-encode as JPEG, and wrap it as image +/// content (+ a text block with the metadata). +fn image_result(file: &std::path::Path, max: Option, format: &str, save_to: Option<&str>, meta: Value) -> ToolResult { + let bytes = match std::fs::read(file) { + Ok(b) => b, + Err(e) => return ToolResult::error(format!("cannot read rendered image {}: {e}", file.display())), + }; + let _ = std::fs::remove_file(file); + let jpeg = matches!(format, "jpeg" | "jpg"); + let (w, h) = (meta["width"].as_u64().unwrap_or(0) as u32, meta["height"].as_u64().unwrap_or(0) as u32); + let too_big = max.is_some_and(|m| w.max(h) > m); + let (bytes, w, h) = if jpeg || too_big { + let opts = match max { + Some(m) => lightcraft_codecs::DecodeOptions::fit(m, m), + None => lightcraft_codecs::DecodeOptions::default(), + }; + let d = match lightcraft_codecs::decode(&bytes, opts) { + Ok(d) => d, + Err(e) => return ToolResult::error(format!("cannot decode rendered image: {e}")), + }; + let img = d.to_srgb8(); + match encode_image(if jpeg { "jpg" } else { "png" }, &img, 88) { + Ok(b) => (b, img.width as u32, img.height as u32), + Err(e) => return ToolResult::error(e), + } + } else { + (bytes, w, h) + }; + if let Some(p) = save_to + && let Err(e) = std::fs::write(p, &bytes) + { + return ToolResult::error(format!("{p}: {e}")); + } + let mime = if jpeg { "image/jpeg" } else { "image/png" }; + let mut info = meta; + info["width"] = json!(w); + info["height"] = json!(h); + if let Some(p) = save_to { + info["path"] = json!(p); + } else if let Some(o) = info.as_object_mut() { + o.remove("path"); + } + ToolResult { + content: vec![json!({"type": "image", "data": base64_encode(&bytes), "mimeType": mime}), json!({"type": "text", "text": info.to_string()})], + is_error: false, + structured: None, + } +} + +fn render_photo(b: &mut dyn Backend, args: &Value) -> ToolResult { + let size = args.get("size").and_then(Value::as_u64).unwrap_or(1024).clamp(16, 4096); + let id = match args.get("id").and_then(Value::as_u64) { + Some(id) => Some(id), + None => match exec(b, "library.state", json!({})) { + Ok(s) => s["selection"]["active"].as_u64().or(s["selection"]["ids"][0].as_u64()), + Err(e) => return ToolResult::error(e), + }, + }; + let Some(id) = id else { return ToolResult::error("no active photo: import or select one, or pass `id`") }; + let file = temp_path("render"); + let path = file.to_string_lossy().to_string(); + let meta = match b.call("ui.render", json!({"id": id, "size": size, "path": path})) { + Ok(m) => m, + Err(e) => return ToolResult::error(e), + }; + let mut meta = meta; + meta["id"] = json!(id); + image_result(&file, None, args.get("format").and_then(Value::as_str).unwrap_or("png"), args.get("path").and_then(Value::as_str), meta) +} + +fn screenshot(b: &mut dyn Backend, args: &Value) -> ToolResult { + let file = temp_path("screenshot"); + let path = file.to_string_lossy().to_string(); + match b.call("ui.screenshot", json!({"path": path})) { + Ok(meta) => image_result( + &file, + Some(args.get("maxSize").and_then(Value::as_u64).unwrap_or(1600).clamp(64, 8192) as u32), + args.get("format").and_then(Value::as_str).unwrap_or("png"), + args.get("path").and_then(Value::as_str), + meta, + ), + Err(e) => ToolResult::error(e), + } +} + +fn obj(args: &Value, keys: &[&str]) -> Value { + let mut m = Map::new(); + for k in keys { + if let Some(v) = args.get(*k).filter(|v| !v.is_null()) { + m.insert((*k).to_string(), v.clone()); + } + } + Value::Object(m) +} + +/// Run one tool. +pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { + let args = if args.is_null() { &json!({}) } else { args }; + if !args.is_object() { + return ToolResult::error("tool arguments must be an object"); + } + let s = |k: &str| args.get(k).and_then(Value::as_str); + if let Some(rest) = name.strip_prefix(COMMAND_TOOL_PREFIX) { + // Command ids never contain `_`, so the mapping is reversible. + return ToolResult::from(exec(b, &rest.replace('_', "."), args.clone())); + } + match name { + "list_commands" => { + let filter = s("filter").unwrap_or("").to_lowercase(); + ToolResult::from(b.call("engine.commands", json!({})).map(|v| { + match v { + Value::Array(a) if !filter.is_empty() => Value::Array( + a.into_iter() + .filter(|c| { + c["id"].as_str().unwrap_or("").to_lowercase().contains(&filter) + || c["label"].as_str().unwrap_or("").to_lowercase().contains(&filter) + }) + .collect(), + ), + v => v, + } + })) + } + "run_command" => match s("command") { + Some(c) => ToolResult::from(exec(b, c, args.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({})))), + None => ToolResult::error("missing `command`"), + }, + "import" => { + let paths: Vec = match args.get("paths") { + Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), + Some(Value::String(p)) => vec![p.clone()], + _ => return ToolResult::error("missing `paths`"), + }; + let files = expand_paths(&paths); + if files.is_empty() { + return ToolResult::error(format!("no photos found in {paths:?}")); + } + let mut p = json!({"paths": files}); + if let Some(a) = args.get("album") { + p["album"] = a.clone(); + } + ToolResult::from(exec(b, "library.import", p).map(|mut r| { + if let Some(o) = r.as_object_mut() { + o.insert("files".into(), json!(files.len())); + } + r + })) + } + "query_photos" => ToolResult::from(exec(b, "catalog.query", obj(args, &["filter", "sort", "offset", "limit"]))), + "select_photos" => { + let mut p = obj(args, &["ids", "active", "mode"]); + if p.get("active").is_none() + && let Some(first) = args["ids"].get(0) + { + p["active"] = first.clone(); + } + ToolResult::from(exec(b, "library.select", p)) + } + "list_controls" => ToolResult::from(exec(b, "develop.controls", obj(args, &["section"]))), + "get_develop" => ToolResult::from(exec(b, "develop.get", obj(args, &["id"]))), + "set_develop" => { + let r = (|| { + activate(b, args)?; + let values = args.get("values").filter(|v| v.as_object().is_some_and(|o| !o.is_empty())); + let settings = args.get("settings").filter(|v| v.as_object().is_some_and(|o| !o.is_empty())); + if values.is_none() && settings.is_none() { + return Err("give `values` ({controlId: number}) and/or `settings` (partial develop JSON)".to_string()); + } + if let Some(v) = values { + exec(b, "develop.set", json!({"values": v}))?; + } + if let Some(st) = settings { + let mut p = json!({"settings": st}); + if let Some(l) = args.get("label") { + p["label"] = l.clone(); + } + exec(b, "develop.merge", p)?; + } + // Echo the resulting values of the touched sliders. + let controls = exec(b, "develop.controls", json!({}))?; + let touched: Vec = match (values, controls) { + (Some(Value::Object(v)), Value::Array(all)) => all + .into_iter() + .filter(|c| c["id"].as_str().is_some_and(|id| v.contains_key(id))) + .map(|c| json!({"id": c["id"], "value": c["value"]})) + .collect(), + _ => Vec::new(), + }; + Ok(json!({"ok": true, "controls": touched})) + })(); + ToolResult::from(r) + } + "apply_preset" => match s("preset").or(s("id")) { + Some(p) => { + let mut params = obj(args, &["amount", "ids"]); + params["id"] = json!(p); + ToolResult::from(exec(b, "preset.apply", params)) + } + None => ToolResult::error("missing `preset`"), + }, + "crop" => ToolResult::from(activate(b, args).and_then(|_| { + if args.get("reset").and_then(Value::as_bool) == Some(true) { + exec(b, "crop.reset", json!({})) + } else { + exec(b, "crop.set", obj(args, &["rect", "angle"])) + } + })), + "render_photo" => render_photo(b, args), + "export" => ToolResult::from(activate(b, args).and_then(|_| exec(b, "app.export", obj(args, &["path", "longEdge", "quality"])))), + "screenshot" => screenshot(b, args), + "inspect_ui" => ToolResult::from(b.call("ui.inspect", json!({}))), + "set_ui" => ToolResult::from(b.call("ui.set", args.get("state").cloned().unwrap_or(json!({})))), + "list_widgets" => ToolResult::from(b.call("ui.widgets", obj(args, &["filter"]))), + "click" => { + let mut p = obj(args, &["x", "y", "count", "button"]); + match s("widget") { + Some(w) => { + p["id"] = json!(w); + ToolResult::from(b.call("ui.clickWidget", p)) + } + None if args.get("x").is_some() && args.get("y").is_some() => ToolResult::from(b.call("ui.click", p)), + None => ToolResult::error("give `widget` or `x` and `y`"), + } + } + "press_key" => ToolResult::from(b.call("ui.key", obj(args, &["key", "cmd", "shift", "alt", "ctrl"]))), + "type_text" => ToolResult::from(b.call("ui.text", obj(args, &["text"]))), + "pointer_gesture" => ToolResult::from(b.call("ui.pointer", obj(args, &["events", "alt", "shift", "cmd"]))), + other => ToolResult::error(format!("unknown tool `{other}` (see tools/list)")), + } +} diff --git a/crates/mcp/tests/e2e.rs b/crates/mcp/tests/e2e.rs new file mode 100644 index 00000000..ea27919c --- /dev/null +++ b/crates/mcp/tests/e2e.rs @@ -0,0 +1,155 @@ +//! 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 lightcraft_mcp::{Backend, Headless, 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 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")); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md new file mode 100644 index 00000000..875d2bce --- /dev/null +++ b/docs/control-protocol.md @@ -0,0 +1,36 @@ +# Control protocol + +`lightcraft --control 7980` (or `LIGHTCRAFT_CONTROL_PORT=7980`) starts a JSON-lines server on +`127.0.0.1:7980` (loopback only). One request per line, one reply per line, in order: + +```text +→ {"id": 1, "method": "engine.execute", "params": {"command": "photo.rate", "params": {"rating": 4}}} +← {"id": 1, "ok": true, "result": null} +← {"id": 2, "ok": false, "error": "unknown command `nope`"} +``` + +Requests are answered on the UI thread between frames (timeout 60 s). The MCP server's connect +mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: +`crates/ui-egui/src/control.rs` (methods) and `apps/lightcraft/src/control_server.rs` (transport). + +## Methods + +| Method | Params | Result | +|---|---|---| +| `engine.execute` (alias `ui.menu.invoke`) | `{command, params?}` | Run any engine or UI command (see `engine.commands`) | +| `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled | +| `ui.menu.list` | — | Menu entries | +| `ui.inspect` | — | UI state, window, canvas/image rects, active photo, selection, perf, status | +| `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) | +| `ui.clickWidget` / `ui.dragWidget` | `{id, count?, fx?, fy?}` / `{id, toX?, toY?, dx?, dy?, steps?}` | Real egui input on a widget | +| `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points | +| `ui.pointer` | `{events: [{kind: down\|drag\|up, x, y}], alt?, shift?, cmd?}` | Gesture in normalized image coordinates (Detail view) | +| `ui.key` | `{key, cmd?, shift?, alt?, ctrl?}` | Key press | +| `ui.text` | `{text}` | Text input | +| `ui.scroll` | `{dx, dy}` | Mouse wheel | +| `ui.set` | partial UI state, e.g. `{"view": "detail"}` | Resulting UI state | +| `ui.dialog.confirm` / `ui.dialog.cancel` | — | Close the open dialog | +| `ui.resize` | `{width, height}` | Resize the window | +| `ui.screenshot` | `{path?}` | `{path, width, height}` once the frame (with finished renders) is captured | +| `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` | +| `app.quit` | — | Close the app | diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 00000000..1242a7a1 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,156 @@ +# LightCraft MCP server + +`lightcraft-cli mcp` exposes LightCraft to AI agents through the +[Model Context Protocol](https://modelcontextprotocol.io): newline-delimited JSON-RPC 2.0 over +stdio (protocol revision `2025-06-18`; `2025-03-26` and `2024-11-05` clients are accepted). The +server lives in `crates/mcp` (`lightcraft-mcp`, layer L5) and is hand-written: no async runtime, +no C dependencies. + +It runs in one of two modes: + +| Mode | Command | What it drives | +|---|---|---| +| **Headless** (default) | `lightcraft-cli mcp [--demo] [FILES/FOLDERS…]` | An in-process engine `Session`. Develop, render and export without a window. | +| **Connect** | `lightcraft-cli mcp --connect [127.0.0.1:7980]` | A running desktop app started with `lightcraft --control 7980`, through its loopback JSON-lines control channel ([control-protocol.md](control-protocol.md)). Adds the UI tools (screenshot, clicks, keys, pointer gestures). | + +Options: `--demo` starts the headless session with the procedurally generated demo library; +`--compact` lists only the helper tools (see below). In connect mode the server starts even when +the app is not running yet and connects on the first call (and reconnects if the app restarts). + +Logs go to stderr; stdout carries only protocol messages. + +## Wiring it into a client + +Build once: `cargo build --release -p lightcraft-cli` (binary: `target/release/lightcraft-cli`). + +### Claude Code + +```sh +# headless, with a folder of photos imported at start +claude mcp add lightcraft -- /path/to/lightcraft/target/release/lightcraft-cli mcp ~/Pictures/shoot + +# or: drive the running desktop app (start it with `lightcraft --control 7980`) +claude mcp add lightcraft-app -- /path/to/lightcraft/target/release/lightcraft-cli mcp --connect 127.0.0.1:7980 +``` + +Or check a project-scoped `.mcp.json` into your repo: + +```json +{ + "mcpServers": { + "lightcraft": { + "command": "/path/to/lightcraft/target/release/lightcraft-cli", + "args": ["mcp", "--connect", "127.0.0.1:7980"] + } + } +} +``` + +### Other clients (Claude Desktop, Cursor, …) + +Every stdio MCP client takes the same shape: a `command` plus `args`. For example +`claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "lightcraft": { + "command": "/path/to/lightcraft/target/release/lightcraft-cli", + "args": ["mcp", "--demo"] + } + } +} +``` + +During development you can also point the client at `cargo run --release -p lightcraft-cli -- mcp` +(with `"cwd"` set to the repository), at the cost of a slower start. + +## Tools + +### Helpers + +| Tool | Does | +|---|---| +| `list_commands {filter?}` | Every command: id, label, menu, shortcut, parameter doc, enabled now | +| `run_command {command, params?}` | Run any command by id | +| `import {paths, album?}` | Import files/folders (folders are scanned recursively); the first new photo becomes active | +| `query_photos {filter?, sort?, offset?, limit?}` | Photos in the current view (or matching a catalog `Filter`) | +| `select_photos {ids, active?, mode?}` | Set the selection / active photo | +| `list_controls {section?}` | Every develop slider: id (`light.exposure`…), range, default, current value | +| `get_develop {id?}` | Full develop-settings JSON | +| `set_develop {id?, values?, settings?, label?}` | `values`: `{controlId: number}`; `settings`: partial develop JSON deep-merged. Undoable | +| `apply_preset {preset, amount?, ids?}` | Apply a preset (ids from `cmd_presets_list`) | +| `crop {id?, rect?, angle?, reset?}` | Normalized crop rect `[x0,y0,x1,y1]` and straighten angle | +| `render_photo {id?, size?, format?, path?}` | Render with current settings → **image content** (PNG, or JPEG with `format: "jpeg"`), long edge `size` (default 1024) | +| `export {path, id?, longEdge?, quality?}` | Full-quality render to `.png` / `.jpg` / `.tif` / `.webp` (headless; the desktop app writes PNG) | + +Tools taking `id` make that photo active first; without it they act on the active photo. + +### Connect mode only + +| Tool | Does | +|---|---| +| `screenshot {maxSize?, format?, path?}` | The app window as an image, after pending renders finish | +| `inspect_ui` | View, panel, window/image rects, selection, status | +| `set_ui {state}` | Merge UI state, e.g. `{"view": "detail"}` | +| `list_widgets {filter?}` / `click {widget \| x,y, count?}` | Widgets by automation id; real egui clicks | +| `press_key {key, cmd?, shift?, alt?}` / `type_text {text}` | Keyboard input (shortcuts) | +| `pointer_gesture {events}` | Gestures in normalized image coordinates (brush strokes, gradients, crop handles) | + +In headless mode these return a tool error explaining how to start the app. + +### One tool per command + +Everything is a command in LightCraft, so `tools/list` also contains one tool per entry of the +command registry (engine commands, plus the app's UI commands such as `view.detail` when +connected): the id with `.` replaced by `_` and a `cmd_` prefix — `photo.rate` → `cmd_photo_rate`, +`develop.set` → `cmd_develop_set`, `edit.undo` → `cmd_edit_undo`. Arguments are the command's +JSON params (documented in each tool's description and by `list_commands`). Pass `--compact` to +leave these out (about 90 headless / 140 connected) when a client struggles with many tools; +`run_command` still reaches every command. + +## Resources + +`resources/list` / `resources/read` serve JSON snapshots: + +| URI | Content | +|---|---| +| `lightcraft://library` | Source, filter, sort, selection, undo/redo labels (`library.state`) | +| `lightcraft://photos` | Photos in the current view (`catalog.query`) | +| `lightcraft://photo/active` | Everything about the active photo (`photo.inspect`) | +| `lightcraft://develop/active` | The active photo's develop settings (`develop.get`) | +| `lightcraft://controls` | Every develop control with its current value (`develop.controls`) | + +## Example session + +```text +→ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"me","version":"1"}}} +← {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{…},"resources":{…}},"serverInfo":{"name":"lightcraft",…},"instructions":"…"}} +→ {"jsonrpc":"2.0","method":"notifications/initialized"} +→ {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"set_develop","arguments":{"values":{"light.exposure":0.7}}}} +← {"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"{…}"}],"isError":false,"structuredContent":{"ok":true,"controls":[{"id":"light.exposure","value":0.7}]}}} +→ {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"render_photo","arguments":{"size":768}}} +← {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"image","data":"iVBORw0…","mimeType":"image/png"},{"type":"text","text":"{\"id\":21,\"width\":512,\"height\":768}"}],"isError":false}} +``` + +Errors from commands (unknown control, nothing selected, app not reachable) come back as tool +results with `isError: true` so the model can read and correct them; malformed JSON-RPC gets the +standard error codes (-32700 parse, -32600 invalid request, -32601 method not found, -32602 +invalid params, -32002 resource not found). + +## Other CLI subcommands + +```sh +lightcraft-cli render in.dng -o out.jpg --set light.exposure=0.5 --set light.contrast=20 --size 2048 +lightcraft-cli render in.jpg -o out.png --settings look.json --preset +lightcraft-cli commands [--json] # the command registry +lightcraft-cli controls [--json] # develop control ids and ranges +``` + +## Tests + +- `crates/mcp/tests/e2e.rs` — M0.9 acceptance: over the stdio framing, set exposure and render; + checks the decoded PNG gets brighter/darker. Runs headless and through the TCP transport + (`Remote`) against a stand-in control server; also import → render → JPEG export of a real file. +- `apps/lightcraft-cli/tests/cli.rs` — spawns `lightcraft-cli mcp` with real pipes; `render`; + `commands`.