Skip to main content
Glama
ahelja

FreeCAD MCP

by ahelja
README.md
# FreeCAD MCP

An MCP server that lets an AI agent drive a **live FreeCAD desktop session** — the
model builds geometry, you watch it appear in the FreeCAD window, and the agent can
screenshot the 3D view to check its own work.

Verified against FreeCAD 1.1.3 on macOS.

## How it works

```
MCP client (opencode)
      │  stdio / MCP
      ▼
freecad-mcp                 ← own uv venv, Python 3.10+, mcp SDK
      │  XML-RPC on 127.0.0.1:9875
      ▼
FreeCADMCP addon            ← runs inside FreeCAD's bundled Python 3.11
      │  QTimer task queue
      ▼
FreeCAD GUI main thread     ← documents, geometry, 3D view
```

Two processes, deliberately. The MCP server never imports FreeCAD, so it can use a
modern venv and any dependencies it likes, while the addon stays stdlib-only inside
FreeCAD's interpreter.

The RPC server runs on a background thread, but **every** FreeCAD call is marshalled
onto the Qt main thread through a queue drained by a `QTimer`
(`addon/FreeCADMCP/freecad_mcp_bridge/dispatcher.py`). Touching FreeCAD documents from
a worker thread corrupts state or crashes the app, so this hop is not optional.

The wire protocol is a single `call(method, payload_json) -> json` endpoint. Passing
JSON through one method sidesteps every XML-RPC marshalling quirk (no `None`, no
nested heterogeneous types) and keeps errors as structured data instead of faults.

## Setup

```sh
make install          # create the venv, install dependencies
make addon-install    # symlink the addon into FreeCAD's Mod directory
make freecad          # start FreeCAD -- the bridge autostarts
make check            # confirm the bridge answers
```

`make check` should print your FreeCAD version and open documents.

The addon is **symlinked**, not copied, so edits in this repo take effect on the next
FreeCAD restart (`make freecad-restart`).

### Wiring it into opencode

`opencode.json` already registers the server. Restart opencode to pick it up —
config is read once at startup.

It uses `"cwd": "."`, which opencode resolves from the workspace directory, so there
is no absolute path to update when you clone this elsewhere.

`timeout` is raised to 120s because the 5s default is not enough for `capture_view`
or `export_objects` on a non-trivial model.

### Other MCP clients

Clients without a workspace concept (Claude Desktop and friends) need an explicit
path — replace `/absolute/path/to/FreeCAD-MCP` with wherever you cloned this:

```json
{
  "mcpServers": {
    "freecad": {
      "command": "uv",
      "args": ["run", "--directory", "/absolute/path/to/FreeCAD-MCP", "freecad-mcp"]
    }
  }
}
```

## Tools

| Tool | Purpose |
| --- | --- |
| `freecad_status` | Connectivity check, FreeCAD version, open documents |
| `list_documents` / `create_document` / `open_document` | Document management |
| `save_document` / `close_document` / `recompute` | Persistence and rebuild |
| `list_objects` / `get_object` | Inspect the tree; `get_object` reports volume, area, bounding box |
| `create_object` / `edit_object` / `move_object` / `delete_object` | Typed geometry editing |
| `get_selection` | Read what the user selected in the GUI |
| `run_python` / `reset_python` | Execute Python inside FreeCAD (escape hatch) |
| `capture_view` | Screenshot the 3D view, returned as an image |
| `export_objects` | STEP, IGES, STL, OBJ, PLY, 3MF, AMF, OFF, BREP, FCStd |

### Value formats

`create_object` and `edit_object` accept JSON and coerce it to FreeCAD's native types:

| Property kind | Accepted |
| --- | --- |
| lengths | `10`, `10.5` (millimetres) |
| vectors | `[x, y, z]` or `{"x":.., "y":.., "z":..}` |
| placement | `{"Base": [x,y,z], "Rotation": {"Axis": [0,0,1], "Angle": 45}}` |
| rotation | `45` (degrees about Z), `{"Axis":..,"Angle":..}`, `{"Yaw":..,"Pitch":..,"Roll":..}` |
| links | `"Box"` — an object Name *or* Label |
| colours | `"#ff8800"`, `[1.0, 0.53, 0.0]`, or `[255, 133, 0]` |

Angles are degrees, lengths millimetres.

`view_properties` targets the ViewObject: `ShapeColor`, `Transparency` (0–100),
`DisplayMode`, `Visibility`.

### Example

Creating a plate with four drilled holes:

```
create_object  type_id="Part::Box"      properties={"Length":60,"Width":40,"Height":10}
create_object  type_id="Part::Cylinder" properties={"Radius":3,"Height":20,
                                                    "Placement":{"Base":[10,10,-5]}}
create_object  type_id="Part::Cut"      properties={"Base":"Box","Tool":"Cylinder"}
capture_view                            # look at it before declaring success
export_objects path="~/plate.step"
```

