From 188f2d5fc4140c353f8ec5d0b9c405611444f35f Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:05:29 -0400 Subject: [PATCH] mcp, cli: list each command's parameters as JSON Schema CommandSpec::listing gives id, label, the parameter doc and the schema; MCP list_commands, the control channel's engine.commands and the new fieldcraft-cli commands --json use it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- apps/fieldcraft-cli/src/main.rs | 5 +++++ crates/engine/src/lib.rs | 13 +++++++++++++ crates/mcp/src/backend.rs | 2 +- crates/mcp/src/tests.rs | 3 +++ crates/ui-egui/src/control.rs | 4 +--- docs/control-protocol.md | 2 +- docs/mcp.md | 2 +- 7 files changed, 25 insertions(+), 6 deletions(-) diff --git a/apps/fieldcraft-cli/src/main.rs b/apps/fieldcraft-cli/src/main.rs index 6ce2ff7..eaa5902 100644 --- a/apps/fieldcraft-cli/src/main.rs +++ b/apps/fieldcraft-cli/src/main.rs @@ -15,6 +15,7 @@ use serde_json::json; const USAGE: &str = "\ usage: fieldcraft-cli commands list every command with its parameters + fieldcraft-cli commands --json the same as JSON, each command's parameters as JSON Schema fieldcraft-cli run run a script (a JSON array of {\"command\", \"params\"}) and print the model summary fieldcraft-cli features print docs/features.md (every capability, ready or @@ -36,6 +37,10 @@ const MAX_SCRIPT_BYTES: u64 = 256 * 1024 * 1024; fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); let result = match args.first().map(String::as_str) { + Some("commands") if args.get(1).map(String::as_str) == Some("--json") => { + let all: Vec<_> = fieldcraft_engine::commands().iter().map(fieldcraft_engine::CommandSpec::listing).collect(); + serde_json::to_string_pretty(&all).map(|text| println!("{text}")).map_err(|e| e.to_string()) + } Some("commands") => { for c in fieldcraft_engine::commands() { println!("{:<24} {:<18} {}", c.id, c.label, c.params); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d409f2b..ec833ab 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -121,6 +121,19 @@ pub fn commands() -> &'static [CommandSpec] { cmd::ALL } +impl CommandSpec { + /// The command as JSON for listings (`fieldcraft-cli commands --json`, the control channel's + /// `engine.commands`, MCP `list_commands`): id, label, the parameter doc and, when described, + /// the parameters as JSON Schema. + pub fn listing(&self) -> Value { + let mut v = serde_json::json!({"id": self.id, "label": self.label, "params": self.params}); + if let Some(form) = self.form { + v["schema"] = schema::json_schema(form); + } + v + } +} + pub fn find_command(id: &str) -> Option<&'static CommandSpec> { commands().iter().find(|c| c.id == id) } diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 9feb88d..85bad6a 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -98,7 +98,7 @@ impl Backend for Headless { } fn commands(&mut self) -> Result { - Ok(fieldcraft_engine::commands().iter().map(|c| json!({"id": c.id, "label": c.label, "params": c.params})).collect()) + Ok(fieldcraft_engine::commands().iter().map(fieldcraft_engine::CommandSpec::listing).collect()) } fn picture(&mut self, r: &PictureRequest) -> Result { diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 71d6e2a..9140ae7 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -243,6 +243,9 @@ fn a_batch_stops_at_the_first_error() { assert_eq!(ok(&mut s, "inspect_model", json!({}))["model"]["nodes"], 0); let listed = ok(&mut s, "list_commands", json!({"filter": "SOLVE."})); assert!(listed.as_array().unwrap().iter().all(|c| c["id"].as_str().unwrap().starts_with("solve."))); + // Each command's parameters as JSON Schema, for agents to build valid calls. + let statics = listed.as_array().unwrap().iter().find(|c| c["id"] == "solve.static").unwrap(); + assert_eq!(statics["schema"]["properties"]["threads"]["maximum"], 256); } #[test] diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 7d1d024..37620c6 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -174,9 +174,7 @@ pub fn handle(app: &mut ModelApp, ctx: &egui::Context, req: &ControlRequest) -> let Some(steps) = p.get("commands") else { return err("missing `commands` (a list of {command, params})") }; wrap(app.run_script(steps).map(|()| json!({"journal": app.session.journal.len()}))) } - "engine.commands" => { - ok(Value::Array(fieldcraft_engine::commands().iter().map(|c| json!({"id": c.id, "label": c.label, "params": c.params})).collect())) - } + "engine.commands" => ok(Value::Array(fieldcraft_engine::commands().iter().map(fieldcraft_engine::CommandSpec::listing).collect())), "ui.inspect" => ok(json!({ "ui": app.ui, "camera": app.cam, diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 11f47cf..35184fd 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -26,7 +26,7 @@ checked and clamped (drag steps, wheel lines, window size); `ui.render` refuses |---|---|---| | `engine.execute` | `command`: command id, `params`: JSON object | the command's result | | `engine.script` | `commands`: `[{command, params}, …]` | stops at the first error; `journal`: commands that changed the model so far | -| `engine.commands` | — | every command: `id`, `label`, `params` (the parameter documentation) | +| `engine.commands` | — | every command: `id`, `label`, `params` (the parameter documentation) and `schema` (its parameters as JSON Schema) | Commands never open forms or file pickers when run this way. `fieldcraft-cli commands` prints the same registry. `file.sample {name}` opens a sample (`parts`, `cantilever`, `impact`) and diff --git a/docs/mcp.md b/docs/mcp.md index a2f095b..3a4d0a3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -91,7 +91,7 @@ software on the computer. | Tool | Arguments | What it does | |---|---|---| -| `list_commands` | `filter?` | Every command with its label and parameter documentation; `filter` matches id and label. | +| `list_commands` | `filter?` | Every command with its label, parameter documentation and `schema` (its parameters as JSON Schema: types, units as `x-dimension`, defaults, required fields, one-of variants); `filter` matches id and label. | | `execute` | `command`, `params?` | Run any command (`list_commands`). A failure leaves the model unchanged and names what to fix. | | `batch` | `commands: [{command, params}]` | Run steps in order, stop at the first error; the reply has every result and the failing step (`failed.index`, `failed.command`, `failed.error`). Steps before it stay applied (`undo` takes them back). | | `inspect_model` | — | `model.info` (counts, units, definitions), `model.activity` (the step table) and `geometry.bodies` (bodies with measures and persistent face names, which sets name as `{body, face}`). |