diff --git a/CHANGELOG.md b/CHANGELOG.md index 776096a..2e9adcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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"). @@ -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). diff --git a/ROADMAP.md b/ROADMAP.md index 38bf402..8f534ac 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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.5 — Sprint 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). --- @@ -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 | --- @@ -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 --- @@ -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) diff --git a/pyproject.toml b/pyproject.toml index a82f486..cc52c81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/src/__init__.py b/src/__init__.py index 53eaaa8..c7ac6cf 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -2,4 +2,4 @@ # SPDX-License-Identifier: MIT """partsmith — minimal headless parametric-CAD server.""" -__version__ = "0.3.5" +__version__ = "0.3.6" diff --git a/src/mcp_transport.py b/src/mcp_transport.py index a01b095..c176dc6 100644 --- a/src/mcp_transport.py +++ b/src/mcp_transport.py @@ -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 @@ -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 @@ -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 @@ -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 *``, @@ -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: @@ -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. @@ -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" @@ -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 diff --git a/tests/integration/test_mcp_tools.py b/tests/integration/test_mcp_tools.py index 5b7e00f..81d95bb 100644 --- a/tests/integration/test_mcp_tools.py +++ b/tests/integration/test_mcp_tools.py @@ -2,13 +2,9 @@ # SPDX-License-Identifier: MIT """Integration: full MCP tool-call round-trip. -Initialize -> partsmith_create_model (cube) -> verify geometry + -preview -> partsmith_export (STL) -> verify decodable STL bytes. - -This is the v0.3.2 completion of issue #5: where test_mcp_handshake -proves the transport + tool inventory, this proves the tools actually -*execute* end-to-end against a real build123d + trimesh stack inside -the container. +Initialize -> partsmith_create_model (cube) -> verify geometry -> +partsmith_export (STL) -> verify decodable STL bytes. Plus preview +opt-in (v0.3.6) and export integrity metadata (v0.3.5). """ import base64 @@ -52,19 +48,16 @@ def _initialize(partsmith_url): def _tool_result_dict(rpc_response_body): """Extract the tool's dict result from a tools/call JSON-RPC response. - FastMCP can surface a tool's dict return as ``structuredContent`` and/or - a JSON string in ``content[0].text``. Parse defensively so the test - isn't coupled to one wrapping. + FastMCP can surface a tool's dict return as structuredContent and/or + a JSON string in content[0].text. Parse defensively so the test isn't + coupled to one wrapping. """ result = rpc_response_body.get("result", {}) - # Preferred: structuredContent (FastMCP puts dict returns here) if isinstance(result.get("structuredContent"), dict): sc = result["structuredContent"] - # Some FastMCP versions wrap the dict under a "result" key if set(sc.keys()) == {"result"} and isinstance(sc["result"], dict): return sc["result"] return sc - # Fallback: content[0].text as JSON content = result.get("content", []) if content and isinstance(content, list): first = content[0] @@ -80,11 +73,14 @@ def _tool_result_dict(rpc_response_body): def test_create_model_then_export_roundtrip(partsmith_url): - """create_model(cube) -> geometry + preview; export(stl) -> valid STL bytes.""" + """create_model(cube) -> geometry; export(stl) -> valid STL bytes. + + As of v0.3.6 create_model does NOT embed a preview by default (see + test_create_model_preview_optional for the opt-in path). + """ init = _initialize(partsmith_url) assert init.status_code == 200, f"initialize failed: {init.status_code}" - # 1. Create a 20mm cube create = _rpc( partsmith_url, "tools/call", @@ -106,7 +102,6 @@ def test_create_model_then_export_roundtrip(partsmith_url): ) geom = create_result.get("geometry") assert geom is not None, "create_model returned no geometry" - # 20mm cube => 8000 mm^3 assert abs(geom["volume_mm3"] - 8000.0) < 1.0, ( f"Expected ~8000 mm^3 for a 20mm cube, got {geom.get('volume_mm3')!r}" ) @@ -114,13 +109,10 @@ def test_create_model_then_export_roundtrip(partsmith_url): assert bbox["size"] == [20.0, 20.0, 20.0], ( f"Expected 20x20x20 bbox, got {bbox.get('size')!r}" ) - # Preview PNG should be present + look like a PNG - preview_b64 = create_result.get("preview_data_b64") - assert preview_b64, "create_model returned no preview_data_b64" - preview_bytes = base64.b64decode(preview_b64) - assert preview_bytes[:8] == b"\x89PNG\r\n\x1a\n", "preview is not a PNG" + assert "preview_data_b64" not in create_result, ( + "default create_model should not embed a preview" + ) - # 2. Export to STL export = _rpc( partsmith_url, "tools/call", @@ -139,8 +131,6 @@ def test_create_model_then_export_roundtrip(partsmith_url): ) stl_bytes = base64.b64decode(export_result["data_b64"]) assert len(stl_bytes) > 0, "exported STL is empty" - # Binary STL: 80-byte header + 4-byte triangle count, then 50 bytes/tri. - # A box is 12 triangles. ASCII STL starts with b"solid". Accept either. is_binary_stl = len(stl_bytes) >= 84 is_ascii_stl = stl_bytes[:5].lower() == b"solid" assert is_binary_stl or is_ascii_stl, ( @@ -149,13 +139,68 @@ def test_create_model_then_export_roundtrip(partsmith_url): ) +def test_create_model_preview_optional(partsmith_url): + """v0.3.6 (#20): preview is opt-in. Default omits it; include_preview + yields a capped, verifiable inline PNG. + """ + init = _initialize(partsmith_url) + assert init.status_code == 200 + + default = _rpc( + partsmith_url, + "tools/call", + { + "name": "partsmith_create_model", + "arguments": { + "code": "from build123d import *\nresult = Box(15, 15, 15)", + "name": "ci-preview-default", + }, + }, + req_id=2, + ) + assert default.status_code == 200 + default_result = _tool_result_dict(default.json()) + assert default_result.get("success") is True + assert "preview_data_b64" not in default_result, ( + f"default create should carry no preview: keys={list(default_result)}" + ) + + withp = _rpc( + partsmith_url, + "tools/call", + { + "name": "partsmith_create_model", + "arguments": { + "code": "from build123d import *\nresult = Box(15, 15, 15)", + "name": "ci-preview-on", + "include_preview": True, + }, + }, + req_id=3, + ) + assert withp.status_code == 200, ( + f"create w/ preview returned {withp.status_code}: {withp.text[:300]}" + ) + r = _tool_result_dict(withp.json()) + assert r.get("success") is True + if "preview_data_b64" in r: + png = base64.b64decode(r["preview_data_b64"]) + assert png[:8] == b"\x89PNG\r\n\x1a\n", "preview is not a PNG" + assert r["preview_size_bytes"] == len(png), ( + "preview_size_bytes mismatch vs decoded preview length" + ) + assert hashlib.sha256(png).hexdigest() == r["preview_sha256"], ( + "preview_sha256 does not match decoded preview bytes" + ) + else: + assert "preview_note" in r, ( + f"preview omitted but no preview_note explaining why: {list(r)}" + ) + + def test_export_integrity_metadata(partsmith_url): """v0.3.5 (#18): export carries sha256 + size_bytes that match the decoded bytes, plus a fetchable url_path for stl/step/3mf. - - This is the regression guard for the silent-truncation class of bug: - a client that writes the bytes can now compare against these to catch - a partial/corrupt write deterministically. """ init = _initialize(partsmith_url) assert init.status_code == 200 @@ -187,29 +232,25 @@ def test_export_integrity_metadata(partsmith_url): ) r = _tool_result_dict(export.json()) - # Metadata present assert "sha256" in r, f"export missing sha256: {r!r}" assert "size_bytes" in r, f"export missing size_bytes: {r!r}" assert r.get("inline") is True, f"expected tiny STL inline: {r!r}" - # url_path advertised for an stl export (workspace-servable) assert r.get("url_path") == "/workspace/ci-integrity-box.stl", ( f"expected fetchable url_path for stl, got {r.get('url_path')!r}" ) - # The integrity fields must actually match the delivered bytes — - # this is exactly the check a client performs to detect truncation. data = base64.b64decode(r["data_b64"]) assert len(data) == r["size_bytes"], ( f"size_bytes {r['size_bytes']} != decoded len {len(data)}" ) assert hashlib.sha256(data).hexdigest() == r["sha256"], ( - "sha256 does not match decoded bytes — integrity contract broken" + "sha256 does not match decoded bytes: integrity contract broken" ) def test_render_section_via_mcp(partsmith_url): - """create_model -> render_section returns an inline PNG (v0.2.6 tool live).""" + """create_model -> render_section returns an inline PNG (v0.2.6 tool).""" init = _initialize(partsmith_url) assert init.status_code == 200 @@ -242,14 +283,13 @@ def test_render_section_via_mcp(partsmith_url): assert result.get("inline") is True, f"Expected inline PNG, got {result!r}" png = base64.b64decode(result["data_b64"]) assert png[:8] == b"\x89PNG\r\n\x1a\n", "section render is not a PNG" - # v0.3.5: PNG renders are not workspace-servable -> no url_path assert "url_path" not in result, ( f"render PNG should not advertise url_path: {result!r}" ) def test_render_drawing_via_mcp(partsmith_url): - """create_model -> render_drawing returns an inline PNG (v0.3.4 tool live).""" + """create_model -> render_drawing returns an inline PNG (v0.3.4 tool).""" init = _initialize(partsmith_url) assert init.status_code == 200