Skip to content

Commit 1e8332c

Browse files
authored
Merge pull request #15 from JLay2026/feat/integration-tests-v032
test(v0.3.2): complete integration suite — tool round-trip + Caddy compat (closes #5)
2 parents dca0506 + 5a2fb42 commit 1e8332c

5 files changed

Lines changed: 334 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,70 @@ All notable changes to [JLay2026/partsmith](https://github.com/JLay2026/partsmit
44
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/);
55
this project follows semver-ish conventions (see [`ROADMAP.md`](ROADMAP.md)).
66

7+
## [0.3.2] — 2026-06-11
8+
9+
### Added
10+
- **`tests/integration/test_mcp_tools.py`** — full MCP tool-call
11+
round-trip (the deferred half of #5):
12+
- `test_create_model_then_export_roundtrip` — initialize → call
13+
`partsmith_create_model` with a 20 mm cube → assert
14+
`success` + geometry (8000 mm³, 20×20×20 bbox) + a real PNG in
15+
`preview_data_b64` → call `partsmith_export` (STL) → assert the
16+
returned base64 decodes to valid STL bytes.
17+
- `test_render_section_via_mcp` — proves the v0.2.6 cross-section
18+
tool executes against the real trimesh stack in the container and
19+
returns an inline PNG.
20+
- `_tool_result_dict()` helper parses the FastMCP tools/call response
21+
defensively (`structuredContent` or `content[0].text` JSON) so the
22+
tests aren't coupled to one FastMCP wrapping.
23+
- **`tests/integration/test_caddy_compat.py`** — reverse-proxy header
24+
compatibility (the other deferred half of #5):
25+
- `test_forwarded_headers_accepted``/health` with
26+
`X-Forwarded-Proto/-For/-Host` returns 200 (proves
27+
`--forwarded-allow-ips` parses rather than rejects the headers).
28+
- `test_redirect_preserves_https_scheme` — GET `/mcp` (no trailing
29+
slash) under `X-Forwarded-Proto: https` must redirect to an
30+
`https://` Location, directly guarding the v0.2.1 scheme-downgrade
31+
regression. Skips (rather than false-fails) if the build doesn't
32+
redirect that path or emits a relative Location — the regression
33+
only manifests as an absolute `http://` Location.
34+
35+
### Completes
36+
Issue [#5](https://github.com/JLay2026/partsmith/issues/5) is now fully
37+
delivered: all four originally-scoped integration test files exist
38+
(`test_health`, `test_mcp_handshake` in v0.3.1; `test_mcp_tools`,
39+
`test_caddy_compat` here). The integration workflow now exercises the
40+
transport, the tool inventory, real tool execution, and proxy-header
41+
handling on every PR.
42+
43+
### Design notes
44+
- **Defensive result parsing.** FastMCP's exact tools/call response
45+
shape (structuredContent vs. content[0].text) varies by version;
46+
`_tool_result_dict()` handles both so a FastMCP bump doesn't
47+
spuriously break the round-trip test.
48+
- **Conditional skip on the scheme test.** The slash-redirect behavior
49+
is server/version-dependent; the test guards the regression when the
50+
redirect exists and skips cleanly otherwise rather than asserting on
51+
behavior that may not be present. The baseline
52+
`test_forwarded_headers_accepted` always runs.
53+
- **Still no new runtime deps.** Tests use `requests` (already a dev
54+
dep since v0.3.1). The integration container has the full stack;
55+
these tests just drive it over HTTP.
56+
57+
### Why
58+
v0.3.1 shipped the CI framework + transport/inventory tests but
59+
deferred the two heavier tests to keep that ship tight. With the
60+
framework proven green across the v0.3.1 merge, completing the suite
61+
now means real tool execution + proxy-header handling are both under
62+
regression guard before Theme 2 (output fidelity) work begins.
63+
64+
Per ROADMAP Theme 4 ("Validated quality").
65+
66+
### Commit
67+
See [`HEAD`](https://github.com/JLay2026/partsmith/commits/main).
68+
69+
---
70+
771
## [0.3.1] — 2026-06-10
872

973
### Added

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "partsmith"
3-
version = "0.3.1"
3+
version = "0.3.2"
44
description = "Minimal headless parametric-CAD server (build123d + REST + MCP)"
55
license = { text = "MIT" }
66
authors = [
@@ -34,7 +34,7 @@ dependencies = [
3434
dev = [
3535
"pytest>=7.0.0",
3636
"ruff>=0.1.0",
37-
"requests>=2.28.0", # v0.3.1: integration tests HTTP client
37+
"requests>=2.28.0", # integration tests HTTP client (v0.3.1+)
3838
]
3939

4040
[project.urls]

src/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
# SPDX-License-Identifier: MIT
33
"""partsmith — minimal headless parametric-CAD server."""
44

5-
__version__ = "0.3.1"
5+
__version__ = "0.3.2"
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# SPDX-FileCopyrightText: 2026 JLay2026
2+
# SPDX-License-Identifier: MIT
3+
"""Integration: reverse-proxy header compatibility.
4+
5+
Catches the v0.2.1 scheme-downgrade regression. The container's
6+
entrypoint runs uvicorn with ``--proxy-headers --forwarded-allow-ips
7+
"*"`` so that X-Forwarded-Proto / X-Forwarded-For from a fronting
8+
reverse proxy (Caddy) are honored. Without those flags, uvicorn uses
9+
the raw socket scheme (http) and redirect Location headers come back
10+
http:// even when the client arrived over https -- breaking clients
11+
that don't follow scheme-downgrade redirects.
12+
13+
In CI there is no real proxy; we inject the forwarded headers directly.
14+
uvicorn with --forwarded-allow-ips "*" trusts them from any source
15+
(including 127.0.0.1), so the behavior is reproducible.
16+
"""
17+
18+
import pytest
19+
import requests
20+
21+
_FWD_HEADERS = {
22+
"X-Forwarded-Proto": "https",
23+
"X-Forwarded-For": "203.0.113.7", # TEST-NET-3, obviously-external
24+
"X-Forwarded-Host": "cad.example.test",
25+
}
26+
27+
28+
def test_forwarded_headers_accepted(partsmith_url):
29+
"""Server processes a request carrying X-Forwarded-* without error.
30+
31+
Proves --forwarded-allow-ips is parsing (not rejecting) the headers.
32+
A regression that removed --proxy-headers would still pass THIS (it
33+
just ignores the headers), so the scheme assertion below is the real
34+
guard; this is the can't-hurt baseline.
35+
"""
36+
r = requests.get(
37+
f"{partsmith_url}/health",
38+
headers=_FWD_HEADERS,
39+
timeout=10,
40+
)
41+
assert r.status_code == 200, (
42+
f"/health with forwarded headers returned {r.status_code}: "
43+
f"{r.text[:200]}"
44+
)
45+
assert r.json().get("status") == "ok"
46+
47+
48+
def test_redirect_preserves_https_scheme(partsmith_url):
49+
"""A slash-redirect under X-Forwarded-Proto: https must redirect to https.
50+
51+
GET /mcp (no trailing slash) is expected to 307/308 redirect to
52+
/mcp/. With proxy-header handling active, the Location must carry
53+
the forwarded scheme (https), not the raw socket scheme (http).
54+
This is the direct guard for the v0.2.1 scheme-downgrade bug.
55+
56+
If the deploy doesn't redirect that path, or emits a relative
57+
Location (no scheme to downgrade), the test skips rather than
58+
false-failing -- the regression only manifests as an absolute
59+
http:// Location.
60+
"""
61+
r = requests.get(
62+
f"{partsmith_url}/mcp",
63+
headers=_FWD_HEADERS,
64+
allow_redirects=False,
65+
timeout=10,
66+
)
67+
if r.status_code not in (301, 302, 307, 308):
68+
pytest.skip(
69+
f"GET /mcp did not redirect (status {r.status_code}); "
70+
f"no Location scheme to verify on this build."
71+
)
72+
location = r.headers.get("Location", "")
73+
if not location.startswith(("http://", "https://")):
74+
pytest.skip(
75+
f"Redirect Location is relative ({location!r}); no scheme "
76+
f"to verify."
77+
)
78+
assert location.startswith("https://"), (
79+
f"Redirect under X-Forwarded-Proto: https produced a non-https "
80+
f"Location: {location!r}. This is the v0.2.1 scheme-downgrade "
81+
f"regression -- check uvicorn --proxy-headers "
82+
f"--forwarded-allow-ips in entrypoint.sh."
83+
)
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
# SPDX-FileCopyrightText: 2026 JLay2026
2+
# SPDX-License-Identifier: MIT
3+
"""Integration: full MCP tool-call round-trip.
4+
5+
Initialize -> partsmith_create_model (cube) -> verify geometry +
6+
preview -> partsmith_export (STL) -> verify decodable STL bytes.
7+
8+
This is the v0.3.2 completion of issue #5: where test_mcp_handshake
9+
proves the transport + tool inventory, this proves the tools actually
10+
*execute* end-to-end against a real build123d + trimesh stack inside
11+
the container.
12+
"""
13+
14+
import base64
15+
import json
16+
17+
import requests
18+
19+
_MCP_HEADERS = {
20+
"Content-Type": "application/json",
21+
"Accept": "application/json, text/event-stream",
22+
}
23+
24+
25+
def _rpc(partsmith_url, method, params=None, req_id=1):
26+
"""Send a JSON-RPC request to the MCP endpoint, return the Response."""
27+
payload = {"jsonrpc": "2.0", "id": req_id, "method": method}
28+
if params is not None:
29+
payload["params"] = params
30+
return requests.post(
31+
f"{partsmith_url}/mcp/",
32+
headers=_MCP_HEADERS,
33+
json=payload,
34+
timeout=30, # build123d execution can take a few seconds
35+
)
36+
37+
38+
def _initialize(partsmith_url):
39+
return _rpc(
40+
partsmith_url,
41+
"initialize",
42+
{
43+
"protocolVersion": "2024-11-05",
44+
"capabilities": {},
45+
"clientInfo": {"name": "ci-tools", "version": "0"},
46+
},
47+
req_id=1,
48+
)
49+
50+
51+
def _tool_result_dict(rpc_response_body):
52+
"""Extract the tool's dict result from a tools/call JSON-RPC response.
53+
54+
FastMCP can surface a tool's dict return as ``structuredContent`` and/or
55+
a JSON string in ``content[0].text``. Parse defensively so the test
56+
isn't coupled to one wrapping.
57+
"""
58+
result = rpc_response_body.get("result", {})
59+
# Preferred: structuredContent (FastMCP puts dict returns here)
60+
if isinstance(result.get("structuredContent"), dict):
61+
sc = result["structuredContent"]
62+
# Some FastMCP versions wrap the dict under a "result" key
63+
if set(sc.keys()) == {"result"} and isinstance(sc["result"], dict):
64+
return sc["result"]
65+
return sc
66+
# Fallback: content[0].text as JSON
67+
content = result.get("content", [])
68+
if content and isinstance(content, list):
69+
first = content[0]
70+
text = first.get("text") if isinstance(first, dict) else None
71+
if text:
72+
try:
73+
return json.loads(text)
74+
except (json.JSONDecodeError, TypeError):
75+
pass
76+
raise AssertionError(
77+
f"Could not extract tool result dict from response: {rpc_response_body!r}"
78+
)
79+
80+
81+
def test_create_model_then_export_roundtrip(partsmith_url):
82+
"""create_model(cube) -> geometry + preview; export(stl) -> valid STL bytes."""
83+
init = _initialize(partsmith_url)
84+
assert init.status_code == 200, f"initialize failed: {init.status_code}"
85+
86+
# 1. Create a 20mm cube
87+
create = _rpc(
88+
partsmith_url,
89+
"tools/call",
90+
{
91+
"name": "partsmith_create_model",
92+
"arguments": {
93+
"code": "from build123d import *\nresult = Box(20, 20, 20)",
94+
"name": "ci-roundtrip-cube",
95+
},
96+
},
97+
req_id=2,
98+
)
99+
assert create.status_code == 200, (
100+
f"create_model call returned {create.status_code}: {create.text[:300]}"
101+
)
102+
create_result = _tool_result_dict(create.json())
103+
assert create_result.get("success") is True, (
104+
f"create_model did not succeed: {create_result!r}"
105+
)
106+
geom = create_result.get("geometry")
107+
assert geom is not None, "create_model returned no geometry"
108+
# 20mm cube => 8000 mm^3
109+
assert abs(geom["volume_mm3"] - 8000.0) < 1.0, (
110+
f"Expected ~8000 mm^3 for a 20mm cube, got {geom.get('volume_mm3')!r}"
111+
)
112+
bbox = geom["bounding_box"]
113+
assert bbox["size"] == [20.0, 20.0, 20.0], (
114+
f"Expected 20x20x20 bbox, got {bbox.get('size')!r}"
115+
)
116+
# Preview PNG should be present + look like a PNG
117+
preview_b64 = create_result.get("preview_data_b64")
118+
assert preview_b64, "create_model returned no preview_data_b64"
119+
preview_bytes = base64.b64decode(preview_b64)
120+
assert preview_bytes[:8] == b"\x89PNG\r\n\x1a\n", "preview is not a PNG"
121+
122+
# 2. Export to STL
123+
export = _rpc(
124+
partsmith_url,
125+
"tools/call",
126+
{
127+
"name": "partsmith_export",
128+
"arguments": {"name": "ci-roundtrip-cube", "format": "stl"},
129+
},
130+
req_id=3,
131+
)
132+
assert export.status_code == 200, (
133+
f"export call returned {export.status_code}: {export.text[:300]}"
134+
)
135+
export_result = _tool_result_dict(export.json())
136+
assert export_result.get("inline") is True, (
137+
f"Expected inline STL for a tiny cube, got: {export_result!r}"
138+
)
139+
stl_bytes = base64.b64decode(export_result["data_b64"])
140+
assert len(stl_bytes) > 0, "exported STL is empty"
141+
# Binary STL: 80-byte header + 4-byte triangle count, then 50 bytes/tri.
142+
# A box is 12 triangles. ASCII STL starts with b"solid". Accept either.
143+
is_binary_stl = len(stl_bytes) >= 84
144+
is_ascii_stl = stl_bytes[:5].lower() == b"solid"
145+
assert is_binary_stl or is_ascii_stl, (
146+
f"exported bytes don't look like STL (len={len(stl_bytes)}, "
147+
f"head={stl_bytes[:16]!r})"
148+
)
149+
150+
151+
def test_render_section_via_mcp(partsmith_url):
152+
"""create_model -> render_section returns an inline PNG (v0.2.6 tool live)."""
153+
init = _initialize(partsmith_url)
154+
assert init.status_code == 200
155+
156+
_rpc(
157+
partsmith_url,
158+
"tools/call",
159+
{
160+
"name": "partsmith_create_model",
161+
"arguments": {
162+
"code": "from build123d import *\nresult = Box(30, 30, 30)",
163+
"name": "ci-section-cube",
164+
},
165+
},
166+
req_id=2,
167+
)
168+
169+
section = _rpc(
170+
partsmith_url,
171+
"tools/call",
172+
{
173+
"name": "partsmith_render_section",
174+
"arguments": {"name": "ci-section-cube", "plane": "YZ", "at": 0.0},
175+
},
176+
req_id=3,
177+
)
178+
assert section.status_code == 200, (
179+
f"render_section call returned {section.status_code}: {section.text[:300]}"
180+
)
181+
result = _tool_result_dict(section.json())
182+
assert result.get("inline") is True, f"Expected inline PNG, got {result!r}"
183+
png = base64.b64decode(result["data_b64"])
184+
assert png[:8] == b"\x89PNG\r\n\x1a\n", "section render is not a PNG"

0 commit comments

Comments
 (0)