//! MCP tools: hand-written helpers (import, develop, render, screenshot, UI input…) plus one tool //! per command in the engine's command registry (`cmd_`), all forwarded to a //! [`Backend`] as control-channel methods. use std::sync::atomic::{AtomicU64, Ordering}; use serde_json::{Map, Value, json}; use crate::backend::Backend; use crate::base64::base64_encode; use crate::headless::{encode_image, expand_paths}; /// Prefix of the tools generated from the command registry. pub const COMMAND_TOOL_PREFIX: &str = "cmd_"; /// The result of `tools/call`: MCP content blocks plus the error flag. #[derive(Clone, Debug, Default, PartialEq)] pub struct ToolResult { pub content: Vec, pub is_error: bool, pub structured: Option, } impl ToolResult { pub fn text(t: impl Into) -> Self { Self { content: vec![json!({"type": "text", "text": t.into()})], ..Default::default() } } /// A JSON result as pretty text plus `structuredContent` when it is an object. pub fn json(v: Value) -> Self { let text = if v.is_null() { "ok".to_string() } else { serde_json::to_string_pretty(&v).unwrap_or_default() }; let mut r = Self::text(text); if v.is_object() { r.structured = Some(v); } r } pub fn error(msg: impl Into) -> Self { Self { is_error: true, ..Self::text(msg) } } fn from(r: Result) -> Self { match r { Ok(v) => Self::json(v), Err(e) => Self::error(e), } } pub fn to_value(&self) -> Value { let mut v = json!({"content": self.content, "isError": self.is_error}); if let Some(s) = &self.structured { v["structuredContent"] = s.clone(); } v } /// The first image block's base64 data and mime type. pub fn image(&self) -> Option<(&str, &str)> { self.content.iter().find(|c| c["type"] == "image").and_then(|c| Some((c["data"].as_str()?, c["mimeType"].as_str()?))) } } fn tool(name: &str, title: &str, description: &str, properties: Value, required: &[&str]) -> Value { json!({ "name": name, "title": title, "description": description, "inputSchema": {"type": "object", "properties": properties, "required": required, "additionalProperties": false}, }) } fn ids_schema(what: &str) -> Value { json!({"type": "array", "items": {"type": "integer"}, "description": what}) } /// The hand-written helper tools. UI tools are included only when a desktop app is attached. pub fn helper_tools(has_ui: bool) -> Vec { let photo_id = json!({"type": "integer", "description": "Photo id (default: the active photo). Makes that photo active first."}); let mut v = vec![ tool( "command_list", "List commands", "Every command (engine + UI when connected) with id, label, menu, shortcut, parameter doc and whether it is enabled now. Each id is also callable as tool `cmd_` or via command_run.", json!({ "filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)"}, "enabled_only": {"type": "boolean", "description": "Only commands that are enabled now"} }), &[], ), tool( "command_run", "Run command", "Run any LightCraft command by id with JSON params (see command_list for ids and parameter docs), e.g. {id: \"photo.rate\", params: {rating: 4}}.", json!({"id": {"type": "string", "description": "Command id"}, "params": {"type": "object", "description": "Command parameters", "additionalProperties": true}}), &["id"], ), tool( "command_batch", "Run commands", "Run several commands in order: {steps: [{id, params?}], stop_on_error?: true}. Returns {completed, failed, results: [{ok, result | error}]}.", json!({ "steps": {"type": "array", "items": {"type": "object", "properties": {"id": {"type": "string"}, "params": {"type": "object", "additionalProperties": true}}, "required": ["id"], "additionalProperties": false}}, "stop_on_error": {"type": "boolean", "description": "Stop at the first failing step (default true)"} }), &["steps"], ), tool( "doc_inspect", "Inspect library", "The library as JSON: current source, filter, sort, selection, active photo and undo/redo labels (library.state) plus counts (catalog.stats).", json!({}), &[], ), tool( "render_preview", "Render preview", "Render a photo with its current develop settings (cropped) and return it as an image (`max_side` is the long edge; does not change selection or save a file).", json!({ "id": {"type": "integer", "description": "Photo id (default: the active photo); selection is unchanged"}, "max_side": {"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)"}, }), &[], ), tool( "list_commands", "List commands", "Every command (engine + UI when connected) with id, label, menu, shortcut, parameter doc and whether it is enabled now. Each id is also callable as tool `cmd_` or via run_command.", json!({"filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)"}}), &[], ), tool( "run_command", "Run command", "Run any LightCraft command by id with JSON params (see list_commands for ids and parameter docs), e.g. {command: \"photo.rate\", params: {rating: 4}}.", json!({"command": {"type": "string"}, "params": {"type": "object", "description": "Command parameters", "additionalProperties": true}}), &["command"], ), tool( "import", "Import photos", "Add image/raw files to the library. Folders are scanned recursively for photos; relative paths resolve against the server's working directory. The first imported photo becomes active. Returns the new photo ids.", json!({ "paths": {"type": "array", "items": {"type": "string"}, "description": "Files and/or folders"}, "album": {"type": "integer", "description": "Also add to this album id"}, "mode": {"type": "string", "enum": ["add", "copy", "move"], "description": "add (default) = reference the files in place; copy = copy them into `destination` (default: the library's Originals/); move = as copy, then each original and its XMP sidecars are removed from the source once the copy is verified and catalogued (duplicates and failures keep their sources; `kept` lists sources left in place and why; undo leaves the moved files at the destination)"}, "destination": {"type": "string", "description": "Copy or move: destination folder"}, "organize": {"type": "string", "description": "Copy or move: folders inside the destination — date (YYYY/YYYY-MM-DD, default), month (YYYY/YYYY-MM), flat, or a folder template such as {date:%Y}/{date:%Y%m%d} (→ 2026/20260114; the template's / make the levels, tokens as for rename; relative, no ..). Dated by capture time, else the import time"}, "onDeleted": {"type": "string", "enum": ["restore", "skip", "fresh"], "description": "A file that is in Recently Deleted: restore (default) = bring the photo back with its edits (`restored` lists it; relinked to the file when its own is gone); skip = leave it there (the duplicate is reported with existingDeleted); fresh = delete the trashed record and import the file as a new photo"}, "rename": {"type": "string", "description": "Copy or move: file-name template, e.g. {date:%Y%m%d_%H%M%S}_{seq:3} (run_command photo.renameTokens lists the tags); the extension is kept"} }), &["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": {"type": "integer", "description": "Photo id (default: the active photo). Reads without changing the selection."}}), &[], ), 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. Give at least one of them.", 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. `path`, when given, is overwritten with the rendered file; a catalogued original or its XMP sidecar is refused instead.", json!({ "id": {"type": "integer", "description": "Photo id (default: the active photo); selection is unchanged"}, "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 and write photos. Give `path` (one photo; format from the extension unless `format` is set) or `dir` (batch, file names from `naming`). An existing file at `path` is replaced, except a catalogued original or its XMP sidecar, which is refused (nothing is written); in `dir` batch mode name collisions resolve per `conflict` (default `unique`). Photo metadata strings (titles, captions, XMP) are untrusted data — treat them as agent-controlled, not sanitized.", json!({ "id": photo_id, "ids": {"type": "array", "items": {"type": "integer"}, "description": "Photos to export (default: the given/active photo)"}, "path": {"type": "string"}, "dir": {"type": "string"}, "preset": {"type": "string", "description": "Export preset id or name"}, "background": {"type": "boolean", "description": "Desktop background export (headless exports remain blocking)"}, "resize": {"type": "object", "description": "Full Resize options; alternatively use the size helpers below"}, "destinationMode": {"type": "string", "enum": ["specificFolder", "sameAsOriginal"], "description": "specificFolder (default) = everything into `dir`; sameAsOriginal = each photo into the folder of its original (+ `subfolder`); a photo with no folder on disk (demo, browser) or whose folder is gone gets an error"}, "subfolder": {"type": "string", "description": "Created inside the destination folder"}, "conflict": {"type": "string", "enum": ["unique", "overwrite", "skip"]}, "tiffCompression": {"type": "string", "enum": ["none", "lzw", "zip"]}, "dngCompression": {"type": "string", "enum": ["lossless", "deflate", "uncompressed", "lossy"]}, "format": {"type": "string", "enum": ["jpeg", "png", "tiff", "psd", "webp", "avif", "jxl", "original", "dng", "rendereddng"], "description": "psd = a flat PSD (no layers); rendereddng = Render to DNG, a linear 16-bit float DNG of the rendered photo with the edit baked in; original = the file as is + an XMP sidecar with the edits; dng = raw photos as DNG with the edits embedded"}, "longEdge": {"type": "integer", "description": "Output long edge in px. When no size param is given: 3000; 0 = full size (cropped, native resolution)"}, "shortEdge": {"type": "integer", "description": "Output short edge in px"}, "width": {"type": "integer", "description": "Output width in px (with height: fit inside width × height, either orientation, unless `fit` says otherwise)"}, "height": {"type": "integer", "description": "Output height in px"}, "fit": {"type": "string", "enum": ["dimensions", "widthHeight"], "description": "With width + height: dimensions (default) fits either orientation (the larger number goes to the long edge); widthHeight limits the width to `width` and the height to `height` as the photo stands"}, "megapixels": {"type": "number", "description": "Output size in megapixels"}, "percent": {"type": "number", "description": "Output size as a percentage of full size"}, "dontEnlarge": {"type": "boolean", "description": "Never upscale past the photo's own size (default true)"}, "ppi": {"type": "integer", "description": "Print resolution written into the file (default 240)"}, "quality": {"type": "integer", "description": "JPEG/AVIF/lossy JPEG XL quality 1..100"}, "jxlLossless": {"type": "boolean", "description": "JPEG XL: mathematically lossless (quality unused); default false"}, "jxlEffort": {"type": "string", "enum": ["fast", "normal"], "description": "JPEG XL encoder effort: normal (default) makes files about a quarter smaller than fast, in about twice the time"}, "jxlProgressive": {"type": "boolean", "description": "JPEG XL: progressive file, a coarse image first (default false)"}, "limitKb": {"type": "integer", "description": "JPEG: largest quality that fits this many KB"}, "sharpen": {"type": "string", "enum": ["none", "screen", "matte", "glossy"]}, "sharpenAmount": {"type": "string", "enum": ["low", "standard", "high"]}, "naming": {"type": "string", "description": "File-name template, e.g. {name}-{seq} or {date:%Y-%m-%d}_{title}_{seq:2} (tokens: {name} {num} {seq} {seq:N} {date} {date:%…} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext})"}, "startNumber": {"type": "integer", "description": "First {seq} value (default 1)"}, "metadata": {"type": "string", "enum": ["all", "allExceptCamera", "copyright", "none"]}, "removeLocation": {"type": "boolean"}, "colorSpace": {"type": "string", "enum": ["srgb", "displayP3", "adobeRgb", "proPhoto", "rec2020"], "description": "Output colour space (default sRGB; AVIF is always sRGB; lossy JPEG XL writes adobeRgb/proPhoto as rec2020). adobeRgb = Adobe RGB (1998) compatible; the embedded ICC profile is generated from the published primaries"}, "bitDepth": {"type": "integer", "enum": [8, 10, 16, 32], "description": "Bits per channel: PNG 8|16 (default 8), TIFF 8|16|32 (default 16; 32 = linear float with a linear profile), PSD 8|16 (default 16), AVIF 8|10, JPEG XL 8|16; JPEG/WebP are 8-bit"}, "hdr": {"type": "boolean", "description": "HDR output for photos edited in HDR (develop.hdr): JPEG as an ISO 21496-1 gain map JPEG, AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ, 32-bit TIFF with highlights above 1; other formats and SDR edits export as usual"}, "hdrTransfer": {"type": "string", "enum": ["pq", "hlg"], "description": "Transfer curve of HDR AVIF output (default pq): pq = SMPTE ST 2084, hlg = ITU-R BT.2100 hybrid log-gamma"}, "addToPhotos": {"type": "boolean", "description": "macOS: after writing the files, add them to Apple Photos (Apple Events to Photos' import; the first time, macOS asks the user to allow LightCraft to control Photos). The outcome is `applePhotos` in the result, the import job: {job, running, requested, album, imported?, ids?, warning?, error?}; headless waits for Photos, the desktop app returns the running job (outcome: command export.photosImports {job}). The files are written either way. An error before exporting on other platforms, or while another import is reserved or running"}, "photosAlbum": {"type": "string", "description": "With addToPhotos: the album to add them to, a top-level Photos album of exactly this name, made when missing (several of that name: nothing is added); default none"}, "watermark": {"description": "Text, or {text, vertical (boolean; defaults to false: upright columns right to left), size (text height as a fraction of the short edge, 0.005..0.5; default 0.035), opacity (0..1; default 0.7), anchor (topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight), insetX (margin from the left or right edge as a fraction of the short edge, 0..0.4; default 0.025), insetY (margin from the top or bottom edge, same units), inset (sets both), rotation (degrees clockwise: 0|90|180|270; default 0; the watermark is sized before it is turned), color [r,g,b] (sRGB 0..255), shadow (boolean), image (path of a graphic drawn instead of the text), imageWidth (fraction of the photo's width, 0.01..1; default 0.2)}. Unknown keys and out-of-range sizes are errors"} }), &[], ), ]; if has_ui { v.extend([ tool("ui_inspect", "Inspect UI", "UI state and selection (same as inspect_ui).", json!({}), &[]), tool("ui_screenshot", "Screenshot", "Return the window as an image without saving a file.", json!({"maxSize": {"type": "integer"}, "format": {"type": "string", "enum": ["png", "jpeg"]}}), &[]), tool( "screenshot", "Screenshot", "Capture the LightCraft window (after pending renders finish) and return it as an image. `path`, when given, is overwritten with the screenshot file; a catalogued original or its XMP sidecar is refused instead.", 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( "clipboard", "Cut, copy or paste", "Cut, copy or paste in the focused text field (click one first), as ⌘X / ⌘C / ⌘V do; cut and copy write the system clipboard. `paste` pastes `text`, or what the system clipboard holds without it. inspect_ui → `copied` is the text the app last copied.", json!({"action": {"type": "string", "enum": ["cut", "copy", "paste"]}, "text": {"type": "string"}}), &["action"], ), tool( "pointer_gesture", "Pointer gesture", "Replay a pointer gesture on the photo in normalized image coordinates (0..1, origin top-left): brush strokes, gradient drags, crop handles, heal spots. Needs the Detail view.", json!({ "events": {"type": "array", "items": {"type": "object", "properties": {"kind": {"type": "string", "enum": ["down", "drag", "move", "up"]}, "x": {"type": "number"}, "y": {"type": "number"}}, "required": ["kind", "x", "y"]}}, "alt": {"type": "boolean"}, "shift": {"type": "boolean"}, "cmd": {"type": "boolean"} }), &["events"], ), ]); } v } /// MCP tool name of a command id (`develop.set` → `cmd_develop_set`). pub fn command_tool_name(id: &str) -> String { format!("{COMMAND_TOOL_PREFIX}{}", id.replace(['.', ' '], "_")) } /// One tool per command reported by the backend's `engine.commands`. pub fn command_tools(backend: &mut dyn Backend) -> Vec { let Ok(Value::Array(cmds)) = backend.call("engine.commands", json!({})) else { return Vec::new() }; cmds.iter() .filter_map(|c| { let id = c["id"].as_str()?; let label = c["label"].as_str().unwrap_or(id); let mut d = format!("{label} (command `{id}`)."); if let Some(p) = c["params"].as_str().filter(|p| !p.is_empty()) { d.push_str(&format!(" Params: {p}.")); } if let Some(m) = c["menu"].as_array().filter(|m| m.iter().any(|x| x.as_str().is_some_and(|s| !s.is_empty()))) { let m: Vec<&str> = m.iter().filter_map(Value::as_str).collect(); d.push_str(&format!(" Menu: {}.", m.join(" › "))); } if let Some(s) = c["shortcut"].as_str() { d.push_str(&format!(" Shortcut: {s}.")); } Some(json!({ "name": command_tool_name(id), "title": label, "description": d, "inputSchema": {"type": "object", "additionalProperties": true}, })) }) .collect() } /// Conservative hints: generated command tools may edit; optional output paths make a helper a writer. fn annotations(name: &str, title: &str) -> Value { let read_only = matches!( name, "command_list" | "list_commands" | "doc_inspect" | "render_preview" | "query_photos" | "list_controls" | "get_develop" | "inspect_ui" | "list_widgets" | "ui_inspect" | "ui_screenshot" ); json!({"title": title, "readOnlyHint": read_only, "destructiveHint": !read_only, "idempotentHint": read_only || matches!(name, "render_photo" | "screenshot"), "openWorldHint": false}) } /// Helpers have fixed keys; generated command tools retain the registry's free-form params. pub(crate) fn check_args(name: &str, args: &Value) -> Result<(), String> { if args.is_null() { return Ok(()); } let Some(m) = args.as_object() else { return Err("tool arguments must be an object".into()); }; let defs = helper_tools(true); let Some(def) = defs.iter().find(|t| t["name"] == name) else { return Ok(()); }; let Some(props) = def["inputSchema"]["properties"].as_object() else { return Ok(()); }; if let Some(bad) = m.keys().find(|k| !props.contains_key(*k)) { let accepted: Vec<&str> = props.keys().map(String::as_str).collect(); return Err(format!("unknown argument \"{bad}\" for {name}; expected: {}", accepted.join(", "))); } Ok(()) } /// All tools: helpers, plus the generated command tools when `with_commands`. pub fn tool_definitions(backend: &mut dyn Backend, with_commands: bool) -> Vec { let mut v = helper_tools(backend.has_ui()); if with_commands { v.extend(command_tools(backend)); } for t in &mut v { let name = t["name"].as_str().unwrap_or_default().to_string(); let title = t["title"].as_str().unwrap_or(&name).to_string(); t["annotations"] = annotations(&name, &title); } v } fn exec(b: &mut dyn Backend, command: &str, params: Value) -> Result { b.call("engine.execute", json!({"command": command, "params": params})) } /// Make `args.id` (if any) the active photo. fn activate(b: &mut dyn Backend, args: &Value) -> Result<(), String> { if let Some(id) = args.get("id").and_then(Value::as_u64) { exec(b, "library.select", json!({"ids": [id], "active": id}))?; } Ok(()) } fn temp_path(tag: &str) -> std::path::PathBuf { static N: AtomicU64 = AtomicU64::new(0); std::env::temp_dir().join(format!("lightcraft-mcp-{}-{}-{tag}.png", std::process::id(), N.fetch_add(1, Ordering::Relaxed))) } /// Read a PNG the backend wrote, optionally downscale / re-encode as JPEG, and wrap it as image /// content (+ a text block with the metadata). fn image_result(file: &std::path::Path, max: Option, format: &str, save_to: Option<&str>, meta: Value) -> ToolResult { let bytes = match std::fs::read(file) { Ok(b) => b, Err(e) => return ToolResult::error(format!("cannot read rendered image {}: {e}", file.display())), }; let _ = std::fs::remove_file(file); let jpeg = matches!(format, "jpeg" | "jpg"); let (w, h) = (meta["width"].as_u64().unwrap_or(0) as u32, meta["height"].as_u64().unwrap_or(0) as u32); let too_big = max.is_some_and(|m| w.max(h) > m); let (bytes, w, h) = if jpeg || too_big { let opts = match max { Some(m) => lightcraft_codecs::DecodeOptions::fit(m, m), None => lightcraft_codecs::DecodeOptions::default(), }; let d = match lightcraft_codecs::decode(&bytes, opts) { Ok(d) => d, Err(e) => return ToolResult::error(format!("cannot decode rendered image: {e}")), }; let img = d.to_srgb8(); match encode_image(if jpeg { "jpg" } else { "png" }, &img, 88) { Ok(b) => (b, img.width as u32, img.height as u32), Err(e) => return ToolResult::error(e), } } else { (bytes, w, h) }; if let Some(p) = save_to && let Err(e) = lightcraft_engine::export::write_file(p, &bytes) { return ToolResult::error(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, } } /// A user-given `path` to save to must not be a photo's original (or its sidecar). fn check_save_path(b: &mut dyn Backend, args: &Value) -> Result<(), String> { match args.get("path").and_then(Value::as_str) { Some(p) => exec(b, "export.checkTarget", json!({"path": p})).map(|_| ()), None => Ok(()), } } fn render_photo(b: &mut dyn Backend, args: &Value) -> ToolResult { if let Err(e) = check_save_path(b, args) { return ToolResult::error(e); } 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 { if let Err(e) = check_save_path(b, args) { return ToolResult::error(e); } 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" | "command_list" => { let filter = s("filter").unwrap_or("").to_lowercase(); let enabled_only = args.get("enabled_only").and_then(Value::as_bool).unwrap_or(false); ToolResult::from(b.call("engine.commands", json!({})).map(|v| { match v { Value::Array(a) => Value::Array( a.into_iter() .filter(|c| { filter.is_empty() || c["id"].as_str().unwrap_or("").to_lowercase().contains(&filter) || c["label"].as_str().unwrap_or("").to_lowercase().contains(&filter) }) .filter(|c| !enabled_only || c["enabled"].as_bool() != Some(false)) .collect(), ), v => v, } })) } "command_run" => match s("id") { Some(c) => ToolResult::from(exec(b, c, args.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({})))), None => ToolResult::error("missing `id`"), }, "command_batch" => { let Some(steps) = args.get("steps").and_then(Value::as_array) else { return ToolResult::error("missing `steps`") }; let stop = args.get("stop_on_error").and_then(Value::as_bool).unwrap_or(true); let (mut completed, mut failed, mut results) = (0, 0, Vec::new()); for st in steps { let Some(id) = st["id"].as_str() else { failed += 1; results.push(json!({"ok": false, "error": "step without `id`"})); if stop { break; } continue; }; match exec(b, id, st.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({}))) { Ok(r) => { completed += 1; results.push(json!({"ok": true, "result": r})); } Err(e) => { failed += 1; results.push(json!({"ok": false, "error": e})); if stop { break; } } } } let r = json!({"completed": completed, "failed": failed, "results": results}); if failed > 0 { ToolResult { is_error: true, ..ToolResult::json(r) } } else { ToolResult::json(r) } } "doc_inspect" => ToolResult::from(doc_inspect(b)), "render_preview" => { let mut a = obj(args, &["id", "format"]); if let Some(m) = args.get("max_side") { a["size"] = m.clone(); } render_photo(b, &a) } "run_command" => match s("command") { Some(c) => ToolResult::from(exec(b, c, args.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({})))), None => ToolResult::error("missing `command`"), }, "import" => { let paths: Vec = match args.get("paths") { Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(), Some(Value::String(p)) => vec![p.clone()], _ => return ToolResult::error("missing `paths`"), }; let files = expand_paths(&paths); if files.is_empty() { return ToolResult::error(format!("no photos found in {paths:?}")); } let mut p = json!({"paths": files}); for k in ["album", "mode", "destination", "organize", "rename", "onDeleted"] { if let Some(v) = args.get(k).filter(|v| !v.is_null()) { p[k] = v.clone(); } } ToolResult::from(exec(b, "library.import", p).map(|mut r| { if let Some(o) = r.as_object_mut() { o.insert("files".into(), json!(files.len())); } r })) } "query_photos" => ToolResult::from(exec(b, "catalog.query", obj(args, &["filter", "sort", "offset", "limit"]))), "select_photos" => { let mut p = obj(args, &["ids", "active", "mode"]); if p.get("active").is_none() && let Some(first) = args["ids"].get(0) { p["active"] = first.clone(); } ToolResult::from(exec(b, "library.select", p)) } "list_controls" => ToolResult::from(exec(b, "develop.controls", obj(args, &["section"]))), "get_develop" => ToolResult::from(exec(b, "develop.get", obj(args, &["id"]))), "set_develop" => { let r = (|| { activate(b, args)?; let values = args.get("values").filter(|v| v.as_object().is_some_and(|o| !o.is_empty())); let settings = args.get("settings").filter(|v| v.as_object().is_some_and(|o| !o.is_empty())); if values.is_none() && settings.is_none() { return Err("give `values` ({controlId: number}) and/or `settings` (partial develop JSON)".to_string()); } if let Some(v) = values { exec(b, "develop.set", json!({"values": v}))?; } if let Some(st) = settings { let mut p = json!({"settings": st}); if let Some(l) = args.get("label") { p["label"] = l.clone(); } exec(b, "develop.merge", p)?; } // Echo the resulting values of the touched sliders. let controls = exec(b, "develop.controls", json!({}))?; let touched: Vec = match (values, controls) { (Some(Value::Object(v)), Value::Array(all)) => all .into_iter() .filter(|c| c["id"].as_str().is_some_and(|id| v.contains_key(id))) .map(|c| json!({"id": c["id"], "value": c["value"]})) .collect(), _ => Vec::new(), }; Ok(json!({"ok": true, "controls": touched})) })(); ToolResult::from(r) } "apply_preset" => match s("preset").or(s("id")) { Some(p) => { let mut params = obj(args, &["amount", "ids"]); params["id"] = json!(p); ToolResult::from(exec(b, "preset.apply", params)) } None => ToolResult::error("missing `preset`"), }, "crop" if args.get("rect").is_none() && args.get("angle").is_none() && args.get("reset").and_then(Value::as_bool) != Some(true) => { ToolResult::error("give `rect`, `angle` or `reset: true`") } "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, &[ "ids", "path", "dir", "format", "longEdge", "shortEdge", "width", "height", "megapixels", "percent", "fit", "dontEnlarge", "ppi", "quality", "limitKb", "sharpen", "sharpenAmount", "naming", "startNumber", "metadata", "removeLocation", "watermark", "colorSpace", "bitDepth", "hdr", "hdrTransfer", "preset", "background", "resize", "destinationMode", "subfolder", "conflict", "tiffCompression", "dngCompression", "addToPhotos", "photosAlbum", ], ), ) })), "screenshot" | "ui_screenshot" => screenshot(b, args), "inspect_ui" | "ui_inspect" => 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"]))), "clipboard" => ToolResult::from(b.call("ui.clipboard", obj(args, &["action", "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)")), } } /// The canonical document resource and tool share the same snapshot. pub(crate) fn doc_inspect(b: &mut dyn Backend) -> Result { let library = exec(b, "library.state", json!({}))?; let stats = exec(b, "catalog.stats", json!({}))?; Ok(json!({"library": library, "stats": stats})) }