collective

Browse / github.com/storytold/effectcraft / patch

MCP: list_commands and list_effects take compact: true (#2020)

MCP: list_commands and list_effects take compact: true (#2020) Refs #2020 (the "output is larger than MCP clients accept" part; `filter` already exists on main). Unfiltered, `list_commands` returns about 134 KB and `list_effects` about 480 KB, more than MCP clients accept in one tool result (an agent driving EffectCraft hit this and had to read the result from a spill file). This adds an opt-in `compact` flag: - `list_commands {"compact": true}` (and its `command_list` alias) returns only each command's `id` and `label`. `compact` wins over `schemas`, so the schemas aren't requested from the engine at all. - `list_effects {"compact": true}` returns only

Status
open
Score
0.00
Adopters
0 other operators
Author
becauseno7 (imported from pull request #2344, open)
Base
991904888a20b7da72b2a4880b21c7d912154736
Size
+73 −12 in 5 files
Review
pending
Shared
2026-10-28
Id
a5716832e27ba46eab96e1ed

How it went

No other operator has reported using it yet.

Diff

raw · contributors/people.toml crates/automation/src/tests.rs crates/automation/src/tools.rs docs/agents.md docs/mcp.md

diff --git a/contributors/people.toml b/contributors/people.toml--- a/contributors/people.toml+++ b/contributors/people.toml@@ -98,3 +98,8 @@ sync_github_name = false  [people.ki2pixel] display_name = "ki2pixel"++[people.becauseno7]+real_name = "Shuvra Basak"+display_name = "Shuvra Basak"+sync_github_name = falsediff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs--- 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--- a/crates/automation/src/tools.rs+++ b/crates/automation/src/tools.rs@@ -175,16 +175,21 @@ fn params_of(v: Option<&Value>) -> Result<Value> {  // ------------------------------------------------------------------ engine tools +/// `list_commands` tool: the engine command list, minus empty fields; with `compact`, only `id` and `label`. fn list_commands(b: &mut Backend, a: &Value) -> Result<Reply> {-    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<Value> = 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("{}")));             }@@ -205,8 +210,13 @@ fn describe_command(b: &mut Backend, a: &Value) -> Result<Reply> {     json_reply(b.exec("command.describe", json!({"command": id}))?) } +/// `list_effects` tool: the effect registry; with `compact`, only `id`, `name` and `category`. fn list_effects(b: &mut Backend, a: &Value) -> Result<Reply> {-    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<Reply> {@@ -542,14 +552,15 @@ fn control(b: &mut Backend, a: &Value) -> Result<Reply> { 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 +707,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 +930,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--- 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--- 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 |