diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index 6e622b377..5080058fb 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -491,6 +491,8 @@ fn bridge_forwards_to_control_channel() { call_json(&mut s, "execute_command", json!({"command": "comp.new", "params": {"name": "B", "width": 64, "height": 64}})); assert_eq!(call_json(&mut s, "get_comp", json!({}))["name"], "B"); + let cmds = call_json(&mut s, "list_commands", json!({"filter": "layer.newSolid", "compact": true})); + assert!(cmds.as_array().unwrap().contains(&json!({"id": "layer.newSolid", "label": "Solid..."})), "{cmds}"); let (c, err) = call(&mut s, "render_frame", json!({})); assert!(!err, "{c:?}"); @@ -738,6 +740,40 @@ fn every_engine_command_is_reachable_over_mcp() { } } +/// `compact: true` shrinks the listings for MCP clients' tool-result limits (#2020): commands with +/// only `id` and `label`, effects with only `id`, `name` and `category`. +#[test] +fn compact_listings_keep_only_ids_and_names() { + let mut s = server(); + let cmds = call_json(&mut s, "list_commands", json!({"filter": "layer.new", "compact": true})); + assert!(cmds.as_array().unwrap().contains(&json!({"id": "layer.newSolid", "label": "Solid..."})), "{cmds}"); + let fx = call_json(&mut s, "list_effects", json!({"filter": "gaussian", "compact": true})); + assert!(fx.as_array().unwrap().contains(&json!({"id": "ec.blur.gaussian", "name": "Gaussian Blur", "category": "Blur & Sharpen"})), "{fx}"); + // Each compact listing is the full one with the same arguments, entry for entry; `compact` + // wins over `schemas`. + let check = |s: &mut McpServer, tool: &str, args: Value, keys: &[&str]| { + let full = call_json(s, tool, args.clone()); + let want: Value = full.as_array().unwrap().iter().map(|e| Value::Object(keys.iter().map(|&k| (k.to_string(), e[k].clone())).collect())).collect(); + let mut compact = args.clone(); + compact["compact"] = json!(true); + assert_eq!(call_json(s, tool, compact), want, "{tool} {args}"); + }; + for args in [json!({}), json!({"filter": "layer.new"}), json!({"enabled_only": true}), json!({"schemas": true})] { + check(&mut s, "list_commands", args.clone(), &["id", "label"]); + check(&mut s, "command_list", args, &["id", "label"]); + } + check(&mut s, "list_effects", json!({}), &["id", "name", "category"]); + check(&mut s, "list_effects", json!({"filter": "gaussian"}), &["id", "name", "category"]); + // `compact: false` is the default listing, byte for byte; `compact: true` is a fraction of it. + let text = |s: &mut McpServer, tool: &str, args: Value| call(s, tool, args).0[0]["text"].as_str().unwrap().to_string(); + for (tool, ratio) in [("list_commands", 2), ("list_effects", 10)] { + let full = text(&mut s, tool, json!({})); + assert_eq!(text(&mut s, tool, json!({"compact": false})), full, "{tool}"); + let compact = text(&mut s, tool, json!({"compact": true})).len(); + assert!(compact * ratio < full.len(), "{tool}: {compact} of {} bytes", full.len()); + } +} + #[test] fn high_level_tools_add_effect_and_report_state() { let mut s = server(); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index a83e0d1ec..d19bfc654 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -176,15 +176,19 @@ fn params_of(v: Option<&Value>) -> Result { // ------------------------------------------------------------------ engine tools fn list_commands(b: &mut Backend, a: &Value) -> Result { - let schemas = get(a, "schemas").and_then(Value::as_bool).unwrap_or(false).then_some(json!(true)); + let compact = get(a, "compact").and_then(Value::as_bool).unwrap_or(false); + let schemas = (!compact && get(a, "schemas").and_then(Value::as_bool).unwrap_or(false)).then_some(json!(true)); let r = b.exec("command.list", obj(&[("filter", get(a, "filter")), ("enabledOnly", get(a, "enabled_only")), ("schemas", schemas.as_ref())]))?; - // Drop empty fields to keep the listing compact. let list: Vec = r .as_array() .cloned() .unwrap_or_default() .into_iter() .map(|mut c| { + if compact { + return json!({"id": c["id"], "label": c["label"]}); + } + // Drop empty fields. if let Some(o) = c.as_object_mut() { o.retain(|_, v| !(v.is_null() || v.as_array().is_some_and(Vec::is_empty) || v.as_str() == Some("{}"))); } @@ -206,7 +210,11 @@ fn describe_command(b: &mut Backend, a: &Value) -> Result { } fn list_effects(b: &mut Backend, a: &Value) -> Result { - json_reply(b.exec("effect.list", obj(&[("filter", get(a, "filter"))]))?) + let mut r = b.exec("effect.list", obj(&[("filter", get(a, "filter"))]))?; + if get(a, "compact").and_then(Value::as_bool).unwrap_or(false) { + r = r.as_array().into_iter().flatten().map(|e| json!({"id": e["id"], "name": e["name"], "category": e["category"]})).collect(); + } + json_reply(r) } fn list_fonts(b: &mut Backend, a: &Value) -> Result { @@ -542,14 +550,15 @@ fn control(b: &mut Backend, a: &Value) -> Result { static TOOLS: &[ToolDef] = &[ ToolDef { name: "list_commands", - 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`).", + 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`); `compact: true` lists only ids and labels.", 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`)."} + "schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."}, + "compact": {"type": "boolean", "description": "Return only each command's `id` and `label` (overrides `schemas`)."} }), &[], ) @@ -696,9 +705,17 @@ static TOOLS: &[ToolDef] = &[ }, ToolDef { name: "list_effects", - description: "The effect registry: id, After Effects name, category, GPU and 32-bpc support. Narrow with `filter` (id, name or category text). Apply with add_effect.", + description: "The effect registry: id, After Effects name, category, GPU and 32-bpc support. Narrow with `filter` (id, name or category text); `compact: true` lists only ids, names and categories. Apply with add_effect.", bridge_only: false, - schema: || schema(json!({"filter": {"type": "string", "description": "Only effects whose id, name or category contains this text."}}), &[]), + schema: || { + schema( + json!({ + "filter": {"type": "string", "description": "Only effects whose id, name or category contains this text."}, + "compact": {"type": "boolean", "description": "Return only each effect's `id`, `name` and `category`."} + }), + &[], + ) + }, run: list_effects, }, ToolDef { @@ -911,14 +928,15 @@ static TOOLS: &[ToolDef] = &[ }, 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`).", + 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`); `compact: true` lists only ids and labels.", 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`)."} + "schemas": {"type": "boolean", "description": "Also return each command's parameter JSON Schema (`schema`)."}, + "compact": {"type": "boolean", "description": "Return only each command's `id` and `label` (overrides `schemas`)."} }), &[], ) diff --git a/docs/agents.md b/docs/agents.md index 5c58d24b7..d7fa7f7b7 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -101,7 +101,7 @@ See [MCP conventions](mcp.md) for core tools, resources, strict arguments, and r | Tool | What it does | |---|---| -| `list_commands {filter?, enabled_only?, schemas?}` | Discover command ids, their param docs (and, with `schemas`, a JSON Schema each), and whether each can run now. | +| `list_commands {filter?, enabled_only?, schemas?, compact?}` | Discover command ids, their param docs (and, with `schemas`, a JSON Schema each), and whether each can run now; `compact` lists only ids and labels (and overrides `schemas`). | | `describe_command {command \| id}` | One command in full: label, menu, shortcut, params doc, JSON `schema`, enabled / `why`. | | `execute_command {command \| id, params?}` | Run any command (undoable). Unknown parameter keys are rejected with the accepted list. | | `get_state` | Editor state (active comp, time, selections, tool) plus `app`: version, command and effect counts, export formats, parity summary. | @@ -111,7 +111,7 @@ See [MCP conventions](mcp.md) for core tools, resources, strict arguments, and r | `get_property {layer, path, comp?, time?}` | A leaf property's value at a time, keyframes and expression. Group paths (for example `geometryOptions`) contain children: inspect them with `get_layer`, then query a leaf such as `geometryOptions/extrusionDepth`. | | `set_property {layer, path, value?, time?, timeBase?, expression?, comp?}` | Sets a static value. With `time` it sets a keyframe (`timeBase: "comp"` for comp seconds); with `expression` it sets an expression. | | `add_keyframe {layer, path, time+value \| keys:[...], timeBase?, interpolation?, comp?}` | Adds keys (layer seconds, or comp seconds with `timeBase: "comp"`), then optionally applies linear/bezier/hold/easyEase. | -| `list_effects {filter?}` | Effect ids, names, categories, GPU / 32-bpc support and parameters. | +| `list_effects {filter?, compact?}` | Effect ids, names, categories, GPU / 32-bpc support and parameters; `compact` lists only ids, names and categories. | | `list_fonts {query?, variable?, rescan?}` | Font families text layers can use, bundled and installed, with their styles, origin, whether each is a variable font and own-language name; `variable` lists only variable fonts; `rescan` picks up fonts installed since launch. | | `add_effect {layer, effect, values?, comp?}` | Apply an effect and set its parameters in one call and one undo step (a failing value leaves nothing applied); returns the instance path (`effects/#n`) and its parameter paths. | | `render_frame {comp?, time?, max_side?, path?, inline?, transparent?}` | Returns a PNG image of a frame; `transparent: true` keeps the alpha (as an RGB + Alpha render writes it) instead of compositing over the comp background. | diff --git a/docs/mcp.md b/docs/mcp.md index 1104927ac..a9f27b959 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -20,7 +20,7 @@ The server provides the same core conventions as FilmCraft #28: | Tool | Arguments | Result | | --- | --- | --- | -| `command_list` | `filter?`, `enabled_only?`, `schemas?` | Command catalog | +| `command_list` | `filter?`, `enabled_only?`, `schemas?`, `compact?` | 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 |