From 5c16320f6bdff936c4a65111b035fd7f9ace15a8 Mon Sep 17 00:00:00 2001 From: storybored379 Date: Thu, 8 Oct 2026 23:02:13 -0700 Subject: [PATCH] MCP: add shared core tools, hints and resources Keep documented helpers listed, reject unknown helper keys, and recover escaped backend panics. Cover core tools, resource snapshots and modern result metadata with regression tests. Co-Authored-By: OpenAI Codex --- crates/mcp/src/server.rs | 66 ++++++++++++++-- crates/mcp/src/tests.rs | 115 ++++++++++++++++++++++++++- crates/mcp/src/tools.rs | 167 +++++++++++++++++++++++++++++++++++++-- docs/mcp.md | 33 ++++++++ 4 files changed, 366 insertions(+), 15 deletions(-) diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index c3371be5f..3f1f12f0c 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -5,7 +5,7 @@ use std::io::{BufRead, Write}; use serde_json::{Value, json}; use crate::backend::Backend; -use crate::tools::{call_tool, tool_definitions}; +use crate::tools::{ToolResult, call_tool, check_args, doc_inspect, tool_definitions}; /// The MCP revision we implement. pub const PROTOCOL_VERSION: &str = "2025-06-18"; @@ -22,11 +22,13 @@ 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)."; +render_preview 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: command_list shows ids and parameter \ +docs; command_run {id, params} runs any of them; command_batch runs steps in order. Inspect with doc_inspect. Edits are undoable (undo / redo)."; /// Resource URIs. +pub const DOCUMENT_URI: &str = "lightcraft://document"; +pub const COMMANDS_URI: &str = "lightcraft://commands"; pub const LIBRARY_URI: &str = "lightcraft://library"; pub const PHOTOS_URI: &str = "lightcraft://photos"; pub const PHOTO_URI: &str = "lightcraft://photo/active"; @@ -132,7 +134,10 @@ impl Server { return Some(error(id, INVALID_PARAMS, "`params` must be an object")); } Some(match self.request(method, ¶ms) { - Ok(r) => response(id, r), + Ok(mut r) => { + modern_result_fields(method, ¶ms, &mut r); + response(id, r) + } Err((code, m)) => error(id, code, m), }) } @@ -158,13 +163,20 @@ impl Server { })) } "ping" => Ok(json!({})), - "tools/list" => Ok(json!({"tools": tool_definitions(self.backend.as_mut(), self.command_tools)})), + "tools/list" => { + let command_tools = self.command_tools; + let tools = self.guarded(|b| tool_definitions(b, command_tools)).map_err(|e| (INTERNAL_ERROR, e))?; + Ok(json!({"tools": 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()) + check_args(name, &args).map_err(|e| (INVALID_PARAMS, e))?; + Ok(self.guarded(|b| call_tool(b, name, &args)).unwrap_or_else(ToolResult::error).to_value()) } "resources/list" => Ok(json!({"resources": [ + resource(DOCUMENT_URI, "document", "Library document", "Library state and counts (same as doc_inspect)"), + resource(COMMANDS_URI, "commands", "Command catalog", "Every command (same as command_list)"), 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)"), @@ -175,6 +187,7 @@ impl Server { "resources/read" => { let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri`".to_string()))?; let cmd = match uri { + DOCUMENT_URI | COMMANDS_URI => "", LIBRARY_URI => "library.state", PHOTOS_URI => "catalog.query", PHOTO_URI => "photo.inspect", @@ -182,10 +195,47 @@ impl Server { 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))?; + let v = self + .guarded(|b| match uri { + DOCUMENT_URI => doc_inspect(b), + COMMANDS_URI => b.call("engine.commands", json!({})), + _ => b.call("engine.execute", json!({"command": cmd, "params": {}})), + }) + .and_then(|v| v) + .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}"))), } } } + +impl Server { + /// Escaped backend panics become errors; subsequent requests can keep using the session. + fn guarded(&mut self, f: impl FnOnce(&mut dyn Backend) -> T) -> Result { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self.backend.as_mut()))).map_err(|p| { + let message = + p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_else(|| "panic".into()); + format!("internal error: {message}") + }) + } +} + +/// Per-request protocol metadata leaves initialize-based clients' result shapes unchanged. +fn modern_result_fields(method: &str, params: &Value, result: &mut Value) { + let modern = params.pointer("/_meta/io.modelcontextprotocol~1protocolVersion").and_then(Value::as_str).is_some_and(|v| v >= "2026-07-28"); + let ttl = match method { + "tools/list" | "resources/list" | "resources/templates/list" => Some(600_000), + "resources/read" => Some(0), + "tools/call" => None, + _ => return, + }; + let Some(obj) = result.as_object_mut().filter(|_| modern) else { + return; + }; + obj.insert("resultType".into(), json!("complete")); + if let Some(ttl) = ttl { + obj.insert("ttlMs".into(), json!(ttl)); + obj.insert("cacheScope".into(), json!("private")); + } +} diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index ee17e1ad7..b58f195b9 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -215,7 +215,7 @@ 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); + assert_eq!(list.len(), 7); 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})); @@ -245,3 +245,116 @@ fn select_photos_rejects_unknown_ids_and_keeps_the_active_photo() { assert!(r.is_error, "{r:?}"); assert_eq!(b.session.active(), Some(first)); } + +#[test] +fn core_tools_have_hints_and_reject_unknown_keys() { + for compact in [false, true] { + let mut s = server().with_command_tools(!compact); + let list = rpc(&mut s, 1, "tools/list", json!({})); + let tools = list["result"]["tools"].as_array().unwrap(); + for name in ["command_list", "command_run", "command_batch", "doc_inspect", "render_preview", "list_commands", "run_command", "render_photo"] + { + assert!(tools.iter().any(|t| t["name"] == name), "missing {name}"); + } + for t in tools { + assert!(!t["title"].as_str().unwrap().is_empty()); + for hint in ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] { + assert!(t["annotations"][hint].is_boolean(), "{t}"); + } + } + for name in ["command_list", "command_run", "command_batch", "doc_inspect", "render_preview", "query_photos", "run_command"] { + let r = rpc(&mut s, 2, "tools/call", json!({"name":name,"arguments":{"misspelled":true}})); + assert_eq!(r["error"]["code"], -32602, "{r}"); + assert!(r["error"]["message"].as_str().unwrap().contains("misspelled"), "{r}"); + } + let preview = tools.iter().find(|t| t["name"] == "render_preview").unwrap(); + assert_eq!(preview["annotations"]["readOnlyHint"], true); + assert!(preview["inputSchema"]["properties"].get("path").is_none()); + let writer = tools.iter().find(|t| t["name"] == "render_photo").unwrap(); + assert_eq!(writer["annotations"]["readOnlyHint"], false); + let export = tools.iter().find(|t| t["name"] == "export").unwrap(); + for key in lightcraft_engine::export::OPTION_PARAMS.iter().chain(lightcraft_engine::export::TARGET_PARAMS) { + assert!(export["inputSchema"]["properties"].get(*key).is_some(), "missing export argument {key}"); + } + let before = rpc(&mut s, 2, "resources/read", json!({"uri":"lightcraft://library"})); + let image = rpc(&mut s, 2, "tools/call", json!({"name":"render_preview","arguments":{"max_side":64}})); + assert_eq!(image["result"]["isError"], false, "{image}"); + assert!(image["result"]["content"].as_array().unwrap().iter().any(|c| c["type"] == "image")); + assert_eq!(before, rpc(&mut s, 2, "resources/read", json!({"uri":"lightcraft://library"}))); + let batch = json!({"steps":[{"id":"catalog.stats"},{"id":"no.such"},{"id":"catalog.stats"}],"stop_on_error":false}); + let r = rpc(&mut s, 3, "tools/call", json!({"name":"command_batch","arguments":batch})); + assert_eq!(r["result"]["isError"], true); + assert_eq!(r["result"]["structuredContent"]["completed"], 2); + assert_eq!(r["result"]["structuredContent"]["failed"], 1); + let r = rpc(&mut s, 4, "tools/call", json!({"name":"command_batch","arguments":{"steps":[{"id":"no.such"},{"id":"catalog.stats"}]}})); + assert_eq!(r["result"]["structuredContent"]["completed"], 0); + assert_eq!(r["result"]["structuredContent"]["results"].as_array().unwrap().len(), 1); + } +} + +#[test] +fn core_resources_match_tools_after_parse_error() { + let mut s = server(); + let bad: Value = serde_json::from_str(&s.handle_line("{bad").unwrap()).unwrap(); + assert_eq!(bad["error"]["code"], -32700); + assert!(bad["id"].is_null()); + let list = rpc(&mut s, 1, "resources/list", json!({})); + for (uri, tool) in [("lightcraft://document", "doc_inspect"), ("lightcraft://commands", "command_list")] { + assert!(list["result"]["resources"].as_array().unwrap().iter().any(|r| r["uri"] == uri)); + let resource = rpc(&mut s, 2, "resources/read", json!({"uri":uri})); + let call = rpc(&mut s, 3, "tools/call", json!({"name":tool,"arguments":{}})); + let a: Value = serde_json::from_str(resource["result"]["contents"][0]["text"].as_str().unwrap()).unwrap(); + let b: Value = serde_json::from_str(call["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(a, b); + } + let old = rpc(&mut s, 4, "resources/read", json!({"uri":"lightcraft://library"})); + assert!(old["result"]["contents"].is_array(), "{old}"); +} + +#[test] +fn escaped_tool_panic_is_an_error_and_session_survives() { + struct Faulty { + inner: Headless, + fail: bool, + } + impl Backend for Faulty { + fn call(&mut self, method: &str, params: Value) -> Result { + if method == "engine.execute" && params["command"] == "catalog.stats" && std::mem::take(&mut self.fail) { + panic!("synthetic backend panic"); + } + self.inner.call(method, params) + } + fn has_ui(&self) -> bool { + false + } + fn describe(&self) -> String { + "test".into() + } + } + let mut s = Server::new(Box::new(Faulty { inner: Headless::demo(), fail: true })); + let params = json!({"name":"run_command","arguments":{"command":"catalog.stats"}}); + let r = rpc(&mut s, 1, "tools/call", params.clone()); + assert_eq!(r["result"]["isError"], true, "{r}"); + assert!(r.to_string().contains("synthetic backend panic")); + assert_eq!(rpc(&mut s, 2, "tools/call", params)["result"]["isError"], false); + assert!(rpc(&mut s, 3, "ping", json!({}))["result"].is_object()); +} + +#[test] +fn modern_requests_receive_result_and_cache_fields() { + let mut s = server(); + let meta = json!({"io.modelcontextprotocol/protocolVersion":"2026-07-28"}); + for (method, mut params) in [ + ("tools/list", json!({})), + ("resources/list", json!({})), + ("resources/templates/list", json!({})), + ("resources/read", json!({"uri":"lightcraft://library"})), + ] { + params["_meta"] = meta.clone(); + let r = rpc(&mut s, 1, method, params); + assert_eq!(r["result"]["resultType"], "complete", "{r}"); + assert_eq!(r["result"]["cacheScope"], "private"); + assert_eq!(r["result"]["ttlMs"], if method == "resources/read" { 0 } else { 600_000 }); + } + assert!(rpc(&mut s, 2, "tools/list", json!({}))["result"].get("resultType").is_none()); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 1b41d86ab..929a0d29d 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -66,7 +66,7 @@ fn tool(name: &str, title: &str, description: &str, properties: Value, required: "name": name, "title": title, "description": description, - "inputSchema": {"type": "object", "properties": properties, "required": required}, + "inputSchema": {"type": "object", "properties": properties, "required": required, "additionalProperties": false}, }) } @@ -78,6 +78,51 @@ fn ids_schema(what: &str) -> Value { 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", @@ -187,6 +232,13 @@ pub fn helper_tools(has_ui: bool) -> Vec { "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"}, + "subfolder": {"type": "string"}, + "conflict": {"type": "string", "enum": ["unique", "overwrite", "skip"]}, + "tiffCompression": {"type": "string", "enum": ["none", "lzw", "zip"]}, + "dngCompression": {"type": "string", "enum": ["lossless", "deflate", "uncompressed"]}, "format": {"type": "string", "enum": ["jpeg", "png", "tiff", "webp", "avif", "original", "dng"], "description": "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"}, @@ -213,6 +265,9 @@ pub fn helper_tools(has_ui: bool) -> Vec { ]; 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", @@ -298,12 +353,60 @@ pub fn command_tools(backend: &mut dyn Backend) -> Vec { .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" + ); + let files = matches!(name, "export" | "render_photo" | "screenshot"); + json!({"title": title, "readOnlyHint": read_only, "destructiveHint": !read_only && !files, + "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 } @@ -445,22 +548,67 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { return ToolResult::from(exec(b, &rest.replace('_', "."), args.clone())); } match name { - "list_commands" => { + "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) if !filter.is_empty() => Value::Array( + Value::Array(a) => Value::Array( a.into_iter() .filter(|c| { - c["id"].as_str().unwrap_or("").to_lowercase().contains(&filter) + 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`"), @@ -585,8 +733,8 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { ), ) })), - "screenshot" => screenshot(b, args), - "inspect_ui" => ToolResult::from(b.call("ui.inspect", json!({}))), + "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" => { @@ -606,3 +754,10 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { 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})) +} diff --git a/docs/mcp.md b/docs/mcp.md index 7e8154060..24f53c659 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -71,6 +71,39 @@ Every stdio MCP client takes the same shape: a `command` plus `args`. For exampl 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. +## Shared core tools + +These follow the same conventions as [filmcraft #28](https://github.com/storytold/filmcraft/pull/28). +They are listed in both full and compact mode; existing documented helpers remain listed too. +There are no hidden compatibility aliases. + +| Tool | Arguments | Result | +|---|---|---| +| `command_list` | `filter?`, `enabled_only?` | Command catalog | +| `command_run` | `id`, `params?` | Command result | +| `command_batch` | `steps: [{id, params?}]`, `stop_on_error?` | Counts and per-step results; stops on error by default | +| `doc_inspect` | none | Library state and catalog counts | +| `render_preview` | `id?`, `max_side?`, `format?` | Image; neither changes selection nor saves a file | + +Each edit in a batch has its own undo step. Connect mode also lists `ui_inspect` and +`ui_screenshot` (the latter has no output-path argument). The existing `inspect_ui`, +`screenshot`, `list_commands`, `run_command` and `render_photo` keep their documented arguments. +Ports and connect mode are unchanged. + +Every listed tool has a title and all four MCP hints. Generated command tools are conservatively +marked as edits. Helpers with optional output paths are marked as writers; `export` is not +idempotent because the default conflict policy chooses a new filename on repeated calls. +Unknown helper argument keys return JSON-RPC `-32602` naming the key and accepted arguments. +Generated `cmd_*` tools and nested command params remain free-form: the registry has prose +parameter docs, not machine-readable schemas. Existing command validation (including strict +export options) is preserved. Escaped tool panics return `isError: true`, and the session keeps +serving; backend panics during resource reads or tool listing return an internal error. +The headless backend owns its session directly, without a session mutex. + +`lightcraft://document` and `lightcraft://commands` contain JSON matching `doc_inspect` and +`command_list`. Existing resources remain available. Requests declaring MCP 2026-07-28 in +per-request `_meta` receive `resultType: complete` and list/read cache hints; reads are not cached. + ## Tools ### Helpers