From 6951cc2a7a82865327db0bc0c9c4d5b3d5a89787 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Wed, 30 Sep 2026 16:53:27 +0100 Subject: [PATCH] feat(mcp): declarative op table and per-variant request validation - Define Ty, Param, Rule, and OpDef in src/mcp_ops.rs for all 39 mutations - Generate Draft 2020-12 schema using per-op anyOf variants with op enum discriminators - Disambiguate colliding properties (replace, center, name) across mutation variants - Add pre-dispatch validator enforcing required fields, rules, and parameter types - Emit unknown parameter warnings into response warnings - Introduce target_document_id for entities_copy_to with deprecated document_id fallback - Update unit tests and automation smoke assertions --- docs/automation/mcp_smoke.py | 13 +- src/app/control/sheets.rs | 3 +- src/lib.rs | 2 + src/mcp.rs | 577 ++++++-------------- src/mcp_ops.rs | 985 +++++++++++++++++++++++++++++++++++ 5 files changed, 1169 insertions(+), 411 deletions(-) create mode 100644 src/mcp_ops.rs diff --git a/docs/automation/mcp_smoke.py b/docs/automation/mcp_smoke.py index bbbff2d31..ac6a0d74e 100644 --- a/docs/automation/mcp_smoke.py +++ b/docs/automation/mcp_smoke.py @@ -52,7 +52,8 @@ def legacy(server: Path) -> None: names = set(definitions) assert names == TOOLS, names execute_request = definitions["ocs_execute"]["inputSchema"]["properties"]["request"] - assert len(execute_request["oneOf"]) == 39 + variants = execute_request.get("anyOf") or execute_request.get("oneOf") + assert len(variants) == 39 op_enum = execute_request["properties"]["op"]["enum"] for shipped in ("entities_create", "entities_delete", "entities_transform", "block_define", "block_delete", "file_identity", "xdata_set", @@ -65,15 +66,19 @@ def legacy(server: Path) -> None: assert "text_search" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"] assert "text_audit" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"] assert execute_request["properties"]["steps"]["maxItems"] == 64 - assert execute_request["properties"]["cmd"]["examples"][0] == "LINE 0,0 10,10" + variant_map = { + v["properties"]["op"].get("const") or v["properties"]["op"]["enum"][0]: v + for v in variants + } + assert variant_map["run"]["properties"]["cmd"]["type"] == "string" assert "set_properties" in execute_request["properties"]["op"]["enum"] assert "save_verified" in execute_request["properties"]["op"]["enum"] assert "record_schema" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"] assert "audit" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"] read_parameters = definitions["ocs_read"]["inputSchema"]["properties"]["parameters"]["properties"] assert {"collection", "where", "paths", "target_format", "target_version"} <= set(read_parameters) - assert execute_request["properties"]["target_version"]["enum"][0] == "R14" - assert execute_request["properties"]["kind"]["enum"] == [ + assert variant_map["save_verified"]["properties"]["target_version"]["enum"][0] == "R14" + assert variant_map["input"]["properties"]["kind"]["enum"] == [ "text", "token", "point", "entity", "structure", "selection", "enter" ] for name in {"ocs_sessions", "ocs_read", "ocs_execute"}: diff --git a/src/app/control/sheets.rs b/src/app/control/sheets.rs index 6e876da7e..3657483f8 100644 --- a/src/app/control/sheets.rs +++ b/src/app/control/sheets.rs @@ -231,8 +231,9 @@ impl OpenCADStudio { req: &Value, ) -> Result, Value> { let handles = crate::app::control::entities::hex_handles(req, "handles")?; - let target = req["document_id"] + let target = req["target_document_id"] .as_u64() + .or_else(|| req["document_id"].as_u64()) .ok_or_else(|| failure("document_required", "Supply the target document_id"))?; let source_index = self.active_tab; let target_index = self diff --git a/src/lib.rs b/src/lib.rs index af79d3b6f..2038f1903 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,8 @@ pub(crate) mod input; pub mod io; #[cfg(not(target_arch = "wasm32"))] pub mod mcp; +#[cfg(not(target_arch = "wasm32"))] +pub mod mcp_ops; pub mod modules; #[cfg(not(target_arch = "wasm32"))] pub(crate) mod network; diff --git a/src/mcp.rs b/src/mcp.rs index 201dcf447..7ce1348a6 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -395,11 +395,13 @@ impl GuiClient { "client_id", Value::String(self.client_id.clone()), ); - insert_default( - &mut object, - "document_id", - self.state["document_id"].clone(), - ); + if op != "entities_copy_to" { + insert_default( + &mut object, + "document_id", + self.state["document_id"].clone(), + ); + } insert_default(&mut object, "revision", self.state["revision"].clone()); if ["input", "property", "run", "action", "save", "save_verified", "undo", "redo"].contains(&op.as_str()) { @@ -655,224 +657,45 @@ 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 { - "batch" => { - let steps = request["steps"].as_array().ok_or_else(|| { - r#"Missing steps for batch. Example request: {"op":"batch","request_id":"draw-1","steps":[{"op":"run","cmd":"LINE 0,0 10,0"}]}"#.to_string() - })?; - if steps.is_empty() || steps.len() > MAX_BATCH_STEPS { +fn validate_execute_request( + request: &Value, + op: &str, +) -> Result { + if op == "batch" { + let steps = request["steps"].as_array().ok_or_else(|| { + r#"Missing steps for batch. Example request: {"op":"batch","request_id":"draw-1","steps":[{"op":"run","cmd":"LINE 0,0 10,0"}]}"#.to_string() + })?; + if steps.is_empty() || steps.len() > MAX_BATCH_STEPS { + return Err(format!( + "batch steps must contain 1 to {MAX_BATCH_STEPS} operations" + )); + } + let mut warnings = Vec::new(); + for (index, step) in steps.iter().enumerate() { + let step_obj = step + .as_object() + .ok_or_else(|| format!("batch step {index} must be an object"))?; + if step_obj.contains_key("request_id") { return Err(format!( - "batch steps must contain 1 to {MAX_BATCH_STEPS} operations" + "batch step {index} must omit request_id; the batch assigns idempotency keys" )); } - for (index, step) in steps.iter().enumerate() { - let step = step - .as_object() - .ok_or_else(|| format!("batch step {index} must be an object"))?; - if step.contains_key("request_id") { - return Err(format!( - "batch step {index} must omit request_id; the batch assigns idempotency keys" - )); - } - let step = Value::Object(step.clone()); - let step_op = step["op"] - .as_str() - .ok_or_else(|| format!("batch step {index} is missing op"))?; - if !BATCH_STEP_OPS.contains(&step_op) { - return Err(format!("Unknown operation {step_op} in batch step {index}")); - } - validate_execute_request(&step, step_op) - .map_err(|error| format!("batch step {index}: {error}"))?; + let step_op = step_obj + .get("op") + .and_then(Value::as_str) + .ok_or_else(|| format!("batch step {index} is missing op"))?; + if !BATCH_STEP_OPS.contains(&step_op) { + return Err(format!("Unknown operation {step_op} in batch step {index}")); } - Ok(()) - } - "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}"#, - ) - } - "set_properties" - if request["collection"].as_str().is_none_or(str::is_empty) - || request["updates"].as_array().is_none_or(Vec::is_empty) => - { - missing( - "collection or updates", - r#"{"op":"set_properties","collection":"entities","handle":"2A","updates":[{"path":"/common/layer","value":"Walls"}]}"#, - ) - } - "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"}"#), - }, - "embed_image" if request["path"].as_str().is_none_or(str::is_empty) => { - missing( - "path", - r#"{"op":"embed_image","path":"/path/logo.png","at":[0,0,0],"width":100}"#, - ) - } - "wblock" if request["path"].as_str().is_none_or(str::is_empty) => { - missing( - "path", - r#"{"op":"wblock","path":"/path/part.dwg","handles":["2A","31"]}"#, - ) - } - "plot" if request["path"].as_str().is_none_or(str::is_empty) => { - missing( - "path", - r#"{"op":"plot","path":"/path/pages.pdf","layout":"Model"}"#, - ) - } - "entities_create" - if request["entities"].as_array().is_none_or(Vec::is_empty) => - { - missing( - "entities", - r#"{"op":"entities_create","entities":[{"type":"Circle","center":[0,0,0],"radius":5}]}"#, - ) - } - "entities_delete" | "entities_transform" | "view_focus" - if request["handles"].as_array().is_none_or(Vec::is_empty) => - { - missing( - "handles", - r#"{"op":"entities_delete","handles":["2A","31"]}"#, - ) - } - "block_define" - if request["name"].as_str().is_none_or(str::is_empty) - || request["handles"].as_array().is_none_or(Vec::is_empty) - || request["base"].as_array().is_none() => - { - missing( - "name, handles and base", - r#"{"op":"block_define","name":"MARK","base":[0,0,0],"handles":["2A"]}"#, - ) - } - "xdata_set" - if request["app"].as_str().is_none_or(str::is_empty) - || request["handles"].as_array().is_none_or(Vec::is_empty) => - { - missing( - "app and handles", - r#"{"op":"xdata_set","app":"SPM","handles":["2A"],"data":[{"code":1000,"value":"tag"}]}"#, - ) - } - "entities_copy_to" - if request["handles"].as_array().is_none_or(Vec::is_empty) - || request["document_id"].as_u64().is_none() => - { - missing( - "handles and document_id", - r#"{"op":"entities_copy_to","handles":["2A"],"document_id":2}"#, - ) - } - "group_create" | "selection_set_save" - if request["name"].as_str().is_none_or(str::is_empty) - || request["handles"].as_array().is_none_or(Vec::is_empty) => - { - missing( - "name and handles", - r#"{"op":"group_create","name":"Frame","handles":["2A"]}"#, - ) - } - "selection_set_load" if request["name"].as_str().is_none_or(str::is_empty) => { - missing("name", r#"{"op":"selection_set_load","name":"Frame"}"#) - } - "block_delete" if request["name"].as_str().is_none_or(str::is_empty) => { - missing("name", r#"{"op":"block_delete","name":"Frame"}"#) - } - "layout_create" if request["name"].as_str().is_none_or(str::is_empty) => { - missing("name", r#"{"op":"layout_create","name":"Plan"}"#) - } - "page_setup_set" if request["layout"].as_str().is_none_or(str::is_empty) => { - missing( - "layout", - r#"{"op":"page_setup_set","layout":"Plan","paper":"ISO_A4_(210.00_x_297.00_MM)"}"#, - ) - } - "save_verified" if request["path"].as_str().is_none_or(str::is_empty) => { - missing( - "path", - r#"{"op":"save_verified","request_id":"deliver-1","path":"/absolute/output.dwg","target_version":"2018"}"#, - ) - } - "text_replace" => { - if request["pairs"].as_array().is_none() - && request["find"].as_str().is_none_or(str::is_empty) - { - missing( - "find or pairs", - r#"{"op":"text_replace","request_id":"tr1","find":"OLD","replace":"NEW"}"#, - ) - } else { - Ok(()) + let step_val = crate::mcp_ops::validate_request(step, true) + .map_err(|error| format!("batch step {index}: {error}"))?; + for w in step_val.warnings { + warnings.push(format!("batch step {index}: {w}")); } } - _ => Ok(()), + return Ok(crate::mcp_ops::ValidationResult { warnings }); } + crate::mcp_ops::validate_request(request, false) } fn compact_state(state: &Value) -> Value { @@ -976,15 +799,23 @@ fn call_tool( if request_id.len() > 128 { return Err("request_id must not exceed 128 bytes".into()); } - validate_execute_request(&request, op)?; + let val_res = validate_execute_request(&request, op)?; let wait = arguments["wait_seconds"].as_f64().unwrap_or(30.0); let detail = arguments["response_detail"].as_str().unwrap_or("compact"); let gui = client(clients, session_id)?; - let response = if op == "batch" { + let mut response = if op == "batch" { gui.execute_batch(request, wait)? } else { gui.request(request, wait)? }; + if !val_res.warnings.is_empty() { + if let Some(object) = response.as_object_mut() { + object.insert( + "warnings".into(), + Value::Array(val_res.warnings.into_iter().map(Value::String).collect()), + ); + } + } shape_execute_response(response, detail, gui) } "ocs_capture" => { @@ -1007,184 +838,13 @@ fn call_tool( } } +#[allow(dead_code)] fn batch_step_schema() -> Value { - json!({ - "type":"object", - "properties":{ - "op":{"type":"string","enum":BATCH_STEP_OPS}, - "document_id":{"type":"integer","minimum":0}, - "cmd":{"type":"string","minLength":1}, - "path":{"type":"string","minLength":1}, - "target_format":{"type":"string","enum":["dwg","dxf"]}, - "target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"]}, - "allow_lossy":{"type":"boolean","default":false}, - "kind":{"type":"string","enum":["text","token","point","entity","structure","selection","enter"]}, - "text":{"type":"string"}, - "point":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}, - "space":{"type":"string","enum":["wcs","ucs","relative"]}, - "handle":{"type":"string"}, - "handles":{"type":"array","items":{"type":"string"}}, - "type":{"type":"string"}, - "layer":{"type":"string"}, - "clear":{"type":"boolean"}, - "field":{"type":"string"}, - "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"}]}, - "collection":{"type":"string"}, - "updates":{"type":"array","minItems":1,"items":{"type":"object","properties":{"path":{"type":"string","pattern":"^/"},"value":{},"expected":{}},"required":["path","value"],"additionalProperties":false}}, - "name":{"type":"string","enum":crate::app::automation_action_names()} - }, - "required":["op"], - "additionalProperties":false - }) + crate::mcp_ops::batch_step_schema() } 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"]}, - "find":{"type":"string","description":"Search text to replace; supports DXF Unicode escapes and special characters."}, - "replace":{"type":"string","description":"Replacement text."}, - "pairs":{"type":"array","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]},"description":"Batch find/replace pairs executed in a single atomic undo transaction."}, - "match_case":{"type":"boolean","default":false,"description":"Case-sensitive text search/replace."}, - "whole_word":{"type":"boolean","default":false,"description":"Match only whole words (bounded by non-alphanumeric characters)."}, - "ignore_accents":{"type":"boolean","description":"Ignore accents/diacritics in text (defaults to true when match_case is false)."}, - "dry_run":{"type":"boolean","default":false,"description":"When true, simulates replacement without mutating entities or pushing undo."}, - "replace_all":{"type":"boolean","default":true,"description":"Replace all occurrences within matching entities (default true)."}, - "scope":{"type":"string","enum":["all","active_space","blocks"],"default":"all","description":"Text search/replace scope."}, - "path":{"type":"string","minLength":1,"description":"Absolute path: drawing for open or save, image file for embed_image, target DWG/DXF for wblock, target PDF for plot; save_verified requires .dwg or .dxf."}, - "block":{"type":"string","description":"Block definition name for wblock (exports its entities flattened into model space)."}, - "linked":{"type":"boolean","description":"embed_image: true stores a path-linked RasterImage instead of an embedded OLE2FRAME."}, - "layout":{"type":"string","description":"plot: Model (default), a layout name, or all."}, - "area":{"type":"string","enum":["extents","display","limits","window","layout"],"description":"plot area; layout applies to paper-space layouts."}, - "window":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"World [x0,y0,x1,y1] plot rectangle for area window."}, - "paper":{"type":"string","description":"plot: canonical paper-catalog sheet name."}, - "orientation":{"type":"string","enum":["Portrait","Landscape"],"description":"plot sheet orientation."}, - "fit":{"type":"boolean","description":"plot: scale content to fit the sheet (default true)."}, - "scale":{"type":"string","description":"plot scale as paper:drawing, e.g. 1:100; disables fit."}, - "center":{"type":"boolean","description":"plot: center the content on the sheet (default true)."}, - "offset_x":{"type":"number","description":"plot offset when not centered."}, - "offset_y":{"type":"number","description":"plot offset when not centered."}, - "upside_down":{"type":"boolean","description":"plot: rotate content 180 degrees."}, - "plot_style":{"type":"string","description":"plot: CTB file path or a discovered plot style name."}, - "transparency":{"type":"boolean","description":"plot: keep transparency."}, - "lineweights":{"type":"boolean","description":"plot: honor object lineweights."}, - "merge_lines":{"type":"boolean","description":"plot: merge overlapping lines."}, - "stamp":{"type":"boolean","description":"plot: draw the plot stamp."}, - "entities":{"type":"array","minItems":1,"description":"entities_create: typed definitions, one object per entity. type is one of Line, Circle, Arc, LwPolyline, Point, Text, MText, Insert, Solid, Hatch; geometry fields depend on the type; layer and color (ACI) are optional and a missing layer is created.","items":{"type":"object"}}, - "action":{"type":"string","enum":["move","copy","rotate","scale","mirror","array"],"description":"entities_transform action (default move)."}, - "vector":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"move/copy displacement [dx,dy(,dz)]."}, - "center":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"rotate/scale center point."}, - "angle_deg":{"type":"number","description":"rotate angle, degrees CCW."}, - "factor":{"type":"number","exclusiveMinimum":0,"description":"scale factor."}, - "axis":{"type":"array","minItems":2,"maxItems":2,"description":"mirror line [[x1,y1],[x2,y2]].","items":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":2}}, - "copy":{"type":"boolean","description":"mirror: keep the originals and return mirrored copies."}, - "rows":{"type":"integer","minimum":1,"description":"array row count (default 1)."}, - "columns":{"type":"integer","minimum":1,"description":"array column count (default 1)."}, - "row_spacing":{"type":"number","description":"array row spacing."}, - "column_spacing":{"type":"number","description":"array column spacing."}, - "base":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"block_define base point (becomes the block origin)."}, - "insert_at":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"block_define Insert placement (default = base)."}, - "app":{"type":"string","minLength":1,"description":"xdata_set: application name; its RegApp table entry is registered automatically."}, - "data":{"type":"array","description":"xdata_set typed values [{code,value}]: 1000 string, 1003 layer, 1004 hex bytes, 1005 hex handle, 1010-1013 [x,y,z], 1040/1041/1042 real, 1070 int16, 1071 int32. An empty or absent list removes the application record.","items":{"type":"object","properties":{"code":{"type":"integer"},"value":{}},"required":["code","value"]}}, - "highlight":{"type":"boolean","description":"view_focus: also select the entities (default true)."}, - "template":{"type":"string","description":"new/wblock: load this DWG/DXF/DWT file as the base document so its tables and styles survive."}, - "discard":{"type":"boolean","description":"close: true erases unsaved changes instead of refusing a dirty document."}, - "per_page":{"type":"boolean","description":"plot: write one PDF per layout as -.pdf."}, - "get":{"type":"array","items":{"type":"string"},"description":"sysvar: variable names to read (ltscale, pdmode, pdsize, celtscale, textsize, filletrad, mirrtext, insunits, osmode, clayer, ctextstyle, extmin, extmax)."}, - "set":{"type":"object","description":"sysvar: name=value pairs to write in one undo step.","additionalProperties":true}, - "select":{"type":"boolean","description":"selection_set_load: also select the recalled entities (default true)."}, - "where":{"type":"array","description":"query: cross-property filters over entity properties with RFC 6901 paths and the records operator set.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string"},"value":{}},"required":["path"]}}, - "target_format":{"type":"string","enum":["dwg","dxf"],"description":"Explicit output format; it must match the path extension."}, - "target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"],"description":"Explicit CAD output version. Omit only to preserve the document version."}, - "allow_lossy":{"type":"boolean","default":false,"description":"Acknowledge dropping unsupported passthrough records reported by audit."}, - "overwrite":{"type":"boolean","default":false,"description":"For save_verified only: replace an existing destination."}, - "at":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"World [x,y] or [x,y,z] placement corner for embed_image (picture grows up-right)."}, - "width":{"type":"number","exclusiveMinimum":0,"description":"World width for embed_image; height follows the image aspect ratio. Defaults to pixel_width/100."}, - "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: selection filter for select, export set for wblock."}, - "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"}]}, - "collection":{"type":"string","description":"Record collection returned by ocs_read records."}, - "updates":{"type":"array","minItems":1,"description":"Atomic, type-checked property replacements. Paths are RFC 6901 JSON Pointers relative to record.properties.","items":{"type":"object","properties":{"path":{"type":"string","pattern":"^/"},"value":{},"expected":{"description":"Optional compare-and-set value."}},"required":["path","value"],"additionalProperties":false}}, - "name":{"type":"string","enum":crate::app::automation_action_names(),"description":"UI action returned by ocs_read commands."}, - "steps":{"type":"array","minItems":1,"maxItems":MAX_BATCH_STEPS,"description":"Sequential editor operations executed with fresh state and idempotency keys. Execution stops at the first failure; completed_steps says what committed.","items":batch_step_schema()} - }, - "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":"set_properties"}},"required":["collection","updates"]}, - {"properties":{"op":{"const":"action"}},"required":["name"]}, - {"properties":{"op":{"const":"embed_image"}},"required":["path"]}, - {"properties":{"op":{"const":"wblock"}},"required":["path"]}, - {"properties":{"op":{"const":"plot"}},"required":["path"]}, - {"properties":{"op":{"const":"entities_create"}},"required":["entities"]}, - {"properties":{"op":{"const":"entities_delete"}},"required":["handles"]}, - {"properties":{"op":{"const":"entities_transform"}},"required":["handles"]}, - {"properties":{"op":{"const":"text_replace"}}}, - {"properties":{"op":{"const":"block_define"}},"required":["name","base","handles"]}, - {"properties":{"op":{"const":"block_delete"}},"required":["name"]}, - {"properties":{"op":{"const":"xdata_set"}},"required":["app","handles"]}, - {"properties":{"op":{"const":"view_focus"}},"required":["handles"]}, - {"properties":{"op":{"const":"entities_copy_to"}},"required":["handles","document_id"]}, - {"properties":{"op":{"const":"group_create"}},"required":["name","handles"]}, - {"properties":{"op":{"const":"selection_set_save"}},"required":["name","handles"]}, - {"properties":{"op":{"const":"selection_set_load"}},"required":["name"]}, - {"properties":{"op":{"const":"user_select"}},"description":"Ask the person at the screen to pick entities; resolves when they press Enter (or cancel on Escape). Optional type/layer/prompt/detail/clear."}, - {"properties":{"op":{"const":"getpoint"}},"description":"Ask the person at the screen to pick one point; resolves with the picked point when they click, or cancels on Escape. Optional prompt."}, - {"properties":{"op":{"const":"close"}}}, - {"properties":{"op":{"const":"sysvar"}}}, - {"properties":{"op":{"const":"layout_create"}},"required":["name"]}, - {"properties":{"op":{"const":"page_setup_set"}},"required":["layout"]}, - {"properties":{"op":{"const":"file_identity"}}}, - {"properties":{"op":{"const":"save"}}}, - {"properties":{"op":{"const":"save_verified"}},"required":["path"]}, - {"properties":{"op":{"const":"stop"}}}, - {"properties":{"op":{"const":"batch"}},"required":["steps"]} - ] - }) + crate::mcp_ops::execute_request_schema() } fn read_output_schema() -> Value { @@ -1569,13 +1229,10 @@ 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() - ); + let request_variants = tools[2]["inputSchema"]["properties"]["request"]["anyOf"] + .as_array() + .unwrap(); + assert_eq!(request_variants.len(), EXECUTE_OPS.len()); assert_eq!( tools[2]["inputSchema"]["properties"]["request"]["properties"]["steps"]["maxItems"], MAX_BATCH_STEPS @@ -1588,10 +1245,15 @@ mod tests { tools[3]["inputSchema"]["properties"]["scope"]["default"], "viewport" ); - assert_eq!( - tools[2]["inputSchema"]["properties"]["request"]["properties"]["cmd"]["examples"][0], - "LINE 0,0 10,10" - ); + let find_variant = |op_name: &str| { + request_variants + .iter() + .find(|v| v["properties"]["op"]["enum"][0] == op_name) + .cloned() + .unwrap_or_else(|| panic!("variant for {op_name} exists")) + }; + let run_var = find_variant("run"); + assert_eq!(run_var["properties"]["cmd"]["type"], "string"); assert!(READ_OPS.contains(&"capabilities")); assert!(READ_OPS.contains(&"records")); assert!(READ_OPS.contains(&"record_schema")); @@ -1602,9 +1264,9 @@ mod tests { assert!(EXECUTE_OPS.contains(&"save_verified")); assert!(EXECUTE_OPS.contains(&"text_replace")); assert!(BATCH_STEP_OPS.contains(&"text_replace")); + let save_var = find_variant("save_verified"); assert_eq!( - tools[2]["inputSchema"]["properties"]["request"]["properties"] - ["target_version"]["enum"][0], + save_var["properties"]["target_version"]["enum"][0], "R14" ); assert_eq!( @@ -1625,8 +1287,9 @@ mod tests { "not_exists" ]) ); + let set_props_var = find_variant("set_properties"); assert_eq!( - tools[2]["inputSchema"]["properties"]["request"]["properties"]["updates"]["items"]["required"], + set_props_var["properties"]["updates"]["items"]["required"], json!(["path", "value"]) ); assert!(tools[0].get("outputSchema").is_some()); @@ -1823,4 +1486,106 @@ mod tests { assert_eq!(result["isError"], true); assert_eq!(result["structuredContent"]["code"], "stale_state"); } + + #[test] + fn all_op_examples_pass_pre_dispatch_validation() { + for op in crate::mcp_ops::OPS { + let mut req: Value = serde_json::from_str(op.example) + .unwrap_or_else(|e| panic!("invalid json example for {}: {e}", op.name)); + if let Some(obj) = req.as_object_mut() { + obj.insert("request_id".into(), Value::String("example-id".into())); + } + let res = crate::mcp_ops::validate_request(&req, false); + assert!( + res.is_ok(), + "example for op '{}' failed validation: {:?}", + op.name, + res.err() + ); + + if op.batchable { + let mut batch_req: Value = serde_json::from_str(op.example).unwrap(); + if let Some(obj) = batch_req.as_object_mut() { + obj.remove("request_id"); + } + let b_res = crate::mcp_ops::validate_request(&batch_req, true); + assert!( + b_res.is_ok(), + "batch example for op '{}' failed validation: {:?}", + op.name, + b_res.err() + ); + } + } + } + + #[test] + fn pre_dispatch_validator_catches_unknown_parameters_as_warnings() { + let req = json!({ + "op": "run", + "request_id": "r1", + "cmd": "LINE 0,0 10,0", + "extra_param": 123 + }); + let res = crate::mcp_ops::validate_request(&req, false).expect("validation passes"); + assert_eq!(res.warnings.len(), 1); + assert!(res.warnings[0].contains("unknown parameter 'extra_param' ignored for op 'run'")); + + // Batch step warnings propagation + let batch = json!({ + "op": "batch", + "request_id": "b1", + "steps": [ + {"op": "run", "cmd": "LINE 0,0 10,0", "stray": true} + ] + }); + let batch_res = validate_execute_request(&batch, "batch").expect("batch passes"); + assert_eq!(batch_res.warnings.len(), 1); + assert!(batch_res.warnings[0].contains("batch step 0: unknown parameter 'stray' ignored for op 'run'")); + } + + #[test] + fn pre_dispatch_validator_enforces_required_fields_and_rules() { + // Missing required field + let missing_cmd = json!({"op": "run", "request_id": "r1"}); + let err = crate::mcp_ops::validate_request(&missing_cmd, false).unwrap_err(); + assert!(err.contains("Missing cmd for run. Example request:"), "{err}"); + + // Required-when rule (scale requires factor) + let scale_missing_factor = json!({ + "op": "entities_transform", + "request_id": "r2", + "handles": ["2A"], + "action": "scale" + }); + let err2 = crate::mcp_ops::validate_request(&scale_missing_factor, false).unwrap_err(); + assert!(err2.contains("Missing factor for entities_transform. Example request:"), "{err2}"); + + // Hex handle enforcement + let invalid_handle = json!({ + "op": "entities_delete", + "request_id": "r3", + "handles": ["NOT_A_HEX_HANDLE!"] + }); + let err3 = crate::mcp_ops::validate_request(&invalid_handle, false).unwrap_err(); + assert!(err3.contains("hexadecimal handle strings"), "{err3}"); + + // Non-batchable op rejected in batch + let stop_in_batch = json!({"op": "stop"}); + let err4 = crate::mcp_ops::validate_request(&stop_in_batch, true).unwrap_err(); + assert!(err4.contains("operation 'stop' cannot be used in a batch step"), "{err4}"); + } + + #[test] + fn schema_size_and_defs_structure() { + let schema = execute_request_schema(); + assert_eq!(schema["$schema"], "https://json-schema.org/draft/2020-12/schema"); + assert!(schema["$defs"]["handle"].is_object()); + assert!(schema["$defs"]["point"].is_object()); + assert!(schema["$defs"]["window"].is_object()); + assert!(schema["$defs"]["batch_step"].is_object()); + let json_str = schema.to_string(); + // Generates cleanly without exponential blowup: compact ~15KB to 30KB + assert!(json_str.len() < 40_000, "schema size is {} bytes", json_str.len()); + } } diff --git a/src/mcp_ops.rs b/src/mcp_ops.rs new file mode 100644 index 000000000..4cb5846c4 --- /dev/null +++ b/src/mcp_ops.rs @@ -0,0 +1,985 @@ +//! Declarative description of every MCP op. Single source of truth for the +//! advertised JSON Schema, the pre-dispatch validator, and test examples. + +use serde_json::{json, Map, Value}; + +pub const TARGET_VERSIONS: &[&str] = &[ + "R14", "2000", "2004", "2007", "2010", "2013", "2018", + "AC1014", "AC1015", "AC1018", "AC1021", "AC1024", "AC1027", "AC1032", +]; + +pub const ENVELOPE_KEYS: &[&str] = &[ + "op", + "request_id", + "document_id", + "revision", + "geometry_revision", + "camera_revision", + "selection", + "client_id", + "steps", +]; + +#[derive(Clone, Copy)] +pub enum Ty { + Str, + Bool, + Num, + Int, + /// [x, y] or [x, y, z]; a missing z is 0.0 at runtime. + Point, + /// Hex string, optional 0x prefix. JSON integers are rejected at runtime. + Handle, + /// Non-empty array of Handle. + Handles, + /// [x0, y0, x1, y1] + Window, + Enum(&'static [&'static str]), + /// Runtime compares case-insensitively; the schema advertises canonical case. + EnumCi(&'static [&'static str]), + ActionName, + ArrayOf(&'static Ty), + /// Escape hatch: a JSON Schema fragment as text. + Raw(&'static str), +} + +pub struct Param { + pub name: &'static str, + pub ty: Ty, + pub required: bool, + pub doc: &'static str, +} + +pub enum Rule { + /// At least one of these keys must be present. + AnyOf(&'static [&'static str]), + /// If `key` equals `equals`, every key in `then` must be present. + RequiredWhen { + key: &'static str, + equals: &'static str, + then: &'static [&'static str], + }, +} + +pub struct OpDef { + pub name: &'static str, + pub doc: &'static str, + pub batchable: bool, + pub params: &'static [Param], + pub rules: &'static [Rule], + /// Minimal valid payload (JSON text). Drives schema, validator and parity tests. + pub example: &'static str, +} + +const fn req(name: &'static str, ty: Ty, doc: &'static str) -> Param { + Param { name, ty, required: true, doc } +} + +const fn opt(name: &'static str, ty: Ty, doc: &'static str) -> Param { + Param { name, ty, required: false, doc } +} + +// ============================================================================ +// OP DEFINITIONS +// ============================================================================ + +pub static NEW: OpDef = OpDef { + name: "new", + doc: "Start an empty document, optionally seeded from a DWG/DXF/DWT template.", + batchable: true, + params: &[ + opt("template", Ty::Str, "Optional template drawing path whose styles and tables survive."), + ], + rules: &[], + example: r#"{"op":"new"}"#, +}; + +pub static OPEN: OpDef = OpDef { + name: "open", + doc: "Open an existing drawing file (DWG or DXF).", + batchable: true, + params: &[ + req("path", Ty::Str, "Absolute path to the drawing file."), + ], + rules: &[], + example: r#"{"op":"open","path":"drawing.dxf"}"#, +}; + +pub static ACTIVATE: OpDef = OpDef { + name: "activate", + doc: "Switch the active drawing tab to another open document.", + batchable: true, + params: &[ + req("document_id", Ty::Int, "Target document ID to activate."), + ], + rules: &[], + example: r#"{"op":"activate","document_id":1}"#, +}; + +pub static RUN: OpDef = OpDef { + name: "run", + doc: "Execute a command string with prompt answers separated by spaces (e.g. 'LINE 0,0 10,10').", + batchable: true, + params: &[ + req("cmd", Ty::Str, "Command name and arguments."), + ], + rules: &[], + example: r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#, +}; + +pub static START: OpDef = OpDef { + name: "start", + doc: "Start an interactive command waiting for subsequent 'input' steps.", + batchable: true, + params: &[ + req("cmd", Ty::Str, "Command name to start."), + ], + rules: &[], + example: r#"{"op":"start","cmd":"LINE"}"#, +}; + +pub static INPUT: OpDef = OpDef { + name: "input", + doc: "Feed the next step input to an active command.", + batchable: true, + params: &[ + req("kind", Ty::Enum(&["text", "token", "point", "entity", "structure", "selection", "enter"]), "Input kind."), + opt("text", Ty::Str, "Free text or token option."), + opt("point", Ty::Point, "Point coordinates."), + opt("space", Ty::Enum(&["wcs", "ucs", "relative"]), "Coordinate space for point (defaults to wcs)."), + opt("handle", Ty::Handle, "Target entity handle for entity/structure pick."), + ], + rules: &[ + Rule::RequiredWhen { key: "kind", equals: "token", then: &["text"] }, + Rule::RequiredWhen { key: "kind", equals: "point", then: &["point"] }, + Rule::RequiredWhen { key: "kind", equals: "entity", then: &["handle", "point"] }, + Rule::RequiredWhen { key: "kind", equals: "structure", then: &["handle", "point"] }, + ], + example: r#"{"op":"input","kind":"point","point":[10,20]}"#, +}; + +pub static CANCEL: OpDef = OpDef { + name: "cancel", + doc: "Cancel the active command, equivalent to pressing Escape.", + batchable: true, + params: &[], + rules: &[], + example: r#"{"op":"cancel"}"#, +}; + +pub static UNDO: OpDef = OpDef { + name: "undo", + doc: "Undo the last drawing modification.", + batchable: true, + params: &[], + rules: &[], + example: r#"{"op":"undo"}"#, +}; + +pub static REDO: OpDef = OpDef { + name: "redo", + doc: "Redo the previously undone drawing modification.", + batchable: true, + params: &[], + rules: &[], + example: r#"{"op":"redo"}"#, +}; + +pub static SELECT: OpDef = OpDef { + name: "select", + doc: "Modify current entity selection by handles, filters, or query criteria.", + batchable: true, + params: &[ + opt("handles", Ty::Handles, "Explicit entity handles to select."), + opt("type", Ty::Str, "Entity type filter."), + opt("layer", Ty::Str, "Layer name filter."), + opt("clear", Ty::Bool, "Clear existing selection before applying."), + opt("where", Ty::ArrayOf(&Ty::Raw(r#"{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string"},"value":{}},"required":["path"]}"#)), "Property filter predicates."), + ], + rules: &[], + example: r#"{"op":"select","clear":true}"#, +}; + +pub static PROPERTY: OpDef = OpDef { + name: "property", + doc: "Modify a single property of selected entities.", + batchable: true, + params: &[ + req("field", Ty::Str, "Property field name."), + req("value", Ty::Raw(r#"{"description":"New property value"}"#), "New value for the field."), + ], + rules: &[], + example: r#"{"op":"property","field":"color","value":1}"#, +}; + +pub static SET_PROPERTIES: OpDef = OpDef { + name: "set_properties", + doc: "Atomically update record properties by RFC 6901 JSON pointer paths.", + batchable: true, + params: &[ + req("collection", Ty::Str, "Record collection name."), + opt("handle", Ty::Handle, "Target record handle if applicable."), + req("updates", Ty::ArrayOf(&Ty::Raw(r#"{"type":"object","properties":{"path":{"type":"string"},"value":{},"expected":{}},"required":["path","value"]}"#)), "List of pointer replacements."), + ], + rules: &[], + example: r#"{"op":"set_properties","collection":"entities","handle":"2A","updates":[{"path":"/common/layer","value":"Walls"}]}"#, +}; + +pub static ACTION: OpDef = OpDef { + name: "action", + doc: "Trigger a named GUI action or dialog command.", + batchable: true, + params: &[ + req("name", Ty::ActionName, "Name of the UI action to execute."), + ], + rules: &[], + example: r#"{"op":"action","name":"zoom_extents"}"#, +}; + +pub static EMBED_IMAGE: OpDef = OpDef { + name: "embed_image", + doc: "Embed or link a raster image into the drawing.", + batchable: true, + params: &[ + req("path", Ty::Str, "Path to the image file."), + opt("at", Ty::Point, "Lower-left corner [x, y] or [x, y, z]."), + opt("width", Ty::Num, "World width of the image."), + opt("linked", Ty::Bool, "Store as path-linked RasterImage instead of embedded OLE2FRAME."), + ], + rules: &[], + example: r#"{"op":"embed_image","path":"logo.png","at":[0,0]}"#, +}; + +pub static WBLOCK: OpDef = OpDef { + name: "wblock", + doc: "Export selected entities or a block definition to an external DWG/DXF file.", + batchable: true, + params: &[ + req("path", Ty::Str, "Target drawing path for export."), + opt("handles", Ty::Handles, "Entity handles to export."), + opt("block", Ty::Str, "Block name to export."), + opt("template", Ty::Str, "Base template file to carry styles and tables."), + opt("normalize", Ty::Bool, "Translate exported entities to origin [0,0,0]."), + ], + rules: &[], + example: r#"{"op":"wblock","path":"part.dwg","handles":["2A"]}"#, +}; + +pub static PLOT: OpDef = OpDef { + name: "plot", + doc: "Plot layouts to a PDF file. scale (e.g. \"1:100\") overrides fit; fit defaults to true when scale is omitted.", + batchable: true, + params: &[ + req("path", Ty::Str, "Output PDF file path (must end in .pdf)."), + opt("layout", Ty::Str, "Target layout name or 'all' (defaults to 'Model')."), + opt("area", Ty::EnumCi(&["extents", "display", "limits", "layout", "window"]), "Plot area (defaults to extents)."), + opt("window", Ty::Window, "World [x0, y0, x1, y1] rectangle; required when area=window."), + opt("paper", Ty::Str, "Canonical paper size name from paper catalog."), + opt("orientation", Ty::EnumCi(&["Portrait", "Landscape"]), "Sheet orientation."), + opt("fit", Ty::Bool, "Fit drawing to sheet (defaults to true if scale omitted)."), + opt("scale", Ty::Str, "Plot scale as paper:drawing ratio (e.g. \"1:100\")."), + opt("center", Ty::Bool, "Center plot on sheet (defaults to true)."), + opt("offset_x", Ty::Num, "Plot offset X in mm."), + opt("offset_y", Ty::Num, "Plot offset Y in mm."), + opt("upside_down", Ty::Bool, "Rotate content 180 degrees."), + opt("plot_style", Ty::Str, "CTB file path or named plot style table."), + opt("transparency", Ty::Bool, "Honor transparency settings."), + opt("lineweights", Ty::Bool, "Honor lineweight settings."), + opt("merge_lines", Ty::Bool, "Merge overlapping lines."), + opt("stamp", Ty::Bool, "Print plot stamp."), + opt("per_page", Ty::Bool, "When layout='all', write one PDF per layout as -.pdf."), + ], + rules: &[Rule::RequiredWhen { key: "area", equals: "window", then: &["window"] }], + example: r#"{"op":"plot","path":"out.pdf","area":"extents","scale":"1:100"}"#, +}; + +pub static ENTITIES_CREATE: OpDef = OpDef { + name: "entities_create", + doc: "Create new CAD entities from typed definitions.", + batchable: true, + params: &[ + req("entities", Ty::ArrayOf(&Ty::Raw(r#"{"type":"object","properties":{"type":{"type":"string"}},"required":["type"]}"#)), "List of entity definitions to create."), + ], + rules: &[], + example: r#"{"op":"entities_create","entities":[{"type":"Line","start":[0,0],"end":[10,10]}]}"#, +}; + +pub static ENTITIES_DELETE: OpDef = OpDef { + name: "entities_delete", + doc: "Delete entities by their handles.", + batchable: true, + params: &[ + req("handles", Ty::Handles, "Handles of entities to delete."), + ], + rules: &[], + example: r#"{"op":"entities_delete","handles":["2A"]}"#, +}; + +pub static ENTITIES_TRANSFORM: OpDef = OpDef { + name: "entities_transform", + doc: "Move, copy, rotate, scale, mirror or array entities.", + batchable: true, + params: &[ + req("handles", Ty::Handles, "Entities to transform."), + opt("action", Ty::EnumCi(&["move", "copy", "rotate", "scale", "mirror", "array"]), "Transform action (defaults to move)."), + opt("vector", Ty::Point, "move/copy: displacement [dx, dy] or [dx, dy, dz]."), + opt("center", Ty::Point, "rotate/scale: base point."), + opt("angle_deg", Ty::Num, "rotate: angle in degrees CCW."), + opt("factor", Ty::Num, "scale: non-zero scale factor."), + opt("axis", Ty::ArrayOf(&Ty::Point), "mirror: axis as two points [[x1, y1], [x2, y2]]."), + opt("copy", Ty::Bool, "mirror/copy: keep originals and return new copies."), + opt("rows", Ty::Int, "array: row count (1..100, defaults to 1)."), + opt("columns", Ty::Int, "array: column count (1..100, defaults to 1)."), + opt("row_spacing", Ty::Num, "array: row spacing."), + opt("column_spacing", Ty::Num, "array: column spacing."), + ], + rules: &[ + Rule::RequiredWhen { key: "action", equals: "scale", then: &["factor"] }, + Rule::RequiredWhen { key: "action", equals: "mirror", then: &["axis"] }, + ], + example: r#"{"op":"entities_transform","handles":["2A"],"action":"move","vector":[10,0]}"#, +}; + +pub static TEXT_REPLACE: OpDef = OpDef { + name: "text_replace", + doc: "Find and replace text in drawing text entities. Give find (+ optional replace) for one pair, or pairs for several.", + batchable: true, + params: &[ + opt("find", Ty::Str, "Text to find."), + opt("replace", Ty::Str, "Replacement text. Defaults to empty string (deletes the match)."), + opt("pairs", Ty::ArrayOf(&Ty::Raw( + r#"{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find"]}"# + )), "Several find/replace pairs applied in order."), + opt("match_case", Ty::Bool, "Case-sensitive search."), + opt("whole_word", Ty::Bool, "Match only whole words."), + opt("ignore_accents", Ty::Bool, "Ignore accents/diacritics (defaults to true when match_case is false)."), + opt("dry_run", Ty::Bool, "Report matches without changing anything."), + opt("replace_all", Ty::Bool, "Replace all occurrences within matching entities (defaults to true)."), + opt("scope", Ty::Enum(&["all", "active_space", "model_space", "blocks"]), "Search scope."), + opt("layer", Ty::Str, "Optional layer name filter."), + opt("handles", Ty::Handles, "Optional handles filter."), + opt("type", Ty::Str, "Optional entity type filter."), + opt("bounds", Ty::Window, "Optional bounding box filter [min_x, min_y, max_x, max_y]."), + ], + rules: &[Rule::AnyOf(&["find", "pairs"])], + example: r#"{"op":"text_replace","find":"OLD","replace":"NEW"}"#, +}; + +pub static BLOCK_DEFINE: OpDef = OpDef { + name: "block_define", + doc: "Define a block from existing entities and place an Insert reference.", + batchable: true, + params: &[ + req("name", Ty::Str, "Block name."), + req("handles", Ty::Handles, "Entities that form the block."), + req("base", Ty::Point, "Block origin point."), + opt("insert_at", Ty::Point, "Placement coordinates for the replacement Insert reference (defaults to base)."), + opt("replace", Ty::Bool, "Overwrite an existing block definition of the same name."), + ], + rules: &[], + example: r#"{"op":"block_define","name":"DOOR_90","handles":["2A","2B"],"base":[0,0]}"#, +}; + +pub static BLOCK_DELETE: OpDef = OpDef { + name: "block_delete", + doc: "Delete a block definition and all its child entities and inserts.", + batchable: true, + params: &[ + req("name", Ty::Str, "Block name to delete."), + ], + rules: &[], + example: r#"{"op":"block_delete","name":"DOOR"}"#, +}; + +pub static XDATA_SET: OpDef = OpDef { + name: "xdata_set", + doc: "Attach or clear extended entity data (XData) for a registered application.", + batchable: true, + params: &[ + req("app", Ty::Str, "Application name."), + req("handles", Ty::Handles, "Target entity handles."), + opt("data", Ty::ArrayOf(&Ty::Raw(r#"{"type":"object","properties":{"code":{"type":"integer"},"value":{}},"required":["code","value"]}"#)), "Typed XData entries [{code, value}]. Empty list removes app record."), + ], + rules: &[], + example: r#"{"op":"xdata_set","app":"APP","handles":["2A"],"data":[{"code":1000,"value":"tag"}]}"#, +}; + +pub static VIEW_FOCUS: OpDef = OpDef { + name: "view_focus", + doc: "Focus the viewport camera on the bounds of specific entities.", + batchable: true, + params: &[ + req("handles", Ty::Handles, "Entities to focus on."), + opt("highlight", Ty::Bool, "Also select the entities (defaults to true)."), + ], + rules: &[], + example: r#"{"op":"view_focus","handles":["2A"]}"#, +}; + +pub static ENTITIES_COPY_TO: OpDef = OpDef { + name: "entities_copy_to", + doc: "Copy entities from the current document into another open document.", + batchable: true, + params: &[ + req("handles", Ty::Handles, "Entities in the current active document to copy."), + opt("target_document_id", Ty::Int, "Target document ID to receive the copied entities."), + opt("document_id", Ty::Int, "Deprecated alias for target_document_id."), + ], + rules: &[Rule::AnyOf(&["target_document_id", "document_id"])], + example: r#"{"op":"entities_copy_to","handles":["2A"],"target_document_id":2}"#, +}; + +pub static GROUP_CREATE: OpDef = OpDef { + name: "group_create", + doc: "Create a named entity group.", + batchable: true, + params: &[ + req("name", Ty::Str, "Group name."), + req("handles", Ty::Handles, "Entities in the group."), + ], + rules: &[], + example: r#"{"op":"group_create","name":"Group1","handles":["2A"]}"#, +}; + +pub static SELECTION_SET_SAVE: OpDef = OpDef { + name: "selection_set_save", + doc: "Save an entity handle set under a name for later recall.", + batchable: true, + params: &[ + req("name", Ty::Str, "Selection set name."), + req("handles", Ty::Handles, "Entities to save."), + ], + rules: &[], + example: r#"{"op":"selection_set_save","name":"Sel1","handles":["2A"]}"#, +}; + +pub static SELECTION_SET_LOAD: OpDef = OpDef { + name: "selection_set_load", + doc: "Recall a previously saved selection set by name.", + batchable: true, + params: &[ + req("name", Ty::Str, "Selection set name."), + opt("select", Ty::Bool, "Also select the recalled entities in the viewport (defaults to true)."), + ], + rules: &[], + example: r#"{"op":"selection_set_load","name":"Sel1"}"#, +}; + +pub static USER_SELECT: OpDef = OpDef { + name: "user_select", + doc: "Prompt the user at the desktop screen to interactively pick entities.", + batchable: true, + params: &[ + opt("prompt", Ty::Str, "Prompt text pinned on screen while user picks."), + opt("detail", Ty::EnumCi(&["summary", "geometry", "full"]), "Returned entity detail level (defaults to full)."), + opt("type", Ty::Str, "Entity type filter."), + opt("layer", Ty::Str, "Layer filter."), + opt("clear", Ty::Bool, "Clear current selection before pick begins (defaults to true)."), + ], + rules: &[], + example: r#"{"op":"user_select","prompt":"Pick entities"}"#, +}; + +pub static GETPOINT: OpDef = OpDef { + name: "getpoint", + doc: "Prompt the user at the desktop screen to click one point in the viewport.", + batchable: true, + params: &[ + opt("prompt", Ty::Str, "Prompt text displayed while waiting for click."), + ], + rules: &[], + example: r#"{"op":"getpoint","prompt":"Click start point"}"#, +}; + +pub static CLOSE: OpDef = OpDef { + name: "close", + doc: "Close the active drawing document.", + batchable: true, + params: &[ + opt("discard", Ty::Bool, "Erase unsaved changes instead of refusing a dirty document."), + ], + rules: &[], + example: r#"{"op":"close"}"#, +}; + +pub static SYSVAR: OpDef = OpDef { + name: "sysvar", + doc: "Read or write CAD system variables (ltscale, pdmode, pdsize, clayer, etc.).", + batchable: true, + params: &[ + opt("get", Ty::ArrayOf(&Ty::Str), "List of variable names to read."), + opt("set", Ty::Raw(r#"{"type":"object","description":"Variable name=value pairs to set"}"#), "Key-value map of variables to set in one undo transaction."), + ], + rules: &[], + example: r#"{"op":"sysvar","get":["LTSCALE"]}"#, +}; + +pub static LAYOUT_CREATE: OpDef = OpDef { + name: "layout_create", + doc: "Create a new paper-space layout tab.", + batchable: true, + params: &[ + req("name", Ty::Str, "Layout tab name."), + ], + rules: &[], + example: r#"{"op":"layout_create","name":"Sheet1"}"#, +}; + +pub static PAGE_SETUP_SET: OpDef = OpDef { + name: "page_setup_set", + doc: "Configure page setup / print settings for a layout.", + batchable: true, + params: &[ + req("layout", Ty::Str, "Target layout name."), + opt("paper", Ty::Str, "Paper size name."), + opt("orientation", Ty::EnumCi(&["Portrait", "Landscape"]), "Paper orientation."), + opt("fit", Ty::Bool, "Fit to paper (defaults to true)."), + opt("scale", Ty::Str, "Paper-to-drawing scale ratio."), + opt("center", Ty::Bool, "Center drawing on sheet (defaults to true)."), + opt("offset_x", Ty::Num, "X offset in mm."), + opt("offset_y", Ty::Num, "Y offset in mm."), + opt("plot_style", Ty::Str, "CTB file path or named style."), + opt("area", Ty::EnumCi(&["extents", "display", "limits", "layout", "window"]), "Plot area."), + opt("window", Ty::Window, "World [x0, y0, x1, y1] rectangle; required when area=window."), + ], + rules: &[Rule::RequiredWhen { key: "area", equals: "window", then: &["window"] }], + example: r#"{"op":"page_setup_set","layout":"Sheet1","paper":"A4"}"#, +}; + +pub static FILE_IDENTITY: OpDef = OpDef { + name: "file_identity", + doc: "Inspect or refresh internal drawing UUIDs and fingerprint identity.", + batchable: true, + params: &[ + opt("renew", Ty::Bool, "Generate fresh document identity tokens."), + ], + rules: &[], + example: r#"{"op":"file_identity"}"#, +}; + +pub static SAVE: OpDef = OpDef { + name: "save", + doc: "Save the drawing to disk, optionally changing format or CAD version.", + batchable: true, + params: &[ + opt("path", Ty::Str, "Destination file path."), + opt("target_format", Ty::Enum(&["dwg", "dxf"]), "Target CAD format."), + opt("target_version", Ty::Enum(TARGET_VERSIONS), "Target AutoCAD version."), + opt("allow_lossy", Ty::Bool, "Acknowledge dropping unsupported passthrough records."), + ], + rules: &[], + example: r#"{"op":"save"}"#, +}; + +pub static SAVE_VERIFIED: OpDef = OpDef { + name: "save_verified", + doc: "Save to an explicit path, reload and verify roundtrip semantic equality.", + batchable: false, + params: &[ + req("path", Ty::Str, "Destination file path (.dwg or .dxf)."), + opt("target_format", Ty::Enum(&["dwg", "dxf"]), "Explicit output format matching extension."), + opt("target_version", Ty::Enum(TARGET_VERSIONS), "Explicit CAD version."), + opt("allow_lossy", Ty::Bool, "Acknowledge dropping unsupported records."), + opt("overwrite", Ty::Bool, "Replace destination if file already exists."), + ], + rules: &[], + example: r#"{"op":"save_verified","path":"drawing.dwg"}"#, +}; + +pub static STOP: OpDef = OpDef { + name: "stop", + doc: "Irreversible kill switch: disables all automation mutations for the session until manually re-enabled in the desktop GUI.", + batchable: false, + params: &[], + rules: &[], + example: r#"{"op":"stop"}"#, +}; + +pub static BATCH: OpDef = OpDef { + name: "batch", + doc: "Execute sequential operations with fresh state and step idempotency keys. Stops on first error.", + batchable: false, + params: &[ + req("steps", Ty::Raw(r##"{"type":"array","minItems":1,"maxItems":64,"description":"Sequential operations list","items":{"$ref":"#/$defs/batch_step"}}"##), "List of batch steps to execute."), + ], + rules: &[], + example: r#"{"op":"batch","steps":[{"op":"run","cmd":"LINE 0,0 10,0"}]}"#, +}; + +pub static OPS: &[&OpDef] = &[ + &NEW, + &OPEN, + &ACTIVATE, + &RUN, + &START, + &INPUT, + &CANCEL, + &UNDO, + &REDO, + &SELECT, + &PROPERTY, + &SET_PROPERTIES, + &ACTION, + &EMBED_IMAGE, + &WBLOCK, + &PLOT, + &ENTITIES_CREATE, + &ENTITIES_DELETE, + &ENTITIES_TRANSFORM, + &TEXT_REPLACE, + &BLOCK_DEFINE, + &BLOCK_DELETE, + &XDATA_SET, + &VIEW_FOCUS, + &ENTITIES_COPY_TO, + &GROUP_CREATE, + &SELECTION_SET_SAVE, + &SELECTION_SET_LOAD, + &USER_SELECT, + &GETPOINT, + &CLOSE, + &SYSVAR, + &LAYOUT_CREATE, + &PAGE_SETUP_SET, + &FILE_IDENTITY, + &SAVE, + &SAVE_VERIFIED, + &STOP, + &BATCH, +]; + +pub fn find_op(name: &str) -> Option<&'static OpDef> { + OPS.iter().copied().find(|op| op.name == name) +} + +// ============================================================================ +// SCHEMA EMITTERS +// ============================================================================ + +fn ty_schema(ty: &Ty) -> Value { + match ty { + Ty::Str => json!({"type": "string"}), + Ty::Bool => json!({"type": "boolean"}), + Ty::Num => json!({"type": "number"}), + Ty::Int => json!({"type": "integer"}), + Ty::Point => json!({"$ref": "#/$defs/point"}), + Ty::Handle => json!({"$ref": "#/$defs/handle"}), + Ty::Handles => json!({"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/handle"}}), + Ty::Window => json!({"$ref": "#/$defs/window"}), + Ty::Enum(v) | Ty::EnumCi(v) => json!({"type": "string", "enum": v}), + Ty::ActionName => { + let names = crate::app::automation_action_names(); + json!({"type": "string", "enum": names}) + } + Ty::ArrayOf(inner) => json!({"type": "array", "items": ty_schema(inner)}), + Ty::Raw(s) => serde_json::from_str(s).expect("Raw fragment must be valid JSON"), + } +} + +pub fn shared_defs() -> Value { + let batch_variants: Vec = OPS + .iter() + .filter(|op| op.batchable) + .map(|op| op_schema(op)) + .collect(); + + json!({ + "point": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 3}, + "window": {"type": "array", "items": {"type": "number"}, "minItems": 4, "maxItems": 4}, + "handle": {"type": "string", "pattern": "^(0[xX])?[0-9A-Fa-f]+$"}, + "batch_step": { + "type": "object", + "properties": { + "op": {"type": "string"}, + "document_id": {"type": "integer", "minimum": 0} + }, + "required": ["op"], + "anyOf": batch_variants + } + }) +} + +fn rules_text(op: &OpDef) -> String { + let mut out = String::new(); + for r in op.rules { + match r { + Rule::AnyOf(keys) => { + out.push_str(&format!(" Requires at least one of: {}.", keys.join(", "))); + } + Rule::RequiredWhen { key, equals, then } => { + out.push_str(&format!(" When {key}={equals}, requires: {}.", then.join(", "))); + } + } + } + out +} + +pub fn op_schema(op: &OpDef) -> Value { + let mut props = Map::new(); + props.insert("op".into(), json!({"enum": [op.name]})); + for p in op.params { + let mut s = ty_schema(&p.ty); + if !p.doc.is_empty() { + s["description"] = json!(p.doc); + } + props.insert(p.name.into(), s); + } + let mut required = vec!["op"]; + required.extend(op.params.iter().filter(|p| p.required).map(|p| p.name)); + json!({ + "type": "object", + "description": format!("{}{}", op.doc, rules_text(op)), + "properties": props, + "required": required, + }) +} + +pub fn execute_request_schema() -> Value { + let variants: Vec = OPS.iter().map(|op| op_schema(op)).collect(); + let op_names: Vec<&str> = OPS.iter().map(|op| op.name).collect(); + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "op": {"type": "string", "enum": op_names, "description": "Semantic editor operation."}, + "request_id": {"type": "string", "minLength": 1, "maxLength": 128, "description": "Caller-generated idempotency key."}, + "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."}, + "camera_revision": {"type": "integer", "minimum": 0, "description": "Expected camera revision."}, + "selection": {"type": "array", "items": {"$ref": "#/$defs/handle"}, "description": "Expected selected handles."}, + "steps": {"type": "array", "minItems": 1, "maxItems": 64, "description": "Sequential batch operations.", "items": {"$ref": "#/$defs/batch_step"}} + }, + "required": ["op", "request_id"], + "anyOf": variants, + "$defs": shared_defs() + }) +} + +pub fn batch_step_schema() -> Value { + let batch_variants: Vec = OPS + .iter() + .filter(|op| op.batchable) + .map(|op| op_schema(op)) + .collect(); + + json!({ + "type": "object", + "properties": { + "op": {"type": "string"}, + "document_id": {"type": "integer", "minimum": 0} + }, + "required": ["op"], + "anyOf": batch_variants + }) +} + +// ============================================================================ +// PRE-DISPATCH VALIDATOR +// ============================================================================ + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidationResult { + pub warnings: Vec, +} + +fn check_point(val: &Value) -> bool { + val.as_array().is_some_and(|arr| { + (2..=3).contains(&arr.len()) && arr.iter().all(|v| v.as_f64().is_some_and(f64::is_finite)) + }) +} + +fn check_handle(val: &Value) -> bool { + let Some(s) = val.as_str() else { return false }; + let hex = s.trim_start_matches("0x").trim_start_matches("0X"); + !hex.is_empty() && hex.chars().all(|c| c.is_ascii_hexdigit()) +} + +fn check_window(val: &Value) -> bool { + val.as_array().is_some_and(|arr| { + arr.len() == 4 && arr.iter().all(|v| v.as_f64().is_some_and(f64::is_finite)) + }) +} + +fn validate_type(ty: &Ty, val: &Value) -> Result<(), String> { + match ty { + Ty::Str => { + if val.as_str().is_none() { + return Err("expected string".into()); + } + } + Ty::Bool => { + if val.as_bool().is_none() { + return Err("expected boolean".into()); + } + } + Ty::Num => { + if !val.as_f64().is_some_and(f64::is_finite) { + return Err("expected finite number".into()); + } + } + Ty::Int => { + if val.as_i64().is_none() && val.as_u64().is_none() { + return Err("expected integer".into()); + } + } + Ty::Point => { + if !check_point(val) { + return Err("expected [x,y] or [x,y,z] point".into()); + } + } + Ty::Handle => { + if !check_handle(val) { + return Err("expected hexadecimal handle string".into()); + } + } + Ty::Handles => { + let Some(arr) = val.as_array() else { + return Err("expected array of handles".into()); + }; + if arr.is_empty() { + return Err("handles array must not be empty".into()); + } + if !arr.iter().all(check_handle) { + return Err("all items in handles must be valid hexadecimal handle strings".into()); + } + } + Ty::Window => { + if !check_window(val) { + return Err("expected [x0, y0, x1, y1] window array".into()); + } + } + Ty::Enum(allowed) => { + let Some(s) = val.as_str() else { + return Err("expected string".into()); + }; + if !allowed.contains(&s) { + return Err(format!("expected one of: {}", allowed.join(", "))); + } + } + Ty::EnumCi(allowed) => { + let Some(s) = val.as_str() else { + return Err("expected string".into()); + }; + if !allowed.iter().any(|a| a.eq_ignore_ascii_case(s)) { + return Err(format!("expected one of: {}", allowed.join(", "))); + } + } + Ty::ActionName => { + let Some(s) = val.as_str() else { + return Err("expected action name string".into()); + }; + if !crate::app::automation_action_names().contains(&s) { + return Err(format!("unknown action name: '{s}'")); + } + } + Ty::ArrayOf(inner) => { + let Some(arr) = val.as_array() else { + return Err("expected array".into()); + }; + for (idx, item) in arr.iter().enumerate() { + validate_type(inner, item) + .map_err(|e| format!("item {idx}: {e}"))?; + } + } + Ty::Raw(_) => {} + } + Ok(()) +} + +pub fn validate_request( + request: &Value, + is_batch_step: bool, +) -> Result { + let Some(obj) = request.as_object() else { + return Err("request must be a JSON object".into()); + }; + + let Some(op_name) = obj.get("op").and_then(Value::as_str) else { + return Err("missing 'op' string in request".into()); + }; + + let Some(op_def) = find_op(op_name) else { + return Err(format!("unknown operation: '{op_name}'")); + }; + + if is_batch_step && !op_def.batchable { + return Err(format!("operation '{op_name}' cannot be used in a batch step")); + } + + // Check required params + for p in op_def.params { + if p.required { + let val = obj.get(p.name); + if val.is_none() || val.unwrap().is_null() { + return Err(format!( + "Missing {} for {}. Example request: {}", + p.name, op_name, op_def.example + )); + } + } + } + + // Check rules + for r in op_def.rules { + match r { + Rule::AnyOf(keys) => { + let any_present = keys.iter().any(|k| obj.get(*k).is_some_and(|v| !v.is_null())); + if !any_present { + return Err(format!( + "Missing {} for {}. Example request: {}", + keys.join(" or "), + op_name, + op_def.example + )); + } + } + Rule::RequiredWhen { key, equals, then } => { + let matches = obj.get(*key).and_then(Value::as_str).is_some_and(|v| v.eq_ignore_ascii_case(equals)); + if matches { + for req_key in *then { + let val = obj.get(*req_key); + if val.is_none() || val.unwrap().is_null() { + return Err(format!( + "Missing {} for {}. Example request: {}", + req_key, op_name, op_def.example + )); + } + } + } + } + } + } + + // Validate types for present parameters + for p in op_def.params { + if let Some(val) = obj.get(p.name) { + if !val.is_null() { + validate_type(&p.ty, val).map_err(|e| { + format!("parameter '{}' for op '{}': {e}", p.name, op_name) + })?; + } + } + } + + // Detect unknown keys (Phase 1: warning) + let mut warnings = Vec::new(); + let mut allowed: Vec<&str> = op_def.params.iter().map(|p| p.name).collect(); + if is_batch_step { + allowed.extend(["op", "document_id"]); + } else { + allowed.extend(ENVELOPE_KEYS.iter().copied()); + } + + for (k, _) in obj { + if !allowed.contains(&k.as_str()) { + if k == "data_base64" { + warnings.push("data_base64 is supported only in the Web/WASM build, not in native desktop".into()); + } else { + warnings.push(format!("unknown parameter '{k}' ignored for op '{op_name}'")); + } + } + } + + Ok(ValidationResult { warnings }) +}