From 0e4f755e6f9097377100d674048ede6fde7f8074 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 5 Sep 2026 18:22:05 +0300 Subject: [PATCH] Make native MCP self-describing --- docs/automation/README.md | 21 +++- docs/automation/mcp_smoke.py | 30 ++++- src/app/control/mod.rs | 149 +++++++++++++++++++++-- src/app/mod.rs | 3 + src/mcp.rs | 221 ++++++++++++++++++++++++++++++++++- 5 files changed, 407 insertions(+), 17 deletions(-) diff --git a/docs/automation/README.md b/docs/automation/README.md index e59e070f7..18775e0ae 100644 --- a/docs/automation/README.md +++ b/docs/automation/README.md @@ -31,9 +31,28 @@ The normal flow is to call `ocs_sessions`, read the chosen session with `ocs_rea } ``` +To discover command syntax without reading application source, call `ocs_read` with `op: "commands"`. The unfiltered response lists commands and actions. Add `parameters: {"name": "PLINE"}` for a command's batch examples and interactive guidance. + +A complete command can be sent with `run`. Prompt answers are separated by spaces, points use `x,y` or `x,y,z`, and option answers use their displayed token: + +```json +{ + "session_id": "SESSION_FROM_OCS_SESSIONS", + "request": { + "op": "run", + "request_id": "polyline-1", + "document_id": 1, + "revision": 12, + "cmd": "PLINE 0,0 10,0 10,10 C" + } +} +``` + +For unfamiliar or conditional commands, use `start`, then inspect `state.command` in every response. Its `accepts` array gives the valid MCP input kinds, `options` gives the current tokens, and `input_example` gives the next request shape. Add the current state fields and a new `request_id` to each step. + Every `ocs_execute` request requires a caller-generated `request_id`. Reuse that ID only to retry the identical request after a timeout, together with the same session ID, document ID, expected revision, and selection. Commands report `waiting_input` while more input is required and asynchronous work remains `running` until its real callback finishes. Geometry stays in OpenCADStudio and its geometry kernel. -The source-tree protocol smoke test uses only the Python standard library: +The source-tree protocol smoke test treats the executable as a black box and uses only the Python standard library. It checks both supported protocol styles, the published schemas, structured errors, and the four-tool surface: ```sh cargo build diff --git a/docs/automation/mcp_smoke.py b/docs/automation/mcp_smoke.py index 47ff970c4..6c651d3af 100644 --- a/docs/automation/mcp_smoke.py +++ b/docs/automation/mcp_smoke.py @@ -44,12 +44,21 @@ def legacy(server: Path) -> None: "params": {"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "smoke", "version": "1"}}, }) assert initialized["result"]["serverInfo"]["name"] == "OpenCADStudio" - assert initialized["result"]["instructions"] + assert "state.command.accepts" in initialized["result"]["instructions"] process.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n") process.stdin.flush() tools = request(process, {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) - names = {tool["name"] for tool in tools["result"]["tools"]} + definitions = {tool["name"]: tool for tool in tools["result"]["tools"]} + names = set(definitions) assert names == TOOLS, names + execute_request = definitions["ocs_execute"]["inputSchema"]["properties"]["request"] + assert len(execute_request["oneOf"]) == 14 + assert execute_request["properties"]["cmd"]["examples"][0] == "LINE 0,0 10,10" + assert execute_request["properties"]["kind"]["enum"] == [ + "text", "token", "point", "entity", "structure", "selection", "enter" + ] + for name in {"ocs_sessions", "ocs_read", "ocs_execute"}: + assert definitions[name]["outputSchema"]["type"] == "object" assert "resultType" not in tools["result"] sessions = request(process, { "jsonrpc": "2.0", @@ -89,6 +98,23 @@ def modern(server: Path) -> None: assert tools["result"]["ttlMs"] >= 0 assert tools["result"]["cacheScope"] in {"public", "private"} + missing_cmd = request(process, { + "jsonrpc": "2.0", + "id": "missing-cmd", + "method": "tools/call", + "params": { + "name": "ocs_execute", + "arguments": { + "session_id": "missing", + "request": {"op": "run", "request_id": "run-1"}, + }, + "_meta": meta, + }, + }) + error = missing_cmd["result"]["structuredContent"] + assert error["code"] == "invalid_arguments" + assert "LINE 0,0 10,10" in error["error"] + invalid_mutation = request(process, { "jsonrpc": "2.0", "id": "invalid-mutation", diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index e0c93dfc3..64efd2f7e 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -1,6 +1,6 @@ //! Semantic control shared by the GUI, headless client and web adapter. use super::{Message, OpenCADStudio}; -use crate::command::StepInput; +use crate::command::{InputKind, StepInput}; use iced::Task; use serde_json::{json, Value}; use std::{collections::VecDeque, sync::OnceLock}; @@ -166,6 +166,78 @@ fn point(req: &Value) -> Result { Ok(glam::DVec3::from_array(p)) } +fn command_examples(name: &str) -> &'static [&'static str] { + match name { + "LINE" => &["LINE 0,0 10,10"], + "CIRCLE" => &["CIRCLE 5,5 3"], + "PLINE" => &["PLINE 0,0 10,0 10,10 C"], + "ZOOM" => &["ZOOM EXTENTS"], + _ => &[], + } +} + +fn active_command_metadata(command: &dyn crate::command::CadCommand) -> Value { + let options = command.options(); + let mut accepts = Vec::new(); + if command.is_selection_gathering() { + accepts.push("selection"); + } + if command.needs_structure_point_pick() { + accepts.push("structure"); + } + if command.needs_entity_pick() { + accepts.push("entity"); + } + if accepts.is_empty() && !command.needs_tangent_pick() { + accepts.push(match command.input_kind() { + InputKind::Point => "point", + InputKind::SingleToken => "token", + InputKind::FreeText => "text", + }); + } + if options.iter().any(|option| !option.keyword.is_empty()) && !accepts.contains(&"token") { + accepts.push("token"); + } + accepts.push("enter"); + + let input_example = if accepts.contains(&"point") { + json!({"op":"input","kind":"point","point":[0,0,0],"space":"wcs"}) + } else if accepts.contains(&"entity") { + json!({"op":"input","kind":"entity","handle":"HANDLE","point":[0,0,0]}) + } else if accepts.contains(&"structure") { + json!({"op":"input","kind":"structure","handle":"HANDLE","point":[0,0,0]}) + } else if accepts.contains(&"selection") { + json!({"op":"input","kind":"selection"}) + } else if accepts.contains(&"text") { + json!({"op":"input","kind":"text","text":"value"}) + } else if accepts.contains(&"token") { + let token = options + .iter() + .find(|option| !option.keyword.is_empty()) + .map_or("VALUE", |option| option.keyword.as_str()); + json!({"op":"input","kind":"token","text":token}) + } else { + json!({"op":"input","kind":"enter"}) + }; + + json!({ + "name":command.name(), + "prompt":command.prompt(), + "accepts":accepts, + "input_example":input_example, + "options":options.iter().map(|option| json!({ + "label":option.label, + "keyword":option.keyword, + "kind":if option.keyword.is_empty(){"enter"}else{"token"} + })).collect::>(), + "entity_pick":command.needs_entity_pick(), + "structure_pick":command.needs_structure_point_pick(), + "selection":command.is_selection_gathering(), + "tangent_pick":command.needs_tangent_pick(), + "free_text":command.is_free_text_step() + }) +} + impl OpenCADStudio { pub(super) fn control_busy(&self) -> bool { self.control.pending.is_some() @@ -173,10 +245,7 @@ impl OpenCADStudio { pub(super) fn control_state(&self) -> Value { let tab = &self.tabs[self.active_tab]; - let command = tab.active_cmd.as_ref().map(|c| json!({ - "name":c.name(),"prompt":c.prompt(),"options":c.options().iter().map(|o|json!({"label":o.label,"keyword":o.keyword})).collect::>(), - "entity_pick":c.needs_entity_pick(),"structure_pick":c.needs_structure_point_pick(),"selection":c.is_selection_gathering(),"tangent_pick":c.needs_tangent_pick(),"free_text":c.is_free_text_step() - })); + let command = tab.active_cmd.as_deref().map(active_command_metadata); json!({"ok":true,"protocol":1,"session_id":session_id(),"version":env!("OCS_APP_VERSION"), "mode":if self.main_window.is_some(){"gui"}else{"headless"},"enabled":self.control.enabled, "document_id":tab.id,"revision":tab.edit_revision,"geometry_revision":tab.scene.geometry_epoch,"camera_revision":tab.scene.camera_generation, @@ -272,8 +341,45 @@ impl OpenCADStudio { names.extend(self.command_line.dynamic_commands.iter().cloned()); names.sort_unstable(); names.dedup(); + if let Some(requested) = req["name"].as_str() { + let Some(name) = names + .iter() + .find(|name| name.eq_ignore_ascii_case(requested)) + else { + return ( + failure( + "unknown_command", + format!("Unknown command {requested}; call commands without name to list commands"), + ), + Task::none(), + ); + }; + return ( + json!({ + "ok":true, + "command":{ + "name":name, + "batch":{ + "op":"run", + "syntax":"Command name followed by prompt answers separated by spaces. Points use x,y or x,y,z; options use their token.", + "examples":command_examples(name) + }, + "interactive":{ + "op":"start", + "request":{"op":"start","cmd":name}, + "next":"Follow state.command.accepts, options and input_example after every step." + } + } + }), + Task::none(), + ); + } return ( - json!({"ok":true,"commands":names,"actions":actions::NAMES}), + json!({ + "ok":true,"commands":names,"actions":actions::NAMES, + "detail_parameters":{"name":"LINE"}, + "guidance":"Request one command by name for batch examples and interactive input guidance." + }), Task::none(), ); } @@ -787,6 +893,9 @@ impl OpenCADStudio { } } mod actions; +pub(super) fn action_names() -> &'static [&'static str] { + actions::NAMES +} #[cfg(test)] mod tests { @@ -816,9 +925,15 @@ mod tests { request(&mut app, json!({"op":"new"}))["status"], "completed" ); + let started = request(&mut app, json!({"op":"start","cmd":"LINE"})); + assert_eq!(started["status"], "waiting_input"); + assert!(started["state"]["command"]["accepts"] + .as_array() + .unwrap() + .contains(&json!("point"))); assert_eq!( - request(&mut app, json!({"op":"start","cmd":"LINE"}))["status"], - "waiting_input" + started["state"]["command"]["input_example"]["kind"], + "point" ); assert_eq!( request( @@ -864,6 +979,24 @@ mod tests { assert_eq!(app.automation_op(r#"{"op":"entities"}"#)["total"], 0); } #[test] + fn command_discovery_explains_batch_and_interactive_use() { + let mut app = OpenCADStudio::new_for_test(); + let detail = app + .control_request(json!({"op":"commands","name":"pline"})) + .0; + assert_eq!(detail["command"]["name"], "PLINE"); + assert_eq!( + detail["command"]["batch"]["examples"][0], + "PLINE 0,0 10,0 10,10 C" + ); + assert_eq!(detail["command"]["interactive"]["op"], "start"); + assert_eq!( + app.control_request(json!({"op":"commands","name":"NOT_A_COMMAND"})) + .0["code"], + "unknown_command" + ); + } + #[test] fn control_retry_is_idempotent_and_stale_state_rejected() { let mut app = OpenCADStudio::new_for_test(); request(&mut app, json!({"op":"new"})); diff --git a/src/app/mod.rs b/src/app/mod.rs index eb89bc04c..563b7e0e9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,6 +1,9 @@ mod alias; mod automation; mod control; +pub(crate) fn automation_action_names() -> &'static [&'static str] { + control::action_names() +} pub(crate) mod config; #[cfg(not(target_arch = "wasm32"))] pub use automation::{export_headless, serve}; diff --git a/src/mcp.rs b/src/mcp.rs index 20bafd840..ac479b52f 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -22,7 +22,7 @@ const MODERN_PROTOCOL_VERSION: &str = "2026-07-28"; const MAX_REQUEST: usize = 1_048_576; const MAX_RESPONSE: u64 = 16 * 1024 * 1024; const CACHE_TTL_MS: u64 = 3_600_000; -const INSTRUCTIONS: &str = "Use ocs_sessions first; it opens OpenCADStudio when needed. Read state and commands before editing. Preserve session_id, document_id, revision, selection and request_id. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OpenCADStudio and its geometry kernel calculate geometry. Verify important results with queries and ocs_capture, and save only to an explicit path."; +const INSTRUCTIONS: &str = "Call ocs_sessions, then ocs_read state before editing. Preserve session_id, document_id, revision, selection and request_id. Use ocs_read commands with parameters.name for command help. For interactive work, call start and follow state.command.accepts, options and input_example. For batch work, run.cmd contains the command name followed by prompt answers separated by spaces; points use x,y or x,y,z. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OpenCADStudio and its geometry kernel calculate geometry. Verify important results with queries and ocs_capture, and save only to an explicit path."; const READ_OPS: &[&str] = &[ "state", "hello", @@ -306,6 +306,87 @@ fn required_string<'a>(arguments: &'a Value, key: &str) -> Result<&'a str, Strin .ok_or_else(|| format!("Missing {key}")) } +fn validate_execute_request(request: &Value, op: &str) -> Result<(), String> { + let missing = |field: &str, example: &str| { + Err(format!( + "Missing {field} for {op}. Example request: {example}" + )) + }; + match op { + "open" if request["path"].as_str().is_none_or(str::is_empty) => { + missing("path", r#"{"op":"open","path":"/path/drawing.dxf"}"#) + } + "activate" if request["document_id"].as_u64().is_none() => { + missing("document_id", r#"{"op":"activate","document_id":2}"#) + } + "run" | "start" if request["cmd"].as_str().is_none_or(str::is_empty) => missing( + "cmd", + r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#, + ), + "input" => match request["kind"].as_str() { + Some("text") => Ok(()), + Some("token") if request["text"].as_str().is_some_and(|value| !value.is_empty()) => { + Ok(()) + } + Some("token") => missing( + "text", + r#"{"op":"input","kind":"token","text":"C"}"#, + ), + Some("point") + if request["point"] + .as_array() + .is_some_and(|values| values.len() == 3 && values.iter().all(Value::is_number)) => + { + Ok(()) + } + Some("point") => missing( + "point", + r#"{"op":"input","kind":"point","point":[0,0,0],"space":"wcs"}"#, + ), + Some("entity" | "structure") + if request["handle"].as_str().is_some_and(|value| !value.is_empty()) + && request["point"] + .as_array() + .is_some_and(|values| values.len() == 3 && values.iter().all(Value::is_number)) => + { + Ok(()) + } + Some("entity" | "structure") => missing( + "handle and point", + r#"{"op":"input","kind":"entity","handle":"2A","point":[0,0,0]}"#, + ), + Some("selection" | "enter") => Ok(()), + Some(kind) => Err(format!( + "Unknown input kind {kind}. Use text, token, point, entity, structure, selection or enter" + )), + None => missing( + "kind", + r#"{"op":"input","kind":"point","point":[0,0,0]}"#, + ), + }, + "property" + if request["field"].as_str().is_none_or(str::is_empty) + || request.get("value").is_none() => + { + missing( + "field or value", + r#"{"op":"property","field":"color","value":1}"#, + ) + } + "action" => match request["name"].as_str() { + Some(name) if crate::app::automation_action_names().contains(&name) => Ok(()), + Some(name) => Err(format!( + "Unknown action {name}. Call ocs_read commands to list actions" + )), + None => missing( + "name", + r#"{"op":"action","name":"zoom_extents"}"#, + ), + }, + _ => Ok(()), + } +} + fn call_tool( name: &str, arguments: &Value, @@ -344,6 +425,7 @@ fn call_tool( if request_id.len() > 128 { return Err("request_id must not exceed 128 bytes".into()); } + validate_execute_request(&request, op)?; let wait = arguments["wait_seconds"].as_f64().unwrap_or(30.0); client(clients, session_id)?.request(request, wait) } @@ -365,24 +447,117 @@ fn call_tool( } } +fn execute_request_schema() -> Value { + let handle = json!({ + "type":"string", + "pattern":"^(0[xX])?[0-9A-Fa-f]+$", + "description":"Entity handle returned by ocs_read entities or state selection." + }); + let point = json!({ + "type":"array","items":{"type":"number"},"minItems":3,"maxItems":3, + "description":"Finite [x,y,z] coordinates." + }); + json!({ + "type":"object", + "properties":{ + "op":{"type":"string","enum":EXECUTE_OPS,"description":"Semantic editor operation."}, + "request_id":{"type":"string","minLength":1,"maxLength":128,"description":"Caller-generated idempotency key. Reuse it only when retrying the identical request."}, + "document_id":{"type":"integer","minimum":0,"description":"Target document from current state."}, + "revision":{"type":"integer","minimum":0,"description":"Expected edit revision from current state."}, + "geometry_revision":{"type":"integer","minimum":0,"description":"Expected geometry revision when geometry state matters."}, + "camera_revision":{"type":"integer","minimum":0,"description":"Expected camera revision when view state matters."}, + "selection":{"type":"array","items":handle.clone(),"description":"Expected selected handles from current state."}, + "cmd":{"type":"string","minLength":1,"description":"Command name followed by its prompt answers separated by spaces. Points use x,y or x,y,z; option answers use their token. Read command details first when unsure.","examples":["LINE 0,0 10,10","CIRCLE 5,5 3","PLINE 0,0 10,0 10,10 C"]}, + "path":{"type":"string","minLength":1,"description":"Absolute drawing path for open or save."}, + "kind":{"type":"string","enum":["text","token","point","entity","structure","selection","enter"],"description":"Input kind listed in state.command.accepts."}, + "text":{"type":"string","description":"Free text or one option/value token."}, + "point":point, + "space":{"type":"string","enum":["wcs","ucs","relative"],"default":"wcs","description":"Coordinate space for point input."}, + "handle":handle.clone(), + "handles":{"type":"array","items":handle,"description":"Entity handles to select."}, + "type":{"type":"string","description":"Entity type filter for select."}, + "layer":{"type":"string","description":"Layer filter for select."}, + "clear":{"type":"boolean","description":"Clear the current selection before applying select filters."}, + "field":{"type":"string","minLength":1,"description":"Property id returned by ocs_read properties."}, + "value":{"description":"New property value; its JSON type must match the property kind.","anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"object"},{"type":"array"},{"type":"null"}]}, + "name":{"type":"string","enum":crate::app::automation_action_names(),"description":"UI action returned by ocs_read commands."} + }, + "required":["op","request_id"], + "additionalProperties":false, + "oneOf":[ + {"properties":{"op":{"const":"new"}}}, + {"properties":{"op":{"const":"open"}},"required":["path"]}, + {"properties":{"op":{"const":"activate"}},"required":["document_id"]}, + {"properties":{"op":{"const":"run"}},"required":["cmd"]}, + {"properties":{"op":{"const":"start"}},"required":["cmd"]}, + {"properties":{"op":{"const":"input"}},"required":["kind"],"oneOf":[ + {"properties":{"kind":{"const":"text"}}}, + {"properties":{"kind":{"const":"token"}},"required":["text"]}, + {"properties":{"kind":{"const":"point"}},"required":["point"]}, + {"properties":{"kind":{"const":"entity"}},"required":["handle","point"]}, + {"properties":{"kind":{"const":"structure"}},"required":["handle","point"]}, + {"properties":{"kind":{"const":"selection"}}}, + {"properties":{"kind":{"const":"enter"}}} + ]}, + {"properties":{"op":{"const":"cancel"}}}, + {"properties":{"op":{"const":"undo"}}}, + {"properties":{"op":{"const":"redo"}}}, + {"properties":{"op":{"const":"select"}}}, + {"properties":{"op":{"const":"property"}},"required":["field","value"]}, + {"properties":{"op":{"const":"action"}},"required":["name"]}, + {"properties":{"op":{"const":"save"}}}, + {"properties":{"op":{"const":"stop"}}} + ] + }) +} + +fn read_output_schema() -> Value { + json!({ + "type":"object", + "properties":{ + "ok":{"type":"boolean"},"status":{"type":"string"},"code":{"type":"string"}, + "error":{"anyOf":[{"type":"string"},{"type":"null"}]},"document_id":{"type":"integer"}, + "revision":{"type":"integer"},"geometry_revision":{"type":"integer"}, + "camera_revision":{"type":"integer"} + }, + "required":["ok"],"additionalProperties":true + }) +} + +fn execute_output_schema() -> Value { + json!({ + "type":"object", + "properties":{ + "ok":{"type":"boolean"}, + "status":{"type":"string","enum":["accepted","running","waiting_input","completed","cancelled","failed"]}, + "request_id":{"type":"string"},"code":{"type":"string"},"error":{"anyOf":[{"type":"string"},{"type":"null"}]}, + "result":{"type":"object"},"changes":{"anyOf":[{"type":"array"},{"type":"null"}]},"state":{"type":"object"} + }, + "required":["ok"],"additionalProperties":true + }) +} + fn tool_definitions() -> Value { json!([ { "name":"ocs_sessions", "description":"List real OpenCADStudio GUI sessions and documents. Launch the installed editor if none is running.", "inputSchema":{"type":"object","properties":{"launch_if_none":{"type":"boolean","default":true,"description":"Launch OpenCADStudio when no live session exists."}},"additionalProperties":false}, + "outputSchema":{"type":"object","properties":{"result":{"type":"array","items":{"type":"object","properties":{"ok":{"const":true},"session_id":{"type":"string"},"document_id":{"type":"integer"},"revision":{"type":"integer"},"selection":{"type":"array","items":{"type":"string"}},"documents":{"type":"array"}},"required":["ok","session_id","document_id","revision","selection","documents"],"additionalProperties":true}}},"required":["result"],"additionalProperties":false}, "annotations":{"title":"List OCS sessions","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} }, { "name":"ocs_read", - "description":"Read state, commands/actions, query, entities, layers, header, properties, measurements, history, events or operation status from a live OCS session.", - "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters such as document_id, handles, after, or request_id."}},"required":["session_id"],"additionalProperties":false}, + "description":"Read state, commands/actions, query, entities, layers, header, properties, measurements, history, events or operation status from a live OCS session. For command help use op commands with parameters.name.", + "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name for detailed commands help."},"document_id":{"type":"integer","minimum":0},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"}},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1}},"additionalProperties":true}},"required":["session_id"],"additionalProperties":false}, + "outputSchema":read_output_schema(), "annotations":{"title":"Read OCS state","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} }, { "name":"ocs_execute", - "description":"Execute one semantic OCS action. Use state document_id and revision. request_id is the idempotency key. Accepted, running and waiting_input are not completion.", - "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"request":{"type":"object","properties":{"op":{"type":"string","enum":EXECUTE_OPS,"description":"Semantic editor operation."},"request_id":{"type":"string","minLength":1,"maxLength":128,"description":"Caller-generated idempotency key. Reuse it only when retrying the identical request."},"document_id":{"type":"integer","minimum":0,"description":"Target document from current state."},"revision":{"type":"integer","minimum":0,"description":"Expected edit revision from current state."},"geometry_revision":{"type":"integer","minimum":0,"description":"Expected geometry revision when geometry state matters."},"camera_revision":{"type":"integer","minimum":0,"description":"Expected camera revision when view state matters."},"selection":{"type":"array","items":{"type":"string"},"description":"Expected selected handles from current state."}},"required":["op","request_id"]},"wait_seconds":{"type":"number","minimum":0,"maximum":60,"default":30}},"required":["session_id","request"],"additionalProperties":false}, + "description":"Execute one semantic OCS action. Use current state fields and a unique request_id. Use run for a complete command line or start plus input for guided steps. accepted, running and waiting_input are not completion.", + "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"request":execute_request_schema(),"wait_seconds":{"type":"number","minimum":0,"maximum":60,"default":30,"description":"Time to wait for asynchronous completion before returning."}},"required":["session_id","request"],"additionalProperties":false}, + "outputSchema":execute_output_schema(), "annotations":{"title":"Execute OCS action","readOnlyHint":false,"destructiveHint":true,"idempotentHint":true,"openWorldHint":false} }, { @@ -414,7 +589,7 @@ fn error_result(message: impl ToString) -> Value { let message = message.to_string(); json!({ "content":[{"type":"text","text":message.clone()}], - "structuredContent":{"ok":false,"error":message}, + "structuredContent":{"ok":false,"status":"failed","code":"invalid_arguments","error":message,"retryable":false}, "isError":true }) } @@ -589,6 +764,20 @@ mod tests { tools[2]["inputSchema"]["properties"]["request"]["required"], json!(["op", "request_id"]) ); + assert_eq!( + tools[2]["inputSchema"]["properties"]["request"]["oneOf"] + .as_array() + .unwrap() + .len(), + EXECUTE_OPS.len() + ); + assert_eq!( + tools[2]["inputSchema"]["properties"]["request"]["properties"]["cmd"]["examples"][0], + "LINE 0,0 10,10" + ); + assert!(tools[0].get("outputSchema").is_some()); + assert!(tools[1].get("outputSchema").is_some()); + assert!(tools[2].get("outputSchema").is_some()); } #[test] @@ -684,6 +873,26 @@ mod tests { .contains("request_id")); } + #[test] + fn execute_errors_explain_missing_operation_fields() { + let mut clients = HashMap::new(); + let called = handle_message( + json!({"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"ocs_execute","arguments":{"session_id":"missing","request":{"op":"run","request_id":"run-1"}}}}), + &mut clients, + ) + .unwrap(); + assert_eq!(called["result"]["isError"], true); + assert_eq!( + called["result"]["structuredContent"]["code"], + "invalid_arguments" + ); + let error = called["result"]["structuredContent"]["error"] + .as_str() + .unwrap(); + assert!(error.contains("Missing cmd"), "{error}"); + assert!(error.contains("LINE 0,0 10,10"), "{error}"); + } + #[test] fn gui_failures_are_mcp_errors() { let result = tool_result(json!({