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:
parent
ac14995c75
commit
50307e59db
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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(¶ms);
|
||||
}
|
||||
self.session.execute(id, ¶ms).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()))
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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, ¶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<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}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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)")),
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
@ -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`.
|
||||
Loading…
Reference in New Issue