diff --git a/docs/automation/README.md b/docs/automation/README.md index 18775e0ae..d812c6244 100644 --- a/docs/automation/README.md +++ b/docs/automation/README.md @@ -13,9 +13,9 @@ The client must start that command over standard input/output. If it asks for th The server provides four tools: - `ocs_sessions` finds running editor sessions and opens OpenCADStudio when none exists. -- `ocs_read` reads document state, commands, entities, layers, properties, measurements, history, events, and operation status. -- `ocs_execute` performs semantic commands and input against the real editor. -- `ocs_capture` returns a PNG of the current window for visual verification. +- `ocs_read` reads document state, command manifests, entities, layers, properties, kernel measurements and spatial relationships, history, events, and operation status. +- `ocs_execute` performs one operation or a sequential batch against the real editor. +- `ocs_capture` returns a bounded PNG of the drawing viewport or complete window. The normal flow is to call `ocs_sessions`, read the chosen session with `ocs_read`, then pass the returned document state into `ocs_execute`. For example, an undo request has this shape: @@ -48,13 +48,52 @@ A complete command can be sent with `run`. Prompt answers are separated by space } ``` +Use `batch` when the steps are already known. OCS supplies each step with the state produced by the previous one and stops on the first failure. `completed_steps` and `next_step` show exactly what committed: + +```json +{ + "session_id": "SESSION_FROM_OCS_SESSIONS", + "response_detail": "changed_entities", + "request": { + "op": "batch", + "request_id": "shape-1", + "steps": [ + {"op": "run", "cmd": "LINE 0,0 10,0"}, + {"op": "run", "cmd": "CIRCLE 5,5 2"} + ] + } +} +``` + +Execute responses use `response_detail: "compact"` by default and return only the state needed for the next edit. Use `changed_entities` to receive the current geometry of affected handles in the same response, or `full` when the complete editor state is needed. + +`ocs_read` query accepts exact handles, type/layer filters, field projection, world-XY bounds, nearest-curve ranking, closed-curve containment, and exact intersections between two planar curves. Nearest points, containment, curve length, area, and intersections are calculated by the geometry kernel: + +```json +{ + "session_id": "SESSION_FROM_OCS_SESSIONS", + "op": "query", + "parameters": {"intersections": ["2A", "31"]} +} +``` + 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. +`ocs_capture` defaults to the drawing viewport and a longest edge of 1600 pixels. Set `scope` to `window` for the full interface or change `max_dimension` between 256 and 4096. + +Clients using MCP 2026-07-28 can advertise `io.modelcontextprotocol/tasks`. OCS then returns a standard task handle when an operation is still running and accepts `tasks/get`, `tasks/update`, and `tasks/cancel`. Other clients continue to receive the existing operation status and can read it with `ocs_read`. + 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 python3 docs/automation/mcp_smoke.py target/debug/OpenCADStudio ``` + +The repeatable live-editor evaluation draws three isolated entities in one batch, verifies exact intersections, nearest geometry and kernel measurements, removes the entities, and reports call count, elapsed time and wire bytes: + +```sh +python3 docs/automation/mcp_eval.py target/debug/OpenCADStudio +``` diff --git a/docs/automation/mcp_eval.py b/docs/automation/mcp_eval.py new file mode 100644 index 000000000..956633c4f --- /dev/null +++ b/docs/automation/mcp_eval.py @@ -0,0 +1,182 @@ +"""Repeatable black-box speed and capability check for the native MCP server.""" + +import json +from pathlib import Path +import subprocess +import sys +import time + + +MODERN = "2026-07-28" +META = { + "io.modelcontextprotocol/protocolVersion": MODERN, + "io.modelcontextprotocol/clientInfo": {"name": "ocs-eval", "version": "1"}, + "io.modelcontextprotocol/clientCapabilities": { + "extensions": {"io.modelcontextprotocol/tasks": {}} + }, +} + + +class Client: + def __init__(self, server: Path) -> None: + self.process = subprocess.Popen( + [str(server), "--mcp"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + self.serial = 0 + self.rpc_calls = 0 + self.tool_calls = 0 + self.tasks = 0 + self.request_bytes = 0 + self.response_bytes = 0 + + def rpc(self, method: str, params: dict) -> dict: + assert self.process.stdin and self.process.stdout + self.serial += 1 + payload = {"jsonrpc": "2.0", "id": self.serial, "method": method, "params": params} + wire = json.dumps(payload, separators=(",", ":")) + self.request_bytes += len(wire.encode()) + self.rpc_calls += 1 + self.process.stdin.write(wire + "\n") + self.process.stdin.flush() + line = self.process.stdout.readline() + self.response_bytes += len(line.encode()) + response = json.loads(line) + if "error" in response: + raise RuntimeError(response["error"]) + return response["result"] + + def tool(self, name: str, arguments: dict) -> dict: + self.tool_calls += 1 + result = self.rpc("tools/call", {"name": name, "arguments": arguments, "_meta": META}) + if result.get("resultType") == "task": + self.tasks += 1 + task_id = result["taskId"] + poll_interval = result.get("pollIntervalMs", 250) + while True: + time.sleep(poll_interval / 1000) + task = self.rpc("tasks/get", {"taskId": task_id, "_meta": META}) + if task["status"] in {"failed", "cancelled"}: + raise RuntimeError(task) + if task["status"] == "completed": + result = task["result"] + break + structured = result.get("structuredContent") + if structured is None: + raise RuntimeError(result) + if structured.get("ok") is False: + raise RuntimeError(structured) + return structured + + def close(self) -> None: + assert self.process.stdin + self.process.stdin.close() + if self.process.wait(timeout=5) != 0: + raise RuntimeError(self.process.stderr.read() if self.process.stderr else "MCP exited") + + +def main() -> None: + server = Path(sys.argv[1] if len(sys.argv) > 1 else "target/debug/OpenCADStudio").resolve() + client = Client(server) + started = time.perf_counter() + handles: list[str] = [] + session: str | None = None + base = 0 + succeeded = False + try: + discovery = client.rpc("server/discover", {"_meta": META}) + assert "io.modelcontextprotocol/tasks" in discovery["capabilities"]["extensions"] + sessions = client.tool("ocs_sessions", {"launch_if_none": True})["result"] + session = sessions[0]["session_id"] + active = next( + document for document in sessions[0]["documents"] + if document["id"] == sessions[0]["document_id"] + ) + if active.get("start"): + client.tool( + "ocs_execute", + {"session_id": session, "request": {"op": "new", "request_id": "eval-new"}}, + ) + + base = 1_000_000 + int(time.time()) % 100_000 + draw = client.tool( + "ocs_execute", + { + "session_id": session, + "response_detail": "changed_entities", + "wait_seconds": 0, + "request": { + "op": "batch", + "request_id": f"eval-draw-{base}", + "steps": [ + {"op": "run", "cmd": f"LINE {base-5},0 {base+5},0"}, + {"op": "run", "cmd": f"LINE {base},-5 {base},5"}, + {"op": "run", "cmd": f"CIRCLE {base+20},0 2"}, + ], + }, + }, + ) + assert draw["completed_steps"] == 3, draw + assert client.tasks > 0, "wait_seconds=0 did not exercise MCP Tasks" + changed = draw["changed_entities"] + line_handles = [entity["handle"] for entity in changed if entity["type"] == "Line"] + circle_handles = [entity["handle"] for entity in changed if entity["type"] == "Circle"] + assert len(line_handles) == 2 and len(circle_handles) == 1, changed + handles = line_handles + circle_handles + + crossings = client.tool( + "ocs_read", + {"session_id": session, "op": "query", "parameters": {"intersections": line_handles}}, + ) + assert crossings["count"] == 1 and crossings["intersections"][0]["point"] == [base, 0.0] + + nearest = client.tool( + "ocs_read", + {"session_id": session, "op": "query", "parameters": {"near": [base + 20, 0], "handles": handles, "limit": 1}}, + ) + assert nearest["entities"][0]["handle"] == circle_handles[0] + + measured = client.tool( + "ocs_read", + {"session_id": session, "op": "measure", "parameters": {"handles": circle_handles}}, + ) + assert abs(measured["measurements"][0]["curve"]["area"] - 12.566370614359172) < 1e-9 + succeeded = True + finally: + if handles and session: + try: + client.tool( + "ocs_execute", + { + "session_id": session, + "request": { + "op": "batch", + "request_id": f"eval-clean-{base}", + "steps": [ + {"op": "select", "handles": handles}, + {"op": "run", "cmd": "ERASE"}, + ], + }, + }, + ) + except Exception: + pass + elapsed = (time.perf_counter() - started) * 1000 + metrics = { + "ok": succeeded, + "elapsed_ms": round(elapsed, 1), + "tool_calls": client.tool_calls, + "tasks": client.tasks, + "rpc_calls": client.rpc_calls, + "request_bytes": client.request_bytes, + "response_bytes": client.response_bytes, + } + client.close() + print(json.dumps(metrics, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/docs/automation/mcp_smoke.py b/docs/automation/mcp_smoke.py index 6c651d3af..e28fb6867 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"]) == 14 + assert len(execute_request["oneOf"]) == 15 + assert execute_request["properties"]["steps"]["maxItems"] == 64 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" @@ -86,6 +87,7 @@ def modern(server: Path) -> None: assert discovered["result"]["resultType"] == "complete" assert discovered["result"]["ttlMs"] >= 0 assert discovered["result"]["cacheScope"] in {"public", "private"} + assert "io.modelcontextprotocol/tasks" in discovered["result"]["capabilities"]["extensions"] tools = request(process, { "jsonrpc": "2.0", @@ -97,6 +99,9 @@ def modern(server: Path) -> None: assert tools["result"]["resultType"] == "complete" assert tools["result"]["ttlMs"] >= 0 assert tools["result"]["cacheScope"] in {"public", "private"} + definitions = {tool["name"]: tool for tool in tools["result"]["tools"]} + assert definitions["ocs_execute"]["inputSchema"]["properties"]["response_detail"]["default"] == "compact" + assert definitions["ocs_capture"]["inputSchema"]["properties"]["scope"]["default"] == "viewport" missing_cmd = request(process, { "jsonrpc": "2.0", diff --git a/src/app/automation.rs b/src/app/automation.rs index 3836ada6b..a3c33d0a0 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -138,9 +138,9 @@ fn v3(v: acadrust::types::Vector3) -> Value { json!([v.x, v.y, v.z]) } -/// One entity as JSON: handle, type, layer, plus basic geometry for the common -/// types (others carry only the common fields). -fn entity_json(e: &acadrust::EntityType) -> Value { +/// One entity as JSON. Summary mode carries identity only, geometry adds the +/// entity's defining values, and full also includes its world bounds. +fn entity_json(e: &acadrust::EntityType, detail: &str) -> Value { use acadrust::EntityType as E; let c = e.common(); let mut obj = json!({ @@ -148,6 +148,9 @@ fn entity_json(e: &acadrust::EntityType) -> Value { "type": crate::entities::names::ui_name(e), "layer": c.layer, }); + if detail == "summary" { + return obj; + } let map = obj.as_object_mut().expect("json object"); match e { E::Line(l) => { @@ -194,9 +197,41 @@ fn entity_json(e: &acadrust::EntityType) -> Value { } _ => {} } + if detail == "full" { + let bounds = e.as_entity().bounding_box(); + map.insert("bounds".into(), json!({ + "min":[bounds.min.x,bounds.min.y,bounds.min.z], + "max":[bounds.max.x,bounds.max.y,bounds.max.z] + })); + } obj } +fn request_point(req: &Value, key: &str) -> Option<[f64; 2]> { + let values = req[key].as_array()?; + if !(2..=3).contains(&values.len()) { + return None; + } + let x = values[0].as_f64()?; + let y = values[1].as_f64()?; + (x.is_finite() && y.is_finite()).then_some([x, y]) +} + +fn request_handle(value: &Value) -> Option { + value.as_str() + .and_then(|value| u64::from_str_radix(value.trim_start_matches("0x"), 16).ok()) + .map(acadrust::Handle::new) +} + +fn projected_fields(mut entity: Value, fields: Option<&Vec>) -> Value { + let Some(fields) = fields else { return entity }; + let Some(source) = entity.as_object_mut() else { return entity }; + let keep: std::collections::HashSet<&str> = + fields.iter().filter_map(Value::as_str).collect(); + source.retain(|key, _| key == "handle" || keep.contains(key.as_str())); + entity +} + impl OpenCADStudio { /// Handle one JSON request line and return the JSON response. #[cfg(any(test, not(target_arch = "wasm32")))] @@ -445,38 +480,156 @@ impl OpenCADStudio { Ok(()) } - /// List entities (handle, type, layer, basic geometry), optionally filtered - /// by `type` and/or `layer`, capped by `limit` (default 1000). + /// Query entities by identity, metadata and exact plane-curve relationships. fn entity_query(&self, req: &Value) -> Value { let i = self.active_tab; + let tab = &self.tabs[i]; + if let Some(pair) = req["intersections"].as_array() { + if pair.len() != 2 { + return err("query intersections expects exactly two handles"); + } + let Some(first) = request_handle(&pair[0]) else { + return err("query intersections contains an invalid first handle"); + }; + let Some(second) = request_handle(&pair[1]) else { + return err("query intersections contains an invalid second handle"); + }; + let Some(first_entity) = tab.scene.document.get_entity(first) else { + return err("query intersections first entity does not exist"); + }; + let Some(second_entity) = tab.scene.document.get_entity(second) else { + return err("query intersections second entity does not exist"); + }; + let Some(first_curve) = crate::entities::curve::entity_curve_xy(first_entity) else { + return err("query intersections first entity is not a planar curve"); + }; + let Some(second_curve) = crate::entities::curve::entity_curve_xy(second_entity) else { + return err("query intersections second entity is not a planar curve"); + }; + let crossings = cadkernel::geom2d::intersect( + &first_curve, + &second_curve, + cadkernel::geom2d::Tolerance::default(), + ); + return json!({ + "ok":true, + "document_id":tab.id, + "geometry_revision":tab.scene.geometry_epoch, + "handles":[format!("{:X}",first.value()),format!("{:X}",second.value())], + "count":crossings.len(), + "intersections":crossings.into_iter().map(|crossing|json!({ + "point":[crossing.point[0],crossing.point[1]], + "parameter_first":crossing.t_a, + "parameter_second":crossing.t_b + })).collect::>() + }); + } + let type_filter = req["type"].as_str(); let layer_filter = req["layer"].as_str(); - let limit = req["limit"].as_u64().unwrap_or(1000).min(10000) as usize; - let offset = req["offset"].as_u64().unwrap_or(0); - - let mut entities = Vec::new(); - let mut matched = 0u64; - for e in self.tabs[i].scene.document.entities() { - if let Some(tf) = type_filter { - if !crate::entities::names::ui_name(e).eq_ignore_ascii_case(tf) { - continue; - } - } - if let Some(lf) = layer_filter { - if e.common().layer != lf { - continue; - } - } - matched += 1; - if matched > offset && entities.len() < limit { - entities.push(entity_json(e)); - } + let handles: Option> = req["handles"] + .as_array() + .map(|values| values.iter().filter_map(request_handle).map(|h| h.value()).collect()) + .or_else(|| request_handle(&req["handle"]) + .map(|handle| std::iter::once(handle.value()).collect())); + let near = request_point(req, "near"); + let contains = request_point(req, "contains_point"); + let bounds = req["bounds"].as_array().and_then(|values| { + (values.len() == 4).then(|| Some([ + values[0].as_f64()?, values[1].as_f64()?, + values[2].as_f64()?, values[3].as_f64()?, + ])).flatten() + }); + if req.get("handles").is_some() && handles.as_ref().is_some_and(|parsed| { + parsed.len() != req["handles"].as_array().map_or(0, Vec::len) + }) { + return err("query handles contains an invalid hexadecimal handle"); } + if req.get("handle").is_some() && request_handle(&req["handle"]).is_none() { + return err("query handle must be hexadecimal"); + } + if req.get("near").is_some() && near.is_none() { + return err("query near expects two or three finite coordinates"); + } + if req.get("contains_point").is_some() && contains.is_none() { + return err("query contains_point expects two or three finite coordinates"); + } + if req.get("bounds").is_some() && bounds.is_none_or(|bounds| { + !bounds.iter().all(|value| value.is_finite()) + || bounds[0] > bounds[2] || bounds[1] > bounds[3] + }) { + return err("query bounds expects finite [min_x,min_y,max_x,max_y]"); + } + let detail = req["detail"].as_str().unwrap_or("geometry"); + if !matches!(detail, "summary" | "geometry" | "full") { + return err("query detail must be summary, geometry or full"); + } + let limit = req["limit"].as_u64().unwrap_or(1000).min(10000) as usize; + let offset = req["offset"].as_u64().unwrap_or(0) as usize; + + let mut matched = Vec::new(); + for e in tab.scene.document.entities() { + if handles.as_ref().is_some_and(|handles| { + !handles.contains(&e.common().handle.value()) + }) { + continue; + } + if type_filter.is_some_and(|value| { + !crate::entities::names::ui_name(e).eq_ignore_ascii_case(value) + }) || layer_filter.is_some_and(|value| e.common().layer != value) { + continue; + } + if let Some(bounds) = bounds { + let entity_bounds = e.as_entity().bounding_box(); + if entity_bounds.max.x < bounds[0] || entity_bounds.max.y < bounds[1] + || entity_bounds.min.x > bounds[2] || entity_bounds.min.y > bounds[3] + { + continue; + } + } + let curve = (near.is_some() || contains.is_some()) + .then(|| crate::entities::curve::entity_curve_xy(e)).flatten(); + if let Some(point) = contains { + let Some(curve) = curve.as_ref().filter(|curve| curve.is_closed()) else { + continue; + }; + if !cadkernel::geom2d::contains( + std::slice::from_ref(curve), point, + cadkernel::geom2d::Tolerance::default(), + ) { + continue; + } + } + let nearest = near.and_then(|point| curve.as_ref() + .map(|curve| cadkernel::geom2d::closest_point(curve, point))); + if near.is_some() && nearest.is_none() { + continue; + } + let mut entity = entity_json(e, detail); + if let Some(nearest) = nearest { + let object = entity.as_object_mut().expect("entity JSON object"); + object.insert("distance".into(), json!(nearest.distance)); + object.insert("closest_point".into(), + json!([nearest.point[0], nearest.point[1]])); + object.insert("parameter".into(), json!(nearest.t)); + } + matched.push((nearest.map(|nearest| nearest.distance), entity)); + } + if near.is_some() { + matched.sort_by(|left, right| left.0.partial_cmp(&right.0) + .unwrap_or(std::cmp::Ordering::Equal)); + } + let count = matched.len(); + let fields = req["fields"].as_array(); + let entities: Vec = matched.into_iter().skip(offset).take(limit) + .map(|(_, entity)| projected_fields(entity, fields)).collect(); json!({ "ok": true, - "count": matched, + "document_id":tab.id, + "geometry_revision":tab.scene.geometry_epoch, + "count": count, "returned": entities.len(), - "next_offset": if offset + (entities.len() as u64) < matched { Some(offset + entities.len() as u64) } else { None }, + "next_offset": (offset + entities.len() < count).then_some(offset + entities.len()), "entities": entities, }) } diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index 64efd2f7e..7bc23ef39 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -172,10 +172,110 @@ fn command_examples(name: &str) -> &'static [&'static str] { "CIRCLE" => &["CIRCLE 5,5 3"], "PLINE" => &["PLINE 0,0 10,0 10,10 C"], "ZOOM" => &["ZOOM EXTENTS"], + "MOVE" => &["MOVE 0,0 100,0"], + "ROTATE" => &["ROTATE 0,0 90"], + "UCS" => &["UCS Z 90"], + "PDMODE" => &["PDMODE 3"], + "LTSCALE" => &["LTSCALE 2.5"], _ => &[], } } +fn command_category(name: &str) -> &'static str { + if [ + "LINE", "CIRCLE", "PLINE", "ARC", "ELLIPSE", "POINT", "SPLINE", "RECT", "RECTANG", + "POLYGON", "HATCH", "RAY", "XLINE", "3DPOLY", "MLINE", "TEXT", "MTEXT", "TABLE", + ] + .contains(&name) + { + "create" + } else if [ + "MOVE", + "COPY", + "ROTATE", + "SCALE", + "MIRROR", + "ERASE", + "TRIM", + "EXTEND", + "OFFSET", + "FILLET", + "CHAMFER", + "BREAK", + "JOIN", + "EXPLODE", + "STRETCH", + "ARRAY", + "PEDIT", + "SPLINEDIT", + "REVERSE", + "ALIGN", + ] + .contains(&name) + { + "modify" + } else if ["DIST", "ID", "AREA", "MEASURE", "MEASUREGEOM"].contains(&name) { + "inspect" + } else if ["ZOOM", "PAN", "UCS", "PLAN", "VPORTS", "3DORBIT"].contains(&name) { + "view" + } else if name.starts_with("DIM") || ["LEADER", "MLEADER", "TOLERANCE"].contains(&name) { + "annotate" + } else if [ + "BOX", + "CYLINDER", + "CONE", + "SPHERE", + "TORUS", + "WEDGE", + "EXTRUDE", + "REVOLVE", + "SWEEP", + "LOFT", + "UNION", + "SUBTRACT", + "INTERSECT", + "PRESSPULL", + ] + .contains(&name) + { + "model" + } else { + "other" + } +} + +fn command_selection_policy(name: &str) -> &'static str { + if [ + "ARRAY", + "ARRAYPATH", + "ARRAYPOLAR", + "ARRAYRECT", + "BLOCK", + "COPY", + "COPYCLIP", + "CUTCLIP", + "ERASE", + "EXPLODE", + "GROUP", + "LAYFRZ", + "LAYLCK", + "LAYMCUR", + "LAYOFF", + "LAYULK", + "MIRROR", + "MOVE", + "ROTATE", + "SCALE", + "STRETCH", + ] + .contains(&name) + { + "current_selection_or_prompt" + } else { + "command_defined" + } +} + fn active_command_metadata(command: &dyn crate::command::CadCommand) -> Value { let options = command.options(); let mut accepts = Vec::new(); @@ -261,7 +361,7 @@ impl OpenCADStudio { "mtext_editor":self.mtext_editor.as_ref().map(|e|json!({"text":e.content.text(),"height":e.height,"style":e.style})), "text_editor":self.text_inline.is_some(),"event_cursor":self.control.serial, "operation":self.control.pending.as_ref().map(|p| &p.id), - "capabilities":["commands","step_input","entity_pick","structure_pick","selection","properties","layers","history","documents","events","capture","measure"] + "capabilities":["commands","command_manifest","step_input","batch","compact_results","entity_pick","structure_pick","selection","properties","layers","history","documents","events","capture","viewport_capture","measure","spatial_query"] }) } @@ -359,6 +459,11 @@ impl OpenCADStudio { "ok":true, "command":{ "name":name, + "category":command_category(name), + "preconditions":{ + "document":true, + "selection":command_selection_policy(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.", @@ -368,16 +473,27 @@ impl OpenCADStudio { "op":"start", "request":{"op":"start","cmd":name}, "next":"Follow state.command.accepts, options and input_example after every step." - } + }, + "completion":"completed is final; waiting_input requires another input step; failed includes the reason." } }), Task::none(), ); } + if let Some(search) = req["search"].as_str().filter(|search| !search.is_empty()) { + let search = search.to_ascii_uppercase(); + names.retain(|name| name.contains(&search)); + } + let count = names.len(); + let offset = req["offset"].as_u64().unwrap_or(0) as usize; + let limit = req["limit"].as_u64().unwrap_or(200).min(1000) as usize; + let commands: Vec = names.into_iter().skip(offset).take(limit).collect(); return ( json!({ - "ok":true,"commands":names,"actions":actions::NAMES, - "detail_parameters":{"name":"LINE"}, + "ok":true,"count":count,"returned":commands.len(), + "next_offset":(offset + commands.len() < count).then_some(offset + commands.len()), + "commands":commands,"actions":actions::NAMES, + "detail_parameters":{"name":"LINE"},"search_parameter":{"search":"LINE"}, "guidance":"Request one command by name for batch examples and interactive input guidance." }), Task::none(), @@ -857,7 +973,16 @@ impl OpenCADStudio { h.isolines.max(0) as usize, ) }); - out.push(json!({"handle":format!("{:X}",handle.value()),"type":crate::entities::names::ui_name(entity),"bounds":{"min":[bb.min.x,bb.min.y,bb.min.z],"max":[bb.max.x,bb.max.y,bb.max.z]},"mesh":metrics.map(|m|json!({"vertices":m.metrics.vertices,"triangles":m.metrics.triangles,"surface_area":m.metrics.surface_area,"volume":m.metrics.volume,"centroid":m.metrics.centroid}))})); + let curve = crate::entities::curve::entity_curve(entity).map(|planar| { + let length = planar.curve.length(); + json!({ + "bounded":length.is_finite(), + "closed":planar.curve.is_closed(), + "length":length.is_finite().then_some(length), + "area":planar.curve.is_closed().then(|| planar.curve.enclosed_area().abs()) + }) + }); + out.push(json!({"handle":format!("{:X}",handle.value()),"type":crate::entities::names::ui_name(entity),"bounds":{"min":[bb.min.x,bb.min.y,bb.min.z],"max":[bb.max.x,bb.max.y,bb.max.z]},"curve":curve,"mesh":metrics.map(|m|json!({"vertices":m.metrics.vertices,"triangles":m.metrics.triangles,"surface_area":m.metrics.surface_area,"volume":m.metrics.volume,"centroid":m.metrics.centroid}))})); } json!({"ok":true,"document_id":tab.id,"geometry_revision":tab.scene.geometry_epoch,"measurements":out}) } @@ -869,17 +994,66 @@ impl OpenCADStudio { ) { let result = (|| -> Result { let s = screenshot.ok_or("Renderer did not return an image")?; - image::save_buffer_with_format( - &path, - &s.rgba, - s.size.width, - s.size.height, - image::ColorType::Rgba8, - image::ImageFormat::Png, - ) - .map_err(|e| e.to_string())?; + let requested_scope = self + .control + .pending + .as_ref() + .and_then(|pending| pending.request["scope"].as_str()) + .unwrap_or("window"); + let max_dimension = self + .control + .pending + .as_ref() + .and_then(|pending| pending.request["max_dimension"].as_u64()) + .unwrap_or(1600) + .clamp(256, 4096) as u32; + let mut image = + image::RgbaImage::from_raw(s.size.width, s.size.height, s.rgba.to_vec()) + .ok_or("Renderer returned malformed image data")?; + let mut actual_scope = "window"; + if requested_scope == "viewport" { + if let Some(bounds) = crate::ui::wrap_bar::dropdown_bounds( + crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID, + ) { + let scale = s.scale_factor; + let left = (bounds.x * scale).floor().clamp(0.0, image.width() as f32) as u32; + let top = (bounds.y * scale).floor().clamp(0.0, image.height() as f32) as u32; + let right = ((bounds.x + bounds.width) * scale) + .ceil() + .clamp(0.0, image.width() as f32) as u32; + let bottom = ((bounds.y + bounds.height) * scale) + .ceil() + .clamp(0.0, image.height() as f32) as u32; + if right > left && bottom > top { + image = image::imageops::crop_imm( + &image, + left, + top, + right - left, + bottom - top, + ) + .to_image(); + actual_scope = "viewport"; + } + } + } + let longest = image.width().max(image.height()); + if longest > max_dimension { + let scale = max_dimension as f64 / longest as f64; + let width = ((image.width() as f64 * scale).round() as u32).max(1); + let height = ((image.height() as f64 * scale).round() as u32).max(1); + image = image::imageops::resize( + &image, + width, + height, + image::imageops::FilterType::Triangle, + ); + } + image + .save_with_format(&path, image::ImageFormat::Png) + .map_err(|e| e.to_string())?; Ok( - json!({"path":path,"width":s.size.width,"height":s.size.height,"scale_factor":s.scale_factor,"document_id":self.tabs[self.active_tab].id,"revision":self.tabs[self.active_tab].edit_revision,"camera_revision":self.tabs[self.active_tab].scene.camera_generation}), + json!({"path":path,"scope":actual_scope,"width":image.width(),"height":image.height(),"scale_factor":s.scale_factor,"document_id":self.tabs[self.active_tab].id,"revision":self.tabs[self.active_tab].edit_revision,"camera_revision":self.tabs[self.active_tab].scene.camera_generation}), ) })(); match result { @@ -990,6 +1164,8 @@ mod tests { "PLINE 0,0 10,0 10,10 C" ); assert_eq!(detail["command"]["interactive"]["op"], "start"); + assert_eq!(detail["command"]["category"], "create"); + assert_eq!(detail["command"]["preconditions"]["document"], true); assert_eq!( app.control_request(json!({"op":"commands","name":"NOT_A_COMMAND"})) .0["code"], @@ -997,6 +1173,56 @@ mod tests { ); } #[test] + fn control_queries_exact_curve_relationships_and_metrics() { + let mut app = OpenCADStudio::new_for_test(); + request(&mut app, json!({"op":"new"})); + request(&mut app, json!({"op":"run","cmd":"LINE -5,0 5,0"})); + request(&mut app, json!({"op":"run","cmd":"LINE 0,-5 0,5"})); + request(&mut app, json!({"op":"run","cmd":"CIRCLE 20,0 2"})); + + let lines = app.control_request(json!({"op":"query","type":"Line"})).0; + let first = lines["entities"][0]["handle"].as_str().unwrap(); + let second = lines["entities"][1]["handle"].as_str().unwrap(); + let intersections = app + .control_request(json!({"op":"query","intersections":[first,second]})) + .0; + assert_eq!(intersections["count"], 1, "{intersections}"); + assert_eq!( + intersections["intersections"][0]["point"], + json!([0.0, 0.0]) + ); + + let nearest = app + .control_request(json!({"op":"query","near":[20.0,0.0],"limit":1})) + .0; + assert_eq!(nearest["entities"][0]["type"], "Circle"); + assert_eq!(nearest["entities"][0]["distance"], 2.0); + let circle = nearest["entities"][0]["handle"] + .as_str() + .unwrap() + .to_owned(); + + let contains = app + .control_request(json!({"op":"query","contains_point":[20.0,0.0]})) + .0; + assert_eq!(contains["count"], 1, "{contains}"); + assert_eq!(contains["entities"][0]["handle"], circle); + + let projected = app + .control_request( + json!({"op":"query","handle":circle,"detail":"full","fields":["type"]}), + ) + .0; + assert_eq!(projected["entities"][0].as_object().unwrap().len(), 2); + + let measured = app + .control_request(json!({"op":"measure","handles":[circle]})) + .0; + let curve = &measured["measurements"][0]["curve"]; + assert!((curve["length"].as_f64().unwrap() - std::f64::consts::TAU * 2.0).abs() < 1e-9); + assert!((curve["area"].as_f64().unwrap() - std::f64::consts::PI * 4.0).abs() < 1e-9); + } + #[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/mcp.rs b/src/mcp.rs index ac479b52f..887528b62 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -3,11 +3,11 @@ //! `OpenCADStudio --mcp` speaks MCP over stdio. All drawing work is forwarded //! to the authenticated GUI control bridge; this module contains no geometry. -use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; use serde::Deserialize; -use serde_json::{json, Map, Value}; +use serde_json::{Map, Value, json}; use std::{ - collections::HashMap, + collections::{HashMap, VecDeque}, fs::{File, OpenOptions}, io::{self, BufRead, BufReader, Read, Write}, net::TcpStream, @@ -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 = "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 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 a command manifest. Use ocs_execute batch when several steps are known, and request changed_entities when the resulting geometry is needed. For interactive work, call start and follow state.command.accepts, options and input_example. A 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 OCS and its geometry kernel calculate geometry; use query near, contains_point and intersections for exact relationships. Verify important results with queries and a viewport capture, and save only to an explicit path."; const READ_OPS: &[&str] = &[ "state", "hello", @@ -38,9 +38,14 @@ const READ_OPS: &[&str] = &[ "operation", ]; const EXECUTE_OPS: &[&str] = &[ + "new", "open", "activate", "run", "start", "input", "cancel", "undo", "redo", "select", + "property", "action", "save", "stop", "batch", +]; +const BATCH_STEP_OPS: &[&str] = &[ "new", "open", "activate", "run", "start", "input", "cancel", "undo", "redo", "select", "property", "action", "save", "stop", ]; +const MAX_BATCH_STEPS: usize = 64; #[derive(Clone, Deserialize)] struct Descriptor { @@ -53,6 +58,44 @@ struct GuiClient { descriptor: Descriptor, state: Value, client_id: String, + batches: VecDeque, +} + +struct BatchExecution { + id: String, + request: Value, + steps: Vec, + next: usize, + active: Option, + results: Vec, + changes: Vec, + state: Option, + terminal: Option, +} + +struct McpTask { + id: String, + name: String, + arguments: Value, + created_at: String, + last_updated_at: String, + result: Option, + error: Option, +} + +#[derive(Default)] +struct TaskStore { + tasks: VecDeque, +} + +impl TaskStore { + fn insert(&mut self, task: McpTask) { + self.tasks.push_back(task); + } + + fn get_mut(&mut self, id: &str) -> Option<&mut McpTask> { + self.tasks.iter_mut().find(|task| task.id == id) + } } fn random_id() -> Result { @@ -61,6 +104,31 @@ fn random_id() -> Result { Ok(bytes.iter().map(|byte| format!("{byte:02x}")).collect()) } +fn iso8601_now() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + let days = seconds.div_euclid(86_400); + let day_seconds = seconds.rem_euclid(86_400); + let shifted = days + 719_468; + let era = shifted.div_euclid(146_097); + let day_of_era = shifted - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let mut year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_prime = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_prime + 2) / 5 + 1; + let month = month_prime + if month_prime < 10 { 3 } else { -9 }; + year += i64::from(month <= 2); + let hour = day_seconds / 3_600; + let minute = day_seconds % 3_600 / 60; + let second = day_seconds % 60; + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z") +} + #[cfg(unix)] fn private_descriptor(path: &Path) -> bool { use std::os::unix::fs::MetadataExt; @@ -226,6 +294,7 @@ impl GuiClient { descriptor, state, client_id: random_id()?, + batches: VecDeque::new(), }) } @@ -287,6 +356,206 @@ impl GuiClient { } Ok(response) } + + fn execute_batch(&mut self, request: Value, wait_seconds: f64) -> Result { + let id = required_string(&request, "request_id")?.to_owned(); + let mut batch = if let Some(position) = self.batches.iter().position(|batch| batch.id == id) + { + let batch = self + .batches + .remove(position) + .expect("batch position exists"); + if batch.request != request { + self.batches.push_back(batch); + return Err("request_id was already used for a different batch".into()); + } + batch + } else { + let steps = request["steps"] + .as_array() + .cloned() + .ok_or_else(|| "batch requires a steps array".to_string())?; + BatchExecution { + id, + request, + steps, + next: 0, + active: None, + results: Vec::new(), + changes: Vec::new(), + state: None, + terminal: None, + } + }; + + if let Some(result) = batch.terminal.clone() { + self.batches.push_back(batch); + return Ok(result); + } + + let deadline = Instant::now() + Duration::from_secs_f64(wait_seconds.clamp(0.0, 60.0)); + let mut attempted = false; + loop { + if batch.next == batch.steps.len() { + let waiting = batch + .state + .as_ref() + .is_some_and(|state| !state["command"].is_null()); + let result = batch_result( + &batch, + if waiting { + "waiting_input" + } else { + "completed" + }, + true, + ); + batch.terminal = Some(result.clone()); + self.batches.push_back(batch); + trim_batches(&mut self.batches); + return Ok(result); + } + if attempted && Instant::now() >= deadline { + let result = batch_result(&batch, "running", true); + self.batches.push_back(batch); + trim_batches(&mut self.batches); + return Ok(result); + } + attempted = true; + + let step_id = batch + .active + .clone() + .unwrap_or_else(|| batch_step_id(&batch.id, batch.next)); + let response = if batch.active.is_some() { + self.request( + json!({"op":"operation","request_id":step_id}), + deadline + .saturating_duration_since(Instant::now()) + .as_secs_f64(), + ) + } else { + let mut step = batch.steps[batch.next] + .as_object() + .cloned() + .ok_or_else(|| format!("batch step {} must be an object", batch.next))?; + for key in [ + "revision", + "geometry_revision", + "camera_revision", + "selection", + "client_id", + ] { + step.remove(key); + } + step.insert("request_id".into(), Value::String(step_id.clone())); + self.request( + Value::Object(step), + deadline + .saturating_duration_since(Instant::now()) + .as_secs_f64(), + ) + }; + let response = match response { + Ok(response) => response, + Err(error) => { + batch.active = Some(step_id); + self.batches.push_back(batch); + trim_batches(&mut self.batches); + return Err(error); + } + }; + + if matches!(response["status"].as_str(), Some("accepted" | "running")) { + batch.active = Some(step_id); + let result = batch_result(&batch, "running", true); + self.batches.push_back(batch); + trim_batches(&mut self.batches); + return Ok(result); + } + + batch.active = None; + if let Some(state) = response.get("state") { + batch.state = Some(state.clone()); + } + if let Some(changes) = response["changes"].as_array() { + batch + .changes + .extend(changes.iter().cloned().map(|mut change| { + if let Some(object) = change.as_object_mut() { + object.insert("step".into(), Value::from(batch.next)); + } + change + })); + } + let mut compact = response.clone(); + if let Some(object) = compact.as_object_mut() { + object.remove("state"); + object.remove("changes"); + object.insert("step".into(), Value::from(batch.next)); + object.insert("op".into(), batch.steps[batch.next]["op"].clone()); + } + batch.results.push(compact); + batch.next += 1; + + if response["ok"].as_bool() == Some(false) + || matches!(response["status"].as_str(), Some("failed" | "cancelled")) + { + let result = batch_result( + &batch, + response["status"].as_str().unwrap_or("failed"), + false, + ); + batch.terminal = Some(result.clone()); + self.batches.push_back(batch); + trim_batches(&mut self.batches); + return Ok(result); + } + if response["status"] == "waiting_input" + && batch + .steps + .get(batch.next) + .and_then(|step| step["op"].as_str()) + .is_none_or(|op| !matches!(op, "input" | "cancel")) + { + let result = batch_result(&batch, "waiting_input", true); + batch.terminal = Some(result.clone()); + self.batches.push_back(batch); + trim_batches(&mut self.batches); + return Ok(result); + } + } + } +} + +fn batch_step_id(id: &str, step: usize) -> String { + let hash = id + .as_bytes() + .iter() + .fold(0xcbf29ce484222325u64, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3) + }); + format!("batch-{hash:016x}-{step}") +} + +fn batch_result(batch: &BatchExecution, status: &str, ok: bool) -> Value { + json!({ + "ok":ok, + "status":status, + "request_id":batch.id, + "completed_steps":batch.next, + "total_steps":batch.steps.len(), + "next_step":(batch.next < batch.steps.len()).then_some(batch.next), + "results":batch.results, + "changes":batch.changes, + "state":batch.state + }) +} + +fn trim_batches(batches: &mut VecDeque) { + while batches.len() > 64 { + batches.pop_front(); + } } fn client<'a>( @@ -313,29 +582,59 @@ fn validate_execute_request(request: &Value, op: &str) -> Result<(), String> { )) }; 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 { + return Err(format!( + "batch steps must contain 1 to {MAX_BATCH_STEPS} operations" + )); + } + 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}"))?; + } + 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"}"#, - ), + "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()) => { + 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("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)) => + if request["point"].as_array().is_some_and(|values| { + values.len() == 3 && values.iter().all(Value::is_number) + }) => { Ok(()) } @@ -344,10 +643,12 @@ fn validate_execute_request(request: &Value, op: &str) -> Result<(), String> { 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)) => + 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(()) } @@ -359,10 +660,7 @@ fn validate_execute_request(request: &Value, op: &str) -> Result<(), String> { 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]}"#, - ), + None => missing("kind", r#"{"op":"input","kind":"point","point":[0,0,0]}"#), }, "property" if request["field"].as_str().is_none_or(str::is_empty) @@ -378,15 +676,75 @@ fn validate_execute_request(request: &Value, op: &str) -> Result<(), String> { Some(name) => Err(format!( "Unknown action {name}. Call ocs_read commands to list actions" )), - None => missing( - "name", - r#"{"op":"action","name":"zoom_extents"}"#, - ), + None => missing("name", r#"{"op":"action","name":"zoom_extents"}"#), }, _ => Ok(()), } } +fn compact_state(state: &Value) -> Value { + let mut compact = Map::new(); + for key in [ + "session_id", + "document_id", + "revision", + "geometry_revision", + "camera_revision", + "selection", + "command", + "modal", + "event_cursor", + "operation", + ] { + if let Some(value) = state.get(key) { + compact.insert(key.into(), value.clone()); + } + } + Value::Object(compact) +} + +fn response_handles(response: &Value) -> Vec { + let mut handles = Vec::new(); + if let Some(changes) = response["changes"].as_array() { + for handle in changes + .iter() + .filter_map(|change| change["handle"].as_str()) + { + if !handles.iter().any(|value| value == handle) { + handles.push(handle.to_owned()); + } + } + } + handles +} + +fn shape_execute_response( + mut response: Value, + detail: &str, + gui: &mut GuiClient, +) -> Result { + if detail == "changed_entities" { + let handles = response_handles(&response); + if !handles.is_empty() { + let entities = gui.request( + json!({"op":"query","handles":handles,"detail":"geometry","limit":MAX_BATCH_STEPS * 100}), + 30.0, + )?; + if let Some(object) = response.as_object_mut() { + object.insert("changed_entities".into(), entities["entities"].clone()); + } + } + } + if detail != "full" { + if let Some(state) = response.get("state").cloned() { + if let Some(object) = response.as_object_mut() { + object.insert("state".into(), compact_state(&state)); + } + } + } + Ok(response) +} + fn call_tool( name: &str, arguments: &Value, @@ -427,13 +785,22 @@ fn call_tool( } validate_execute_request(&request, op)?; let wait = arguments["wait_seconds"].as_f64().unwrap_or(30.0); - client(clients, session_id)?.request(request, wait) + let detail = arguments["response_detail"].as_str().unwrap_or("compact"); + let gui = client(clients, session_id)?; + let response = if op == "batch" { + gui.execute_batch(request, wait)? + } else { + gui.request(request, wait)? + }; + shape_execute_response(response, detail, gui) } "ocs_capture" => { let session_id = required_string(arguments, "session_id")?; let path = std::env::temp_dir().join(format!("ocs-capture-{}.png", random_id()?)); + let scope = arguments["scope"].as_str().unwrap_or("viewport"); + let max_dimension = arguments["max_dimension"].as_u64().unwrap_or(1600); let result = client(clients, session_id)? - .request(json!({"op":"capture","path":path.to_string_lossy()}), 30.0)?; + .request(json!({"op":"capture","path":path.to_string_lossy(),"scope":scope,"max_dimension":max_dimension}), 30.0)?; if result["ok"].as_bool() != Some(true) || result["status"].as_str() != Some("completed") { @@ -447,6 +814,32 @@ fn call_tool( } } +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}, + "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"}]}, + "name":{"type":"string","enum":crate::app::automation_action_names()} + }, + "required":["op"], + "additionalProperties":false + }) +} + fn execute_request_schema() -> Value { let handle = json!({ "type":"string", @@ -480,7 +873,8 @@ fn execute_request_schema() -> Value { "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."} + "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, @@ -506,7 +900,8 @@ fn execute_request_schema() -> Value { {"properties":{"op":{"const":"property"}},"required":["field","value"]}, {"properties":{"op":{"const":"action"}},"required":["name"]}, {"properties":{"op":{"const":"save"}}}, - {"properties":{"op":{"const":"stop"}}} + {"properties":{"op":{"const":"stop"}}}, + {"properties":{"op":{"const":"batch"}},"required":["steps"]} ] }) } @@ -548,22 +943,22 @@ fn tool_definitions() -> Value { }, { "name":"ocs_read", - "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}, + "description":"Read state, command manifests, entities, layers, properties, kernel measurements and spatial relationships, 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.","properties":{"name":{"type":"string","description":"Command name for detailed commands help."},"search":{"type":"string","description":"Case-insensitive command-name search."},"document_id":{"type":"integer","minimum":0},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity handles for query or measure."},"type":{"type":"string","description":"Entity type filter for query."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"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,"maximum":10000}},"additionalProperties":false}},"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 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}, + "description":"Execute semantic OCS actions. Use current state fields and a unique request_id. Use run for one complete command, batch to remove round trips, 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":"Total time to wait for completion before returning."},"response_detail":{"type":"string","enum":["compact","changed_entities","full"],"default":"compact","description":"compact returns only state needed for the next edit; changed_entities also returns current geometry for changed handles; full preserves the complete editor state."}},"required":["session_id","request"],"additionalProperties":false}, "outputSchema":execute_output_schema(), "annotations":{"title":"Execute OCS action","readOnlyHint":false,"destructiveHint":true,"idempotentHint":true,"openWorldHint":false} }, { "name":"ocs_capture", - "description":"Capture the actual current OpenCADStudio window as PNG for visual verification.", - "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."}},"required":["session_id"],"additionalProperties":false}, + "description":"Capture the actual current OCS drawing viewport or window as a bounded PNG for visual verification.", + "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"scope":{"type":"string","enum":["viewport","window"],"default":"viewport","description":"Capture only the drawing viewport by default, or the complete application window."},"max_dimension":{"type":"integer","minimum":256,"maximum":4096,"default":1600,"description":"Resize the longest image edge to at most this many pixels."}},"required":["session_id"],"additionalProperties":false}, "annotations":{"title":"Capture OCS window","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} } ]) @@ -607,6 +1002,56 @@ fn modern_request(params: &Value) -> bool { == Some(MODERN_PROTOCOL_VERSION) } +fn supports_tasks(params: &Value) -> bool { + params["_meta"]["io.modelcontextprotocol/clientCapabilities"]["extensions"] + ["io.modelcontextprotocol/tasks"] + .is_object() +} + +fn task_value(task: &McpTask, status: &str) -> Value { + let mut value = json!({ + "resultType":"complete", + "taskId":task.id, + "status":status, + "createdAt":task.created_at, + "lastUpdatedAt":task.last_updated_at, + "ttlMs":3_600_000, + "pollIntervalMs":250 + }); + if let Some(result) = &task.result { + value["result"] = result.clone(); + } + if let Some(error) = &task.error { + value["error"] = error.clone(); + } + value +} + +fn poll_task(task: &mut McpTask, clients: &mut HashMap) -> Value { + if task.result.is_some() { + return task_value(task, "completed"); + } + if task.error.is_some() { + return task_value(task, "failed"); + } + task.last_updated_at = iso8601_now(); + let mut arguments = task.arguments.clone(); + arguments["wait_seconds"] = Value::from(0); + match call_tool(&task.name, &arguments, clients) { + Ok(value) if matches!(value["status"].as_str(), Some("accepted" | "running")) => { + task_value(task, "working") + } + Ok(value) => { + task.result = Some(tool_result(value)); + task_value(task, "completed") + } + Err(error) => { + task.error = Some(json!({"code":-32000,"message":error})); + task_value(task, "failed") + } + } +} + fn protocol_result(mut result: Value, modern: bool, cacheable: bool) -> Value { if modern { let object = result @@ -643,7 +1088,11 @@ fn unsupported_protocol(id: Value, requested: &str) -> Value { }) } -fn handle_message(message: Value, clients: &mut HashMap) -> Option { +fn handle_message( + message: Value, + clients: &mut HashMap, + tasks: &mut TaskStore, +) -> Option { let id = message.get("id").cloned(); let method = message.get("method").and_then(Value::as_str)?; if id.is_none() { @@ -684,7 +1133,7 @@ fn handle_message(message: Value, clients: &mut HashMap) -> O protocol_result( json!({ "supportedVersions":[MODERN_PROTOCOL_VERSION,PROTOCOL_VERSION], - "capabilities":{"tools":{}}, + "capabilities":{"tools":{},"extensions":{"io.modelcontextprotocol/tasks":{}}}, "instructions":INSTRUCTIONS }), true, @@ -704,11 +1153,72 @@ fn handle_message(message: Value, clients: &mut HashMap) -> O .get("arguments") .cloned() .unwrap_or_else(|| json!({})); - let result = call_tool(name, &arguments, clients) - .map(tool_result) - .unwrap_or_else(error_result); + let called = call_tool(name, &arguments, clients); + if modern && supports_tasks(¶ms) { + if let Ok(value) = &called { + if matches!(value["status"].as_str(), Some("accepted" | "running")) { + let task_id = + random_id().unwrap_or_else(|_| format!("task-{}", iso8601_now())); + let now = iso8601_now(); + tasks.insert(McpTask { + id: task_id.clone(), + name: name.to_owned(), + arguments, + created_at: now.clone(), + last_updated_at: now, + result: None, + error: None, + }); + return Some(response( + id, + protocol_result( + json!({ + "resultType":"task", + "taskId":task_id, + "status":"working", + "statusMessage":"OCS operation is running.", + "createdAt":tasks.tasks.back().unwrap().created_at, + "lastUpdatedAt":tasks.tasks.back().unwrap().last_updated_at, + "ttlMs":3_600_000, + "pollIntervalMs":250 + }), + true, + false, + ), + )); + } + } + } + let result = called.map(tool_result).unwrap_or_else(error_result); response(id, protocol_result(result, modern, false)) } + "tasks/get" if modern && supports_tasks(¶ms) => { + let Some(task_id) = params["taskId"].as_str() else { + return Some(rpc_error(id, -32602, "Missing taskId")); + }; + let Some(task) = tasks.get_mut(task_id) else { + return Some(rpc_error(id, -32602, "Unknown or expired taskId")); + }; + response(id, protocol_result(poll_task(task, clients), true, false)) + } + "tasks/update" if modern && supports_tasks(¶ms) => { + let Some(task_id) = params["taskId"].as_str() else { + return Some(rpc_error(id, -32602, "Missing taskId")); + }; + if tasks.get_mut(task_id).is_none() { + return Some(rpc_error(id, -32602, "Unknown or expired taskId")); + } + response(id, protocol_result(json!({}), true, false)) + } + "tasks/cancel" if modern && supports_tasks(¶ms) => { + let Some(task_id) = params["taskId"].as_str() else { + return Some(rpc_error(id, -32602, "Missing taskId")); + }; + if tasks.get_mut(task_id).is_none() { + return Some(rpc_error(id, -32602, "Unknown or expired taskId")); + } + response(id, protocol_result(json!({}), true, false)) + } _ => rpc_error(id, -32601, format!("Method not found: {method}")), }) } @@ -719,10 +1229,11 @@ pub fn run() { let stdout = io::stdout(); let mut output = stdout.lock(); let mut clients = HashMap::new(); + let mut tasks = TaskStore::default(); for line in stdin.lock().lines() { let response = match line { Ok(line) if !line.trim().is_empty() => match serde_json::from_str::(&line) { - Ok(message) => handle_message(message, &mut clients), + Ok(message) => handle_message(message, &mut clients, &mut tasks), Err(error) => Some(rpc_error(Value::Null, -32700, error)), }, Ok(_) => None, @@ -771,6 +1282,18 @@ mod tests { .len(), EXECUTE_OPS.len() ); + assert_eq!( + tools[2]["inputSchema"]["properties"]["request"]["properties"]["steps"]["maxItems"], + MAX_BATCH_STEPS + ); + assert_eq!( + tools[2]["inputSchema"]["properties"]["response_detail"]["default"], + "compact" + ); + assert_eq!( + tools[3]["inputSchema"]["properties"]["scope"]["default"], + "viewport" + ); assert_eq!( tools[2]["inputSchema"]["properties"]["request"]["properties"]["cmd"]["examples"][0], "LINE 0,0 10,10" @@ -786,17 +1309,21 @@ mod tests { let initialized = handle_message( json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25"}}), &mut clients, + &mut TaskStore::default(), ) .unwrap(); assert_eq!(initialized["result"]["protocolVersion"], "2025-11-25"); - assert!(initialized["result"]["instructions"] - .as_str() - .unwrap() - .contains("geometry kernel")); + assert!( + initialized["result"]["instructions"] + .as_str() + .unwrap() + .contains("geometry kernel") + ); let listed = handle_message( json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}), &mut clients, + &mut TaskStore::default(), ) .unwrap(); assert_eq!(listed["result"]["tools"].as_array().unwrap().len(), 4); @@ -810,12 +1337,17 @@ mod tests { let discovered = handle_message( json!({"jsonrpc":"2.0","id":"discover","method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}), &mut clients, + &mut TaskStore::default(), ) .unwrap(); assert_eq!(discovered["result"]["resultType"], "complete"); assert_eq!(discovered["result"]["ttlMs"], CACHE_TTL_MS); assert_eq!(discovered["result"]["cacheScope"], "public"); assert_eq!(discovered["result"]["supportedVersions"][0], "2026-07-28"); + assert!( + discovered["result"]["capabilities"]["extensions"]["io.modelcontextprotocol/tasks"] + .is_object() + ); assert_eq!( discovered["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"], "OpenCADStudio" @@ -824,6 +1356,7 @@ mod tests { let listed = handle_message( json!({"jsonrpc":"2.0","id":"tools","method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}), &mut clients, + &mut TaskStore::default(), ) .unwrap(); assert_eq!(listed["result"]["resultType"], "complete"); @@ -837,6 +1370,7 @@ mod tests { let called = handle_message( json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ocs_read","arguments":{"session_id":"missing","op":"save"}}}), &mut clients, + &mut TaskStore::default(), ) .unwrap(); assert_eq!(called["result"]["isError"], true); @@ -848,6 +1382,7 @@ mod tests { let rejected = handle_message( json!({"jsonrpc":"2.0","id":4,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2099-01-01"}}}), &mut clients, + &mut TaskStore::default(), ) .unwrap(); assert_eq!(rejected["error"]["code"], -32022); @@ -864,13 +1399,16 @@ mod tests { let called = handle_message( json!({"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"ocs_execute","arguments":{"session_id":"missing","request":{"op":"undo"}}}}), &mut clients, + &mut TaskStore::default(), ) .unwrap(); assert_eq!(called["result"]["isError"], true); - assert!(called["result"]["structuredContent"]["error"] - .as_str() - .unwrap() - .contains("request_id")); + assert!( + called["result"]["structuredContent"]["error"] + .as_str() + .unwrap() + .contains("request_id") + ); } #[test] @@ -879,6 +1417,7 @@ mod tests { 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, + &mut TaskStore::default(), ) .unwrap(); assert_eq!(called["result"]["isError"], true); @@ -893,6 +1432,55 @@ mod tests { assert!(error.contains("LINE 0,0 10,10"), "{error}"); } + #[test] + fn validates_batch_steps_and_compacts_state() { + let batch = json!({ + "op":"batch", + "request_id":"draw", + "steps":[{"op":"run","cmd":"LINE 0,0 10,0"},{"op":"run","cmd":"CIRCLE 5,5 2"}] + }); + assert!(validate_execute_request(&batch, "batch").is_ok()); + let invalid = json!({ + "op":"batch", + "request_id":"draw", + "steps":[{"op":"run","request_id":"nested","cmd":"LINE 0,0 10,0"}] + }); + assert!( + validate_execute_request(&invalid, "batch") + .unwrap_err() + .contains("omit request_id") + ); + + let compact = compact_state(&json!({ + "session_id":"s","document_id":3,"revision":4,"geometry_revision":5, + "camera_revision":6,"selection":[],"command":null,"documents":[1,2,3], + "camera":{"distance":100.0},"event_cursor":7 + })); + assert_eq!(compact["revision"], 4); + assert!(compact.get("camera").is_none()); + assert!(compact.get("documents").is_none()); + } + + #[test] + fn task_metadata_uses_the_modern_shape() { + let now = iso8601_now(); + assert_eq!(now.len(), 20); + assert!(now.ends_with('Z')); + let task = McpTask { + id: "task".into(), + name: "ocs_execute".into(), + arguments: json!({}), + created_at: now.clone(), + last_updated_at: now, + result: None, + error: None, + }; + let value = task_value(&task, "working"); + assert_eq!(value["resultType"], "complete"); + assert_eq!(value["status"], "working"); + assert_eq!(value["pollIntervalMs"], 250); + } + #[test] fn gui_failures_are_mcp_errors() { let result = tool_result(json!({