225 lines
9.6 KiB
Markdown
225 lines
9.6 KiB
Markdown
# 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 <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):
|
|
|
|
```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_<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 archive`s 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:
|
|
|
|
```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.
|
|
|
|
|