# FreeCAD Robust MCP Server — Ansible Deployment Deploys the [FreeCAD Robust MCP Server](https://pypi.org/project/freecad-robust-mcp/) 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 `. 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 ` 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): ```yaml freecad_mcp: hosts: caliban.incus: ``` Host vars in `ansible/inventory/host_vars/caliban.incus.yml`: ```yaml # 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`: ```yaml 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: ```bash cd ~/git/ouranos/ansible source ~/env/ouranos/bin/activate # 1. Build the bridge tarball on the controller (~/rel/freecad_mcp_bridge_.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 archive`s it to `~/rel/freecad_mcp_bridge_.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: ```bash # 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 ```bash # 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.