MCP demo: examples/demo/enclosure_mcp.py builds an electronics enclosure in the running app over MCP, one execute call per step of enclosure_steps.json (rounded box, shell, USB port cut, four screw bosses, then width 80 -> 100 mm rebuilt), ending on an orbit; record.sh and captions.py record it with ffmpeg and burn in captions

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-09 13:00:14 +00:00
1 parent 517b7a911c
commit 025380277a
5 files changed
+477

No files matched your search

+9
View File
@@ -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
+39
View File
@@ -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")
+139
View File
@@ -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()
+265
View File
@@ -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
}
]
}
+25
View File
@@ -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.*