Merge pull request #6392 from dotCooCoo/feat/command-input-schemas

MCP: command listings give each command with a schema an inputSchema
This commit is contained in:
BFlat
2026-11-10 11:42:18 +07:00
committed by GitHub
8 changed files with 161 additions and 12 deletions
+17 -1
View File
@@ -149,11 +149,26 @@ pub struct CommandInfo {
/// The parameters' schema as a JSON Schema 2020-12 object schema, for a command that has one.
#[serde(skip_serializing_if = "Option::is_none")]
pub schema: Option<Value>,
/// The same schema in the form an MCP tool's `inputSchema` takes ([`input_schema`]).
#[serde(rename = "inputSchema", skip_serializing_if = "Option::is_none")]
pub input_schema: Option<Value>,
}
/// `schema` as an MCP tool input schema: the root `type` is `"object"`, since the arguments are
/// always an object, and every other keyword is kept. The root of [`CommandInfo::schema`] also
/// takes null when no parameter is required.
pub(crate) fn input_schema(schema: &Value) -> Value {
let mut o = schema.clone();
if let Some(map) = o.as_object_mut() {
map.insert("type".into(), "object".into());
}
o
}
impl CommandSpec {
pub fn info(&self, s: &Session) -> CommandInfo {
let e = (self.enabled)(s);
let schema = self.schema.map(vectorcraft_apischema::ParamSchema::to_json);
CommandInfo {
id: self.id,
label: self.label,
@@ -162,7 +177,8 @@ impl CommandSpec {
params: self.params,
enabled: e.is_ok(),
disabled_reason: e.err(),
schema: self.schema.map(vectorcraft_apischema::ParamSchema::to_json),
input_schema: schema.as_ref().map(input_schema),
schema,
}
}
}
+60
View File
@@ -323,6 +323,66 @@ mod tests {
assert!(without.get("schema").is_none());
}
/// The listing gives each command with a schema an `inputSchema`, the form an MCP tool's input
/// schema takes: the same schema with the root `type` `"object"`, and every other keyword kept,
/// nested parameters, required names, defaults and VectorCraft's own fields included. A command
/// with no schema lists neither.
#[test]
fn engine_commands_list_an_input_schema_with_an_object_root() {
let s = Session::new();
let listed: Vec<Value> = s.commands().iter().map(|c| serde_json::to_value(c).unwrap()).collect();
let mut with_schema = 0;
for c in &listed {
let id = c["id"].as_str().unwrap();
let Some(schema) = c.get("schema") else {
assert!(c.get("inputSchema").is_none(), "{id}");
continue;
};
with_schema += 1;
let input = c.get("inputSchema").unwrap_or_else(|| panic!("{id} has no inputSchema"));
assert_eq!(input["type"], "object", "{id}");
assert_eq!(input["$schema"], vectorcraft_apischema::DIALECT, "{id}");
check_schema(input, id).unwrap_or_else(|e| panic!("{e}\n{input:#}"));
assert!(!instance_valid(input, &Value::Null), "{id}: the arguments are an object");
let without_type = |v: &Value| {
let mut v = v.clone();
v.as_object_mut().map(|o| o.remove("type"));
v
};
assert_eq!(without_type(schema), without_type(input), "{id}");
}
assert_eq!(with_schema, schemas().len());
let get = |id: &str| listed.iter().find(|c| c["id"] == id).unwrap();
// No required parameter: the schema also takes null, the input schema does not.
let list = get("view.saved.list");
assert_eq!(list["schema"]["type"], json!(["object", "null"]));
assert_eq!(list["inputSchema"]["type"], "object");
// Required parameters and their constraints are kept.
let new_view = get("view.saved.new");
assert_eq!(new_view["inputSchema"]["required"], json!(["center", "zoom"]));
assert_eq!(new_view["inputSchema"]["properties"], new_view["schema"]["properties"]);
// Nested objects in an array keep their keys.
let edit = &get("view.saved.edit")["inputSchema"]["properties"]["edits"];
assert!(edit.to_string().contains("newName"), "{edit}");
assert!(get("file.new").get("inputSchema").is_none());
}
/// A deprecated command and a deprecated parameter keep their deprecation in the input schema.
#[test]
fn an_input_schema_keeps_deprecation() {
static PARAMS: [Param; 2] = [Param::new("old", Type::Boolean).optional().deprecated(1), Param::new("on", Type::Boolean).optional()];
let schema = ParamSchema::new(&PARAMS, "Old command.").deprecated(1).to_json();
let input = crate::cmd::input_schema(&schema);
check_schema(&input, "synthetic").unwrap_or_else(|e| panic!("{e}\n{input:#}"));
assert_eq!(schema["type"], json!(["object", "null"]));
assert_eq!(input["type"], "object");
assert_eq!(input["deprecated"], true);
assert_eq!(input["x-deprecatedSince"], 1);
assert_eq!(input["properties"]["old"], schema["properties"]["old"]);
assert_eq!(input["properties"]["old"]["deprecated"], true);
assert_eq!(input["description"], "Old command.");
}
/// The listing says what each View command changes when it runs. Only the commands that make an
/// undo step list `document`.
#[test]
+47 -2
View File
@@ -57,18 +57,63 @@ fn command_listings_take_query_menu_and_brief() {
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.
// `brief` drops the params text and the schemas, 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!(full.as_array().unwrap().iter().any(|c| c.get("inputSchema").is_some()));
assert!(
brief
.as_array()
.unwrap()
.iter()
.all(|c| c.get("params").is_none() && c.get("schema").is_none() && c.get("inputSchema").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));
}
#[path = "../../../apischema/src/conformance.rs"]
mod conformance;
/// Both listings give a command with a parameter schema an `inputSchema` with an object root,
/// which follows the 2020-12 rules, and the filters select the same commands as before.
#[test]
fn command_listings_carry_input_schemas() {
let mut s = Server::new(Box::new(Headless::with_document()));
let list = payload(&call(&mut s, "list_commands", json!({"filter":"view.saved"})));
let bare = payload(&call(&mut s, "command_list", json!({"filter":"view.saved"})));
assert_eq!(list["commands"], bare);
assert_eq!(list["count"], bare.as_array().unwrap().len());
let commands = bare.as_array().unwrap();
let with_input: Vec<&Value> = commands.iter().filter(|c| c.get("inputSchema").is_some()).collect();
assert!(with_input.len() >= 3, "{bare}");
for c in with_input {
let input = &c["inputSchema"];
assert_eq!(input["type"], "object", "{c}");
conformance::check_schema(input, c["id"].as_str().unwrap()).unwrap_or_else(|e| panic!("{e}\n{input:#}"));
assert!(!conformance::instance_valid(input, &Value::Null));
assert_eq!(conformance::instance_valid(&c["schema"], &Value::Null), c["schema"]["type"] != "object", "{c}");
}
let saved_list = commands.iter().find(|c| c["id"] == "view.saved.list").unwrap();
assert_eq!(saved_list["schema"]["type"], json!(["object", "null"]));
assert_eq!(saved_list["inputSchema"]["type"], "object");
// A command without a schema lists neither.
let new = payload(&call(&mut s, "command_list", json!({"filter":"file.new","enabled_only":true})));
let new = new.as_array().unwrap().iter().find(|c| c["id"] == "file.new").unwrap().clone();
assert!(new.get("schema").is_none() && new.get("inputSchema").is_none(), "{new}");
// `brief`, `menu` and the enabled filter select the same ids with and without the schemas.
let ids = |v: &Value| v.as_array().unwrap().iter().map(|c| c["id"].clone()).collect::<Vec<_>>();
let view = payload(&call(&mut s, "list_commands", json!({"menu":"view","enabledOnly":true})));
let view_brief = payload(&call(&mut s, "list_commands", json!({"menu":"view","enabledOnly":true,"brief":true})));
assert_eq!(ids(&view["commands"]), ids(&view_brief["commands"]));
assert_eq!(view["count"], view_brief["count"]);
assert!(view_brief["commands"].as_array().unwrap().iter().all(|c| c.get("inputSchema").is_none() && c.get("schema").is_none()));
}
#[test]
fn conventions_commands_batch_and_bounded_preview() {
let mut s = Server::new(Box::new(Headless::with_document()));
+17
View File
@@ -261,3 +261,20 @@ fn log_records_go_out_as_notifications_once_asked_for() {
assert!(lines[last_reply + 1..].iter().all(|l| l.get("id").is_none()), "{lines:?}");
result(&mut s, 4, "logging/setLevel", json!({"level": null}));
}
/// `list_commands` gives each command's input schema over stdin and stdout, as a client reads it.
#[test]
fn list_commands_over_stdio_carries_input_schemas() {
let mut s = server();
let call = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "list_commands", "arguments": {"filter": "view.saved"}}});
let mut out = Vec::new();
s.serve((call.to_string() + "\n").as_bytes(), &mut out).unwrap();
let lines: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
let reply = lines.iter().find(|l| l["id"] == 1).cloned().unwrap_or(Value::Null);
assert_eq!(reply["result"]["isError"], false, "{reply}");
let listed: Value = serde_json::from_str(reply["result"]["content"][0]["text"].as_str().unwrap_or("null")).unwrap_or(Value::Null);
let commands = listed["commands"].as_array().cloned().unwrap_or_default();
assert!(commands.iter().filter(|c| c.get("inputSchema").is_some()).count() >= 3, "{listed}");
let saved_list = commands.iter().find(|c| c["id"] == "view.saved.list").cloned().unwrap_or(Value::Null);
assert_eq!(saved_list["inputSchema"]["type"], "object", "{listed}");
}
+2 -2
View File
@@ -98,7 +98,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"command_list",
"List commands",
"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. list_commands lists the same commands; it takes enabledOnly instead of enabled_only and returns {count, commands}.",
"Discover command ids, labels, parameter descriptions and enabled state. A command with a parameter schema also lists it as schema (JSON Schema 2020-12) and as inputSchema (the same schema with an object root, the form of a tool's inputSchema). Returns an array. The whole catalogue is large: narrow it with filter (or query), menu, and brief. list_commands lists the same commands; it takes enabledOnly instead of enabled_only and returns {count, commands}.",
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}}),
&[],
@@ -156,7 +156,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"list_commands",
"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. command_list lists the same commands as a bare array and takes enabled_only.",
"List VectorCraft commands (id, label, menu path, shortcut, parameter description, enabled state, and schema and inputSchema for a command with a parameter schema). 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. command_list lists the same commands as a bare array and takes enabled_only.",
obj(
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}}),
&[],
+5 -4
View File
@@ -10,8 +10,7 @@ pub(super) 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.
pub(super) 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";
pub(super) const BRIEF: &str = "Leave out each command's params description and its schema and inputSchema (the bulk of the catalogue); run command_list with the id as filter to read them";
/// The `max_side` argument of the window captures.
pub(super) const MAX_SIDE_WINDOW: &str =
@@ -30,7 +29,7 @@ pub(super) fn trace_views(a: &Args) -> Result<bool, String> {
/// 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).
/// text, `schema` or `inputSchema`: the full catalogue is too large for many clients, #2173).
fn filter_commands(all: Value, a: &Args) -> Value {
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"));
@@ -63,7 +62,9 @@ fn filter_commands(all: Value, a: &Args) -> Value {
.filter(hit)
.map(|mut c| {
if brief && let Some(o) = c.as_object_mut() {
o.remove("params");
for key in ["params", "schema", "inputSchema"] {
o.remove(key);
}
}
c
})
+12 -3
View File
@@ -369,10 +369,10 @@ The older names below remain listed because existing workflows use them.
| Tool | Arguments and result |
|---|---|
| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text); returns the command array. `list_commands` lists the same commands; it takes `enabledOnly` and returns `{count, commands}` |
| `command_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text, `schema` or `inputSchema`); returns the command array. A command with a parameter schema lists `schema` and `inputSchema` (see [Parameter schemas](#parameter-schemas)). `list_commands` lists the same commands; it takes `enabledOnly` and returns `{count, commands}` |
| `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)). `run_command` runs a command the same way and takes its id as `command` |
| `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), and the edits made in a document that a step opens or creates (`document.open`, `file.new`) are one undo step there, named the same, also when no document was open before the batch. In a document that was open before the batch and that a step switches to (`document.activate`), each edit stays its own undo step; 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 |
| `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_list` | Optional `filter` (or `query`), `menu` (a menu path substring, levels joined with ` > `), `enabled_only`, `brief` (no `params` text, `schema` or `inputSchema`); 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)) |
| `doc_inspect` | Optional `depth`, `childLimit`, `ids`, `kind`, `limit`, `offset`; same summary as `inspect_document` |
| `render_preview` | Optional `max_side` (1–4096, default 1024), `artboard` (0-based, default 0) and `traceViews`; inline PNG of that artboard without editing it, drawn as `export` draws a PNG with `background: "white"` at the scale that fits `max_side` (at most 1). With `traceViews: true`, Image Trace objects draw in the view set with `imageTrace.setView` (outlines, the source image…), as in the app's window; by default they draw their tracing result. Artboards too large to render within the allocation bound return a tool error |
@@ -446,7 +446,7 @@ opens the picker through `run_command` with the remote backend.
| Tool | Arguments | Notes |
|---|---|---|
| `list_commands` | `{filter?, query?, menu?, enabledOnly?, brief?}` | The command catalogue: id, label, menu, shortcut, params doc (left out with `brief`), enablement. |
| `list_commands` | `{filter?, query?, menu?, enabledOnly?, brief?}` | The command catalogue: id, label, menu, shortcut, params doc, enablement, and `schema` and `inputSchema` for a command with a parameter schema (the params doc and both schemas are left out with `brief`). |
| `run_command` | `{command, params?}` | Runs any command. Use it for everything without a dedicated tool. |
| `inspect_document` | `{depth?, childLimit?, ids?, kind?, limit?, offset?}` | Artboards (with `color`, `#rrggbb`, for one with a background color), 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. |
@@ -1403,6 +1403,15 @@ JSON Schema defines one, and the command receives `2`. An `x-lenient` parameter'
and the engine also passes a value of another type to the command. The command's `params` text equals the text the
schema writes, and a test checks that they are the same.
The `engine.commands` list and the `command_list` and `list_commands` tools also give such a command an `inputSchema`
field: the same schema in the form of an MCP tool's `inputSchema`
(https://modelcontextprotocol.io/specification/2025-06-18/schema), whose root `type` is always `"object"`
(https://modelcontextprotocol.io/specification/2026-07-28/schema). It differs from `schema` only in the root `type`,
which is `"object"` also when no parameter is required; every other keyword, `$schema` and the `x-` fields included,
is the same. A command without a schema lists neither field. The commands remain commands run with `command_run`
or `run_command`: `tools/list` does not list them as tools. With `brief`, the listings leave out `schema` and
`inputSchema` as well as the `params` text; a listing with the command's id as `filter` gives them.
The engine checks the params of such a command against its schema before the command runs, for every new call: from
the desktop app, the control channel, MCP or the CLI, and when one command runs another. The steps of `command.batch`
and `action.batch` are checked before the first step runs. A value of the wrong type, out of range or not in its list,
+1
View File
@@ -671,6 +671,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Architecture:** 19+ crates with enforced layering (`cargo xtask layers`). Every action is a command (~400 engine + ~50 UI). Undo is unlimited via structural sharing. `command.batch` runs several commands as one transaction.
- **Parameter schemas:** a command can carry a machine-readable parameter schema (the new `vectorcraft-apischema` crate): each parameter's type, range, unit, whether it is required, its default, whether null counts as left out, and the API levels it was added and deprecated at. `engine.commands` lists the schema in the shape of a JSON Schema 2020-12 object schema, a test keeps the command's `params` text equal to the text the schema writes, and a new call has the params checked against it before the command runs (batch steps before the first step), with an error naming the parameter and no change to the document; saved steps keep a key the schema does not name, and their values are checked. The View commands `view.capsLock`, `view.saved.new`, `view.saved.edit`, `view.saved.list`, `view.transparencyGrid` and `view.snapToGlyph.options` have schemas. The schemas belong to API level 1 (`vectorcraft_engine::schemas::API_VERSION`), and a test fails when a schema changes without a new level. Tests: `crates/apischema/src/tests.rs` (with `conformance.rs`, a separate check of the JSON form against the JSON Schema 2020-12 rules), `schemas::tests` and `cmd::views::tests::each_view_command_refuses_a_bad_value_naming_the_parameter` in `vectorcraft-engine`.
- **Input schemas in the command listings:** `engine.commands` and the MCP tools `command_list` and `list_commands` give each command with a parameter schema an `inputSchema` next to `schema`: the same schema with the root `type` `"object"`, the form an MCP tool's input schema takes ([MCP schema reference](https://modelcontextprotocol.io/specification/2026-07-28/schema)). A command without a schema lists neither, and `brief` leaves out both schemas with the `params` text. Tests: `schemas::tests::engine_commands_list_an_input_schema_with_an_object_root` and `schemas::tests::an_input_schema_keeps_deprecation` in `vectorcraft-engine`, and `tests::conventions::command_listings_carry_input_schemas` in `vectorcraft-mcp`, which check every listed input schema against the JSON Schema 2020-12 rules.
- **Automation:**
- Variables: Update Data Set writes what the art holds into the active data set for the variables that data set holds, and keeps the data set's value for a text, image or graph variable whose objects are locked or inside a locked layer or group; `dataset.update {all: true}` also adds the other variables that the art reports a value for.
- Variables: `dataset.select`, `dataset.next` and `dataset.prev` rewrite type and apply the other values on hidden objects and in hidden layers and groups, which stay hidden unless a visibility value shows them, so a layer shown later holds the selected data set's text; objects that are locked or in a locked layer or group are skipped, except by a visibility value.