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
@@ -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 = false@@ -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();@@ -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`)."} }), &[], )@@ -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. |@@ -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 |