Files

FreeCAD Robust MCP Server — Ansible Deployment

Deploys the FreeCAD Robust MCP Server to Caliban as two systemd services:

  • freecad-mcp.service — the MCP server (HTTP/streamable-http transport on :22061), pip-installed into a venv under /srv/freecad-mcp, run as the hardened harper service user.
  • freecad-mcp-bridge.service — FreeCAD itself running in GUI mode on the XRDP desktop (display :10), exposing the XML-RPC bridge on localhost:9875. Run as robert (the principal_user, who owns the X session), from source staged as a tarball.

The MCP server connects to the bridge over localhost:9875; the bridge in turn drives FreeCAD. The two halves rendezvous only on that local port.

Architecture

┌──────────────────────────────────────────────────────────┐
│  caliban.incus                                            │
│                                                           │
│  ┌──────────────────────┐                                 │
│  │  freecad-mcp.service │                                 │
│  │  (streamable-http)   │◄─── :22061 ────────────────────┤◄── MCP Client
│  │  venv + PyPI package │     (user: harper, hardened)    │
│  └──────────┬───────────┘                                 │
│             │ xmlrpc localhost:9875                        │
│             ▼                                              │
│  ┌──────────────────────────────┐                         │
│  │  freecad-mcp-bridge.service  │                         │
│  │  /usr/bin/freecad (GUI)      │  DISPLAY=:10 (XRDP)     │
│  │  startup_bridge.py           │  user: robert          │
│  │  XML-RPC :9875 / socket :9876│                         │
│  └──────────────────────────────┘                         │
└──────────────────────────────────────────────────────────┘

Two services, two users (by design)

Service User Transport / port Hardened Needs X
freecad-mcp.service harper HTTP :22061 yes no
freecad-mcp-bridge.service robert XML-RPC :9875 (+ 9876) no yes (:10)

The bridge runs as robert because it attaches to the standard XRDP display :10, owned by robert with Xauthority /home/robert/.Xauthority. It cannot be hardened like the server unit — it needs the user's X session and home.

How the bridge starts (no just/mise/uv needed)

The bridge runs inside FreeCAD's own Python interpreter via /usr/bin/freecad <startup_bridge.py>. The README "Option B" (just freecad::run-gui) in the upstream repo is only a launcher wrapper that locates FreeCAD and runs that same script — just, mise, and uv are not required for the bridge.

The bridge scripts are not shipped in the pip wheel (it packages only src/freecad_mcp). They live in the git repo under freecad/RobustMCPBridge/freecad_mcp_bridge/, so the bridge is delivered separately as a staged tarball (see Deployment below).

GUI vs headless: We run GUI mode to keep the GUI-only tools (screenshots, object color, visibility, camera). freecadcmd <blocking_bridge.py> would run headless without those tools — not used here.

Python version: FreeCAD 1.0.0 on Caliban uses the system Python (3.13), not a bundled 3.11. The upstream ABI-match warning applies only to embedded mode (importing FreeCAD into an external interpreter). We run scripts inside FreeCAD and the bridge is pure stdlib, so the version mismatch is a non-issue.

Lazy connect: a green server healthcheck is not "FreeCAD reachable"

freecad-mcp.service starts and answers the MCP initialize handshake without the bridge running — the XML-RPC connection to FreeCAD is only attempted on the first CAD tool call. So the server playbook's initialize check proves "transport up", not "FreeCAD reachable". The bridge playbook's validation (below) is what proves the full chain.

Prerequisites

  • Caliban host in the freecad_mcp inventory group (already configured).
  • python3 + python3-venv on Caliban (installed by the playbook).
  • freecad package on Caliban (installed by the playbook).
  • The XRDP display :10 running, owned by robert (the standard Ouranos RDP desktop — not configured here, it is always present).

Files in this role

ansible/freecad_mcp/
├── deploy.yml                       # Two plays: MCP server + GUI bridge
├── stage.yml                        # Clones the fork + builds the bridge tarball
├── .env.j2                          # MCP server env (FREECAD_* vars)
├── freecad-mcp.service.j2           # MCP server unit (harper, hardened)
└── freecad-mcp-bridge.service.j2    # FreeCAD GUI bridge unit (robert, :10)

Inventory

ansible/inventory/hosts (already present):

