docs: update FreeCAD MCP README to document dual-service architecture

This commit is contained in:
2026-05-31 10:13:43 -04:00
parent 3893b91a55
commit 77a82b4784
2 changed files with 208 additions and 137 deletions

View File

@@ -1,62 +1,104 @@
# FreeCAD Robust MCP Server — Ansible Deployment
Deploys the [FreeCAD Robust MCP Server](https://pypi.org/project/freecad-robust-mcp/)
to Caliban as a systemd service with HTTP transport.
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 │
│ └─────────────────────┘ │
│ xmlrpc :9875
┌──────────────────────┐
│ │ FreeCAD (future) │
│ │ XML-RPC server │
└──────────────────────┘
└─────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────
│ 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│
└──────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
```
## FreeCAD bridge required for tool calls
## Two services, two users (by design)
The service starts and answers the MCP `initialize` handshake **without** FreeCAD
running — the XML-RPC connection to FreeCAD is only attempted on the first CAD
tool call (lazy connect). So a green Ansible healthcheck means "transport up",
**not** "FreeCAD reachable".
| 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`) |
Actual CAD tool calls require FreeCAD running with the Robust MCP Bridge
workbench started, listening on XML-RPC `localhost:9875`. Standing up that bridge
(GUI or headless) on Caliban is a separate step from getting this service to
boot.
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 Ansible inventory (already exists in Ouranos)
- Python 3.11+ on Caliban (already present)
- 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).
## Deployment
### 1. Copy playbook files to Ouranos
Copy the contents of this directory into your Ouranos repo:
## Files in this role
```
ansible/freecad_mcp/
├── deploy.yml
├── .env.j2
── freecad-mcp.service.j2
├── 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)
```
### 2. Add inventory group
## Inventory
Add to `ansible/inventory/hosts`:
`ansible/inventory/hosts` (already present):
```yaml
freecad_mcp:
@@ -64,9 +106,7 @@ freecad_mcp:
caliban.incus:
```
### 3. Add host variables
Add to `ansible/inventory/host_vars/caliban.incus.yml`:
Host vars in `ansible/inventory/host_vars/caliban.incus.yml`:
```yaml
# FreeCAD Robust MCP Server
@@ -74,60 +114,93 @@ freecad_mcp_user: harper
freecad_mcp_group: harper
freecad_mcp_directory: /srv/freecad-mcp
freecad_mcp_port: 22061
freecad_mcp_version: "0.5.0"
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"
```
Update `services` list:
Group vars in `ansible/inventory/group_vars/all/vars.yml`:
```yaml
services:
- alloy
- caliban
- docker
- freecad_mcp
- kernos
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
```
### 4. Run the playbook
## 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
To upgrade to a new PyPI version, update `freecad_mcp_version` in host_vars
and re-run the playbook. The pip install task will detect the version change
and the handler will restart the service.
- **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 playbook automatically validates the deployment by:
The playbooks validate automatically:
1. Waiting for the HTTP port to become available
2. Sending an MCP `initialize` JSON-RPC request to `/mcp`
3. Verifying a 200 response
- **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.
You can also manually test:
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
# On Caliban
# 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 systemd service runs with hardened settings:
The **MCP server** unit (`freecad-mcp.service`, user `harper`) is hardened:
| Setting | Value | Rationale |
|---------|-------|-----------|
@@ -137,4 +210,15 @@ The systemd service runs with hardened settings:
| `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.