M0.9: MCP server + lightcraft-cli

crates/mcp (lightcraft-mcp, L5): hand-written MCP over stdio (JSON-RPC 2.0,
protocol 2025-06-18 with 2025-03-26/2024-11-05 negotiation): initialize,
ping, tools/list, tools/call, resources/list/read. Two backends behind one
trait: Headless (in-process engine Session; answers engine.execute,
engine.commands, ui.render, app.export) and Remote (the desktop app's
loopback JSON-lines control channel; lazy connect + reconnect).

Tools: helpers (list_commands, run_command, import, query_photos,
select_photos, list_controls, get/set_develop, apply_preset, crop,
render_photo -> image content, export), UI tools when connected
(screenshot -> image content, inspect_ui, set_ui, list_widgets, click,
press_key, type_text, pointer_gesture), plus one cmd_<id> tool generated
per command in the registry (--compact omits them).

apps/lightcraft-cli: `mcp [--connect [ADDR]] [--demo] [--compact] [FILES]`,
`render <in> -o <out> [--set control=value]... [--settings] [--preset]
[--size] [--quality]`, `commands`, `controls`.

Acceptance e2e: crates/mcp/tests/e2e.rs sets exposure over MCP and renders
(headless and through the TCP transport); apps/lightcraft-cli/tests/cli.rs
does the same through the real binary's stdio. Docs: docs/mcp.md,
docs/control-protocol.md. Also rustfmt of one line in pipeline/finish.rs
(main was not fmt-clean).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-09-30 23:37:08 -04:00
parent ac14995c75
commit 50307e59db
16 changed files with 1952 additions and 1 deletions

22
Cargo.lock generated
View File

@ -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"

View File

@ -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 }

View File

@ -0,0 +1,232 @@
//! `lightcraft-cli`: headless LightCraft.
//!
//! ```text
//! lightcraft-cli mcp [--connect [ADDR]] [--demo] [--compact] [FILES/FOLDERS…]
//! lightcraft-cli render <in> -o <out> [--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 <IN> -o <OUT> [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<String> = 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<String> = 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<dyn Backend> = 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<Value> = None;
let mut preset = None;
let mut size: Option<u64> = 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(())
}

View File

@ -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::<f64>() / (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"));
}

16
crates/mcp/Cargo.toml Normal file
View File

@ -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 }

112
crates/mcp/src/backend.rs Normal file
View File

@ -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<Value, String>;
/// 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>, 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<Self> {
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<String> {
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<Value, String> {
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)
}
}

60
crates/mcp/src/base64.rs Normal file
View File

@ -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<Vec<u8>> {
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<u8> = (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());
}
}

162
crates/mcp/src/headless.rs Normal file
View File

@ -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<PhotoId, String> {
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<Rgba8, String> {
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<Value, String> {
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<Value, String> {
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(&params);
}
self.session.execute(id, &params).map_err(|e| e.to_string())
}
"engine.commands" => {
let mut v: Vec<Value> = 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<String> {
fn walk(p: &Path, out: &mut Vec<String>) {
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<Vec<u8>, 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()))
}

33
crates/mcp/src/lib.rs Normal file
View File

@ -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;

191
crates/mcp/src/server.rs Normal file
View File

@ -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<dyn Backend>,
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<String>) -> 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<dyn Backend>) -> 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<String> {
let line = line.trim();
if line.is_empty() {
return None;
}
let reply = match serde_json::from_str::<Value>(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<Value> = 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<Value> {
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, &params);
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, &params) {
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<Value, (i64, String)> {
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}"))),
}
}
}

126
crates/mcp/src/tests.rs Normal file
View File

@ -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::<Value>(text).unwrap();
}
assert_eq!(rpc(&mut s, 99, "resources/read", json!({"uri": "lightcraft://nope"}))["error"]["code"], -32002);
}

523
crates/mcp/src/tools.rs Normal file
View File

@ -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_<id with . → _>`), 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<Value>,
pub is_error: bool,
pub structured: Option<Value>,
}
impl ToolResult {
pub fn text(t: impl Into<String>) -> 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<String>) -> Self {
Self { is_error: true, ..Self::text(msg) }
}
fn from(r: Result<Value, String>) -> 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<Value> {
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_<id with . replaced by _>` 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<Value> {
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<Value> {
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<Value, String> {
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<u32>, 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<String> = 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<Value> = 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)")),
}
}

155
crates/mcp/tests/e2e.rs Normal file
View File

@ -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<Value> {
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"));
}

View File

@ -101,7 +101,11 @@ fn grain_noise(x: f32, y: f32, seed: u32) -> f32 {
pub fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInfo) -> Rgba8 {
let (w, h) = (p.img.width, p.img.height);
let tone = if info.raw { ToneMap::new(s.light.contrast, s.light.whites, s.light.blacks) } else { ToneMap::display(s.light.contrast, s.light.whites, s.light.blacks) };
let tone = if info.raw {
ToneMap::new(s.light.contrast, s.light.whites, s.light.blacks)
} else {
ToneMap::display(s.light.contrast, s.light.whites, s.light.blacks)
};
let ops = ColorOps::new(s);
let curves = curve_luts(&s.curve);
let vig = if s.section_enabled("effects") { vignette(s) } else { None };

36
docs/control-protocol.md Normal file
View File

@ -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 |

156
docs/mcp.md Normal file
View File

@ -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 <presetId>
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`.