"No, you rotated them, I asked you to swap them."
That's a message I sent to a coding agent that was supposed to assemble a two-axis pan-tilt mechanism in FreeCAD. A bit later I was telling it which part to rotate, around which axis, by how many degrees, and then along which single coordinate to shift it. The agent executed CAD operations, but I was the one assembling, through a chat. It took 1 hour 37 minutes, and the base was still unassembled.
So I extended the MCP server: hram/freecad-mcp, a fork of neka-nat/freecad-mcp (MIT). Afterwards, Claude and Codex each assembled the same mechanism on their own, in about 15 and 17 minutes. But the geometry checks in the new tools missed a mistake: Codex had swapped the two side plates.
This post covers the tool, the two runs, and what the checks can and can't catch.
The task and the first attempt
I'm building a platform that rotates a phone in two planes, pan and tilt, with two MG996R servos. I'm a software developer, not a CAD user. I used the Pan and Tilt for Standard Servo design by micromet. The archive has five printed parts (base, rotating tray, two different side plates, top platform), a sixth STL for an IR sensor mount, and no CAD assembly or servo models.
On October 26 I gave Codex the archive. A good-looking screenshot wasn't enough to tell whether things were placed correctly: one part ended up inside another, and when I asked it to swap the side plates it rotated them instead. After a few rounds I switched to commanding each step. My messages from the log (originally in Russian):
"no you rotated them and I asked to swap them"
"let's start over. lay out all the parts. I'll tell you step by step what to assemble with what"
From there I chose axes and angles, picked which hole to align with which, forbade touching neighboring parts, limited corrections to a single coordinate, and asked for rollbacks. The 1 hour 37 minutes is time to partial acceptance. It isn't a benchmark, only an illustration of how much human involvement the process needed. What bothered me wasn't that the agent made mistakes; it was that it couldn't reliably detect and fix them without me.
Why "script executed" isn't enough
Take a typical task: bolt a bracket to a base through two holes. With upstream, the agent writes the Python itself: finds the faces, converts coordinates to world space, computes a Placement, applies it. Then it gets this back:
execute_code(<own script: find faces → compute Placement → apply>)
→ "Script executed successfully"
get_view() → one picture from one angle
If the holes are off by 0.3 mm, you only see it by eye. A single projection doesn't answer whether holes are coaxial, what the gap between planes is, or whether parts intersect. I asked a different agent to assess the toolset and its assessment matched my concern; then I had a coding agent extend the server. That was my judgment call, not a proven root cause of the earlier failures.
What the fork does
The fork keeps all 17 original tools unchanged and adds 14. The mounting task becomes a sequence of ready-made operations:
define_interface(base) define_interface(bracket) # which face, which holes
plan_alignment(...) # a plan; the document is not changed
preview_alignment(plan_id) # the part in its proposed position, 3 views
apply_alignment(plan_id) # move in a transaction; roll back if tolerance is violated
check_assembly(plan_id) # independent re-check
inspect_region(...) # isometric + top + right in one response
The result is measurements, not "success". Real output from a test scene (FreeCAD 1.1.4, add-on 0.2.1): two 40×24×6 mm blocks with two holes each, bracket initially offset and rotated.
passed: true
max_hole_error_mm: 6.2e-14 hole_axis_angles_deg: [0.0, 0.0]
gap_mm: 3.6e-14 opposing_faces_error_deg: 0.0
collisions: [{part: "Base", overlap_mm3: 0.0}]
collision_scope: ["Base"] whole_assembly_checked: false
Values around 1e-14 mm are floating-point noise, so the holes match exactly.
The cycle on that same test scene.
Design details that matter in practice:
-
Holes are ordered.
define_interfacetakes a contact plane and at least two holes in a given order. The tool doesn't guess which side of a part faces outward or resolve symmetry; the agent has to decide. -
Apply is a transaction.
apply_alignmentmoves the part, recomputes, re-measures, and rolls back if hole match, axis angle, contact gap or intersection volume violates the tolerance. It doesn't save the file. - Plans go stale safely. Geometry fingerprints, a document change counter and document identity mean a plan built before the model changed is rejected.
-
Long operations are jobs. A normal call waits up to 20 seconds, then returns a
job_id. Withstart_assembly_operation, a repeatedrequest_idwith the same arguments returns the same job instead of re-running the move.outcome_unknownis reported instead of a false error when the GUI stalls, and cancellation works between safe stages. -
Unsaved state is visible.
get_document_statusreports the GUI modified flag, whether the file exists, and whether a recompute is needed, as separate fields. -
Permanent links.
create_assemblyandcreate_fixed_jointuse FreeCAD's own Fixed/Grounded Joint, with a geometry check after the solver runs.
The new operations need the fork's own add-on (0.2.1); scripts/install_addon.py installs it and keeps a backup of the previous one.
Two agents, no coordinates from me
Claude, 15 min 7 s. On October 27 I asked Claude to assemble the same archive on its own. When it started reading an earlier project of mine, I stopped it: "forget the stand project. do it yourself, don't peek." It had already seen some service information and earlier commit titles, so the run isn't perfectly isolated, but after that I gave no placement hints. It reconstructed the five parts as geometry suitable for face and cylinder analysis, checked volumes against the source models (difference up to 0.04%), added two simplified servo models and a horn, checked body pairs for intersections, saved an FCStd and rendered four views.
One caveat from the logs: Claude did the main layout with its own Python scripts and used the new alignment API on the two side plates only afterwards, to verify and reproduce their position. So this isn't an isolated test of each new command.
Claude's own collage, unretouched. The mechanism looks right, but the green caption inside the image, "all axes aligned to 1e-6 mm, no intersections", is wrong: the measurements are below.
Codex, about 17 min 29 s. I repeated the task in Codex to rule out "it's just a different model". It also finished without step-by-step instructions, this time using define_interface, plan_alignment, preview_alignment, apply_alignment, check_assembly and inspect_region for a number of joints. It created two placeholder servo models, horns, and also included the sixth archive part, the IR sensor mount.
Codex's result, deliberately left uncorrected. The side plates are in each other's places.
What the checks missed
When I looked at the Codex model, the side plates were in the wrong places. Codex had also added four 1 mm spacers that weren't in the original design, while trying to remove intersections near the top. In my assessment the spacers were needed because of the swap: instead of fixing the placement, the agent compensated with extra parts. That causal link is my reading of the result; I did not run an experiment with the spacers removed and the plates swapped back.
Meanwhile its checks showed hole deviation below 5e-14 mm for the joints it had defined, zero angular error, and no volumetric intersections among 91 pairs of bodies in the neutral pose.
There's no contradiction. The tool verified what the agent itself declared correct: the chosen pair of holes, the allowed gap, no intersection at the checked position. It did not verify whether the agent understood which side plate is left and which is right. You can align the wrong parts, the wrong way round, with mathematical precision.
Claude's checks weren't clean either. Its final measurements showed an axis offset of 0.5831 mm, up to 0.25 mm across matched mounts, one hole without a match, and an 84.823 mm³ intersection between the placeholder servo shaft and horn disk. Its final image nevertheless claimed all axes aligned to 1e-6 mm with no intersections. One of its own checks had also first shown zero deviations because, before measuring, it restored the reference positions of the parts; the agent noticed the suspicious zeros and fixed the script. Verification code can be wrong in the same way the thing it verifies can.
Limits of the tool and of my claims
-
Collision checks are partial. Intersections are checked only between the moving part, the fixed part and the
obstaclesyou list. Every response sayswhole_assembly_checked: false. - The agent still chooses. Picking the contact faces and the hole order stays with the agent.
-
Not supported yet:
App::Link, movingApp::Partcontainers, and editing arbitrary existing networks of Assembly constraints. - Tested scope. 286 tests pass on the version I examined, and live smoke scenarios cover nested Placements, PartDesign Body, rollback on collision and cancel, preview, and Fixed Joint after save and reopen. That's tested scenarios, not a guarantee for an arbitrary CAD model.
- Timings aren't a speed metric. The runs differ in agent, context, geometry representation and set of parts, so I can't say the tools made assembly six times faster.
- No physical result yet. The servos were simplified CAD mockups. I haven't shown that real MG996R servos fit without rework or that the mechanism survives a phone's load.
- When upstream is enough. If you draw parts, edit objects, run FEM or generate models by script, you don't need the new tools.
What did change is my role. On day 1 I explained how to rotate and where to shift. On day 2 I said what to assemble and reviewed the result. When Codex erred, I didn't re-teach it geometric transformations; I spotted a wrong engineering decision on a finished model. Checking how precisely parts are joined is now automated; checking whether the agent chose the right parts to join is still on me.
Try it
uv sync
uv run freecad-mcp --host 127.0.0.1
You need Python 3.12+, uv, and FreeCAD running with the MCP RPC on port 9875, plus the fork's add-on. Details are in the README: hram/freecad-mcp. The mechanical models and assembly images in this post are based on micromet's design, distributed under Creative Commons Attribution–ShareAlike (the license version wasn't established in my research); the MCP's MIT license doesn't apply to them.
I keep the full case study, with the longer chronology, on my site: https://hram.github.io/en/articles/freecad-mcp/



Top comments (0)