Skip to main content
Glama

cad-mcp

ci python runtime deps 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

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

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:

{
  "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, 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%.

Related MCP server: gemini-cad-mcp

Install

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:

.venv/bin/python -m cadmcp.server --check     # versions, renderer, font, output dir

Troubleshooting

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.

Pillow is missing. pip install "cad-mcp[render]". Modelling and verification work without it.

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.

Configure your client

{
  "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:

{
  "mcpServers": {
    "cad": {
      "command": "C:\\dev\\cad-mcp\\.venv\\Scripts\\python.exe",
      "args": ["-m", "cadmcp.server"],
      "env": { "CAD_MCP_OUT": "C:\\dev\\cad-mcp\\out" }
    }
  }
}
claude mcp add cad -- /absolute/path/to/cad-mcp/.venv/bin/python -m cadmcp.server

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:

{
  "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"
      }
    }
  }
}

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.

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

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.

Available Tools

7 tools
check_meshA

Geometrically inspect a model you already built; returns numeric evidence you can use to decide whether it came out right.

Includes watertightness, orientation consistency, Euler characteristic, genus, volume, surface area and bounding box. genus tells you whether the hole count is right (a part with one through-hole has genus=1, a solid has genus=0, a lattice coupon is high). It is a topological invariant, which makes it far more reliable than "looks about right". slices: z heights in mm to also measure cross-section areas; wall_samples>0 samples the minimum wall thickness.

Inputs: name (required), slices, wall_samples.

describe_modelB

Full information about a model you built: parameters, topology, bounding box, files.

Use it mid-session to recall which parameters this model was actually built from.

Inputs: name (required).

export_meshA

Export the model as files for downstream tools: STL (printing / slicing), OBJ+MTL (DCC).

Defaults to both STL and OBJ. There is a hard gate before anything is written: structural damage -- non-manifold edges, non-positive volume, NaN vertices, a fragmented shell -- is refused, because such a mesh punches holes in a slicer or errors out. Watertightness only warns (some shapes are meant to be open). Pass force=True to override.

Inputs: name (required), force, formats, stl_binary.

list_partsA

List the built-in parametric parts and their tunable parameters (with defaults and types).

Call this before modelling to get the exact parameter names -- guessing one raises unknown_parameter.

Inputs: no named parameters.

look_at_meshB

Render the model and return the image directly to you -- this is the core of self-verification.

The render comes back as image content, so you can actually see what you built and decide what to change.

views: 'four' (a 2x2 montage; start here) / 'single' (one viewpoint) / 'grid' (six). turntable_frames>0 also writes a turntable GIF for the human, and returns its path. Returns [image, ...] followed by a JSON summary -- the image always comes first.

Inputs: name (required), size, views, azimuth, +4 more.

make_latticeA

Model free-form geometry with signed distance fields: lattices, porous structures, implicit surfaces.

primitive: sphere / box / rounded_box / gyroid / schwarz_p. size_mm: the part's bounding box in mm. A single number means a cube with that edge length, [x, y, z] an arbitrary box; omit it to use cell_mm as a cube. res: samples per axis -- a precision knob, not a dimension. Start at 64; 128 is fine but 8x slower. The resulting voxel edge (size/res) is reported back. wall_mm: wall thickness for shells and lattices; period_mm: lattice period (defaults to cell_mm). lattice: shell (hollow it out) / gyroid_grading (fill the interior with lattice). subtract: corner_notch / corner_notch2 (cut a corner away to expose the interior).

Inputs: res, name, cell_mm, lattice, +5 more.

make_partB

Build a part from the built-in parametric library (gear / vase / torus knot / lattice coupon).

Returns a topology verdict. See list_parts for valid part values; an unknown key in params is reported explicitly rather than silently ignored. The model is stored in the session and referenced by name from the other tools.

Inputs: part (required), name, params.

Tool Schema Changelog

Recent tool additions, removals, and schema changes observed during successful MCP inspections.

Loading tool schema changelog…

    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

    ActivitySlowing
    ResponsivenessNo recent community issues

    Related MCP Connectors

    Related MCP Servers

    • A
      license
      A
      quality
      B
      maintenance
      Enables multimodal AI to convert photos with reference objects into parametric OpenSCAD models and watertight 3D-printable STL files, including calibration, part generation, and printability inspection.
      7
      MIT
    • A
      license
      Not graded
      quality
      B
      maintenance
      Enables AI agents to design, modify, and verify exact B-rep 3D CAD models by running build123d scripts on an OpenCascade kernel, with live three.js preview, collision and FDM printability checks, text-based spatial perception, user notes/sketch annotations, version history, and one-click export to OrcaSlicer.
      1
      MIT
    • A
      license
      Not graded
      quality
      C
      maintenance
      Enables AI coding agents to turn natural-language descriptions and dimensional sketches into validated, watertight 3D-printable STL files by compiling build123d scripts, inspecting meshes, and rendering deterministic multiview previews.
      MIT