From 7e38081b54e502c99a7995b2629c18bf301c32b6 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:37:57 +0200 Subject: [PATCH] Control channel: control.methods lists the methods, ping answers (fixes #843) (#852) A client could only learn the top-level methods from docs/control-protocol.md; help, methods, ping and the like all came back as "unknown method". The app and the headless backend now answer control.methods with each method they serve ({method, params, description}) and ping with "pong", and an unknown method's error points to control.methods. A test keeps the app's list equal to the doc table and checks every listed method is dispatched. --- crates/mcp/src/backend.rs | 37 ++++++++++++- crates/mcp/tests/control_methods.rs | 42 ++++++++++++++ crates/ui-egui/src/control.rs | 41 +++++++++++++- crates/ui-egui/tests/control_methods.rs | 73 +++++++++++++++++++++++++ docs/control-protocol.md | 5 +- 5 files changed, 194 insertions(+), 4 deletions(-) create mode 100644 crates/mcp/tests/control_methods.rs create mode 100644 crates/ui-egui/tests/control_methods.rs diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 77dbf73..dc0f25f 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -131,7 +131,18 @@ fn launch_app(addr: &str) -> std::io::Result<()> { /// Methods that only read state, so resending them after a lost reply cannot change the drawing twice. fn is_read_only(method: &str) -> bool { - matches!(method, "drawing.inspect" | "document.inspect" | "engine.commands" | "cmdline.state" | "ui.inspect" | "ui.render" | "ui.screenshot") + matches!( + method, + "drawing.inspect" + | "document.inspect" + | "engine.commands" + | "cmdline.state" + | "control.methods" + | "ping" + | "ui.inspect" + | "ui.render" + | "ui.screenshot" + ) } impl Backend for Remote { @@ -172,6 +183,23 @@ impl Backend for Remote { } } +/// What the headless `control.methods` replies: the methods [`Headless`] answers, a subset of the +/// app's (`docs/control-protocol.md`). +const HEADLESS_METHODS: &[(&str, &str, &str)] = &[ + ("cmdline.input", "{text}", "Type a line at the command line exactly like a user (commands, prompts, keywords; empty = Enter)."), + ("cmdline.script", "{text}", "Run a multi-line script, like a .scr file."), + ("cmdline.key", "{key: \"enter\"|\"escape\"}", "Press Enter or Escape at the command line."), + ("cmdline.state", "", "Current prompt, keywords, accepted input kinds and history tail."), + ("engine.execute", "{command, params}", "Run any command with JSON parameters. Never opens a dialog."), + ("engine.commands", "", "Every command: id, label, menu path, shortcut, aliases, params doc, enabled."), + ("drawing.inspect", "{entities?, limit?}", "Drawing summary and entities with handles and geometry."), + ("ui.render", "{path?, width?, height?, fit?}", "Render the drawing; replies pngBase64."), + ("app.open", "{path}", "Open a drawing."), + ("app.save", "{path?}", "Save the drawing (Save As with a path)."), + ("control.methods", "", "This list."), + ("ping", "", "Replies \"pong\"."), +]; + /// An in-process headless session (no window). pub struct Headless { pub session: Session, @@ -288,7 +316,12 @@ impl Backend for Headless { s.execute(id, &p).map_err(|e| e.to_string()) } "ui.screenshot" => Err("no UI in headless mode; use render".into()), - other => Err(format!("unknown method `{other}`")), + "control.methods" => Ok(HEADLESS_METHODS + .iter() + .map(|(method, params, description)| json!({"method": method, "params": params, "description": description})) + .collect()), + "ping" => Ok(json!("pong")), + other => Err(format!("unknown method `{other}`; `control.methods` lists them (docs/control-protocol.md)")), } } fn has_ui(&self) -> bool { diff --git a/crates/mcp/tests/control_methods.rs b/crates/mcp/tests/control_methods.rs new file mode 100644 index 0000000..da2727f --- /dev/null +++ b/crates/mcp/tests/control_methods.rs @@ -0,0 +1,42 @@ +//! The headless backend's `control.methods` lists the methods it answers, all of them documented +//! in `docs/control-protocol.md` (the app's list is tested in `crates/ui-egui/tests/control_methods.rs`). + +use std::collections::BTreeSet; + +use cadcraft_mcp::{Backend, Headless}; +use serde_json::json; + +fn documented() -> BTreeSet { + let doc = include_str!("../../../docs/control-protocol.md"); + let table = doc.lines().skip_while(|l| !l.starts_with("| Method |")).skip(2).take_while(|l| l.starts_with('|')); + table.filter_map(|l| l.split('|').nth(1)).flat_map(|cell| cell.split('`').skip(1).step_by(2).map(str::to_string).collect::>()).collect() +} + +#[test] +fn control_methods_lists_what_headless_answers() { + let mut b = Headless::default(); + let methods = b.call("control.methods", json!({})).unwrap(); + let methods = methods.as_array().unwrap(); + let doc = documented(); + let missing = std::env::temp_dir().join("cadcraft-843-no-such-dir").join("x.dxf"); + let params = json!({"path": missing.to_string_lossy()}); + for m in methods { + let name = m["method"].as_str().unwrap(); + assert!(doc.contains(name), "{name} is not in docs/control-protocol.md"); + assert!(m["params"].is_string() && m["description"].as_str().is_some_and(|d| !d.is_empty()), "{m}"); + if let Err(e) = b.call(name, params.clone()) { + assert!(!e.starts_with("unknown method"), "{name}: {e}"); + } + } + let names: BTreeSet<&str> = methods.iter().filter_map(|m| m["method"].as_str()).collect(); + assert!(names.contains("control.methods") && names.contains("ping") && names.contains("engine.execute"), "{names:?}"); + assert!(!names.contains("ui.inspect"), "headless has no UI to inspect"); +} + +#[test] +fn ping_answers_and_unknown_method_points_to_control_methods() { + let mut b = Headless::default(); + assert_eq!(b.call("ping", json!({})).unwrap(), json!("pong")); + let e = b.call("help", json!({})).unwrap_err(); + assert!(e.starts_with("unknown method `help`") && e.contains("control.methods"), "{e}"); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 3abed25..48f9a2b 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -18,6 +18,7 @@ //! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path?, width?, height?, fit?}`: headless //! render of the drawing (no window needed; fits the drawing unless `fit` is false; replies `pngBase64`) //! - `app.open {path}`, `app.save {path?}`, `app.quit` +//! - `control.methods`: this list, with params and a description; `ping`: replies `"pong"` use std::sync::Arc; use std::sync::atomic::{AtomicU8, Ordering}; @@ -185,6 +186,42 @@ fn wrap(r: Result) -> Outcome { } } +/// What `control.methods` replies: method, params, description. Keep in step with `handle` and the +/// table in `docs/control-protocol.md` (`tests/control_methods.rs` checks the table). +const METHODS: &[(&str, &str, &str)] = &[ + ("cmdline.input", "{text}", "Type a line at the command line exactly like a user (commands, prompts, keywords; empty = Enter)."), + ("cmdline.script", "{text}", "Run a multi-line script, like a .scr file."), + ("cmdline.key", "{key: \"enter\"|\"escape\"}", "Press Enter or Escape at the command line."), + ("cmdline.state", "", "Current prompt, keywords, accepted input kinds and history tail."), + ("engine.execute", "{command, params}", "Run any command with JSON parameters. Never opens a dialog."), + ("engine.commands", "", "Every command: id, label, menu path, shortcut, aliases, params doc, enabled."), + ("drawing.inspect", "{entities?, limit?}", "Drawing summary and entities with handles and geometry."), + ("ui.inspect", "", "UI state, canvas rect, view, cursor and snap, performance, keyboard focus."), + ("ui.elements", "{label?, role?, focused?}", "Observed visible controls with their egui id, rectangle, state and focus."), + ("ui.menu.list", "", "The menu tree."), + ("ui.menu.invoke", "{command}", "Invoke a menu item like a click (interactive)."), + ("ui.pointer", "{x, y, space?, button?, action?}", "Click or move in the drawing area (world coordinates by default). Right button = Enter."), + ("ui.click", "{x, y, button?, shift?, ctrl?}", "Real egui click in screen points."), + ("ui.move", "{x, y}", "Real egui pointer move in screen points."), + ("ui.drag", "{x, y, to: [x, y], button?}", "Real press, move and release."), + ("ui.key", "{key, cmd?, shift?, alt?}", "Synthetic key press."), + ("ui.text", "{text}", "Synthetic text input."), + ("ui.set", "UiState fields", "Change UI state: palettes, toolbars, command line, theme…"), + ("ui.resize", "{width, height}", "Resize the window."), + ("ui.focus", "", "Activate the native window."), + ("ui.screenshot", "{path?}", "PNG of the window (needs a presented frame)."), + ("ui.render", "{path?, width?, height?, fit?}", "Headless render of the drawing; replies pngBase64."), + ("app.open", "{path}", "Open a drawing."), + ("app.save", "{path?}", "Save the drawing (Save As with a path)."), + ("app.quit", "", "Quit without the unsaved-changes prompt."), + ("control.methods", "", "This list."), + ("ping", "", "Replies \"pong\"."), +]; + +fn methods_json() -> Value { + METHODS.iter().map(|(method, params, description)| json!({"method": method, "params": params, "description": description})).collect() +} + pub fn all_commands(app: &CadApp) -> Value { let mut v: Vec = cadcraft_engine::command_specs().iter().map(|c| serde_json::to_value(c.info(&app.session)).unwrap_or_default()).collect(); for (id, label, menu, sc) in crate::menus::UI_COMMANDS { @@ -461,7 +498,9 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou ctx.send_viewport_cmd(egui::ViewportCommand::Close); ok(Value::Null) } - other => err(format!("unknown method `{other}`")), + "control.methods" => ok(methods_json()), + "ping" => ok(json!("pong")), + other => err(format!("unknown method `{other}`; `control.methods` lists them (docs/control-protocol.md)")), } } diff --git a/crates/ui-egui/tests/control_methods.rs b/crates/ui-egui/tests/control_methods.rs new file mode 100644 index 0000000..3649565 --- /dev/null +++ b/crates/ui-egui/tests/control_methods.rs @@ -0,0 +1,73 @@ +//! `control.methods` lists what the control channel answers, matching the table in +//! `docs/control-protocol.md`; `ping` answers; an unknown method points to `control.methods` (#843). + +use std::collections::BTreeSet; + +use cadcraft_engine::Session; +use cadcraft_ui_egui::control::{ControlRequest, Outcome, handle}; +use cadcraft_ui_egui::{CadApp, Services}; +use serde_json::{Value, json}; + +fn call(app: &mut CadApp, method: &str, params: Value) -> Value { + let (req, _reply) = ControlRequest::new(method, params); + match handle(app, &egui::Context::default(), &req) { + Outcome::Done(v) => v, + Outcome::Screenshot { .. } => Value::Null, + } +} + +fn app() -> CadApp { + CadApp::new(Session::new(), Services::default()) +} + +/// The method names in the first column of the doc's method table. +fn documented() -> BTreeSet { + let doc = include_str!("../../../docs/control-protocol.md"); + let table = doc.lines().skip_while(|l| !l.starts_with("| Method |")).skip(2).take_while(|l| l.starts_with('|')); + table.filter_map(|l| l.split('|').nth(1)).flat_map(|cell| cell.split('`').skip(1).step_by(2).map(str::to_string).collect::>()).collect() +} + +fn listed(app: &mut CadApp) -> Vec { + let v = call(app, "control.methods", json!({})); + assert_eq!(v["ok"], true, "{v}"); + v["result"].as_array().cloned().unwrap_or_default() +} + +#[test] +fn control_methods_matches_the_documented_table() { + let mut app = app(); + let methods = listed(&mut app); + for m in &methods { + assert!(m["method"].is_string() && m["params"].is_string() && m["description"].as_str().is_some_and(|d| !d.is_empty()), "{m}"); + } + let names: BTreeSet = methods.iter().filter_map(|m| m["method"].as_str().map(str::to_string)).collect(); + assert_eq!(names.len(), methods.len(), "a method is listed twice"); + let doc = documented(); + assert!(doc.contains("control.methods") && doc.contains("ping"), "{doc:?}"); + assert_eq!(names, doc); +} + +#[test] +fn every_listed_method_is_answered() { + let mut app = app(); + let missing = std::env::temp_dir().join("cadcraft-843-no-such-dir").join("x.dxf"); + let params = json!({"path": missing.to_string_lossy()}); + for m in listed(&mut app) { + let name = m["method"].as_str().unwrap_or_default(); + let r = call(&mut app, name, params.clone()); + assert!(!r["error"].as_str().is_some_and(|e| e.starts_with("unknown method")), "{name}: {r}"); + } +} + +#[test] +fn ping_answers() { + assert_eq!(call(&mut app(), "ping", json!({})), json!({"ok": true, "result": "pong"})); +} + +#[test] +fn unknown_method_points_to_control_methods() { + let r = call(&mut app(), "help", json!({})); + assert_eq!(r["ok"], false); + let e = r["error"].as_str().unwrap_or_default(); + assert!(e.starts_with("unknown method `help`") && e.contains("control.methods"), "{e}"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 34cde6e..40af6bb 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -9,7 +9,8 @@ Start the desktop app with `--control PORT` (or `CADCRAFT_CONTROL_PORT=PORT`). I {"id": 1, "ok": true, "result": {"prompt": "Command:", "output": ["Command: CIRCLE"], ...}} ``` -Errors come back as `{"id": …, "ok": false, "error": "message"}`. +Errors come back as `{"id": …, "ok": false, "error": "message"}`. An unknown method's error points +to `control.methods`, which lists the methods the server answers. The server waits up to 60 s for the app to answer a request. After that it replies with a timeout, and `state` tells whether sending the request again is safe: @@ -42,6 +43,8 @@ The server waits up to 60 s for the app to answer a request. After that it repli | `ui.screenshot` | `{path?}` | PNG of the window (needs a presented frame). | | `ui.render` | `{path?, width?, height?, fit?}` | Headless render. `fit` (default true) frames the whole drawing; `false` uses the current view. Replies `{pngBase64, width, height, bytes, path?}`. | | `app.open` / `app.save` / `app.quit` | `{path}` | File operations. | +| `control.methods` | | Every method this server answers: `{method, params, description}`. Headless (`cadcraft-cli mcp`) lists only the methods it serves. | +| `ping` | | Replies `"pong"`. A cheap health check. | The MCP server (`cadcraft-cli mcp --connect 127.0.0.1:PORT`) wraps this protocol; headless (`cadcraft-cli mcp`) it serves the same methods from an in-process session.