forked from 1b-agent/opencadstudio-upstream
Add client-neutral native MCP control
This commit is contained in:
1 parent
28cc473062
commit
071e224d26
17 files changed
+804
-118
No files matched your search
@@ -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:
|
||||
|
||||
|
||||
Executable
+65
@@ -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
|
||||
@@ -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'
|
||||
@@ -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")
|
||||
@@ -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 +0,0 @@
|
||||
mcp==1.29.1
|
||||
Reference in new issue
Block a user