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)

WhatURL
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:latest with the neka-nat/freecad-mcp addon injected into the GUI’s Mod dir. A custom-cont-init.d script writes freecad_mcp_settings.json with auto_start_rpc=true, remote_enabled=true, allowed_ips incl. 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". Runs mcp-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; RPC 127.0.0.1:9875; MCP 127.0.0.1:9876. (noVNC is on 3080/3081 because ruview owns 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):

WhatURL
Browsable exports (clickable listing)https://cad.taild4189d.ts.net:8080/
  • Compose service in docker-compose.yml; config build/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 :8443 mounts.
  • Verified from the Mac: listing loads and plate.3mf downloads (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.

WhatURL
Landing (lists exports, click-to-view, drag-drop, Open-file picker)https://cad.taild4189d.ts.net:8090/
Viewer, model auto-loaded via URL hashhttps://cad.taild4189d.ts.net:8090/o3dv/#model=/exports/plate.step
  • Compose service o3dv in docker-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 host 127.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 nginx autoindex_format json listing 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 function openInViewer(files): an overlay opens an iframe of /o3dv/ and the local File objects are handed to it client-side via its #open_file input (input.files = <DataTransfer> + dispatch change → O3DV LoadModelFromFileList). No server upload — a local File can’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 lite EmbeddedViewer.
    • 🔴 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 Dockerfile sed-patches source/website/index.js (window.self !== window.topfalse); esbuild then dead-code-eliminates the whole guard block. The proper anti-clickjacking control is a header, not the JS buster: nginx sends X-Frame-Options: SAMEORIGIN and Content-Security-Policy: frame-ancestors 'self' (both always, 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 change handler resets input.value='' (so the same file can be re-picked), which empties the live FileList. openInViewer must snapshot with Array.from(files) up front — otherwise the deferred iframe.onload feed sees an empty list and the first picked file silently fails to load.
  • STEP libs are self-hosted, not CDN. v0.19.0 fetches occt-import-js, rhino3dm, web-ifc, draco3d from jsdelivr; the Dockerfile sed-patches source/engine/import/importerutils.js to (new URL('libs/…', document.baseURI)).href and downloads the dist (incl. occt-import-js.wasm) into /o3dv/libs/. The OCCT worker is built from a blob with absolute libs/ 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.step auto-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.3mf also renders. All lib/wasm assets return HTTP 200 (application/wasm).
    • Drag-drop (landing): handing a local plate.step File to the overlay renders it in the full viewer (same OCCT translator, 600v/588t); a local plate.stl renders 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 #filepicker change with a local plate.step (the exact input.value=''-cleared first-file case) renders it in the iframe viewer (OCCT, 600v/588t); plate.stl via the picker renders too (1,716v/572t). Same one openInViewer path as drag-drop.
    • Headers: curl -I on both / and /o3dv/ shows X-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.

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 LAN 192.168.1.123 first, tailnet 100.115.209.87 fallback; 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_ssh shell function

The plain ssh fails caveat is about the interactive shell alias. launchd invokes /usr/bin/rsync -e /usr/bin/ssh directly, so key-based auth to the mainframe works non-interactively — no command ssh needed there.

How to use (main session)

  1. create_document("MyPart")
  2. execute_code("<FreeCAD Python>") to build geometry.
  3. To make it show/screenshot in noVNC after a raw create_document, the code must activate the GUI doc + refresh (gotcha below).
  4. Export with Import.export / Mesh.export to /exports/..., then pull the file from the host for a three.js viewer.

Restart / operate

How to reach the box — cad is 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. The cad tailnet identity is a userspace tailscaled sidecar running on telep-mainframe, as are all the other per-service nodes (knowledgebase, drive, chatcut, orcaslicer, bambuddy, …), so ssh 100.120.203.1 lands on telep-mainframe — the same place ssh levander@100.115.209.87 gets 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.service

Reboot-safe: containers restart: unless-stopped, docker + tailscaled-cad.service enabled at boot, tailscale serve config persisted in the node state.

restart: unless-stopped did NOT keep freecad-mcp alive (2026-09-05)

On 2026-09-02 the freecad-mcp container exited 128 with failed to create task for container: failed to create shim task: ttrpc: closed and stayed dead for three days at RestartCount: 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 on https://cad.taild4189d.ts.net:8443/. docker ps -a (with -a) is the probe; docker start freecad-mcp is 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. Before saveImage/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 (no doc_name), unlike proximile’s. It also does not capture print() stdout — return data via a file in /exports if you need it.
  • mcp version conflict: mcp-proxy needs mcp.server.lowlevel.server.request_ctx, removed in mcp 2.x. Pin mcp<2 in the MCP image (freecad-mcp allows <3 so 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 serve first hit to https://cad... can time out once while it provisions the LE cert; retry succeeds (~0.1 s after).
  • Auth for the cad node 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)

See also telep-mainframe, 2026-08-26-cad-designer-agent (the agent that drives this).