solidcraft --control PORT writes a random per-launch token to a user-only file (<data>/control/PORT.token; 0700/0600 on Unix, icacls on Windows; SOLIDCRAFT_CONTROL_TOKEN overrides) and refuses any connection whose first request lacks it. mcp --connect, the demo and packaging smoke clients send it. MCP execute/batch/open/save/export/screenshot are annotated destructive and open-world, new_design destructive. Docs: control-protocol.md, mcp.md, AGENTS.md, pending release note in releasing.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
159 lines
6.4 KiB
Python
Executable File
159 lines
6.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Build an electronics enclosure in a running SolidCraft over MCP, one command at a time.
|
|
|
|
solidcraft --control 7878 &
|
|
python3 examples/demo/enclosure_mcp.py --port 7878
|
|
|
|
The script is a minimal MCP client: it starts `solidcraft-cli mcp --connect 127.0.0.1:PORT`
|
|
(stdio, JSON-RPC 2.0, one message per line), initializes, then sends one `execute` tool call
|
|
per step of enclosure_steps.json, waiting `pause` seconds after each so the timeline can be
|
|
watched growing. Camera moves (fit, home view, the closing orbit) go over the app's control
|
|
channel, since they change the view, not the design. With --log it writes one JSON line per
|
|
step (time, caption) for captions.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
|
|
class Mcp:
|
|
"""A stdio MCP client for `solidcraft-cli mcp`."""
|
|
|
|
def __init__(self, argv):
|
|
self.p = subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, bufsize=1)
|
|
self.next_id = 0
|
|
|
|
def send(self, method, params=None, notify=False):
|
|
msg = {"jsonrpc": "2.0", "method": method}
|
|
if params is not None:
|
|
msg["params"] = params
|
|
if not notify:
|
|
self.next_id += 1
|
|
msg["id"] = self.next_id
|
|
self.p.stdin.write(json.dumps(msg) + "\n")
|
|
self.p.stdin.flush()
|
|
if notify:
|
|
return None
|
|
while True:
|
|
line = self.p.stdout.readline()
|
|
if not line:
|
|
raise RuntimeError("the MCP server exited")
|
|
r = json.loads(line)
|
|
if r.get("id") == self.next_id:
|
|
if "error" in r:
|
|
raise RuntimeError(r["error"])
|
|
return r["result"]
|
|
|
|
def tool(self, name, arguments):
|
|
r = self.send("tools/call", {"name": name, "arguments": arguments})
|
|
text = "".join(c.get("text", "") for c in r.get("content", []) if c.get("type") == "text")
|
|
if r.get("isError"):
|
|
raise RuntimeError(f"{name}: {text}")
|
|
return text
|
|
|
|
def close(self):
|
|
self.p.stdin.close()
|
|
self.p.wait(timeout=10)
|
|
|
|
|
|
def control_token(port):
|
|
"""The app's control token: SOLIDCRAFT_CONTROL_TOKEN, else the token file the app wrote for
|
|
this port (docs/control-protocol.md)."""
|
|
if os.environ.get("SOLIDCRAFT_CONTROL_TOKEN"):
|
|
return os.environ["SOLIDCRAFT_CONTROL_TOKEN"].strip()
|
|
if os.environ.get("SOLIDCRAFT_CONTROL_DIR"):
|
|
folder = Path(os.environ["SOLIDCRAFT_CONTROL_DIR"])
|
|
elif sys.platform == "win32":
|
|
folder = Path(os.environ["LOCALAPPDATA"]) / "SolidCraft" / "control"
|
|
elif sys.platform == "darwin":
|
|
folder = Path.home() / "Library/Application Support/SolidCraft/control"
|
|
else:
|
|
data = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local/share")
|
|
folder = Path(data) / "solidcraft" / "control"
|
|
return (folder / f"{port}.token").read_text().strip()
|
|
|
|
|
|
def control(port, method, params=None):
|
|
"""One request on the app's control channel (camera moves)."""
|
|
with socket.create_connection(("127.0.0.1", port)) as s:
|
|
msg = {"id": 1, "method": method, "params": params or {}, "token": control_token(port)}
|
|
s.sendall((json.dumps(msg) + "\n").encode())
|
|
buf = b""
|
|
while not buf.endswith(b"\n"):
|
|
chunk = s.recv(1 << 16)
|
|
if not chunk:
|
|
break
|
|
buf += chunk
|
|
return json.loads(buf or b"{}")
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
ap.add_argument("--port", type=int, default=7878, help="the app's --control port")
|
|
ap.add_argument("--cli", default="solidcraft-cli", help="path to solidcraft-cli")
|
|
ap.add_argument("--steps", default=os.path.join(HERE, "enclosure_steps.json"))
|
|
ap.add_argument("--pause", type=float, default=2.5, help="seconds after each step (unless the step says)")
|
|
ap.add_argument("--log", help="write a JSON line per step: {t, caption, command}")
|
|
ap.add_argument("--no-orbit", action="store_true", help="skip the closing orbit")
|
|
a = ap.parse_args()
|
|
|
|
steps = json.load(open(a.steps))["steps"]
|
|
log = open(a.log, "w") if a.log else None
|
|
t0 = time.time()
|
|
|
|
def mark(caption, command=""):
|
|
if log:
|
|
log.write(json.dumps({"t": time.time(), "caption": caption, "command": command}) + "\n")
|
|
log.flush()
|
|
|
|
mcp = Mcp([a.cli, "mcp", "--connect", f"127.0.0.1:{a.port}"])
|
|
init = mcp.send("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "enclosure-demo", "version": "1"}})
|
|
mcp.send("notifications/initialized", notify=True)
|
|
print(f"connected to {init.get('serverInfo', {}).get('name')}", file=sys.stderr)
|
|
|
|
for i, st in enumerate(steps):
|
|
if st.get("caption"):
|
|
mark(st["caption"], st["command"])
|
|
print(f"[{time.time() - t0:5.1f}s] {i + 1:2}/{len(steps)} {st['command']} {json.dumps(st.get('params', {}))}", file=sys.stderr)
|
|
mcp.tool("execute", {"command": st["command"], "params": st.get("params", {})})
|
|
if st.get("view"):
|
|
control(a.port, "ui.view", {"view": st["view"], "animate": True})
|
|
time.sleep(st.get("pause", a.pause))
|
|
|
|
if not a.no_orbit:
|
|
# The home view, a slow orbit (a right drag across the viewport), back home to end on.
|
|
mark("")
|
|
control(a.port, "ui.view", {"view": "home", "animate": True})
|
|
time.sleep(1.5)
|
|
vp = control(a.port, "ui.inspect").get("result", {}).get("viewport") or [250, 126, 1350, 800]
|
|
cx, cy = vp[0] + vp[2] / 2, vp[1] + vp[3] / 2
|
|
mark("Orbit")
|
|
control(a.port, "ui.drag", {"x0": cx - 350, "y0": cy, "x1": cx + 350, "y1": cy + 30, "button": "right", "steps": 240})
|
|
# The app plays the drag one event per frame: wait until the camera stops turning.
|
|
last, still = None, 0
|
|
for _ in range(120):
|
|
time.sleep(0.5)
|
|
yaw = control(a.port, "ui.inspect").get("result", {}).get("camera", {}).get("yaw")
|
|
still = still + 1 if yaw == last else 0
|
|
last = yaw
|
|
if still >= 2:
|
|
break
|
|
# And back to the iso view to end on.
|
|
control(a.port, "ui.view", {"view": "home", "animate": True})
|
|
time.sleep(3)
|
|
mark("")
|
|
mcp.close()
|
|
print(f"done in {time.time() - t0:.1f}s", file=sys.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|