mcp-spatial-critic
# mcp-spatial-critic
[](https://pypi.org/project/mcp-spatial-critic/)
[](./tests)
[](./LICENSE)
[](./pyproject.toml)
An **MCP server** (Model Context Protocol, stdio JSON-RPC 2.0) that gives headless
CAD agents a deterministic 3D-printing critic. Zero dependencies — pure Python
standard library, no numpy, no trimesh, no Blender, no OpenSCAD at runtime.
> An LLM can write OpenSCAD that looks correct in a screenshot and still exports a
> mesh that is inside-out, floating 5 mm above the plate, split into three bodies,
> or full of zero-area slivers. `mcp-spatial-critic` fails the build **before** the
> printer does.
```text
agent → generate STL → mcp-spatial-critic → {"approved": false, "errors": ["NON_MANIFOLD"]}
→ agent repairs → re-audit → {"approved": true}
```
## Why agents need this
Text-only agents cannot see geometry. Vision models see a render, not a manifold.
The failure modes below all render beautifully and all fail in a slicer:
| Defect | What the render shows | What the slicer does |
| --- | --- | --- |
| Non-manifold edges | Normal shade | Refuses to slice, or silently deletes shells |
| Floating base | Normal shade | Starts printing in mid-air |
| Disconnected shells | Normal shade | Prints 3 loose parts instead of one |
| Inverted normals | Normal shade (mostly) | Treats solid as void, prints nothing |
| Sub-nozzle features | Normal shade | Vase mode collapse / missing wall |
## The five invariants
Every audit returns a `critic_score` from `0.0` to `10.0` and a list of critiques.
| Check | Penalty | Error code |
| --- | --- | --- |
| Every edge shared by exactly 2 triangles | −5.0 | `NON_MANIFOLD` |
| Lowest Z sits on the build plate | −2.0 | `FLOATING_BASE` |
| Exactly one connected shell | −2.0 | `MULTIPLE_SHELLS` |
| Smallest bounding dimension ≥ `min_wall_mm` | −1.5 | `THIN_FEATURE` |
| Positive signed volume (outward normals) | −1.0 | `INVERTED_OR_ZERO_VOLUME` |
| No zero-area facets | −1.0 | `DEGENERATE_TRIANGLES` |
`approved` is `true` only when the error list is empty, the mesh is watertight and
there is a single shell. The score is a ranking signal for repair loops; `approved`
is the gate.
## Install
```bash
# from GitHub — works today
pip install git+https://github.com/vanovri/mcp-spatial-critic
# or a checkout you can hack on
git clone https://github.com/vanovri/mcp-spatial-critic
cd mcp-spatial-critic
pip install -e .
```
Requires Python 3.10+. No third-party runtime packages.
## Register the MCP server
Add this to your agent's MCP configuration (Claude Desktop `claude_desktop_config.json`,
Claude Code `.mcp.json`, or any MCP-compatible client):
```json
{
"mcpServers": {
"spatial-critic": {
"command": "mcp-spatial-critic",
"args": []
}
}
}
```
The server resolves relative paths against the directory it was launched from. Set
`MCP_SPATIAL_CRITIC_PRESETS` to override where `list_presets` looks.
## Tools
### `audit_stl`
```json
{
"name": "audit_stl",
"arguments": { "path": "build/bracket.stl", "min_wall_mm": 2.0 }
}
```
Response payload (also usable directly as a Python dict via `audit_stl_file`):
```json
{
"file": "bracket.stl",
"path": "build/bracket.stl",
"triangle_count": 12,
"vertex_count": 8,
"watertight": true,
"non_manifold_edge_count": 0,
"shell_count": 1,
"bounding_box_mm": {
"width_x": 20.0, "depth_y": 20.0, "height_z": 10.0,
"min_z": 0.0, "max_z": 10.0
},
"volume_mm3": 4000.0,
"degenerate_triangle_count": 0,
"critic_score": 10.0,
"approved": true,
"critiques": [],
"errors": []
}
```
`isError` is set on the MCP result whenever `approved` is false, so a tool-using
agent sees the failure as a tool error rather than a success.
### `generate_reference_box`
Writes a known-good watertight box and audits it. Use it as a control experiment
when an agent claims its exporter is broken.
```json
{ "out_path": "reference_box.stl", "width": 20, "depth": 20, "height": 10 }
```
### `list_presets`
Lists the parametric presets bundled in [`presets/`](./presets):
| Preset | What it demonstrates |
| --- | --- |
| `esp32_mount.scad` | PCB standoff walls, snap lips, USB-C cutout, vent slots |
| `m3_heatset_boss.scad` | M3 brass insert seat with strengthening ribs |
| `pagoda_roof_tier.py` | Generator that splits >45° eaves into printable steps |
```bash
python presets/pagoda_roof_tier.py > pagoda.scad
openscad -o pagoda.stl pagoda.scad
```
## Talk to it without an agent
The transport is line-delimited JSON-RPC 2.0 on stdio, so you can drive it by hand:
```bash
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"generate_reference_box","arguments":{"out_path":"box.stl"}}}' \
| mcp-spatial-critic
```
## Library use
```python
from mcp_spatial_critic import audit_stl_file, audit_stl_mesh, generate_watertight_box_stl
report = audit_stl_file("build/bracket.stl", min_wall_mm=1.6)
if not report["approved"]:
for critique in report["critiques"]:
print("FAIL:", critique)
# or audit an in-memory mesh
report = audit_stl_mesh(generate_watertight_box_stl(height=1.0))
assert "THIN_FEATURE" in report["errors"]
```
## How it works
* **ASCII + binary STL parsers** (`core.py`) — binary uses `struct` with a
50-byte stride; ASCII tolerates CRLF, tabs and malformed vertex lines.
* **Manifold test** — canonicalised undirected edge keys (vertices rounded to
1e-5 mm) counted in a dict; any count ≠ 2 is a non-manifold edge.
* **Shell count** — union-find with path compression over shared vertices.
* **Orientation** — signed volume via the divergence theorem,
`Σ dot(v1, cross(v2, v3)) / 6`.
* **Deterministic output** — no timestamps, no random ordering; the same STL
always produces the same bytes on the wire.
* **Server** — `handle_request()` is a pure function (`dict → dict | None`), so
the whole protocol is unit-testable without starting a process.
```bash
python -m unittest discover -s tests -v # 40 tests
```
## Known limitations (deliberate, tracked as issues)
1. `min_wall_mm` is compared against the **bounding box**, not per-wall ray casts.
A 40 × 40 × 20 mm hollow box with 0.4 mm walls passes this check. Real thin-wall
detection needs slice-plane sampling — see `GOOD_FIRST_ISSUES.md` #3.
2. Only STL is supported. 3MF and OBJ are open tasks.
3. The critic **diagnoses**, it never repairs. Auto-repair belongs in the agent loop.
4. Binary STL headers that begin with `solid` are disambiguated by looking for the
`facet` keyword in the first 512 bytes. A pathological header could fool it.
5. No build-volume check yet (the bbox is reported, the comparison is not).
## Contributing
See [`GOOD_FIRST_ISSUES.md`](./GOOD_FIRST_ISSUES.md) for scoped, test-covered
starter tasks. Every PR needs a test that fails before the fix. Run the suite with
`python -m unittest discover -s tests -v`.
## License
MIT — see [`LICENSE`](./LICENSE).
TDQS
Scored across 3 tools
Each tool has a clearly distinct purpose: auditing an existing STL, generating and auditing a reference box, and listing presets. The generate_reference_box tool's inclusion of an audit step does not blur its boundary because it is explicitly a fixture-creation operation.
All three names follow a consistent snake_case verb_noun pattern: audit_stl, generate_reference_box, list_presets. The convention is predictable and readable.
Three tools is on the low end but fits the focused purpose of an STL audit critic. Each tool earns its place, though the surface is somewhat thin for anything beyond basic auditing.
The core audit operation is covered, but the surface has notable dead ends: presets can be listed but not run or inspected, and there is no repair or re-audit workflow for failed meshes. Agents can work around this for simple audits but will hit gaps in a full CAD validation lifecycle.