Skip to main content
Glama
README.md
# cad-mcp

[![ci](https://github.com/Arookie127/cad-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/Arookie127/cad-mcp/actions/workflows/ci.yml)
[![python](https://img.shields.io/badge/python-3.10%2B-blue)](pyproject.toml)
[![runtime deps](https://img.shields.io/badge/runtime-mcp%20%2B%20numpy-informational)](pyproject.toml)
[![license](https://img.shields.io/badge/license-MIT-green)](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
```

![19-tooth helical gear](docs/gear-turntable.gif)

*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³.*

![Four parts](docs/gallery.png)

## 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

A3.7/5.0

Scored across 7 tools

Disambiguation4/5

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.

Naming Consistency4/5

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.

Tool Count5/5

Seven tools is well-scoped for a model-build-inspect-export workflow; each tool covers a distinct stage and none feels redundant or padded.

Completeness4/5

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.

Maintenance

ActivityMaintained
ResponsivenessNo issues