FreeCAD Headless CAD Workstation
Headless FreeCAD GUI on telep-mainframe with browser preview (noVNC/Selkies) and a
FreeCAD MCP server (HTTP/SSE), so a remote Claude Code session can drive parametric CAD
and the designs appear live in the browser viewport. Exposed on a dedicated Tailscale
node cad (own tailscaled sidecar, not the mainframe’s node, not tailscale serve on the
main node).
URLs (tailnet only)
| What | URL |
|---|---|
| noVNC browser preview (live FreeCAD GUI) | https://cad.taild4189d.ts.net/ |
| MCP endpoint (Streamable HTTP) | https://cad.taild4189d.ts.net:8443/mcp |
| MCP endpoint (SSE, alt) | https://cad.taild4189d.ts.net:8443/sse |
| Browsable exports (clickable listing) | https://cad.taild4189d.ts.net:8080/ |
| Online3DViewer (click-to-view / drag-drop / Open-file 3D: STEP/STL/3MF/glTF) | https://cad.taild4189d.ts.net:8090/ |
Node: cad / cad.taild4189d.ts.net / 100.120.203.1. No login on the noVNC page. Tailnet-only, no LAN exposure.
MCP config for the main Claude Code session
Add to ~/.claude.json (or .mcp.json) on the Mac. Server name FreeCADMCP, 15 tools,
Streamable HTTP transport over the tailnet:
{
"mcpServers": {
"freecad": {
"type": "http",
"url": "https://cad.taild4189d.ts.net:8443/mcp"
}
}
}Or CLI: claude mcp add --transport http freecad https://cad.taild4189d.ts.net:8443/mcp
Tools: create_document, create_object, edit_object, delete_object, execute_code, execute_code_async, get_view, get_objects, get_object, list_documents, reload_document, insert_part_from_library, get_parts_list, get_rpc_status, run_fem_analysis.
execute_code runs arbitrary FreeCAD Python inside the live GUI process — the workhorse.
Architecture
main Claude Code (Mac, tailnet)
│ Streamable HTTP https://cad...:8443/mcp
▼
cad tailnet node (tailscaled-cad.service, userspace, statedir /var/lib/tailscale-cad)
│ tailscale serve :8443 → 127.0.0.2:9876 (MCP) ← .2, corrected 2026-09-05
│ tailscale serve :443 → 127.0.0.1:3080 (noVNC)
│ tailscale serve :8080 → 127.0.0.1:8085 (exports-http autoindex)
│ tailscale serve :8090 → 127.0.0.1:8087 (Online3DViewer)
▼
docker (/home/levander/freecad/docker-compose.yml)
├─ freecad-mcp mcp-proxy → neka-nat freecad-mcp (stdio) ── XML-RPC → freecad:9875
└─ freecad LSIO FreeCAD GUI + Selkies noVNC + neka-nat FreeCADMCP addon
(addon runs the XML-RPC server ON the GUI thread → designs appear live)
- freecad container:
lscr.io/linuxserver/freecad:latestwith the neka-nat/freecad-mcp addon injected into the GUI’sModdir. Acustom-cont-init.dscript writesfreecad_mcp_settings.jsonwithauto_start_rpc=true,remote_enabled=true,allowed_ipsincl. the docker subnets, so the XML-RPC server (:9875) auto-starts inside the GUI on every boot — no toolbar click. - freecad-mcp container:
python:3.12-slim+pip install mcp-proxy freecad-mcp "mcp<2". Runsmcp-proxy … -- freecad-mcp --host=freecad, exposing SSE + Streamable HTTP on:9876. - Host port map (loopback only): noVNC
127.0.0.1:3080→3000,3081→3001; RPC127.0.0.1:9875; MCP127.0.0.1:9876. (noVNC is on 3080/3081 becauseruviewowns 3000/3001.)
Exports
MCP/execute_code writes exports to container /exports = host
/home/levander/freecad/exports/. Verified STL + glTF export of the test part (40×20×5 mm
plate, 6 mm hole): test_plate.stl, test_plate.gltf (+ .bin), plus a live-viewport
test_plate_preview.png. glTF via import Import; Import.export([obj], "/exports/x.gltf");
STL via import Mesh; Mesh.export([obj], "/exports/x.stl"). Use /exports/... paths for
three.js Artifact snapshots.
Export pipeline (2026-08-26)
The exports dir is now a full pipeline: a reusable export helper, a browsable HTTP
listing on the cad node, and an auto-pull to the Mac. A dedicated
on-prem CAD-designer agent drives all of this from the box.
Export helper — 3MF + STL + STEP in one call
Dropped at host /home/levander/freecad/exports/cad_export.py = container /exports/cad_export.py,
so the GUI process can exec/import it. Exports STEP (exact) + STL + 3MF (mesh, mm,
LinearDeflection 0.05 / AngularDeflection 0.5). The snippet the main session / CAD agent runs via
MCP execute_code:
exec(open("/exports/cad_export.py").read())
export_all(obj, "part_name") # → /exports/part_name.{step,stl,3mf}The helper itself:
import Mesh
import MeshPart
import Import
def export_all(obj, name, linear=0.05, angular=0.5):
base = "/exports/" + name
Import.export([obj], base + ".step")
mesh = MeshPart.meshFromShape(Shape=obj.Shape, LinearDeflection=linear, AngularDeflection=angular, Relative=False)
mesh.write(base + ".stl")
mesh.write(base + ".3mf")
return [base + ext for ext in (".step", ".stl", ".3mf")]Verified end-to-end via the MCP: a 30×20×4 mm test box exported STEP+STL+3MF into /exports.
Browse exports at cad:8080 (Task A)
A tiny nginx:alpine container exports-http mounts ./exports:/exports:ro with autoindex on,
binds host 127.0.0.1:8085:80, and is routed through the cad node’s tailscale serve on a
dedicated port :8080 (does NOT collide with noVNC :443/ or MCP :8443/mcp):
| What | URL |
|---|---|
| Browsable exports (clickable listing) | https://cad.taild4189d.ts.net:8080/ |
- Compose service in
docker-compose.yml; configbuild/exports-nginx/default.conf(root /exports; autoindex on;). - Serve added with
tailscale --socket=/run/tailscale-cad/tailscaled.sock serve --bg --https=8080 http://127.0.0.1:8085— persisted in the node state, reboot-safe alongside the/and:8443mounts. - Verified from the Mac: listing loads and
plate.3mfdownloads (HTTP 200, valid 3MF zip).
3D viewer at cad:8090 — Online3DViewer (2026-08-26)
A slick in-browser 3D viewer (orbit / measure / section) for the CAD exports, self-hosted
from kovacsv/Online3DViewer v0.19.0 (the 3dviewer.net
engine). Click any design on the landing page → it opens in the viewer with the model auto-loaded.
STEP is imported client-side via occt-import-js (OpenCASCADE WASM); STL/3MF/glTF/OBJ read
natively.
| What | URL |
|---|---|
| Landing (lists exports, click-to-view, drag-drop, Open-file picker) | https://cad.taild4189d.ts.net:8090/ |
| Viewer, model auto-loaded via URL hash | https://cad.taild4189d.ts.net:8090/o3dv/#model=/exports/plate.step |
- Compose service
o3dvindocker-compose.yml: multi-stage build (build/o3dv/Dockerfile) — node:20 builds the O3DV website (npm run build_website_dev), nginx:alpine serves it. Mounts./exports:/exports:ro, binds host127.0.0.1:8087:80,restart: unless-stopped. - One origin serves everything (no CORS):
/landing,/o3dv/the viewer,/exports/the model files. The landing (build/o3dv/landing.html) is pure JS — it fetches the nginxautoindex_format jsonlisting of/exports/and renders a click-to-view card per model, so new exports appear on reload, no rebuild. Filters to viewable types (step/stp/stl/3mf/gltf/glb/obj/igs/ply/…). - Auto-load syntax: URL hash
#model=<url>(keyword params joined by$; model URLs by,). Cards link to/o3dv/#model=/exports/<name>(absolute, same-origin). - Open local files two ways — drag-drop AND a native file picker — on the landing (plus the
viewer’s own native drop). The landing is a full-page drop zone, and the drop affordance
is also a click-to-browse button (“Open file…”) that fires a hidden
<input type="file" multiple accept=".step,.stp,.stl,.3mf,.gltf,.glb,.bin,.obj,.mtl,…">(Finder/Explorer dialog). Both paths funnel into one functionopenInViewer(files): an overlay opens an iframe of/o3dv/and the localFileobjects are handed to it client-side via its#open_fileinput (input.files = <DataTransfer>+ dispatchchange→ O3DVLoadModelFromFileList). No server upload — a localFilecan’t ride a URL hash, so it goes through the File API. Multi-file models (e.g..obj+.mtl,.gltf+.bin) pass through together. Full toolbar (orbit/measure/section) because it’s the real website, not the liteEmbeddedViewer.- 🔴 GOTCHA: the O3DV website has an anti-embed guard —
if (window.self !== window.top)it blanks the body with “Embedding … in an iframe is not supported.” The Dockerfilesed-patchessource/website/index.js(window.self !== window.top→false); esbuild then dead-code-eliminates the whole guard block. The proper anti-clickjacking control is a header, not the JS buster: nginx sendsX-Frame-Options: SAMEORIGINandContent-Security-Policy: frame-ancestors 'self'(bothalways, server-level so/and/o3dv/inherit) — cross-origin framing is blocked, the same-origin landing→/o3dv/embed still renders. Verified the header does NOT break the same-origin iframe. - 🔴 GOTCHA: the picker’s
changehandler resetsinput.value=''(so the same file can be re-picked), which empties the liveFileList.openInViewermust snapshot withArray.from(files)up front — otherwise the deferrediframe.onloadfeed sees an empty list and the first picked file silently fails to load.
- 🔴 GOTCHA: the O3DV website has an anti-embed guard —
- STEP libs are self-hosted, not CDN. v0.19.0 fetches
occt-import-js,rhino3dm,web-ifc,draco3dfrom jsdelivr; the Dockerfilesed-patchessource/engine/import/importerutils.jsto(new URL('libs/…', document.baseURI)).hrefand downloads the dist (incl.occt-import-js.wasm) into/o3dv/libs/. The OCCT worker is built from a blob with absolutelibs/URLs so the worker resolves the wasm correctly. - Serve added with
tailscale --socket=/run/tailscale-cad/tailscaled.sock serve --bg --https=8090 http://127.0.0.1:8087— persisted in the node state, reboot-safe alongside the other mounts. - Verified end-to-end from the Mac (headless Chromium):
- Click-to-view:
#model=/exports/plate.stepauto-loads, navigator shows “Open CASCADE STEP translator 7.8”, sidebar Vertices 600 / Triangles 588 / Unit mm / 60×40×4 mm — the STEP round-tripped through OCCT WASM and rendered;plate.3mfalso renders. All lib/wasm assets return HTTP 200 (application/wasm). - Drag-drop (landing): handing a local
plate.stepFile to the overlay renders it in the full viewer (same OCCT translator, 600v/588t); a localplate.stlrenders too (1,716v / 572t); re-dropping into an already-open viewer swaps the model. Zero console errors on either path; the click-to-view list stays intact. - File picker (landing): firing the hidden
#filepickerchange with a localplate.step(the exactinput.value=''-cleared first-file case) renders it in the iframe viewer (OCCT, 600v/588t);plate.stlvia the picker renders too (1,716v/572t). Same oneopenInViewerpath as drag-drop. - Headers:
curl -Ion both/and/o3dv/showsX-Frame-Options: SAMEORIGIN+Content-Security-Policy: frame-ancestors 'self'; the same-origin landing→/o3dv/iframe still loads and renders a model with those headers active.
- Click-to-view:
Auto-pull to the Mac ~/cad-exports/ (Task C)
A macOS launchd agent rsyncs the exports to ~/cad-exports/ every ~90 s.
- Plist:
~/Library/LaunchAgents/com.levander.cad-exports-sync.plist(RunAtLoad,StartInterval 90), runs~/cad-exports/.sync.sh. - Script does
rsync -az -e ssh …:/home/levander/freecad/exports/ → ~/cad-exports/, trying LAN192.168.1.123first, tailnet100.115.209.87fallback; quiet + idempotent (BatchMode=yes, excludes its own.sync.sh/.sync.log). - Load:
launchctl load ~/Library/LaunchAgents/com.levander.cad-exports-sync.plist. - Verified:
plate.3mf(and the rest) land in~/cad-exports/.
launchd uses the REAL ssh, not the
_kaku_wrapped_sshshell functionThe
plain ssh failscaveat is about the interactive shell alias. launchd invokes/usr/bin/rsync -e /usr/bin/sshdirectly, so key-based auth to the mainframe works non-interactively — nocommand sshneeded there.
How to use (main session)
create_document("MyPart")execute_code("<FreeCAD Python>")to build geometry.- To make it show/screenshot in noVNC after a raw
create_document, the code must activate the GUI doc + refresh (gotcha below). - Export with
Import.export/Mesh.exportto/exports/..., then pull the file from the host for a three.js viewer.
Restart / operate
How to reach the box —
cadis a sidecar node, not a machine (confirmed 2026-09-05)
cad(100.120.203.1) is not Tailscale-SSH-enabled — only telep-mainframe and telep-router are. But the host is fully manageable remotely anyway. Thecadtailnet identity is a userspacetailscaledsidecar running on telep-mainframe, as are all the other per-service nodes (knowledgebase,drive,chatcut,orcaslicer,bambuddy, …), sossh 100.120.203.1lands on telep-mainframe — the same placessh levander@100.115.209.87gets you, and where the docker stack actually lives. Do not conclude that a FreeCAD fault needs someone on site: a full recovery was done remotely on 2026-09-05.
command ssh levander@192.168.1.123 # LAN; tailnet fallback levander@100.115.209.87
ssh 100.120.203.1 # the `cad` tailnet IP — also lands on telep-mainframe
cd /home/levander/freecad
docker compose ps
docker compose restart # or: down / up -d --build
docker logs freecad ; docker logs freecad-mcp
python3 -c 'import xmlrpc.client as x; print(x.ServerProxy("http://127.0.0.1:9875").ping())' # RPC health
sudo tailscale --socket=/run/tailscale-cad/tailscaled.sock serve status # proxy config
sudo systemctl status tailscaled-cad.serviceReboot-safe: containers restart: unless-stopped, docker + tailscaled-cad.service
enabled at boot, tailscale serve config persisted in the node state.
restart: unless-stoppeddid NOT keepfreecad-mcpalive (2026-09-05)On 2026-09-02 the
freecad-mcpcontainer exited128withfailed to create task for container: failed to create shim task: ttrpc: closedand stayed dead for three days atRestartCount: 0— the restart policy never fired, because the containerd shim failed before a task was ever created and there was no task for Docker to restart. Symptom was HTTP 502 onhttps://cad.taild4189d.ts.net:8443/.docker ps -a(with-a) is the probe;docker start freecad-mcpis the fix. Full write-up: 2026-09-05-freecad-mcp-502-dead-container-and-qt-event-loop-wedge.
Gotchas
- Live viewport requires the RPC to run ON the GUI thread. proximile/FreeCAD-MCP’s own
container starts the RPC in a separate offscreen FreeCAD process (
AppRun -c exec(...)), so MCP-created parts would NOT appear in noVNC. We instead use the LSIO GUI image + the neka-nat addon, whose RPC dispatches to the GUI thread (QTimer), so parts render live. - Screenshots blank after a raw
create_document+execute_code. The new doc isn’t the active GUI view yet. BeforesaveImage/get_view, run:FreeCAD.setActiveDocument(name); FreeCADGui.setActiveDocument(name); obj.ViewObject.Visibility=True; FreeCADGui.updateGui(); view=FreeCADGui.getDocument(name).ActiveView; view.viewAxonometric(); view.fitAll(); FreeCADGui.updateGui()then save. - neka-nat
execute_code(code)takes ONE arg (nodoc_name), unlike proximile’s. It also does not captureprint()stdout — return data via a file in/exportsif you need it. - mcp version conflict:
mcp-proxyneedsmcp.server.lowlevel.server.request_ctx, removed inmcp2.x. Pinmcp<2in the MCP image (freecad-mcpallows<3so it floats to 2.x otherwise and crashes the proxy on import). - Port 3000/3001 taken by
ruview→ noVNC remapped to host 3080/3081. tailscale servefirst hit tohttps://cad...can time out once while it provisions the LE cert; retry succeeds (~0.1 s after).- Auth for the
cadnode reused/etc/agent-tsauthkey(same key mechanism as the other per-app nodes):tailscale --socket=… up --hostname=cad --authkey="$(cat /etc/agent-tsauthkey)".
Files
/home/levander/freecad/docker-compose.yml/home/levander/freecad/build/freecad/(Dockerfile,install-mcp-addon.sh,FreeCADMCP/addon)/home/levander/freecad/build/mcp/Dockerfile/home/levander/freecad/exports/(STL/glTF/preview/STEP/3MF) +exports/cad_export.py(export helper)/home/levander/freecad/build/exports-nginx/default.conf(autoindex config)/home/levander/freecad/build/o3dv/(Online3DViewer:Dockerfile,default.conf,landing.html)/etc/systemd/system/tailscaled-cad.service- Mac:
~/Library/LaunchAgents/com.levander.cad-exports-sync.plist+~/cad-exports/.sync.sh
Failure modes (debugging chapters)
- 2026-09-02-freecad-mcp-rpc-refused-gui-not-running —
Errno 111on:9875: the FreeCAD GUI isn’t running (the RPC lives on the GUI thread);nc -zlies because of the userspace raw forwarder. - 2026-09-02-freecad-mcp-gui-dispatch-timeout-modal-dialog —
get_rpc_statushealthybutexecute_codetimes out at 90 s: a modal Document Recovery dialog owns Qt’s main thread. ⚠️ partially corrected on 2026-09-05 —DISPLAY=:1not:0, andxdotoolalone is not always enough. - 2026-09-05-freecad-mcp-502-dead-container-and-qt-event-loop-wedge — HTTP 502 on
:8443: thefreecad-mcpproxy container wasExited (128)with a never-fired restart policy; plus a Qt event loop that stayed wedged after the dialog was closed, cured by stashing theFreeCAD_Doc_*recovery snapshots and then restarting.
See also telep-mainframe, 2026-08-26-cad-designer-agent (the agent that drives this).