freecad_mcp:
  hosts:
    caliban.incus:

Host vars in ansible/inventory/host_vars/caliban.incus.yml:

# FreeCAD Robust MCP Server
freecad_mcp_user: harper
freecad_mcp_group: harper
freecad_mcp_directory: /srv/freecad-mcp
freecad_mcp_port: 22061
freecad_mcp_xmlrpc_port: 9875
freecad_mcp_socket_port: 9876

# FreeCAD MCP Bridge (GUI, runs as principal_user on the XRDP display)
freecad_mcp_bridge_directory: "/home/{{ principal_user }}/freecad-mcp-bridge"
freecad_mcp_bridge_display: ":10"

Group vars in ansible/inventory/group_vars/all/vars.yml:

freecad_mcp_version: 0.6.1        # PyPI version pin (server install)
freecad_mcp_git_ref: "main"       # fork ref for BOTH the pip install and the staged bridge tarball

Deployment

The bridge source is delivered via the staging pattern: cloned on the Ansible controller, packed with git archive, and unpacked on the host (no deploy keys on Caliban). Stage first, then deploy:

cd ~/git/ouranos/ansible
source ~/env/ouranos/bin/activate

# 1. Build the bridge tarball on the controller (~/rel/freecad_mcp_bridge_<ref>.tar)
ansible-playbook freecad_mcp/stage.yml

# 2. Deploy the MCP server (idempotent) + the GUI bridge
ansible-playbook freecad_mcp/deploy.yml

stage.yml clones/pulls the fork into ~/gh/freecad-addon-robust-mcp-server at freecad_mcp_git_ref and git archives it to ~/rel/freecad_mcp_bridge_<ref>.tar. deploy.yml unpacks that into ~robert/freecad-mcp-bridge and points the bridge unit at freecad/RobustMCPBridge/freecad_mcp_bridge/startup_bridge.py.

Upgrading

  • MCP server: bump freecad_mcp_version (PyPI) and/or freecad_mcp_git_ref in group vars, re-run deploy.yml. The pip task detects the change and the handler restarts freecad-mcp.
  • Bridge: re-run stage.yml (rebuilds the tarball from the latest fork ref), then deploy.yml. The unarchive change notifies the restart freecad-mcp-bridge handler.

Validation

The playbooks validate automatically:

  • Server play: waits for :22061, sends an MCP initialize request to /mcp, expects HTTP 200 (transport-level only — see lazy-connect note above).
  • Bridge play: waits for :9875, then calls the bridge's XML-RPC execute with _result_ = bool(FreeCAD.GuiUp) and asserts the result is True — proving FreeCAD is up in GUI mode, end to end.

Manual checks:

# Transport up (no FreeCAD needed):
curl -X POST http://caliban.incus:22061/mcp \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","method":"initialize","id":1,"params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1.0.0"}}}'

# Bridge listening + in GUI mode:
ss -ltnp | grep 9875
python3 -c 'import xmlrpc.client as x; print(x.ServerProxy("http://localhost:9875", allow_none=True).execute("_result_ = bool(FreeCAD.GuiUp)"))'

Service Management

# MCP server
sudo systemctl status freecad-mcp
sudo systemctl restart freecad-mcp
sudo journalctl -u freecad-mcp -f

# FreeCAD GUI bridge
sudo systemctl status freecad-mcp-bridge
sudo systemctl restart freecad-mcp-bridge
sudo journalctl -u freecad-mcp-bridge -f

Security

The MCP server unit (freecad-mcp.service, user harper) is hardened:

Setting Value Rationale
NoNewPrivileges true No privilege escalation
ProtectSystem strict Filesystem is read-only except allowed paths
ProtectHome read-only Home directories protected
PrivateTmp true Isolated /tmp namespace
ReadWritePaths /srv/freecad-mcp Only app directory is writable

The bridge unit (freecad-mcp-bridge.service, user robert) is not hardened: FreeCAD GUI needs the user's X session, .Xauthority, and FreeCAD config in the home directory. It binds XML-RPC/socket on localhost only.

Known limitation

The bridge depends on the XRDP :10 session (owned by robert). Restart=on-failure recovers crashes, but not loss of the X display — if that session restarts, restart freecad-mcp-bridge afterward. Auto-tying the two is a possible follow-up.