cad-mcp
# cad-mcp
[](https://github.com/Arookie127/cad-mcp/actions/workflows/ci.yml)
[](pyproject.toml)
[](pyproject.toml)
[](LICENSE)
**An MCP server that lets an LLM design parametric 3D parts — and actually verify they are printable.**
Most "AI + CAD" demos stop at generating geometry. That is the easy half. The hard half is
that a mesh can *look* correct in a screenshot and still be a broken solid — and the model
has no way to notice.
cad-mcp closes that loop. Every part the model builds is checked for topology **before** it
can be exported, and the verdict comes back as structured numbers (watertight? genus? volume?
wall thickness?) instead of a picture the model has to squint at.
```
you: make me a 19-tooth helical gear with a 12 mm bore
model: make_part("involute_helical_gear", {"teeth": 19, "bore_dia": 12})
→ watertight=True genus=1 volume=31140.45 mm³ 19,912 triangles
└ genus=1 is the check that matters: exactly one through-hole, i.e. the bore.
genus=0 would mean the bore is missing; genus=2 would mean the blank is split.
model: look_at_mesh("gear", views="four") → the image below comes back into the context
export_mesh("gear", ["stl", "obj"]) → out/gear.stl
```

*Rendered by the server itself, in pure NumPy — no OpenGL, no CAD kernel. 19 teeth, module 2.5,
22° helix, 16 mm face width, DIN 6885 keyway. Watertight, genus 1, 31,140.45 mm³.*

## What a refusal looks like
The interesting case is not a part that builds. It is a part that builds, looks fine, and is not
printable. Ask for a lattice with walls thinner than a voxel and this comes back:
```json
{
"ok": false, "watertight": true,
"genus": -928.0, "shells": 737,
"fragmented": true, "volume_mm3": 11.7538,
"warning": "The mesh shattered: 737 disconnected shells, overall genus -928 (negative). This is not a part, it is a pile of flakes; exporting will be refused. Raise the wall thickness or lower res."
}
```
Every signal a human would check says *fine*: watertight, zero non-manifold edges, positive
volume, and a screenshot that looks like a dense lattice. The mesh is 737 disconnected shards,
each one a perfectly closed surface — which is why only the shell count and the sign of the
whole-mesh genus reveal it. `export_mesh` then refuses before touching the disk; `force=True`
overrides, and says so in its return value.
A full session — build, verify, look, change the bore, export, get refused — is recorded in
[docs/demo-session.md](docs/demo-session.md), generated by `scripts/make_session_demo.py`
against the real server. Every JSON block in it is an actual tool return value, including an
independent check that predicts a volume the server later reports to 0.01%.
## Install
```bash
python -m venv .venv
.venv/bin/pip install -e ".[render]" # Windows: .venv\Scripts\pip
```
`cad-mcp` needs Python 3.10+ and NumPy. The `render` extra adds Pillow, which is only required
for the visual half (`look_at_mesh`). Without it, the modelling and verification tools still work.
There is no CAD kernel, no OpenCASCADE, no `trimesh`, no compiled extension.
Modelling every built-in part — including the vase, whose radius profile is a smooth spline —
works on that install alone. The spline has two implementations: `scipy.interpolate.CubicSpline`
when scipy is present, and a built-in NumPy one otherwise. They agree to 4.4e-16, and
`tests/test_kernel.py` asserts that, because "scipy is optional" is only true if the parts
actually build without it. Verified by installing NumPy alone into a fresh venv: all four parts
build, and the vase comes out with bit-identical vertices (12432×3, `max|Δ| = 0.0`) and the same
62,960.4021 mm³ as the scipy environment.
Check the install before wiring it into a client:
```bash
.venv/bin/python -m cadmcp.server --check # versions, renderer, font, output dir
```
### Troubleshooting
<details>
<summary><b><code>ModuleNotFoundError: No module named 'cadmcp'</code> right after a successful <code>pip install -e .</code></b></summary>
Almost always a **non-ASCII path on Windows**. An editable install works by dropping a `.pth`
file into `site-packages`; pip writes it as UTF-8, but `site.py` decodes it with the *locale*
encoding (cp936 on a Chinese Windows). The path decodes to mojibake, the directory does not
exist, and Python silently skips it — `pip list` still shows `cad-mcp 0.1.0`, so the install
looks fine while the import keeps failing.
Three ways out, best first:
1. Give the server the source tree directly — no `.pth` involved. Add `PYTHONPATH` to the
client's `env` (see the last config block below).
2. Or do a regular install instead of an editable one: `pip install ".[render]"`.
3. Or move the checkout somewhere ASCII-only, e.g. `C:\dev\cad-mcp`.
</details>
<details>
<summary><b><code>look_at_mesh</code> returns <code>render_unavailable</code></b></summary>
Pillow is missing. `pip install "cad-mcp[render]"`. Modelling and verification work without it.
</details>
<details>
<summary><b>Labels in the rendered grid are empty boxes</b></summary>
The bundled fallback font has no CJK glyphs. `--check` prints which font was picked. Point
`CADMCP_FONT` at any font file that has the glyphs, e.g. `C:\Windows\Fonts\msyh.ttc`, and pass it
in the client's `env`.
</details>
## Configure your client
<details open>
<summary><b>Claude Desktop</b> — <code>claude_desktop_config.json</code></summary>
```json
{
"mcpServers": {
"cad": {
"command": "/absolute/path/to/cad-mcp/.venv/bin/python",
"args": ["-m", "cadmcp.server"],
"env": { "CAD_MCP_OUT": "/absolute/path/to/output/dir" }
}
}
}
```
On Windows the interpreter lives at `.venv\Scripts\python.exe`, and backslashes in JSON have to
be doubled:
```json
{
"mcpServers": {
"cad": {
"command": "C:\\dev\\cad-mcp\\.venv\\Scripts\\python.exe",
"args": ["-m", "cadmcp.server"],
"env": { "CAD_MCP_OUT": "C:\\dev\\cad-mcp\\out" }
}
}
}
```
</details>
<details>
<summary><b>Claude Code</b></summary>
```bash
claude mcp add cad -- /absolute/path/to/cad-mcp/.venv/bin/python -m cadmcp.server
```
</details>
<details>
<summary><b>Any other MCP client</b> (stdio transport)</summary>
Run the server as a subprocess and speak MCP over stdin/stdout:
```
command: /absolute/path/to/cad-mcp/.venv/bin/python
args: ["-m", "cadmcp.server"]
env: CAD_MCP_OUT=/absolute/path/to/output/dir # optional, defaults to ./out
```
`CAD_MCP_OUT` is the only knob. Everything the server writes — STL, OBJ, PNG, GIF — lands there.
That is the whole setup. If you would rather run the server straight out of a checkout without
`pip install -e ".[render]"`, point Python at the source tree instead:
```json
{
"mcpServers": {
"cad": {
"command": "/absolute/path/to/cad-mcp/.venv/bin/python",
"args": ["-m", "cadmcp.server"],
"env": {
"PYTHONPATH": "/absolute/path/to/cad-mcp/src",
"CAD_MCP_OUT": "/absolute/path/to/output/dir"
}
}
}
}
```
</details>
## The seven tools
| Tool | What it does |
|---|---|
| `list_parts` | Lists the built-in parametric parts and their parameters, **with defaults and ranges read from the implementation**, so the model never has to guess an argument name |
| `make_part` | Instantiates a parametric part (`involute_helical_gear`, `twisted_vase`, `torus_knot_tube`, `lattice_section_sample`) and returns its topology verdict |
| `make_lattice` | Builds free-form geometry from signed distance fields: spheres, boxes, gyroid / Schwarz-P TPMS lattices, shells, with optional corner notches to expose the interior. Reports `ok=false` (and writes nothing) if what came out is fragmented |
| `check_mesh` | Re-runs the topology report, cuts cross-sections, estimates minimum wall thickness — pass/fail gates for printability. Reports `printable` separately from `ok`: the check running and the part being printable are two different questions. Also reports **shell count** and a `fragmented` flag: a watertight mesh whose overall genus is negative is not a part, it is a pile of shards |
| `look_at_mesh` | Renders one or more viewpoints (or a turntable GIF) and **returns the images as MCP image content**, so the model sees its own work |
| `export_mesh` | Writes binary/ASCII STL or OBJ+MTL. **Refuses at the gate**: non-manifold edges, non-positive volume, NaN vertices, or a fragmented shell are blocked before anything touches the disk. Non-watertight only warns. Override with `force=True` |
| `describe_model` | Replays a session model: the **exact parameters it was built from**, its topology, and every artifact on disk. This is what makes "now make the bore 14 mm" work without the model having to remember its own earlier tool call |
Arguments like `involute_helical_gear(teeth=19, module=2.5, helix_deg=22)` are validated against
the real signature — call it with `teath=19` and you get
`involute_helical_gear 不认识这些参数: ['teath']` instead of a silently wrong gear.
One thing worth knowing when writing a client: a *wrong type* is caught earlier, by the SDK's
own argument validation, and comes back as `isError=true` with a plain-text message — there is no
JSON body for that case. Only the field name reaches the server log. So parse the JSON payload of
a tool result, but check `isError` first.
## Why the verification numbers are trustworthy
cad-mcp does not grade its own homework. The checks below compare the tessellated mesh against
**closed-form results computed independently of the mesher**, so a bug in the mesher cannot
cancel itself out. All values are reproducible with `python scripts/make_verification.py`
and stored in [`docs/verification.json`](docs/verification.json).
| Check | Measured | Independent value | Error |
|---|---|---|---|
| Gear circular tooth thickness at the pitch circle | 3.92488 mm | πm/2 − backlash/2 = 3.90699 mm | **+0.458 %** |
| SDF sphere volume, r = 10 mm, 96³ grid | 4184.7218 mm³ | 4/3·πr³ = 4188.7902 mm³ | **0.097 %** |
| Binary STL round-trip (gear, 19,912 tris) | 995,684 bytes, volume Δ 0.0 mm³ | 84 + 50n = 995,684 bytes | **exact** |
| Gear minimum wall thickness (240 samples) | 2.1124 mm (p05 = 2.4258, median = 7.5016) | — | — |
The residual 0.458 % on tooth thickness is not a bug: it is the polygon approximation of the
involute flank at the pitch circle (12 flank samples per tooth). Tightening `flank_samples`
converges it toward πm/2, which is exactly the behaviour you want a validator to show.
Every part below is watertight with zero non-manifold edges:
| Part | Triangles | Genus | Volume | Notes |
|---|---|---|---|---|
| `involute_helical_gear` (19T, m2.5, 22°) | 19,912 | 1 | 31,140.45 mm³ | genus 1 = the bore, nothing else |
| `twisted_vase` | 24,860 | 0 | 62,960.36 mm³ | hollow, superellipse cross-section |
| `torus_knot_tube` | 18,480 | 1 | 125,254.19 mm³ | swept tube, self-intersection free |
| `lattice_section_sample` (gyroid) | 128,284 | 87 | — | 33.3 % solid fraction |
| SDF gyroid, 64³ grid | 33,900 | 197 | 2,682.08 mm³ | 20 mm cube, 0.625 mm voxels |
## Speed
Single-threaded, pure NumPy, on an ordinary desktop CPU (`scripts/make_verification.py`):
| Operation | Time |
|---|---|
| `make_part` gear, 19 teeth (0.5 s of that is involute + fillet tessellation) | 0.54 s |
| `make_part` vase | 0.09 s |
| `make_lattice` gyroid, 64³ | 0.07 s |
| `check_mesh`, 3 cross-sections | 0.003 s |
| `export_mesh` binary STL | 0.009 s |
| `render` one 960×720 view | 3.5 s |
| `render` 4-view grid, 320 px each | 2.3 s |
| `render` 12-frame turntable GIF | 4.5 s |
Modelling is cheap; rendering is the slow part, which is why `look_at_mesh` takes a `views`
count and a `size` — ask for one 640×480 view while iterating, four larger ones when you are done.
## What is inside
```
src/cadmcp/
├── geom.py 34 KB Mesh, welding, topology report (Euler characteristic, genus,
│ manifold audit), slicing, min wall thickness, ear-clipping,
│ solid/tube sweeps
├── models.py 16 KB the four parametric parts, involute flanks + root fillets
├── implicit.py 36 KB SDF primitives, booleans, smooth min/max, twists, gyroid /
│ Schwarz-P, surface-nets mesher
├── render.py 52 KB software rasteriser: supersampled triangle fill, Phong shading,
│ contact shadows, ground pool, perspective/ortho cameras,
│ turntables, multi-view grids
├── meshio.py 5 KB binary/ASCII STL, OBJ+MTL round-trip
└── server.py the MCP layer
```
The kernel is deliberately **analytic where it can be, numeric where it must be**: parametric
parts are exact sweeps of closed profiles, while lattices come out of an implicit field.
Four implementation notes that cost real debugging time, in case you fork this:
- **`sdf_to_mesh(f, lo, hi, res)` takes a resolution *count* per axis, not a voxel size.**
Passing a voxel size (~0.3) makes `min(nx,ny,nz) < 2` and the mesher refuses. The compute
domain is also padded 12 % so the iso-surface never touches the boundary.
- **Pillow's built-in font has no CJK glyphs.** Every Chinese label in a `render_grid` came out
as `□□□` — in the exact images meant for this README. The renderer now resolves a system CJK
font (Microsoft YaHei / SimHei / PingFang / Noto CJK), centres the label on its real glyph
bbox, and exposes `font_supports_cjk()` so callers can fall back to English. Override with
`CADMCP_FONT`.
- **A watertight mesh can still be garbage.** Set a lattice wall thinner than one voxel and the
surface-nets mesher happily returns *737 disconnected shards* — every one of them a perfectly
closed surface, `watertight=True`, `non_manifold_edges=0`, positive volume. Every "does it look
broken?" signal says no. What catches it is the **shell count**, plus the fact that genus, being
a whole-mesh formula (`g = (2 − χ)/2`), turns negative when the mesh is really a pile of
fragments. That is why `export_mesh` refuses on `fragmented` and why `check_mesh` reports
`shells` — the shard detection is the check that a picture could never have made.
- **Vertex normals belong to a smoothing *sector*, not to a vertex.** The gear rendered with a
fine vertical stipple across its hub and tooth tips while flat panels and every other part came
out clean. Three hypotheses died on data first: six `specular`/`shininess` combinations, eight
tessellation settings, and crease angles from 5° to 90° all produced the identical stipple — the
last one because a fixed count of 92 edges sit at exactly 180°, independent of sampling. The
cause was real: normals were one area-weighted average over *every* incident face, and the hub
cylinder's vertices sit directly against a coplanar annular face, which dragged the average
toward +Z. Measured on the tip cylinder, normals deviated from radial by a median of 26.4° and
up to 81.2°. They are now accumulated per **smoothing sector** — connected components along
non-hard edges, the equivalent of a smoothing group. Smooth surfaces collapse back to one
sector, so the fix is provably inert where it should be, and `tests/test_kernel.py` asserts both
directions: the torus knot matches the naive average to 1e-6°, the gear does not.
## Development
```bash
python tests/test_e2e_stdio.py # 62 checks, drives a real server subprocess over stdio
python tests/test_kernel.py # geometry kernel unit tests
python scripts/preflight.py # 54 checks that every claim in THIS README still holds
python scripts/make_demo.py # regenerate docs/gallery.png + docs/gear-turntable.gif
python scripts/make_verification.py # regenerate docs/verification.json
python scripts/make_session_demo.py # regenerate docs/demo-session.md
```
`scripts/preflight.py` exists because a README is the part of a project that rots silently. Code
gets refactored, an argument is renamed, a verification number is recomputed — and no unit test
notices, because unit tests test code. So every factual claim in this file is an assertion: it
checks that each referenced file exists, that the numbers printed in the verification table match
`docs/verification.json` character for character, that CI really runs both test scripts on all
three platforms, and that the four built-in parts still build with the genus this README claims.
The end-to-end test is not a mock: it spawns `python -m cadmcp.server`, speaks the real MCP
protocol over stdio, calls every tool, and asserts that `look_at_mesh` returns an `image`
content block **before** the text summary — that ordering is what makes the model look at the
picture instead of skipping it.
Both test scripts reconfigure their output to UTF-8 on startup. A Chinese Windows console
defaults to cp936, where a single `mm³` in an assertion message raises `UnicodeEncodeError` —
turning a clean pass/fail report into a stack trace. CI hides this behind
`PYTHONIOENCODING: utf-8`; running the tests locally does not, so they fix it themselves.
## Limitations
- Three parts families, not a general CAD kernel. No fillets/chamfers on arbitrary edges, no
B-rep booleans, no constraints or assemblies.
- Models live in server memory for the session. Restart the server and they are gone — export
what you want to keep.
- `look_at_mesh` needs Pillow; the modelling and verification tools do not.
- STL/OBJ only. No STEP, so nothing here round-trips back into a parametric CAD system.
- SDF meshing is grid-bound: sharp features below one voxel are lost, and `res` is a memory
knob as much as a quality knob (capped at 200 M voxels).
## License
MIT — see [LICENSE](LICENSE).
TDQS
Scored across 7 tools
The three build/inspect verbs are well-separated: make_part (library parts) vs make_lattice (free-form SDF) vs list_parts (discovery). check_mesh, look_at_mesh, and describe_model overlap somewhat (all report topology/bbox), but their descriptions clearly split numeric evidence, visual rendering, and parameter recall.
Nearly all names follow a verb_object snake_case pattern (list_parts, make_part, check_mesh, export_mesh, describe_model). The sole deviation is look_at_mesh, which inserts a preposition but remains easily readable.
Seven tools is well-scoped for a model-build-inspect-export workflow; each tool covers a distinct stage and none feels redundant or padded.
Covers discovery, two build paths, numeric and visual verification, export, and parameter recall. Minor gaps: no delete_model, no list_models to enumerate session models, and no pure edit/parameter-update tool, though rebuilding via make_part/make_lattice can work around these.