Files
cadcraft/crates/mcp/src/tools.rs
T
Valentin KleinandClaude Opus 5.5 dacd8ece49 Control channel: headless and app reply alike to cmdline.input, state and app.open (fixes #351) (#352)
Both backends now return the same command-line state (prompt, running,
keywords, accept, buffer, history, historyExpanded). cmdline.input echoes
a refused line to the history and `output` and also reports it in `error`
on both sides. Headless app.open without a path answers "missing path"
like the app instead of reaching OPEN. Documented in control-protocol.md.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:32:24 -04:00

145 lines
9.7 KiB
Rust

//! MCP tool definitions and dispatch.
use serde_json::{Value, json};
use crate::backend::Backend;
pub fn tool_definitions(has_ui: bool) -> Value {
let mut tools = vec![
json!({"name": "command_line", "description": "Type one line at the CADCraft command line, exactly as a user would at an AutoCAD-style prompt. Starts commands by name or alias (LINE, L, CIRCLE, C, PLINE, RECTANG, MOVE, TRIM, OFFSET, ZOOM…) and answers the active prompt: points `x,y`, relative `@dx,dy`, polar `@dist<angle`, distances, keywords (e.g. `c` for Close), empty text = Enter. Spaces act as Enter, so `circle 0,0 5` works in one call. Returns the new prompt and command-line output, and `error` when the line was refused.",
"inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}}),
json!({"name": "script", "description": "Run a multi-line script (like an AutoCAD .scr file): one or more inputs per line, blank line = Enter.",
"inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]}}),
json!({"name": "execute", "description": "Run any command with JSON parameters (no prompts or dialogs). Use list_commands to find ids and parameter docs. Examples: line {points:[[0,0],[10,0]]}, circle {center:[0,0], radius:5}, layer.new {name:\"Walls\", color:\"red\", current:true}, move {handles:[\"10A\"], delta:[5,0]}, zoom {mode:\"extents\"}.",
"inputSchema": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}, "required": ["command"]}}),
json!({"name": "list_commands", "description": "List commands with ids, labels, menu paths, aliases and parameter docs. Optional filter: a substring of the command id, label or an alias.",
"inputSchema": {"type": "object", "properties": {"filter": {"type": "string"}}}}),
json!({"name": "inspect_drawing", "description": "Summary of the active drawing: entity counts, extents, layers, styles, blocks, layouts, selection, undo history and (optionally) entities with handles and geometry.",
"inputSchema": {"type": "object", "properties": {"entities": {"type": "boolean"}, "limit": {"type": "integer"}}}}),
json!({"name": "query_entities", "description": "Entities filtered by type (Line, Circle, Polyline…), layer and/or a window [[x0,y0],[x1,y1]] (objects whose geometry is inside or crosses it; crossing:false = entirely inside). Returns count (all matches), returned (this page) and entities; page with offset and limit (default 500).",
"inputSchema": {"type": "object", "properties": {"type": {"type": "string"}, "layer": {"type": "string"},
"window": {"type": "array", "items": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}, "minItems": 2, "maxItems": 2},
"crossing": {"type": "boolean"}, "limit": {"type": "integer", "minimum": 0}, "offset": {"type": "integer", "minimum": 0}}}}),
json!({"name": "render", "description": "Render the active drawing to a PNG (fitted to extents by default) and return it as an image.",
"inputSchema": {"type": "object", "properties": {"width": {"type": "integer"}, "height": {"type": "integer"}, "fit": {"type": "boolean"}, "path": {"type": "string"}}}}),
json!({"name": "new_drawing", "description": "Create a new drawing (imperial by default, or metric).",
"inputSchema": {"type": "object", "properties": {"metric": {"type": "boolean"}}}}),
json!({"name": "open", "description": "Open a DXF file.", "inputSchema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}}),
json!({"name": "save", "description": "Save the drawing (DXF or DWG by extension). A .svg, .png or .pdf path exports instead: the drawing keeps its name and unsaved changes.", "inputSchema": {"type": "object", "properties": {"path": {"type": "string"}}}}),
json!({"name": "cancel", "description": "Press Escape: cancel the running command (or clear the selection).", "inputSchema": {"type": "object", "properties": {}}}),
];
if has_ui {
tools.push(json!({"name": "screenshot", "description": "Screenshot the CADCraft window (PNG).", "inputSchema": {"type": "object", "properties": {"path": {"type": "string"}}}}));
tools.push(json!({"name": "ui_inspect", "description": "UI state: panels, canvas rect, view, cursor and snap, performance.", "inputSchema": {"type": "object", "properties": {}}}));
tools.push(json!({"name": "ui_click", "description": "Click in the drawing area at world coordinates (or screen with space:\"screen\"). Right button = Enter.", "inputSchema": {"type": "object", "properties": {"x": {"type": "number"}, "y": {"type": "number"}, "space": {"type": "string"}, "button": {"type": "string"}}, "required": ["x", "y"]}}));
}
json!({ "tools": tools })
}
fn text(v: &Value) -> Value {
json!({"content": [{"type": "text", "text": serde_json::to_string_pretty(v).unwrap_or_default()}]})
}
fn err_text(e: &str) -> Value {
json!({"content": [{"type": "text", "text": e}], "isError": true})
}
/// Whether `v` fits the subset of JSON Schema the tool definitions use: `type`, `items`,
/// `minItems`, `maxItems` and `minimum`.
fn fits(v: &Value, schema: &Value) -> bool {
let typed = match schema["type"].as_str() {
Some("string") => v.is_string(),
// JSON Schema integers include numbers such as 2.0.
Some("integer") => v.as_f64().is_some_and(|f| f.fract() == 0.0),
Some("number") => v.is_number(),
Some("boolean") => v.is_boolean(),
Some("array") => v.is_array(),
Some("object") => v.is_object(),
_ => true,
};
let len = v.as_array().map(Vec::len);
typed
&& schema["minimum"].as_f64().is_none_or(|m| v.as_f64().is_none_or(|f| f >= m))
&& schema["minItems"].as_u64().is_none_or(|m| len.is_none_or(|n| n as u64 >= m))
&& schema["maxItems"].as_u64().is_none_or(|m| len.is_none_or(|n| n as u64 <= m))
&& (schema["items"].is_null() || v.as_array().is_none_or(|a| a.iter().all(|i| fits(i, &schema["items"]))))
}
/// Arguments checked against the tool's input schema: an object, required properties present and
/// each given property fitting its schema (`null` counts as not given). `Err` is the
/// invalid-params message.
fn check_args(name: &str, args: &Value) -> Result<Value, String> {
let defs = tool_definitions(true);
let schema = defs["tools"].as_array().and_then(|t| t.iter().find(|t| t["name"] == name)).map(|t| t["inputSchema"].clone());
let Some(schema) = schema else { return Err(format!("unknown tool `{name}`")) };
let a = match args {
Value::Null => json!({}),
Value::Object(_) => args.clone(),
_ => return Err(format!("`arguments` of `{name}` must be an object")),
};
for key in schema["required"].as_array().into_iter().flatten().filter_map(Value::as_str) {
if a.get(key).is_none_or(Value::is_null) {
return Err(format!("`{name}` needs `{key}`"));
}
}
for (key, prop) in schema["properties"].as_object().into_iter().flatten() {
if let Some(v) = a.get(key).filter(|v| !v.is_null())
&& !fits(v, prop)
{
return Err(format!("`{key}` of `{name}` must match {prop}"));
}
}
Ok(a)
}
/// Whether a command-catalog entry's id, label or one of its aliases contains `f` (lower case).
fn command_matches(c: &Value, f: &str) -> bool {
let has = |s: &str| s.to_ascii_lowercase().contains(f);
["id", "label"].iter().any(|k| c[*k].as_str().is_some_and(has))
|| c["aliases"].as_array().is_some_and(|a| a.iter().filter_map(Value::as_str).any(has))
}
/// Run a tool. `Err` is a protocol error (unknown tool, or arguments that don't fit its input
/// schema), answered with JSON-RPC invalid params; a failing tool is an `isError` result.
pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> Result<Value, String> {
let a = check_args(name, args)?;
let r = match name {
"command_line" => b.call("cmdline.input", a),
"script" => b.call("cmdline.script", a),
"execute" => b.call(
"engine.execute",
json!({"command": a.get("command").cloned().unwrap_or(Value::Null), "params": a.get("params").cloned().unwrap_or(json!({}))}),
),
"list_commands" => b.call("engine.commands", json!({})).map(|v| {
let f = a.get("filter").and_then(Value::as_str).map(str::to_ascii_lowercase);
match (v, f) {
(Value::Array(items), Some(f)) => Value::Array(items.into_iter().filter(|c| command_matches(c, &f)).collect()),
(v, _) => v,
}
}),
"inspect_drawing" => b.call("drawing.inspect", a),
"query_entities" => b.call("engine.execute", json!({"command": "entities", "params": a})),
"new_drawing" => b.call("engine.execute", json!({"command": "new", "params": a})),
"open" => b.call("app.open", a),
"save" => b.call("app.save", a),
"cancel" => b.call("cmdline.key", json!({"key": "escape"})),
"screenshot" => b.call("ui.screenshot", a),
"ui_inspect" => b.call("ui.inspect", json!({})),
"ui_click" => b.call("ui.pointer", a),
"render" => {
return Ok(match b.call("ui.render", a) {
Ok(v) => match v.get("pngBase64").and_then(Value::as_str) {
Some(data) => json!({"content": [{"type": "image", "data": data, "mimeType": "image/png"}]}),
None => text(&v),
},
Err(e) => err_text(&e),
});
}
other => return Err(format!("unknown tool `{other}`")),
};
Ok(match r {
Ok(v) => text(&v),
Err(e) => err_text(&e),
})
}