MCP: the open tool description names DWG as well as DXF (#1887)

* MCP: the open tool description names DWG as well as DXF

The open tool reads both DXF and DWG through the io layer, but
tools/list advertised it as "Open a DXF file." so clients reading
the descriptions would not try a DWG. Say "Open a DXF or DWG file."

Adds a regression test that finds open in the tools/list reply and
asserts its description still mentions DXF and now mentions DWG.

Fixes #1875

* contributors: credit nicholasgower by GitHub profile name

* docs: document the functions touched by #1875

---------

Co-authored-by: nicholasgower <nicholasgower@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Nicholas Gower
2026-10-24 01:56:34 -04:00
committed by GitHub
co-authored by nicholasgower Brandon Thomas
parent fef32761af
commit 9fc626a33b
3 changed files with 37 additions and 3 deletions
+3 -2
View File
@@ -74,6 +74,9 @@ sync_github_name = false
[people.gurparvez] [people.gurparvez]
real_name = "Gurparvez" real_name = "Gurparvez"
[people.nicholasgower]
sync_github_name = true
[people.ffishman] [people.ffishman]
real_name = "Rodrigo Gressler" real_name = "Rodrigo Gressler"
display_name = "Rodrigo" display_name = "Rodrigo"
@@ -84,5 +87,3 @@ real_name = "Richard Sauer"
display_name = "Richard Sauer" display_name = "Richard Sauer"
sync_github_name = false sync_github_name = false
[people.nicholasgower]
sync_github_name = true
+29
View File
@@ -4,12 +4,16 @@ use serde_json::{Value, json};
use crate::{Headless, Server}; use crate::{Headless, Server};
/// Send one JSON-RPC request to the server and return the parsed reply; fails the test if the
/// server does not answer or answers with invalid JSON.
fn call(s: &mut Server, id: u64, method: &str, params: Value) -> Value { fn call(s: &mut Server, id: u64, method: &str, params: Value) -> Value {
let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string(); let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
let r = s.handle_line(&line).expect("reply"); let r = s.handle_line(&line).expect("reply");
serde_json::from_str(&r).unwrap() serde_json::from_str(&r).unwrap()
} }
/// Call an MCP tool and return its decoded result: JSON text is parsed, an image is passed
/// through, and a tool error fails the test with the reported message.
fn tool(s: &mut Server, name: &str, args: Value) -> Value { fn tool(s: &mut Server, name: &str, args: Value) -> Value {
let r = call(s, 99, "tools/call", json!({"name": name, "arguments": args})); let r = call(s, 99, "tools/call", json!({"name": name, "arguments": args}));
let c = &r["result"]["content"][0]; let c = &r["result"]["content"][0];
@@ -17,6 +21,8 @@ fn tool(s: &mut Server, name: &str, args: Value) -> Value {
if c["type"] == "text" { serde_json::from_str(c["text"].as_str().unwrap()).unwrap_or(Value::Null) } else { c.clone() } if c["type"] == "text" { serde_json::from_str(c["text"].as_str().unwrap()).unwrap_or(Value::Null) } else { c.clone() }
} }
/// The MCP handshake works: `initialize` names the server, the `initialized` notification is
/// accepted, `tools/list` offers the command line, and an unknown method is -32601.
#[test] #[test]
fn lifecycle_and_tools() { fn lifecycle_and_tools() {
let mut s = Server::new(Box::new(Headless::default())); let mut s = Server::new(Box::new(Headless::default()));
@@ -30,6 +36,8 @@ fn lifecycle_and_tools() {
assert_eq!(bad["error"]["code"], -32601); assert_eq!(bad["error"]["code"], -32601);
} }
/// Guards that an agent can complete a drawing (plate, holes, layer, text) using only the MCP
/// tools, and verify it only through MCP: counts, selection queries, undo and a PNG render.
#[test] #[test]
fn agent_draws_a_plate_with_holes() { fn agent_draws_a_plate_with_holes() {
let mut s = Server::new(Box::new(Headless::default())); let mut s = Server::new(Box::new(Headless::default()));
@@ -70,6 +78,8 @@ fn agent_draws_a_plate_with_holes() {
assert!(img["data"].as_str().unwrap().len() > 100); assert!(img["data"].as_str().unwrap().len() > 100);
} }
/// Guards that bad input is reported, not fatal: a refused command and a screenshot without a
/// UI are `isError` results, and malformed JSON is a parse error (-32700).
#[test] #[test]
fn errors_are_tool_errors_not_crashes() { fn errors_are_tool_errors_not_crashes() {
let mut s = Server::new(Box::new(Headless::default())); let mut s = Server::new(Box::new(Headless::default()));
@@ -111,6 +121,19 @@ fn bad_tool_calls_are_invalid_params() {
assert_eq!(s.handle_line("[]").map(|r| serde_json::from_str::<Value>(&r).unwrap()["error"]["code"].clone()), Some(json!(-32600))); assert_eq!(s.handle_line("[]").map(|r| serde_json::from_str::<Value>(&r).unwrap()["error"]["code"].clone()), Some(json!(-32600)));
} }
/// `open` reads both DXF and DWG, so `tools/list` must advertise both (#1875).
#[test]
fn open_tool_description_names_dxf_and_dwg() {
let mut s = Server::new(Box::new(Headless::default()));
let tools = call(&mut s, 1, "tools/list", json!({}));
let open = tools["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "open").unwrap();
let desc = open["description"].as_str().unwrap();
assert!(desc.contains("DXF"), "`open` must still name DXF: {desc}");
assert!(desc.contains("DWG"), "`open` reads DWG too: {desc}");
}
/// Guards that `query_entities` advertises its `selected` filter and honours it (active-space
/// selection only, both ways) without changing the drawing.
#[test] #[test]
fn query_entities_selection_filter_is_discoverable_and_forwarded() { fn query_entities_selection_filter_is_discoverable_and_forwarded() {
let mut s = Server::new(Box::new(Headless::default())); let mut s = Server::new(Box::new(Headless::default()));
@@ -149,6 +172,8 @@ fn query_entities_pages_and_windows_by_geometry() {
assert_eq!(r["count"], 1, "only the circle is entirely inside: {r}"); assert_eq!(r["count"], 1, "only the circle is entirely inside: {r}");
} }
/// Guards that the `list_commands` filter matches ids, labels and aliases but not parameter
/// docs, so searching `circle` does not return QSELECT merely for mentioning circles.
#[test] #[test]
fn list_commands_filter_matches_names_not_docs() { fn list_commands_filter_matches_names_not_docs() {
let mut s = Server::new(Box::new(Headless::default())); let mut s = Server::new(Box::new(Headless::default()));
@@ -160,6 +185,8 @@ fn list_commands_filter_matches_names_not_docs() {
assert!(r.as_array().unwrap().iter().any(|c| c["id"] == "pline"), "aliases and ids match"); assert!(r.as_array().unwrap().iter().any(|c| c["id"] == "pline"), "aliases and ids match");
} }
/// Guards that `find_text` returns TEXT and MTEXT with their placement, strips MTEXT formatting,
/// and filters by text, layer and window; a windowed render keeps that area's aspect ratio.
#[test] #[test]
fn find_text_reads_texts_and_render_shows_one_area() { fn find_text_reads_texts_and_render_shows_one_area() {
let mut s = Server::new(Box::new(Headless::default())); let mut s = Server::new(Box::new(Headless::default()));
@@ -199,6 +226,8 @@ fn find_text_reads_texts_and_render_shows_one_area() {
assert_eq!((w, h), (1600, 800)); assert_eq!((w, h), (1600, 800));
} }
/// Guards a lazy Remote bridge: it serves tools without the app, reports how to start it
/// (`--control`), and reaches the app once that app starts listening on the port.
#[test] #[test]
fn a_lazy_bridge_starts_without_the_app_and_says_how_to_open_it() { fn a_lazy_bridge_starts_without_the_app_and_says_how_to_open_it() {
// Nothing listens on a port that was just freed. // Nothing listens on a port that was just freed.
+5 -1
View File
@@ -4,6 +4,8 @@ use serde_json::{Value, json};
use crate::backend::Backend; use crate::backend::Backend;
/// The `tools/list` payload: one definition (name, description, input schema) per MCP tool,
/// including the UI-only tools when `has_ui` says a window is available.
pub fn tool_definitions(has_ui: bool) -> Value { pub fn tool_definitions(has_ui: bool) -> Value {
let mut tools = vec![ 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.", 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.",
@@ -30,7 +32,7 @@ pub fn tool_definitions(has_ui: bool) -> Value {
"limit": {"type": "integer", "minimum": 0}, "offset": {"type": "integer", "minimum": 0}}}}), "limit": {"type": "integer", "minimum": 0}, "offset": {"type": "integer", "minimum": 0}}}}),
json!({"name": "new_drawing", "description": "Create a new drawing (imperial by default, or metric).", json!({"name": "new_drawing", "description": "Create a new drawing (imperial by default, or metric).",
"inputSchema": {"type": "object", "properties": {"metric": {"type": "boolean"}}}}), "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": "open", "description": "Open a DXF or DWG 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": "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": {}}}), json!({"name": "cancel", "description": "Press Escape: cancel the running command (or clear the selection).", "inputSchema": {"type": "object", "properties": {}}}),
]; ];
@@ -42,10 +44,12 @@ pub fn tool_definitions(has_ui: bool) -> Value {
json!({ "tools": tools }) json!({ "tools": tools })
} }
/// Wrap a JSON value as a successful MCP result containing pretty-printed text.
fn text(v: &Value) -> Value { fn text(v: &Value) -> Value {
json!({"content": [{"type": "text", "text": serde_json::to_string_pretty(v).unwrap_or_default()}]}) json!({"content": [{"type": "text", "text": serde_json::to_string_pretty(v).unwrap_or_default()}]})
} }
/// Wrap an error message as an MCP tool error result (`isError`), not a protocol error.
fn err_text(e: &str) -> Value { fn err_text(e: &str) -> Value {
json!({"content": [{"type": "text", "text": e}], "isError": true}) json!({"content": [{"type": "text", "text": e}], "isError": true})
} }