From 9172950ad50349b186f23dba6d4d18ba429ad5c6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 19:34:24 -0800 Subject: [PATCH 1/2] MCP: command listings give each command with a schema an inputSchema engine.commands, command_list and list_commands now list an inputSchema next to schema for each command with a parameter schema. It is the same JSON Schema 2020-12 schema with the root type "object", the form an MCP tool's inputSchema takes (https://modelcontextprotocol.io/specification/2026-07-28/schema); schema keeps the root type ["object", "null"] when no parameter is required. A command without a schema lists neither field. tools/list is unchanged, and the commands are still run with command_run or run_command. brief now leaves out schema and inputSchema as well as the params text; a listing filtered to the command's id gives them. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd.rs | 18 ++++++++- crates/engine/src/schemas.rs | 60 +++++++++++++++++++++++++++++ crates/mcp/src/tests/conventions.rs | 49 ++++++++++++++++++++++- crates/mcp/src/tools/definitions.rs | 4 +- crates/mcp/src/tools/dispatch.rs | 9 +++-- docs/mcp.md | 15 ++++++-- docs/target-app-parity.md | 1 + 7 files changed, 144 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 4042f0a13..a68703f11 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -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, + /// 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, +} + +/// `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, } } } diff --git a/crates/engine/src/schemas.rs b/crates/engine/src/schemas.rs index c70c4bb82..a24613a07 100644 --- a/crates/engine/src/schemas.rs +++ b/crates/engine/src/schemas.rs @@ -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 = 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] diff --git a/crates/mcp/src/tests/conventions.rs b/crates/mcp/src/tests/conventions.rs index 929851917..0b7352bf4 100644 --- a/crates/mcp/src/tests/conventions.rs +++ b/crates/mcp/src/tests/conventions.rs @@ -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::>(); + 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())); diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index 616e32aae..8fe45b83e 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -98,7 +98,7 @@ pub fn tool_definitions() -> Vec { 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 { 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}}), &[], diff --git a/crates/mcp/src/tools/dispatch.rs b/crates/mcp/src/tools/dispatch.rs index eabd73e3e..b1c5af70b 100644 --- a/crates/mcp/src/tools/dispatch.rs +++ b/crates/mcp/src/tools/dispatch.rs @@ -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 { /// 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 }) diff --git a/docs/mcp.md b/docs/mcp.md index 2618ebfa4..e818579c8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 17cc2849b..31b606249 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -669,6 +669,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. From 9d01c0e7b5bfeed18e2fbdd37f7d89dc4cf92aff Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 19:59:29 -0800 Subject: [PATCH 2/2] MCP: test that list_commands gives input schemas over stdio --- crates/mcp/src/tests/protocol.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/mcp/src/tests/protocol.rs b/crates/mcp/src/tests/protocol.rs index 1fe41c3e5..a3c79aae7 100644 --- a/crates/mcp/src/tests/protocol.rs +++ b/crates/mcp/src/tests/protocol.rs @@ -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 = 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}"); +}