build123d-mcp
pzfreo/build123d-mcp/CLAUDE.md
Always use uv run pytest — it auto-installs all dependencies from pyproject.toml before running. The target is 100% passing. There are no accepted pre-existing failures — if tests are failing, fix them. Communicates over stdio (MCP SDK v2 MCPServer). When configuring an MCP client, set cwd to the project root. 1. Create tools/<name>.py with a function def <name>(session, ...) -> str. Optional parameters must carry their defaults on this function — the worker wire omits arguments the caller didn't supply.…
CLAUDE.md95 starsChanged 4 months ago
- Commits and pushes
# build123d-mcp
## Running tests
Always use `uv run pytest` — it auto-installs all dependencies from `pyproject.toml` before running.
```
uv run pytest tests/
```
The target is 100% passing. There are no accepted pre-existing failures — if tests are failing, fix them.
## Running the server
```
uv tool run --python 3.12 build123d-mcp
```
Communicates over stdio (MCP SDK v2 `MCPServer`). When configuring an MCP client, set `cwd` to the project root.
## Project structure
```
server.py — MCPServer entry point; registers all MCP tools; holds module-level _session singleton
session.py — Persistent state: namespace, current_shape, objects dict, snapshots
security.py — Three-layer defence: AST check → restricted builtins → exec timeout
session_registry.py — Per-handle CAD sessions for HTTP (cap + idle eviction)
tools/execute.py — Thin wrapper delegating to session.execute()
tools/render.py — pyvista-based PNG rendering; headless (xvfb) if no DISPLAY
tools/measure.py — Geometry queries returning JSON (bounding_box, volume, area, wall thickness, clearance)
tools/export.py — STEP/STL export; path traversal blocked
```
## Adding a new tool
1. Create `tools/<name>.py` with a function `def <name>(session, ...) -> str`.
Optional parameters must carry their defaults on this function — the worker
wire omits arguments the caller didn't supply.
2. Add a typed stub method on `WorkerSession` in `worker.py`:
```python
@_op(_tool(f"{_T}.<name>:<name>"), _GEOMETRY_TIMEOUT)
def <name>(self, arg1: str, arg2: float = 0.0) -> str:
raise NotImplementedError
```
The `@_op` decorator registers the op in `_OPS` and replaces the body with
bind-and-send — the signature is the single typed wire interface, so keep
its defaults equal to the tool function's. Ops that need more than
`fn(session, **args)` in the worker get a small `_op_<name>` handler
instead of `_tool(...)`.
3. Register in `server.py`:
```python
@mcp.tool()
def <name>(...) -> str:
"""Docstring shown to MCP clients."""
return _session.<name>(...)
```
4. Classify the op in `tests/test_worker_boundary_coverage.py` (add it to the
smoke inventory, `SESSION_STATEFUL_TOOLS`, or the reasoned allowlist) —
`test_every_dispatch_op_is_classified` fails until you do.
## Session model
- **Namespace persists** across `execute()` calls — imports and variables accumulate.
- **`current_shape`** is auto-detected after each execute: an explicit `show()`/`annotate()` in the call wins, then a variable named `result`, then any new `BuildPart` or `Shape`.
- **`objects` dict** holds named shapes registered via `show(shape, name=None)`. Name defaults to `"shape"` if omitted.
- **Snapshots** save `current_shape` + `objects` only — the Python namespace is NOT restored on `restore_snapshot()`.
- **`reset()`** clears everything: namespace, shapes, objects, snapshots.
## Concurrency model
- **A `WorkerSession` is single-threaded over its pipe.** `_call()` holds
`self._lock` across `send → poll → recv`, so concurrent callers serialise.
This matters under HTTP transport, where one shared `WorkerSession` fields
concurrent requests — without the lock, replies mispair. **Subclasses
override `_do_call`, not `_call`**, so they inherit the guard (e.g.
`InProcessSession`). See `docs/adr/0001-worker-ipc-concurrency.md` (#322).
- **HTTP mode shares one CAD session by default.** The lock makes the sharing
*safe*, not *isolated* — concurrent clients still see one namespace. This is
still the default (`--max-sessions 1`), so existing deployments are unchanged.
- **`--max-sessions N` (N > 1) isolates clients by handle.** A request carrying
an `Mcp-Cad-Session` header gets that handle's own `WorkerSession` from
`SessionRegistry` (`session_registry.py`), resolved by `CadSessionMiddleware`
and created on first sight; requests without the header still share the
default session. Sessions are capped and evicted after
`--session-idle-timeout` seconds, because each one is a subprocess with the
OCC kernel loaded.
- **The handle comes from outside the MCP client** — an auth gateway or a
client's static config. It is deliberately *not* the MCP protocol session id,
which dies on reconnect and would silently discard a half-built model. **We do
not authenticate handles**: put an auth gateway in front, and a session-aware
load balancer if running multiple processes. See
`docs/adr/0003-http-cad-session-handles.md` (#428), which supersedes ADR
0001's "isolation is the embedder's job" position.
## Security model
Three layers, all must pass before user code runs:
1. **AST check** — blocks imports of anything not in the allowlist (`build123d`, `math`, `numpy`, `typing`, `collections`, `itertools`, `functools`, `copy`) and bare calls to `eval`, `exec`, `open`, etc.
2. **Restricted builtins** — exec namespace gets a filtered `__builtins__` dict; `open`, `eval`, `exec`, `compile` removed; `__import__` wrapped to enforce the same allowlist at runtime.
3. **Exec timeout** — default 120 s wall-clock, configurable via `--exec-timeout` CLI flag or `BUILD123D_EXEC_TIMEOUT` env var. After timeout, the thread continues in background and the namespace may be dirty; callers should `reset()` or `restore_snapshot()`.
Known limits: memory exhaustion is not bounded; Python introspection chains can escape the sandbox; part-library files (`--library`) are trusted input — the AST check on them is non-transitive.
## Gotchas
- After a timeout the namespace may be partially modified — don't trust state without a reset.
- `restore_snapshot()` restores geometry only; Python variables set after the snapshot remain in scope.
- `show()` stores shapes by reference; mutating the shape object after calling `show()` will affect the stored object.
- Clip plane in `render_view` slices at the mesh's own bounding-box midpoint, not world origin.
## Releasing
**Cutting a release means one command:** `gh release create vX.Y.Z --generate-notes` (or use the GitHub Release UI). That triggers the `Publish` workflow which builds, uploads to PyPI, and auto-bumps `pyproject.toml` to the next `.dev0` version. Nothing else is needed.
**Never edit `pyproject.toml` manually. Never push tags manually.** Manual edits/tags don't trigger the publish workflow and create orphan tags + version drift. If you see `pyproject.toml` showing `0.3.14.dev0`, that means `0.3.13` is the current PyPI release and `0.3.14` is the next planned release; don't "fix" the version.
**Version convention:** between releases, `pyproject.toml` carries a `.dev0` suffix (PEP 440 dev release). `0.3.12 < 0.3.13.dev0 < 0.3.13.dev99 < 0.3.13` — TestPyPI builds (`.devN`) are always newer than the last published release but older than the eventual real release.
**Before cutting a release:** make sure `CHANGELOG.md`'s top entry matches the version you're about to release (strip the `.dev0` suffix mentally — `pyproject.toml = 0.3.14.dev0` means you're cutting `v0.3.14`).
Discussion
Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.
Posts are public.Sign in to post
No one has posted yet. Be the first.

