From d838af024ba1a21096b2dcd2a6e030b06a74d122 Mon Sep 17 00:00:00 2001 From: "Ayberk A." <105879357+AyberkA0@users.noreply.github.com> Date: Fri, 16 Oct 2026 07:58:24 +0300 Subject: [PATCH] MCP: command listings take query, menu and brief (#2173) The command catalogue is about 340,000 characters, more than many MCP clients show, and `query` (the name agents reach for first) was refused as an unknown argument. command_list and list_commands now take `query` as another name for `filter`, `menu` to keep the commands under a menu path ("Object > Path"), and `brief` to leave out each command's params text, which is most of the catalogue; the params of one command are a filtered listing away. --- crates/mcp/src/tests_conventions.rs | 29 ++++++++++++++++ crates/mcp/src/tools.rs | 54 ++++++++++++++++++++++------- docs/mcp.md | 4 +-- 3 files changed, 73 insertions(+), 14 deletions(-) diff --git a/crates/mcp/src/tests_conventions.rs b/crates/mcp/src/tests_conventions.rs index 4bca66943..c5478fa9f 100644 --- a/crates/mcp/src/tests_conventions.rs +++ b/crates/mcp/src/tests_conventions.rs @@ -40,6 +40,35 @@ fn conventions_catalog_and_strict_keys() { assert_eq!(rpc(&mut s, "ping", json!({}))["result"], json!({})); } +/// #2173: the command listings take `query` (as `filter`), a `menu` filter, and `brief`, which +/// leaves out the long params text, so the catalogue fits a client's output limit. +#[test] +fn command_listings_take_query_menu_and_brief() { + let mut s = Server::new(Box::new(Headless::with_document())); + let ids = |v: &Value| v.as_array().unwrap().iter().map(|c| c["id"].as_str().unwrap().to_string()).collect::>(); + let by_filter = payload(&call(&mut s, "command_list", json!({"filter":"rectangle"}))); + let by_query = payload(&call(&mut s, "command_list", json!({"query":"rectangle"}))); + assert!(ids(&by_filter).contains(&"shape.rectangle".to_string())); + assert_eq!(by_filter, by_query); + // `menu` keeps the commands under that menu path. + let path_menu = payload(&call(&mut s, "command_list", json!({"menu":"object > path"}))); + assert!(ids(&path_menu).contains(&"object.path.addAnchorPoints".to_string()), "{path_menu}"); + for c in path_menu.as_array().unwrap() { + let menu: Vec<&str> = c["menu"].as_array().unwrap().iter().map(|m| m.as_str().unwrap()).collect(); + assert_eq!(menu.get(..2), Some(&["Object", "Path"][..]), "{c}"); + } + // `brief` drops the params text and nothing else. + let full = payload(&call(&mut s, "command_list", json!({}))); + let brief = payload(&call(&mut s, "command_list", json!({"brief":true}))); + assert_eq!(ids(&full), ids(&brief)); + assert!(full.as_array().unwrap().iter().any(|c| c.get("params").is_some())); + assert!(brief.as_array().unwrap().iter().all(|c| c.get("params").is_none() && c.get("label").is_some())); + assert!(brief.to_string().len() * 2 < full.to_string().len(), "brief is much shorter"); + // The other listing takes them too. + let r = payload(&call(&mut s, "list_commands", json!({"query":"rectangle","brief":true,"menu":""}))); + assert_eq!(ids(&r["commands"]), ids(&by_filter)); +} + #[test] fn conventions_commands_batch_and_bounded_preview() { let mut s = Server::new(Box::new(Headless::with_document())); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 1048810d3..d4f81d6a1 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -102,8 +102,11 @@ pub fn tool_definitions() -> Vec { tool( "command_list", "List commands", - "Discover command ids, labels, parameter descriptions and enabled state. Returns an array.", - obj(json!({"filter": string("Case-insensitive id, label or menu substring"), "enabled_only": {"type":"boolean"}}), &[]), + "Discover command ids, labels, parameter descriptions and enabled state. Returns an array. The whole catalogue is large: narrow it with filter (or query), menu, and brief.", + obj( + json!({"filter": string("Case-insensitive id, label or menu substring"), "query": string("Same as filter"), "menu": string(MENU_FILTER), "enabled_only": {"type":"boolean"}, "brief": {"type":"boolean", "description": BRIEF}}), + &[], + ), true, ), tool( @@ -153,7 +156,7 @@ pub fn tool_definitions() -> Vec { "List commands", "List VectorCraft commands (id, label, menu path, shortcut, parameter description, enabled state). Every editing action is a command; run any of them with run_command. Coordinates are points in document space, y down, origin at the first artboard's top-left.", obj( - json!({"filter": string("Case-insensitive substring matched against id, label and menu path (e.g. \"align\", \"Object\")"), "enabledOnly": {"type": "boolean", "description": "Only commands that can run right now"}}), + json!({"filter": string("Case-insensitive substring matched against id, label and menu path (e.g. \"align\", \"Object\")"), "query": string("Same as filter"), "menu": string(MENU_FILTER), "enabledOnly": {"type": "boolean", "description": "Only commands that can run right now"}, "brief": {"type": "boolean", "description": BRIEF}}), &[], ), true, @@ -838,25 +841,52 @@ fn image_result(png: &[u8], info: Value) -> ToolResult { } } +/// The `menu` argument of the command listings. +const MENU_FILTER: &str = "Case-insensitive substring of the menu path, its levels joined with \" > \" (e.g. \"Object\", \"Object > Path\")"; +/// The `brief` argument of the command listings. +const BRIEF: &str = "Leave out each command's params description (the bulk of the catalogue); run command_list with the id as filter to read it"; + +/// The command catalogue narrowed by the listing tools' arguments: `filter` (or `query`) on the +/// id, label and menu path, `menu` on the menu path, `enabledOnly`, and `brief` (no `params` +/// text: the full catalogue is too large for many clients, #2173). fn filter_commands(all: Value, a: &Args) -> Value { - let needle = a.get("filter").and_then(Value::as_str).map(str::to_lowercase).filter(|s| !s.is_empty()); + let text = |k: &str| a.get(k).and_then(Value::as_str).map(str::to_lowercase).filter(|s| !s.is_empty()); + let needle = text("filter").or_else(|| text("query")); + let in_menu = text("menu"); let enabled_only = a.get("enabledOnly").and_then(Value::as_bool).unwrap_or(false); + let brief = a.get("brief").and_then(Value::as_bool).unwrap_or(false); let Value::Array(list) = all else { return all }; + let menu_path = |c: &Value, sep: &str| { + c.get("menu") + .and_then(Value::as_array) + .map(|m| m.iter().filter_map(Value::as_str).collect::>().join(sep)) + .unwrap_or_default() + .to_lowercase() + }; let hit = |c: &Value| { if enabled_only && c.get("enabled").and_then(Value::as_bool) == Some(false) { return false; } + if let Some(m) = &in_menu + && !menu_path(c, " > ").contains(m.as_str()) + { + return false; + } let Some(n) = &needle else { return true }; let field = |k: &str| c.get(k).and_then(Value::as_str).unwrap_or("").to_lowercase(); - let menu = c - .get("menu") - .and_then(Value::as_array) - .map(|m| m.iter().filter_map(Value::as_str).collect::>().join(" ")) - .unwrap_or_default() - .to_lowercase(); - field("id").contains(n) || field("label").contains(n) || menu.contains(n) + field("id").contains(n.as_str()) || field("label").contains(n.as_str()) || menu_path(c, " ").contains(n.as_str()) }; - Value::Array(list.into_iter().filter(hit).collect()) + Value::Array( + list.into_iter() + .filter(hit) + .map(|mut c| { + if brief && let Some(o) = c.as_object_mut() { + o.remove("params"); + } + c + }) + .collect(), + ) } fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result { diff --git a/docs/mcp.md b/docs/mcp.md index 5ee1fe0d5..f9e0d2886 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -258,7 +258,7 @@ The older names below remain listed because existing workflows use them. | Tool | Arguments and result | |---|---| -| `command_list` | Optional `filter`, `enabled_only`; returns the command array | +| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text); returns the command array | | `command_run` | `id`, optional `params`; runs the command through the existing backend. An unknown key in `params` is an error that names it and the nearest key the command takes (`invalid parameters for `file.new`: unknown parameter `widht`; did you mean `width`?`), so a misspelt key no longer runs the command with its default (see [Parameter keys](#parameter-keys)) | | `command_batch` | `steps: [{id, params?}]` (at most 256), optional `stop_on_error` (default true), `atomic` and `label`; returns `completed`, `failed`, `results: [{ok, result\|error}]` and `created` (the ids the `draw_path` / `draw_shape` steps made, in order). A step's `id` is a command id, or `draw_path` / `draw_shape` with that tool's arguments as `params` (checked as `tools/call` checks them; the step's result is the tool's, so `"$N.id"` is the new object). Each edit has its own undo step, or with `atomic: true` the batch's edits are one undo step named `label` (else after its first edit; `edit.undoGroup.begin`/`end` underneath); failures set `isError`. A param string `"$N.path"` or `"$last.path"` takes a value from an earlier step's result (see `run` below). Kept step results total at most 8 MiB; past that the batch stops with the reason. This is the MCP tool, not the engine command `command.batch` (that one takes `commands: [{command, params}]` and is what the Actions panel plays); use `command_run` with `command.batch` when replaying a recorded action | | `doc_inspect` | Optional `depth`, `childLimit`; same summary as `inspect_document` | @@ -300,7 +300,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | Tool | Arguments | Notes | |---|---|---| -| `list_commands` | `{filter?, enabledOnly?}` | The command catalogue: id, label, menu, shortcut, params doc, enablement. | +| `list_commands` | `{filter?, query?, menu?, enabledOnly?, brief?}` | The command catalogue: id, label, menu, shortcut, params doc (left out with `brief`), enablement. | | `run_command` | `{command, params?}` | Runs any command. Use it for everything without a dedicated tool. | | `inspect_document` | `{depth?, childLimit?}` | Artboards, layer tree (ids, kinds, bounds, paint), selection, history, tool. A type node's `fill`, `stroke` and `strokeWidth` are the paint its characters show (its first run's); fills and strokes of the type object itself come as `objectFill`, `objectStroke` and `objectStrokeWidth`. Paint comes as a label (`"#ff0000"`, `"None"`, `"G1 40% (#ff9999)"` for a swatch at a tint); `paint.proxies` gives it as an object. | | `inspect_ui` | `{}` | UI state. Remote mode only. |