Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 60 additions & 86 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,52 @@ All notable changes to [JLay2026/partsmith](https://github.com/JLay2026/partsmit
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
this project follows semver-ish conventions (see [`ROADMAP.md`](ROADMAP.md)).

## [0.3.6] — 2026-06-12

### Changed
- **`create_model` / `modify_model` / `load_design` preview is now
opt-in** (`src/mcp_transport.py` `_do_create`). New `include_preview`
parameter, **default `False`**. Previously every create/modify/load
unconditionally embedded a full 800×600 iso PNG as inline base64
(~75–150 KB → ~25 K+ tokens), which overflowed the calling client's
response token budget on every call. Default responses are now just
`success` + `geometry` + `stdout` (tiny).
- **When requested, the preview is downscaled + capped.** Rendered at
384×288 and gated behind `PARTSMITH_PREVIEW_INLINE_MAX` (default
48 KB); if it still exceeds the cap the bytes are dropped and a
`preview_note` points the caller at `partsmith_render_3d`. Inline
previews now carry `preview_size_bytes` + `preview_sha256` for parity
with the v0.3.5 file-delivery contract.

### Added
- **`tests/integration/test_mcp_tools.py::test_create_model_preview_optional`**
— asserts a default create carries no `preview_data_b64`, and that
`include_preview=True` yields either a capped, sha-verifiable inline
preview or a `preview_note` (never an unbounded raw embed). The
existing round-trip test now asserts the default-no-preview contract.

### Migration note
This changes the default MCP create/modify/load response shape:
`preview_data_b64` no longer appears unless `include_preview=True`.
Callers that relied on the auto-preview should either pass
`include_preview=True` or call `partsmith_render_3d` explicitly. REST
`create_model` is unchanged (HTTP clients aren't subject to the chat
token cap).

### Why
The auto-preview was the last unbounded inline payload after #18 made
exports verifiable. It provided little value (most creates are followed
by `measure` or an explicit render) at the cost of overflowing every
create response. Default-off makes `create_model` lightweight and
predictable. Per ROADMAP Theme 4 (validated quality).

Resolves [#20](https://github.com/JLay2026/partsmith/issues/20).

### Commit
See [`HEAD`](https://github.com/JLay2026/partsmith/commits/main).

---

## [0.3.5] — 2026-06-12

### Added
Expand Down Expand Up @@ -189,26 +235,11 @@ delivered: all four originally-scoped integration test files exist
transport, the tool inventory, real tool execution, and proxy-header
handling on every PR.

### Design notes
- **Defensive result parsing.** FastMCP's exact tools/call response
shape (structuredContent vs. content[0].text) varies by version;
`_tool_result_dict()` handles both so a FastMCP bump doesn't
spuriously break the round-trip test.
- **Conditional skip on the scheme test.** The slash-redirect behavior
is server/version-dependent; the test guards the regression when the
redirect exists and skips cleanly otherwise rather than asserting on
behavior that may not be present. The baseline
`test_forwarded_headers_accepted` always runs.
- **Still no new runtime deps.** Tests use `requests` (already a dev
dep since v0.3.1). The integration container has the full stack;
these tests just drive it over HTTP.

### Why
v0.3.1 shipped the CI framework + transport/inventory tests but
deferred the two heavier tests to keep that ship tight. With the
framework proven green across the v0.3.1 merge, completing the suite
now means real tool execution + proxy-header handling are both under
regression guard before Theme 2 (output fidelity) work begins.
deferred the two heavier tests to keep that ship tight. Completing the
suite now means real tool execution + proxy-header handling are both
under regression guard before Theme 2 (output fidelity) work begins.

Per ROADMAP Theme 4 ("Validated quality").

Expand All @@ -225,77 +256,20 @@ See [`HEAD`](https://github.com/JLay2026/partsmith/commits/main).
Installs only `pytest + fastapi + pydantic` (~10 sec); tests that
need build123d / trimesh / matplotlib run against the real container
via the integration workflow instead.
- **`.github/workflows/integration.yml`.** New separate workflow that:
1. Builds the partsmith image from the current branch (Buildx +
GH Actions cache; ~30 sec on warm cache, ~5 min first time)
2. Starts the container on 127.0.0.1:8123, waits up to 90s for
`/health` to return 200
3. Runs `pytest tests/integration/` with `PARTSMITH_URL` set
4. Dumps `docker logs partsmith` on failure for diagnosis
- **`tests/integration/`** suite (initial scaffold + 3 tests):
- `conftest.py` — `partsmith_url` fixture; skips if
`PARTSMITH_URL` env unset so local pytest discovery is harmless
- `test_health.py:test_health_returns_200_with_version` — REST
`/health` smoke. Catches v0.1.2-class deploy regressions
(port-bind failure, FastAPI lifespan crash, etc.)
- `test_mcp_handshake.py:test_mcp_initialize_returns_partsmith_serverinfo`
— POST `/mcp/` initialize. Catches v0.2.1 (lifespan / URL prefix),
v0.2.2 (DNS rebinding 421), v0.2.3 (stateful long-poll hang)
regressions
- `test_mcp_handshake.py:test_mcp_tools_list_includes_expected_surface`
— verifies the full v0.2.0 + v0.2.4 + v0.2.6 + v0.2.7 tool surface
is exposed; banded by version cohort so a missing tool is a clear
signal of which release regressed
- **`requests>=2.28.0`** dev dependency for integration test HTTP client.

### Deferred to a future patch (v0.3.2 or later)
Issue [#5](https://github.com/JLay2026/partsmith/issues/5) originally
scoped four integration test files. Shipped 2/4 here; the other 2 land
as a follow-up once this framework has proven stable in CI for a week
or two of actual PRs:

- `test_mcp_tools.py` — full round-trip: initialize → call
`partsmith_create_model` with a cube → verify success + geometry +
preview_data_b64 → call `partsmith_export` → verify STL bytes
decodable. Higher complexity (chain of MCP tool calls), value is
important but deferred to keep the v0.3.1 ship surface tight.
- `test_caddy_compat.py` — verify uvicorn handles `X-Forwarded-Proto:
https` correctly. Catches the v0.2.1 scheme-downgrade regression.
Easy to add but separate concern.

### Design notes
- **Two workflows, not one.** Lightweight CI (ruff + pytest) runs in
~30 sec and gates every PR. Integration runs in 1-5 min and runs
alongside but doesn't block. Separation means a build123d API drift
doesn't sneak in just because pip cache went stale.
- **`PARTSMITH_URL` env var, not testcontainers-python.** The
`testcontainers` library adds a dep, complicates local dev, and
doesn't materially simplify the CI workflow over plain
`docker run + curl + pytest`. Skipped per the project's
"small over capable" toolkit preference.
- **Local-dev story preserved.** `conftest.py` skips if
`PARTSMITH_URL` is unset, so `pytest tests/integration/ -v` on a
laptop without a running container just says "skipped". Run with
`PARTSMITH_URL=http://127.0.0.1:8123 pytest tests/integration/ -v`
against a local container to validate before pushing.
- **GH Actions cache for Docker layers.** `cache-from / cache-to type=gha`
on the buildx step means subsequent runs reuse the OpenCASCADE Python
wheel layer (the expensive part). First-PR build is ~5 min; rebuilds
on the same branch are ~30 sec.
- **`.github/workflows/integration.yml`.** New separate workflow that
builds the partsmith image, starts the container on 127.0.0.1:8123,
waits for `/health`, and runs `tests/integration/` with
`PARTSMITH_URL` set.
- **`tests/integration/`** suite (scaffold + 3 tests): `conftest.py`
(`partsmith_url` fixture; skips if unset), `test_health.py`,
`test_mcp_handshake.py` (initialize + tool inventory).
- **`requests>=2.28.0`** dev dependency for the integration HTTP client.

### Why
v0.2.x shipped four deploy bugs (lifespan, double-prefix path, scheme
downgrade, DNS rebinding) that would have been caught in 5 minutes by a
container-based integration test. Cost was ~5 hours of evening debugging
across the v0.2.0 → v0.2.3 cycle. This is the boring defensive layer
that protects every future feature ship.

Resolves the immediate ask of [#5](https://github.com/JLay2026/partsmith/issues/5)
(pytest integration suite + GH Actions CI workflow exist + run on every
PR). Two integration tests remain to land in a follow-up.

Per ROADMAP Theme 4 ("Validated quality"). Fifth v0.3.x item to ship,
and the one that protects investment in everything else.
downgrade, DNS rebinding) that a container-based integration test would
have caught in 5 minutes. This is the boring defensive layer that
protects every future feature ship. Per ROADMAP Theme 4.

### Commit
See [`HEAD`](https://github.com/JLay2026/partsmith/commits/main).
Expand Down
22 changes: 13 additions & 9 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ Strategic direction for [JLay2026/partsmith](https://github.com/JLay2026/partsmi
For past releases see [`CHANGELOG.md`](CHANGELOG.md). For tactical work in
flight see [the issues tracker](https://github.com/JLay2026/partsmith/issues).

**Last updated:** 2026-06-12 (post-v0.3.5Sprint A closed two Theme 2
items: cross-section fill + topology deltas v0.3.3, dimensioned drawings
v0.3.4. v0.3.5 added robust artifact delivery (Theme 4). Renderer-swap
spike remains).
**Last updated:** 2026-06-12 (post-v0.3.6 — Theme 2 substantially done
(cross-section + fill/deltas + drawings); Theme 4 hardened delivery
(v0.3.5 verifiable file responses, v0.3.6 opt-in capped preview).
Renderer-swap spike #14 remains).

---

Expand All @@ -18,7 +18,7 @@ spike remains).
| Theme 1 — Author ergonomics | ✅ Effectively complete (#1, #2, #3 shipped; #4 backlog) |
| Theme 2 — Output fidelity | 🟡 In progress (cross-section v0.2.6 + fill/deltas v0.3.3 + drawings v0.3.4 shipped; renderer swap remains) |
| Theme 3 — Print workflow | ⬜ Not started |
| Theme 4 — Validated quality | ✅ Complete (#5 integration suite v0.3.1+v0.3.2; #18 robust artifact delivery v0.3.5) |
| Theme 4 — Validated quality | ✅ Complete (#5 integration suite v0.3.1+v0.3.2; #18 verifiable delivery v0.3.5; #20 opt-in preview v0.3.6) |
| Theme 5 — Operational (deploy) | ✅ ZimaOS Custom Install live; nut/ retrofit backlog |

---
Expand Down Expand Up @@ -107,24 +107,27 @@ The four v0.2.x deploy bugs (see CHANGELOG) would have been caught by
integration tests against a containerized partsmith. **The full suite
now exists** (v0.3.1 framework + v0.3.2 completion) and runs on every PR.
**v0.3.5** added a verifiable file-delivery contract after a real
silent-truncation incident.
silent-truncation incident; **v0.3.6** made the create preview opt-in +
capped, killing the last unbounded inline payload.

| Issue | Item | Status |
|---|---|---|
| [#5](https://github.com/JLay2026/partsmith/issues/5) | pytest integration suite + GitHub Actions CI | ✅ Complete — framework v0.3.1, full suite v0.3.2 |
| [#18](https://github.com/JLay2026/partsmith/issues/18) | Robust artifact delivery — sha256 + size_bytes + url_path fallback on file responses | ✅ Shipped v0.3.5 |
| [#20](https://github.com/JLay2026/partsmith/issues/20) | Opt-in, capped create/modify/load preview (no more unbounded auto-PNG) | ✅ Shipped v0.3.6 |

**Shipped:**
- `ci.yml` lightweight pytest job (ruff + `tests/test_versioning.py` on every PR, ~30s)
- `integration.yml` — builds the container, starts it, runs `tests/integration/` against `/health` + `/mcp/`
- Integration files: `test_health`, `test_mcp_handshake` (v0.3.1);
`test_mcp_tools` (create→export→section→drawing round-trips + export
integrity metadata), `test_caddy_compat` (X-Forwarded-Proto scheme
check) (v0.3.2+)
- Catches the v0.2.1/v0.2.2/v0.2.3 regression classes in a single MCP handshake test
integrity metadata + preview opt-in), `test_caddy_compat`
(X-Forwarded-Proto scheme check) (v0.3.2+)
- v0.3.5: every MCP file response carries `sha256` + `size_bytes`
(catches silent truncation on the client write) and exports advertise
a fetchable `url_path` fallback
- v0.3.6: `create_model`/`modify_model`/`load_design` preview is opt-in
(`include_preview`, default off), downscaled + capped when requested

---

Expand Down Expand Up @@ -162,6 +165,7 @@ v0.3.2 ✅ Theme 4 #5 — integration suite completed (mcp_tools, caddy_compat)
v0.3.3 ✅ Theme 2 #13 — cross-section fill + topology deltas
v0.3.4 ✅ Theme 2 #12 — dimensioned drawings
v0.3.5 ✅ Theme 4 #18 — robust artifact delivery (sha256 + url_path on file responses)
v0.3.6 ✅ Theme 4 #20 — opt-in, capped create preview
---- you are here ----
v0.3.x — Theme 2 #14 renderer-swap spike (likely declined) or feature callouts
v0.5.0 — Theme 3 (3MF metadata, pre-slicing analysis)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "partsmith"
version = "0.3.5"
version = "0.3.6"
description = "Minimal headless parametric-CAD server (build123d + REST + MCP)"
license = { text = "MIT" }
authors = [
Expand Down
2 changes: 1 addition & 1 deletion src/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
# SPDX-License-Identifier: MIT
"""partsmith — minimal headless parametric-CAD server."""

__version__ = "0.3.5"
__version__ = "0.3.6"
70 changes: 59 additions & 11 deletions src/mcp_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
e.g. a shell-heredoc write that got cut off), and
exports always expose a fetchable url_path fallback,
not just files over the inline cap.
v0.3.6 (issue #20): create/modify/load preview is now opt-in
(include_preview, default False) and, when requested,
downscaled + capped so it can never blow the client's
response token budget. Previously every create
embedded a full 800x600 iso PNG unconditionally.
"""
from __future__ import annotations

Expand Down Expand Up @@ -41,6 +46,14 @@
os.environ.get("PARTSMITH_INLINE_MAX_BYTES", str(8 * 1024 * 1024))
)

# v0.3.6: opt-in create/modify/load preview. Rendered small and capped
# well below the chat-client response budget so it can never overflow it
# (a full 800x600 iso PNG was ~75-150 KB -> ~25k+ tokens of base64).
PREVIEW_SIZE = (384, 288)
PREVIEW_INLINE_MAX = int(
os.environ.get("PARTSMITH_PREVIEW_INLINE_MAX", str(48 * 1024))
)

# Extensions that GET /workspace/{filename} will actually serve (the
# server's own allow-list). url_path is only advertised for these; render
# PNGs live in the renders dir, are not persisted to the workspace, and
Expand Down Expand Up @@ -115,15 +128,26 @@ def build_mcp(
"""Build a FastMCP server exposing partsmith tools."""
mcp = FastMCP("partsmith")

def _do_create(code: str, name: str) -> dict:
def _do_create(code: str, name: str, include_preview: bool = False) -> dict:
result = engine.execute_code(code, name)
if result.get("success") and result.get("geometry"):
if include_preview and result.get("success") and result.get("geometry"):
state = engine.get(name)
if state and state.shape:
try:
png = render_3d(state.shape, view="iso")
result["preview_data_b64"] = base64.b64encode(png).decode("ascii")
result["preview_content_type"] = "image/png"
png = render_3d(state.shape, view="iso", size=PREVIEW_SIZE)
if len(png) <= PREVIEW_INLINE_MAX:
result["preview_data_b64"] = base64.b64encode(png).decode(
"ascii"
)
result["preview_content_type"] = "image/png"
result["preview_size_bytes"] = len(png)
result["preview_sha256"] = hashlib.sha256(png).hexdigest()
else:
result["preview_note"] = (
f"Preview ({len(png)} bytes) exceeds the "
f"{PREVIEW_INLINE_MAX}-byte inline cap; call "
"partsmith_render_3d for a full render."
)
except Exception as e:
result["preview_error"] = str(e)
return result
Expand All @@ -144,7 +168,11 @@ def partsmith_health() -> dict:
}

@mcp.tool()
def partsmith_create_model(code: str, name: str = "default") -> dict:
def partsmith_create_model(
code: str,
name: str = "default",
include_preview: bool = False,
) -> dict:
"""Execute build123d Python code and register the resulting shape.

Execution namespace includes ``from build123d import *``,
Expand All @@ -154,13 +182,30 @@ def partsmith_create_model(code: str, name: str = "default") -> dict:

Models are in-memory only. Use partsmith_save_design to persist
source code that survives container restart (versioned).

Args:
code: build123d source. Assign the final shape to ``result``.
name: Model identifier (default "default").
include_preview: When True, embed a small inline iso PNG
(preview_data_b64 + preview_sha256/size_bytes), capped so
it can't overflow the response. Default False -- keeps the
response light; call partsmith_render_3d when you actually
want to see the model.
"""
return _do_create(code, name)
return _do_create(code, name, include_preview=include_preview)

@mcp.tool()
def partsmith_modify_model(code: str, name: str = "default") -> dict:
"""Re-execute build123d code under an existing model name."""
return _do_create(code, name)
def partsmith_modify_model(
code: str,
name: str = "default",
include_preview: bool = False,
) -> dict:
"""Re-execute build123d code under an existing model name.

See partsmith_create_model for ``include_preview`` (default
False; opt in for a small inline preview).
"""
return _do_create(code, name, include_preview=include_preview)

@mcp.tool()
def partsmith_list_models() -> dict:
Expand Down Expand Up @@ -398,6 +443,7 @@ def partsmith_save_design(
def partsmith_load_design(
name: str,
version: Optional[int] = None,
include_preview: bool = False,
) -> dict:
"""Load a saved design from disk and execute it as a model.

Expand All @@ -408,6 +454,8 @@ def partsmith_load_design(
Args:
name: Design identifier (must exist in the design store).
version: Specific version int, or None (default) for latest.
include_preview: When True, embed a small inline iso PNG
(default False; see partsmith_create_model).

Returns same shape as partsmith_create_model plus:
loaded_from: "design_store"
Expand All @@ -420,7 +468,7 @@ def partsmith_load_design(
except ValueError as e:
return {"error": f"Invalid name or version: {e}"}

result = _do_create(code, name)
result = _do_create(code, name, include_preview=include_preview)
result["loaded_from"] = "design_store"
result["design_metadata"] = metadata.to_dict()
return result
Expand Down
Loading
Loading