diff --git a/docs/mcp.md b/docs/mcp.md index 118d7e90..083bc523 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -72,6 +72,15 @@ result with `isError: true` and the engine's message, which the model can read a `batch` rollback undoes as many steps as the batch added to the undo history; batches that themselves call `edit.undo`, `file.new` or `doc.open` are not rolled back exactly. +## Demo: an enclosure built over MCP + +`examples/demo/enclosure_mcp.py` is a small MCP client. It drives a running app (`solvecraft +--control PORT`) through `solvecraft-cli mcp --connect` and builds an electronics enclosure one +`execute` call at a time (`enclosure_steps.json`): a rounded box, shelled open at the top, a +USB port cut through a side wall, four screw bosses, and then a change of the `width` +parameter that the timeline rebuilds. `examples/demo/record.sh` records the run with ffmpeg, +with each step's caption burnt in. + ## A typical session ```text diff --git a/examples/demo/captions.py b/examples/demo/captions.py new file mode 100755 index 00000000..f3f17080 --- /dev/null +++ b/examples/demo/captions.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""captions.py LOG START OUT.ass: the step log of enclosure_mcp.py --log as ASS captions +(top centre: the step, under it the MCP call), timed from START (the recording's epoch).""" + +import json +import sys + +log, start, out = sys.argv[1], float(sys.argv[2]), sys.argv[3] +marks = [json.loads(line) for line in open(log) if line.strip()] +head = """[Script Info] +ScriptType: v4.00+ +PlayResX: 1920 +PlayResY: 1080 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Step,DejaVu Sans,34,&H00FFFFFF,&H00FFFFFF,&H00000000,&H80000000,1,0,0,0,100,100,0,0,3,10,0,8,0,0,150,1 +Style: Call,DejaVu Sans Mono,22,&H00A0E0FF,&H00FFFFFF,&H00000000,&H80000000,0,0,0,0,100,100,0,0,3,8,0,8,0,0,206,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +""" + + +def ts(t): + t = max(t, 0.0) + return f"{int(t // 3600)}:{int(t % 3600 // 60):02}:{t % 60:05.2f}" + + +lines = [] +for i, m in enumerate(marks): + if not m["caption"]: + continue + a = m["t"] - start + b = (marks[i + 1]["t"] - start) if i + 1 < len(marks) else a + 3 + lines.append(f"Dialogue: 0,{ts(a)},{ts(b)},Step,,0,0,0,,{m['caption']}") + if m.get("command"): + lines.append(f"Dialogue: 0,{ts(a)},{ts(b)},Call,,0,0,0,,MCP tools/call execute {m['command']}") +open(out, "w").write(head + "\n".join(lines) + "\n") diff --git a/examples/demo/enclosure_mcp.py b/examples/demo/enclosure_mcp.py new file mode 100755 index 00000000..a9ca3cb3 --- /dev/null +++ b/examples/demo/enclosure_mcp.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Build an electronics enclosure in a running SolveCraft over MCP, one command at a time. + + solvecraft --control 7878 & + python3 examples/demo/enclosure_mcp.py --port 7878 + +The script is a minimal MCP client: it starts `solvecraft-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 + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +class Mcp: + """A stdio MCP client for `solvecraft-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(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: + s.sendall((json.dumps({"id": 1, "method": method, "params": params or {}}) + "\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="solvecraft-cli", help="path to solvecraft-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() diff --git a/examples/demo/enclosure_steps.json b/examples/demo/enclosure_steps.json new file mode 100644 index 00000000..ad083f85 --- /dev/null +++ b/examples/demo/enclosure_steps.json @@ -0,0 +1,265 @@ +{ + "description": "An electronics enclosure built over MCP, one command at a time (examples/demo/enclosure_mcp.py). Each step: an MCP `execute` call; `caption` is shown in the video, `pause` (s) is the wait after it, `view` then animates the app's camera to that view. A lip on the rim is left out until issue #29 (Lip on a rounded rim) is fixed.", + "steps": [ + { + "caption": "User parameters: width, depth, height, wall", + "command": "parameters.change", + "params": { + "name": "width", + "expression": "80 mm", + "comment": "outside, X" + } + }, + { + "caption": "", + "command": "parameters.change", + "params": { + "name": "depth", + "expression": "60 mm", + "comment": "outside, Y" + }, + "pause": 0.6 + }, + { + "caption": "", + "command": "parameters.change", + "params": { + "name": "height", + "expression": "30 mm" + }, + "pause": 0.6 + }, + { + "caption": "", + "command": "parameters.change", + "params": { + "name": "wall", + "expression": "2 mm" + }, + "pause": 0.6 + }, + { + "caption": "Sketch on XY", + "command": "sketch.create", + "params": { + "plane": "XY", + "name": "Outline" + } + }, + { + "caption": "Center rectangle", + "command": "sketch.rectangle.center", + "params": { + "center": [ + 0, + 0 + ], + "corner": [ + 40, + 30 + ] + } + }, + { + "caption": "Centre it on the origin", + "command": "sketch.constraint.coincident", + "params": { + "a": "p5", + "b": "origin" + } + }, + { + "caption": "Dimension: width", + "command": "sketch.dimension", + "params": { + "entities": [ + "l1" + ], + "value": "width" + } + }, + { + "caption": "Dimension: depth", + "command": "sketch.dimension", + "params": { + "entities": [ + "l2" + ], + "value": "depth" + } + }, + { + "caption": "Finish sketch", + "command": "sketch.finish", + "params": {} + }, + { + "caption": "Extrude by height", + "command": "solid.extrude", + "params": { + "distance": "height", + "body_name": "Enclosure" + }, + "view": "home" + }, + { + "caption": "Round the vertical edges (6 mm)", + "command": "solid.fillet", + "params": { + "edges": [ + [ + 40, + 30, + 15 + ], + [ + -40, + 30, + 15 + ], + [ + -40, + -30, + 15 + ], + [ + 40, + -30, + 15 + ] + ], + "radius": 6 + } + }, + { + "caption": "Shell it open at the top (wall)", + "command": "solid.shell", + "params": { + "faces": [ + [ + 0, + 0, + 30 + ] + ], + "thickness": "wall" + } + }, + { + "caption": "Sketch on YZ for the USB port", + "command": "sketch.create", + "params": { + "plane": { + "origin": [ + 0, + 0, + 0 + ], + "x_dir": [ + 0, + 1, + 0 + ], + "y_dir": [ + 0, + 0, + 1 + ] + }, + "name": "USB port" + } + }, + { + "caption": "Port outline", + "command": "sketch.rectangle.center", + "params": { + "center": [ + 0, + 12 + ], + "corner": [ + 6, + 15.5 + ] + } + }, + { + "caption": "Finish sketch", + "command": "sketch.finish", + "params": {} + }, + { + "caption": "Cut through the side wall", + "command": "solid.extrude", + "params": { + "through_all": true, + "operation": "cut" + } + }, + { + "caption": "Screw boss", + "command": "plastic.boss", + "params": { + "position": [ + -30, + -20, + 2 + ], + "diameter": 7, + "height": 12, + "hole_diameter": 2.5 + } + }, + { + "caption": "Screw boss", + "command": "plastic.boss", + "params": { + "position": [ + 30, + -20, + 2 + ], + "diameter": 7, + "height": 12, + "hole_diameter": 2.5 + } + }, + { + "caption": "Screw boss", + "command": "plastic.boss", + "params": { + "position": [ + 30, + 20, + 2 + ], + "diameter": 7, + "height": 12, + "hole_diameter": 2.5 + } + }, + { + "caption": "Screw boss", + "command": "plastic.boss", + "params": { + "position": [ + -30, + 20, + 2 + ], + "diameter": 7, + "height": 12, + "hole_diameter": 2.5 + } + }, + { + "caption": "Change one parameter: width 80 \u2192 100 mm", + "command": "parameters.change", + "params": { + "name": "width", + "expression": "100 mm" + }, + "pause": 4 + } + ] +} diff --git a/examples/demo/record.sh b/examples/demo/record.sh new file mode 100755 index 00000000..0ff8851e --- /dev/null +++ b/examples/demo/record.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# record.sh DISPLAY PORT OUTDIR: record enclosure_mcp.py driving the app on X display DISPLAY +# (1920x1080, the app started with --control PORT) to OUTDIR/enclosure-demo.mp4 (H.264, +# captions burnt in) and OUTDIR/enclosure-demo.gif. Needs ffmpeg with x11grab and libass. +set -e +DISP=${1:-:0}; PORT=${2:-7878}; OUT=${3:-.} +HERE=$(cd "$(dirname "$0")" && pwd) +CLI=${SOLVECRAFT_CLI:-solvecraft-cli} +mkdir -p "$OUT" +# x11grab stamps frames with the wall clock: its "start:" is when the first frame was taken. +ffmpeg -loglevel info -nostats -y -f x11grab -draw_mouse 0 -video_size 1920x1080 -framerate 30 -i "$DISP" \ + -c:v libx264 -preset veryfast -crf 16 -pix_fmt yuv420p "$OUT/raw.mp4" 2> "$OUT/ffmpeg.log" & +FF=$! +sleep 2 +python3 "$HERE/enclosure_mcp.py" --port "$PORT" --cli "$CLI" --log "$OUT/steps.log" +sleep 2 +kill -INT $FF; wait $FF || true +START=$(grep -o "start: [0-9.]*" "$OUT/ffmpeg.log" | head -1 | cut -d" " -f2) +python3 "$HERE/captions.py" "$OUT/steps.log" "$START" "$OUT/captions.ass" +ffmpeg -loglevel error -y -i "$OUT/raw.mp4" -vf "ass=$OUT/captions.ass" \ + -c:v libx264 -preset slow -crf 20 -pix_fmt yuv420p -movflags +faststart "$OUT/enclosure-demo.mp4" +ffmpeg -loglevel error -y -i "$OUT/enclosure-demo.mp4" \ + -vf "fps=8,scale=720:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=64:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=4" \ + "$OUT/enclosure-demo.gif" +ls -la "$OUT"/enclosure-demo.*