Ship client-neutral MCP bundles

This commit is contained in:
Hakan Seven committed 2026-09-04 23:58:39 +03:00
1 parent 17e5cda3fb
commit afbf826b0c
30 files changed
+285 -446

No files matched your search

+14 -33
View File
@@ -1,42 +1,23 @@
# OpenCADStudio automation
# OpenCADStudio MCP control
`--serve` remains available for isolated headless scripts. `live.py` connects to the real desktop application through a private per-process descriptor under the user's OCS configuration directory. The GUI owns every mutation: transport threads only validate and enqueue requests.
Every native OpenCADStudio build contains the same MCP server as the editor. `OpenCADStudio --mcp` starts it over stdio, opens the desktop editor when needed, and exposes the live document without Python, a package manager, a sidecar service, or client-specific code.
The live protocol requires protocol version 1, stable session/document IDs, an expected edit revision for mutations, and a unique request ID. Commands report `waiting_input` while interactive work remains. Asynchronous file and renderer work remains `running` until its real callback finishes. A bounded operation cache makes retries with the same payload idempotent; after a restart or an expired operation, clients must read state instead of replaying a mutation.
The server provides four tools:
Operations include document state and activation, paged entity queries, layer/header/property inspection, command discovery, start/run/step/cancel, WCS/UCS/relative points, entity and structure picks, selection, property edits, semantic UI actions, undo/redo, protected save, event polling, and current-window PNG capture. The status bar includes a control switch whose green, yellow, and red states mean ready, busy, and off.
- `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.
The Web build exposes `ocs_control_submit(json)` and `ocs_control_take(ticket)`. They enter the same bounded GUI queue and return the same semantic responses; browser code polls the ticket until a response is present.
Mutations carry a session ID, document ID, expected revision, selection, and idempotent request ID. 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.
`OpenCADStudio --mcp` is the common local entry point for every MCP client. It is built into the application, uses stdio, launches the editor when needed, and has no Python or package-manager dependency. Tool definitions and safety instructions are served by MCP itself, so the drawing behavior is identical in every client.
Release builds also produce one standard MCP Bundle containing the native binaries for supported desktop platforms. The release workflow publishes its `server.json` metadata to the official MCP Registry. Compatible AI hosts can therefore discover and install the same server without an OpenCADStudio-specific adapter.
Run `./docs/automation/install.sh` once for a source build. It installs the release binary in `~/.local/share/ocs-control` and automatically connects installed Codex and Claude Code CLIs. Both registrations point to the same binary. Re-run an individual connection later with:
Registry discovery and permission prompts belong to the AI host. Hosts that implement MCP Registry and MCP Bundle support need no OpenCADStudio-specific setup; other hosts cannot be enabled silently by the application.
The source-tree protocol smoke test uses only the Python standard library:
```sh
~/.local/share/ocs-control/connect.sh codex
~/.local/share/ocs-control/connect.sh claude-code
```
Any other local MCP client uses the same command and argument. `connect.sh config` prints the portable configuration object to paste into clients that do not provide a registration CLI:
```json
{"mcpServers":{"opencadstudio":{"command":"/path/to/OpenCADStudio","args":["--mcp"]}}}
```
Client-specific skills and extension manifests may improve discovery, but contain no drawing or protocol implementation. Adding another AI client therefore requires only registering this command; the OpenCADStudio executable and tools do not change.
The legacy client is unchanged:
```python
from ocs import Ocs
with Ocs(binary="OpenCADStudio") as app:
app.new()
app.run("LINE 0,0 100,0")
```
For direct live diagnostics:
```sh
python3 docs/automation/live.py --launch
python3 docs/automation/live.py '{"op":"state"}'
cargo build
python3 docs/automation/mcp_smoke.py target/debug/OpenCADStudio
```
-65
View File
@@ -1,65 +0,0 @@
#!/bin/sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
SERVER=${OCS_MCP_COMMAND:-"$ROOT/OpenCADStudio"}
NAME=opencadstudio
require_server() {
[ -x "$SERVER" ] || {
printf 'OpenCADStudio MCP executable not found: %s\n' "$SERVER" >&2
exit 1
}
}
connect_codex() {
SKILLS=${CODEX_HOME:-"$HOME/.codex"}/skills
SOURCE="$ROOT/integrations/codex/opencadstudio-control"
if [ -f "$SOURCE/SKILL.md" ]; then
mkdir -p "$SKILLS/opencadstudio-control/agents"
cp "$SOURCE/SKILL.md" "$SKILLS/opencadstudio-control/SKILL.md"
cp "$SOURCE/agents/openai.yaml" "$SKILLS/opencadstudio-control/agents/openai.yaml"
fi
codex mcp remove "$NAME" >/dev/null 2>&1 || true
codex mcp add "$NAME" -- "$SERVER" --mcp
printf 'Connected Codex to OpenCADStudio.\n'
}
connect_claude_code() {
claude mcp remove "$NAME" >/dev/null 2>&1 || true
claude mcp add --transport stdio --scope user "$NAME" -- "$SERVER" --mcp
printf 'Connected Claude Code to OpenCADStudio.\n'
}
print_config() {
ESCAPED=$(printf '%s' "$SERVER" | sed 's/\\/\\\\/g; s/"/\\"/g')
printf '{"mcpServers":{"%s":{"command":"%s","args":["--mcp"]}}}\n' "$NAME" "$ESCAPED"
}
require_server
case ${1:-all} in
all)
FOUND=false
if command -v codex >/dev/null 2>&1; then connect_codex; FOUND=true; fi
if command -v claude >/dev/null 2>&1; then connect_claude_code; FOUND=true; fi
if [ "$FOUND" = false ]; then
printf 'No supported AI CLI was found. Use this configuration in any MCP client:\n'
print_config
fi
;;
codex)
command -v codex >/dev/null 2>&1 || { printf 'Codex CLI is not installed.\n' >&2; exit 1; }
connect_codex
;;
claude-code|claude)
command -v claude >/dev/null 2>&1 || { printf 'Claude Code is not installed.\n' >&2; exit 1; }
connect_claude_code
;;
config)
print_config
;;
*)
printf 'Usage: %s [all|codex|claude-code|config]\n' "$0" >&2
exit 2
;;
esac
-19
View File
@@ -1,19 +0,0 @@
#!/bin/sh
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)
DEST=${XDG_DATA_HOME:-"$HOME/.local/share"}/ocs-control
mkdir -p "$DEST"
cargo build --manifest-path "$ROOT/Cargo.toml" --release
cp "$ROOT/target/release/OpenCADStudio" "$DEST/OpenCADStudio.new"
chmod 755 "$DEST/OpenCADStudio.new"
mv -f "$DEST/OpenCADStudio.new" "$DEST/OpenCADStudio"
cp "$ROOT/docs/automation/live.py" "$DEST/live.py"
cp "$ROOT/docs/automation/connect.sh" "$DEST/connect.sh"
chmod 755 "$DEST/connect.sh"
mkdir -p "$DEST/integrations/codex/opencadstudio-control/agents"
cp "$ROOT/integrations/codex/opencadstudio-control/SKILL.md" "$DEST/integrations/codex/opencadstudio-control/SKILL.md"
cp "$ROOT/integrations/codex/opencadstudio-control/agents/openai.yaml" "$DEST/integrations/codex/opencadstudio-control/agents/openai.yaml"
rm -f "$DEST/mcp_server.py"
rm -rf "$DEST/venv"
"$DEST/connect.sh" all
printf 'Installed the client-neutral OpenCADStudio MCP runtime.\n'
-130
View File
@@ -1,130 +0,0 @@
"""Persistent local GUI client; no CAD implementation lives in this adapter."""
from __future__ import annotations
import json
import os
from pathlib import Path
import socket
import subprocess
import sys
import time
import uuid
from typing import Any
READ_OPS = {"state", "hello", "query", "entities", "layers", "header", "properties", "measure", "history", "commands", "events", "operation"}
def discovery_dir() -> Path:
if sys.platform == "win32":
base = Path(os.environ["APPDATA"])
elif sys.platform == "darwin":
base = Path.home() / "Library/Application Support"
else:
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
return base / "OpenCADStudio/automation"
def exchange(descriptor: dict, request: dict, timeout: float = 15) -> dict:
payload = {**request, "token": descriptor["token"], "session_id": descriptor["session_id"], "protocol": 1}
wire = (json.dumps(payload, ensure_ascii=False) + "\n").encode()
if len(wire) > 1_048_576:
raise ValueError("Request exceeds 1 MiB")
with socket.create_connection(("127.0.0.1", descriptor["port"]), timeout=timeout) as sock:
sock.sendall(wire)
with sock.makefile("rb") as stream:
line = stream.readline(16 * 1024 * 1024 + 1)
if not line or len(line) > 16 * 1024 * 1024:
raise ConnectionError("No valid OCS response; query request_id before retrying a mutation")
return json.loads(line)
def sessions() -> list[dict]:
found = []
for path in sorted(discovery_dir().glob("*.json")):
descriptor = None
try:
if os.name == "posix" and (path.stat().st_uid != os.getuid() or path.stat().st_mode & 0o077):
continue
descriptor = json.loads(path.read_text())
state = exchange(descriptor, {"op": "hello"}, timeout=1)
if state.get("ok") and state.get("session_id") == descriptor["session_id"]:
found.append({"descriptor": descriptor, "state": state})
except (OSError, ValueError, KeyError):
# A crashed process cannot remove its descriptor. Only reap a file
# whose recorded PID is certainly gone; a busy live session keeps
# its discovery record for the next call.
try:
os.kill(int(descriptor["pid"]), 0)
except (ProcessLookupError, TypeError, KeyError):
try:
path.unlink()
except OSError:
pass
continue
return found
class LiveOcs:
def __init__(self, session_id: str | None = None):
available = sessions()
if session_id:
available = [s for s in available if s["state"]["session_id"] == session_id]
if len(available) != 1:
raise ValueError("Choose session_id from sessions; found " + str(len(available)))
self.descriptor = available[0]["descriptor"]
self.state = available[0]["state"]
self.client_id = uuid.uuid4().hex
def request(self, request: dict, wait_seconds: float = 30) -> dict:
req = dict(request)
if req.get("op") not in READ_OPS:
req.setdefault("request_id", uuid.uuid4().hex)
req.setdefault("client_id", self.client_id)
req.setdefault("document_id", self.state["document_id"])
req.setdefault("revision", self.state["revision"])
if req.get("op") in {"input", "property", "run", "action", "save", "undo", "redo"}:
req.setdefault("selection", self.state.get("selection", []))
response = exchange(self.descriptor, req)
deadline = time.monotonic() + min(max(wait_seconds, 0), 60)
while response.get("status") in ("accepted", "running") and time.monotonic() < deadline:
time.sleep(0.05)
response = exchange(self.descriptor, {"op": "operation", "request_id": req["request_id"]})
if "state" in response:
self.state = response["state"]
elif req.get("op") in ("hello", "state") and response.get("ok"):
self.state = response
return response
def launch(binary: str | None = None) -> list[dict]:
existing = sessions()
if existing:
return existing
executable = binary or os.environ.get("OCS_BINARY", str(Path.home() / ".local/share/ocs-control/OpenCADStudio"))
log_dir = Path.home() / ".local/share/ocs-control"
log_dir.mkdir(parents=True, exist_ok=True)
with (log_dir / "gui.log").open("ab") as log:
proc = subprocess.Popen([executable, "--new-instance"], stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True)
deadline = time.monotonic() + 30
while time.monotonic() < deadline:
if proc.poll() is not None:
raise RuntimeError(f"OCS exited ({proc.returncode}); inspect {log_dir / 'gui.log'}")
available = sessions()
if available:
return available
time.sleep(0.2)
raise TimeoutError("OCS is still starting; call sessions again")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("request", nargs="?", help="JSON request; omit to list sessions")
parser.add_argument("--session")
parser.add_argument("--launch", action="store_true")
args = parser.parse_args()
if args.launch:
launch()
if args.request:
print(json.dumps(LiveOcs(args.session).request(json.loads(args.request)), ensure_ascii=False))
else:
print(json.dumps([s["state"] for s in sessions()], ensure_ascii=False))
-153
View File
@@ -1,153 +0,0 @@
"""Thin Python client for the Open CAD Studio headless automation server.
Launches `OpenCADStudio --serve` and talks to it over a line-based JSON protocol
(one request object per line on stdin, one response per line on stdout). There
is nothing to compile or maintain on the Python side — every method is one JSON
message; the real work is Open CAD Studio's own command system.
from ocs import Ocs
with Ocs(binary="OpenCADStudio") as ocs:
ocs.open("plan.dwg")
ocs.run("LAYER Walls")
print(ocs.entities()) # {"total": 42, "by_type": {...}}
ocs.save("plan_out.dwg")
Each call returns the parsed response dict and raises `OcsError` on `ok: false`.
"""
from __future__ import annotations
import json
import socket
import subprocess
from typing import Any, Optional
class OcsError(RuntimeError):
"""Raised when the server replies with `{"ok": false, ...}`."""
class Ocs:
"""Connect by spawning the server (default) or over a TCP socket.
- `Ocs()` spawns `OpenCADStudio --serve` and talks over stdin/stdout.
- `Ocs(port=4242)` connects to a server started with `--serve --port 4242`.
"""
def __init__(
self,
binary: str = "OpenCADStudio",
port: Optional[int] = None,
host: str = "127.0.0.1",
) -> None:
self.proc: Optional[subprocess.Popen] = None
self.sock: Optional[socket.socket] = None
if port is not None:
self.sock = socket.create_connection((host, port))
io = self.sock.makefile("rw")
self._r, self._w = io, io
else:
self.proc = subprocess.Popen(
[binary, "--serve"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
text=True,
bufsize=1,
)
self._r, self._w = self.proc.stdout, self.proc.stdin
self._read() # the {"ready": true} greeting
# ── protocol ────────────────────────────────────────────────────────────
def _read(self) -> dict[str, Any]:
line = self._r.readline()
if not line:
raise OcsError("server closed the connection")
return json.loads(line)
def _send(self, **req: Any) -> dict[str, Any]:
self._w.write(json.dumps(req) + "\n")
self._w.flush()
resp = self._read()
if not resp.get("ok", False):
raise OcsError(resp.get("error", "unknown error"))
return resp
# ── operations ──────────────────────────────────────────────────────────
def new(self) -> dict[str, Any]:
"""Start an empty document."""
return self._send(op="new")
def open(self, path: str) -> dict[str, Any]:
"""Load a DWG/DXF drawing."""
return self._send(op="open", path=path)
def run(self, cmd: str) -> dict[str, Any]:
"""Run a command through Open CAD Studio's command system."""
return self._send(op="run", cmd=cmd)
def entities(self) -> dict[str, Any]:
"""Total entity count and a breakdown by type."""
return self._send(op="entities")
def query(
self,
type: Optional[str] = None,
layer: Optional[str] = None,
limit: Optional[int] = None,
) -> dict[str, Any]:
"""List entities (handle, type, layer, geometry), optionally filtered."""
return self._send(op="query", type=type, layer=layer, limit=limit)
def layers(self) -> dict[str, Any]:
"""List layers (name, color, on/off, frozen, locked) and the current one."""
return self._send(op="layers")
def header(self) -> dict[str, Any]:
"""Read drawing header variables (units, PDMODE/PDSIZE, LTSCALE, …)."""
return self._send(op="header")
def select(
self,
handles: Optional[list[str]] = None,
type: Optional[str] = None,
layer: Optional[str] = None,
clear: bool = False,
) -> dict[str, Any]:
"""Set the selection by handle, type, or layer (a following selection
command like ``run("ERASE")`` then acts on it). `clear=True` deselects."""
return self._send(
op="select", handles=handles, type=type, layer=layer, clear=clear
)
def undo(self) -> dict[str, Any]:
"""Undo the last change."""
return self._send(op="undo")
def redo(self) -> dict[str, Any]:
"""Redo the last undone change."""
return self._send(op="redo")
def save(self, path: Optional[str] = None) -> dict[str, Any]:
"""Write the document (defaults to the opened/last-saved path)."""
return self._send(op="save", path=path)
# ── lifecycle ───────────────────────────────────────────────────────────
def close(self) -> None:
try:
self._w.close()
except Exception:
pass
if self.proc is not None:
try:
self.proc.wait(timeout=5)
except subprocess.TimeoutExpired:
self.proc.kill()
if self.sock is not None:
self.sock.close()
def __enter__(self) -> "Ocs":
return self
def __exit__(self, *_exc: object) -> None:
self.close()