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 hardenedharperservice user.freecad-mcp-bridge.service— FreeCAD itself running in GUI mode on the XRDP desktop (display:10), exposing the XML-RPC bridge onlocalhost:9875. Run asrobert(theprincipal_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
FreeCADinto 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_mcpinventory group (already configured). python3+python3-venvon Caliban (installed by the playbook).freecadpackage on Caliban (installed by the playbook).- The XRDP display
:10running, owned byrobert(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/orfreecad_mcp_git_refin group vars, re-rundeploy.yml. The pip task detects the change and the handler restartsfreecad-mcp. - Bridge: re-run
stage.yml(rebuilds the tarball from the latest fork ref), thendeploy.yml. Theunarchivechange notifies therestart freecad-mcp-bridgehandler.
Validation
The playbooks validate automatically:
- Server play: waits for
:22061, sends an MCPinitializerequest to/mcp, expects HTTP 200 (transport-level only — see lazy-connect note above). - Bridge play: waits for
:9875, then calls the bridge's XML-RPCexecutewith_result_ = bool(FreeCAD.GuiUp)and asserts the result isTrue— 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.