MCP: add core tools, annotations and resources

Add strict tool keys, resource reads and panic recovery with regression coverage. Keep existing documented tools listed.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
This commit is contained in:
storybored379 2026-10-08 22:22:02 -07:00
parent cae67546dd
commit 164538fecb
5 changed files with 408 additions and 12 deletions

View File

@ -13,13 +13,15 @@ use crate::{Backend, Error, base64};
/// Protocol revisions we speak, newest first. We answer with the client's if we know it.
pub const PROTOCOL_VERSIONS: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"];
const INSTRUCTIONS: &str = "EffectCraft is an After Effects-class motion graphics compositor. Everything is an engine command: list_commands discovers ids and params, execute_command runs them (undoable). Typical flow: execute_command comp.new -> execute_command layer.newText / layer.newSolid (returns the layer id) -> set_property / add_keyframe -> execute_command effect.apply -> render_frame to look at the result. Inspect with get_project, get_comp and get_layer (every property node carries its `path`, e.g. `transform/position`, `effects/#1/blurriness`). Times are seconds. In bridge mode (app started with `--control <port>`) screenshot and the ui_* tools show and operate the live window.";
const INSTRUCTIONS: &str = "EffectCraft is an After Effects-class motion graphics compositor. Everything is an engine command: command_list discovers ids and params, command_run {id, params} runs them (undoable; command_batch runs several in order). Typical flow: command_run comp.new -> command_run layer.newText / layer.newSolid (returns the layer id) -> set_property / add_keyframe -> command_run effect.apply -> render_preview to look at the result. Inspect with doc_inspect, get_project, get_comp and get_layer (every property node carries its `path`, e.g. `transform/position`, `effects/#1/blurriness`). Times are seconds. In bridge mode (app started with `--control <port>`) screenshot and the ui_* tools show and operate the live window.";
// JSON-RPC error codes.
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;
pub struct McpServer {
backend: Backend,
@ -78,7 +80,10 @@ impl McpServer {
};
let params = msg.get("params").cloned().unwrap_or(json!({}));
Some(match self.request(method, &params) {
Ok(result) => json!({"jsonrpc": "2.0", "id": id, "result": result}),
Ok(mut result) => {
modern_result_fields(method, &params, &mut result);
json!({"jsonrpc": "2.0", "id": id, "result": result})
}
Err((code, m)) => error(id, code, &m),
})
}
@ -124,7 +129,7 @@ impl McpServer {
match method {
"initialize" => {
let asked = params.get("protocolVersion").and_then(Value::as_str).unwrap_or("");
let version = PROTOCOL_VERSIONS.iter().find(|v| **v == asked).copied().unwrap_or(PROTOCOL_VERSIONS[0]);
let version = PROTOCOL_VERSIONS.iter().find(|v| **v == asked).copied().unwrap_or(PROTOCOL_VERSIONS.first().copied().unwrap_or("2025-06-18"));
let mode = if self.backend.is_bridge() { "bridge" } else { "headless" };
let instructions = if self.autosave.is_some() {
format!("{INSTRUCTIONS} Headless auto-save and recovery: {}", self.autosave_info())
@ -133,7 +138,7 @@ impl McpServer {
};
Ok(json!({
"protocolVersion": version,
"capabilities": {"tools": {"listChanged": false}},
"capabilities": {"tools": {"listChanged": false}, "resources": {"listChanged": false, "subscribe": false}},
"serverInfo": {"name": "effectcraft", "title": format!("EffectCraft ({mode})"), "version": env!("CARGO_PKG_VERSION")},
"instructions": instructions,
"_meta": {"effectcraftAutoSave": self.autosave_info()},
@ -146,12 +151,15 @@ impl McpServer {
}
"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(json!({}));
if tools::available(self.backend.is_bridge()).all(|t| t.name != name) {
let hint = if tools::find(name).is_some() { format!(" ({})", crate::backend::NEED_BRIDGE) } else { String::new() };
return Err((INVALID_PARAMS, format!("unknown tool `{name}`{hint}")));
}
let args = params.get("arguments").cloned().unwrap_or(json!({}));
let mut result = call_result(tools::run(&mut self.backend, name, &args));
if let Some(m) = tools::find(name).and_then(|t| t.unknown_arg(&args)) {
return Err((INVALID_PARAMS, m));
}
let mut result = call_result(self.guarded(name, &args));
// Also checkpoint a partially successful tool that returned an error. The
// project is durable before the client receives its reply (even if it kills
// the process immediately afterwards).
@ -181,9 +189,24 @@ impl McpServer {
}
Ok(result)
}
// Advertised as absent, but answer politely for clients that probe anyway.
"resources/list" => Ok(json!({"resources": []})),
"resources/list" => Ok(json!({"resources": [
{"uri": DOCUMENT_URI, "name": "document", "title": "Project", "description": "The project overview and the active composition (same as doc_inspect).", "mimeType": "application/json"},
{"uri": COMMANDS_URI, "name": "commands", "title": "Command catalog", "description": "Every engine command with id, label, menu, shortcut and params (same as command_list).", "mimeType": "application/json"},
]})),
"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 tool = match uri {
DOCUMENT_URI => "doc_inspect",
COMMANDS_URI => "command_list",
_ => return Err((RESOURCE_NOT_FOUND, format!("resource not found: {uri}"))),
};
match self.guarded(tool, &json!({})) {
Ok(Reply::Json(v)) => Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": v.to_string()}]})),
Ok(Reply::Image { .. }) => Err((INTERNAL_ERROR, "unexpected image".into())),
Err(e) => Err((INTERNAL_ERROR, e.to_string())),
}
}
"prompts/list" => Ok(json!({"prompts": []})),
m => Err((METHOD_NOT_FOUND, format!("method not found: {m}"))),
}
@ -193,7 +216,9 @@ impl McpServer {
/// A `tools/call` result: tool failures are reported in-band (`isError`) so the model can react.
pub fn call_result(r: Result<Reply, Error>) -> Value {
match r {
Ok(Reply::Json(v)) => json!({"content": [{"type": "text", "text": v.to_string()}], "isError": false}),
Ok(Reply::Json(v)) => {
json!({"content": [{"type": "text", "text": v.to_string()}], "isError": v.get("failed").and_then(Value::as_u64).is_some_and(|n| n > 0)})
}
Ok(Reply::Image { png, info }) => json!({
"content": [
{"type": "image", "data": base64::encode(&png), "mimeType": "image/png"},
@ -205,6 +230,47 @@ pub fn call_result(r: Result<Reply, Error>) -> Value {
}
}
impl McpServer {
/// Run a tool with panics caught: a panic becomes an error result and the session keeps
/// serving (docs/mcp.md).
pub(crate) fn guarded(&mut self, name: &str, args: &Value) -> Result<Reply, Error> {
let backend = &mut self.backend;
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| tools::run(backend, name, args))).unwrap_or_else(|p| {
let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
Err(Error::Other(format!("internal error: {msg}")))
})
}
}
/// The craft catalog resources (docs/mcp.md).
const DOCUMENT_URI: &str = "effectcraft://document";
const COMMANDS_URI: &str = "effectcraft://commands";
/// Whether a request declares MCP 2026-07-28 or later in its `_meta` (a "modern" client, which
/// negotiates per request instead of through `initialize`).
pub(crate) fn is_modern(params: &Value) -> bool {
params.pointer("/_meta/io.modelcontextprotocol~1protocolVersion").and_then(Value::as_str).is_some_and(|v| v >= "2026-07-28")
}
/// Modern clients reject list/read results without `resultType`, `ttlMs` and `cacheScope`; those
/// fields are added for them. Sessions that negotiated an older revision through `initialize` keep
/// the legacy shape. The tool and resource lists never change while the server runs (cacheable
/// for ten minutes); reads follow the project, so they are never cached.
pub(crate) fn modern_result_fields(method: &str, params: &Value, result: &mut Value) {
let ttl_ms = match method {
"tools/list" | "resources/list" | "resources/templates/list" | "prompts/list" => Some(600_000),
"resources/read" => Some(0),
"tools/call" => None,
_ => return,
};
let Some(obj) = result.as_object_mut().filter(|_| is_modern(params)) else { return };
obj.insert("resultType".into(), json!("complete"));
if let Some(ttl_ms) = ttl_ms {
obj.insert("ttlMs".into(), json!(ttl_ms));
obj.insert("cacheScope".into(), json!("private"));
}
}
fn error(id: Value, code: i64, message: &str) -> Value {
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})
}

View File

@ -734,3 +734,143 @@ fn autosave_non_utf8_path_returns_an_error_instead_of_panicking() {
let result = server().with_autosave(&root);
assert!(matches!(result, Err(crate::Error::Other(message)) if message.contains("UTF-8")));
}
#[test]
fn core_tools_annotations_and_strict_keys() {
let mut s = server();
let tools = rpc(&mut s, 1, "tools/list", json!({}));
let tools = tools["tools"].as_array().unwrap();
for name in ["command_list", "command_run", "command_batch", "doc_inspect", "render_preview"] {
assert!(tools.iter().any(|t| t["name"] == name), "missing {name}");
}
for t in tools {
assert!(t["title"].as_str().is_some_and(|s| !s.is_empty()));
for hint in ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] {
assert!(t["annotations"][hint].is_boolean(), "{t}");
}
let msg = json!({"jsonrpc":"2.0", "id":2, "method":"tools/call", "params":{"name":t["name"], "arguments":{"typo_key":1}}});
let reply = s.handle(&msg).unwrap();
assert_eq!(reply["error"]["code"], -32602, "{reply}");
assert!(reply["error"]["message"].as_str().unwrap().contains("typo_key"));
}
let r = call_json(&mut s, "command_run", json!({"id":"comp.new", "params":{"width":32,"height":32}}));
assert!(r.is_object());
let (content, err) = call(&mut s, "command_batch", json!({"steps":[{"id":"no.such.command"},{"id":"layer.newNull"}]}));
assert!(err);
let result: Value = serde_json::from_str(content[0]["text"].as_str().unwrap()).unwrap();
assert_eq!(result["completed"], 0);
assert_eq!(result["failed"], 1);
assert_eq!(result["results"].as_array().unwrap().len(), 1);
let (content, err) = call(&mut s, "command_batch", json!({"stop_on_error":false,"steps":[{"id":"no.such.command"},{"id":"layer.newNull"}]}));
assert!(err);
let result: Value = serde_json::from_str(content[0]["text"].as_str().unwrap()).unwrap();
assert_eq!(result["completed"], 1);
assert_eq!(result["failed"], 1);
assert!(call_json(&mut s, "doc_inspect", json!({}))["activeComp"].is_object());
assert!(!call(&mut s, "render_preview", json!({"max_side":16})).1);
}
#[test]
fn resources_match_tools_and_recover_after_parse_error() {
let mut s = server();
let bad: Value = serde_json::from_str(&s.handle_line("{broken").unwrap()).unwrap();
assert_eq!(bad["error"]["code"], -32700);
assert!(bad["id"].is_null());
assert_eq!(rpc(&mut s, 1, "ping", json!({})), json!({}));
let list = rpc(&mut s, 2, "resources/list", json!({}));
for (uri, tool) in [("effectcraft://document", "doc_inspect"), ("effectcraft://commands", "command_list")] {
assert!(list["resources"].as_array().unwrap().iter().any(|r| r["uri"] == uri));
let r = rpc(&mut s, 3, "resources/read", json!({"uri":uri}));
assert_eq!(r["contents"][0]["mimeType"], "application/json");
let value: Value = serde_json::from_str(r["contents"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(value, call_json(&mut s, tool, json!({})));
}
}
/// Rendering is a direct tool call, outside Session::execute's existing panic guard.
#[test]
fn render_panic_is_an_error_and_session_remains_usable() {
use effectcraft_engine::{
color::Label,
project::{Footage, FootageKind, ItemId, ItemKind},
raster::Image,
render::FootageSource,
time::Tick,
};
use std::sync::{Arc, Mutex};
struct BrokenFootage(Mutex<bool>);
impl FootageSource for BrokenFootage {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<Image>> {
let mut first = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if *first {
*first = false;
panic!("synthetic decoder failure");
}
Some(Arc::new(Image::new(16, 16)))
}
}
let mut session = Session { footage: Arc::new(BrokenFootage(Mutex::new(true))), ..Default::default() };
session.execute("comp.new", json!({"width":16,"height":16})).unwrap();
let item = Arc::make_mut(&mut session.project).add_item(
"synthetic",
Label::Aqua,
None,
ItemKind::Footage(Footage { path: "synthetic.png".into(), kind: FootageKind::Still, width: 16, height: 16, has_video: true, ..Default::default() }),
);
session.execute("layer.addItem", json!({"item":item.0})).unwrap();
let mut s = McpServer::new(Backend::headless(session));
let (content, err) = call(&mut s, "render_frame", json!({}));
assert!(err);
assert!(content[0]["text"].as_str().unwrap().contains("synthetic decoder failure"));
assert_eq!(rpc(&mut s, 2, "ping", json!({})), json!({}));
assert!(!call(&mut s, "render_frame", json!({})).1);
}
/// MCP 2026-07-28 ("modern") clients declare their revision per request in `_meta` and reject
/// list/read results without `resultType`, `ttlMs` and `cacheScope`; legacy sessions (revision
/// negotiated through `initialize`) keep the old result shape.
#[test]
fn modern_clients_get_result_type() {
let mut s = server();
let meta = json!({
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "t", "version": "1"},
"io.modelcontextprotocol/clientCapabilities": {},
});
for (id, method, params) in [
(1, "tools/list", json!({"_meta": meta})),
(2, "resources/list", json!({"_meta": meta})),
(3, "resources/templates/list", json!({"_meta": meta})),
(4, "resources/read", json!({"_meta": meta, "uri": "effectcraft://document"})),
(5, "tools/call", json!({"_meta": meta, "name": "command_list", "arguments": {}})),
] {
let r = rpc(&mut s, id, method, params);
assert_eq!(r["resultType"], "complete", "{method}: {r}");
if method != "tools/call" {
assert!(r["ttlMs"].is_u64(), "{method}: {r}");
assert!(matches!(r["cacheScope"].as_str(), Some("public" | "private")), "{method}: {r}");
}
}
let mut s = server();
rpc(&mut s, 1, "initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}}));
for (id, method, params) in
[(2, "tools/list", json!({})), (3, "resources/list", json!({})), (4, "resources/read", json!({"uri": "effectcraft://document"}))]
{
let r = rpc(&mut s, id, method, params);
let res = r.as_object().unwrap();
assert!(!res.contains_key("resultType") && !res.contains_key("ttlMs") && !res.contains_key("cacheScope"), "{method}: {r}");
}
}
#[test]
fn render_job_state_recovers_a_poisoned_lock() {
let shared = std::sync::Arc::new(effectcraft_engine::render_queue::JobShared::default());
let worker = shared.clone();
let _ = std::thread::spawn(move || {
let mut state = worker.state.lock().unwrap();
state.done = 3;
panic!("synthetic worker panic while holding the progress lock");
})
.join();
assert_eq!(shared.snapshot().done, 3);
}

View File

@ -31,10 +31,62 @@ impl ToolDef {
pub fn input_schema(&self) -> Value {
(self.schema)()
}
/// The MCP `Tool` object.
/// The MCP `Tool` object, with a title and annotations (docs/mcp.md).
pub fn descriptor(&self) -> Value {
json!({"name": self.name, "description": self.description, "inputSchema": self.input_schema()})
let title = title_of(self.name);
let read_only = READ_ONLY.contains(&self.name);
let files = WRITES_FILES.contains(&self.name);
json!({
"name": self.name,
"title": title,
"description": self.description,
"inputSchema": self.input_schema(),
"annotations": {
"title": title,
"readOnlyHint": read_only,
"destructiveHint": !read_only && !files,
"idempotentHint": read_only || files,
"openWorldHint": false,
},
})
}
/// The first argument this tool doesn't take, as a message naming the accepted ones.
pub fn unknown_arg(&self, args: &Value) -> Option<String> {
let Some(m) = args.as_object() else { return Some("arguments must be an object".into()) };
let schema = self.input_schema();
let props = schema.get("properties")?.as_object()?;
let bad = m.keys().find(|k| !props.contains_key(*k))?;
let accepted: Vec<&str> = props.keys().map(String::as_str).collect();
Some(format!("unknown argument \"{bad}\" for {}; expected: {}", self.name, accepted.join(", ")))
}
}
/// Tools that only read.
const READ_ONLY: &[&str] = &[
"command_list",
"list_commands",
"describe_command",
"doc_inspect",
"render_preview",
"get_project",
"get_comp",
"get_layer",
"get_property",
"list_effects",
"list_fonts",
"get_state",
"ui_inspect",
"ui_elements",
];
/// Tools that write files without changing the project.
const WRITES_FILES: &[&str] = &["save_project", "render_frame", "screenshot"];
/// `get_project` → "Get project".
fn title_of(name: &str) -> String {
let s = name.replace('_', " ");
let mut c = s.chars();
c.next().map(|f| f.to_uppercase().collect::<String>() + c.as_str()).unwrap_or_default()
}
/// All tools (headless + bridge-only).
@ -64,7 +116,7 @@ pub fn run(b: &mut Backend, name: &str, args: &Value) -> Result<Reply> {
// ------------------------------------------------------------------ schema fragments
fn schema(props: Value, required: &[&str]) -> Value {
json!({"type": "object", "properties": props, "required": required})
json!({"type": "object", "properties": props, "required": required, "additionalProperties": false})
}
fn comp_s() -> Value {
json!({"type": ["integer", "string"], "description": "Composition item id or name; omit for the active comp."})
@ -825,4 +877,101 @@ static TOOLS: &[ToolDef] = &[
},
run: batch_tool,
},
ToolDef {
name: "command_list",
description: "List engine commands: id, label, menu path, shortcut, `params` doc (e.g. `{name?, width?, height?, frameRate?, duration? (s)}`) and whether it can run now (`why` if not). Every menu item, shortcut and panel gesture maps to one of these ids; run them with execute_command. Narrow with `filter` (e.g. `layer.new`, `keys`, `effect`).",
bridge_only: false,
schema: || {
schema(
json!({
"filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)."},
"enabled_only": {"type": "boolean", "description": "Only commands that can run right now."},
"schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."}
}),
&[],
)
},
run: list_commands,
},
ToolDef {
name: "command_run",
description: "Run any engine command by id with JSON params; edits are undoable. Examples: comp.new {\"name\":\"Main\",\"width\":1920,\"height\":1080,\"frameRate\":30,\"duration\":5}; layer.newText {\"text\":\"Hello\",\"size\":120,\"fill\":\"#ffffff\"} (returns {\"layer\":id}); layer.newSolid {\"color\":\"#3366ff\"}; layer.newShape; effect.apply {\"layer\":3,\"effect\":\"Gaussian Blur\"}; time.set {\"time\":2.5}; layer.select {\"layers\":[3]}. Layers are referenced by id, \"#n\" or name; times are seconds.",
bridge_only: false,
schema: || {
schema(
json!({
"id": {"type": "string", "description": "Command id, e.g. `comp.new`, `layer.newText`, `effect.apply` (see list_commands)."},
"params": {"type": "object", "description": "Command parameters as documented by list_commands.", "additionalProperties": true}
}),
&["id"],
)
},
run: execute_command,
},
ToolDef {
name: "render_preview",
description: "Render a composition frame (default: the active comp at the current time) and return it as a PNG image, longest side `max_side` (default 960, 0 = full size). Use it to check your work.",
bridge_only: false,
schema: || {
schema(
json!({
"comp": comp_s(),
"time": {"type": "number", "description": "Comp time in seconds (default: current time)."},
"max_side": {"type": "integer", "description": "Longest side in pixels (default 960; 0 = full comp size)."},
"transparent": {"type": "boolean", "description": "Keep the frame's alpha (RGBA PNG, as an RGB + Alpha render writes it) instead of compositing over the comp's background colour."}
}),
&[],
)
},
run: render_frame,
},
ToolDef {
name: "command_batch",
description: "Run commands in order, returning each result or error. Stop at the first error by default; stop_on_error: false continues. Each edit has its own undo step; use batch for an atomic undo group.",
bridge_only: false,
schema: || {
schema(
json!({"steps": {"type": "array", "items": {"type": "object", "properties": {"id": {"type": "string"}, "params": {"type": "object"}}, "required": ["id"], "additionalProperties": false}}, "stop_on_error": {"type": "boolean"}}),
&["steps"],
)
},
run: command_batch,
},
ToolDef {
name: "doc_inspect",
description: "Inspect the project overview and active composition (null when no composition is active).",
bridge_only: false,
schema: || schema(json!({}), &[]),
run: doc_inspect,
},
];
fn doc_inspect(b: &mut Backend, _: &Value) -> Result<Reply> {
let project = b.exec("project.summary", json!({}))?;
let comp = b.exec("comp.info", json!({})).ok();
json_reply(json!({"project": project, "activeComp": comp}))
}
fn command_batch(b: &mut Backend, a: &Value) -> Result<Reply> {
let steps = need(a, "steps")?.as_array().ok_or_else(|| Error::BadArgs("steps must be an array".into()))?;
let stop = get(a, "stop_on_error").and_then(Value::as_bool).unwrap_or(true);
let mut results = Vec::new();
let (mut completed, mut failed) = (0, 0);
for step in steps {
match execute_command(b, step) {
Ok(Reply::Json(result)) => {
completed += 1;
results.push(json!({"ok": true, "result": result}));
}
Ok(Reply::Image { .. }) => return Err(Error::Other("unexpected command image".into())),
Err(e) => {
failed += 1;
results.push(json!({"ok": false, "error": e.to_string()}));
if stop {
break;
}
}
}
}
json_reply(json!({"completed": completed, "failed": failed, "results": results}))
}

View File

@ -97,6 +97,11 @@ may be absent from the last checkpoint.
| `script_ui {action?, window?, widget?, value?, result?}` | Drive ScriptUI windows that scripts opened: `list` (default) the open windows, `get` a window's control tree, `click` a button / checkbox / radio button, `set` text, a slider or a list item, `close` a window (`result`: what a dialog's `show()` returns, default 2 = Cancel). |
| `batch {steps: [{command, params}], label?, atomic?}` | Several commands in one call and one undo step (`engine.batch`); `"$N.key"` in a param is step N's result (`"$1.layer"`). A failing step rolls the batch back and names the step. |
| *(bridge)* `screenshot {panel?, id?, max_side?, path?}`, `ui_inspect`, `ui_elements {prefix?}`, `ui_click`, `ui_drag`, `ui_key`, `ui_type`, `ui_set`, `control {method, params}` | Look at and operate the live window. |
| `command_list` | List commands (same arguments as `list_commands`). |
| `command_run {id, params?}` | Run a command. |
| `command_batch {steps, stop_on_error?}` | Run commands in order with per-step results; each edit has its own undo step. |
| `doc_inspect` | Project overview and active composition. |
| `render_preview` | Render a PNG without writing a file (`comp`, `time`, `max_side`, `transparent`). |
Layers are referenced by id (from `get_comp`), `"#n"` (1-based index from the top) or name. Comps are
referenced by id or name, and default to the active comp. Property paths come from `get_layer`, for

36
docs/mcp.md Normal file
View File

@ -0,0 +1,36 @@
# MCP conventions
Run `effectcraft-cli mcp` for a headless session, or `effectcraft-cli mcp --bridge 9877`
to use the desktop app's control channel. Existing ports are unchanged.
The server provides the same core conventions as FilmCraft #28:
| Tool | Arguments | Result |
| --- | --- | --- |
| `command_list` | `filter?`, `enabled_only?`, `schemas?` | Command catalog |
| `command_run` | `id`, `params?` | Engine command result |
| `command_batch` | `steps: [{id, params?}]`, `stop_on_error?` | `completed`, `failed`, per-step `results` |
| `doc_inspect` | none | Project overview and active composition |
| `render_preview` | `comp?`, `time?`, `max_side?`, `transparent?` | PNG and frame information |
`command_batch` stops at the first error by default; false continues. Each edit has its
own undo step. The existing `batch` tool retains its atomic undo grouping and result references.
Existing tools, including `list_commands`, `execute_command`, and `batch`, remain listed
because existing workflows use them; there are no hidden aliases. See [agents.md](agents.md)
for the full catalog. Each tool has a title and read-only, destructive, idempotent and
open-world hints. Tools with an optional output path are conservatively annotated as file writers.
Unknown top-level tool argument keys return JSON-RPC `-32602` naming the key and accepted
arguments. EffectCraft already rejects unknown engine command parameters; that behaviour is
preserved. Tool failures (including escaped panics) return `isError: true`. Malformed JSON
returns `-32700` with a null id and the session keeps serving. The MCP backend owns its session
without a mutex; the engine's render-job mutexes already recover poisoned locks.
Resources `effectcraft://document` and `effectcraft://commands` return JSON matching
`doc_inspect` and `command_list`. Clients declaring MCP 2026-07-28 in per-request `_meta`
receive `resultType: complete` and list/read cache hints; document reads are never cached.
```json
{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"command_run","arguments":{"id":"comp.new","params":{"width":640,"height":360}}}}
{"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"effectcraft://document"}}
```