A parametric-CAD server built for AI agents. partsmith wraps build123d (OpenCascade) behind a small REST API and a Model Context Protocol (MCP) endpoint, so an LLM client like Claude can design real, printable 3D parts by writing build123d Python — then render, measure, validate, version, and export them, all over a single HTTP surface.
It's headless, single-file-simple, and designed to live on a small
Linux host behind an authenticating reverse proxy. The container does
no authentication itself — the perimeter is the security boundary (see
SECURITY.md).
- Author parts in build123d Python (
result = Box(30, 20, 10)), with a library of helpers auto-injected for common 3D-printing patterns (screw holes, slots, fillets, mounting patterns). - See inside designs: 3D shaded views, 2D orthographic projections with dimensions, multiview composites, and cross-sections through any plane — all returned as inline PNGs without leaving the chat.
- Validate printability: watertight / manifold / wall-thickness checks before you ever open a slicer.
- Persist + version designs to disk: every save is a new version,
with a
diffthat reports source changes + geometry deltas (volume, surface area, bounding box) between any two versions. - Export to STL / STEP / 3MF.
Everything works identically over REST and MCP — both share the same in-process engine, so a model authored via one protocol is visible to the other.
partsmith ships a docker-compose.yml with x-casaos metadata in
JLay2026/zimaboard-services.
Paste it into the ZimaOS dashboard's Custom Install, fill the form,
click Install. See
docs/partsmith-setup.md
there for the full walkthrough (including the one-time bind-mount chown
step).
The image is published to GHCR on every release:
# Pre-create bind-mount dirs as uid 1000 (the container's user).
# Skipping this makes every export/render return a permission-denied 500.
sudo mkdir -p /srv/partsmith/workspace /srv/partsmith/renders
sudo chown -R 1000:1000 /srv/partsmith/workspace /srv/partsmith/renders
docker run -d --name partsmith \
-p 127.0.0.1:8123:8123 \
-v /srv/partsmith/workspace:/workspace \
-v /srv/partsmith/renders:/renders \
ghcr.io/jlay2026/partsmith:latest
curl http://127.0.0.1:8123/health
# {"status":"ok","version":"0.3.1"}Pin a specific version with :0.3.1 instead of :latest. Available
tags: https://github.com/JLay2026/partsmith/pkgs/container/partsmith.
git clone https://github.com/JLay2026/partsmith
cd partsmith
pip install -e .
python -m uvicorn src.server:app --host 0.0.0.0 --port 8123
curl http://localhost:8123/healthOr build the container locally: docker compose up -d --build
(pre-create + chown workspace/ and renders/ as uid 1000 first, as
above).
partsmith exposes a Streamable-HTTP MCP server at /mcp/. URL-based
MCP clients (Cowork's managed MCP UI, Claude Code, etc.) register it
directly — no shim process.
| Field | Value |
|---|---|
| URL | <your-base-url>/mcp/ (trailing slash required) |
| Transport | streamable-http |
| Auth | Whatever your reverse proxy enforces (send headers via the client's Headers field) |
On connect, the client sees the full partsmith_* tool surface (17
tools as of v0.3.1).
The execution namespace for every create_model / save_design call
has from build123d import * and import numpy as np pre-loaded, plus
seven helpers for patterns that recur in real designs:
| Helper | What it makes |
|---|---|
through_hole(diameter, depth) |
Plain cylindrical hole |
screw_hole(diameter, depth, countersink=True, ...) |
Machine-screw hole, M-series defaults |
hex_hole(across_flats, depth) |
Nut pocket (M3=5.5, M4=7, M5=8, M6=10 mm) |
slot(length, width, depth) |
Stadium-shaped slot for adjustable mounts |
chamfer_edges(part, radius, edges='all'|'top'|'bottom'|'side') |
Chamfer with edge selection |
fillet_top_edges(part, radius) |
Round all top-face edges |
screw_pattern(positions, hole_func) |
Apply a hole at each (x, y) |
# Wall bracket: 4 countersunk holes, top edges filleted
body = Box(100, 50, 10)
holes = screw_pattern(
[(10, 10), (90, 10), (10, 40), (90, 40)],
lambda: screw_hole(3.2, 10),
).moved(Location((0, 0, 10)))
result = fillet_top_edges(body - holes, radius=1.5)Models live in memory and are wiped on container restart. Designs
live on disk under {workspace}/designs/{name}/ and survive restarts,
with one vN.py + vN.json pair per version:
designs/bracket/
v1.py v1.json
v2.py v2.json
v3.py v3.json
save_design(name, code)appends the next version (or passversion=Nto overwrite a slot).load_design(name)loads the latest (or a specific version) and registers it as a model.diff_designs(name, v1, v2)returns a unified source diff plus volume / surface-area / bounding-box deltas — "is v3 actually better than v2 in the ways I care about?" answered at a glance.
| Endpoint | Method | Purpose | Since |
|---|---|---|---|
/health |
GET | {"status":"ok","version":"X"} |
v0.1 |
/model/create |
POST | Execute build123d code, store as named model, return geometry + base64 preview | v0.1 |
/model/modify |
POST | Re-execute for an existing name | v0.1 |
/model/list |
GET | List loaded models | v0.1 |
/model/{name}/measure |
GET | Bounding box, volume, area, topology counts | v0.1 |
/render/3d |
POST | 3D shaded view (Lambertian), base64 PNG | v0.1 |
/render/2d |
POST | 2D orthographic view + optional dimensions | v0.1 |
/render/multiview |
POST | Composite: front + right + top + iso | v0.1 |
/render/section |
POST | 2D cross-section on XY/XZ/YZ at an offset | v0.2.6 |
/render/all |
POST | Render every standard view to disk | v0.1 |
/export |
POST | Export STL / STEP / 3MF as binary | v0.1 |
/analyze/printability |
POST | Watertight / manifold / wall-thickness check | v0.1 |
/workspace/{filename} |
GET | Stream a previously-exported file (MCP large-file fallback) | v0.2 |
/design/save |
POST | Save a design version (also executes as a model) | v0.2.4 |
/design/list |
GET | List saved designs (latest of each) | v0.2.4 |
/design/{name} |
GET | Load source + metadata (?version=N) |
v0.2.4 |
/design/{name} |
DELETE | Delete a version, or all (?version=N) |
v0.2.4 |
/design/{name}/load |
POST | Load + execute a design as a model (?version=N) |
v0.2.4 |
/design/{name}/versions |
GET | List version numbers | v0.2.7 |
/design/{name}/diff |
GET | Diff two versions (?v1=&v2=) |
v0.2.7 |
All prefixed partsmith_ to avoid collisions in multi-server setups.
| Tool | Purpose | Since |
|---|---|---|
partsmith_health |
Status + version + transport | v0.2 |
partsmith_create_model |
Execute build123d code, register as model, return geometry + preview | v0.2 |
partsmith_modify_model |
Re-execute against an existing name | v0.2 |
partsmith_list_models |
List loaded models | v0.2 |
partsmith_measure_model |
Bounding box, volume, area, counts | v0.2 |
partsmith_render_3d |
3D shaded view | v0.2 |
partsmith_render_2d |
2D orthographic projection | v0.2 |
partsmith_render_multiview |
2×2 composite | v0.2 |
partsmith_render_section |
Cross-section through a plane | v0.2.6 |
partsmith_export |
Export STL / STEP / 3MF | v0.2 |
partsmith_analyze_printability |
Watertight / manifold / wall check | v0.2 |
partsmith_save_design |
Save a design version | v0.2.4 |
partsmith_load_design |
Load + execute a saved design | v0.2.4 |
partsmith_list_designs |
List saved designs | v0.2.4 |
partsmith_delete_design |
Delete a design / version | v0.2.4 |
partsmith_list_versions |
List a design's versions | v0.2.7 |
partsmith_diff_designs |
Diff two versions (source + geometry deltas) | v0.2.7 |
Tools that return files (partsmith_render_*, partsmith_export)
inline the bytes as base64 when ≤ PARTSMITH_INLINE_MAX_BYTES
(default 8 MiB). Larger payloads persist to the workspace and
return a url_path the client fetches via GET /workspace/<filename>
with the same auth headers.
| Response shape | When | Client action |
|---|---|---|
{"inline": true, "data_b64": "..."} |
≤ 8 MiB | base64-decode and use |
{"inline": false, "url_path": "/workspace/foo.stl"} |
> 8 MiB | GET base_url + url_path |
| Env var | Default | Purpose |
|---|---|---|
PARTSMITH_WORKSPACE |
/workspace |
Where exports + saved designs land |
PARTSMITH_RENDERS |
/renders |
Where rendered PNGs are cached |
PARTSMITH_HOST |
0.0.0.0 |
Server bind address |
PARTSMITH_PORT |
8123 |
Server bind port |
PARTSMITH_MAX_BODY_BYTES |
1048576 (1 MiB) |
Reject REST requests larger than this. /mcp is exempt. |
PARTSMITH_INLINE_MAX_BYTES |
8388608 (8 MiB) |
MCP file payloads above this return a url_path instead of inlining. |
- Models are in-memory; designs are on disk. Container restart
wipes the model registry (LRU-capped at 32) but never the design
store. Use
save_designto persist anything you want to keep. - Cross-protocol state is shared — REST and MCP wrap the same
CADEngine. - Single uvicorn worker by default — concurrent renders serialize.
Fine for single-user; add
--workers Ninentrypoint.shfor parallelism (~+500 MB resident per worker). - Bind-mount perms — the container runs as uid 1000;
workspace/andrenders/must be writable by that uid. v0.1.3+ surfaces perm failures as a 500 naming the exact path + expected uid.
Two GitHub Actions workflows run on every PR + push to main:
-
ci.yml—rufflint + a lightweightpytestjob (tests/test_versioning.py, pure stdlib, ~13 s). -
integration.yml— builds the container, starts it, and runstests/integration/against/healthand/mcp/. A single MCP handshake test guards against the v0.2.x deploy-bug classes (lifespan crash, double-prefixed URL, DNS-rebinding 421, stateful long-poll hang). Run locally with:docker run -d --rm -p 127.0.0.1:8123:8123 --name p-test \ ghcr.io/jlay2026/partsmith:latest PARTSMITH_URL=http://127.0.0.1:8123 pytest tests/integration/ -v docker stop p-test
The production deploy lives in
JLay2026/zimaboard-services
(partsmith/docker-compose.yml + docs/partsmith-setup.md), fronted
by Caddy with LAN/Tailscale-only matchers. partsmith is reached at
https://cad.<your-internal-domain>/ and its MCP endpoint at
/mcp/. The container itself is loopback-bound; all external access
goes through the proxy perimeter.
ROADMAP.md— themes, status, release sequenceCHANGELOG.md— every release with the "why"SECURITY.md— threat model ("perimeter is the boundary")NOTICE.md— credit to prior build123d-MCP work
There were a few existing build123d-MCP wrappers when this started. We
shipped a new one because (a) the most prominent existing project had
a SyntaxError sitting in its main file for four months with nobody
noticing, and (b) the wrapped surface is small enough (~1,400 LOC) that
owning it outright is cheaper than maintaining a patched fork. The
project follows a "small over capable" principle — every feature has
to justify its line count, and new patterns get added when a real
design has demanded them, not on speculation.
See NOTICE.md for credit to prior work.
MIT — see LICENSE.