Add client-neutral native MCP control

This commit is contained in:
Hakan Seven committed 2026-09-04 23:27:03 +03:00
1 parent 28cc473062
commit 071e224d26
17 files changed
+804 -118

No files matched your search

+16 -1
View File
@@ -8,7 +8,22 @@ Operations include document state and activation, paged entity queries, layer/he
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.
Run `./docs/automation/install.sh` once. It installs the release binary and pinned MCP environment in `~/.local/share/ocs-control`, registers the `opencadstudio` stdio server in the user's Codex configuration, and leaves source-independent launch paths. The personal `opencadstudio-control` skill tells later Codex sessions how to use it safely.
`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.
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:
```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:
+65
View File
@@ -0,0 +1,65 @@
#!/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
+9 -10
View File
@@ -8,13 +8,12 @@ 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/mcp_server.py" "$DEST/mcp_server.py"
uv venv "$DEST/venv"
uv pip install --python "$DEST/venv/bin/python" -r "$ROOT/docs/automation/requirements-mcp.txt"
SKILLS=${CODEX_HOME:-"$HOME/.codex"}/skills
mkdir -p "$SKILLS/opencadstudio-control/agents"
cp "$ROOT/integrations/codex/opencadstudio-control/SKILL.md" "$SKILLS/opencadstudio-control/SKILL.md"
cp "$ROOT/integrations/codex/opencadstudio-control/agents/openai.yaml" "$SKILLS/opencadstudio-control/agents/openai.yaml"
codex mcp remove opencadstudio >/dev/null 2>&1 || true
codex mcp add opencadstudio -- "$DEST/venv/bin/python" "$DEST/mcp_server.py"
printf 'Installed OpenCADStudio and its persistent Codex MCP connection.\n'
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'
-51
View File
@@ -1,51 +0,0 @@
"""Local stdio MCP adapter for a running OpenCADStudio GUI."""
from pathlib import Path
import tempfile
from typing import Any
from mcp.server.fastmcp import FastMCP, Image
from live import LiveOcs, READ_OPS, launch, sessions
mcp = FastMCP("OpenCADStudio", instructions="Use ocs_sessions, then ocs_read(state/commands) before editing. All changes target the actual GUI document. Preserve session_id, document_id and revisions. waiting_input means the command or dialog still needs input. Poll running operations; never replay a mutation with a new request_id after a timeout. Geometry is handled by OCS and its kernel.")
clients: dict[str, LiveOcs] = {}
def client(session_id: str) -> LiveOcs:
if session_id not in clients:
clients[session_id] = LiveOcs(session_id)
return clients[session_id]
@mcp.tool()
def ocs_sessions(launch_if_none: bool = True) -> list[dict[str, Any]]:
"""List real OCS GUI sessions and documents. Launch installed OCS if none is running."""
available = launch() if launch_if_none else sessions()
return [s["state"] for s in available]
@mcp.tool()
def ocs_read(session_id: str, op: str = "state", parameters: dict[str, Any] | None = None) -> dict[str, Any]:
"""Read state, commands/actions, query (type/layer/offset/limit), entities, layers, header, properties, measure(handles), history, events(after), operation(request_id)."""
if op not in READ_OPS:
raise ValueError("Use ocs_execute for mutations")
return client(session_id).request({**(parameters or {}), "op": op})
@mcp.tool()
def ocs_execute(session_id: str, request: dict[str, Any], wait_seconds: float = 30) -> dict[str, Any]:
"""Execute one semantic action. op: new/open(path)/activate(document_id)/start(cmd)/run(cmd)/input(kind,text or point)/select(handles)/property(field,value)/action(name)/save(path)/undo/redo/cancel. Input kinds: text, token, point(space wcs/ucs/relative), entity or structure(handle,point), selection, enter. Supply document_id and revision from state. request_id is the idempotency key. Accepted/running is not completion. Query operation after a timeout."""
return client(session_id).request(request, wait_seconds)
@mcp.tool()
def ocs_capture(session_id: str) -> Image:
"""Capture the actual current OCS window as PNG for visual verification."""
with tempfile.TemporaryDirectory(prefix="ocs-capture-") as directory:
path = Path(directory) / "window.png"
result = client(session_id).request({"op": "capture", "path": str(path)})
if not result.get("ok") or result.get("status") != "completed":
raise RuntimeError(str(result))
return Image(data=path.read_bytes(), format="png")
if __name__ == "__main__":
mcp.run(transport="stdio")
+42 -14
View File
@@ -1,19 +1,47 @@
"""Protocol smoke test for the bundled MCP adapter."""
import asyncio
"""Protocol smoke test for the native client-neutral MCP endpoint."""
import json
from pathlib import Path
import subprocess
import sys
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
async def main() -> None:
server = Path(__file__).with_name("mcp_server.py")
params = StdioServerParameters(command=sys.executable, args=[str(server)])
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()
names = {tool.name for tool in tools.tools}
assert names == {"ocs_sessions", "ocs_read", "ocs_execute", "ocs_capture"}, names
def main() -> None:
server = Path(sys.argv[1] if len(sys.argv) > 1 else "target/debug/OpenCADStudio").resolve()
process = subprocess.Popen(
[str(server), "--mcp"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
assert process.stdin and process.stdout
def request(payload: dict) -> dict:
process.stdin.write(json.dumps(payload) + "\n")
process.stdin.flush()
return json.loads(process.stdout.readline())
initialized = request({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {"protocolVersion": "2025-11-25", "capabilities": {}, "clientInfo": {"name": "smoke", "version": "1"}},
})
assert initialized["result"]["serverInfo"]["name"] == "OpenCADStudio"
assert initialized["result"]["instructions"]
process.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n")
process.stdin.flush()
tools = request({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}})
names = {tool["name"] for tool in tools["result"]["tools"]}
assert names == {"ocs_sessions", "ocs_read", "ocs_execute", "ocs_capture"}, names
sessions = request({
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {"name": "ocs_sessions", "arguments": {"launch_if_none": False}},
})
assert not sessions["result"].get("isError"), sessions
process.stdin.close()
assert process.wait(timeout=5) == 0
if __name__ == "__main__":
asyncio.run(main())
main()
-1
View File
@@ -1 +0,0 @@
mcp==1.29.1