## Configuration

| Variable | Default | Applies to |
| --- | --- | --- |
| `FREECAD_MCP_HOST` | `127.0.0.1` | both |
| `FREECAD_MCP_PORT` | `9875` | both |
| `FREECAD_MCP_TIMEOUT` | `300` (seconds) | MCP server |
| `FREECAD_MCP_AUTOSTART` | `1` | addon |

The addon also reads FreeCAD parameters under
`BaseApp/Preferences/Mod/FreeCADMCP` (`Host`, `Port`, `AutoStart`), editable via
*Tools > Edit parameters*. Environment variables win.

The bridge binds to loopback only and has no authentication — anything that can reach
the port can execute arbitrary Python in FreeCAD. Do not expose it beyond `127.0.0.1`.

### Bridge controls in FreeCAD

The addon adds an **MCP** workbench with *Start* / *Stop* / *Status* commands. The
bridge autostarts 1.5s after the GUI is up, and stops cleanly on quit.

## Development

```sh
make smoke            # end-to-end test through a real MCP client (FreeCAD must run)
make lint             # ruff
make freecad-restart  # reload addon changes
make addon-status     # is the addon linked?
```

`make smoke` exercises all 18 tools against a live session and verifies boolean
geometry numerically (a drilled plate's volume must match `l·w·h − nπr²h`).

### Two FreeCAD gotchas this codebase works around

**1. `InitGui.py` has no usable module scope.** FreeCAD runs it via `exec` from
inside a function, so top-level names become that function's *locals*. Any function or
class method defined in `InitGui.py` resolves its globals against FreeCAD's own init
module and dies with `NameError`. All logic therefore lives in
`freecad_mcp_bridge/workbench.py`, which is imported normally; `InitGui.py` is a
five-line shim.

**2. `package.xml` decides where FreeCAD looks for your code.** When a workbench
entry omits `<subdirectory>`, FreeCAD defaults it to the workbench `<name>` — so a
workbench named `MCP` makes FreeCAD search `FreeCADMCP/MCP/InitGui.py` and silently
skip the addon. Hence the explicit `<subdirectory>./</subdirectory>`.

A third, smaller one: in FreeCAD 1.x, `ShapeColor` is a Python shim over
`ShapeAppearance` rather than a registered property, so `getTypeIdOfProperty` fails
for it. `serialize.py` falls back to inferring the type from the value currently
stored.

## Layout

```
addon/FreeCADMCP/            installed into FreeCAD's Mod directory
  InitGui.py                 thin shim (see gotcha 1)
  package.xml                addon metadata (see gotcha 2)
  freecad_mcp_bridge/
    workbench.py             workbench, commands, autostart
    server.py                XML-RPC server + JSON envelope
    dispatcher.py            background thread -> Qt main thread
    handlers.py              the FreeCAD operations
    serialize.py             JSON <-> FreeCAD type conversion
src/freecad_mcp/
  server.py                  MCP tool definitions
  client.py                  XML-RPC client
  __main__.py                CLI entry point
scripts/smoke.py             end-to-end test
```

## Troubleshooting

**`make check` says the bridge is unreachable.** Confirm FreeCAD is running, the addon
is linked (`make addon-status`), and check FreeCAD's *View > Panels > Report view* for
`[FreeCAD MCP]` messages.

**`Address already in use` on startup.** A previous FreeCAD process still holds the
port — `make freecad-quit` force-kills leftovers.

**Addon changes have no effect.** The addon is imported once at startup; run
`make freecad-restart`.

**A tool times out.** Something is blocking FreeCAD's main thread, usually a modal
dialog. Dismiss it in the GUI.

TDQS

A3.9/5.0

Scored across 18 tools

Disambiguation5/5

Each tool targets a distinct resource and action: document lifecycle (create/open/save/close/list), object inspection/manipulation (list/get/create/edit/move/delete), and utility operations (recompute, selection, status, python, view, export). The only minor overlap is freecad_status vs list_documents, but their descriptions clearly differentiate bridge health/version info from detailed document listing.

Naming Consistency4/5

The vast majority of tools follow a verb_noun snake_case pattern (create_document, get_object, delete_object, export_objects). Two deviations exist: recompute is verb-only, and freecad_status is noun_status rather than get_freecad_status. These are minor and do not impede readability.

Tool Count4/5

18 tools for a CAD server is on the higher end but appropriate given the breadth of modeling, document, and utility operations. Each tool serves a distinct function, and the count is not bloated with redundant entries. It is slightly above the ideal 3-15 range but still well-scoped.

Completeness4/5

The tool surface covers full CRUD for documents and objects, plus essential utilities like recompute, selection, rendering, and export. Advanced parametric modeling (e.g., sketches, pads) is delegated to run_python, which acts as a well-documented escape hatch. Minor gaps like object duplication or renaming are workable via the Python escape hatch.

Maintenance

ActivitySlowing
ResponsivenessNo issues