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",