mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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.
This commit is contained in:
@@ -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::<Vec<_>>();
|
||||
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()));
|
||||
|
||||
+42
-12
@@ -102,8 +102,11 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
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<Value> {
|
||||
"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::<Vec<_>>().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::<Vec<_>>().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<ToolResult, String> {
|
||||
|
||||
+2
-2
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user