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.
This commit is contained in:
Marco Arnulfo 2026-10-13 10:37:57 +02:00 committed by GitHub
parent 3f709bed2e
commit 7e38081b54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 194 additions and 4 deletions

View File

@ -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 {

View File

@ -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<String> {
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::<Vec<_>>()).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}");
}

View File

@ -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<Value, String>) -> 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<Value> = 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)")),
}
}

View File

@ -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<String> {
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::<Vec<_>>()).collect()
}
fn listed(app: &mut CadApp) -> Vec<Value> {
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<String> = 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}");
}

View File

@ -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.