forked from 1b-agent/opencadstudio-upstream
Accelerate native MCP workflows
This commit is contained in:
1 parent
0e4f755e6f
commit
cb88f2ded6
6 files changed
+1291
-98
No files matched your search
@@ -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
|
||||
```
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user