Accelerate native MCP workflows

This commit is contained in:
Hakan Seven committed 2026-09-05 19:03:16 +03:00
1 parent 0e4f755e6f
commit cb88f2ded6
6 files changed
+1291 -98

No files matched your search

+42 -3
View File
@@ -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
```
+182
View File
@@ -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()
+6 -1
View File
@@ -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",
+180 -27
View File
@@ -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<acadrust::Handle> {
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>>) -> 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::<Vec<_>>()
});
}
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<std::collections::HashSet<u64>> = 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<Value> = 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,
})
}
+241 -15
View File
@@ -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<String> = 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<Value, String> {
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"}));
+640 -52
View File
@@ -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<BatchExecution>,
}
struct BatchExecution {
id: String,
request: Value,
steps: Vec<Value>,
next: usize,
active: Option<String>,
results: Vec<Value>,
changes: Vec<Value>,
state: Option<Value>,
terminal: Option<Value>,
}
struct McpTask {
id: String,
name: String,
arguments: Value,
created_at: String,
last_updated_at: String,
result: Option<Value>,
error: Option<Value>,
}
#[derive(Default)]
struct TaskStore {
tasks: VecDeque<McpTask>,
}
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<String, String> {
@@ -61,6 +104,31 @@ fn random_id() -> Result<String, String> {
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<Value, String> {
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<BatchExecution>) {
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<String> {
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<Value, String> {
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<String, GuiClient>) -> 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<String, GuiClient>) -> Option<Value> {
fn handle_message(
message: Value,
clients: &mut HashMap<String, GuiClient>,
tasks: &mut TaskStore,
) -> Option<Value> {
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<String, GuiClient>) -> 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<String, GuiClient>) -> 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(&params) {
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(&params) => {
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(&params) => {
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(&params) => {
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::<Value>(&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!({